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
parent 94c906cc59
commit de6ce0c350
14 changed files with 2294 additions and 226 deletions
+205
View File
@@ -0,0 +1,205 @@
/* global console, fetch, process, setTimeout */
import { existsSync, readFileSync } from "node:fs";
const env = loadEnvFiles([".env", ".env.local"]);
const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.io/v1").replace(/\/$/, "");
const projectId = readEnv("VITE_APPWRITE_PROJECT_ID", "6a0752ee001fb2ef7138");
const databaseId = readEnv("VITE_APPWRITE_DATABASE_ID", "redbull_tracker");
const intakeTableId = readEnv("VITE_APPWRITE_COLLECTION_ID", "intake_entries");
const chatTableId = readEnv("VITE_APPWRITE_CHAT_COLLECTION_ID", "coach_chats");
const apiKey = readEnv("APPWRITE_API_KEY", "");
if (!apiKey) {
throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_.");
}
await ensureDatabase(databaseId, "Red Bull Tracker");
await ensureTable({
tableId: intakeTableId,
name: "Intake entries",
columns: [
{ kind: "string", key: "userId", size: 64, required: true },
{ kind: "float", key: "cans", required: true },
{ kind: "string", key: "flavour", size: 128, required: true },
{ kind: "string", key: "flavourAccent", size: 32, required: true },
{ kind: "integer", key: "sizeMl", required: true },
{ kind: "float", key: "pricePerCan", required: true },
{ kind: "datetime", key: "dateTime", required: true },
{ kind: "string", key: "notes", size: 2000, required: false },
{ kind: "string", key: "store", size: 256, required: false },
{ kind: "boolean", key: "sugarFree", required: true },
{ kind: "float", key: "caffeineMgPerCan", required: false },
{ kind: "string", key: "importKey", size: 512, required: true },
{ kind: "string", key: "source", size: 32, required: true },
],
indexes: [
{ key: "user_date_desc", type: "key", columns: ["userId", "dateTime"], orders: ["ASC", "DESC"], lengths: [32] },
{ key: "user_import_key", type: "key", columns: ["userId", "importKey"], orders: ["ASC", "ASC"], lengths: [32, 128] },
],
});
await ensureTable({
tableId: chatTableId,
name: "Coach chats",
columns: [
{ kind: "string", key: "userId", size: 64, required: true },
{ kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true },
{ kind: "longtext", key: "encryptedMessages", required: true, encrypt: true },
{ kind: "string", key: "titleIv", size: 128, required: true },
{ kind: "string", key: "messagesIv", size: 128, required: true },
{ kind: "string", key: "salt", size: 128, required: true },
{ kind: "integer", key: "version", required: true },
{ kind: "datetime", key: "updatedAt", required: true },
],
indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }],
});
console.log("Appwrite database and tables ready.");
async function ensureDatabase(id, name) {
const existing = await request("GET", `/tablesdb/${id}`, undefined, [200, 404]);
if (existing.status === 200) {
console.log(`Database ${id} exists.`);
return;
}
await request("POST", "/tablesdb", { databaseId: id, name, enabled: true }, [201]);
console.log(`Database ${id} created.`);
}
async function ensureTable({ tableId, name, columns, indexes }) {
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}`, undefined, [200, 404]);
if (existing.status === 404) {
await request(
"POST",
`/tablesdb/${databaseId}/tables`,
{
tableId,
name,
permissions: ['create("users")'],
rowSecurity: true,
enabled: true,
},
[201],
);
console.log(`Table ${tableId} created.`);
} else {
await request(
"PUT",
`/tablesdb/${databaseId}/tables/${tableId}`,
{ name, permissions: ['create("users")'], rowSecurity: true, enabled: true, purge: true },
[200],
);
console.log(`Table ${tableId} exists and permissions updated.`);
}
for (const column of columns) {
await ensureColumn(tableId, column);
}
await waitForColumns(tableId, columns.map((column) => column.key));
for (const index of indexes) {
await ensureIndex(tableId, index);
}
}
async function ensureColumn(tableId, column) {
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.key}`, undefined, [200, 404]);
if (existing.status === 200) {
console.log(`Column ${tableId}.${column.key} exists.`);
return;
}
const body = {
key: column.key,
required: column.required,
array: false,
};
if (column.size) body.size = column.size;
if (column.encrypt) body.encrypt = true;
await request("POST", `/tablesdb/${databaseId}/tables/${tableId}/columns/${column.kind}`, body, [202, 201]);
console.log(`Column ${tableId}.${column.key} created.`);
}
async function ensureIndex(tableId, index) {
const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]);
if (existing.status === 200) {
console.log(`Index ${tableId}.${index.key} exists.`);
return;
}
await request(
"POST",
`/tablesdb/${databaseId}/tables/${tableId}/indexes`,
{ key: index.key, type: index.type, columns: index.columns, orders: index.orders, lengths: index.lengths },
[202, 201],
);
console.log(`Index ${tableId}.${index.key} created.`);
}
async function waitForColumns(tableId, keys) {
const pending = new Set(keys);
for (let attempt = 0; attempt < 30 && pending.size; attempt += 1) {
for (const key of [...pending]) {
const response = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]);
if (response.status === 200 && ["available", "failed"].includes(response.body.status)) {
if (response.body.status === "failed") throw new Error(`Column ${tableId}.${key} failed: ${response.body.error || "unknown error"}`);
pending.delete(key);
}
}
if (pending.size) await delay(1_000);
}
if (pending.size) throw new Error(`Timed out waiting for columns: ${[...pending].join(", ")}`);
}
async function request(method, path, body, okStatuses) {
const response = await fetch(`${endpoint}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"X-Appwrite-Key": apiKey,
"X-Appwrite-Project": projectId,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const parsed = text ? parseJson(text) : null;
if (!okStatuses.includes(response.status)) {
const message = parsed?.message || text || `${method} ${path} failed with status ${response.status}`;
throw new Error(message);
}
return { status: response.status, body: parsed };
}
function parseJson(value) {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readEnv(name, fallback) {
return process.env[name] || env[name] || fallback;
}
function loadEnvFiles(paths) {
const values = {};
for (const path of paths) {
if (!existsSync(path)) continue;
const lines = readFileSync(path, "utf8").split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
values[match[1]] = match[2].trim().replace(/^(["'])(.*)\1$/, "$2");
}
}
return values;
}