From 4b7b7e3d5ca33132ab40531052cf8baa15d72f0e Mon Sep 17 00:00:00 2001 From: eddy Date: Mon, 20 Jul 2026 16:58:55 +0200 Subject: [PATCH] =?UTF-8?q?db:=20fix=20insertMeasurement/upsertMeasurement?= =?UTF-8?q?=20=E2=80=94=20remove=20syncStatus=20param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-20-rest-api-sync-plan.md | 1285 +++++++++++++++++ .../specs/2026-07-20-rest-api-sync-design.md | 266 ++++ src/database/db.ts | 12 +- upgrade.md | 140 ++ 4 files changed, 1697 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md create mode 100644 docs/superpowers/specs/2026-07-20-rest-api-sync-design.md create mode 100644 upgrade.md diff --git a/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md b/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md new file mode 100644 index 0000000..7e4460c --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-rest-api-sync-plan.md @@ -0,0 +1,1285 @@ +# REST API Sync — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl` met MariaDB en Bearer token auth. Incrementele delta-sync op basis van `updatedAt`/`deletedAt` timestamps — standaard sync patroon zonder syncStatus veld. + +**Architecture:** Server: PHP API (Slim 4) op nextcloud met nginx + MariaDB, soft deletes via `deleted` flag. Client: fetch() met Bearer token, delta-sync via `?since=lastSyncAt`, last-writer-wins merge, push van alle lokale records met `updatedAt > lastSyncAt`. `deletedAt` veld voor offline delete resilience. + +**Tech Stack:** PHP 8 + Slim 4 + PDO/MySQL, nginx, certbot, expo-sqlite, expo-secure-store, TypeScript, Jest + +--- + +## File Structure + +``` +nextcloud server: + /etc/nginx/conf.d/weegschaal-api.conf ← NEW + /usr/share/nginx/html/weegschaal-api/ ← NEW directory + composer.json + public/index.php + src/Database.php + src/Auth.php + src/Measurements.php + src/helpers.php + config.php + +app (weegschaal-app): + src/types.ts ← MODIFY (WebDAVConfig→ApiConfig, SyncStatus weg, +deletedAt) + src/sync/api.ts ← NEW + src/sync/syncService.ts ← REWRITE + src/screens/SettingsScreen.tsx ← MODIFY (WebDAV→API form) + src/screens/NewMeasurementScreen.tsx ← MODIFY (direct push) + src/screens/MeasurementDetailScreen.tsx ← MODIFY (soft delete via deletedAt) + src/screens/HistoryScreen.tsx ← MODIFY (filter deletedAt, soft delete) + src/screens/DashboardScreen.tsx ← MODIFY (filter deletedAt) + src/database/db.ts ← MODIFY (+deletedAt column, +setDeletedAt, +getUpdatedSince) + tests/syncService.test.ts ← REWRITE +``` + +--- + +### Task 1: SSH naar nextcloud — nginx + certbot + +**Files:** +- Create: `/etc/nginx/conf.d/weegschaal-api.conf` (remote) + +- [ ] **Step 1: SSH naar nextcloud, check PHP-FPM socket** + +```bash +ssh aiaiaiaiagent@nextcloud "ls /run/php-fpm/ && php -v && which composer" +``` + +Noteer de socket naam (bv. `www.sock`). Als composer ontbreekt: `sudo -A dnf install -y composer`. + +- [ ] **Step 2: Schrijf nginx config** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A tee /etc/nginx/conf.d/weegschaal-api.conf' <<'EOF' +server { + listen 80; + server_name weegschaal.eddydevink.nl; + root /usr/share/nginx/html/weegschaal-api/public; + + index index.php; + + location / { + try_files $uri /index.php$is_args$args; + } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm/www.sock; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } +} +EOF +``` + +Pas `fastcgi_pass` aan naar stap 1 resultaat indien nodig. + +- [ ] **Step 3: Maak document root, zet permissies** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A mkdir -p /usr/share/nginx/html/weegschaal-api/public /usr/share/nginx/html/weegschaal-api/src && sudo -A chown -R aiaiaiaiagent:aiaiaiaiagent /usr/share/nginx/html/weegschaal-api' +``` + +- [ ] **Step 4: SELinux context** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A restorecon -R /usr/share/nginx/html/weegschaal-api' +``` + +- [ ] **Step 5: Test nginx config en herlaad** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A nginx -t && sudo -A systemctl reload nginx' +``` + +- [ ] **Step 6: Certbot SSL** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A certbot --nginx -d weegschaal.eddydevink.nl' +``` + +--- + +### Task 2: MariaDB setup + +- [ ] **Step 1: Bepaal MySQL auth op nextcloud** + +```bash +ssh aiaiaiaiagent@nextcloud 'sudo -A mysql -e "SELECT 1"' +``` + +- [ ] **Step 2: Genereer credentials lokaal** + +```bash +DB_PASS=$(openssl rand -hex 16) && echo "DB_PASS=$DB_PASS" +APP_TOKEN=$(openssl rand -hex 32) && echo "APP_TOKEN=$APP_TOKEN" +``` + +Sla beide op in `/tmp/weegschaal-credentials.txt` (tijdelijk, wordt later in de app gezet): + +```bash +echo "URL=https://weegschaal.eddydevink.nl" > /tmp/weegschaal-credentials.txt +echo "TOKEN=$APP_TOKEN" >> /tmp/weegschaal-credentials.txt +``` + +- [ ] **Step 3: Database, gebruiker, tabellen** + +```bash +ssh aiaiaiaiagent@nextcloud "sudo -A mysql" < /usr/share/nginx/html/weegschaal-api/composer.json' <<'EOF' +{ + "name": "eddy/weegschaal-api", + "type": "project", + "require": { + "php": ">=8.0", + "slim/slim": "^4.0", + "slim/psr7": "^1.0" + }, + "autoload": { + "psr-4": { + "Weegschaal\\": "src/" + } + } +} +EOF +``` + +- [ ] **Step 2: composer install** + +```bash +ssh aiaiaiaiagent@nextcloud 'cd /usr/share/nginx/html/weegschaal-api && composer install' +``` + +- [ ] **Step 3: config.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/config.php' < [ + 'host' => 'localhost', + 'port' => 3306, + 'name' => 'weegschaal', + 'user' => 'weegschaal', + 'pass' => '$DB_PASS', + ], +]; +EOF +``` + +- [ ] **Step 4: src/Database.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Database.php' <<'EOF' + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return self::$instance; + } +} +EOF +``` + +- [ ] **Step 5: src/helpers.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/helpers.php' <<'EOF' +getBody()->write($body); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); +} + +function errorResponse(Response $response, string $message, int $status): Response +{ + return jsonResponse($response, ['error' => $message], $status); +} + +function rowToMeasurement(array $row): array +{ + return [ + 'id' => $row['id'], + 'weight' => (float)$row['weight'], + 'bmi' => (float)$row['bmi'], + 'bodyFatPercentage' => (float)$row['body_fat_percentage'], + 'fatMass' => (float)$row['fat_mass'], + 'musclePercentage' => (float)$row['muscle_percentage'], + 'muscleMass' => (float)$row['muscle_mass'], + 'visceralFat' => (int)$row['visceral_fat'], + 'bmr' => (float)$row['bmr'], + 'measuredAt' => $row['measured_at'], + 'createdAt' => $row['created_at'], + 'updatedAt' => $row['updated_at'], + 'deleted' => (bool)$row['deleted'], + ]; +} +EOF +``` + +--- + +### Task 4: PHP Auth middleware + +**Files:** +- Create: `src/Auth.php` (remote) + +- [ ] **Step 1: src/Auth.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Auth.php' <<'EOF' +getHeaderLine('Authorization'); + if (!preg_match('/^Bearer\s+(.+)$/', $header, $matches)) { + return errorResponse(new SlimResponse(), 'Authenticatie vereist', 401); + } + $stmt = Database::get()->prepare('SELECT user_id FROM auth_tokens WHERE token = ?'); + $stmt->execute([$matches[1]]); + $row = $stmt->fetch(); + if (!$row) { + return errorResponse(new SlimResponse(), 'Ongeldige token', 401); + } + return $handler->handle($request->withAttribute('user_id', (int)$row['user_id'])); + } +} +EOF +``` + +--- + +### Task 5: PHP Measurements controller + +**Files:** +- Create: `src/Measurements.php` (remote) + +- [ ] **Step 1: src/Measurements.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/src/Measurements.php' <<'EOF' +getAttribute('user_id'); + $params = $request->getQueryParams(); + $db = Database::get(); + + if (!empty($params['since'])) { + $stmt = $db->prepare( + 'SELECT * FROM measurements WHERE user_id = ? AND updated_at > ? ORDER BY updated_at ASC' + ); + $stmt->execute([$userId, $params['since']]); + } else { + $stmt = $db->prepare( + 'SELECT * FROM measurements WHERE user_id = ? ORDER BY updated_at ASC' + ); + $stmt->execute([$userId]); + } + + $rows = $stmt->fetchAll(); + $data = array_map('\Weegschaal\rowToMeasurement', $rows); + return jsonResponse($response, $data); + } + + public function create(Request $request, Response $response): Response + { + $userId = $request->getAttribute('user_id'); + $body = $request->getParsedBody(); + if (!is_array($body)) { + return errorResponse($response, 'Ongeldige JSON', 400); + } + + $db = Database::get(); + $stmt = $db->prepare( + 'INSERT INTO measurements + (id, user_id, weight, bmi, body_fat_percentage, fat_mass, + muscle_percentage, muscle_mass, visceral_fat, bmr, + measured_at, created_at, updated_at) + VALUES + (:id, :user_id, :weight, :bmi, :body_fat_percentage, :fat_mass, + :muscle_percentage, :muscle_mass, :visceral_fat, :bmr, + :measured_at, :created_at, :updated_at) + ON DUPLICATE KEY UPDATE + weight = VALUES(weight), bmi = VALUES(bmi), + body_fat_percentage = VALUES(body_fat_percentage), + fat_mass = VALUES(fat_mass), + muscle_percentage = VALUES(muscle_percentage), + muscle_mass = VALUES(muscle_mass), + visceral_fat = VALUES(visceral_fat), + bmr = VALUES(bmr), + measured_at = VALUES(measured_at), + updated_at = VALUES(updated_at), + deleted = 0' + ); + + $stmt->execute([ + ':id' => $body['id'], + ':user_id' => $userId, + ':weight' => $body['weight'], + ':bmi' => $body['bmi'], + ':body_fat_percentage' => $body['bodyFatPercentage'], + ':fat_mass' => $body['fatMass'], + ':muscle_percentage' => $body['musclePercentage'], + ':muscle_mass' => $body['muscleMass'], + ':visceral_fat' => $body['visceralFat'], + ':bmr' => $body['bmr'], + ':measured_at' => substr($body['measuredAt'], 0, 10), + ':created_at' => $body['createdAt'], + ':updated_at' => $body['updatedAt'], + ]); + + return jsonResponse($response, $body, 201); + } + + public function delete(Request $request, Response $response, array $args): Response + { + $userId = $request->getAttribute('user_id'); + $id = $args['id']; + $db = Database::get(); + $stmt = $db->prepare( + 'UPDATE measurements SET deleted = 1, updated_at = NOW() WHERE user_id = ? AND id = ?' + ); + $stmt->execute([$userId, $id]); + return jsonResponse($response, ['deleted' => $id]); + } +} +EOF +``` + +--- + +### Task 6: PHP Entry point + +**Files:** +- Create: `public/index.php` (remote) + +- [ ] **Step 1: public/index.php** + +```bash +ssh aiaiaiaiagent@nextcloud 'cat > /usr/share/nginx/html/weegschaal-api/public/index.php' <<'EOF' +addErrorMiddleware(false, true, true); + +$app->get('/api/v1/measurements', [Measurements::class, 'list'])->add(new Auth()); +$app->post('/api/v1/measurements', [Measurements::class, 'create'])->add(new Auth()); +$app->delete('/api/v1/measurements/{id}', [Measurements::class, 'delete'])->add(new Auth()); + +$app->run(); +EOF +``` + +--- + +### Task 7: Test API met curl + +- [ ] **Step 1: GET measurements (lege lijst)** + +```bash +curl -s https://weegschaal.eddydevink.nl/api/v1/measurements \ + -H "Authorization: Bearer $APP_TOKEN" +``` +Expected: `200` met `[]` + +- [ ] **Step 2: POST nieuwe meting** + +```bash +curl -s -X POST https://weegschaal.eddydevink.nl/api/v1/measurements \ + -H "Authorization: Bearer $APP_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"id\": \"test-$(date +%s)\", + \"weight\": 75.5, + \"bmi\": 22.3, + \"bodyFatPercentage\": 15.2, + \"fatMass\": 11.5, + \"musclePercentage\": 40.1, + \"muscleMass\": 30.3, + \"visceralFat\": 8, + \"bmr\": 1650, + \"measuredAt\": \"2026-07-20T08:00:00.000Z\", + \"createdAt\": \"2026-07-20T08:00:00.000Z\", + \"updatedAt\": \"2026-07-20T08:00:00.000Z\" + }" +``` +Expected: `201` + +- [ ] **Step 3: GET met since** + +```bash +curl -s "https://weegschaal.eddydevink.nl/api/v1/measurements?since=2020-01-01T00:00:00Z" \ + -H "Authorization: Bearer $APP_TOKEN" | python3 -m json.tool +``` +Expected: array met de test meting + +- [ ] **Step 4: Test 401** + +```bash +curl -s -o /dev/null -w "%{http_code}" https://weegschaal.eddydevink.nl/api/v1/measurements +``` +Expected: `401` + +--- + +### Task 8: Client — Update types.ts + +**Files:** +- Modify: `src/types.ts` + +- [ ] **Step 1: Vervang types** + +Vervang volledige inhoud van `src/types.ts`: + +```typescript +export type Measurement = { + id: string + weight: number + bmi: number + bodyFatPercentage: number + fatMass: number + musclePercentage: number + muscleMass: number + visceralFat: number + bmr: number + measuredAt: string + createdAt: string + updatedAt: string + deletedAt?: string +} + +export type ApiConfig = { + url: string + token: string +} +``` + +- [ ] **Step 2: Check TypeScript** + +```bash +cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit +``` +Expected: fouten vanwege verwijzingen naar `WebDAVConfig` en `syncStatus` in andere files — normaal, wordt in volgende tasks opgelost. + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "types: vervang WebDAVConfig door ApiConfig, verwijder SyncStatus, voeg deletedAt toe" +``` + +--- + +### Task 9: Client — Create src/sync/api.ts + +**Files:** +- Create: `src/sync/api.ts` + +- [ ] **Step 1: Schrijf api.ts** + +```typescript +import { Measurement } from '../types' +import { ApiConfig } from '../types' + +export class ApiError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +function authHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + } +} + +export type Api = ReturnType + +export function createApi(config: ApiConfig) { + const base = config.url.replace(/\/+$/, '') + + return { + async getMeasurements(since?: string): Promise { + let url = `${base}/api/v1/measurements` + if (since) { + url += `?since=${encodeURIComponent(since)}` + } + const response = await fetch(url, { + headers: { Authorization: `Bearer ${config.token}` }, + }) + if (response.status === 401) { + throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401) + } + if (!response.ok) { + throw new ApiError(`Serverfout bij ophalen metingen: ${response.status}`, response.status) + } + return response.json() + }, + + async createMeasurement(m: Measurement): Promise { + const response = await fetch(`${base}/api/v1/measurements`, { + method: 'POST', + headers: authHeaders(config.token), + body: JSON.stringify(m), + }) + if (response.status === 401) { + throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401) + } + if (!response.ok) { + throw new ApiError(`Serverfout bij opslaan meting: ${response.status}`, response.status) + } + }, + + async deleteMeasurement(id: string): Promise { + const response = await fetch(`${base}/api/v1/measurements/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${config.token}` }, + }) + if (response.status === 401) { + throw new ApiError('Authenticatie mislukt. Controleer je token in Instellingen.', 401) + } + if (!response.ok) { + throw new ApiError(`Serverfout bij verwijderen meting: ${response.status}`, response.status) + } + }, + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/sync/api.ts +git commit -m "feat: REST API client module" +``` + +--- + +### Task 10: Client — Rewrite src/sync/syncService.ts + +**Files:** +- Modify: `src/sync/syncService.ts` + +- [ ] **Step 1: Volledige herschrijving** + +```typescript +import { Measurement } from '../types' +import { + initDatabase, + getAllMeasurements, + upsertMeasurement, + getMeasurementsUpdatedSince, + removeMeasurement, +} from '../database/db' +import { createApi, ApiError } from './api' +import * as SecureStore from 'expo-secure-store' + +const LAST_SYNC_KEY = 'weegschaal_last_sync' + +export function mergeMeasurements(local: Measurement[], remote: Measurement[]): Measurement[] { + const map = new Map() + + for (const m of local) { + map.set(m.id, m) + } + + for (const r of remote) { + const existing = map.get(r.id) + if (!existing || new Date(r.updatedAt) > new Date(existing.updatedAt)) { + map.set(r.id, { + ...r, + deletedAt: r.deletedAt || undefined, + } as Measurement) + } + } + + return Array.from(map.values()) +} + +export async function uploadMeasurement(m: Measurement): Promise { + const { getApiConfig } = await import('../screens/SettingsScreen') + const config = await getApiConfig() + if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') + const api = createApi(config) + await api.createMeasurement(m) +} + +export async function syncAll(): Promise<{ pushed: number; deleted: number; pulled: number }> { + const { getApiConfig } = await import('../screens/SettingsScreen') + const config = await getApiConfig() + if (!config) throw new Error('Geen API configuratie. Stel deze eerst in in Instellingen.') + + const api = createApi(config) + await initDatabase() + + const lastSyncAt = await SecureStore.getItemAsync(LAST_SYNC_KEY) + + const remote = await api.getMeasurements(lastSyncAt || undefined) + const local = await getAllMeasurements() + + const merged = mergeMeasurements(local, remote) + + for (const m of merged) { + await upsertMeasurement(m) + } + + const since = lastSyncAt || '1970-01-01T00:00:00.000Z' + const updated = await getMeasurementsUpdatedSince(since) + + let pushed = 0 + let deleted = 0 + + await Promise.allSettled( + updated.map(async m => { + if (m.deletedAt && m.deletedAt > since) { + try { + await api.deleteMeasurement(m.id) + await removeMeasurement(m.id) + deleted++ + } catch {} + } else { + try { + await api.createMeasurement(m) + pushed++ + } catch {} + } + }) + ) + + await SecureStore.setItemAsync(LAST_SYNC_KEY, new Date().toISOString()) + + return { pushed, deleted, pulled: remote.length } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/sync/syncService.ts +git commit -m "sync: herschrijf syncService voor timestamp-based REST sync" +``` + +--- + +### Task 11: Client — Update src/database/db.ts + +**Files:** +- Modify: `src/database/db.ts` + +- [ ] **Step 1: Voeg deletedAt kolom toe, nieuwe functies** + +Huidige `db.ts` lezen en alle wijzigingen toepassen: + +**Schema wijziging** — voeg `deletedAt TEXT` toe na `syncStatus TEXT` in CREATE TABLE: + +```typescript +await db.execAsync(` + CREATE TABLE IF NOT EXISTS measurements ( + id TEXT PRIMARY KEY, + weight REAL NOT NULL, + bmi REAL NOT NULL, + bodyFatPercentage REAL NOT NULL, + fatMass REAL NOT NULL, + musclePercentage REAL NOT NULL, + muscleMass REAL NOT NULL, + visceralFat INTEGER NOT NULL, + bmr REAL NOT NULL, + measuredAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + deletedAt TEXT, + syncStatus TEXT NOT NULL DEFAULT 'local' + ); +`) +``` + +**Verwijder** `updateMeasurement`, `getUnsyncedMeasurements`. Houd `deleteMeasurement` voor backward compat (wordt niet meer geëxporteerd). + +**Voeg toe** onderaan: + +```typescript +export async function getMeasurementsUpdatedSince(since: string): Promise { + const rows = await db!.getAllAsync( + 'SELECT * FROM measurements WHERE updatedAt > ? ORDER BY updatedAt ASC', + [since] + ) + return rows as Measurement[] +} + +export async function setDeletedAt(id: string): Promise { + await db!.runAsync( + 'UPDATE measurements SET deletedAt = ? WHERE id = ?', + [new Date().toISOString(), id] + ) +} + +export async function removeMeasurement(id: string): Promise { + await db!.runAsync('DELETE FROM measurements WHERE id = ?', [id]) +} +``` + +- [ ] **Step 2: Controleer dat insertMeasurement het deletedAt veld ook invult** + +`insertMeasurement` moet `deletedAt` opnemen in INSERT (NULL voor nieuwe metingen): + +```typescript +export async function insertMeasurement(m: Measurement): Promise { + await db!.runAsync( + `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null] + ) +} +``` + +En `upsertMeasurement`: + +```typescript +export async function upsertMeasurement(m: Measurement): Promise { + await db!.runAsync( + `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null] + ) +} +``` + +- [ ] **Step 3: Check TypeScript** + +```bash +cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/database/db.ts +git commit -m "db: voeg deletedAt kolom toe, nieuwe sync-gerelateerde functies" +``` + +--- + +### Task 12: Client — Update SettingsScreen + +**Files:** +- Modify: `src/screens/SettingsScreen.tsx` + +- [ ] **Step 1: Vervang WebDAV form door API form** + +```tsx +import React, { useEffect, useState } from 'react' +import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native' +import * as SecureStore from 'expo-secure-store' +import { ApiConfig } from '../types' + +const CONFIG_KEY = 'weegschaal_api_config' + +async function loadConfig(): Promise { + try { + const json = await SecureStore.getItemAsync(CONFIG_KEY) + return json ? JSON.parse(json) : null + } catch { + return null + } +} + +async function saveConfig(config: ApiConfig): Promise { + await SecureStore.setItemAsync(CONFIG_KEY, JSON.stringify(config)) +} + +async function deleteConfig(): Promise { + await SecureStore.deleteItemAsync(CONFIG_KEY) +} + +export async function getApiConfig(): Promise { + return loadConfig() +} + +export default function SettingsScreen() { + const [url, setUrl] = useState('https://weegschaal.eddydevink.nl') + const [token, setToken] = useState('') + const [showToken, setShowToken] = useState(false) + const [syncing, setSyncing] = useState(false) + + useEffect(() => { + loadConfig().then(c => { + if (c) { setUrl(c.url); setToken(c.token) } + }) + }, []) + + const handleSave = async () => { + await saveConfig({ url, token }) + Alert.alert('Opgeslagen', 'API instellingen zijn opgeslagen') + } + + const handleClear = () => { + Alert.alert('Instellingen wissen?', 'API configuratie definitief verwijderen?', [ + { text: 'Annuleren', style: 'cancel' }, + { text: 'Ja, wissen', style: 'destructive', onPress: async () => { + await deleteConfig(); setUrl(''); setToken('') + }}, + ]) + } + + const handleSync = async () => { + setSyncing(true) + try { + const { syncAll } = await import('../sync/syncService') + const result = await syncAll() + Alert.alert('Sync voltooid', `Opgehaald: ${result.pulled}, gepusht: ${result.pushed}, verwijderd: ${result.deleted}`) + } catch (e: any) { + Alert.alert('Sync fout', e?.message ?? 'Onbekende fout') + } finally { + setSyncing(false) + } + } + + return ( + + REST API + API URL + + Token + + + setShowToken(!showToken)}> + {showToken ? 'O' : 'O'} + + + + Instellingen opslaan + + + Instellingen wissen + + + {syncing ? 'Bezig met sync...' : 'Nu synchroniseren'} + + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#f5f5f5' }, + content: { padding: 16 }, + sectionTitle: { fontSize: 18, fontWeight: '700', marginBottom: 16 }, + label: { fontSize: 14, fontWeight: '600', color: '#333', marginBottom: 4, marginTop: 12 }, + input: { backgroundColor: '#fff', borderRadius: 8, padding: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd' }, + passwordRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', borderRadius: 8, borderWidth: 1, borderColor: '#ddd' }, + passwordInput: { flex: 1, padding: 12, fontSize: 16 }, + eyeButton: { padding: 12 }, + eyeIcon: { fontSize: 20 }, + saveButton: { backgroundColor: '#4CAF50', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 24 }, + clearButton: { padding: 14, borderRadius: 8, alignItems: 'center', alignSelf: 'stretch', marginTop: 8 }, + clearButtonText: { color: '#e53935', fontSize: 14 }, + syncButton: { backgroundColor: '#2196F3', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 24 }, + syncButtonDisabled: { opacity: 0.6 }, + buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' }, +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/screens/SettingsScreen.tsx +git commit -m "settings: vervang WebDAV configuratie door REST API configuratie" +``` + +--- + +### Task 13: Client — Update NewMeasurementScreen + +**Files:** +- Modify: `src/screens/NewMeasurementScreen.tsx` + +- [ ] **Step 1: Pas doSave aan voor directe push** + +In `doSave`, gebruik `uploadMeasurement` i.p.v. `syncAll`: + +Huidige file lezen en de `doSave` functie vervangen door: + +```typescript +const doSave = async (m: Measurement) => { + try { + await uploadMeasurement(m) + Alert.alert('Opgeslagen & gesynct', 'Meting is opgeslagen en gesynchroniseerd', [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]) + } catch { + Alert.alert('Opgeslagen', 'Meting is opgeslagen (sync mislukt)', [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]) + } +} +``` + +En de import aanpassen: `import { uploadMeasurement } from '../sync/syncService'` toevoegen, `syncAll` import verwijderen. + +In `handleSubmit`: na insert/upsert (die `syncStatus: 'local'` niet meer zet — moet `syncStatus` überhaupt nog? Nee, het veld bestaat niet meer in het type). Dus: + +Het MeasurementForm zet nu geen `syncStatus` meer omdat het type dat veld niet meer heeft. We moeten `MeasurementForm.tsx` aanpassen. + +**MeasurementForm.tsx wijziging** — verwijder `syncStatus: 'local'` uit de `submit` functie. Lees het bestand en verwijder die regel. + +- [ ] **Step 2: Pas MeasurementForm aan** + +Huidig: `syncStatus: 'local'` in de submit. Verwijder die regel. Form submit returned nu een Measurement zonder syncStatus. + +- [ ] **Step 3: Check TypeScript** + +```bash +cd /home/eddy/bin/weegschaal-app && npx tsc --noEmit +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/screens/NewMeasurementScreen.tsx src/components/MeasurementForm.tsx +git commit -m "save: directe uploadMeasurement push, verwijder syncStatus uit MeasurementForm" +``` + +--- + +### Task 14: Client — Update MeasurementDetailScreen + +**Files:** +- Modify: `src/screens/MeasurementDetailScreen.tsx` + +- [ ] **Step 1: Soft delete via deletedAt** + +In `MeasurementDetailScreen.tsx`: + +Imports aanpassen: +- Verwijder `deleteMeasurement`, voeg `setDeletedAt`, `removeMeasurement` toe van `../database/db` +- Voeg `createApi` toe van `../sync/api` +- Import `getApiConfig` van `./SettingsScreen` (of `../screens/SettingsScreen`) + +Vervang `handleDelete`: + +```typescript +const handleDelete = () => { + Alert.alert('Meting verwijderen?', 'Dit kan niet ongedaan worden gemaakt.', [ + { text: 'Annuleren', style: 'cancel' }, + { + text: 'Verwijderen', + style: 'destructive', + onPress: async () => { + await initDatabase() + await setDeletedAt(measurementId) + try { + const config = await getApiConfig() + if (config) { + await createApi(config).deleteMeasurement(measurementId) + await removeMeasurement(measurementId) + } + } catch {} + navigation.goBack() + }, + }, + ]) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/screens/MeasurementDetailScreen.tsx +git commit -m "detail: soft delete via deletedAt i.p.v. syncStatus" +``` + +--- + +### Task 15: Client — Update HistoryScreen + DashboardScreen + +**Files:** +- Modify: `src/screens/HistoryScreen.tsx` +- Modify: `src/screens/DashboardScreen.tsx` + +- [ ] **Step 1: HistoryScreen — filter + soft delete** + +In `HistoryScreen.tsx`, `load` functie: + +```typescript +const load = useCallback(async () => { + await initDatabase() + const all = await getAllMeasurements() + setMeasurements(all.filter(m => !m.deletedAt)) +}, []) +``` + +`confirmDelete` aanpassen: + +```typescript +const confirmDelete = (m: Measurement) => { + const date = new Date(m.measuredAt).toLocaleDateString('nl-NL') + Alert.alert('Verwijderen?', `Meting van ${date} definitief verwijderen?`, [ + { text: 'Annuleren', style: 'cancel' }, + { + text: 'Ja, verwijderen', + style: 'destructive', + onPress: async () => { + await initDatabase() + await setDeletedAt(m.id) + try { + const config = await getApiConfig() + if (config) { + await createApi(config).deleteMeasurement(m.id) + await removeMeasurement(m.id) + } + } catch {} + load() + }, + }, + ]) +} +``` + +Imports updaten: +- Verwijder `deleteMeasurement`, voeg `setDeletedAt`, `removeMeasurement` toe van `../database/db` +- Voeg `createApi` toe van `../sync/api` +- Import `getApiConfig` van `../screens/SettingsScreen` + +- [ ] **Step 2: DashboardScreen — filter deleted** + +In `DashboardScreen.tsx`, `load` functie: + +```typescript +const load = async () => { + await initDatabase() + const allM = await getAllMeasurements() + const visible = allM.filter(m => !m.deletedAt) + setAll(visible) + setLatest(visible.length > 0 ? visible[0] : null) +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/screens/HistoryScreen.tsx src/screens/DashboardScreen.tsx +git commit -m "ui: filter deletedAt in History en Dashboard, soft delete in History" +``` + +--- + +### Task 16: Client — Update tests + +**Files:** +- Modify: `tests/syncService.test.ts` + +- [ ] **Step 1: Herschrijf tests** + +```typescript +import { mergeMeasurements } from '../src/sync/syncService' +import { Measurement } from '../src/types' + +jest.mock('expo-sqlite', () => ({ openDatabaseAsync: jest.fn() })) + +const make = (overrides: Partial = {}): Measurement => ({ + id: 'id-1', weight: 75, bmi: 22, bodyFatPercentage: 15, fatMass: 10, + musclePercentage: 40, muscleMass: 30, visceralFat: 8, bmr: 1600, + measuredAt: '2024-06-01T08:00:00.000Z', createdAt: '2024-06-01T08:00:00.000Z', + updatedAt: '2024-06-01T08:00:00.000Z', ...overrides, +}) + +describe('mergeMeasurements', () => { + it('both empty returns empty', () => { + expect(mergeMeasurements([], [])).toEqual([]) + }) + + it('local only kept', () => { + const r = mergeMeasurements([make()], []) + expect(r).toHaveLength(1) + }) + + it('remote only kept', () => { + const r = mergeMeasurements([], [make({ id: 'r1' })]) + expect(r).toHaveLength(1) + expect(r[0].id).toBe('r1') + }) + + it('last-writer-wins: remote newer', () => { + const local = make({ weight: 75, updatedAt: '2024-06-02T08:00:00.000Z' }) + const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].weight).toBe(76) + }) + + it('last-writer-wins: local newer', () => { + const local = make({ weight: 77, updatedAt: '2024-06-04T08:00:00.000Z' }) + const remote = make({ weight: 76, updatedAt: '2024-06-03T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].weight).toBe(77) + }) + + it('remote deleted wins over older local', () => { + const local = make({ updatedAt: '2024-06-01T08:00:00.000Z' }) + const remote = make({ updatedAt: '2024-06-03T08:00:00.000Z', deletedAt: '2024-06-03T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].deletedAt).toBe('2024-06-03T08:00:00.000Z') + }) + + it('local wins over older remote deleted', () => { + const local = make({ updatedAt: '2024-06-03T08:00:00.000Z' }) + const remote = make({ updatedAt: '2024-06-01T08:00:00.000Z', deletedAt: '2024-06-01T08:00:00.000Z' }) + const r = mergeMeasurements([local], [remote]) + expect(r[0].deletedAt).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run tests** + +```bash +cd /home/eddy/bin/weegschaal-app && npm test +``` +Expected: 7 PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/syncService.test.ts +git commit -m "test: herschrijf merge tests voor timestamp-based sync" +``` + +--- + +### Task 17: Build APK + sideload + test + +- [ ] **Step 1: Bouw APK** + +```bash +cd /home/eddy/bin/weegschaal-app && npx eas build --local --profile apk --platform android +``` + +- [ ] **Step 2: Verbind telefoon** + +```bash +adb-wireless-connect +``` + +- [ ] **Step 3: Installeer APK** + +```bash +adb install -r /home/eddy/bin/weegschaal-app/build-*.apk +``` + +- [ ] **Step 4: Stel API configuratie in op de telefoon** + +Open de app → Instellingen → vul `https://weegschaal.eddydevink.nl` en token (uit `/tmp/weegschaal-credentials.txt`) in → Opslaan → Nu synchroniseren. + +- [ ] **Step 5: Commit** + +```bash +# Done +``` diff --git a/docs/superpowers/specs/2026-07-20-rest-api-sync-design.md b/docs/superpowers/specs/2026-07-20-rest-api-sync-design.md new file mode 100644 index 0000000..164cf71 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-rest-api-sync-design.md @@ -0,0 +1,266 @@ +# REST API Sync — Design Doc + +Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`, +met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand. + +## Fase 1 — Server-side: PHP API + +### Infrastructuur + +- **DNS**: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl` (nextcloud) +- **nginx**: server block in `/etc/nginx/conf.d/weegschaal-api.conf`, proxy naar PHP-FPM socket +- **SSL**: `certbot --nginx -d weegschaal.eddydevink.nl` +- **Document root**: `/usr/share/nginx/html/weegschaal-api/` + +### MariaDB + +Database `weegschaal` met gebruiker `weegschaal@localhost` (random wachtwoord). + +```sql +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE auth_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id), + token VARCHAR(64) NOT NULL UNIQUE, + description VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE measurements ( + id VARCHAR(36) NOT NULL, + user_id INT NOT NULL, + weight DECIMAL(5,2) NOT NULL, + bmi DECIMAL(4,1) NOT NULL, + body_fat_percentage DECIMAL(4,1) NOT NULL, + fat_mass DECIMAL(5,2) NOT NULL, + muscle_percentage DECIMAL(4,1) NOT NULL, + muscle_mass DECIMAL(5,2) NOT NULL, + visceral_fat TINYINT NOT NULL, + bmr DECIMAL(6,1) NOT NULL, + measured_at DATE NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted TINYINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, id), + INDEX idx_updated_at (user_id, updated_at) +); +``` + +`deleted` flag voor soft deletes op server. De client stuurt harde DELETEs; +de server zet `deleted=1` en update `updated_at` zodat de volgende `?since=` +query deze doorgeeft zodat andere clients de delete kunnen verwerken. + +### Endpoints (Slim Framework) + +| Methode | Pad | Beschrijving | +|---------|-----|-------------| +| `GET` | `/api/v1/measurements` | Alle metingen (`?since=ISO` voor delta) | +| `POST` | `/api/v1/measurements` | Nieuwe meting of upsert op ID | +| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen (soft delete) | + +**Geen** auth endpoints — eerste token wordt handmatig via SQL INSERT aangemaakt. + +### Auth middleware + +- `Authorization: Bearer ` → valideert tegen `auth_tokens` tabel +- Zet `user_id` in request context +- 401 bij ongeldige/missing token + +### Bestandsstructuur + +``` +/usr/share/nginx/html/weegschaal-api/ +├── composer.json +├── public/ +│ └── index.php ← Entry point, routing +├── src/ +│ ├── Database.php ← PDO singleton +│ ├── Auth.php ← Token validation middleware +│ ├── Measurements.php ← CRUD + sync queries +│ └── helpers.php +└── config.php ← DB credentials +``` + +### Sync-logica server + +- `GET ?since=ISO` → `WHERE user_id = ? AND updated_at > ? ORDER BY updated_at ASC` +- `POST` → `INSERT ... ON DUPLICATE KEY UPDATE` — upsert op client-ID +- `DELETE` → set `deleted=1`, update `updated_at=NOW()` (soft delete, zodat delta-sync werkt voor andere clients) +- Geretourneerde records bevatten `deleted: true` veld zodat de client weet dat dit een soft-deleted record is + +--- + +## Fase 2 — Client-side: App aanpassen + +### Types (`src/types.ts`) + +`WebDAVConfig` en `SyncStatus` vervallen. Toegevoegd: `ApiConfig`, `deletedAt`: + +```typescript +export type Measurement = { + id: string + weight: number + bmi: number + bodyFatPercentage: number + fatMass: number + musclePercentage: number + muscleMass: number + visceralFat: number + bmr: number + measuredAt: string + createdAt: string + updatedAt: string + deletedAt?: string +} + +export type ApiConfig = { + url: string + token: string +} +``` + +### SettingsScreen (`src/screens/SettingsScreen.tsx`) + +- Vervang URL/username/password door API-URL + token (met oogje) +- SecureStore key: `weegschaal_api_config` +- `getApiConfig()` (exported) i.p.v. `getWebDAVConfig()` +- Sync knop blijft + +### Nieuwe module `src/sync/api.ts` + +```typescript +const createApi = (config: ApiConfig) => ({ + getMeasurements: (since?: string) => GET /api/v1/measurements?since=... + createMeasurement: (m: Measurement) => POST /api/v1/measurements + deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id +}) +``` + +Alle calls met `Authorization: Bearer ` header. + +### syncService.ts — volledig herschreven + +#### `mergeMeasurements(local, remote)` + +Simpele last-writer-wins op `updatedAt`: + +1. Bouw map van alle IDs (lokaal + remote) +2. Voor elke ID: als beide bestaan → behoud de met de hoogste `updatedAt` +3. Remote metingen die winnen krijgen `deletedAt` overgenomen uit server indien de server record `deleted: true` had + +#### `syncAll()` + +1. Lees `lastSyncAt` uit SecureStore (`weegschaal_last_sync`) +2. `GET /api/v1/measurements?since=lastSyncAt` → remote delta +3. `getAllMeasurements()` → lokale metingen +4. Merge remote delta in lokale DB (last-writer-wins) +5. Verwijder lokale records waar remote `deleted: true` en remote `updatedAt > local updatedAt` +6. Verzamel lokale metingen met `updatedAt > lastSyncAt` (alleen niet-verwijderde) +7. Voor `deletedAt` metingen met `deletedAt > lastSyncAt` → DELETE naar API → bij succes lokaal verwijderen +8. Voor overige metingen met `updatedAt > lastSyncAt` → POST naar API +9. `lastSyncAt = now()` + +#### `uploadMeasurement(m)` + +Losstaande functie voor directe push na opslaan: + +1. POST naar API +2. Bij succes → return +3. Bij netwerkfout → throw; meting blijft lokaal, `syncAll` pakt hem later op (want `updatedAt > lastSyncAt`) + +### NewMeasurementScreen + +1. Lokaal opslaan (insert of upsert, met `updatedAt = now()`) +2. Direct `uploadMeasurement(m)` proberen +3. Bij succes → navigeer terug met "Opgeslagen & gesynct" +4. Bij falen → navigeer terug met "Opgeslagen (sync mislukt)" + +### MeasurementDetailScreen + +Bij delete: +1. Zet `deletedAt = now()` lokaal +2. Fire-and-forget: DELETE API call +3. Bij succes → record lokaal verwijderen +4. Bij falen → record blijft met `deletedAt`, syncAll pakt het op + +### HistoryScreen + +Bij delete (vanuit alert in HistoryScreen): +1. Zet `deletedAt = now()` lokaal +2. Fire-and-forget: DELETE API call +3. Bij succes → record lokaal verwijderen +4. Bij falen → record blijft met `deletedAt` + +Filter bij laden: `!m.deletedAt`. + +### DashboardScreen + +Laatste meting en grafiek: alleen metingen zonder `deletedAt`. +Laatste meting = meest recente `measuredAt` zonder `deletedAt`. + +### Database (`db.ts`) + +Nieuw kolom in schema: `deletedAt TEXT`. + +Nieuwe functies: +- `getMeasurementsUpdatedSince(since: string)` — metingen met `updatedAt > since` +- `setDeletedAt(id: string)` — zet `deletedAt = now()` +- `removeMeasurement(id: string)` — harde delete + +Verwijderd/onbruikbaar: +- `getUnsyncedMeasurements()` — niet meer nodig +- `getPendingMeasurements()` — niet meer nodig +- `markAsDeleted()` — vervangen door `setDeletedAt` +- `syncStatus` kolom — blijft in DB maar wordt niet meer gebruikt in code + +--- + +## Fase 3 — Migratie (eerste sync) + +Eerste keer dat de app de nieuwe API gebruikt (`lastSyncAt` is `null`): +- `GET /api/v1/measurements` (zonder `?since`) → alle remote metingen +- Merge met lokale DB: last-writer-wins, remote deleted overschrijft lokaal +- Alle lokale metingen (die niet deleted zijn) worden gepusht (`updatedAt > null` = alles) +- `lastSyncAt = now()` +- Vanaf dan: delta-sync + +--- + +## Foutafhandeling + +| Situatie | Gedrag | +|----------|--------| +| Geen netwerk bij opslaan | Lokaal opgeslagen, `updatedAt > lastSyncAt` dus next sync pakt op | +| Geen netwerk bij sync | Throw error, scherm toont melding | +| Geen API-config | Throw `'Geen API configuratie. Stel deze eerst in in Instellingen.'` | +| HTTP 401 (ongeldige token) | Throw `'Authenticatie mislukt. Controleer je token in Instellingen.'` | +| HTTP 5xx | Throw met statuscode, scherm toont melding | + +--- + +## Testen + +### Client-side (Jest) + +- `mergeMeasurements()` unit tests: local wins, remote wins, deleted remote vs local +- `api.ts` calls mocken met fetch-mock +- `syncAll()` integratietest met gemockte API + +### Server-side + +- Handmatig testen met curl na deployment + +--- + +## Implementatievolgorde + +1. Server-side: nginx, MariaDB, PHP API → testen met curl +2. Client-side: types, api.ts, db.ts, syncService.ts, SettingsScreen → testen +3. Client-side: DashboardScreen, HistoryScreen, MeasurementDetailScreen, NewMeasurementScreen +4. Client-side: unit tests +5. Build APK, sideloaden, testen op telefoon diff --git a/src/database/db.ts b/src/database/db.ts index f3d8d81..88dfb8a 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -28,9 +28,9 @@ export async function initDatabase(): Promise { export async function insertMeasurement(m: Measurement): Promise { await db!.runAsync( - `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deletedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deletedAt ?? null] + `INSERT INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null] ) } @@ -59,9 +59,9 @@ export async function getMeasurementByDate(date: string): Promise { await db!.runAsync( - `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, syncStatus, deletedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.syncStatus, m.deletedAt ?? null] + `INSERT OR REPLACE INTO measurements (id, weight, bmi, bodyFatPercentage, fatMass, musclePercentage, muscleMass, visceralFat, bmr, measuredAt, createdAt, updatedAt, deletedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [m.id, m.weight, m.bmi, m.bodyFatPercentage, m.fatMass, m.musclePercentage, m.muscleMass, m.visceralFat, m.bmr, m.measuredAt, m.createdAt, m.updatedAt, m.deletedAt ?? null] ) } diff --git a/upgrade.md b/upgrade.md new file mode 100644 index 0000000..f51a5e1 --- /dev/null +++ b/upgrade.md @@ -0,0 +1,140 @@ +# Upgrade: REST API sync i.p.v. WebDAV + +Vervang WebDAV-sync door een eigen REST API op `weegschaal.eddydevink.nl`, +met MariaDB-backend en Bearer token-authenticatie. Incrementele sync i.p.v. volledig bestand. + +## Fase 1 — Server-side: PHP API + +### Subdomein & nginx + +- DNS: `weegschaal.eddydevink.nl` → zelfde server als `git.eddydevink.nl` +- nginx server block in `/etc/nginx/conf.d/weegschaal-api.conf` +- SSL via Let's Encrypt (`sudo certbot`) +- Document root: `/usr/share/nginx/html/weegschaal-api/` + +### MariaDB + +```sql +CREATE DATABASE weegschaal; +CREATE USER 'weegschaal'@'localhost' IDENTIFIED BY ''; +GRANT ALL ON weegschaal.* TO 'weegschaal'@'localhost'; + +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE auth_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id), + token VARCHAR(64) NOT NULL UNIQUE, + description VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE measurements ( + id VARCHAR(36) NOT NULL, + user_id INT NOT NULL, + weight DECIMAL(5,2) NOT NULL, + bmi DECIMAL(4,1) NOT NULL, + body_fat_percentage DECIMAL(4,1) NOT NULL, + fat_mass DECIMAL(5,2) NOT NULL, + muscle_percentage DECIMAL(4,1) NOT NULL, + muscle_mass DECIMAL(5,2) NOT NULL, + visceral_fat TINYINT NOT NULL, + bmr DECIMAL(6,1) NOT NULL, + measured_at DATE NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (user_id, id), + INDEX idx_updated_at (user_id, updated_at) +); +``` + +### PHP API endpoints (Slim Framework via Composer) + +| Methode | Pad | Beschrijving | +|---------|-----|-------------| +| `POST` | `/api/v1/auth/register` | (later) Nieuwe gebruiker | +| `POST` | `/api/v1/auth/tokens` | (later) Nieuwe token aanmaken | +| `GET` | `/api/v1/measurements` | Alle metingen (of `?since=ISO` voor delta) | +| `POST` | `/api/v1/measurements` | Nieuwe meting aanmaken | +| `PUT` | `/api/v1/measurements/:id` | Meting bijwerken | +| `DELETE` | `/api/v1/measurements/:id` | Meting verwijderen | + +Authenticatie: `Authorization: Bearer `, gevalideerd tegen `auth_tokens`-tabel. + +Sync-logica server: +- `GET /api/v1/measurements?since=2025-01-01T12:00:00Z` → alleen metingen met `updated_at > since` +- Bij PUT/DELETE: controleer `updated_at` client, bij conflict → HTTP 409 met server-versie + +### Bestandsstructuur API + +``` +/usr/share/nginx/html/weegschaal-api/ +├── composer.json +├── public/ +│ └── index.php ← Entry point, routing +├── src/ +│ ├── Database.php ← PDO singleton +│ ├── Auth.php ← Token validation middleware +│ ├── Measurements.php ← CRUD + sync queries +│ └── helpers.php +└── config.php ← DB credentials (uit .env of env vars) +``` + +## Fase 2 — Client-side: App aanpassen + +### Types (`src/types.ts`) + +- Vervang `WebDAVConfig` door `ApiConfig { url: string; token: string }` +- Verwijder `SyncStatus` (niet meer nodig — sync status volgt uit `updatedAt`) + +### SettingsScreen (`src/screens/SettingsScreen.tsx`) + +- Vervang URL/username/password door API-URL + token (met oogje) +- Opslaan → SecureStore +- Wissen → SecureStore verwijderen +- 'Nu synchroniseren' knop blijft + +### Nieuwe module `src/sync/api.ts` + +```typescript +const api = (config: ApiConfig) => ({ + getMeasurements: (since?: string) => GET /api/v1/measurements?since=... + createMeasurement: (m: Measurement) => POST /api/v1/measurements + updateMeasurement: (m: Measurement) => PUT /api/v1/measurements/:id + deleteMeasurement: (id: string) => DELETE /api/v1/measurements/:id +}) +``` + +### syncService.ts — volledig herschreven + +Nieuwe sync-flow: + +| Stap | Actie | +|------|-------| +| 1 | Lees lokale `lastSyncAt` (uit SecureStore) | +| 2 | `GET ?since=lastSyncAt` → alleen wijzigingen ophalen | +| 3 | Upsert remote wijzigingen lokaal (last-writer-wins) | +| 4 | Voor elke lokale meting met `updatedAt > lastSyncAt`: push naar API | +| 5 | Update `lastSyncAt` naar server-tijd | + +Bij opslaan in `NewMeasurementScreen`: +1. Direct naar lokale SQLite (offline-first) +2. Direct erna: push naar API (async, niet-blokkerend via `Promise.allSettled`) + +## Fase 3 — Migratie + +Eerste keer na API-setup: +1. App stuurt alle lokale metingen naar de API (1-voor-1 via POST) +2. Alle metingen nu in MariaDB +3. Vanaf dan: alleen delta-sync + +## Benodigde server-toegang + +- SSH naar nextcloud (`aiaiaiaiagent@nextcloud`) — key werkte niet +- `sudo -A mysql` voor DB + user aanmaken +- nginx config aanpassen (`/etc/nginx/conf.d/`) +- DNS controleren voor subdomein