Compare commits

124 Commits

Author SHA1 Message Date
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
Dejan Ribarovski
be4a3b401a Merge pull request #28 from dat515-2025/merge/frontend_basics
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
Merge/frontend basics
2025-10-21 13:34:53 +02:00
8c72091658 Merge branch 'main' into merge/frontend_basics 2025-10-21 13:31:50 +02:00
607c5eadd7 feat(infrastructure): remove old deployment 2025-10-20 19:20:56 +02:00
2617c640a8 fix(app): add missing env variables
Some checks failed
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (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
2025-10-17 16:04:52 +02:00
cb9ef5e461 feat(app): add sentry loging 2025-10-17 15:59:18 +02:00
b0cabe027f add debug logging 2025-10-17 15:42:58 +02:00
8974561308 add debug logging 2025-10-17 15:14:10 +02:00
2f275ef605 fix(infrastructure): add frontend URL to CORS 2025-10-17 12:58:11 +02:00
d593f7a994 feat(infrastructure): move to secrets
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-16 18:30:13 +02:00
ef5b3f2d30 feat(infrastructure): move to secrets 2025-10-16 18:25:06 +02:00
60109c4a35 fix(infrastructure): add oauth keys as secret 2025-10-16 18:18:19 +02:00
b6f9ee8fc7 fix(infrastructure): add missing slash 2025-10-16 18:11:19 +02:00
52333b24d5 Merge pull request #29 from dat515-2025/merge/deployment_envs
fix(infrastructure): add env variables to deployment
2025-10-16 18:05:59 +02:00
8929920072 Potential fix for code scanning alert no. 9: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-16 18:04:04 +02:00
cdb6cf5e20 Update .github/workflows/deploy-pr.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-16 18:02:09 +02:00
5190e9c48e fix(infrastructure): use correct runner 2025-10-16 18:00:07 +02:00
815bf7f065 fix(infrastructure): use correct runner 2025-10-16 17:50:39 +02:00
85a390565a fix(infrastructure): use correct runner 2025-10-16 17:43:55 +02:00
20d26b7edc fix(infrastructure): use correct runner 2025-10-16 17:42:16 +02:00
579dda50b9 fix(infrastructure): use correct runner 2025-10-16 17:42:02 +02:00
4f7d30daf6 fix(infrastructure): use correct runner 2025-10-16 17:32:00 +02:00
49c96187c9 fix(infrastructure): use correct runner 2025-10-16 17:17:41 +02:00
d1feafd4ef fix(infrastructure): use correct runner 2025-10-16 17:12:01 +02:00
efb454ba99 fix(infrastructure): use correct runner 2025-10-16 17:06:06 +02:00
810f1ccb32 fix(infrastructure): use correct runner 2025-10-16 17:01:38 +02:00
c4afdf5ad2 fix(infrastructure): use correct runner 2025-10-16 15:10:33 +02:00
c290a109b6 fix(infrastructure): use variables, not secrets 2025-10-16 15:01:53 +02:00
7c161f6f37 fix(infrastructure): add env variables to deployment 2025-10-16 14:49:26 +02:00
c4991ea3c4 fix(infrastructure): add env variables to deployment 2025-10-16 14:47:16 +02:00
3b6b64d472 update report.md 2025-10-16 13:51:52 +02:00
ribardej
9bc543a5fa feat(docs): weekly meeting 2025-10-16 13:27:53 +02:00
ribardej
14516a808b feat(docs): this week meeting.md 2025-10-16 11:15:54 +02:00
ribardej
922ebf46ae feat(docs): Catch up on report.md 2025-10-15 16:25:28 +02:00
ribardej
1f5d6f127f feat(backend): fixed build errors regarding token in headers 2025-10-15 15:21:10 +02:00
ribardej
3a7580c315 feat(backend): added missing untracked files 2025-10-15 15:08:18 +02:00
ribardej
c21af2732e feat(backend): implemented self delete for users 2025-10-15 11:11:04 +02:00
ribardej
f208e73986 feat(frontend): added account and appearance tabs 2025-10-15 11:00:47 +02:00
ribardej
eb087e457c feat(frontend): improved and centered UI 2025-10-15 10:06:22 +02:00
ribardej
89d032dd69 feat(frontend): introduced a working frontend prototype 2025-10-14 11:34:25 +02:00
e200c73b47 fix(backend): use correct variable to register routers
Some checks failed
Deploy Prod / Build and push image (reusable) (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
2025-10-13 17:11:31 +02:00
Dejan Ribarovski
ac10ab381e Merge pull request #26 from dat515-2025/20-create-a-controller-layer-on-backend-side
20 create a controller layer on backend side
2025-10-13 14:05:05 +02:00
Dejan Ribarovski
879109144c Merge branch 'main' into 20-create-a-controller-layer-on-backend-side 2025-10-13 14:03:24 +02:00
ribardej
7061e57442 Merge remote-tracking branch 'origin/20-create-a-controller-layer-on-backend-side' into 20-create-a-controller-layer-on-backend-side 2025-10-13 13:57:04 +02:00
ribardej
30068079c6 feat(backend): renamed endpoints for consistency 2025-10-13 13:56:44 +02:00
Dejan Ribarovski
9580bea630 Update 7project/backend/app/api/transactions.py
Better error message

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 13:52:36 +02:00
Dejan Ribarovski
975f5e5bec Update 7project/backend/app/api/transactions.py
Better error message

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 13:52:24 +02:00
ribardej
f1065bc274 feat(backend): update consistent Pydantic v2 use everywhere 2025-10-13 13:50:59 +02:00
Dejan Ribarovski
12152238c6 Merge pull request #23 from dat515-2025/merge/oauth
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Waiting to run
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
feat(auth): add support for OAuth and MojeID
2025-10-13 12:46:17 +02:00
Dejan Ribarovski
21ef5a3961 Merge pull request #25 from dat515-2025/merge/database_backups
feat(infrastructure): add backups
2025-10-13 12:41:27 +02:00
ribardej
2f20fb12e4 feat(backend): implemented basic controller layer 2025-10-13 12:07:47 +02:00
bf213234b1 feat(infrastructure): add backups 2025-10-12 20:14:48 +02:00
95c8bf1e92 Update 7project/backend/app/app.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 22:25:04 +02:00
b213f22a15 feat(auth): refactor 2025-10-11 22:22:36 +02:00
0cf06b7bd9 feat(auth): add CustomOpenID class to force get_user_info implementation 2025-10-11 21:37:49 +02:00
7a67b12533 Update 7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 21:32:03 +02:00
a91aea805f feat(auth): add BankID OAuth provider 2025-10-11 21:16:53 +02:00
32764ab1b0 feat(auth): allow updating custom fields from oauth, update MojeID 2025-10-11 20:34:36 +02:00
ribardej
6c248039ac feat(backend): fixed DB user schema 2025-10-10 16:16:43 +02:00
df0f2584ae feat(auth): add support for OAuth and MojeID 2025-10-10 15:58:40 +02:00
b7570e334f feat(auth): add support for OAuth and MojeID 2025-10-10 15:51:18 +02:00
4ea6876b74 feat(infrastructure): add forgotten values.yaml 2025-10-10 13:57:43 +02:00
6d5dd1a222 feat(infrastructure): update deployment
Some checks failed
Deploy Prod / Build and push image (reusable) (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
2025-10-09 18:51:17 +02:00
ribardej
f09f9eaa82 feat(infrastructure): redone the system diagram 2025-10-09 15:55:23 +02:00
ae10c4daff Merge pull request #19 from dat515-2025/merge/basic_database_structure
feat(models): add basic database structure
2025-10-09 15:24:11 +02:00
abebdb019b feat(models): change unique index 2025-10-09 15:15:24 +02:00
6040f4339c feat(models): database changes 2025-10-09 15:09:26 +02:00
72c241f4f7 feat(infrastructure): database changes 2025-10-09 15:07:33 +02:00
8db669ac72 feat(infrastructure): database changes 2025-10-09 14:56:51 +02:00
e32e18f0de feat(models): add basic database structure 2025-10-09 14:41:11 +02:00
95996d22f8 feat(models): add basic database structure 2025-10-09 14:33:07 +02:00
ribardej
991c070918 meeting notes 2025-10-09 13:57:45 +02:00
derib2613
a717e4afeb Merge pull request #17 from dat515-2025/11-update-deployment
update
2025-10-09 12:43:45 +02:00
ribardej
2bc03bcd5b feat(infrastructure): add documentation markdown files 2025-10-08 17:17:28 +02:00
dbd37a8b83 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 21:36:30 +02:00
f1cbdbce9c update 2025-10-06 21:29:35 +02:00
fa1b9523a1 feat(infrastructure): add frontend, deploy to cloudflare
Some checks failed
Deploy Prod / Build and push image (reusable) (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
2025-10-06 20:56:42 +02:00
e5fceb886b feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:48:01 +02:00
ec7c0cbc7a Merge pull request #16 from dat515-2025/merge/cloudflare-deploy
feat(infrastructure): add frontend, deploy to cloudflare
2025-10-06 18:44:31 +02:00
9ea02ed10c feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:39:38 +02:00
afb8199cad feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:37:35 +02:00
1e23b32f30 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:26:17 +02:00
cdfaf3e66d feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:23:52 +02:00
21ccb00f4a feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:12:39 +02:00
901fff8651 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:09:23 +02:00
9c4144f5c4 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:05:32 +02:00
37f4d44caf Merge remote-tracking branch 'origin/main' 2025-10-06 17:40:51 +02:00
d0ffab97c3 feat(infrastructure): add metrics server 2025-10-06 17:40:40 +02:00
ribardej
b6f8daba8c rabbitmq legacy support 2025-10-06 16:30:50 +02:00
316939b53c feat(infrastructure): update rabbitmq env
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-10-05 22:49:57 +02:00
101bb34cb0 feat(infrastructure): update rabbitmq env 2025-10-05 22:45:05 +02:00
9a7759ab3d feat(infrastructure): update rabbitmq env 2025-10-05 22:35:48 +02:00
c15dea5456 feat(infrastructure): update rabbitmq env 2025-10-05 22:25:49 +02:00
fae5d828bf feat(infrastructure): update rabbitmq env 2025-10-05 22:23:35 +02:00
7ee45b451e feat(infrastructure): update rabbitmq env 2025-10-05 22:13:07 +02:00
d4da625408 feat(infrastructure): update rabbitmq env 2025-10-05 21:30:22 +02:00
5c4e155546 feat(infrastructure): update rabbitmq env 2025-10-05 21:17:34 +02:00
edfa42eee5 feat(infrastructure): update rabbitmq env 2025-10-05 21:16:36 +02:00
ba7cc381cf feat(infrastructure): revert rootless container 2025-10-05 21:11:12 +02:00
fc1b614f19 feat(infrastructure): rootless container 2025-10-05 21:08:23 +02:00
7b9d72791f feat(infrastructure): rootless container 2025-10-05 21:05:42 +02:00
48d56681fb feat(infrastructure): rootless container 2025-10-05 21:01:22 +02:00
c45ecbc5bc feat(infrastructure): rootless container 2025-10-05 20:57:28 +02:00
a940e257ee feat(infrastructure): automatic deploy 2025-10-05 20:54:00 +02:00
bc219338b1 feat(infrastructure): automatic deploy 2025-10-05 20:48:10 +02:00
384d5004eb feat(infrastructure): automatic deploy 2025-10-05 20:44:14 +02:00
8e1d65a078 feat(infrastructure): automatic deploy 2025-10-05 20:40:40 +02:00
35e2ca6a72 feat(infrastructure): automatic deploy 2025-10-05 18:39:00 +02:00
bda4cafcf6 feat(infrastructure): automatic deploy 2025-10-05 18:33:22 +02:00
29422f6500 feat(infrastructure): automatic deploy 2025-10-05 18:28:53 +02:00
d03ff463a0 feat(infrastructure): automatic deploy 2025-10-05 18:22:38 +02:00
e0fd68b135 feat(infrastructure): automatic deploy 2025-10-05 18:15:23 +02:00
8cef7467cf feat(infrastructure): automatic deploy 2025-10-05 18:09:04 +02:00
c9705616dd feat(infrastructure): automatic deploy 2025-10-05 18:07:01 +02:00
40131cf7ca feat(infrastructure): automatic deploy 2025-10-05 18:06:53 +02:00
3a6ee3dace refactor(structure): remove frontend placeholder
Some checks failed
Build, Push and Update Image in Manifest / build-and-update (push) Has been cancelled
2025-10-05 01:32:18 +02:00
d58d553945 refactor(structure): move to 7project dir 2025-10-05 01:30:55 +02:00
291305c2e5 Merge pull request #2 from dat515-2025/merge/background-worker
feat(infrastructure): update queue worker
2025-10-05 01:23:54 +02:00
9cbe121b11 fix(infrastructure): prometheus 2025-10-05 01:23:22 +02:00
8edaaee117 Update workflow.yml
Some checks failed
Build, Push and Update Image in Manifest / build-and-update (push) Has been cancelled
2025-10-02 15:39:01 +02:00
github-actions[bot]
4cb09bb053 fix(infrastructure): alembic - use SSL for DB connection 2025-10-02 13:10:59 +00:00
dd0ca4b4f1 fix(infrastructure): alembic - use SSL for DB connection 2025-10-02 15:10:09 +02:00
168 changed files with 4488 additions and 825 deletions

105
.github/workflows/build-image.yaml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Build and Push Image
on:
workflow_call:
inputs:
mode:
description: "Build mode: 'prod' or 'pr'"
required: true
type: string
image_repo:
description: "Docker image repository (e.g., user/app)"
required: false
default: "lukastrkan/cc-app-demo"
type: string
context:
description: "Docker build context path"
required: false
default: "7project/backend"
type: string
pr_number:
description: "PR number (required when mode=pr)"
required: false
type: string
secrets:
DOCKER_USER:
required: true
DOCKER_PASSWORD:
required: true
outputs:
digest:
description: "Built image digest"
value: ${{ jobs.build.outputs.digest }}
image_repo:
description: "Image repository used"
value: ${{ jobs.build.outputs.image_repo }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.set.outputs.digest }}
image_repo: ${{ steps.set.outputs.image_repo }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Compute image repo and tags
id: meta
env:
MODE: ${{ inputs.mode }}
IMAGE_REPO: ${{ inputs.image_repo }}
PR: ${{ inputs.pr_number }}
run: |
set -euo pipefail
if [ -z "${IMAGE_REPO:-}" ]; then IMAGE_REPO="lukastrkan/cc-app-demo"; fi
echo "IMAGE_REPO=$IMAGE_REPO" >> $GITHUB_ENV
SHA_SHORT="${GITHUB_SHA::12}"
case "$MODE" in
prod)
TAG1="prod-$SHA_SHORT"
TAG2="latest"
;;
pr)
if [ -z "${PR:-}" ]; then echo "pr_number input is required for mode=pr"; exit 1; fi
TAG1="pr-$PR"
TAG2="pr-$PR-$SHA_SHORT"
;;
*)
echo "Unknown mode '$MODE' (expected 'prod' or 'pr')"; exit 1;
;;
esac
echo "TAG1=$TAG1" >> $GITHUB_ENV
echo "TAG2=$TAG2" >> $GITHUB_ENV
- name: Build and push image
id: build
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
push: true
tags: |
${{ env.IMAGE_REPO }}:${{ env.TAG1 }}
${{ env.IMAGE_REPO }}:${{ env.TAG2 }}
platforms: linux/amd64
- name: Set outputs
id: set
env:
IMAGE_REPO: ${{ env.IMAGE_REPO }}
run: |
echo "digest=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT
echo "image_repo=$IMAGE_REPO" >> $GITHUB_OUTPUT

174
.github/workflows/deploy-pr.yaml vendored Normal file
View File

@@ -0,0 +1,174 @@
name: Deploy Preview (PR)
on:
pull_request:
types: [opened, reopened, synchronize, closed]
permissions:
contents: read
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)
uses: ./.github/workflows/build-image.yaml
with:
mode: pr
image_repo: lukastrkan/cc-app-demo
context: 7project/backend
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit
get_urls:
if: github.event.action != 'closed'
name: Generate Preview URLs
uses: ./.github/workflows/url_generator.yml
with:
runner: vhs
mode: pr
pr_number: ${{ github.event.pull_request.number }}
base_domain: ${{ vars.DEV_BASE_DOMAIN }}
secrets: inherit
frontend:
if: github.event.action != 'closed'
name: Frontend - Build and Deploy to Cloudflare Pages (PR)
needs: [get_urls]
uses: ./.github/workflows/frontend-pages.yml
with:
mode: pr
pr_number: ${{ github.event.pull_request.number }}
backend_url_scheme: ${{ needs.get_urls.outputs.backend_url_scheme }}
secrets: inherit
deploy:
if: github.event.action != 'closed'
name: Helm upgrade/install (PR preview)
runs-on: vhs
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: false
needs: [build, frontend, get_urls]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Configure kubeconfig
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
run: |
mkdir -p ~/.kube
if [ -z "$KUBE_CONFIG" ]; then
echo "Secret KUBE_CONFIG is required (kubeconfig content)"; exit 1; fi
echo "$KUBE_CONFIG" > ~/.kube/config
chmod 600 ~/.kube/config
- name: Helm upgrade/install PR preview
env:
DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }}
RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }}
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
DIGEST: ${{ needs.build.outputs.digest }}
DOMAIN: "${{ needs.get_urls.outputs.backend_url }}"
DOMAIN_SCHEME: "${{ needs.get_urls.outputs.backend_url_scheme }}"
FRONTEND_DOMAIN: "${{ needs.get_urls.outputs.frontend_url }}"
FRONTEND_DOMAIN_SCHEME: "${{ needs.get_urls.outputs.frontend_url_scheme }}"
run: |
PR=${{ github.event.pull_request.number }}
RELEASE=myapp-pr-$PR
NAMESPACE=pr-$PR
helm upgrade --install "$RELEASE" ./7project/charts/myapp-chart \
-n "$NAMESPACE" --create-namespace \
-f 7project/charts/myapp-chart/values-dev.yaml \
--set prNumber="$PR" \
--set deployment="pr-$PR" \
--set domain="$DOMAIN" \
--set domain_scheme="$DOMAIN_SCHEME" \
--set frontend_domain="$FRONTEND_DOMAIN" \
--set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \
--set image.digest="$DIGEST" \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD"
- name: Post preview URLs as PR comment
uses: actions/github-script@v7
env:
BACKEND_URL: ${{ needs.get_urls.outputs.backend_url_scheme }}
FRONTEND_URL: ${{ needs.get_urls.outputs.frontend_url_scheme }}
with:
script: |
const pr = context.payload.pull_request;
if (!pr) { core.setFailed('No pull_request context'); return; }
const prNumber = pr.number;
const backendUrl = process.env.BACKEND_URL || '(not available)';
const frontendUrl = process.env.FRONTEND_URL || '(not available)';
const marker = '<!-- preview-comment-marker -->';
const body = `${marker}\nPreview environment is running\n- Frontend: ${frontendUrl}\n- Backend: ${backendUrl}\n`;
const { owner, repo } = context.repo;
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page: 100 });
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
uninstall:
if: github.event.action == 'closed'
name: Helm uninstall (PR preview)
runs-on: vhs
steps:
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Configure kubeconfig
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
run: |
mkdir -p ~/.kube
if [ -z "$KUBE_CONFIG" ]; then
echo "Secret KUBE_CONFIG is required (kubeconfig content)"; exit 1; fi
echo "$KUBE_CONFIG" > ~/.kube/config
chmod 600 ~/.kube/config
- name: Helm uninstall release and cleanup namespace
run: |
PR=${{ github.event.pull_request.number }}
RELEASE=myapp-pr-$PR
NAMESPACE=pr-$PR
helm uninstall "$RELEASE" -n "$NAMESPACE" || true
# Optionally delete the namespace if empty
kubectl delete namespace "$NAMESPACE" --ignore-not-found=true || true

