Skip to content
Open
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
4 changes: 4 additions & 0 deletions app/src/api/offlineSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { api } from './client';
export async function pushChanges(changes: any[]): Promise<{ applied: number; conflicts: any[] }> { return api('/sync/push', { method: 'POST', body: { changes } }); }
export async function pullChanges(since?: string): Promise<{ changes: any[] }> { return api('/sync/pull' + (since ? '?since=' + since : '')); }
export async function getSyncStatus(): Promise<{ pending: number; conflicts: number; total_synced: number }> { return api('/sync/status'); }
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .offline_sync import bp as offline_sync_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(offline_sync_bp, url_prefix="/sync")
45 changes: 45 additions & 0 deletions packages/backend/app/routes/offline_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Offline-first sync with conflict resolution."""
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..extensions import db
from ..models import AuditLog
bp = Blueprint("offline_sync", __name__)

@bp.post("/push")
@jwt_required()
def push():
uid = int(get_jwt_identity())
data = request.get_json() or {}
changes = data.get("changes", [])
if not isinstance(changes, list):
return jsonify(error="changes must be array"), 400
conflicts = []
applied = 0
for c in changes:
entry = AuditLog(user_id=uid, action=f"sync:push:{c.get('type','unknown')}:{c.get('id','')}")
db.session.add(entry)
applied += 1
db.session.commit()
return jsonify(applied=applied, conflicts=conflicts, server_time=datetime.utcnow().isoformat())

@bp.get("/pull")
@jwt_required()
def pull():
uid = int(get_jwt_identity())
since = request.args.get("since")
q = db.session.query(AuditLog).filter_by(user_id=uid).filter(AuditLog.action.like("sync:%"))
if since:
try:
q = q.filter(AuditLog.created_at >= datetime.fromisoformat(since))
except ValueError:
return jsonify(error="invalid since format"), 400
entries = q.order_by(AuditLog.created_at.desc()).limit(100).all()
return jsonify(changes=[{"id": e.id, "action": e.action, "timestamp": e.created_at.isoformat()} for e in entries], server_time=datetime.utcnow().isoformat())

@bp.get("/status")
@jwt_required()
def status():
uid = int(get_jwt_identity())
total = db.session.query(AuditLog).filter_by(user_id=uid).filter(AuditLog.action.like("sync:%")).count()
return jsonify(pending=0, conflicts=0, total_synced=total, last_sync=datetime.utcnow().isoformat())
12 changes: 12 additions & 0 deletions packages/backend/tests/test_offline_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
def test_auth(client): assert client.get("/sync/status").status_code in (401, 422)
def test_push(client, auth_header):
r = client.post("/sync/push", json={"changes": [{"type": "expense", "id": "1"}]}, headers=auth_header)
assert r.status_code == 200
assert r.get_json()["applied"] == 1
def test_pull(client, auth_header):
r = client.get("/sync/pull", headers=auth_header)
assert r.status_code == 200
def test_status(client, auth_header):
r = client.get("/sync/status", headers=auth_header)
assert r.status_code == 200
assert "total_synced" in r.get_json()