27 Commits

Author SHA1 Message Date
d6a913a896 feat(worker): add transaction saving to db 2025-10-29 18:11:53 +01:00
d8ea25943c feat(code): remove sentry debug endpoint 2025-10-29 14:32:25 +01:00
06dcccb321 fix(tests): add missing dependencies 2025-10-29 14:28:25 +01:00
e916a57e4e fix(tests): move requirements.txt 2025-10-29 14:25:18 +01:00
7d2e94e683 feat(database): add encryption key 2025-10-29 14:23:14 +01:00
3348e0a035 feat(database): encrypt transactions data 2025-10-29 14:17:53 +01:00
Dejan Ribarovski
4f6d46ba7e Merge pull request #37 from dat515-2025/36-add-mock-databases-or-services-to-fetch-mocked-transactions
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Run Python Tests / build-and-test (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
feat(frontend): Added Mock Bank connection
2025-10-23 13:02:32 +02:00
Dejan Ribarovski
9fc8601e4d Update 7project/frontend/src/pages/Dashboard.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:39 +02:00
Dejan Ribarovski
e488771cc7 Update 7project/frontend/src/pages/MockBankModal.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:04 +02:00
ribardej
77992bab17 feat(docs): meeting update 2025-10-23 12:47:27 +02:00
ribardej
6972a03090 feat(frontend): Added Mock Bank connection 2025-10-23 12:08:28 +02:00
Dejan Ribarovski
6d7f834808 Merge pull request #35 from dat515-2025/34-improve-frontend-functionality
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Run Python Tests / build-and-test (push) Waiting to run
34 improve frontend functionality
2025-10-23 10:02:57 +02:00
ribardej
d5611e3e92 fix(frontend): Fixed type error 2025-10-23 09:57:15 +02:00
ribardej
5ecfc62b02 feat(frontend): improved UI 2025-10-23 09:22:10 +02:00
d0cbec5fca update report
Some checks are pending
Run Python Tests / build-and-test (push) Waiting to run
2025-10-22 21:52:14 +02:00
ribardej
82eb34c6e6 feat(frontend): improved Dashboard.tsx, added transaction date 2025-10-22 17:37:11 +02:00
Dejan Ribarovski
cddc1d3a9f Merge pull request #31 from dat515-2025/30-create-tests-and-set-up-a-github-pipeline
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Run Python Tests / build-and-test (push) Waiting to run
30 create tests and set up a GitHub pipeline
2025-10-22 14:59:54 +02:00
ribardej
e78b8c2e6b fix(tests): fixed a service test and one warning regarding 422 status
Some checks failed
Run Python Tests / build-and-test (push) Has been cancelled
2025-10-22 14:53:45 +02:00
ribardej
aade88beb9 fix(backend): added a default value for FRONTEND_DOMAIN_SCHEME so the tests dont crash 2025-10-22 14:48:56 +02:00
Dejan Ribarovski
5305531950 Merge branch 'main' into 30-create-tests-and-set-up-a-github-pipeline 2025-10-22 14:39:28 +02:00
ribardej
6d8a6a55c0 fix(backend): refactored app to fastApi to avoid CORS errors 2025-10-22 14:37:48 +02:00
396047574a fix(auth): increase timeout
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-10-21 22:43:30 +02:00
d926168ef9 fix(oauth): use prod MojeID 2025-10-21 22:24:39 +02:00
41956b8e0c Merge pull request #32 from dat515-2025/merge/fix_react_oauth
feat(oauth): add csas connection, allow oauth from react
2025-10-21 22:13:54 +02:00
Dejan Ribarovski
40d07677bd Potential fix for code scanning alert no. 11: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-21 15:57:23 +02:00
ribardej
76eb2cce41 feat(tests): Added tests to PR and Prod workflows 2025-10-21 15:44:33 +02:00
ribardej
391e9da0c4 feat(tests): Implemented basic tests and github workflow 2025-10-21 15:36:49 +02:00
38 changed files with 1539 additions and 101 deletions

View File

@@ -9,6 +9,29 @@ permissions:
pull-requests: write
jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build:
if: github.event.action != 'closed'
name: Build and push image (reusable)
@@ -95,7 +118,8 @@ jobs:
--set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \
--set image.digest="$DIGEST" \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD"
--set-string database.password="$DB_PASSWORD" \
--set-string database.encryptionSecret="$PR"
- name: Post preview URLs as PR comment
uses: actions/github-script@v7

View File

@@ -21,6 +21,29 @@ concurrency:
cancel-in-progress: false
jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build:
name: Build and push image (reusable)
uses: ./.github/workflows/build-image.yaml
@@ -107,3 +130,4 @@ jobs:
--set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \
--set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \
--set-string database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}"