128
.github/workflows/deploy-prod.yaml vendored Normal file
View File

@@ -0,0 +1,128 @@
name: Deploy Prod
on:
push:
branches: [ "main" ]
paths:
- 7project/backend/**
- 7project/frontend/**
- 7project/charts/myapp-chart/**
- .github/workflows/deploy-prod.yaml
- .github/workflows/build-image.yaml
- .github/workflows/frontend-pages.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-prod
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
with:
mode: prod
image_repo: lukastrkan/cc-app-demo
context: 7project/backend
secrets: inherit
get_urls:
name: Generate Production URLs
uses: ./.github/workflows/url_generator.yml
with:
mode: prod
runner: vhs
base_domain: ${{ vars.PROD_DOMAIN }}
secrets: inherit
frontend:
name: Frontend - Build and Deploy to Cloudflare Pages (prod)
needs: [get_urls]
uses: ./.github/workflows/frontend-pages.yml
with:
mode: prod
backend_url_scheme: ${{ needs.get_urls.outputs.backend_url_scheme }}
secrets: inherit
deploy:
name: Helm upgrade/install (prod)
runs-on: vhs
needs: [build, frontend, get_urls]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Configure kubeconfig
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
run: |
mkdir -p ~/.kube
if [ -z "$KUBE_CONFIG" ]; then
echo "Secret KUBE_CONFIG is required (kubeconfig content)"; exit 1; fi
echo "$KUBE_CONFIG" > ~/.kube/config
chmod 600 ~/.kube/config
- name: Helm upgrade/install prod
env:
DOMAIN: ${{ needs.get_urls.outputs.backend_url }}
DOMAIN_SCHEME: ${{ needs.get_urls.outputs.backend_url_scheme }}
FRONTEND_DOMAIN: ${{ needs.get_urls.outputs.frontend_url }}
FRONTEND_DOMAIN_SCHEME: ${{ needs.get_urls.outputs.frontend_url_scheme }}
RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }}
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
DIGEST: ${{ needs.build.outputs.digest }}
BANKID_CLIENT_ID: ${{ secrets.BANKID_CLIENT_ID }}
BANKID_CLIENT_SECRET: ${{ secrets.BANKID_CLIENT_SECRET }}
MOJEID_CLIENT_ID: ${{ secrets.MOJEID_CLIENT_ID }}
MOJEID_CLIENT_SECRET: ${{ secrets.MOJEID_CLIENT_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
helm upgrade --install myapp ./7project/charts/myapp-chart \
-n prod --create-namespace \
-f 7project/charts/myapp-chart/values-prod.yaml \
--set deployment="prod" \
--set domain="$DOMAIN" \
--set domain_scheme="$DOMAIN_SCHEME" \
--set frontend_domain="$FRONTEND_DOMAIN" \
--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 oauth.bankid.clientId="$BANKID_CLIENT_ID" \
--set-string oauth.bankid.clientSecret="$BANKID_CLIENT_SECRET" \
--set-string oauth.mojeid.clientId="$MOJEID_CLIENT_ID" \
--set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \

135
.github/workflows/frontend-pages.yml vendored Normal file
View File

@@ -0,0 +1,135 @@
name: Frontend - Build and Deploy to Cloudflare Pages
on:
workflow_call:
inputs:
mode:
description: "Build mode: 'prod' or 'pr'"
required: true
type: string
pr_number:
description: 'PR number (required when mode=pr)'
required: false
type: string
project_name:
description: 'Cloudflare Pages project name (overrides default)'
required: false
type: string
backend_url_scheme:
description: 'The full scheme URL for the backend (e.g., https://api.example.com)'
required: true
type: string
secrets:
CLOUDFLARE_API_TOKEN:
required: true
CLOUDFLARE_ACCOUNT_ID:
required: true
outputs:
deployed_url:
description: 'URL of deployed frontend'
value: ${{ jobs.deploy.outputs.deployed_url }}
jobs:
build:
name: Build frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: 7project/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 7project/frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Set backend URL from workflow input
run: |
echo "VITE_BACKEND_URL=${{ inputs.backend_url_scheme }}" >> $GITHUB_ENV
- name: Build
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: 7project/frontend/dist
deploy:
name: Deploy to Cloudflare Pages
needs: build
runs-on: ubuntu-latest
outputs:
deployed_url: ${{ steps.out.outputs.deployed_url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist
- name: Determine project name and branch
id: pname
env:
INPUT_MODE: ${{ inputs.mode }}
INPUT_PR: ${{ inputs.pr_number }}
run: |
set -euo pipefail
# Prefer manual input, then repo variable, fallback to repo-name
INPUT_NAME='${{ inputs.project_name }}'
VAR_NAME='${{ vars.CF_PAGES_PROJECT_NAME }}'
if [ -n "$INPUT_NAME" ]; then PNAME_RAW="$INPUT_NAME";
elif [ -n "$VAR_NAME" ]; then PNAME_RAW="$VAR_NAME";
else PNAME_RAW="${GITHUB_REPOSITORY##*/}-frontend"; fi
# Normalize project name to lowercase to satisfy Cloudflare Pages naming
PNAME="${PNAME_RAW,,}"
# Determine branch for Pages
if [ "${INPUT_MODE}" = "pr" ]; then
if [ -z "${INPUT_PR}" ]; then echo "pr_number is required when mode=pr"; exit 1; fi
PBRANCH="pr-${INPUT_PR}"
else
PBRANCH="main"
fi
echo "project_name=$PNAME" >> $GITHUB_OUTPUT
echo "branch=$PBRANCH" >> $GITHUB_OUTPUT
- name: Ensure Cloudflare Pages project exists
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PNAME: ${{ steps.pname.outputs.project_name }}
run: |
set -euo pipefail
npx wrangler pages project create "$PNAME" --production-branch=main || true
- name: Deploy using Cloudflare Wrangler
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=${{ steps.pname.outputs.project_name }} --branch=${{ steps.pname.outputs.branch }}
- name: Compute deployed URL
id: out
env:
PNAME: ${{ steps.pname.outputs.project_name }}
PBRANCH: ${{ steps.pname.outputs.branch }}
run: |
set -euo pipefail
if [ "$PBRANCH" = "main" ]; then
URL="https://${PNAME}.pages.dev"
else
URL="https://${PBRANCH}.${PNAME}.pages.dev"
fi
echo "deployed_url=$URL" >> $GITHUB_OUTPUT

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

