diff --git a/.maestro/flows/sos.yaml b/.maestro/flows/sos.yaml new file mode 100644 index 00000000..dbc789cd --- /dev/null +++ b/.maestro/flows/sos.yaml @@ -0,0 +1,63 @@ +appId: app.petchain.mobile.staging +--- +# SOS happy path: tap button → countdown → contacts notified +- launchApp + +# Navigate to Emergency Contacts screen +- tapOn: "Emergency" +- assertVisible: "Emergency" + +# ── Scenario 1: Full SOS activation ────────────────────────────────────────── +# Mock the emergency API so no real alerts are sent +- runScript: + file: ../scripts/mockEmergencyApi.js + +# Hold the SOS button long enough to trigger countdown (1.5 s hold → 3 s countdown) +- longPressOn: + id: "sos-button" + duration: 2000 + +# Confirm dialog / countdown is visible +- assertVisible: + id: "sos-confirm-dialog" + +# Wait for auto-send (countdown finishes in 3 s) +- waitForAnimationToEnd: + timeout: 5000 + +# Verify the confirmation that contacts were notified +- assertVisible: "SOS Sent" + +# ── Scenario 2: SOS cancelled during countdown ───────────────────────────── +- tapOn: + id: "sos-button" +- longPressOn: + id: "sos-button" + duration: 2000 + +- assertVisible: + id: "sos-confirm-dialog" + +# Tap cancel before countdown ends +- tapOn: + id: "sos-cancel-button" + +# Dialog should dismiss and no SOS should be sent +- assertNotVisible: "SOS Sent" +- assertVisible: + id: "sos-button" + +# ── Scenario 3: SOS with no emergency contacts configured ────────────────── +# Remove all contacts first +- runScript: + file: ../scripts/clearEmergencyContacts.js + +- longPressOn: + id: "sos-button" + duration: 2000 + +- waitForAnimationToEnd: + timeout: 5000 + +# Should show "add contacts" prompt when no contacts exist +- assertVisible: "Add Emergency Contact" diff --git a/.maestro/scripts/clearEmergencyContacts.js b/.maestro/scripts/clearEmergencyContacts.js new file mode 100644 index 00000000..a3afcdf5 --- /dev/null +++ b/.maestro/scripts/clearEmergencyContacts.js @@ -0,0 +1,6 @@ +// Clear all emergency contacts so the "no contacts" scenario can be tested. +http.mock('GET', '/api/emergency/contacts', { + status: 200, + body: JSON.stringify([]), + headers: { 'Content-Type': 'application/json' }, +}); diff --git a/.maestro/scripts/mockEmergencyApi.js b/.maestro/scripts/mockEmergencyApi.js new file mode 100644 index 00000000..ca466b8c --- /dev/null +++ b/.maestro/scripts/mockEmergencyApi.js @@ -0,0 +1,10 @@ +// Mock the emergency SOS API call so no real alerts are sent during tests. +// Maestro runScript injects this before the flow uses the API. +http.mock('POST', '/api/emergency/sos', { + status: 200, + body: JSON.stringify({ success: true, message: 'SOS Sent', contactsNotified: 2 }), + headers: { 'Content-Type': 'application/json' }, +}); + +// Also mock location permission grant +output.mockLocationPermission = true; diff --git a/backend/config/database.ts b/backend/config/database.ts index d9610622..5beda93a 100644 --- a/backend/config/database.ts +++ b/backend/config/database.ts @@ -9,15 +9,32 @@ const DATABASE_URL = // ── Connection pool ──────────────────────────────────────────────────────────── export const pool = new Pool({ connectionString: DATABASE_URL, - max: Number(process.env.DB_POOL_SIZE) || 20, - idleTimeoutMillis: Number(process.env.DB_IDLE_TIMEOUT) || 30000, - connectionTimeoutMillis: 5000, + max: Number(process.env.DB_POOL_MAX) || 20, + idleTimeoutMillis: Number(process.env.DB_IDLE_TIMEOUT_MS) || 10000, + connectionTimeoutMillis: Number(process.env.DB_CONNECTION_TIMEOUT_MS) || 3000, }); pool.on('error', (err) => { console.error('[db] Unexpected pool error:', err.message); }); +/** Returns current pool stats: total, idle, and waiting client counts. */ +export function getPoolStats() { + return { + total: pool.totalCount, + idle: pool.idleCount, + waiting: pool.waitingCount, + }; +} + +// Log a warning when the waiting queue grows beyond 5 +pool.on('connect', () => { + const { waiting } = getPoolStats(); + if (waiting > 5) { + console.warn(`[db] WARN: pool waiting count is ${waiting} — consider increasing DB_POOL_MAX`); + } +}); + // ── Migration runner ─────────────────────────────────────────────────────────── export interface PostgresMigrationOptions { diff --git a/backend/server/app.ts b/backend/server/app.ts index b216169b..13a9c88e 100644 --- a/backend/server/app.ts +++ b/backend/server/app.ts @@ -60,6 +60,8 @@ import notificationTemplatesRouter from '../src/routes/notificationTemplates'; import oauthRouter from '../src/routes/oauth'; import shelterRouter from '../src/routes/shelter'; +import { getPoolStats } from '../config/database'; + // Readiness probe state — set to false while the process is draining let isReady = true; export function setReadiness(ready: boolean): void { @@ -129,7 +131,16 @@ export function createApp(): Express { // --- Health & readiness probes (unauthenticated, exempt from rate limiting) -- api.get('/health', (_req, res) => { - res.json({ ok: true, service: 'petchain-api', timestamp: new Date().toISOString() }); + const pool = getPoolStats(); + if (pool.waiting > 5) { + console.warn(`[db] WARN: pool waiting count is ${pool.waiting}`); + } + res.json({ + ok: true, + service: 'petchain-api', + timestamp: new Date().toISOString(), + pool, + }); }); api.get('/ready', (_req, res) => { diff --git a/backend/server/routes/reports.ts b/backend/server/routes/reports.ts index 382e98cb..81835d40 100644 --- a/backend/server/routes/reports.ts +++ b/backend/server/routes/reports.ts @@ -1,55 +1,192 @@ +/** + * Report job queue — async PDF generation backed by Redis. + * + * Flow: + * 1. POST /api/reports/pets/:petId/health → enqueues job, returns { jobId } + * 2. GET /api/reports/:jobId/status → returns { status, progress?, url? } + * 3. GET /api/reports/:jobId/download → streams the PDF when complete + * + * Job status stored in Redis with 1-hour TTL. + * #595 + */ import express from 'express'; +import { randomUUID } from 'crypto'; import { authenticateJWT, type AuthenticatedRequest } from '../../middleware/auth'; import { generateHealthReport } from '../../services/reportService'; import { sendError } from '../response'; import { store } from '../store'; +import { getRedisClient } from '../../config/redis'; const router = express.Router(); - router.use(authenticateJWT); +const JOB_TTL_SECONDS = 3600; // 1 hour +const JOB_KEY_PREFIX = 'petchain:report:job:'; + +type JobStatus = 'queued' | 'processing' | 'complete' | 'failed'; + +interface JobRecord { + jobId: string; + status: JobStatus; + petId: string; + userId: string; + filename?: string; + recordCount?: number; + error?: string; + createdAt: string; +} + +// In-memory buffer store (keyed by jobId). In production, use cloud storage / signed URLs. +const pdfBuffers = new Map(); + +async function saveJob(job: JobRecord): Promise { + const redis = getRedisClient(); + await redis.set(`${JOB_KEY_PREFIX}${job.jobId}`, JSON.stringify(job), 'EX', JOB_TTL_SECONDS); +} + +async function loadJob(jobId: string): Promise { + const redis = getRedisClient(); + const raw = await redis.get(`${JOB_KEY_PREFIX}${jobId}`); + return raw ? (JSON.parse(raw) as JobRecord) : null; +} + +/** + * Runs PDF generation in the background (next tick) and updates Redis job status. + */ +function processJobAsync(jobId: string): void { + setImmediate(async () => { + const job = await loadJob(jobId); + if (!job) return; + + job.status = 'processing'; + await saveJob(job); + + try { + const pet = store.pets.get(job.petId); + const owner = pet ? store.users.get(pet.ownerId) : undefined; + if (!pet || !owner) throw new Error('Pet or owner not found'); + + const records = [...store.medicalRecords.values()].filter((r) => r.petId === job.petId); + const medications = [...store.medications.values()].filter((m) => m.petId === job.petId); + + const result = await generateHealthReport({ + pet, + owner, + records, + medications, + generatedBy: job.userId, + }); + + pdfBuffers.set(jobId, result.buffer); + job.status = 'complete'; + job.filename = result.filename; + job.recordCount = result.recordCount; + } catch (err) { + job.status = 'failed'; + job.error = err instanceof Error ? err.message : 'Unknown error'; + } + + await saveJob(job); + }); +} + /** - * GET /api/reports/pets/:petId/health - * Generate and download a PDF health report for a pet. - * Query params: dateFrom, dateTo (ISO date strings, optional) + * POST /api/reports/pets/:petId/health + * Enqueues a PDF generation job; returns { jobId } immediately. */ -router.get('/pets/:petId/health', async (req: AuthenticatedRequest, res) => { +router.post('/pets/:petId/health', async (req: AuthenticatedRequest, res) => { const { petId } = req.params as { petId: string }; - const { dateFrom, dateTo } = req.query as { dateFrom?: string; dateTo?: string }; const pet = store.pets.get(petId); if (!pet) return sendError(res, 404, 'NOT_FOUND', 'Pet not found'); - if (pet.ownerId !== req.user?.id) { return sendError(res, 403, 'FORBIDDEN', 'Only the pet owner may generate reports'); } - const owner = store.users.get(pet.ownerId); - if (!owner) return sendError(res, 404, 'NOT_FOUND', 'Owner not found'); - - const records = [...store.medicalRecords.values()].filter((r) => r.petId === petId); - const medications = [...store.medications.values()].filter((m) => m.petId === petId); + const jobId = randomUUID(); + const job: JobRecord = { + jobId, + status: 'queued', + petId, + userId: req.user?.id ?? 'unknown', + createdAt: new Date().toISOString(), + }; try { - const result = await generateHealthReport({ - pet, - owner, - records, - medications, - generatedBy: req.user?.id ?? 'unknown', - dateFrom, - dateTo, - }); - - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); - res.setHeader('X-Record-Count', String(result.recordCount)); - res.send(result.buffer); - } catch (err) { - console.error('[reports] PDF generation failed:', err); - return sendError(res, 500, 'REPORT_FAILED', 'Failed to generate health report'); + await saveJob(job); + } catch { + // Redis unavailable — fall back to synchronous generation + const owner = store.users.get(pet.ownerId); + if (!owner) return sendError(res, 404, 'NOT_FOUND', 'Owner not found'); + const { dateFrom, dateTo } = req.query as { dateFrom?: string; dateTo?: string }; + const records = [...store.medicalRecords.values()].filter((r) => r.petId === petId); + const medications = [...store.medications.values()].filter((m) => m.petId === petId); + try { + const result = await generateHealthReport({ + pet, + owner, + records, + medications, + generatedBy: req.user?.id ?? 'unknown', + dateFrom, + dateTo, + }); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.setHeader('X-Record-Count', String(result.recordCount)); + return res.send(result.buffer); + } catch (err) { + console.error('[reports] sync PDF fallback failed:', err); + return sendError(res, 500, 'REPORT_FAILED', 'Failed to generate health report'); + } } + + processJobAsync(jobId); + res.status(202).json({ jobId }); +}); + +/** + * GET /api/reports/:jobId/status + * Poll job status: { jobId, status, recordCount?, error? } + */ +router.get('/:jobId/status', async (req: AuthenticatedRequest, res) => { + const { jobId } = req.params as { jobId: string }; + const job = await loadJob(jobId).catch(() => null); + + if (!job) return sendError(res, 404, 'NOT_FOUND', 'Job not found or expired'); + if (job.userId !== req.user?.id) return sendError(res, 403, 'FORBIDDEN', 'Access denied'); + + res.json({ + jobId: job.jobId, + status: job.status, + recordCount: job.recordCount, + filename: job.filename, + error: job.error, + }); +}); + +/** + * GET /api/reports/:jobId/download + * Download the completed PDF. + */ +router.get('/:jobId/download', async (req: AuthenticatedRequest, res) => { + const { jobId } = req.params as { jobId: string }; + const job = await loadJob(jobId).catch(() => null); + + if (!job) return sendError(res, 404, 'NOT_FOUND', 'Job not found or expired'); + if (job.userId !== req.user?.id) return sendError(res, 403, 'FORBIDDEN', 'Access denied'); + if (job.status !== 'complete') { + return sendError(res, 409, 'NOT_READY', `Report is ${job.status}`); + } + + const buffer = pdfBuffers.get(jobId); + if (!buffer) return sendError(res, 410, 'GONE', 'PDF has expired from memory'); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${job.filename}"`); + res.setHeader('X-Record-Count', String(job.recordCount ?? 0)); + res.send(buffer); }); export default router; diff --git a/backend/services/__tests__/stellarAnchorService.contract.test.ts b/backend/services/__tests__/stellarAnchorService.contract.test.ts new file mode 100644 index 00000000..94eeb421 --- /dev/null +++ b/backend/services/__tests__/stellarAnchorService.contract.test.ts @@ -0,0 +1,297 @@ +/** + * Consumer-driven contract tests for the Stellar SEP-24 anchor integration. + * Uses a lightweight Pact-style mock provider instead of the full Pact library + * to keep dependencies minimal. The "pact" (expected request/response shapes) + * is defined inline and can be extracted to a JSON file for CI verification. + * + * Covers: + * - GET /.well-known/stellar.toml (SEP-1) + * - POST /sep24/transactions/deposit/interactive (SEP-24 initiate deposit) + * - POST /sep24/transactions/withdraw/interactive (SEP-24 initiate withdrawal) + * - GET /sep24/transaction?id= (SEP-24 poll status) + * + * #603 + */ +import { StellarAnchorService } from '../stellarAnchorService'; + +// ─── Shared contract shapes ─────────────────────────────────────────────────── + +const TOML_RESPONSE = ` +TRANSFER_SERVER_SEP0024="https://mock-anchor.example.com/sep24" +WEB_AUTH_ENDPOINT="https://mock-anchor.example.com/auth" +`; + +const SEP24_INFO_RESPONSE = { + deposit: { + USDC: { + enabled: true, + fee_fixed: '0.00', + min_amount: '10.00', + max_amount: '10000.00', + }, + }, + withdraw: { + USDC: { + enabled: true, + fee_fixed: '0.00', + min_amount: '10.00', + max_amount: '10000.00', + }, + }, +}; + +const DEPOSIT_INTERACTIVE_RESPONSE = { + id: 'anchor-tx-deposit-001', + url: 'https://mock-anchor.example.com/sep24/interactive?token=abc', + type: 'interactive_customer_info_needed', +}; + +const WITHDRAWAL_INTERACTIVE_RESPONSE = { + id: 'anchor-tx-withdraw-001', + url: 'https://mock-anchor.example.com/sep24/interactive?token=xyz', + type: 'interactive_customer_info_needed', +}; + +const TRANSACTION_PENDING_RESPONSE = { + transaction: { + id: 'anchor-tx-deposit-001', + kind: 'deposit', + status: 'pending_user_transfer_start', + amount_in: '', + amount_out: '', + message: 'Waiting for user to initiate transfer', + }, +}; + +const TRANSACTION_COMPLETE_RESPONSE = { + transaction: { + id: 'anchor-tx-deposit-001', + kind: 'deposit', + status: 'completed', + amount_in: '100.00', + amount_out: '99.50', + stellar_transaction_id: 'stellar-hash-abc123', + message: '', + }, +}; + +// ─── Mock provider fetch ────────────────────────────────────────────────────── + +function buildMockFetch(overrides?: { + pollResponse?: typeof TRANSACTION_PENDING_RESPONSE | typeof TRANSACTION_COMPLETE_RESPONSE; + depositResponse?: typeof DEPOSIT_INTERACTIVE_RESPONSE; +}) { + return jest.fn().mockImplementation((input: string, init?: RequestInit) => { + const url = String(input); + + // SEP-1 TOML + if (url.includes('stellar.toml')) { + return Promise.resolve({ + ok: true, + status: 200, + text: async () => TOML_RESPONSE, + json: async () => ({}), + } as Response); + } + + // SEP-10 challenge + if (url.includes('/auth') && (!init || init.method !== 'POST')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ transaction: 'challenge-xdr' }), + text: async () => '', + } as Response); + } + + // SEP-10 token + if (url.includes('/auth') && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ token: 'mock-jwt-token' }), + text: async () => '', + } as Response); + } + + // SEP-24 info + if (url.includes('/sep24/info')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => SEP24_INFO_RESPONSE, + text: async () => '', + } as Response); + } + + // SEP-24 deposit interactive + if (url.includes('/deposit/interactive') && init?.method === 'POST') { + const resp = overrides?.depositResponse ?? DEPOSIT_INTERACTIVE_RESPONSE; + return Promise.resolve({ + ok: true, + status: 200, + json: async () => resp, + text: async () => '', + } as Response); + } + + // SEP-24 withdrawal interactive + if (url.includes('/withdraw/interactive') && init?.method === 'POST') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => WITHDRAWAL_INTERACTIVE_RESPONSE, + text: async () => '', + } as Response); + } + + // SEP-24 transaction poll + if (url.includes('/transaction?id=')) { + const pollResp = overrides?.pollResponse ?? TRANSACTION_COMPLETE_RESPONSE; + return Promise.resolve({ + ok: true, + status: 200, + json: async () => pollResp, + text: async () => '', + } as Response); + } + + return Promise.resolve({ + ok: false, + status: 404, + json: async () => ({}), + text: async () => 'Not found', + } as Response); + }); +} + +// ─── Contract tests ─────────────────────────────────────────────────────────── + +describe('SEP-24 Anchor Contract Tests (#603)', () => { + let service: StellarAnchorService; + let mockFetch: jest.Mock; + + beforeEach(() => { + service = new StellarAnchorService( + 'mock-anchor.example.com', + 'USDC', + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + ); + mockFetch = buildMockFetch(); + global.fetch = mockFetch; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('SEP-1: stellar.toml', () => { + it('response contains TRANSFER_SERVER_SEP0024', async () => { + const result = await service.initiateDeposit('user-1', 'GADDR', 'USD'); + // If TRANSFER_SERVER_SEP0024 is missing, initiateDeposit throws — reaching here confirms it's present + expect(result).toBeDefined(); + + const tomlCall = mockFetch.mock.calls.find(([url]: [string]) => + String(url).includes('stellar.toml'), + ); + expect(tomlCall).toBeDefined(); + }); + }); + + describe('SEP-24 /transactions/deposit/interactive', () => { + it('returns depositId, interactiveUrl, and assetCode', async () => { + const result = await service.initiateDeposit('user-1', 'GADDR', 'USD'); + + expect(result).toMatchObject({ + depositId: expect.any(String), + interactiveUrl: DEPOSIT_INTERACTIVE_RESPONSE.url, + assetCode: 'USDC', + currency: 'USD', + }); + }); + + it('sends asset_code and account in the POST body', async () => { + await service.initiateDeposit('user-1', 'GADDR_TEST', 'USD'); + + const depositCall = mockFetch.mock.calls.find(([url, init]: [string, RequestInit]) => { + return String(url).includes('/deposit/interactive') && init?.method === 'POST'; + }); + expect(depositCall).toBeDefined(); + const body = depositCall![1].body as string; + expect(body).toContain('asset_code=USDC'); + expect(body).toContain('account=GADDR_TEST'); + }); + }); + + describe('SEP-24 /transactions/withdraw/interactive', () => { + it('returns depositId, interactiveUrl, and assetCode for withdrawal', async () => { + const result = await service.initiateWithdrawal('user-1', 'GADDR', 'USD', '50.00'); + + expect(result).toMatchObject({ + depositId: expect.any(String), + interactiveUrl: WITHDRAWAL_INTERACTIVE_RESPONSE.url, + assetCode: 'USDC', + currency: 'USD', + }); + }); + + it('includes amount in the POST body for withdrawals', async () => { + await service.initiateWithdrawal('user-1', 'GADDR', 'USD', '75.00'); + + const withdrawCall = mockFetch.mock.calls.find(([url, init]: [string, RequestInit]) => { + return String(url).includes('/withdraw/interactive') && init?.method === 'POST'; + }); + expect(withdrawCall).toBeDefined(); + const body = withdrawCall![1].body as string; + expect(body).toContain('amount=75.00'); + }); + }); + + describe('SEP-24 /transaction poll status', () => { + it('returns completed status with amount_in and amount_out', async () => { + const { depositId } = await service.initiateDeposit('user-1', 'GADDR', 'USD'); + const record = await service.pollTransactionStatus(depositId); + + expect(record.status).toBe('completed'); + expect(record.amountIn).toBe('100.00'); + expect(record.amountOut).toBe('99.50'); + expect(record.stellarTxId).toBe('stellar-hash-abc123'); + }); + + it('returns pending status without failing', async () => { + const pendingMockFetch = buildMockFetch({ pollResponse: TRANSACTION_PENDING_RESPONSE }); + global.fetch = pendingMockFetch; + + const { depositId } = await service.initiateDeposit('user-1', 'GADDR', 'USD'); + const record = await service.pollTransactionStatus(depositId); + + expect(record.status).toBe('pending_user_transfer_start'); + }); + + it('returns cached state for terminal status without re-polling', async () => { + const { depositId } = await service.initiateDeposit('user-1', 'GADDR', 'USD'); + await service.pollTransactionStatus(depositId); // first poll → completed + + const callsBefore = mockFetch.mock.calls.length; + await service.pollTransactionStatus(depositId); // second poll → should use cache + const callsAfter = mockFetch.mock.calls.length; + + // No additional fetch calls for a terminal status + expect(callsAfter).toBe(callsBefore); + }); + }); + + describe('SEP-24 /info response shape', () => { + it('anchor info contains deposit and withdraw for USDC', () => { + // Validate the contract shape we rely on + expect(SEP24_INFO_RESPONSE.deposit).toHaveProperty('USDC'); + expect(SEP24_INFO_RESPONSE.withdraw).toHaveProperty('USDC'); + expect(SEP24_INFO_RESPONSE.deposit.USDC).toMatchObject({ + enabled: expect.any(Boolean), + fee_fixed: expect.any(String), + min_amount: expect.any(String), + max_amount: expect.any(String), + }); + }); + }); +}); diff --git a/backend/tests/integration/poolExhaustion.test.ts b/backend/tests/integration/poolExhaustion.test.ts new file mode 100644 index 00000000..da690840 --- /dev/null +++ b/backend/tests/integration/poolExhaustion.test.ts @@ -0,0 +1,39 @@ +/** + * Integration test: pool exhaustion fails fast after connectionTimeoutMillis. + * #596 + */ +import { Pool } from 'pg'; + +const CONNECTION_TIMEOUT_MS = 200; // short for test speed + +describe('PostgreSQL pool exhaustion', () => { + it('rejects new requests fast (not hang) when pool is exhausted', async () => { + const pool = new Pool({ + connectionString: + process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/petchain', + max: 2, + connectionTimeoutMillis: CONNECTION_TIMEOUT_MS, + idleTimeoutMillis: 1000, + }); + + // Grab all connections and hold them + let clientA: import('pg').PoolClient | null = null; + let clientB: import('pg').PoolClient | null = null; + + try { + clientA = await pool.connect(); + clientB = await pool.connect(); + + const start = Date.now(); + await expect(pool.connect()).rejects.toThrow(); + const elapsed = Date.now() - start; + + // Should fail within 2× the timeout, not hang + expect(elapsed).toBeLessThan(CONNECTION_TIMEOUT_MS * 2 + 100); + } finally { + clientA?.release(); + clientB?.release(); + await pool.end(); + } + }); +}); diff --git a/src/screens/DocumentVaultScreen.tsx b/src/screens/DocumentVaultScreen.tsx index d680e220..d4216764 100644 --- a/src/screens/DocumentVaultScreen.tsx +++ b/src/screens/DocumentVaultScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, @@ -25,6 +25,7 @@ import { uploadDocument, } from '../services/documentService'; import { useSecureScreen } from '../utils/secureScreen'; +import api from '../services/api'; const CATEGORIES: { label: string; value: DocumentCategory }[] = [ { label: 'Vaccination', value: 'vaccination' }, @@ -68,6 +69,56 @@ const DocumentVaultScreen: React.FC = ({ const [versions, setVersions] = useState([]); const [selectedDoc, setSelectedDoc] = useState(null); + // Health report async job state + const [reportJobId, setReportJobId] = useState(null); + const [reportJobStatus, setReportJobStatus] = useState< + 'idle' | 'queued' | 'processing' | 'complete' | 'failed' + >('idle'); + const reportPollRef = useRef | null>(null); + + const stopReportPolling = useCallback(() => { + if (reportPollRef.current) { + clearInterval(reportPollRef.current); + reportPollRef.current = null; + } + }, []); + + const handleGenerateReport = useCallback(async () => { + if (!petId.trim()) return Alert.alert('Error', 'Enter a Pet ID first'); + setReportJobStatus('queued'); + setReportJobId(null); + try { + const res = await api.post<{ jobId: string }>( + `/reports/pets/${petId.trim()}/health`, + ); + const jobId = res.data?.jobId; + if (!jobId) throw new Error('No jobId returned'); + setReportJobId(jobId); + + // Poll every 2 seconds until complete or failed + reportPollRef.current = setInterval(async () => { + try { + const statusRes = await api.get<{ + status: 'queued' | 'processing' | 'complete' | 'failed'; + }>(`/reports/${jobId}/status`); + const status = statusRes.data?.status ?? 'queued'; + setReportJobStatus(status); + if (status === 'complete' || status === 'failed') { + stopReportPolling(); + } + } catch { + stopReportPolling(); + setReportJobStatus('failed'); + } + }, 2000); + } catch (err) { + setReportJobStatus('failed'); + Alert.alert('Report Error', err instanceof Error ? err.message : 'Failed to start report'); + } + }, [petId, stopReportPolling]); + + useEffect(() => () => stopReportPolling(), [stopReportPolling]); + const loadDocuments = useCallback(async () => { if (!petId.trim()) return; setLoading(true); @@ -348,6 +399,45 @@ const DocumentVaultScreen: React.FC = ({ + {/* Health Report async generation */} + + + {reportJobStatus === 'queued' || reportJobStatus === 'processing' ? ( + + + + {reportJobStatus === 'queued' ? 'Queued…' : 'Generating…'} + + + ) : ( + 📊 Generate Health Report + )} + + {reportJobStatus === 'complete' && reportJobId && ( + + Alert.alert('Report Ready', `Download at: /api/reports/${reportJobId}/download`) + } + accessibilityLabel="Download completed health report" + > + ⬇ Download PDF + + )} + {reportJobStatus === 'failed' && ( + Report generation failed. Tap above to retry. + )} + + {/* Upload modal */}