60
.github/workflows/run-tests.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Run Python Tests
permissions:
contents: read
# -----------------
# --- Triggers ----
# -----------------
# This section defines when the workflow will run.
on:
# Run on every push to the 'main' branch
push:
branches: [ "main", "30-create-tests-and-set-up-a-github-pipeline" ]
# Also run on every pull request that targets the 'main' branch
pull_request:
branches: [ "main" ]
# -----------------
# ------ Jobs -----
# -----------------
# A workflow is made up of one or more jobs that can run in parallel or sequentially.
jobs:
# A descriptive name for your job
build-and-test:
# Specifies the virtual machine to run the job on. 'ubuntu-latest' is a common and cost-effective choice.
runs-on: ubuntu-latest
# -----------------
# ----- Steps -----
# -----------------
# A sequence of tasks that will be executed as part of the job.
steps:
# Step 1: Check out your repository's code
# This action allows the workflow to access your code.
- name: Check out repository code
uses: actions/checkout@v4
# Step 2: Set up the Python environment
# This action installs a specific version of Python on the runner.
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11' # Use the Python version that matches your project
# Step 3: Install project dependencies
# Runs shell commands to install the libraries listed in your requirements.txt.
- name: Add test dependencies to requirements
run: |
echo "pytest==8.4.2" >> ./7project/backend/requirements.txt
echo "pytest-asyncio==1.2.0" >> ./7project/backend/requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r ./7project/backend/requirements.txt
# Step 4: Run your tests!
# Executes the pytest command to run your test suite.
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend

View File

@@ -0,0 +1,32 @@
"""add date to transaction
Revision ID: 1f2a3c4d5e6f
Revises: eabec90a94fe
Create Date: 2025-10-22 16:18:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import func
# revision identifiers, used by Alembic.
revision: str = '1f2a3c4d5e6f'
down_revision: Union[str, Sequence[str], None] = 'eabec90a94fe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema by adding date column with server default current_date."""
op.add_column(
'transaction',
sa.Column('date', sa.Date(), nullable=False, server_default=sa.text('CURRENT_DATE'))
)
def downgrade() -> None:
"""Downgrade schema by removing date column."""
op.drop_column('transaction', 'date')

View File