@@ -0,0 +1,55 @@
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: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r 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

74
.github/workflows/url_generator.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Generate Preview or Production URLs
on:
workflow_call:
inputs:
mode:
description: "Build mode: 'prod' or 'pr'"
required: true
type: string
pr_number:
description: 'PR number (required when mode=pr)'
required: false
type: string
runner:
description: 'The runner to use for this job'
required: false
type: string
default: 'ubuntu-latest'
base_domain:
description: 'The base domain for production URLs (e.g., example.com)'
required: true
type: string
outputs:
backend_url:
description: "The backend URL without scheme (e.g., api.example.com)"
value: ${{ jobs.generate-urls.outputs.backend_url }}
frontend_url:
description: "The frontend URL without scheme (e.g., app.example.com)"
value: ${{ jobs.generate-urls.outputs.frontend_url }}
backend_url_scheme:
description: "The backend URL with scheme (e.g., https://api.example.com)"
value: ${{ jobs.generate-urls.outputs.backend_url_scheme }}
frontend_url_scheme:
description: "The frontend URL with scheme (e.g., https://app.example.com)"
value: ${{ jobs.generate-urls.outputs.frontend_url_scheme }}
jobs:
generate-urls:
permissions:
contents: none
runs-on: ${{ inputs.runner }}
outputs:
backend_url: ${{ steps.set_urls.outputs.backend_url }}
frontend_url: ${{ steps.set_urls.outputs.frontend_url }}
backend_url_scheme: ${{ steps.set_urls.outputs.backend_url_scheme }}
frontend_url_scheme: ${{ steps.set_urls.outputs.frontend_url_scheme }}
steps:
- name: Generate URLs
id: set_urls
env:
BASE_DOMAIN: ${{ inputs.base_domain }}
run: |
set -euo pipefail
if [ "${{ inputs.mode }}" = "prod" ]; then
BACKEND_URL="api.${BASE_DOMAIN}"
FRONTEND_URL="finance.${BASE_DOMAIN}"
else
# This is your current logic
FRONTEND_URL="pr-${{ inputs.pr_number }}.group-8-frontend.pages.dev"
BACKEND_URL="api-pr-${{ inputs.pr_number }}.${BASE_DOMAIN}"
fi
FRONTEND_URL_SCHEME="https://$FRONTEND_URL"
BACKEND_URL_SCHEME="https://$BACKEND_URL"
# This part correctly writes to GITHUB_OUTPUT for the step
echo "backend_url_scheme=$BACKEND_URL_SCHEME" >> $GITHUB_OUTPUT
echo "frontend_url_scheme=$FRONTEND_URL_SCHEME" >> $GITHUB_OUTPUT
echo "backend_url=$BACKEND_URL" >> $GITHUB_OUTPUT
echo "frontend_url=$FRONTEND_URL" >> $GITHUB_OUTPUT

View File

@@ -1,54 +0,0 @@
name: Build, Push and Update Image in Manifest
on:
push:
branches: [ "main" ]
paths:
- 'backend/**'
workflow_dispatch:
jobs:
build-and-update:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: ${{ secrets.DOCKER_USER }}/cc-app-demo:latest
- name: Get image digest
run: echo "IMAGE_DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
- name: Update manifests with new image digest
uses: OpsVerseIO/image-updater-action@0.1.0
with:
branch: main
targetBranch: main
createPR: 'false'
message: "${{ github.event.head_commit.message }}"
token: ${{ secrets.GITHUB_TOKEN }}
changes: |
{
"deployment/app-demo-deployment.yaml": {
"spec.template.spec.containers[0].image": "${{ secrets.DOCKER_USER }}/cc-app-demo@${{ env.IMAGE_DIGEST }}"
},
"deployment/app-demo-worker-deployment.yaml": {
"spec.template.spec.containers[0].image": "${{ secrets.DOCKER_USER }}/cc-app-demo@${{ env.IMAGE_DIGEST }}"
}
}

View File

@@ -45,11 +45,11 @@ flowchart LR
proc_cron[Task planner] --> proc_queue proc_cron[Task planner] --> proc_queue
proc_queue_worker --> ext_bank[(Bank API)] proc_queue_worker --> ext_bank[(Bank API)]
proc_queue_worker --> db proc_queue_worker --> db
client[Client/UI] --> api[API Gateway / Web Server] client[Client/UI] <--> api[API Gateway / Web Server]
api --> svc[Web API] api <--> svc[Web API]
svc --> proc_queue svc --> proc_queue
svc --> db[(Database)] svc <--> db[(Database)]
svc --> cache[(Cache)] svc <--> cache[(Cache)]
``` ```
- Components and responsibilities: What does each box do? - Components and responsibilities: What does each box do?

View File

View File

@@ -1,7 +1,8 @@
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
EXPOSE 8000 EXPOSE 8000
CMD alembic upgrade head && uvicorn app.app:app --host 0.0.0.0 --port 8000 CMD alembic upgrade head && uvicorn app.app:fastApi --host 0.0.0.0 --port 8000

View File

@@ -11,7 +11,7 @@ script_location = %(here)s/alembic
# Uncomment the line below if you want the files to be prepended with date and time # Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens # for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present. # sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator # defaults to the current working directory. for multiple paths, the path separator

View File

