diff --git a/.env.example b/.env.example index 8705f05..a9fa015 100644 --- a/.env.example +++ b/.env.example @@ -2,21 +2,11 @@ VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 VITE_APPWRITE_PROJECT_ID=6a0752ee001fb2ef7138 VITE_APPWRITE_DATABASE_ID=redbull_tracker VITE_APPWRITE_COLLECTION_ID=intake_entries -VITE_APPWRITE_CHAT_COLLECTION_ID=coach_chats +VITE_APPWRITE_BARCODE_COLLECTION_ID=barcode_products -# Optional. Leave blank in local dev so the app uses the current Vite origin, -# including fallback ports like http://127.0.0.1:5174. +# Optional. Leave blank in local dev so the app uses the current Vite origin. VITE_APPWRITE_OAUTH_SUCCESS_URL= VITE_APPWRITE_OAUTH_FAILURE_URL= -# Server-only. Do not prefix with VITE_ or it will be exposed to the browser. -OLLAMA_API_KEY= -OLLAMA_MODEL=deepseek-v4-pro:cloud -VITE_OLLAMA_PROXY_URL=/api/ollama-chat - # Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite. APPWRITE_API_KEY= - -# Appwrite chat table columns needed for encrypted coach chats: -# userId, encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, updatedAt as strings -# version as integer. Enable row security and Users -> Create at table level. diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index 3ed9557..0fe41f1 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -1,222 +1,76 @@ -# Red Bull Intake Tracker Setup +# Red Bull tracker setup -## Commands +This app uses Appwrite for auth and intake entries. -```bash -npm install -npm run dev -npm run build -npm run lint +## env + +Copy `.env.example` to `.env.local`, then fill in: + +```sh +VITE_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 +VITE_APPWRITE_PROJECT_ID=your_project_id +VITE_APPWRITE_DATABASE_ID=redbull_tracker +VITE_APPWRITE_COLLECTION_ID=intake_entries +APPWRITE_API_KEY=server_key_for_setup_only ``` -The Vite dev app runs at `http://localhost:5173` unless that port is already taken. +Leave the OAuth URLs empty in local dev unless you need fixed callback URLs. -## Environment +## setup -Copy `.env.example` to `.env.local` and adjust IDs if you choose different Appwrite resource IDs: +Run: -```bash -cp .env.example .env.local -``` - -This app uses only the Appwrite browser SDK. Do not add an API key to the frontend. - -To create/update the database tables from this repo, set a server/admin key as `APPWRITE_API_KEY` in `.env.local` and run: - -```bash +```sh npm run setup:appwrite ``` -The setup script reads `APPWRITE_API_KEY` only from Node, never from browser code. +The script creates or updates: -Configured defaults: +- database: `redbull_tracker` +- table: `intake_entries` +- table permission: `Users -> Create` +- row security: enabled -- Endpoint: `https://fra.cloud.appwrite.io/v1` -- Project ID: `6a0752ee001fb2ef7138` -- Project name: `Red Bull Tracker App` -- Database ID: `redbull_tracker` -- Collection ID: `intake_entries` -- Chat collection ID: `coach_chats` +Rows use per-user read, update, and delete permissions. -`client.ping()` is called automatically during app boot in `src/App.tsx` through `pingAppwrite()` from `src/lib/appwrite.ts`. +## intake columns -## Auth +| key | type | required | +| --- | --- | --- | +| `userId` | String, 64 | Yes | +| `cans` | Float | Yes | +| `flavour` | String, 128 | Yes | +| `flavourAccent` | String, 32 | Yes | +| `sizeMl` | Integer | Yes | +| `pricePerCan` | Float | Yes | +| `dateTime` | DateTime | Yes | +| `notes` | String, 2000 | No | +| `store` | String, 256 | No | +| `sugarFree` | Boolean | Yes | +| `caffeineMgPerCan` | Float | No | +| `importKey` | String, 512 | Yes | +| `source` | String, 32 | Yes | -Enable these auth methods in Appwrite Console: +## indexes -- Email/password -- GitHub OAuth -- Google OAuth +- `user_date_desc`: `userId`, `dateTime` +- `user_import_key`: `userId`, `importKey` -Add a Web platform in Appwrite Console for local development: +## run -- Hostname: `localhost` -- Hostname: `127.0.0.1` - -If `client.ping()` shows `Failed to fetch`, this is usually the first thing to check. - -For local OAuth callback URLs, add: - -- Success URL: `http://localhost:5173` -- Failure URL: `http://localhost:5173` -- If Vite starts on another port, add that origin too, for example `http://127.0.0.1:5174` - -For production, add your deployed origin as both success and failure URL, then update the `VITE_APPWRITE_OAUTH_*` variables. - -In local dev, you can leave `VITE_APPWRITE_OAUTH_SUCCESS_URL` and `VITE_APPWRITE_OAUTH_FAILURE_URL` blank. The app will use the current browser origin automatically, which avoids getting redirected to a stale Vite port. - -If OAuth returns to the app but you are still logged out: - -- Confirm the current browser origin is listed under Appwrite project platforms, for example `localhost` and `127.0.0.1`. -- Confirm the same origin is allowed in the OAuth provider success/failure URLs. -- Clear old sessions/cookies for the local app and try again. -- Restart Vite after editing `.env.local`. - -## Database - -Appwrite currently uses newer Console wording in many places: - -| In this app / older SDK wording | Current Appwrite Console wording | -| --- | --- | -| Collection | Table | -| Attribute | Column | -| Document | Row | - -So if the Console asks you to create a **table**, that is the same resource as the `VITE_APPWRITE_COLLECTION_ID` this app currently points at. If the setup below says **attributes**, add them as **columns** inside that table. - -The app uses Appwrite's current `TablesDB` SDK methods (`listRows`, `createRow`, `updateRow`, `deleteRow`). The env var remains named `VITE_APPWRITE_COLLECTION_ID` for compatibility with the first setup pass, but its value should be your table ID. - -Create a database with ID: - -```text -redbull_tracker +```sh +npm install +npm run dev ``` -Create a collection with ID: +## deployment-only files -```text -intake_entries -``` +The repo ignores `.deploy/` and local public HTML pages. -Enable document-level permissions on the collection. +For your own deployment, create: -Recommended collection-level permissions: +- `.deploy/head.html` for analytics or other head-only snippets +- `.deploy/body-end.html` for footer links or deploy-only markup +- any local public HTML pages your host needs -- Create: `users` -- Read: none -- Update: none -- Delete: none - -The app writes per-document permissions for the current user: - -- `read("user:{userId}")` -- `update("user:{userId}")` -- `delete("user:{userId}")` - -## Permission Troubleshooting - -If the app shows: - -```text -No permissions provided for action 'create' -``` - -the table is reachable, but the signed-in user is not allowed to create rows yet. - -Fix it in Appwrite Console: - -1. Open **Databases**. -2. Open database `redbull_tracker`. -3. Open table `intake_entries`. -4. Go to **Settings**. -5. Enable **Row Security**. -6. Under **Permissions**, add role **Users**. -7. Check **Create** only. -8. Leave table-level **Read**, **Update**, and **Delete** unchecked. -9. Click **Update** / **Save**. - -Why: table-level **Create** lets authenticated users add their own rows. The app then writes row-level read/update/delete permissions for that exact user, so users do not see each other's entries. - -## Attributes - -Create these attributes: - -| Key | Type | Required | Notes | -| --- | --- | --- | --- | -| `userId` | String, 64 | Yes | Current Appwrite user ID | -| `cans` | Float | Yes | Allows partial cans | -| `flavour` | String, 128 | Yes | Red Bull flavour | -| `flavourAccent` | String, 32 | Yes | UI colour | -| `sizeMl` | Integer | Yes | Can size in ml | -| `pricePerCan` | Float | Yes | GBP price per can | -| `dateTime` | DateTime | Yes | Intake timestamp | -| `notes` | String, 2000 | No | Optional notes | -| `store` | String, 256 | No | Store/location | -| `sugarFree` | Boolean | Yes | Sugar-free flag | -| `caffeineMgPerCan` | Float | No | Custom-size override | -| `importKey` | String, 512 | Yes | Duplicate detection signature | -| `source` | String, 32 | Yes | `manual`, `quick-add`, `excel`, or `json` | - -Recommended indexes: - -- `user_date_desc`: key index on `userId`, `dateTime` -- `user_import_key`: key index on `userId`, `importKey` -- Optional unique index on `userId`, `importKey` if your Appwrite plan/schema supports it - -## Encrypted Coach Chats - -Create a second table with ID: - -```text -coach_chats -``` - -Enable row security on `coach_chats`. - -Recommended table-level permissions: - -- Create: `users` -- Read: none -- Update: none -- Delete: none - -The app encrypts chat titles and messages in the browser before writing rows. The encryption passphrase is not stored, and Appwrite only receives ciphertext. - -Create these chat columns: - -| Key | Type | Required | Notes | -| --- | --- | --- | --- | -| `userId` | String, 64 | Yes | Current Appwrite user ID | -| `encryptedTitle` | String, 4000 | Yes | AES-GCM ciphertext | -| `encryptedMessages` | String, 50000+ | Yes | AES-GCM ciphertext for message JSON | -| `titleIv` | String, 128 | Yes | Base64 IV | -| `messagesIv` | String, 128 | Yes | Base64 IV | -| `salt` | String, 128 | Yes | Base64 PBKDF2 salt | -| `version` | Integer | Yes | Crypto version | -| `updatedAt` | DateTime | Yes | Sort key | - -Recommended chat index: - -- `user_chat_updated`: key index on `userId`, `updatedAt` - -## Component Structure - -- `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state. -- `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper. -- `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures. -- `src/lib/encryptedChats.ts`: Client-side encrypted chat storage for Appwrite. -- `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview. -- `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks. -- `src/lib/storage.ts`: JSON backup export/import parser. -- `src/data/flavours.ts`: Built-in flavours and accent metadata. - -## Nutrition Defaults - -- 250ml: `£1.75`, `80mg` caffeine -- 355ml: `£2.20`, `114mg` caffeine -- 473ml: `£2.85`, `151mg` caffeine -- Custom sizes: caffeine is proportional from 250ml unless a custom override is entered - -The UI shows this disclaimer: - -> Caffeine and sugar values are estimates. Check the can label for exact nutritional information. +Vite injects the optional `.deploy` snippets into `index.html` at build time. diff --git a/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs index cc11891..3077f3a 100644 --- a/scripts/setup-appwrite.mjs +++ b/scripts/setup-appwrite.mjs @@ -1,6 +1,7 @@ /* global console, fetch, process, setTimeout */ import { existsSync, readFileSync } from "node:fs"; +import { URL } from "node:url"; const env = loadEnvFiles([".env", ".env.local"]); @@ -8,8 +9,11 @@ const endpoint = readEnv("VITE_APPWRITE_ENDPOINT", "https://fra.cloud.appwrite.i 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 barcodeTableId = readEnv("VITE_APPWRITE_BARCODE_COLLECTION_ID", "barcode_products"); const apiKey = readEnv("APPWRITE_API_KEY", ""); +const verifiedBarcodeProducts = JSON.parse( + readFileSync(new URL("../src/data/verified-barcodes.json", import.meta.url), "utf8"), +); if (!apiKey) { throw new Error("APPWRITE_API_KEY missing. Add a server/admin Appwrite key to .env.local, without VITE_."); @@ -40,20 +44,30 @@ await ensureTable({ ], }); await ensureTable({ - tableId: chatTableId, - name: "Coach chats", + tableId: barcodeTableId, + name: "Barcode products", 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 }, + { kind: "string", key: "scope", size: 16, required: true }, + { kind: "string", key: "ownerUserId", size: 64, required: false }, + { kind: "string", key: "barcode", size: 32, required: true }, + { kind: "string", key: "flavourName", size: 128, required: true }, + { kind: "integer", key: "sizeMl", required: true }, + { kind: "float", key: "pricePerCan", required: true }, + { kind: "boolean", key: "sugarFree", required: true }, + { kind: "float", key: "caffeineMgPerCan", required: false }, + { kind: "string", key: "verifiedBy", size: 512, required: false }, + { kind: "string", key: "sourceName", size: 512, required: false }, + { kind: "string", key: "sourceUrl", size: 2048, required: false }, + { kind: "string", key: "variant", size: 64, required: false }, + { kind: "string", key: "notes", size: 2000, required: false }, + ], + indexes: [ + { key: "barcode", type: "key", columns: ["barcode"], orders: ["ASC"], lengths: [32] }, + { key: "scope_barcode", type: "key", columns: ["scope", "barcode"], orders: ["ASC", "ASC"], lengths: [16, 32] }, + { key: "user_barcode", type: "key", columns: ["ownerUserId", "barcode"], orders: ["ASC", "ASC"], lengths: [64, 32] }, ], - indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], }); +await seedVerifiedBarcodeProducts(barcodeTableId, verifiedBarcodeProducts); console.log("Appwrite database and tables ready."); @@ -116,12 +130,48 @@ async function ensureColumn(tableId, column) { 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 seedVerifiedBarcodeProducts(tableId, products) { + for (const [barcode, product] of Object.entries(products)) { + const rowId = `verified_${barcode}`; + const data = { + scope: "verified", + ownerUserId: "", + barcode, + flavourName: product.flavourName, + sizeMl: product.sizeMl, + pricePerCan: product.pricePerCan, + sugarFree: Boolean(product.sugarFree), + caffeineMgPerCan: product.caffeineMgPerCan, + verifiedBy: product.verifiedBy ?? "", + sourceName: product.sourceName ?? "", + sourceUrl: product.sourceUrl ?? "", + variant: product.variant ?? "", + notes: product.notes ?? "", + }; + const path = `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`; + const existing = await request("GET", path, undefined, [200, 404]); + + if (existing.status === 404) { + await request( + "POST", + `/tablesdb/${databaseId}/tables/${tableId}/rows`, + { rowId, data, permissions: ['read("users")'] }, + [201], + ); + console.log(`Verified barcode ${barcode} seeded.`); + continue; + } + + await request("PUT", path, { data, permissions: ['read("users")'] }, [200]); + console.log(`Verified barcode ${barcode} updated.`); + } +} + async function ensureIndex(tableId, index) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); if (existing.status === 200) { diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts index 30cd0c0..d015ea9 100644 --- a/src/lib/appwrite.ts +++ b/src/lib/appwrite.ts @@ -8,7 +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", + barcodeCollectionId: env.VITE_APPWRITE_BARCODE_COLLECTION_ID || "barcode_products", oauthSuccessUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_SUCCESS_URL), oauthFailureUrl: resolveOAuthUrl(env.VITE_APPWRITE_OAUTH_FAILURE_URL), }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b6746b5..e5ecceb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,10 +5,9 @@ interface ImportMetaEnv { readonly VITE_APPWRITE_PROJECT_ID?: string; readonly VITE_APPWRITE_DATABASE_ID?: string; readonly VITE_APPWRITE_COLLECTION_ID?: string; - readonly VITE_APPWRITE_CHAT_COLLECTION_ID?: string; + readonly VITE_APPWRITE_BARCODE_COLLECTION_ID?: string; readonly VITE_APPWRITE_OAUTH_SUCCESS_URL?: string; readonly VITE_APPWRITE_OAUTH_FAILURE_URL?: string; - readonly VITE_OLLAMA_PROXY_URL?: string; } interface ImportMeta {