@@ -0,0 +1,47 @@
"""Add encrypted type
Revision ID: 46b9e702e83f
Revises: 1f2a3c4d5e6f
Create Date: 2025-10-29 13:26:24.568523
"""
from typing import Sequence, Union
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '46b9e702e83f'
down_revision: Union[str, Sequence[str], None] = '1f2a3c4d5e6f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('transaction', 'amount',
existing_type=mysql.FLOAT(),
type_=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
existing_nullable=False)
op.alter_column('transaction', 'description',
existing_type=mysql.VARCHAR(length=255),
type_=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('transaction', 'description',
existing_type=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
type_=mysql.VARCHAR(length=255),
existing_nullable=True)
op.alter_column('transaction', 'amount',
existing_type=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
type_=mysql.FLOAT(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.categories import Category
from app.schemas.category import CategoryCreate, CategoryRead
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
@@ -43,6 +43,37 @@ async def list_categories(
return list(res.scalars())
@router.patch("/{category_id}", response_model=CategoryRead)
async def update_category(
category_id: int,
payload: CategoryUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
category = res.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# If name changed, check uniqueness per user
if payload.name is not None and payload.name != category.name:
dup = await session.execute(
select(Category.id).where(Category.user_id == user.id, Category.name == payload.name)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Category with this name already exists")
category.name = payload.name
if payload.description is not None:
category.description = payload.description
await session.commit()
await session.refresh(category)
return category
@router.get("/{category_id}", response_model=CategoryRead)
async def get_category(
category_id: int,

View File

@@ -1,7 +1,8 @@
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.transaction import Transaction
@@ -23,6 +24,7 @@ def _to_read_model(tx: Transaction) -> TransactionRead:
id=tx.id,
amount=tx.amount,
description=tx.description,
date=tx.date,
category_ids=[c.id for c in (tx.categories or [])],
)
@@ -33,7 +35,21 @@ async def create_transaction(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id)
# Build transaction; set `date` only if provided to let DB default apply otherwise
tx_kwargs = dict(
amount=payload.amount,
description=payload.description,
user_id=user.id,
)
if payload.date is not None:
parsed_date = payload.date
if isinstance(parsed_date, str):
try:
parsed_date = date.fromisoformat(parsed_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx_kwargs["date"] = parsed_date
tx = Transaction(**tx_kwargs)
# Attach categories if provided (and owned by user)
if payload.category_ids:
@@ -60,11 +76,18 @@ async def create_transaction(
@router.get("/", response_model=List[TransactionRead])
async def list_transactions(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute(
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id)
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
)
txs = list(res.scalars())
# Eagerly load categories for each transaction
@@ -73,6 +96,36 @@ async def list_transactions(
return [_to_read_model(tx) for tx in txs]
@router.get("/balance_series")
async def get_balance_series(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute(
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
)
txs = list(res.scalars())
# Group by date and accumulate
daily = {}
for tx in txs:
key = tx.date.isoformat() if hasattr(tx.date, 'isoformat') else str(tx.date)
daily[key] = daily.get(key, 0.0) + float(tx.amount)
# Build cumulative series sorted by date
series = []
running = 0.0
for d in sorted(daily.keys()):
running += daily[d]
series.append({"date": d, "balance": running})
return series
@router.get("/{transaction_id}", response_model=TransactionRead)
async def get_transaction(
transaction_id: int,
@@ -111,6 +164,14 @@ async def update_transaction(
tx.amount = payload.amount
if payload.description is not None:
tx.description = payload.description
if payload.date is not None:
new_date = payload.date
if isinstance(new_date, str):
try:
new_date = date.fromisoformat(new_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx.date = new_date
if payload.category_ids is not None:
# Preload categories to avoid async lazy-load during assignment

View File

@@ -27,7 +27,9 @@ sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
send_default_pii=True,
)
fastApi = FastAPI()
app = fastApi
# CORS for frontend dev server
fastApi.add_middleware(
@@ -73,7 +75,7 @@ fastApi.include_router(
auth_backend,
"SECRET",
associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/mojeid/callback",
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/mojeid/callback",
),
prefix="/auth/mojeid",
tags=["auth"],
@@ -85,7 +87,7 @@ fastApi.include_router(
auth_backend,
"SECRET",
associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/bankid/callback",
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/bankid/callback",
),
prefix="/auth/bankid",
tags=["auth"],
@@ -103,10 +105,6 @@ async def root():
async def authenticated_route(user: User = Depends(current_active_verified_user)):
return {"message": f"Hello {user.email}!"}
@fastApi.get("/sentry-debug")
async def trigger_error():
division_by_zero = 1 / 0
@fastApi.get("/debug/scrape/csas/all", tags=["debug"])
async def debug_scrape_csas_all():

View File

@@ -1,15 +1,22 @@
import os
from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, Float, ForeignKey
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Date, func
from sqlalchemy.orm import relationship
from sqlalchemy_utils import EncryptedType
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from app.core.base import Base
from app.models.categories import association_table
SECRET_KEY = os.environ.get("DB_ENCRYPTION_KEY", "localdev")
class Transaction(Base):
__tablename__ = "transaction"
id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Float, nullable=False)
description = Column(String(length=255), nullable=True)
amount = Column(EncryptedType(Float, SECRET_KEY, engine=FernetEngine), nullable=False)
description = Column(EncryptedType(String(length=255), SECRET_KEY, engine=FernetEngine), nullable=True)
date = Column(Date, nullable=False, server_default=func.current_date())
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
# Relationship

View File

@@ -11,7 +11,7 @@ class MojeIDOAuth(CustomOpenID):
super().__init__(
client_id,
client_secret,
"https://mojeid.regtest.nic.cz/.well-known/openid-configuration/",
"https://mojeid.cz/.well-known/openid-configuration/",
"MojeID",
base_scopes=["openid", "email", "profile"],
)

View File

@@ -11,6 +11,11 @@ class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class CategoryRead(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,10 +1,13 @@
from typing import List, Optional
from typing import List, Optional, Union
from datetime import date
from pydantic import BaseModel, Field, ConfigDict
class TransactionBase(BaseModel):
amount: float = Field(..., gt=-1e18, lt=1e18)
description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
class TransactionCreate(TransactionBase):
category_ids: Optional[List[int]] = None
@@ -12,10 +15,12 @@ class TransactionCreate(TransactionBase):
class TransactionUpdate(BaseModel):
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
category_ids: Optional[List[int]] = None
class TransactionRead(TransactionBase):
id: int
category_ids: List[int] = []
date: Optional[Union[date, str]]
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,17 +1,18 @@
import json
import logging
from os.path import dirname, join
from time import strptime
from uuid import UUID
import httpx
from sqlalchemy import select
from app.core.db import async_session_maker
from app.models.transaction import Transaction
from app.models.user import User
logger = logging.getLogger(__name__)
# Reuse CSAS mTLS certs used by OAuth profile calls
OAUTH_DIR = join(dirname(__file__), "..", "oauth")
CERTS = (
join(OAUTH_DIR, "public_key.pem"),
@@ -20,10 +21,6 @@ CERTS = (
async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
"""
Async entry point to load Česká spořitelna transactions for a single user.
Validates the user_id and performs a minimal placeholder action.
"""
try:
uid = UUID(str(user_id))
except Exception:
@@ -34,9 +31,6 @@ async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
async def aload_all_ceska_sporitelna_transactions() -> None:
"""
Async entry point to load Česká spořitelna transactions for all users.
"""
async with async_session_maker() as session:
result = await session.execute(select(User))
users = result.unique().scalars().all()
@@ -54,7 +48,7 @@ async def aload_all_ceska_sporitelna_transactions() -> None:
async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
async with async_session_maker() as session:
async with (async_session_maker() as session):
result = await session.execute(select(User).where(User.id == user_id))
user: User = result.unique().scalar_one_or_none()
if user is None:
@@ -106,16 +100,22 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
if response.status_code != httpx.codes.OK:
continue
# Placeholder: just print the account transactions
transactions = response.json()["transactions"]
pass
for transaction in transactions:
#parse and store transaction to database
#create Transaction object and save to DB
#obj =
description = transaction.get("entryDetails", {}).get("transactionDetails", {}).get(
"additionalRemittanceInformation")
date_str = transaction.get("bookingDate", {}).get("date")
date = strptime(date_str, "%Y-%m-%d") if date_str else None
obj = Transaction(
amount=transaction['amount']['value'],
description=description,
date=date,
user_id=user_id,
)
session.add(obj)
await session.commit()
pass
pass

View File

@@ -102,7 +102,7 @@ bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
return JWTStrategy(secret=SECRET, lifetime_seconds=604800)
auth_backend = AuthenticationBackend(

View File

@@ -0,0 +1,2 @@
[tool.pytest.ini_options]
pythonpath = "."

View File

@@ -54,6 +54,7 @@ sentry-sdk==2.42.0
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.43
SQLAlchemy-Utils==0.42.0
starlette==0.48.0
tomli==2.2.1
typing-inspection==0.4.1

View File

@@ -0,0 +1,22 @@
import sys
import types
import pytest
from fastapi.testclient import TestClient
# Stub sentry_sdk to avoid optional dependency issues during import of app
stub = types.ModuleType("sentry_sdk")
stub.init = lambda *args, **kwargs: None
sys.modules.setdefault("sentry_sdk", stub)
# Import the FastAPI application
from app.app import fastApi as app # noqa: E402
@pytest.fixture(scope="session")
def fastapi_app():
return app
@pytest.fixture(scope="session")
def client(fastapi_app):
return TestClient(fastapi_app, raise_server_exceptions=True)

View File

@@ -0,0 +1,15 @@
from fastapi import status
def test_e2e_minimal_auth_flow(client):
# 1) Service is alive
alive = client.get("/")
assert alive.status_code == status.HTTP_200_OK
# 2) Attempt to login without payload should fail fast (validation error)
login = client.post("/auth/jwt/login")
assert login.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_CONTENT)
# 3) Protected endpoint should not be accessible without token
me = client.get("/users/me")
assert me.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)

View File

@@ -0,0 +1,18 @@
from fastapi import status
import pytest
def test_root_ok(client):
resp = client.get("/")
assert resp.status_code == status.HTTP_200_OK
assert resp.json() == {"status": "ok"}
def test_authenticated_route_requires_auth(client):
resp = client.get("/authenticated-route")
assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
def test_sentry_debug_raises_exception(client):
with pytest.raises(ZeroDivisionError):
client.get("/sentry-debug")

View File

@@ -0,0 +1,55 @@
import types
import asyncio
import pytest
from app.services import user_service
def test_get_oauth_provider_known_unknown():
# Known providers should return a provider instance
bankid = user_service.get_oauth_provider("BankID")
mojeid = user_service.get_oauth_provider("MojeID")
assert bankid is not None
assert mojeid is not None
# Unknown should return None
assert user_service.get_oauth_provider("DoesNotExist") is None
def test_get_jwt_strategy_lifetime():
strategy = user_service.get_jwt_strategy()
assert strategy is not None
# Basic smoke check: strategy has a lifetime set to 3600
assert getattr(strategy, "lifetime_seconds", None) in (604800,)
@pytest.mark.asyncio
async def test_on_after_request_verify_enqueues_email(monkeypatch):
calls = {}
def fake_enqueue_email(to: str, subject: str, body: str):
calls.setdefault("emails", []).append({
"to": to,
"subject": subject,
"body": body,
})
# Patch the enqueue_email used inside user_service
monkeypatch.setattr(user_service, "enqueue_email", fake_enqueue_email)
class DummyUser:
def __init__(self, email):
self.email = email
mgr = user_service.UserManager(user_db=None) # user_db not needed for this method
user = DummyUser("test@example.com")
# Call the hook
await mgr.on_after_request_verify(user, token="abc123", request=None)
# Verify one email has been enqueued with expected content
assert len(calls.get("emails", [])) == 1
email = calls["emails"][0]
assert email["to"] == "test@example.com"
assert "ověření účtu" in email["subject"].lower()
assert "abc123" in email["body"]

View File

@@ -101,6 +101,11 @@ spec:
secretKeyRef:
name: prod
key: SENTRY_DSN
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY
livenessProbe:
httpGet:
path: /

View File

@@ -18,3 +18,4 @@ stringData:
RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }}
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }}
DB_ENCRYPTION_KEY: {{ required "Set .Values.database.encryptionSecret" .Values.database.encryptionSecret | quote }}

View File

@@ -20,7 +20,7 @@ spec:
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
drop: [ "ALL" ]
command:
- celery
- -A
@@ -80,3 +80,8 @@ spec:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY

View File

@@ -75,3 +75,4 @@ database:
userName: app-demo-user
secretName: app-demo-database-secret
password: ""
encryptionSecret: ""

View File

@@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"recharts": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -1047,6 +1048,32 @@
"node": ">= 8"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.1.tgz",
"integrity": "sha512-sETJ3qO72y7L7WiR5K54UFLT3jRzAtqeBPVO15xC3bGA6kDqCH8m/v7BKCPH4czydXzz/1lPEGLvew7GjOO3Qw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
@@ -1362,6 +1389,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1407,6 +1446,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1435,7 +1537,7 @@
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1451,6 +1553,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
@@ -1929,6 +2037,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1982,9 +2099,130 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2003,6 +2241,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2017,6 +2261,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-toolkit": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.40.0.tgz",
"integrity": "sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
@@ -2260,6 +2514,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2463,6 +2723,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2490,6 +2760,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2925,6 +3204,36 @@
"react": "^19.2.0"
}
},
"node_modules/react-is": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2935,6 +3244,54 @@
"node": ">=0.10.0"
}
},
"node_modules/recharts": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3097,6 +3454,12 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3270,10 +3633,41 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"recharts": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -1 +0,0 @@
/* App-level styles moved to ui.css for a cleaner layout. */

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import './App.css';
import LoginRegisterPage from './pages/LoginRegisterPage';
import Dashboard from './pages/Dashboard';
import { logout } from './api';

View File

@@ -16,6 +16,7 @@ export type Transaction = {
amount: number;
description?: string | null;
category_ids: number[];
date?: string | null; // ISO date (YYYY-MM-DD)
};
function getBaseUrl() {
@@ -84,6 +85,7 @@ export type CreateTransactionInput = {
amount: number;
description?: string;
category_ids?: number[];
date?: string; // YYYY-MM-DD
};
export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> {
@@ -99,8 +101,13 @@ export async function createTransaction(input: CreateTransactionInput): Promise<
return res.json();
}
export async function getTransactions(): Promise<Transaction[]> {
const res = await fetch(`${getBaseUrl()}/transactions/`, {
export async function getTransactions(start_date?: string, end_date?: string): Promise<Transaction[]> {
const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/${qs ? `?${qs}` : ''}`;
const res = await fetch(url, {
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to load transactions');
@@ -153,3 +160,68 @@ export async function deleteMe(): Promise<void> {
export function logout() {
localStorage.removeItem('token');
}
// Categories
export type CreateCategoryInput = { name: string; description?: string };
export async function createCategory(input: CreateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/create`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to create category');
}
return res.json();
}
export type UpdateCategoryInput = { name?: string; description?: string };
export async function updateCategory(category_id: number, input: UpdateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/${category_id}`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update category');
}
return res.json();
}
// Transactions update
export type UpdateTransactionInput = {
amount?: number;
description?: string;
date?: string;
category_ids?: number[];
};
export async function updateTransaction(id: number, input: UpdateTransactionInput): Promise<Transaction> {
const res = await fetch(`${getBaseUrl()}/transactions/${id}/edit`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update transaction');
}
return res.json();
}
// Balance series
export type BalancePoint = { date: string; balance: number };
export async function getBalanceSeries(start_date?: string, end_date?: string): Promise<BalancePoint[]> {
const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/balance_series${qs ? `?${qs}` : ''}`;
const res = await fetch(url, { headers: getHeaders() });
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to load balance series');
}
return res.json();
}

View File

@@ -14,8 +14,8 @@ export function applyFontSize(size: FontSize) {
const root = document.documentElement;
const map: Record<FontSize, string> = {
small: '14px',
medium: '16px',
large: '18px',
medium: '18px',
large: '22px',
};
root.style.fontSize = map[size];
}

View File

@@ -0,0 +1,46 @@
// src/BalanceChart.tsx
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type BalancePoint } from '../api';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function BalanceChart({ data }: { data: BalancePoint[] }) {
if (data.length === 0) {
return <div>No data to display</div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
// Increased 'left' margin to create more space for the Y-axis label and tick values
margin={{ top: 5, right: 30, left: 50, bottom: 5 }} // <-- Change this line
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
label={{ value: 'Date', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tickFormatter={(value) => formatAmount(value as number)}
// Adjusted 'offset' for the Y-axis label.
// A negative offset moves it further away from the axis.
label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }} // <-- Change this line
/>
<Tooltip
labelFormatter={formatDate}
formatter={(value) => [formatAmount(value as number), 'Balance']}
/>
<Legend />
<Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,100 @@
// src/CategoryPieCharts.tsx (renamed from CategoryPieChart.tsx)
import { useMemo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type Transaction, type Category } from '../api';
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#AF19FF', '#FF4242', '#8884d8', '#82ca9d'];
// Helper component for a single pie chart
function SinglePieChart({ data, title }: { data: { name: string; value: number }[]; title: string }) {
if (data.length === 0) {
return (
<div style={{ flex: 1, textAlign: 'center' }}>
<h4>{title}</h4>
<div>No data to display.</div>
</div>
);
}
return (
<div style={{ flex: 1 }}>
<h4>{title}</h4>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
nameKey="name"
label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`}
>
{data.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value as number)} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}
export default function CategoryPieCharts({ transactions, categories }: { transactions: Transaction[], categories: Category[] }) {
// Calculate expenses data
const expensesData = useMemo(() => {
const spendingMap = new Map<number, number>();
transactions.forEach(tx => {
// Expenses are typically negative amounts in your system
if (tx.amount < 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
// Use absolute value for display on chart
spendingMap.set(catId, (spendingMap.get(catId) || 0) + Math.abs(tx.amount));
});
}
});
return Array.from(spendingMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
// Calculate earnings data
const earningsData = useMemo(() => {
const incomeMap = new Map<number, number>();
transactions.forEach(tx => {
// Earnings are typically positive amounts in your system
if (tx.amount > 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
incomeMap.set(catId, (incomeMap.get(catId) || 0) + tx.amount);
});
}
});
return Array.from(incomeMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifyContent: 'center' }}>
<SinglePieChart data={expensesData} title="Expenses by Category" />
<SinglePieChart data={earningsData} title="Earnings by Category" />
</div>
);
}

View File

@@ -1,7 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
import { type Category, type Transaction, type BalancePoint, createTransaction, getCategories, getTransactions, createCategory, updateTransaction, getBalanceSeries } from '../api';
import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage';
import BalanceChart from './BalanceChart';
import CategoryPieChart from './CategoryPieChart';
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL } from '../config';
function formatAmount(n: number) {
@@ -14,6 +17,8 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isMockModalOpen, setMockModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
// Start CSAS (George) OAuth after login
async function startOauthCsas() {
@@ -47,13 +52,42 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
const [searchText, setSearchText] = useState('');
// Date-range filter
const [startDate, setStartDate] = useState<string>(''); // YYYY-MM-DD
const [endDate, setEndDate] = useState<string>('');
// Pagination over filtered transactions (20 per page), 0 = latest (most recent)
const pageSize = 20;
const [page, setPage] = useState<number>(0);
// Balance chart series for current date filter
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
// Category creation form
const [newCatName, setNewCatName] = useState('');
const [newCatDesc, setNewCatDesc] = useState('');
// New transaction date
const [txDate, setTxDate] = useState<string>('');
// Inline edit state for transaction categories
const [editingTxId, setEditingTxId] = useState<number | null>(null);
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
async function loadAll() {
setLoading(true);
setError(null);
try {
const [txs, cats] = await Promise.all([getTransactions(), getCategories()]);
const [txs, cats, series] = await Promise.all([
getTransactions(startDate || undefined, endDate || undefined),
getCategories(),
getBalanceSeries(startDate || undefined, endDate || undefined),
]);
setTransactions(txs);
setCategories(cats);
setBalanceSeries(series);
// reset paging to most recent
setPage(0);
} catch (err: any) {
setError(err?.message || 'Failed to load data');
} finally {
@@ -61,15 +95,54 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
}
}
useEffect(() => { loadAll(); }, []);
async function handleGenerateMockTransactions(options: MockGenerationOptions) {
setIsGenerating(true);
setMockModalOpen(false);
const last10 = useMemo(() => {
const sorted = [...transactions].sort((a, b) => b.id - a.id);
return sorted.slice(0, 10);
}, [transactions]);
const { count, minAmount, maxAmount, startDate, endDate, categoryIds } = options;
const newTransactions: Transaction[] = [];
const startDateTime = new Date(startDate).getTime();
const endDateTime = new Date(endDate).getTime();
for (let i = 0; i < count; i++) {
// Generate random data based on user input
const amount = parseFloat((Math.random() * (maxAmount - minAmount) + minAmount).toFixed(2));
const randomTime = Math.random() * (endDateTime - startDateTime) + startDateTime;
const date = new Date(randomTime);
const dateString = date.toISOString().split('T')[0];
const randomCategory = categoryIds.length > 0
? [categoryIds[Math.floor(Math.random() * categoryIds.length)]]
: [];
const payload = {
amount,
date: dateString,
category_ids: randomCategory,
};
try {
const created = await createTransaction(payload);
newTransactions.push(created);
} catch (err) {
console.error("Failed to create mock transaction:", err);
alert('An error occurred while generating transactions. Check the console.');
break;
}
}
setIsGenerating(false);
alert(`${newTransactions.length} mock transactions were successfully generated!`);
await loadAll();
}
useEffect(() => { loadAll(); }, [startDate, endDate]);
const filtered = useMemo(() => {
let arr = last10;
let arr = [...transactions];
const min = minAmount !== '' ? Number(minAmount) : undefined;
const max = maxAmount !== '' ? Number(maxAmount) : undefined;
if (min !== undefined) arr = arr.filter(t => t.amount >= min);
@@ -77,7 +150,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number));
if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase()));
return arr;
}, [last10, minAmount, maxAmount, filterCategoryId, searchText]);
}, [transactions, minAmount, maxAmount, filterCategoryId, searchText]);
const sortedDesc = useMemo(() => {
return [...filtered].sort((a, b) => {
const ad = (a.date || '') > (b.date || '') ? 1 : (a.date || '') < (b.date || '') ? -1 : 0;
if (ad !== 0) return -ad; // date desc
return b.id - a.id; // fallback id desc
});
}, [filtered]);
const totalPages = Math.ceil(sortedDesc.length / pageSize);
const pageStart = page * pageSize;
const pageEnd = pageStart + pageSize;
const visible = sortedDesc.slice(pageStart, pageEnd);
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
@@ -88,16 +174,36 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
amount: Number(amount),
description: description || undefined,
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
date: txDate || undefined,
};
try {
const created = await createTransaction(payload);
setTransactions(prev => [created, ...prev]);
setAmount(''); setDescription(''); setSelectedCategoryId('');
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
} catch (err: any) {
alert(err?.message || 'Failed to create transaction');
}
}
function beginEditCategories(t: Transaction) {
setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]);
}
function cancelEditCategories() {
setEditingTxId(null);
setEditingCategoryIds([]);
}
async function saveEditCategories() {
if (editingTxId == null) return;
try {
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds });
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
cancelEditCategories();
} catch (err: any) {
alert(err?.message || 'Failed to update transaction categories');
}
}
return (
<div className="app-layout">
<aside className="sidebar">
@@ -119,16 +225,23 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y">
{current === 'home' && (
<>
<section className="card">
<h3>Bank connections</h3>
<p className="muted">Connect your CSAS (George) account.</p>
<button className="btn" onClick={startOauthCsas}>Connect CSAS (George)</button>
<section className="card space-y">
<h3>Bank connections</h3>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
<button className="btn primary" onClick={startOauthCsas}>Connect CSAS (George)</button>
</div>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
</div>
</section>
<section className="card">
<h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">No category</option>
@@ -138,9 +251,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</form>
</section>
<section className="card">
<h3>Categories</h3>
<form className="form-row" onSubmit={async (e) => { e.preventDefault(); if (!newCatName.trim()) return; try { const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined }); setCategories(prev => [...prev, cat]); setNewCatName(''); setNewCatDesc(''); } catch (err: any) { alert(err?.message || 'Failed to create category'); } }}>
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
<button className="btn primary" type="submit">Create category</button>
</form>
</section>
<section className="card">
<h3>Filters</h3>
<div className="form-row">
<div className="form-row" style={{ gap: 8, flexWrap: 'wrap' }}>
<input className="input" type="date" placeholder="Start date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<input className="input" type="date" placeholder="End date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
@@ -152,7 +276,30 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</section>
<section className="card">
<h3>Latest Transactions (last 10)</h3>
<h3>Balance over time</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
<BalanceChart data={balanceSeries} />
)}
</section>
{/* 3. Add the new section for the Category Pie Chart */}
<section className="card">
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
// Pass the filtered transactions to see the breakdown for the current view
<CategoryPieChart transactions={filtered} categories={categories} />
)}
</section>
<section className="card">
<h3>Transactions</h3>
{loading ? (
<div>Loading</div>
) : error ? (
@@ -160,26 +307,57 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
) : filtered.length === 0 ? (
<div>No transactions</div>
) : (
<table className="table">
<thead>
<tr>
<th>ID</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
</tr>
</thead>
<tbody>
{filtered.map(t => (
<tr key={t.id}>
<td>{t.id}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td>
<>
<div className="table-controls">
<div className="muted">
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
</div>
<div className="actions">
<button className="btn primary" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}>Previous</button>
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
</div>
</div>
<table className="table">
<thead>
<tr>
<th>Date</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{visible.map(t => (
<tr key={t.id}>
<td>{t.date || ''}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>
{editingTxId === t.id ? (
<div className="space-y" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select multiple className="input" value={editingCategoryIds.map(String)} onChange={(e) => {
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
setEditingCategoryIds(opts);
}}>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button className="btn small" onClick={saveEditCategories}>Save</button>
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
</div>
) : (
<div className="space-x" style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
</>
@@ -195,6 +373,13 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
)}
</main>
</div>
<MockBankModal
isOpen={isMockModalOpen}
isGenerating={isGenerating}
categories={categories}
onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions}
/>
</div>
);
}

View File

@@ -0,0 +1,100 @@
// src/MockBankModal.tsx
import { useState } from 'react';
import { type Category } from '../api';
// Define the shape of the generation options
export interface MockGenerationOptions {
count: number;
minAmount: number;
maxAmount: number;
startDate: string;
endDate: string;
categoryIds: number[];
}
interface MockBankModalProps {
isOpen: boolean;
isGenerating: boolean;
categories: Category[]; // Pass in available categories
onClose: () => void;
onGenerate: (options: MockGenerationOptions) => void;
}
export default function MockBankModal({ isOpen, isGenerating, categories, onClose, onGenerate }: MockBankModalProps) {
// State for all the new form fields
const [count, setCount] = useState('10');
const [minAmount, setMinAmount] = useState('-200');
const [maxAmount, setMaxAmount] = useState('200');
const [startDate, setStartDate] = useState(() => new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]); // Default to one year ago
const [endDate, setEndDate] = useState(() => new Date().toISOString().split('T')[0]); // Default to today
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>([]);
if (!isOpen) return null;
function handleGenerateClick() {
const parsedCount = parseInt(count, 10);
const parsedMinAmount = parseFloat(minAmount);
const parsedMaxAmount = parseFloat(maxAmount);
const parsedStartDate = new Date(startDate);
const parsedEndDate = new Date(endDate);
// Validation
if (
isNaN(parsedCount) || parsedCount <= 0 ||
isNaN(parsedMinAmount) || isNaN(parsedMaxAmount) ||
parsedMaxAmount < parsedMinAmount ||
isNaN(parsedStartDate.getTime()) || isNaN(parsedEndDate.getTime()) ||
parsedEndDate < parsedStartDate
) {
alert(
"Please ensure:\n" +
"- Count is a positive number\n" +
"- Min and Max Amount are valid numbers, and Max >= Min\n" +
"- Start and End Date are valid, and End Date >= Start Date"
);
return;
}
const options: MockGenerationOptions = {
count: parsedCount,
minAmount: parsedMinAmount,
maxAmount: parsedMaxAmount,
startDate,
endDate,
categoryIds: selectedCategoryIds.map(Number),
};
onGenerate(options);
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Generate Mock Transactions</h3>
<p className="muted">
Customize the random transactions you'd like to import.
</p>
<div className="space-y">
<input className="input" type="number" value={count} onChange={(e) => setCount(e.target.value)} placeholder="Number of transactions" />
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="number" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} placeholder="Min amount" />
<input className="input" type="number" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="Max amount" />
</div>
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} placeholder="Earliest date" />
<input className="input" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} placeholder="Latest date" />
</div>
<select multiple className="input" style={{ height: '120px' }} value={selectedCategoryIds} onChange={(e) => setSelectedCategoryIds(Array.from(e.target.selectedOptions, option => option.value))}>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
</div>
<div className="actions" style={{ justifyContent: 'flex-end', marginTop: '16px' }}>
<button className="btn" onClick={onClose} disabled={isGenerating}>Cancel</button>
<button className="btn primary" onClick={handleGenerateClick} disabled={isGenerating}>
{isGenerating ? 'Generating...' : `Generate Transactions`}
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,27 +31,53 @@ body[data-theme="dark"] {
}
/* Layout */
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; }
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100vh; }
.sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; }
.sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
.nav { margin-top: 12px; display: grid; gap: 4px; }
.nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
.nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; }
.content { display: flex; flex-direction: column; height: 100%; }
.topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
.content { display: flex; flex-direction: column; overflow-y: auto; }
.topbar { height: 64px; display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
.topbar .user { color: var(--muted); }
.page { padding: 24px; max-width: 1100px; margin: auto; }
.page { padding: 24px; }
/* Cards */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; }
.card h3 { margin: 0 0 12px; }
/* Forms */
.input, select, textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); }
.input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); border-color: var(--primary); }
.input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background-color: var(--panel);
color: var(--muted);
/* Add these properties specifically for the select element */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
padding-right: 32px; /* Add space for the custom arrow */
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
cursor: pointer;
}
.input:focus, select:focus, textarea:focus {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-color: var(--primary);
}
.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); }
.form-row > * { min-width: 140px; }
.form-row > .btn {
justify-self: start;
}
.actions { display: flex; align-items: center; gap: 8px; }
/* Buttons */
@@ -59,12 +85,25 @@ body[data-theme="dark"] {
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-600); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn, .input, select, textarea, .nav a, .nav button, .segmented button {
transition: all 0.2s ease-in-out;
}
.btn.small {
padding: 4px 10px;
font-size: 0.875rem; /* 14px */
}
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); }
.table th { text-align: left; color: var(--muted); font-weight: 600; }
.table td.amount { text-align: right; font-variant-numeric: tabular-nums; }
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px; /* Adds some space above the table */
}
/* Segmented control */
.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); }
@@ -83,3 +122,32 @@ body.auth-page #root {
/* Utility */
.muted { color: var(--muted); }
.space-y > * + * { margin-top: 12px; }
/* Modal mock bank */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--panel);
padding: 24px;
border-radius: var(--radius);
box-shadow: var(--shadow);
width: 100%;
max-width: 400px;
}
.connection-row {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -8,7 +8,7 @@ Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-08
- Date: 2025-10-16
- Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski
@@ -43,8 +43,8 @@ Prepare 3-5 questions and topics you want to discuss with your mentor.
Last 3 minutes of the meeting, summarize action items.
- [ ] OAuth
- [ ] CI/CD fix
- [x] OAuth
- [x] CI/CD fix
- [ ] Database local (multiple bank accounts)
- [ ] Add tests and set up github pipeline
- [ ] Frontend imporvment - user experience

View File

@@ -0,0 +1,54 @@
# Weekly Meeting Notes
- Group 8 - Personal finance tracker
- Mentor: Jaychander
Keep all meeting notes in the `meetings.md` file in your project folder.
Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-23
- Attendees: Dejan
- Notetaker: Dejan
## Progress Update (Before Meeting)
Last 3 minutes of the meeting, summarize action items.
- [x] OAuth (BankID)
- [x] CI/CD fix
- [X] Database local (multiple bank accounts)
- [X] Add tests and set up github pipeline
- [X] Frontend imporvment - user experience
- [ ] make the report more clear - partly
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
Improved Frontend, added Mock Bank, fixed deployment, fixed OAuth(BankID) on production, added basic tests
### Documentation
Not much - just updated the work done
## Questions and Topics for Discussion (Before Meeting)
This was not prepared, I planned to do it right before meeting, but Jaychander needed to go somewhere earlier.
1. Question 1
2. Question 2
3. Question 3
## Discussion Notes (During Meeting)
The tracker should not store the transactions in the database - security vulnerability.
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] Dont store data in database (security) - Load it on login (from CSAS API and local database), load automatically with email
- [ ] Go through the checklist
- [ ] Look for possible APIs (like stocks or financial details whatever)
- [ ] Report
---

View File

@@ -14,7 +14,7 @@
- 289229, Lukáš Trkan, lukastrkan
- 289258, Dejan Ribarovski, derib2613, ribardej
**Brief Description**:
**Brief Description**: (něco spíš jako abstract, introuction, story behind)
Our application is a finance tracker, so a person can easily track his cash flow
through multiple bank accounts. Person can label transactions with custom categories
and later filter by them.
@@ -338,15 +338,16 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
> Link to the specific commit on GitHub for each contribution.
### [Team Member 1 Name]
### [Lukáš]
| Date | Activity | Hours | Description |
| --------- | ------------------- | ---------- | ----------------------------------- |
| [Date] | Initial Setup | [X.X] | Repository setup, project structure |
| [Date] | Backend Development | [X.X] | Implemented user authentication |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
| Date | Activity | Hours | Description |
|----------------|---------------------|------------|----------------------------------------------------|
| 4.10 to 10.10 | Initial Setup | 40 | Repository setup, project structure, cluster setup |
| 14.10 to 16.10 | Backend Development | 12 | Implemented user authentication - oauth |
| 8.10 to 12.10 | CI/CD | 10 | Created database schema and models |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
### Dejan