@@ -23,9 +23,10 @@ if not DATABASE_URL:
mariadb_password = os.getenv("MARIADB_PASSWORD", "strongpassword") mariadb_password = os.getenv("MARIADB_PASSWORD", "strongpassword")
DATABASE_URL = f"mysql+pymysql://{mariadb_user}:{mariadb_password}@{mariadb_host}:{mariadb_port}/{mariadb_db}" DATABASE_URL = f"mysql+pymysql://{mariadb_user}:{mariadb_password}@{mariadb_host}:{mariadb_port}/{mariadb_db}"
# Use synchronous driver for Alembic migrations
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncmy", "+pymysql") SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncmy", "+pymysql")
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost"
connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
context.configure( context.configure(
@@ -39,7 +40,7 @@ def run_migrations_offline() -> None:
def run_migrations_online() -> None: def run_migrations_online() -> None:
connectable = create_engine(SYNC_DATABASE_URL, poolclass=pool.NullPool) connectable = create_engine(SYNC_DATABASE_URL, poolclass=pool.NullPool, connect_args=connect_args)
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( context.configure(
connection=connection, connection=connection,

View File

@@ -1,8 +1,8 @@
"""Init migration """add categories
Revision ID: 81f275275556 Revision ID: 63e072f09836
Revises: Revises:
Create Date: 2025-09-24 17:39:25.346690 Create Date: 2025-10-09 14:56:14.653249
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -13,7 +13,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '81f275275556' revision: str = '63e072f09836'
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -22,12 +22,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user', op.create_table('user',
sa.Column('first_name', sa.String(length=100), nullable=True), sa.Column('first_name', sa.String(length=100), nullable=True),
sa.Column('last_name', sa.String(length=100), nullable=True), sa.Column('last_name', sa.String(length=100), nullable=True),
@@ -40,13 +34,38 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_table('categories',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('user_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('user_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('category_transaction',
sa.Column('id_category', sa.Integer(), nullable=True),
sa.Column('id_transaction', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['id_category'], ['categories.id'], ),
sa.ForeignKeyConstraint(['id_transaction'], ['transaction.id'], )
)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('category_transaction')
op.drop_table('transaction')
op.drop_table('categories')
op.drop_index(op.f('ix_user_email'), table_name='user') op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user') op.drop_table('user')
op.drop_table('transaction')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""update categories unique
Revision ID: 390041bd839e
Revises: 63e072f09836
Create Date: 2025-10-09 15:14:31.557686
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '390041bd839e'
down_revision: Union[str, Sequence[str], None] = '63e072f09836'
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.drop_index(op.f('name'), table_name='categories')
op.create_unique_constraint('uix_name_user_id', 'categories', ['name', 'user_id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uix_name_user_id', 'categories', type_='unique')
op.create_index(op.f('name'), 'categories', ['name'], unique=True)
# ### end Alembic commands ###

View File

@@ -0,0 +1,48 @@
"""add user oauth
Revision ID: 7af8f296d089
Revises: 390041bd839e
Create Date: 2025-10-10 14:05:00.153376
"""
from typing import Sequence, Union
import fastapi_users_db_sqlalchemy
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7af8f296d089'
down_revision: Union[str, Sequence[str], None] = '390041bd839e'
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.create_table('oauth_account',
sa.Column('id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column('user_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column('oauth_name', sa.String(length=100), nullable=False),
sa.Column('access_token', sa.String(length=1024), nullable=False),
sa.Column('expires_at', sa.Integer(), nullable=True),
sa.Column('refresh_token', sa.String(length=1024), nullable=True),
sa.Column('account_id', sa.String(length=320), nullable=False),
sa.Column('account_email', sa.String(length=320), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oauth_account_account_id'), 'oauth_account', ['account_id'], unique=False)
op.create_index(op.f('ix_oauth_account_oauth_name'), 'oauth_account', ['oauth_name'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_oauth_account_oauth_name'), table_name='oauth_account')
op.drop_index(op.f('ix_oauth_account_account_id'), table_name='oauth_account')
op.drop_table('oauth_account')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""change token length
Revision ID: 5ab2e654c96e
Revises: 7af8f296d089
Create Date: 2025-10-11 21:07:41.930470
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '5ab2e654c96e'
down_revision: Union[str, Sequence[str], None] = '7af8f296d089'
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('oauth_account', 'access_token',
existing_type=mysql.VARCHAR(length=1024),
type_=sa.String(length=4096),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('oauth_account', 'access_token',
existing_type=sa.String(length=4096),
type_=mysql.VARCHAR(length=1024),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, status
from fastapi_users import models
from fastapi_users.manager import BaseUserManager
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.services.user_service import auth_backend, fastapi_users
router = APIRouter()
@router.delete(
"/users/me",
status_code=status.HTTP_204_NO_CONTENT,
tags=["users"],
summary="Delete current user",
response_description="The user has been successfully deleted.",
)
async def delete_me(
user: models.UserProtocol = Depends(fastapi_users.current_user(active=True)),
user_manager: BaseUserManager = Depends(fastapi_users.get_user_manager),
):
"""
Delete the currently authenticated user.
"""
await user_manager.delete(user)
# Keep existing paths as-is under /auth/* and /users/*
router.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)
router.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)

View File

@@ -0,0 +1,77 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
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.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
router = APIRouter(prefix="/categories", tags=["categories"])
@router.post("/create", response_model=CategoryRead, status_code=status.HTTP_201_CREATED)
async def create_category(
payload: CategoryCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
# Enforce per-user unique name via query to provide 409 feedback
res = await session.execute(
select(Category).where(Category.user_id == user.id, Category.name == payload.name)
)
existing = res.scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="Category with this name already exists")
category = Category(name=payload.name, description=payload.description, user_id=user.id)
session.add(category)
await session.commit()
await session.refresh(category)
return category
@router.get("/", response_model=List[CategoryRead])
async def list_categories(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(select(Category).where(Category.user_id == user.id))
return list(res.scalars())
@router.get("/{category_id}", response_model=CategoryRead)
async def get_category(
category_id: int,
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")
return category
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_category(
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category.id).where(Category.id == category_id, Category.user_id == user.id)
)
if res.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Category not found")
await session.execute(
delete(Category).where(Category.id == category_id, Category.user_id == user.id)
)
await session.commit()
return None

View File

@@ -0,0 +1,219 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.transaction import Transaction
from app.models.categories import Category
from app.schemas.transaction import (
TransactionCreate,
TransactionRead,
TransactionUpdate,
)
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
router = APIRouter(prefix="/transactions", tags=["transactions"])
def _to_read_model(tx: Transaction) -> TransactionRead:
return TransactionRead(
id=tx.id,
amount=tx.amount,
description=tx.description,
category_ids=[c.id for c in (tx.categories or [])],
)
@router.post("/create", response_model=TransactionRead, status_code=status.HTTP_201_CREATED)
async def create_transaction(
payload: TransactionCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id)
# Attach categories if provided (and owned by user)
if payload.category_ids:
res = await session.execute(
select(Category).where(
Category.user_id == user.id, Category.id.in_(payload.category_ids)
)
)
categories = list(res.scalars())
if len(categories) != len(set(payload.category_ids)):
raise HTTPException(
status_code=400,
detail="Duplicate category IDs provided or one or more categories not found"
)
tx.categories = categories
session.add(tx)
await session.commit()
await session.refresh(tx)
# Ensure categories are loaded
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.get("/", response_model=List[TransactionRead])
async def list_transactions(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id)
)
txs = list(res.scalars())
# Eagerly load categories for each transaction
for tx in txs:
await session.refresh(tx, attribute_names=["categories"])
return [_to_read_model(tx) for tx in txs]
@router.get("/{transaction_id}", response_model=TransactionRead)
async def get_transaction(
transaction_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.patch("/{transaction_id}/edit", response_model=TransactionRead)
async def update_transaction(
transaction_id: int,
payload: TransactionUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
if payload.amount is not None:
tx.amount = payload.amount
if payload.description is not None:
tx.description = payload.description
if payload.category_ids is not None:
# Preload categories to avoid async lazy-load during assignment
await session.refresh(tx, attribute_names=["categories"])
if payload.category_ids:
# Check for duplicate category IDs in the payload
if len(payload.category_ids) != len(set(payload.category_ids)):
raise HTTPException(status_code=400, detail="Duplicate category IDs in payload")
res = await session.execute(
select(Category).where(
Category.user_id == user.id, Category.id.in_(payload.category_ids)
)
)
categories = list(res.scalars())
if len(categories) != len(payload.category_ids):
raise HTTPException(status_code=400, detail="One or more categories not found")
tx.categories = categories
else:
tx.categories = []
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.delete("/{transaction_id}/delete", status_code=status.HTTP_204_NO_CONTENT)
async def delete_transaction(
transaction_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.delete(tx)
await session.commit()
return None
@router.post("/{transaction_id}/categories/{category_id}", response_model=TransactionRead)
async def assign_category(
transaction_id: int,
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
# Load transaction and category ensuring ownership
res_tx = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res_tx.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
res_cat = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
cat: Optional[Category] = res_cat.scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
await session.refresh(tx, attribute_names=["categories"])
if cat not in tx.categories:
tx.categories.append(cat)
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.delete("/{transaction_id}/categories/{category_id}", response_model=TransactionRead)
async def unassign_category(
transaction_id: int,
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res_tx = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res_tx.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
res_cat = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
cat: Optional[Category] = res_cat.scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
await session.refresh(tx, attribute_names=["categories"])
if cat in tx.categories:
tx.categories.remove(cat)
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)

102
7project/backend/app/app.py Normal file
View File

@@ -0,0 +1,102 @@
import logging
import os
from datetime import datetime
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request
from app.models.user import User
from app.services.user_service import current_active_verified_user
from app.api.auth import router as auth_router
from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider
from fastapi import FastAPI
import sentry_sdk
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
send_default_pii=True,
)
app = FastAPI()
fastApi = FastAPI()
# CORS for frontend dev server
fastApi.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
os.getenv("FRONTEND_DOMAIN_SCHEME", "")
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
fastApi.include_router(auth_router)
fastApi.include_router(categories_router)
fastApi.include_router(transactions_router)
logging.basicConfig(filename='app.log', level=logging.INFO, format='%(asctime)s %(message)s')
@fastApi.middleware("http")
async def log_traffic(request: Request, call_next):
start_time = datetime.now()
response = await call_next(request)
process_time = (datetime.now() - start_time).total_seconds()
client_host = request.client.host
log_params = {
"request_method": request.method,
"request_url": str(request.url),
"request_size": request.headers.get("content-length"),
"request_headers": dict(request.headers),
"response_status": response.status_code,
"response_size": response.headers.get("content-length"),
"response_headers": dict(response.headers),
"process_time": process_time,
"client_host": client_host
}
logging.info(str(log_params))
return response
fastApi.include_router(
fastapi_users.get_oauth_router(
get_oauth_provider("MojeID"),
auth_backend,
"SECRET",
associate_by_email=True,
),
prefix="/auth/mojeid",
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_oauth_router(
get_oauth_provider("BankID"),
auth_backend,
"SECRET",
associate_by_email=True,
),
prefix="/auth/bankid",
tags=["auth"],
)
# Liveness/root endpoint
@fastApi.get("/", include_in_schema=False)
async def root():
return {"status": "ok"}
@fastApi.get("/authenticated-route")
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

View File

@@ -17,6 +17,7 @@ if not DATABASE_URL:
# Load all models to register them # Load all models to register them
from app.models.user import User from app.models.user import User
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.models.categories import Category
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost" ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost"
connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {} connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}

View File

@@ -0,0 +1,25 @@
from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, ForeignKey, Table, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.base import Base
association_table = Table(
"category_transaction",
Base.metadata,
Column("id_category", Integer, ForeignKey("categories.id")),
Column("id_transaction", Integer, ForeignKey("transaction.id"))
)
class Category(Base):
__tablename__ = "categories"
__table_args__ = (
UniqueConstraint("name", "user_id", name="uix_name_user_id"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(length=100), nullable=False)
description = Column(String(length=255), nullable=True)
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
user = relationship("User", back_populates="categories")
transactions = relationship("Transaction", secondary=association_table, back_populates="categories")

View File

@@ -0,0 +1,17 @@
from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, Float, ForeignKey
from sqlalchemy.orm import relationship
from app.core.base import Base
from app.models.categories import association_table
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)
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
# Relationship
user = relationship("User", back_populates="transactions")
categories = relationship("Category", secondary=association_table, back_populates="transactions")

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship, mapped_column, Mapped
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID
from app.core.base import Base
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
# BankID token is longer than default
access_token: Mapped[str] = mapped_column(String(length=4096), nullable=False)
class User(SQLAlchemyBaseUserTableUUID, Base):
first_name = Column(String(length=100), nullable=True)
last_name = Column(String(length=100), nullable=True)
oauth_accounts = relationship("OAuthAccount", lazy="joined")
# Relationship
transactions = relationship("Transaction", back_populates="user")
categories = relationship("Category", back_populates="user")

View File

@@ -0,0 +1,50 @@
import secrets
from typing import Optional, Literal
from httpx_oauth.oauth2 import T
from app.oauth.custom_openid import CustomOpenID
class BankID(CustomOpenID):
def __init__(self, client_id: str, client_secret: str):
super().__init__(
client_id,
client_secret,
"https://oidc.sandbox.bankid.cz/.well-known/openid-configuration",
"BankID",
base_scopes=["openid", "profile.email", "profile.name"],
)
async def get_user_info(self, token: str) -> dict:
info = await self.get_profile(token)
return {
"first_name": info.get("given_name"),
"last_name": info.get("family_name"),
}
async def get_authorization_url(
self,
redirect_uri: str,
state: Optional[str] = None,
scope: Optional[list[str]] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[Literal["plain", "S256"]] = None,
extras_params: Optional[T] = None,
) -> str:
if extras_params is None:
extras_params = {}
# BankID requires random nonce parameter for security
# https://developer.bankid.cz/docs/security_sep
extras_params["nonce"] = secrets.token_urlsafe()
return await super().get_authorization_url(
redirect_uri,
state,
scope,
code_challenge,
code_challenge_method,
extras_params,
)

View File

@@ -0,0 +1,6 @@
from httpx_oauth.clients.openid import OpenID
class CustomOpenID(OpenID):
async def get_user_info(self, token: str) -> dict:
raise NotImplementedError()

View File

@@ -0,0 +1,56 @@
import json
from typing import Optional, Literal, Any
from httpx_oauth.oauth2 import T
from app.oauth.custom_openid import CustomOpenID
class MojeIDOAuth(CustomOpenID):
def __init__(self, client_id: str, client_secret: str):
super().__init__(
client_id,
client_secret,
"https://mojeid.regtest.nic.cz/.well-known/openid-configuration/",
"MojeID",
base_scopes=["openid", "email", "profile"],
)
async def get_user_info(self, token: str) -> Optional[Any]:
info = await self.get_profile(token)
return {
"first_name": info.get("given_name"),
"last_name": info.get("family_name"),
}
async def get_authorization_url(
self,
redirect_uri: str,
state: Optional[str] = None,
scope: Optional[list[str]] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[Literal["plain", "S256"]] = None,
extras_params: Optional[T] = None,
) -> str:
required_fields = {
'id_token': {
'name': {'essential': True},
'given_name': {'essential': True},
'family_name': {'essential': True},
'email': {'essential': True},
'mojeid_valid': {'essential': True},
}}
if extras_params is None:
extras_params = {}
extras_params["claims"] = json.dumps(required_fields)
return await super().get_authorization_url(
redirect_uri,
state,
scope,
code_challenge,
code_challenge_method,
extras_params,
)

View File

@@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict
class CategoryBase(BaseModel):
name: str
description: Optional[str] = None
class CategoryCreate(CategoryBase):
pass
class CategoryRead(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,21 @@
from typing import List, Optional
from pydantic import BaseModel, Field, ConfigDict
class TransactionBase(BaseModel):
amount: float = Field(..., gt=-1e18, lt=1e18)
description: Optional[str] = None
class TransactionCreate(TransactionBase):
category_ids: Optional[List[int]] = None
class TransactionUpdate(BaseModel):
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
description: Optional[str] = None
category_ids: Optional[List[int]] = None
class TransactionRead(TransactionBase):
id: int
category_ids: List[int] = []
model_config = ConfigDict(from_attributes=True)

View File

@@ -4,13 +4,13 @@ from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]): class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None first_name: Optional[str] = None
surname: Optional[str] = None last_name: Optional[str] = None
class UserCreate(schemas.BaseUserCreate): class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None first_name: Optional[str] = None
surname: Optional[str] = None last_name: Optional[str] = None
class UserUpdate(schemas.BaseUserUpdate): class UserUpdate(schemas.BaseUserUpdate):
first_name: Optional[str] = None first_name: Optional[str] = None
surname: Optional[str] = None last_name: Optional[str] = None

View File

@@ -4,11 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users.db import SQLAlchemyUserDatabase
from ..core.db import async_session_maker from ..core.db import async_session_maker
from ..models.user import User from ..models.user import User, OAuthAccount
async def get_async_session() -> AsyncGenerator[AsyncSession, None]: async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session: async with async_session_maker() as session:
yield session yield session
async def get_user_db(session: AsyncSession = Depends(get_async_session)): async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User) yield SQLAlchemyUserDatabase(session, User, OAuthAccount)

View File

@@ -3,26 +3,66 @@ import uuid
from typing import Optional from typing import Optional
from fastapi import Depends, Request from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
from fastapi_users.authentication import ( from fastapi_users.authentication import (
AuthenticationBackend, AuthenticationBackend,
BearerTransport, BearerTransport,
) )
from fastapi_users.authentication.strategy.jwt import JWTStrategy from fastapi_users.authentication.strategy.jwt import JWTStrategy
from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users.db import SQLAlchemyUserDatabase
from httpx_oauth.oauth2 import BaseOAuth2
from app.models.user import User from app.models.user import User
from app.oauth.bank_id import BankID
from app.oauth.custom_openid import CustomOpenID
from app.oauth.moje_id import MojeIDOAuth
from app.services.db import get_user_db from app.services.db import get_user_db
from app.core.queue import enqueue_email from app.core.queue import enqueue_email
SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET") SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
providers = {
"MojeID": MojeIDOAuth(
os.getenv("MOJEID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("MOJEID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
),
"BankID": BankID(
os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
)
}
def get_oauth_provider(name: str) -> Optional[BaseOAuth2]:
if name not in providers:
return None
return providers[name]
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SECRET reset_password_token_secret = SECRET
verification_token_secret = SECRET verification_token_secret = SECRET
async def oauth_callback(self: "BaseUserManager[models.UOAP, models.ID]", oauth_name: str, access_token: str,
account_id: str, account_email: str, expires_at: Optional[int] = None,
refresh_token: Optional[str] = None, request: Optional[Request] = None, *,
associate_by_email: bool = False, is_verified_by_default: bool = False) -> models.UOAP:
user = await super().oauth_callback(oauth_name, access_token, account_id, account_email, expires_at,
refresh_token, request, associate_by_email=associate_by_email,
is_verified_by_default=is_verified_by_default)
# set additional user info from the OAuth provider
provider = get_oauth_provider(oauth_name)
if provider is not None and isinstance(provider, CustomOpenID):
update_dict = await provider.get_user_info(access_token)
await self.user_db.update(user, update_dict)
return user
async def on_after_register(self, user: User, request: Optional[Request] = None): async def on_after_register(self, user: User, request: Optional[Request] = None):
await self.request_verify(user, request) await self.request_verify(user, request)
@@ -52,14 +92,18 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
print("[Email Fallback] Subject:", subject) print("[Email Fallback] Subject:", subject)
print("[Email Fallback] Body:\n", body) print("[Email Fallback] Body:\n", body)
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db) yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy: def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600) return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
auth_backend = AuthenticationBackend( auth_backend = AuthenticationBackend(
name="jwt", name="jwt",
transport=bearer_transport, transport=bearer_transport,
@@ -70,4 +114,3 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
current_active_user = fastapi_users.current_user(active=True) current_active_user = fastapi_users.current_user(active=True)
current_active_verified_user = fastapi_users.current_user(active=True, verified=True) current_active_verified_user = fastapi_users.current_user(active=True, verified=True)

View File

View File

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

View File

@@ -11,6 +11,7 @@ asyncmy==0.2.9
bcrypt==4.3.0 bcrypt==4.3.0
billiard==4.2.2 billiard==4.2.2
celery==5.5.3 celery==5.5.3
certifi==2025.10.5
cffi==2.0.0 cffi==2.0.0
click==8.1.8 click==8.1.8
click-didyoumean==0.3.1 click-didyoumean==0.3.1
@@ -25,7 +26,10 @@ fastapi-users==14.0.1
fastapi-users-db-sqlalchemy==7.0.0 fastapi-users-db-sqlalchemy==7.0.0
greenlet==3.2.4 greenlet==3.2.4
h11==0.16.0 h11==0.16.0
httpcore==1.0.9
httptools==0.6.4 httptools==0.6.4
httpx==0.28.1
httpx-oauth==0.16.1
idna==3.10 idna==3.10
kombu==5.5.4 kombu==5.5.4
makefun==1.16.0 makefun==1.16.0
@@ -46,6 +50,7 @@ python-dateutil==2.9.0.post0
python-dotenv==1.1.1 python-dotenv==1.1.1
python-multipart==0.0.20 python-multipart==0.0.20
PyYAML==6.0.2 PyYAML==6.0.2
sentry-sdk==2.42.0
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.43 SQLAlchemy==2.0.43
@@ -54,6 +59,7 @@ tomli==2.2.1
typing-inspection==0.4.1 typing-inspection==0.4.1
typing_extensions==4.15.0 typing_extensions==4.15.0
tzdata==2025.2 tzdata==2025.2
urllib3==2.5.0
uvicorn==0.37.0 uvicorn==0.37.0
uvloop==0.21.0 uvloop==0.21.0
vine==5.1.0 vine==5.1.0

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_ENTITY)
# 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 (3600,)
@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

@@ -0,0 +1,6 @@
apiVersion: v2
name: myapp-chart
version: 0.1.0
description: Helm chart for my app with MariaDB Database CR
appVersion: "1.0.0"
type: application

View File

@@ -0,0 +1,107 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Values.app.name }}
spec:
replicas: {{ .Values.app.replicas }}
revisionHistoryLimit: 3
selector:
matchLabels:
app: {{ .Values.app.name }}
template:
metadata:
labels:
app: {{ .Values.app.name }}
spec:
containers:
- name: {{ .Values.app.name }}
image: "{{- if .Values.image.digest -}}{{ .Values.image.repository }}@{{ .Values.image.digest }}{{- else -}}{{ .Values.image.repository }}:{{ default "latest" .Values.image.tag }}{{- end -}}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ "ALL" ]
ports:
- containerPort: {{ .Values.app.port }}
env:
- name: MARIADB_HOST
value: "mariadb-repl-maxscale-internal.mariadb-operator.svc.cluster.local"
- name: MARIADB_PORT
value: '3306'
- name: MARIADB_DB
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_DB
- name: MARIADB_USER
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_USER
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_PASSWORD
- name: RABBITMQ_USERNAME
value: {{ .Values.rabbitmq.username | quote }}
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
name: prod
key: RABBITMQ_PASSWORD
- name: RABBITMQ_HOST
value: {{ printf "%s.%s.svc.cluster.local" "rabbitmq-cluster" .Release.Namespace | quote }}
- name: RABBITMQ_PORT
value: {{ .Values.rabbitmq.port | quote }}
- name: RABBITMQ_VHOST
value: {{ .Values.rabbitmq.vhost | default "/" | quote }}
- name: MAIL_QUEUE
value: {{ .Values.worker.mailQueueName | default "mail_queue" | quote }}
- name: MOJEID_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: MOJEID_CLIENT_ID
- name: MOJEID_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: MOJEID_CLIENT_SECRET
- name: BANKID_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: BANKID_CLIENT_ID
- name: BANKID_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: BANKID_CLIENT_SECRET
- name: DOMAIN
value: {{ required "Set .Values.domain" .Values.domain | quote }}
- name: DOMAIN_SCHEME
value: {{ required "Set .Values.domain_scheme" .Values.domain_scheme | quote }}
- name: FRONTEND_DOMAIN
value: {{ required "Set .Values.frontend_domain" .Values.frontend_domain | quote }}
- name: FRONTEND_DOMAIN_SCHEME
value: {{ required "Set .Values.frontend_domain_scheme" .Values.frontend_domain_scheme | quote }}
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: prod
key: SENTRY_DSN
livenessProbe:
httpGet:
path: /
port: {{ .Values.app.port }}
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: {{ .Values.app.port }}
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3

View File

@@ -0,0 +1,18 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Grant
metadata:
name: grant
spec:
mariaDbRef:
name: {{ .Values.mariadb.mariaDbRef.name }}
namespace: {{ .Values.mariadb.mariaDbRef.namespace }}
privileges:
- "ALL PRIVILEGES"
database: {{ required "Set .Values.deployment" .Values.deployment | quote }}
table: "*"
username: {{ required "Set .Values.deployment" .Values.deployment | quote }}
grantOption: true
host: "%"
cleanupPolicy: {{ .Values.mariadb.cleanupPolicy }}
requeueInterval: {{ .Values.mariadb.requeueInterval | quote }}
retryInterval: {{ .Values.mariadb.retryInterval | quote }}

View File

@@ -0,0 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ required "Set .Values.database.secretName" .Values.database.secretName }}
type: kubernetes.io/basic-auth
stringData:
password: {{ required "Set .Values.database.password" .Values.database.password | quote }}

View File

@@ -0,0 +1,16 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: User
metadata:
name: {{ required "Set .Values.deployment" .Values.deployment }}
spec:
mariaDbRef:
name: {{ .Values.mariadb.mariaDbRef.name }}
namespace: {{ .Values.mariadb.mariaDbRef.namespace }}
passwordSecretKeyRef:
name: {{ required "Set .Values.database.secretName" .Values.database.secretName }}
key: password
maxUserConnections: 20
host: "%"
cleanupPolicy: {{ .Values.mariadb.cleanupPolicy }}
requeueInterval: {{ .Values.mariadb.requeueInterval | quote }}
retryInterval: {{ .Values.mariadb.retryInterval | quote }}

View File

@@ -0,0 +1,14 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Database
metadata:
name: {{ required "Set .Values.deployment" .Values.deployment }}
spec:
mariaDbRef:
name: {{ .Values.mariadb.mariaDbRef.name | required "Values mariadb.mariaDbRef.name is required" }}
namespace: {{ .Values.mariadb.mariaDbRef.namespace | default .Release.Namespace }}
characterSet: utf8
collate: utf8_general_ci
cleanupPolicy: {{ .Values.mariadb.cleanupPolicy }}
requeueInterval: {{ .Values.mariadb.requeueInterval | quote }}
retryInterval: {{ .Values.mariadb.retryInterval | quote }}

View File

@@ -0,0 +1,18 @@
apiVersion: v1
kind: Secret
metadata:
name: prod
type: Opaque
stringData:
MOJEID_CLIENT_ID: {{ .Values.oauth.mojeid.clientId | quote }}
MOJEID_CLIENT_SECRET: {{ .Values.oauth.mojeid.clientSecret | quote }}
BANKID_CLIENT_ID: {{ .Values.oauth.bankid.clientId | quote }}
BANKID_CLIENT_SECRET: {{ .Values.oauth.bankid.clientSecret | quote }}
# Database credentials
MARIADB_DB: {{ required "Set .Values.deployment" .Values.deployment | quote }}
MARIADB_USER: {{ required "Set .Values.deployment" .Values.deployment | quote }}
MARIADB_PASSWORD: {{ .Values.database.password | default "" | quote }}
# RabbitMQ credentials
RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }}
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }}

View File

@@ -0,0 +1,10 @@
apiVersion: rabbitmq.com/v1beta1
kind: RabbitmqCluster
metadata:
name: "rabbitmq-cluster"
namespace: {{ .Release.Namespace }}
spec:
replicas: {{ .Values.rabbitmq.replicas | default 1 }}
persistence:
storage: {{ .Values.rabbitmq.storage | default "1Gi" }}
resources: {}

View File

@@ -0,0 +1,15 @@
apiVersion: rabbitmq.com/v1beta1
kind: Permission
metadata:
name: {{ printf "%s-permission" (.Values.rabbitmq.username | default "demo-app") }}
namespace: {{ .Release.Namespace }}
spec:
rabbitmqClusterReference:
name: rabbitmq-cluster
namespace: {{ .Release.Namespace }}
vhost: {{ .Values.rabbitmq.vhost | default "/" | quote }}
user: {{ .Values.rabbitmq.username | default "demo-app" }}
permissions:
configure: ".*"
read: ".*"
write: ".*"

View File

@@ -0,0 +1,12 @@
apiVersion: rabbitmq.com/v1beta1
kind: Queue
metadata:
name: {{ .Values.worker.mailQueueName | replace "_" "-" | lower }}
namespace: {{ .Release.Namespace }}
spec:
rabbitmqClusterReference:
name: rabbitmq-cluster
namespace: {{ .Release.Namespace }}
name: {{ .Values.worker.mailQueueName }}
vhost: {{ .Values.rabbitmq.vhost | default "/" | quote }}
durable: true

View File

@@ -0,0 +1,10 @@
{{- if .Values.rabbitmq.password }}
apiVersion: v1
kind: Secret
metadata:
name: {{ printf "%s-user-credentials" (.Values.rabbitmq.username | default "app-user") }}
namespace: {{ .Release.Namespace }}
stringData:
password: {{ .Values.rabbitmq.password | quote }}
username: {{ .Values.rabbitmq.username | quote }}
{{- end }}

View File

@@ -0,0 +1,13 @@
apiVersion: rabbitmq.com/v1beta1
kind: User
metadata:
name: {{ .Values.rabbitmq.username | default "demo-app" }}
namespace: {{ .Release.Namespace }}
spec:
rabbitmqClusterReference:
name: rabbitmq-cluster
namespace: {{ .Release.Namespace }}
tags:
- management
importCredentialsSecret:
name: {{ printf "%s-user-credentials" (.Values.rabbitmq.username | default "app-user") }}

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.app.name }}
spec:
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.app.port }}
selector:
app: {{ .Values.app.name }}

