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
72 changes: 72 additions & 0 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,78 @@ def _ensure_schema_compatibility(app: Flask) -> None:
NOT NULL DEFAULT 'INR'
"""
)
# Background jobs table (idempotent migration)
cur.execute(
"""
DO $$ BEGIN
CREATE TYPE job_status AS ENUM ('PENDING','RUNNING','SUCCESS','FAILED','DEAD_LETTER');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS background_jobs (
id SERIAL PRIMARY KEY,
job_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
status job_status NOT NULL DEFAULT 'PENDING',
attempt INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
next_run_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_error TEXT,
result JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP
);
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_bg_jobs_status_next_run ON background_jobs(status, next_run_at);"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_bg_jobs_type ON background_jobs(job_type);"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_bg_jobs_created ON background_jobs(created_at DESC);"
)
# Savings goals tables (Issue #133)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS savings_goals (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
target_amount NUMERIC(12,2) NOT NULL,
current_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
deadline DATE,
category VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, status);"
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS savings_contributions (
id SERIAL PRIMARY KEY,
goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
note VARCHAR(500),
contributed_at DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_savings_contributions_goal ON savings_contributions(goal_id, contributed_at DESC);"
)
conn.commit()
except Exception:
app.logger.exception(
Expand Down
27 changes: 27 additions & 0 deletions packages/backend/app/db/migrations/001_background_jobs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Background job tracking for resilient retry & monitoring
-- Part of: Resilient background job retry & monitoring (Issue #130)

DO $$ BEGIN
CREATE TYPE job_status AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED', 'DEAD_LETTER');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

CREATE TABLE IF NOT EXISTS background_jobs (
id SERIAL PRIMARY KEY,
job_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
status job_status NOT NULL DEFAULT 'PENDING',
attempt INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
next_run_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_error TEXT,
result JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_bg_jobs_status_next_run ON background_jobs(status, next_run_at);
CREATE INDEX IF NOT EXISTS idx_bg_jobs_type ON background_jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_bg_jobs_created ON background_jobs(created_at DESC);
26 changes: 26 additions & 0 deletions packages/backend/app/db/migrations/002_savings_goals.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Goal-based savings tracking & milestones (Issue #133)

CREATE TABLE IF NOT EXISTS savings_goals (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
target_amount NUMERIC(12,2) NOT NULL,
current_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
deadline DATE,
category VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, status);

CREATE TABLE IF NOT EXISTS savings_contributions (
id SERIAL PRIMARY KEY,
goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
note VARCHAR(500),
contributed_at DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_contributions_goal ON savings_contributions(goal_id, contributed_at DESC);
51 changes: 51 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,54 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Background job tracking for resilient retry & dead-letter support
DO $$ BEGIN
CREATE TYPE job_status AS ENUM ('PENDING','RUNNING','SUCCESS','FAILED','DEAD_LETTER');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

CREATE TABLE IF NOT EXISTS background_jobs (
id SERIAL PRIMARY KEY,
job_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
status job_status NOT NULL DEFAULT 'PENDING',
attempt INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
next_run_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_error TEXT,
result JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bg_jobs_status_next_run ON background_jobs(status, next_run_at);
CREATE INDEX IF NOT EXISTS idx_bg_jobs_type ON background_jobs(job_type);
CREATE INDEX IF NOT EXISTS idx_bg_jobs_created ON background_jobs(created_at DESC);

-- Goal-based savings tracking & milestones (Issue #133)
CREATE TABLE IF NOT EXISTS savings_goals (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
target_amount NUMERIC(12,2) NOT NULL,
current_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
deadline DATE,
category VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, status);

CREATE TABLE IF NOT EXISTS savings_contributions (
id SERIAL PRIMARY KEY,
goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
note VARCHAR(500),
contributed_at DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_contributions_goal ON savings_contributions(goal_id, contributed_at DESC);
74 changes: 74 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,77 @@ class AuditLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class JobStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
DEAD_LETTER = "DEAD_LETTER"


class BackgroundJob(db.Model):
"""Tracks background job execution with retry support."""

__tablename__ = "background_jobs"

id = db.Column(db.Integer, primary_key=True)
job_type = db.Column(db.String(100), nullable=False, index=True)
payload = db.Column(db.JSON, nullable=False, default=dict)
status = db.Column(
SAEnum(JobStatus), nullable=False, default=JobStatus.PENDING, index=True
)
attempt = db.Column(db.Integer, nullable=False, default=0)
max_retries = db.Column(db.Integer, nullable=False, default=3)
next_run_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
last_error = db.Column(db.Text, nullable=True)
result = db.Column(db.JSON, nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
completed_at = db.Column(db.DateTime, nullable=True)


class SavingsGoalStatus(str, Enum):
ACTIVE = "ACTIVE"
COMPLETED = "COMPLETED"
PAUSED = "PAUSED"


class SavingsGoal(db.Model):
"""A savings goal with a target amount and optional deadline."""

__tablename__ = "savings_goals"

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
target_amount = db.Column(db.Numeric(12, 2), nullable=False)
current_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
currency = db.Column(db.String(10), nullable=False, default="INR")
deadline = db.Column(db.Date, nullable=True)
category = db.Column(db.String(100), nullable=True)
status = db.Column(
db.String(20), nullable=False, default=SavingsGoalStatus.ACTIVE.value
)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)

contributions = db.relationship(
"SavingsContribution", backref="goal", lazy="dynamic", cascade="all, delete-orphan"
)


class SavingsContribution(db.Model):
"""A single contribution toward a savings goal."""

__tablename__ = "savings_contributions"

id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(
db.Integer, db.ForeignKey("savings_goals.id", ondelete="CASCADE"), nullable=False, index=True
)
amount = db.Column(db.Numeric(12, 2), nullable=False)
note = db.Column(db.String(500), nullable=True)
contributed_at = db.Column(db.Date, nullable=False, default=date.today)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
19 changes: 19 additions & 0 deletions packages/backend/app/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ def __init__(self) -> None:
["event", "channel", "status"],
registry=self.registry,
)
self.job_events_total = Counter(
"finmind_job_events_total",
"Background job lifecycle events.",
["event", "job_type", "status"],
registry=self.registry,
)

def observe_http_request(
self, method: str, endpoint: str, status_code: int, duration_seconds: float
Expand All @@ -79,6 +85,13 @@ def record_reminder_event(
event=event, channel=channel, status=status
).inc()

def record_job_event(
self, event: str, job_type: str, status: str = "ok"
) -> None:
self.job_events_total.labels(
event=event, job_type=job_type, status=status
).inc()

def metrics_response(self) -> Response:
if self.multiprocess_enabled:
registry = CollectorRegistry()
Expand Down Expand Up @@ -137,3 +150,9 @@ def track_reminder_event(event: str, channel: str, status: str = "ok") -> None:
obs = current_app.extensions.get("observability")
if obs:
obs.record_reminder_event(event=event, channel=channel, status=status)


def track_job_event(event: str, job_type: str, status: str = "ok") -> None:
obs = current_app.extensions.get("observability")
if obs:
obs.record_job_event(event=event, job_type=job_type, status=status)
Loading