Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .maestro/flows/sos.yaml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions .maestro/scripts/clearEmergencyContacts.js
Original file line number Diff line number Diff line change
@@ -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' },
});
10 changes: 10 additions & 0 deletions .maestro/scripts/mockEmergencyApi.js
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 20 additions & 3 deletions backend/config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion backend/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down
195 changes: 166 additions & 29 deletions backend/server/routes/reports.ts
Original file line number Diff line number Diff line change
@@ -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<string, Buffer>();

async function saveJob(job: JobRecord): Promise<void> {
const redis = getRedisClient();
await redis.set(`${JOB_KEY_PREFIX}${job.jobId}`, JSON.stringify(job), 'EX', JOB_TTL_SECONDS);
}

async function loadJob(jobId: string): Promise<JobRecord | null> {
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;
Loading
Loading