View File

@@ -0,0 +1,14 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: guestbook-tunnel-binding
namespace: {{ .Release.Namespace }}
subjects:
- name: app-server
spec:
target: {{ printf "http://%s.%s.svc.cluster.local" .Values.app.name .Release.Namespace | quote }}
fqdn: {{ required "Set .Values.domain via --set domain=example.com" .Values.domain | quote }}
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

View File

@@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ printf "%s-worker" .Values.app.name }}
spec:
replicas: {{ .Values.worker.replicas }}
revisionHistoryLimit: 3
selector:
matchLabels:
app: {{ printf "%s-worker" .Values.app.name }}
template:
metadata:
labels:
app: {{ printf "%s-worker" .Values.app.name }}
spec:
containers:
- name: {{ printf "%s-worker" .Values.app.name }}
image: "{{- if .Values.image.digest -}}{{ .Values.image.repository }}@{{ .Values.image.digest }}{{- else -}}{{ .Values.image.repository }}:{{ default "latest" .Values.image.tag }}{{- end -}}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
command:
- celery
- -A
- app.celery_app
- worker
- -Q
- $(MAIL_QUEUE)
- --loglevel
- INFO
env:
- name: MARIADB_HOST
value: "mariadb-repl-maxscale-internal.mariadb-operator.svc.cluster.local"
- name: MARIADB_PORT
value: '3306'
- name: MARIADB_DB
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_DB
- name: MARIADB_USER
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_USER
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_PASSWORD
- name: RABBITMQ_USERNAME
value: {{ .Values.rabbitmq.username | quote }}
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
name: prod
key: RABBITMQ_PASSWORD
- name: RABBITMQ_HOST
value: {{ printf "%s.%s.svc.cluster.local" "rabbitmq-cluster" .Release.Namespace | quote }}
- name: RABBITMQ_PORT
value: {{ .Values.rabbitmq.port | quote }}
- name: RABBITMQ_VHOST
value: {{ .Values.rabbitmq.vhost | default "/" | quote }}
- name: MAIL_QUEUE
value: {{ .Values.worker.mailQueueName | default "mail_queue" | quote }}
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: prod
key: SENTRY_DSN

