feat(pwa): implement real offline mode with IndexedDB sync

Add genuine offline read/write capability for trips:

- Dexie IndexedDB schema (trips, places, packing, todo, budget,
  reservations, files, mutationQueue, syncMeta, blobCache)
- Repo layer for all domains: offline reads from Dexie, writes
  optimistically to Dexie and enqueue mutations for later replay
- Mutation queue with UUID idempotency keys (X-Idempotency-Key),
  FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx
- Trip sync manager: caches all trips with end_date >= today or null,
  auto-evicts 7d after end_date, fetches bundle endpoint in one request
- Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap,
  warms SW cache via fetch
- Sync triggers: network online → flush + syncAll; WS reconnect →
  flush only (rate-limiter safe); visibilitychange/30s → flush only
- WS remoteEventHandler writes through to Dexie on every event
- Server idempotency middleware + idempotency_keys table (migration 100,
  24h TTL nightly cleanup)
- GET /api/trips/:id/bundle endpoint for efficient single-request sync
- OfflineBanner component: amber (offline) / blue (syncing) / hidden
- OfflineTab in Settings: cached trip list, re-sync and clear actions
- usePendingMutations hook for per-item pending indicators

Closes #505 #541
This commit is contained in:
jubnl
2026-04-14 23:04:13 +02:00
parent 8c7567faf3
commit b194e8317d
64 changed files with 3837 additions and 638 deletions
+441 -533
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -1578,6 +1578,22 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
},
// Migration 100: Idempotency keys for offline mutation replay
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_body TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
PRIMARY KEY (key, user_id)
);
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
`);
},
];
if (currentVersion < migrations.length) {
+1
View File
@@ -48,6 +48,7 @@ const server = app.listen(PORT, () => {
scheduler.startTripReminders();
scheduler.startVersionCheck();
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
import('./websocket').then(({ setupWebSocket }) => {
+2 -1
View File
@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { applyIdempotency } from './idempotency';
export function extractToken(req: Request): string | null {
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
@@ -38,7 +39,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
return;
}
(req as AuthRequest).user = user;
next();
applyIdempotency(req, res, next, user.id);
};
/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
+59
View File
@@ -0,0 +1,59 @@
import { Request, Response, NextFunction } from 'express';
import { db } from '../db/database';
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
interface IdempotencyRow {
status_code: number;
response_body: string;
}
/**
* Called from within `authenticate` after req.user is set.
*
* For mutating requests carrying X-Idempotency-Key:
* - If (key, userId) already stored: replays the cached response.
* - Otherwise: wraps res.json to capture and store a successful response.
*
* Storing happens in idempotency_keys (24h TTL, cleaned by scheduler).
*/
export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void {
if (!MUTATING_METHODS.has(req.method)) {
next();
return;
}
const key = req.headers['x-idempotency-key'] as string | undefined;
if (!key) {
next();
return;
}
// Return cached response if key already processed for this user
const existing = db.prepare(
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ?'
).get(key, userId) as IdempotencyRow | undefined;
if (existing) {
res.status(existing.status_code).json(JSON.parse(existing.response_body));
return;
}
// Wrap res.json to capture the response on first successful execution
const originalJson = res.json.bind(res);
res.json = function (body: unknown): Response {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
db.prepare(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(key, userId, req.method, req.path, res.statusCode, JSON.stringify(body), Math.floor(Date.now() / 1000));
} catch {
// Non-fatal: if storage fails, the request still succeeds
}
}
return originalJson(body);
};
next();
}
+37
View File
@@ -29,6 +29,13 @@ import {
ValidationError,
TRIP_SELECT,
} from '../services/tripService';
import { listDays } from '../services/dayService';
import { listPlaces } from '../services/placeService';
import { listItems as listPackingItems } from '../services/packingService';
import { listItems as listTodoItems } from '../services/todoService';
import { listBudgetItems } from '../services/budgetService';
import { listReservations } from '../services/reservationService';
import { listFiles } from '../services/fileService';
const router = express.Router();
@@ -294,6 +301,36 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
res.json({ success: true });
});
// ── Offline bundle ────────────────────────────────────────────────────────
// Returns all trip sub-collections in a single request for offline caching.
router.get('/:id/bundle', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const tripId = req.params.id;
const trip = getTrip(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const { days } = listDays(tripId);
const places = listPlaces(String(tripId), {});
const packingItems = listPackingItems(tripId);
const todoItems = listTodoItems(tripId);
const budgetItems = listBudgetItems(tripId);
const reservations = listReservations(tripId);
const files = listFiles(tripId, false);
res.json({
trip,
days,
places,
packingItems,
todoItems,
budgetItems,
reservations,
files,
});
});
// ── ICS calendar export ───────────────────────────────────────────────────
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
+25 -1
View File
@@ -230,11 +230,35 @@ function startVersionCheck(): void {
}, { timezone: tz });
}
// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours
let idempotencyCleanupTask: ScheduledTask | null = null;
function startIdempotencyCleanup(): void {
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
const tz = process.env.TZ || 'UTC';
idempotencyCleanupTask = cron.schedule('0 3 * * *', () => {
try {
const { db } = require('./db/database');
const cutoff = Math.floor(Date.now() / 1000) - 86400;
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
if (result.changes > 0) {
const { logInfo: li } = require('./services/auditLog');
li(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
}
} catch (err: unknown) {
const { logError: le } = require('./services/auditLog');
le(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS };
+72
View File
@@ -892,3 +892,75 @@ describe('Copy trip with data', () => {
expect(newNotes[0].text).toBe('Pack early!');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bundle endpoint — GET /api/trips/:id/bundle
// ─────────────────────────────────────────────────────────────────────────────
describe('Trip bundle', () => {
it('BUNDLE-001 — returns all sub-collections for owned trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-03' });
const res = await request(app)
.get(`/api/trips/${trip.id}/bundle`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.id).toBe(trip.id);
expect(Array.isArray(res.body.days)).toBe(true);
expect(res.body.days).toHaveLength(3);
expect(Array.isArray(res.body.places)).toBe(true);
expect(Array.isArray(res.body.packingItems)).toBe(true);
expect(Array.isArray(res.body.todoItems)).toBe(true);
expect(Array.isArray(res.body.budgetItems)).toBe(true);
expect(Array.isArray(res.body.reservations)).toBe(true);
expect(Array.isArray(res.body.files)).toBe(true);
});
it('BUNDLE-002 — returns 404 for trip that does not exist', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/trips/999999/bundle')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
it('BUNDLE-003 — returns 404 when user has no access to trip', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/bundle`)
.set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
});
it('BUNDLE-004 — members can fetch bundle', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/bundle`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.trip.id).toBe(trip.id);
});
it('BUNDLE-005 — returns 401 when unauthenticated', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app).get(`/api/trips/${trip.id}/bundle`);
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── In-memory store + DB mock using vi.hoisted ────────────────────────────────
const { rows, dbMock } = vi.hoisted(() => {
const rows: Record<string, { status_code: number; response_body: string }> = {};
const dbMock = {
db: {
prepare: vi.fn((sql: string) => ({
get: vi.fn((...args: unknown[]) => {
const [key, userId] = args;
return rows[`${key}:${userId}`] ?? undefined;
}),
run: vi.fn((...args: unknown[]) => {
const [key, userId, , , status_code, response_body] = args as [string, number, string, string, number, string];
const k = `${key}:${userId}`;
if (!rows[k]) rows[k] = { status_code, response_body };
}),
})),
},
};
return { rows, dbMock };
});
vi.mock('../../../src/db/database', () => dbMock);
import { applyIdempotency } from '../../../src/middleware/idempotency';
import type { Request, Response, NextFunction } from 'express';
function makeReq(method = 'POST', headers: Record<string, string> = {}): Request {
return { method, path: '/api/test', headers } as unknown as Request;
}
function makeRes(statusCode = 200): Response {
const ctx = { status: statusCode };
const res = {
get statusCode() { return ctx.status; },
status(code: number) { ctx.status = code; return res; },
json: vi.fn((_body: unknown) => res),
} as unknown as Response;
return res;
}
beforeEach(() => {
Object.keys(rows).forEach(k => delete rows[k]);
vi.clearAllMocks();
});
describe('applyIdempotency', () => {
it('calls next() for GET requests', () => {
const req = makeReq('GET', { 'x-idempotency-key': 'key1' });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).toHaveBeenCalledOnce();
});
it('calls next() when header is absent for POST', () => {
const req = makeReq('POST', {});
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).toHaveBeenCalledOnce();
});
it('replays cached response when key+user already stored', () => {
rows['cached-key:42'] = { status_code: 201, response_body: JSON.stringify({ id: 99 }) };
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 42);
expect(next).not.toHaveBeenCalled();
expect(res.json as ReturnType<typeof vi.fn>).toHaveBeenCalledWith({ id: 99 });
});
it('different user same key does NOT replay', () => {
rows['cached-key:1'] = { status_code: 200, response_body: JSON.stringify({ ok: true }) };
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 99); // different user
expect(next).toHaveBeenCalledOnce();
});
it('stores 2xx response on first execution via wrapped res.json', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'new-key' });
const res = makeRes(201);
const next = vi.fn(() => {
// Simulate handler calling res.json
(res.json as ReturnType<typeof vi.fn>)({ id: 5 });
});
applyIdempotency(req, res, next, 7);
expect(next).toHaveBeenCalledOnce();
expect(rows['new-key:7']).toBeDefined();
expect(rows['new-key:7'].status_code).toBe(201);
expect(JSON.parse(rows['new-key:7'].response_body)).toEqual({ id: 5 });
});
it('does NOT store 4xx responses', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'fail-key' });
const res = makeRes(422);
const next = vi.fn(() => {
(res.json as ReturnType<typeof vi.fn>)({ error: 'Invalid' });
});
applyIdempotency(req, res, next, 3);
expect(rows['fail-key:3']).toBeUndefined();
});
it('handles PUT, PATCH, and DELETE the same as POST', () => {
for (const method of ['PUT', 'PATCH', 'DELETE'] as const) {
const req = makeReq(method, { 'x-idempotency-key': `key-${method}` });
const res = makeRes(200);
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).toHaveBeenCalled();
vi.clearAllMocks();
}
});
});