View File

@@ -0,0 +1,5 @@
env: dev
mariadb:
cleanupPolicy: Delete

View File

@@ -0,0 +1,7 @@
env: prod
app:
replicas: 3
worker:
replicas: 3

View File

@@ -0,0 +1,74 @@
# Base values shared across environments
env: dev
# Optional PR number used to suffix DB name, set via --set prNumber=123 in CI
prNumber: ""
# Optional deployment identifier used to suffix resource names (db, user, secret)
# Example: --set deployment=alice or --set deployment=feature123
deployment: ""
# Public domain to expose the app under (used by TunnelBinding fqdn)
# Set at install time: --set domain=example.com
domain: ""
domain_scheme: ""
frontend_domain: ""
frontend_domain_scheme: ""
sentry_dsn: ""
image:
repository: lukastrkan/cc-app-demo
# You can use a tag or digest. If digest is provided, it takes precedence.
digest: ""
pullPolicy: IfNotPresent
app:
name: "finance-tracker"
replicas: 1
port: 8000
worker:
name: app-demo-worker
replicas: 1
# Queue name for Celery worker and for CRD Queue
mailQueueName: "mail_queue"
service:
port: 80
oauth:
bankid:
clientId: ""
clientSecret: ""
mojeid:
clientId: ""
clientSecret: ""
rabbitmq:
create: true
replicas: 1
storage: 5Gi
# Optional: override the generated cluster name; default is "<app.name>-rabbit[-<deployment>]"
clusterName: ""
port: "5672"
username: demo-app
password: ""
vhost: "/"
mariadb:
name: app-demo-database
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
# Database access resources
database:
userName: app-demo-user
secretName: app-demo-database-secret
password: ""

81
7project/checklist.md Normal file
View File

@@ -0,0 +1,81 @@
# Project Evaluation Checklist
The group earn points by completing items from the categories below.
You are not expected to complete all items.
Focus on areas that align with your project goals and interests.
The core deliverables are required.
This means that you must get at least 2 points for each item in this category.
| **Category** | **Item** | **Max Points** | **Points** |
| -------------------------------- | --------------------------------------- | -------------- | ---------------- |
| **Core Deliverables (Required)** | | | |
| Codebase & Organization | Well-organized project structure | 5 | |
| | Clean, readable code | 5 | |
| | Use planning tool (e.g., GitHub issues) | 5 | |
| | Proper version control usage | 5 | |
| | Complete source code | 5 | |
| Documentation | Comprehensive reproducibility report | 10 | |
| | Updated design document | 5 | |
| | Clear build/deployment instructions | 5 | |
| | Troubleshooting guide | 5 | |
| | Completed self-assessment table | 5 | |
| | Hour sheets for all members | 5 | |
| Presentation Video | Project demonstration | 5 | |
| | Code walk-through | 5 | |
| | Deployment showcase | 5 | |
| **Technical Implementation** | | | |
| Application Functionality | Basic functionality works | 10 | |
| | Advanced features implemented | 10 | |
| | Error handling & robustness | 10 | |
| | User-friendly interface | 5 | |
| Backend & Architecture | Stateless web server | 5 | |
| | Stateful application | 10 | |
| | Database integration | 10 | |
| | API design | 5 | |
| | Microservices architecture | 10 | |
| Cloud Integration | Basic cloud deployment | 10 | |
| | Cloud APIs usage | 10 | |
| | Serverless components | 10 | |
| | Advanced cloud services | 5 | |
| **DevOps & Deployment** | | | |
| Containerization | Basic Dockerfile | 5 | |
| | Optimized Dockerfile | 5 | |
| | Docker Compose | 5 | |
| | Persistent storage | 5 | |
| Deployment & Scaling | Manual deployment | 5 | |
| | Automated deployment | 5 | |
| | Multiple replicas | 5 | |
| | Kubernetes deployment | 10 | |
| **Quality Assurance** | | | |
| Testing | Unit tests | 5 | |
| | Integration tests | 5 | |
| | End-to-end tests | 5 | |
| | Performance testing | 5 | |
| Monitoring & Operations | Health checks | 5 | |
| | Logging | 5 | |
| | Metrics/Monitoring | 5 | |
| Security | HTTPS/TLS | 5 | |
| | Authentication | 5 | |
| | Authorization | 5 | |
| **Innovation & Excellence** | | | |
| Advanced Features and | AI/ML Integration | 10 | |
| Technical Excellence | Real-time features | 10 | |
| | Creative problem solving | 10 | |
| | Performance optimization | 5 | |
| | Exceptional user experience | 5 | |
| **Total** | | **255** | **[Your Total]** |
## Grading Scale
- **Minimum Required: 100 points**
- **Maximum: 200+ points**
| Grade | Points |
| ----- | -------- |
| A | 180-200+ |
| B | 160-179 |
| C | 140-159 |
| D | 120-139 |
| E | 100-119 |
| F | 0-99 |

View File

@@ -15,7 +15,7 @@ services:
volumes: volumes:
- redis_data:/data - redis_data:/data
rabbitmq: rabbitmq:
image: bitnami/rabbitmq:3.13.3-debian-12-r0 image: bitnamilegacy/rabbitmq:3.13.3-debian-12-r0
network_mode: host network_mode: host
ports: ports:
- "5672:5672" - "5672:5672"

View File

@@ -8,4 +8,8 @@ fi
cd backend || { echo "Directory 'backend' does not exist"; exit 1; } cd backend || { echo "Directory 'backend' does not exist"; exit 1; }
alembic revision --autogenerate -m "$1" alembic revision --autogenerate -m "$1"
git add alembic/versions/* git add alembic/versions/*
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Don't forget to check imports in the new migration file!${NC}"
cd - || exit cd - || exit

View File

@@ -4,7 +4,7 @@ This template provides a minimal setup to get React working in Vite with HMR and
Currently, two official plugins are available: Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler ## React Compiler

View File

@@ -13,15 +13,16 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/react": "^19.1.13", "@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"typescript": "~5.8.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.44.0", "typescript-eslint": "^8.45.0",
"vite": "^7.1.7" "vite": "^7.1.7"
} }
}, },
@@ -807,19 +808,22 @@
} }
}, },
"node_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.3.1", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.16.0"
},
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.15.2", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -867,9 +871,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.36.0", "version": "9.37.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
"integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -890,13 +894,13 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.3.5", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.15.2", "@eslint/core": "^0.16.0",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@@ -1044,16 +1048,16 @@
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.35", "version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
"integrity": "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==", "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
"integrity": "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ==", "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1065,9 +1069,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
"integrity": "sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw==", "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1079,9 +1083,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
"integrity": "sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA==", "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1093,9 +1097,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
"integrity": "sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ==", "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1107,9 +1111,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
"integrity": "sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw==", "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1121,9 +1125,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
"integrity": "sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng==", "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1135,9 +1139,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
"integrity": "sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==", "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1149,9 +1153,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
"integrity": "sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==", "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1163,9 +1167,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
"integrity": "sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==", "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1177,9 +1181,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
"integrity": "sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==", "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1191,9 +1195,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
"integrity": "sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==", "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1205,9 +1209,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
"integrity": "sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==", "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1219,9 +1223,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
"integrity": "sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==", "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1233,9 +1237,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
"integrity": "sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==", "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1247,9 +1251,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
"integrity": "sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==", "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1261,9 +1265,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
"integrity": "sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==", "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1275,9 +1279,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
"integrity": "sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==", "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1289,9 +1293,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
"integrity": "sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==", "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1303,9 +1307,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
"integrity": "sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA==", "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1317,9 +1321,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
"integrity": "sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A==", "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1331,9 +1335,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
"integrity": "sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA==", "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1345,9 +1349,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
"integrity": "sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw==", "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1417,10 +1421,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "24.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz",
"integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.14.0"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.13", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1428,27 +1442,27 @@
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "19.1.9", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
"integrity": "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==", "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/scope-manager": "8.45.0",
"@typescript-eslint/type-utils": "8.44.1", "@typescript-eslint/type-utils": "8.45.0",
"@typescript-eslint/utils": "8.44.1", "@typescript-eslint/utils": "8.45.0",
"@typescript-eslint/visitor-keys": "8.44.1", "@typescript-eslint/visitor-keys": "8.45.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -1462,7 +1476,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.45.0",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
@@ -1478,16 +1492,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz",
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/scope-manager": "8.45.0",
"@typescript-eslint/types": "8.44.1", "@typescript-eslint/types": "8.45.0",
"@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/typescript-estree": "8.45.0",
"@typescript-eslint/visitor-keys": "8.44.1", "@typescript-eslint/visitor-keys": "8.45.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@@ -1503,14 +1517,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz",
"integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.44.1", "@typescript-eslint/tsconfig-utils": "^8.45.0",
"@typescript-eslint/types": "^8.44.1", "@typescript-eslint/types": "^8.45.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@@ -1525,14 +1539,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz",
"integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.44.1", "@typescript-eslint/types": "8.45.0",
"@typescript-eslint/visitor-keys": "8.44.1" "@typescript-eslint/visitor-keys": "8.45.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1543,9 +1557,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz",
"integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1560,15 +1574,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz",
"integrity": "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==", "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.44.1", "@typescript-eslint/types": "8.45.0",
"@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/typescript-estree": "8.45.0",
"@typescript-eslint/utils": "8.44.1", "@typescript-eslint/utils": "8.45.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.1.0"
}, },
@@ -1585,9 +1599,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz",
"integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1599,16 +1613,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz",
"integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.44.1", "@typescript-eslint/project-service": "8.45.0",
"@typescript-eslint/tsconfig-utils": "8.44.1", "@typescript-eslint/tsconfig-utils": "8.45.0",
"@typescript-eslint/types": "8.44.1", "@typescript-eslint/types": "8.45.0",
"@typescript-eslint/visitor-keys": "8.44.1", "@typescript-eslint/visitor-keys": "8.45.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@@ -1667,16 +1681,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz",
"integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/scope-manager": "8.45.0",
"@typescript-eslint/types": "8.44.1", "@typescript-eslint/types": "8.45.0",
"@typescript-eslint/typescript-estree": "8.44.1" "@typescript-eslint/typescript-estree": "8.45.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1691,13 +1705,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz",
"integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.44.1", "@typescript-eslint/types": "8.45.0",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@@ -1709,16 +1723,16 @@
} }
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.0.3", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
"integrity": "sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==", "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.28.4", "@babel/core": "^7.28.4",
"@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.35", "@rolldown/pluginutils": "1.0.0-beta.38",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0" "react-refresh": "^0.17.0"
}, },
@@ -1800,9 +1814,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.6", "version": "2.8.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.12.tgz",
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", "integrity": "sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -1834,9 +1848,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.26.2", "version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1854,9 +1868,9 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.218", "electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21", "node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3" "update-browserslist-db": "^1.1.3"
}, },
@@ -1878,9 +1892,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001743", "version": "1.0.30001748",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz",
"integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", "integrity": "sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1997,9 +2011,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.223", "version": "1.5.230",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.223.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz",
"integrity": "sha512-qKm55ic6nbEmagFlTFczML33rF90aU+WtrJ9MdTCThrcvDNdUHN4p6QfVN78U06ZmguqXIyMPyYhw2TrbDUwPQ==", "integrity": "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -2069,20 +2083,20 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.36.0", "version": "9.37.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0", "@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.1", "@eslint/config-helpers": "^0.4.0",
"@eslint/core": "^0.15.2", "@eslint/core": "^0.16.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.36.0", "@eslint/js": "9.37.0",
"@eslint/plugin-kit": "^0.3.5", "@eslint/plugin-kit": "^0.4.0",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
@@ -2143,9 +2157,9 @@
} }
}, },
"node_modules/eslint-plugin-react-refresh": { "node_modules/eslint-plugin-react-refresh": {
"version": "0.4.21", "version": "0.4.23",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.21.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.23.tgz",
"integrity": "sha512-MWDWTtNC4voTcWDxXbdmBNe8b/TxfxRFUL6hXgKWJjN9c1AagYEmpiFWBWzDw+5H3SulWUe1pJKTnoSdmk88UA==", "integrity": "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@@ -2711,9 +2725,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.21", "version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
"integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2891,24 +2905,24 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.1.1", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.1", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.27.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.1.1" "react": "^19.2.0"
} }
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
@@ -2943,9 +2957,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.52.2", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2959,28 +2973,28 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.52.2", "@rollup/rollup-android-arm-eabi": "4.52.4",
"@rollup/rollup-android-arm64": "4.52.2", "@rollup/rollup-android-arm64": "4.52.4",
"@rollup/rollup-darwin-arm64": "4.52.2", "@rollup/rollup-darwin-arm64": "4.52.4",
"@rollup/rollup-darwin-x64": "4.52.2", "@rollup/rollup-darwin-x64": "4.52.4",
"@rollup/rollup-freebsd-arm64": "4.52.2", "@rollup/rollup-freebsd-arm64": "4.52.4",
"@rollup/rollup-freebsd-x64": "4.52.2", "@rollup/rollup-freebsd-x64": "4.52.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.2", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
"@rollup/rollup-linux-arm-musleabihf": "4.52.2", "@rollup/rollup-linux-arm-musleabihf": "4.52.4",
"@rollup/rollup-linux-arm64-gnu": "4.52.2", "@rollup/rollup-linux-arm64-gnu": "4.52.4",
"@rollup/rollup-linux-arm64-musl": "4.52.2", "@rollup/rollup-linux-arm64-musl": "4.52.4",
"@rollup/rollup-linux-loong64-gnu": "4.52.2", "@rollup/rollup-linux-loong64-gnu": "4.52.4",
"@rollup/rollup-linux-ppc64-gnu": "4.52.2", "@rollup/rollup-linux-ppc64-gnu": "4.52.4",
"@rollup/rollup-linux-riscv64-gnu": "4.52.2", "@rollup/rollup-linux-riscv64-gnu": "4.52.4",
"@rollup/rollup-linux-riscv64-musl": "4.52.2", "@rollup/rollup-linux-riscv64-musl": "4.52.4",
"@rollup/rollup-linux-s390x-gnu": "4.52.2", "@rollup/rollup-linux-s390x-gnu": "4.52.4",
"@rollup/rollup-linux-x64-gnu": "4.52.2", "@rollup/rollup-linux-x64-gnu": "4.52.4",
"@rollup/rollup-linux-x64-musl": "4.52.2", "@rollup/rollup-linux-x64-musl": "4.52.4",
"@rollup/rollup-openharmony-arm64": "4.52.2", "@rollup/rollup-openharmony-arm64": "4.52.4",
"@rollup/rollup-win32-arm64-msvc": "4.52.2", "@rollup/rollup-win32-arm64-msvc": "4.52.4",
"@rollup/rollup-win32-ia32-msvc": "4.52.2", "@rollup/rollup-win32-ia32-msvc": "4.52.4",
"@rollup/rollup-win32-x64-gnu": "4.52.2", "@rollup/rollup-win32-x64-gnu": "4.52.4",
"@rollup/rollup-win32-x64-msvc": "4.52.2", "@rollup/rollup-win32-x64-msvc": "4.52.4",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -3009,9 +3023,9 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
@@ -3171,9 +3185,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3185,16 +3199,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.44.1", "version": "8.45.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.44.1.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.45.0.tgz",
"integrity": "sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg==", "integrity": "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.44.1", "@typescript-eslint/eslint-plugin": "8.45.0",
"@typescript-eslint/parser": "8.44.1", "@typescript-eslint/parser": "8.45.0",
"@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/typescript-estree": "8.45.0",
"@typescript-eslint/utils": "8.44.1" "@typescript-eslint/utils": "8.45.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3208,6 +3222,13 @@
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/undici-types": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -3250,9 +3271,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.7", "version": "7.1.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -15,15 +15,16 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/react": "^19.1.13", "@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"typescript": "~5.8.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.44.0", "typescript-eslint": "^8.45.0",
"vite": "^7.1.7" "vite": "^7.1.7"
} }
} }

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
import './App.css';
import LoginRegisterPage from './pages/LoginRegisterPage';
import Dashboard from './pages/Dashboard';
import { logout } from './api';
function App() {
const [hasToken, setHasToken] = useState<boolean>(!!localStorage.getItem('token'));
useEffect(() => {
// Handle OAuth callback: /oauth-callback?access_token=...&token_type=...
if (window.location.pathname === '/oauth-callback') {
const params = new URLSearchParams(window.location.search);
const token = params.get('access_token');
if (token) {
localStorage.setItem('token', token);
setHasToken(true);
}
// Clean URL and redirect to home
window.history.replaceState({}, '', '/');
}
const onStorage = (e: StorageEvent) => {
if (e.key === 'token') setHasToken(!!e.newValue);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
if (!hasToken) {
return <LoginRegisterPage onLoggedIn={() => setHasToken(true)} />;
}
return (
<Dashboard onLogout={() => { logout(); setHasToken(false); }} />
);
}
export default App;

View File

@@ -0,0 +1,155 @@
import { BACKEND_URL } from './config';
export type LoginResponse = {
access_token: string;
token_type: string;
};
export type Category = {
id: number;
name: string;
description?: string | null;
};
export type Transaction = {
id: number;
amount: number;
description?: string | null;
category_ids: number[];
};
function getBaseUrl() {
const base = BACKEND_URL?.replace(/\/$/, '') || '';
return base || '';
}
function getHeaders(contentType: 'json' | 'form' | 'none' = 'json'): Record<string, string> {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (contentType === 'json') {
headers['Content-Type'] = 'application/json';
} else if (contentType === 'form') {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
export async function login(email: string, password: string): Promise<void> {
const body = new URLSearchParams();
body.set('username', email);
body.set('password', password);
const res = await fetch(`${getBaseUrl()}/auth/jwt/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Login failed');
}
const data: LoginResponse = await res.json();
localStorage.setItem('token', data.access_token);
}
export async function register(email: string, password: string, first_name?: string, last_name?: string): Promise<void> {
const res = await fetch(`${getBaseUrl()}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, first_name, last_name }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Registration failed');
}
}
export async function getCategories(): Promise<Category[]> {
const res = await fetch(`${getBaseUrl()}/categories/`, {
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to load categories');
return res.json();
}
export type CreateTransactionInput = {
amount: number;
description?: string;
category_ids?: number[];
};
export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> {
const res = await fetch(`${getBaseUrl()}/transactions/create`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to create transaction');
}
return res.json();
}
export async function getTransactions(): Promise<Transaction[]> {
const res = await fetch(`${getBaseUrl()}/transactions/`, {
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to load transactions');
return res.json();
}
export type User = {
id: string;
email: string;
first_name?: string | null;
last_name?: string | null;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
};
export async function getMe(): Promise<User> {
const res = await fetch(`${getBaseUrl()}/users/me`, {
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to load user');
return res.json();
}
export type UpdateMeInput = Partial<Pick<User, 'first_name' | 'last_name'>> & { password?: string };
export async function updateMe(input: UpdateMeInput): Promise<User> {
const res = await fetch(`${getBaseUrl()}/users/me`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update user');
}
return res.json();
}
export async function deleteMe(): Promise<void> {
const res = await fetch(`${getBaseUrl()}/users/me`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to delete account');
}
}
export function logout() {
localStorage.removeItem('token');
}

View File

@@ -0,0 +1,38 @@
export type Theme = 'system' | 'light' | 'dark';
export type FontSize = 'small' | 'medium' | 'large';
const THEME_KEY = 'app_theme';
const FONT_KEY = 'app_font_size';
export function applyTheme(theme: Theme) {
const body = document.body;
const effective = theme === 'system' ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme;
body.setAttribute('data-theme', effective);
}
export function applyFontSize(size: FontSize) {
const root = document.documentElement;
const map: Record<FontSize, string> = {
small: '14px',
medium: '16px',
large: '18px',
};
root.style.fontSize = map[size];
}
export function saveAppearance(theme: Theme, size: FontSize) {
localStorage.setItem(THEME_KEY, theme);
localStorage.setItem(FONT_KEY, size);
}
export function loadAppearance(): { theme: Theme; size: FontSize } {
const theme = (localStorage.getItem(THEME_KEY) as Theme) || 'light';
const size = (localStorage.getItem(FONT_KEY) as FontSize) || 'medium';
return { theme, size };
}
export function applyAppearanceFromStorage() {
const { theme, size } = loadAppearance();
applyTheme(theme);
applyFontSize(size);
}

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,2 @@
export const BACKEND_URL: string =
import.meta.env.VITE_BACKEND_URL ?? '';

View File

@@ -1,7 +1,11 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './ui.css'
import App from './App.tsx' import App from './App.tsx'
import { applyAppearanceFromStorage } from './appearance'
applyAppearanceFromStorage()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { deleteMe, getMe, type UpdateMeInput, type User, updateMe } from '../api';
export default function AccountPage({ onDeleted }: { onDeleted: () => void }) {
const [user, setUser] = useState<User | null>(null);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const u = await getMe();
setUser(u);
setFirstName(u.first_name || '');
setLastName(u.last_name || '');
} catch (e: any) {
setError(e?.message || 'Failed to load account');
} finally {
setLoading(false);
}
})();
}, []);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const payload: UpdateMeInput = { first_name: firstName || null as any, last_name: lastName || null as any };
const updated = await updateMe(payload);
setUser(updated);
} catch (e: any) {
setError(e?.message || 'Failed to update');
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!confirm('Are you sure you want to delete your account? This cannot be undone.')) return;
try {
await deleteMe();
onDeleted();
} catch (e: any) {
alert(e?.message || 'Failed to delete account');
}
}
return (
<section className="card">
<h3>Account</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : !user ? (
<div>Not signed in</div>
) : (
<div className="space-y">
<div className="muted">Email: <strong>{user.email}</strong></div>
<form onSubmit={handleSave} className="space-y">
<div className="form-row">
<div>
<label className="muted">First name</label>
<input className="input" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<label className="muted">Last name</label>
<input className="input" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="actions" style={{ justifyContent: 'flex-end' }}>
<button className="btn primary" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</button>
</div>
</form>
<div className="actions" style={{ justifyContent: 'space-between' }}>
<div className="muted"></div>
<button className="btn" style={{ borderColor: 'crimson', color: 'crimson' }} onClick={handleDelete}>Delete account</button>
</div>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
import { applyFontSize, applyTheme, loadAppearance, saveAppearance, type FontSize, type Theme } from '../appearance';
export default function AppearancePage() {
const [theme, setTheme] = useState<Theme>('light');
const [size, setSize] = useState<FontSize>('medium');
useEffect(() => {
const { theme, size } = loadAppearance();
setTheme(theme);
setSize(size);
}, []);
function onThemeChange(next: Theme) {
setTheme(next);
applyTheme(next);
saveAppearance(next, size);
}
function onSizeChange(next: FontSize) {
setSize(next);
applyFontSize(next);
saveAppearance(theme, next);
}
return (
<section className="card">
<h3>Appearance</h3>
<div className="space-y">
<div>
<div className="muted" style={{ marginBottom: 6 }}>Theme</div>
<div className="segmented">
<button className={theme === 'light' ? 'active' : ''} onClick={() => onThemeChange('light')}>Light</button>
<button className={theme === 'dark' ? 'active' : ''} onClick={() => onThemeChange('dark')}>Dark</button>
<button className={theme === 'system' ? 'active' : ''} onClick={() => onThemeChange('system')}>System</button>
</div>
</div>
<div>
<div className="muted" style={{ marginBottom: 6 }}>Font size</div>
<div className="segmented">
<button className={size === 'small' ? 'active' : ''} onClick={() => onSizeChange('small')}>Small</button>
<button className={size === 'medium' ? 'active' : ''} onClick={() => onSizeChange('medium')}>Medium</button>
<button className={size === 'large' ? 'active' : ''} onClick={() => onSizeChange('large')}>Large</button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [current, setCurrent] = useState<'home' | 'account' | 'appearance'>('home');
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// New transaction form state
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
// Filters
const [minAmount, setMinAmount] = useState<string>('');
const [maxAmount, setMaxAmount] = useState<string>('');
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
const [searchText, setSearchText] = useState('');
async function loadAll() {
setLoading(true);
setError(null);
try {
const [txs, cats] = await Promise.all([getTransactions(), getCategories()]);
setTransactions(txs);
setCategories(cats);
} catch (err: any) {
setError(err?.message || 'Failed to load data');
} finally {
setLoading(false);
}
}
useEffect(() => { loadAll(); }, []);
const last10 = useMemo(() => {
const sorted = [...transactions].sort((a, b) => b.id - a.id);
return sorted.slice(0, 10);
}, [transactions]);
const filtered = useMemo(() => {
let arr = last10;
const min = minAmount !== '' ? Number(minAmount) : undefined;
const max = maxAmount !== '' ? Number(maxAmount) : undefined;
if (min !== undefined) arr = arr.filter(t => t.amount >= min);
if (max !== undefined) arr = arr.filter(t => t.amount <= max);
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]);
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!amount) return;
const payload = {
amount: Number(amount),
description: description || undefined,
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
};
try {
const created = await createTransaction(payload);
setTransactions(prev => [created, ...prev]);
setAmount(''); setDescription(''); setSelectedCategoryId('');
} catch (err: any) {
alert(err?.message || 'Failed to create transaction');
}
}
return (
<div className="app-layout">
<aside className="sidebar">
<div className="logo">7Project</div>
<nav className="nav">
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav>
</aside>
<div className="content">
<div className="topbar">
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'account' ? 'Account' : 'Appearance'}</h2>
<div className="actions">
<span className="user muted">Signed in</span>
<button className="btn" onClick={onLogout}>Logout</button>
</div>
</div>
<main className="page space-y">
{current === 'home' && (
<>
<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="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>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<button className="btn primary" type="submit">Add</button>
</form>
</section>
<section className="card">
<h3>Filters</h3>
<div className="form-row">
<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) : '')}>
<option value="">All categories</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<input className="input" type="text" placeholder="Search in description" value={searchText} onChange={(e) => setSearchText(e.target.value)} />
</div>
</section>
<section className="card">
<h3>Latest Transactions (last 10)</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : 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>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
{current === 'account' && (
// lazy import avoided for simplicity
<AccountPage onDeleted={onLogout} />
)}
{current === 'appearance' && (
<AppearancePage />
)}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react';
import { login, register } from '../api';
import { BACKEND_URL } from '../config';
function oauthUrl(provider: 'mojeid' | 'bankid') {
const base = BACKEND_URL.replace(/\/$/, '');
const redirect = encodeURIComponent(window.location.origin + '/oauth-callback');
return `${base}/auth/${provider}/authorize?redirect_url=${redirect}`;
}
export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
if (mode === 'login') {
await login(email, password);
onLoggedIn();
} else {
await register(email, password, firstName || undefined, lastName || undefined);
// After register, prompt login automatically
await login(email, password);
onLoggedIn();
}
} catch (err: any) {
setError(err?.message || 'Operation failed');
} finally {
setLoading(false);
}
}
// Add this useEffect hook
useEffect(() => {
// When the component mounts, add a class to the body
document.body.classList.add('auth-page');
// When the component unmounts, remove the class
return () => {
document.body.classList.remove('auth-page');
};
}, []); // The empty array ensures this runs only once
// The JSX no longer needs the wrapper div
return (
<div className="card" style={{ width: 420 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>{mode === 'login' ? 'Welcome back' : 'Create your account'}</h2>
<div className="segmented">
<button className={mode === 'login' ? 'active' : ''} type="button" onClick={() => setMode('login')}>Login</button>
<button className={mode === 'register' ? 'active' : ''} type="button" onClick={() => setMode('register')}>Register</button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y">
<div>
<label className="muted">Email</label>
<input className="input" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label className="muted">Password</label>
<input className="input" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{mode === 'register' && (
<div className="form-row">
<div>
<label className="muted">First name (optional)</label>
<input className="input" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<label className="muted">Last name (optional)</label>
<input className="input" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
)}
{error && <div style={{ color: 'crimson' }}>{error}</div>}
<div className="actions" style={{ justifyContent: 'space-between' }}>
<div className="muted">Or continue with</div>
<div className="actions">
<a className="btn" href={oauthUrl('mojeid')}>MojeID</a>
<a className="btn" href={oauthUrl('bankid')}>BankID</a>
<button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,85 @@
:root {
--bg: #f7f7fb;
--panel: #ffffff;
--text: #9aa3b2;
--muted: #6b7280;
--primary: #6f49fe;
--primary-600: #5a37fb;
--border: #e5e7eb;
--radius: 12px;
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.08);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--text);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { background: var(--bg); margin: 0; display: block; }
/* Dark theme variables */
body[data-theme="dark"] {
--bg: #161a2b;
--panel: #283046;
--text: #283046;
--muted: #cbd5e1;
--primary: #8b7bff;
--primary-600: #7b69ff;
--border: #283046;
}
/* Layout */
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; }
.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); }
.topbar .user { color: var(--muted); }
.page { padding: 24px; max-width: 1100px; margin: auto; }
/* 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); }
.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); }
.form-row > * { min-width: 140px; }
.actions { display: flex; align-items: center; gap: 8px; }
/* Buttons */
.btn { border: 1px solid var(--border); background: #fff; color: var(--text); padding: 10px 14px; border-radius: 10px; cursor: pointer; }
.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); }
/* 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; }
/* Segmented control */
.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); }
.segmented button { border: 0; background: transparent; padding: 8px 12px; border-radius: 8px; color: var(--muted); cursor: pointer; }
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow); }
/* Auth layout */
body.auth-page #root {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
}
/* Utility */
.muted { color: var(--muted); }
.space-y > * + * { margin-top: 12px; }

View File

@@ -4,7 +4,7 @@
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023"], "lib": ["ES2023"],
"module": "ESNext", "module": "ESNext",
"types": [], "types": ["node"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */

View File

@@ -0,0 +1,53 @@
# 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-08
- Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski
## Progress Update (Before Meeting)
Summary of what has been accomplished since the last meeting in the following categories.
## Action Items from Last Week (During Meeting)
- [x] start coding the app logic
- [x] start writing the report so it matches the actual progress
- [x] redo the system diagram so it includes a response flow
### Coding
Implemented initial functioning version of the app, added OAuth with BankId and MojeID,
added database snapshots.
### Documentation
report.md is up to date
## Questions and Topics for Discussion (Before Meeting)
Prepare 3-5 questions and topics you want to discuss with your mentor.
1. What other functionality should be added to the app
2. Priority for the next week (Testing maybe?)
3. Question 3
## Discussion Notes (During Meeting)
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] OAuth
- [ ] CI/CD fix
- [ ] Database local (multiple bank accounts)
- [ ] Add tests and set up github pipeline
- [ ] Frontend imporvment - user experience
- [ ] make the report more clear
---

Some files were not shown because too many files have changed in this diff Show More