From 80a01a10b309904f603258dc058760ec84e7b234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Mon, 26 May 2025 21:45:15 +0200 Subject: [PATCH 01/20] Hotfix Can only click on stats detaiils when availible (#85) * update dependencies * adds loading spinner to dashboard * Hotfix: Stats details can only be clicked when stats avail. --- packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx b/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx index dceba79..7896421 100644 --- a/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx +++ b/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx @@ -185,8 +185,8 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat textUnderlineOffset: "3px", // color: $primary }} - onClick={() => - tryActor.forEach(actor => + // Only activate question stats if there are details available + onClick={() => noDetails ? null : tryActor.forEach(actor => actor.send( actor, CurrentQuizMessages.ActivateQuestionStats( From 39d3e8659fccba37e315a269efe3d3c210edfabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Mon, 26 May 2025 21:47:15 +0200 Subject: [PATCH 02/20] Revert "Hotfix Can only click on stats detaiils when availible (#85)" (#86) This reverts commit 80a01a10b309904f603258dc058760ec84e7b234. --- packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx b/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx index 7896421..dceba79 100644 --- a/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx +++ b/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx @@ -185,8 +185,8 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat textUnderlineOffset: "3px", // color: $primary }} - // Only activate question stats if there are details available - onClick={() => noDetails ? null : tryActor.forEach(actor => + onClick={() => + tryActor.forEach(actor => actor.send( actor, CurrentQuizMessages.ActivateQuestionStats( From b7617a0b4ed86182bfe3011e301d4f1b252818f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Wed, 4 Jun 2025 14:10:52 +0200 Subject: [PATCH 03/20] Create deploy-test.yml --- .github/workflows/deploy-test.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/deploy-test.yml diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml new file mode 100644 index 0000000..64269b2 --- /dev/null +++ b/.github/workflows/deploy-test.yml @@ -0,0 +1,26 @@ +name: Deploy to Test Server + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.TEST_SSH_PRIVATE_KEY }} + + - name: Add test server to known_hosts + run: | + mkdir -p ~/.ssh + echo "${{ secrets.TEST_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts + + - name: Trigger remote deploy script + run: | + ssh -o StrictHostKeyChecking=no \ + ${{ secrets.TEST_SERVER_USER }}@${{ secrets.TEST_SERVER_HOST }} \ + "bash ~/deploy_recapp_to_test.sh" From 6312fa68e48933b436b2266befbbe5640a3af368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Wed, 4 Jun 2025 14:14:29 +0200 Subject: [PATCH 04/20] Update deploy-test.yml --- .github/workflows/deploy-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 64269b2..aaa93ba 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: jobs: deploy: From 90f1e044c01e7d1a5957b29243615c555fc97005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Wed, 4 Jun 2025 14:25:52 +0200 Subject: [PATCH 05/20] Update deploy-test.yml --- .github/workflows/deploy-test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index aaa93ba..bd98115 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -4,11 +4,15 @@ on: push: branches: - main + - 'feature/**' workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest + env: + BRANCH_NAME: ${{ github.ref_name }} + steps: - name: Set up SSH agent uses: webfactory/ssh-agent@v0.7.0 @@ -24,4 +28,10 @@ jobs: run: | ssh -o StrictHostKeyChecking=no \ ${{ secrets.TEST_SERVER_USER }}@${{ secrets.TEST_SERVER_HOST }} \ + set -e + cd ~/recapp + git fetch origin + git checkout --force "${BRANCH_NAME}" "bash ~/deploy_recapp_to_test.sh" + echo "✅ Deployed branch '${BRANCH_NAME}' to test server." + EOF From d9fc23f7d01126f9635775c0d26e974eb8919ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Wed, 4 Jun 2025 14:32:58 +0200 Subject: [PATCH 06/20] Update deploy-test.yml --- .github/workflows/deploy-test.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index bd98115..298be1b 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -24,14 +24,13 @@ jobs: mkdir -p ~/.ssh echo "${{ secrets.TEST_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts - - name: Trigger remote deploy script + - name: Deploy ${{ env.BRANCH_NAME }} to test server run: | + echo "➡️ Starting remote deployment of branch '${BRANCH_NAME}'" ssh -o StrictHostKeyChecking=no \ ${{ secrets.TEST_SERVER_USER }}@${{ secrets.TEST_SERVER_HOST }} \ - set -e - cd ~/recapp - git fetch origin - git checkout --force "${BRANCH_NAME}" - "bash ~/deploy_recapp_to_test.sh" - echo "✅ Deployed branch '${BRANCH_NAME}' to test server." - EOF + "cd ~/recapp && \ + git fetch origin && \ + git checkout --force \"${BRANCH_NAME}\" && \ + bash ~/deploy_recapp_to_test.sh" + echo "✅ Remote deployment of branch '${BRANCH_NAME}' succeeded" From 791f363f362c214f1cc02f8a1dcd29f20ec57640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Wed, 4 Jun 2025 14:40:03 +0200 Subject: [PATCH 07/20] Delete .github/workflows/deploy-check.yml --- .github/workflows/deploy-check.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/deploy-check.yml diff --git a/.github/workflows/deploy-check.yml b/.github/workflows/deploy-check.yml deleted file mode 100644 index 238bc51..0000000 --- a/.github/workflows/deploy-check.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: RECAPP deployment check - -on: - push: - branches: [main, testing, production] - pull_request: - branches: [main, testing, production] - workflow_dispatch: - -jobs: - deploy-recapp: - runs-on: ubuntu-24.04 - - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - - steps: - - name: Checkout the repo - uses: actions/checkout@v4 - - - name: run deployment script - run: sh deployment.sh $GITHUB_WORKSPACE force-build From f473b22a293706de206d97a3e453430b8b56042b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Wed, 4 Jun 2025 17:21:08 +0200 Subject: [PATCH 08/20] Update deployment.sh Updated for Docker deployment --- deployment.sh | 199 +++++++++++++++++++++----------------------------- 1 file changed, 84 insertions(+), 115 deletions(-) diff --git a/deployment.sh b/deployment.sh index 4cb6528..cb14349 100644 --- a/deployment.sh +++ b/deployment.sh @@ -1,131 +1,100 @@ -#!/bin/bash +#!/usr/bin/env bash +# +# deploy_recapp_to_test.sh +# This script deploys the RecApp application to the test environment. +# It assumes: +# - The deploy user has passwordless sudo for the needed npm/docker commands. +# - $HOME is the home directory of that user, and ~/recapp is the app folder. +# - npm scripts: stop:docker:prod, build:docker:prod, start:docker:prod exist in package.json. +# - set -e is in effect, so any command failing will abort the script. -# Auto-Deployment for recapp +set -euo pipefail -# Konfiguration -if [ $# -eq 0 ]; then - REPO_PATH="/home/cloud/recapp" - LOG_FILE="/home/cloud/recapp/deploy.log" -else - REPO_PATH="$1" - LOG_FILE="$1/deploy.log" -fi +# Define log file and rotation parameters +# The log file will be stored in the user's home directory. +# It will rotate when it exceeds 10 MB, keeping the last 3 old logs. +# The log file will be named deploy.log, and old logs will be named deploy.log.1, deploy.log.2, etc. +# The log rotation will be handled by the rotate_logs function. +LOG_DIR="$HOME" +LOG_FILE="$LOG_DIR/deploy.log" +MAX_LOG_SIZE=$((10 * 1024 * 1024)) # 10 MB +MAX_OLD_LOGS=3 -PM2_PROCESS_NAME="backend" +rotate_logs() { + # If deploy.log does not exist, nothing to rotate + [ -f "$LOG_FILE" ] || return -# Funktion zum Loggen -log() { - echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE" - echo "$1" -} + local actual_size + actual_size=$(stat -c%s "$LOG_FILE") + if [ "$actual_size" -le "$MAX_LOG_SIZE" ]; then + return + fi -# Funktion zum Prüfen von Änderungen im Remote -check_remote_changes() { - git fetch origin - local_branch=$(git rev-parse --abbrev-ref HEAD) - remote_branch="origin/$local_branch" - - if git diff --quiet "$local_branch" "$remote_branch"; then - log "No remote changes for current branch ($local_branch)." - return 1 - else - log "New commits detected for current branch ($local_branch)." - return 0 - fi -} + # Shift old logs: deploy.log.2 -> deploy.log.3, deploy.log.1 -> deploy.log.2, deploy.log -> deploy.log.1 + if [ -f "$LOG_DIR/deploy.log.$((MAX_OLD_LOGS - 1))" ]; then + rm -f "$LOG_DIR/deploy.log.$((MAX_OLD_LOGS - 1))" + fi -# Funktion zum Pullen von Änderungen -pull_changes() { - if git pull origin "$(git rev-parse --abbrev-ref HEAD)"; then - log "Pulling changes was successful." - return 0 - else - log "Error on pulling changes." - return 1 + for (( i=MAX_OLD_LOGS-1; i>=1; i-- )); do + if [ -f "$LOG_DIR/deploy.log.$i" ]; then + mv "$LOG_DIR/deploy.log.$i" "$LOG_DIR/deploy.log.$((i + 1))" fi -} + done -# Funktion zum Bauen der Projekte -build_projects() { - log "Starting rebuild" - - # Installiere Abhängigkeiten - if npm ci; then - log "Installed dependencies successfully." - else - log "Error on dependent package installation." - return 1 - fi - - # Führe Lerna build aus - if npx lerna run build; then - log "Build all packages." - return 0 - else - log "Error on building packages." - return 1 - fi + mv "$LOG_FILE" "$LOG_DIR/deploy.log.1" + : > "$LOG_FILE" + echo "$(date '+%Y-%m-%d %H:%M:%S') - Log rotated: previous log moved to deploy.log.1" >> "$LOG_FILE" } -# Funktion zum Neustarten des PM2-Prozesses -restart_pm2() { - if pm2 restart "$PM2_PROCESS_NAME"; then - log "Restartet backend." - return 0 - else - log "Error on restarting the backend." - return 1 - fi -} +# Rotate logs if necessary +rotate_logs -# Funktion zum Kopieren der Frontend-Dateien und Setzen der Rechte -change_frontend_permissions() { - if chmod -R o+r ./packages/frontend/dist/*; then - log "Made frontend build accessible by webserver." - return 0 - else - log "Could not change frontend access rights for webserver" - return 1 - fi +# Append a timestamped message to both the log file and stdout +log() { + local msg="$1" + echo "$(date '+%Y-%m-%d %H:%M:%S') - ${msg}" | tee -a "$LOG_FILE" } -# Funktion zum Zurücksetzen auf den letzten funktionierenden Stand -rollback() { - log "Errors occured. Rolling back." - git reset --hard HEAD~1 - build_projects - restart_pm2 - copy_frontend_files - log "Rollback finished." +# On any unexpected exit (non-zero), log it +on_error() { + local exit_code=$? + log "❌ Deployment script exited with code ${exit_code}." + exit "${exit_code}" } +trap on_error ERR -# Hauptfunktion -main() { - cd "$REPO_PATH" || { log "Fehler: Konnte nicht in das Repository-Verzeichnis wechseln."; exit 1; } - - if [ "$1" = "force-build" ]; then - log "Forced deployment." - if build_projects; then - log "New version was deployed" - else - log "An error occured" - exit 1 - fi - return - fi - - if check_remote_changes; then - if pull_changes && build_projects && restart_pm2 && change_frontend_permissions; then - log "New version was deployed" - else - log "An error occured" - rollback - exit 1 - fi - else - log "No action neccessary." - fi -} +log "=== Starting deployment to test environment ===" + +# Verify sudo privileges (without a password prompt) +if ! sudo -n true 2>/dev/null; then + log "ERROR: This script requires passwordless sudo privileges. Exiting." + exit 1 +fi + +# Ensure the recapp directory exists +REPO_DIR="$HOME/recapp" +if [ ! -d "$REPO_DIR" ]; then + log "ERROR: Directory '$REPO_DIR' not found. Cannot deploy." + exit 1 +fi + +cd "$REPO_DIR" + +# 1) Stop the existing Docker production container +log "Stopping existing production container..." +sudo npm run stop:docker:prod 2>&1 | tee -a "$LOG_FILE" + +# 2) Install dependencies (CI) +log "Installing npm dependencies for CI..." +sudo npm ci 2>&1 | tee -a "$LOG_FILE" + +# 3) Build the Docker image for production +log "Building Docker image for production..." +sudo npm run build:docker:prod 2>&1 | tee -a "$LOG_FILE" + +# 4) Start the new production container +log "Starting new production container..." +sudo npm run start:docker:prod 2>&1 | tee -a "$LOG_FILE" -# Ausführung der Hauptfunktion -main $2 +log "✅ Deployment to test environment completed successfully." +exit 0 From 88a71cccfab6659dabb83b689aac7a5b9da127d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Thu, 5 Jun 2025 12:47:21 +0200 Subject: [PATCH 09/20] Update deployment.sh Logrotate function explicitly returns 0 now. --- deployment.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deployment.sh b/deployment.sh index cb14349..a9f3a18 100644 --- a/deployment.sh +++ b/deployment.sh @@ -22,12 +22,12 @@ MAX_OLD_LOGS=3 rotate_logs() { # If deploy.log does not exist, nothing to rotate - [ -f "$LOG_FILE" ] || return + [ -f "$LOG_FILE" ] || return 0 local actual_size actual_size=$(stat -c%s "$LOG_FILE") if [ "$actual_size" -le "$MAX_LOG_SIZE" ]; then - return + return 0 fi # Shift old logs: deploy.log.2 -> deploy.log.3, deploy.log.1 -> deploy.log.2, deploy.log -> deploy.log.1 @@ -44,6 +44,7 @@ rotate_logs() { mv "$LOG_FILE" "$LOG_DIR/deploy.log.1" : > "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') - Log rotated: previous log moved to deploy.log.1" >> "$LOG_FILE" + return 0 } # Rotate logs if necessary From fe753d55bdcfa2fc1a50eca2a7d2feaac9e46611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Fri, 6 Jun 2025 23:37:44 +0200 Subject: [PATCH 10/20] =?UTF-8?q?=F0=9F=94=92=20Improve=20token=20handling?= =?UTF-8?q?=20in=20TokenActor:=20retry=20on=20failure=20and=20clear=20prev?= =?UTF-8?q?ious=20timeout=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/actors/TokenActor.ts | 68 +++++++++++++--------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/frontend/src/actors/TokenActor.ts b/packages/frontend/src/actors/TokenActor.ts index 3c5351a..60ffb7d 100644 --- a/packages/frontend/src/actors/TokenActor.ts +++ b/packages/frontend/src/actors/TokenActor.ts @@ -1,36 +1,50 @@ -import { Unit, unit, minutes } from "itu-utils"; +// packages/frontend/src/actors/TokenActor.ts + +import { Unit, unit } from "itu-utils"; import { Actor, ActorRef, ActorSystem } from "ts-actors"; import Axios from "axios"; import { cookie } from "../utils"; -const updateToken = () => { - const hasToken = !!cookie("bearer"); - if (hasToken) { - Axios.get(import.meta.env.VITE_BACKEND_URI + "/auth/refresh", { withCredentials: true }).catch(() => { - alert( - "Could not refresh token. Presumeably the authentication server is unavailable. Please report this error if it happens repeatedly." - ); - window.location.href = "/"; - }); - } -}; - export class TokenActor extends Actor { - public interval: any; + public interval: any; + private expiresAt: Date; + + public constructor(name: string, system: ActorSystem) { + super(name, system); + this.expiresAt = new Date(); // Initialize with a default value + } + + public override async afterStart(): Promise { + this.updateToken(); + } - public constructor(name: string, system: ActorSystem) { - super(name, system); - } + public override async beforeShutdown(): Promise { + clearTimeout(this.interval); + } - public override async afterStart(): Promise { - this.interval = setInterval(updateToken, minutes(import.meta.env.VITE_INACTIVITY_LIMIT).valueOf()); - } + private updateToken = () => { + const hasToken = !!cookie("bearer"); + if (hasToken) { + Axios.get(import.meta.env.VITE_BACKEND_URI + "/auth/refresh", { withCredentials: true }) + .then(response => { + this.expiresAt = new Date(response.data.expires_at); + this.scheduleNextUpdate(); + }) + .catch(error => { + console.error("Failed to refresh token:", error); + setTimeout(this.updateToken, 5000); // Retry after 5 seconds + }); + } + }; - public override async beforeShutdown(): Promise { - clearInterval(this.interval); - } + private scheduleNextUpdate = () => { + const buffer = 30000; // 30 seconds before expiry + const delay = this.expiresAt.getTime() - Date.now() - buffer; + clearTimeout(this.interval); // Clear previous timeout + this.interval = setTimeout(this.updateToken, delay); + }; - public async receive(_from: ActorRef, _message: unknown): Promise { - return unit(); - } -} + public async receive(_from: ActorRef, _message: unknown): Promise { + return unit(); + } +} \ No newline at end of file From 9af576c4e8bfa54808bc99274f5ad70160322c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Sat, 7 Jun 2025 00:01:52 +0200 Subject: [PATCH 11/20] Update deploy-test.yml --- .github/workflows/deploy-test.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 298be1b..d00a8de 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -29,8 +29,5 @@ jobs: echo "➡️ Starting remote deployment of branch '${BRANCH_NAME}'" ssh -o StrictHostKeyChecking=no \ ${{ secrets.TEST_SERVER_USER }}@${{ secrets.TEST_SERVER_HOST }} \ - "cd ~/recapp && \ - git fetch origin && \ - git checkout --force \"${BRANCH_NAME}\" && \ - bash ~/deploy_recapp_to_test.sh" + "bash ~/deploy_recapp_to_test.sh \"${BRANCH_NAME}\" echo "✅ Remote deployment of branch '${BRANCH_NAME}' succeeded" From 14c1d26b877d7b8c63abcf048f96978bb286778e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Sat, 7 Jun 2025 00:05:55 +0200 Subject: [PATCH 12/20] Update deploy-test.yml --- .github/workflows/deploy-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index d00a8de..270c150 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -29,5 +29,5 @@ jobs: echo "➡️ Starting remote deployment of branch '${BRANCH_NAME}'" ssh -o StrictHostKeyChecking=no \ ${{ secrets.TEST_SERVER_USER }}@${{ secrets.TEST_SERVER_HOST }} \ - "bash ~/deploy_recapp_to_test.sh \"${BRANCH_NAME}\" + "bash ~/deploy_recapp_to_test.sh \"${BRANCH_NAME}\"" echo "✅ Remote deployment of branch '${BRANCH_NAME}' succeeded" From 42f9fd40ef94276db49ffc878d14ff9da9f60cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Sat, 7 Jun 2025 00:11:27 +0200 Subject: [PATCH 13/20] Update deployment.sh --- deployment.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deployment.sh b/deployment.sh index a9f3a18..6357957 100644 --- a/deployment.sh +++ b/deployment.sh @@ -10,6 +10,8 @@ set -euo pipefail +BRANCH="${1:-main}" + # Define log file and rotation parameters # The log file will be stored in the user's home directory. # It will rotate when it exceeds 10 MB, keeping the last 3 old logs. @@ -49,7 +51,7 @@ rotate_logs() { # Rotate logs if necessary rotate_logs - +echo "test end" # Append a timestamped message to both the log file and stdout log() { local msg="$1" @@ -81,6 +83,12 @@ fi cd "$REPO_DIR" +log "Fetching origin..." +git fetch origin --prune + +log "Checking out branch '$BRANCH' (force)..." +git checkout --force -B "$BRANCH" "origin/$BRANCH" + # 1) Stop the existing Docker production container log "Stopping existing production container..." sudo npm run stop:docker:prod 2>&1 | tee -a "$LOG_FILE" From 86cc4c3403b772a84bcbd90de2944f79daffc199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Sat, 7 Jun 2025 00:20:22 +0200 Subject: [PATCH 14/20] Version 1.6.2 --- packages/frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 0130c58..844d8fb 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@recapp/frontend", "private": true, - "version": "1.6.1", + "version": "1.6.2", "type": "module", "scripts": { "dev": "vite", From 8e525f8571d9670c71d628672f33e8af652cf1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Sat, 7 Jun 2025 00:22:52 +0200 Subject: [PATCH 15/20] version 1.6.2 --- NEWS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 6716aa2..4fe1125 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,2 +1,2 @@ -# recapp 1.4.2 -* almost ready to go public +# recapp 1.6.2 + From 74cc2170743ce3ea110ea02009e4ef11c17f7f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Tue, 10 Jun 2025 23:12:08 +0200 Subject: [PATCH 16/20] :bug: fix(Root): defer auth cookie check to useEffect after actor initialization (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PR version 1.6.2 (#88) * Hotfix Can only click on stats detaiils when availible (#85) * update dependencies * adds loading spinner to dashboard * Hotfix: Stats details can only be clicked when stats avail. * Revert "Hotfix Can only click on stats detaiils when availible (#85)" (#86) This reverts commit 80a01a10b309904f603258dc058760ec84e7b234. * Delete .github/workflows/deploy-check.yml Updated for Docker deployment Logrotate function explicitly returns 0 now. * 🔒 Improve token handling in TokenActor: retry on failure and clear previous timeout (#87) * Update deploy-test.yml * Version 1.6.2 * :adhesive_bandage: fix(Root): defer auth cookie check to useEffect after actor initialization - Move the inline → redirect out of the render path - Perform the login‐redirect in a that runs once after - Prevents spurious redirects on re‐renders (e.g. when using React DevTools “pause”) --- .../src/components/navigation/Root.tsx | 123 ++++++++++-------- 1 file changed, 71 insertions(+), 52 deletions(-) diff --git a/packages/frontend/src/components/navigation/Root.tsx b/packages/frontend/src/components/navigation/Root.tsx index 650abfb..9bca1ce 100644 --- a/packages/frontend/src/components/navigation/Root.tsx +++ b/packages/frontend/src/components/navigation/Root.tsx @@ -1,62 +1,81 @@ +// src/components/navigation/Root.tsx + +import React, { useEffect, useState } from "react"; import { Outlet, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; -import { i18n } from "@lingui/core"; -import { Trans } from "@lingui/react"; +import { i18n } from "@lingui/core"; // keep if you use programmatic i18n APIs +import { Trans } from "@lingui/react"; // keep if you’re using anywhere import { Layout } from "../../layout/Layout"; import { SystemContext } from "ts-actors-react"; - import { Button, Modal } from "react-bootstrap"; import { cookie } from "../../utils"; import { system } from "../../system"; import { ActorSystem } from "ts-actors"; import { Try, fromError, fromValue } from "tsmonads"; -export const Root = () => { - const [init, setInit] = useState>(fromError(new Error())); - const [rpcError, setRpcError] = useState(""); - - const navigate = useNavigate(); - const onRpcError = () => { - setRpcError(""); - document.location.href = `${import.meta.env.VITE_BACKEND_URI}/auth/logout`; - }; - - useEffect(() => { - const run = async () => { - try { - const s: ActorSystem = await system; - setInit(fromValue(s)); - } catch (e) { - setInit(fromError(e as Error)); - } - }; - run(); - }, []); - - if (rpcError !== "") { - return ( - - {i18n._(rpcError + "-title")} - - - - - - - - ); - } - if (!init) { - return null; - } - if (!cookie("bearer")) { - navigate("/", { replace: true }); - } - return ( - - - - - - ); +export const Root: React.FC = () => { + const [init, setInit] = useState>(fromError(new Error())); + const [rpcError, setRpcError] = useState(""); + const navigate = useNavigate(); + + const onRpcError = () => { + setRpcError(""); + document.location.href = `${import.meta.env.VITE_BACKEND_URI}/auth/logout`; + }; + + // 1) Initialize the actor system once on mount + useEffect(() => { + const run = async () => { + try { + const s: ActorSystem = await system; + setInit(fromValue(s)); + } catch (e) { + setInit(fromError(e as Error)); + } + }; + run(); + }, []); + + // 2) After initialization, check the bearer cookie and redirect if missing + useEffect(() => { + if (init.isValue) { + if (!cookie("bearer")) { + navigate("/", { replace: true }); + } + } + }, [init, navigate]); + + // 3) Handle any RPC‐level errors in a modal + if (rpcError !== "") { + return ( + setRpcError("")}> + + Communication Error + + + There was a problem communicating with the backend. +
+ Please try again or contact support. +
+ + + +
+ ); + } + + // 4) While the actor system is initializing, render nothing (or a spinner) + if (!init.isValue) { + return null; + } + + // 5) Once ready and authenticated, render the layout and child routes + return ( + + + + + + ); }; From d6917c255ed5dac9d8f038a8fb70d14f9276e02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Tue, 10 Jun 2025 23:12:55 +0200 Subject: [PATCH 17/20] :bug: Feature/prevent question details without data (#90) :bug: Add ternary operator to conditionally disable question details button --- packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx b/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx index dceba79..7896421 100644 --- a/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx +++ b/packages/frontend/src/components/quiz-tabs/QuizStatsTab.tsx @@ -185,8 +185,8 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat textUnderlineOffset: "3px", // color: $primary }} - onClick={() => - tryActor.forEach(actor => + // Only activate question stats if there are details available + onClick={() => noDetails ? null : tryActor.forEach(actor => actor.send( actor, CurrentQuizMessages.ActivateQuestionStats( From beaeb5249e4ca0a083a17779df7c1ede4fa9d3f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Tue, 10 Jun 2025 23:13:48 +0200 Subject: [PATCH 18/20] Feature/docker debian slim wkhtmltopdf install (#91) * chore(docker): switch to node:20-slim and install wkhtmltopdf - solves crash during export --- packages/backend/Dockerfile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index d71929e..b3dd381 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,4 +1,16 @@ -FROM node:20-alpine +FROM node:20-slim + +# Install wkhtmltopdf + minimal deps, then clean up apt caches +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + wkhtmltopdf \ + fontconfig \ + fonts-dejavu-core \ + ca-certificates \ + libx11-6 \ + libxrender1 \ + libxext6 \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app From e6234b0e5913e2902a9d1d24a3c2a85f43514c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Wed, 11 Jun 2025 09:57:14 +0200 Subject: [PATCH 19/20] Feature/feature/root add spinner on init (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Root): show spinner while initializing and refactor auth redirect * :chore: issues with auto-deploy on the test server * ✨ chore: issues with auto-deploy to testserver * Update deploy-test.yml * ✨ chore: issues with auto-deploy to testserver * version bump --- .github/workflows/deploy-test.yml | 13 ++- NEWS.md | 2 +- packages/backend/package.json | 2 +- packages/frontend/package.json | 2 +- .../src/components/navigation/Root.tsx | 82 ++++++++++++------- 5 files changed, 65 insertions(+), 36 deletions(-) diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 270c150..a1ec405 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -6,12 +6,17 @@ on: - main - 'feature/**' workflow_dispatch: + inputs: + branch: + description: "Branch to deploy" + required: true + default: main jobs: deploy: runs-on: ubuntu-latest env: - BRANCH_NAME: ${{ github.ref_name }} + BRANCH_NAME: ${{ github.event.inputs.branch || github.head_ref || github.ref_name }} steps: - name: Set up SSH agent @@ -26,8 +31,8 @@ jobs: - name: Deploy ${{ env.BRANCH_NAME }} to test server run: | - echo "➡️ Starting remote deployment of branch '${BRANCH_NAME}'" + echo "➡️ Starting remote deployment of branch $BRANCH_NAME" ssh -o StrictHostKeyChecking=no \ ${{ secrets.TEST_SERVER_USER }}@${{ secrets.TEST_SERVER_HOST }} \ - "bash ~/deploy_recapp_to_test.sh \"${BRANCH_NAME}\"" - echo "✅ Remote deployment of branch '${BRANCH_NAME}' succeeded" + "export BRANCH_NAME='${BRANCH_NAME}'; bash ~/deploy_recapp_to_test.sh '${BRANCH_NAME}'" + echo "✅ Remote deployment of branch $BRANCH_NAME succeeded" diff --git a/NEWS.md b/NEWS.md index 4fe1125..1a86eb2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,2 +1,2 @@ -# recapp 1.6.2 +# recapp 1.6.3 diff --git a/packages/backend/package.json b/packages/backend/package.json index 71182c2..b1c3403 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -48,5 +48,5 @@ "start": "ts-node ./src/index.ts", "test": "npm test" }, - "version": "1.0.0" + "version": "1.0.1" } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 844d8fb..ee10955 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@recapp/frontend", "private": true, - "version": "1.6.2", + "version": "1.6.3", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/frontend/src/components/navigation/Root.tsx b/packages/frontend/src/components/navigation/Root.tsx index 9bca1ce..234ac56 100644 --- a/packages/frontend/src/components/navigation/Root.tsx +++ b/packages/frontend/src/components/navigation/Root.tsx @@ -2,17 +2,18 @@ import React, { useEffect, useState } from "react"; import { Outlet, useNavigate } from "react-router-dom"; -import { i18n } from "@lingui/core"; // keep if you use programmatic i18n APIs -import { Trans } from "@lingui/react"; // keep if you’re using anywhere +import { i18n } from "@lingui/core"; +import { Trans } from "@lingui/react"; import { Layout } from "../../layout/Layout"; import { SystemContext } from "ts-actors-react"; -import { Button, Modal } from "react-bootstrap"; +import { Button, Modal, Spinner } from "react-bootstrap"; import { cookie } from "../../utils"; import { system } from "../../system"; import { ActorSystem } from "ts-actors"; import { Try, fromError, fromValue } from "tsmonads"; export const Root: React.FC = () => { + // 1) Keep the monadic Try so it matches your context type const [init, setInit] = useState>(fromError(new Error())); const [rpcError, setRpcError] = useState(""); const navigate = useNavigate(); @@ -22,57 +23,80 @@ export const Root: React.FC = () => { document.location.href = `${import.meta.env.VITE_BACKEND_URI}/auth/logout`; }; - // 1) Initialize the actor system once on mount + // 2) Bootstrap the actor system just once useEffect(() => { - const run = async () => { - try { - const s: ActorSystem = await system; - setInit(fromValue(s)); - } catch (e) { - setInit(fromError(e as Error)); - } - }; - run(); + system + .then(s => setInit(fromValue(s))) + .catch(err => setInit(fromError(err))); }, []); - // 2) After initialization, check the bearer cookie and redirect if missing + // 3) After a successful init, check for the bearer cookie and redirect if missing useEffect(() => { - if (init.isValue) { - if (!cookie("bearer")) { - navigate("/", { replace: true }); + init.match( + // onSuccess + () => { + if (!cookie("bearer")) { + navigate("/", { replace: true }); + } + }, + // onFailure + () => { + /* do nothing on failure; modal handle below */ } - } + ); }, [init, navigate]); - // 3) Handle any RPC‐level errors in a modal - if (rpcError !== "") { + // 4) If we hit an RPC error, show the modal + if (rpcError) { return ( setRpcError("")}> - Communication Error + + + - There was a problem communicating with the backend. +
- Please try again or contact support. +
); } - // 4) While the actor system is initializing, render nothing (or a spinner) - if (!init.isValue) { - return null; + // 5) While initializing, show a centered spinner + const ready = init.match(() => true, () => false); + if (!ready) { + return ( +
+ + + + + +
+ ); } - // 5) Once ready and authenticated, render the layout and child routes + // 6) Everything’s good: render your app return ( - + From 18c22b5a68bf004665a73d6aad8249b21ae5d535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Han=C3=9F?= Date: Mon, 23 Jun 2025 18:05:04 +0200 Subject: [PATCH 20/20] :bug: fix(authRefresh): break infinite refresh loop by returning exipry timestamp (#94) * :bug: fix(authRefresh): break infinite refresh loop by returning expiry timestamp * version bump * version bump --- packages/backend/package.json | 2 +- packages/backend/src/index.ts | 8 +- .../backend/src/middlewares/authRoutes.ts | 48 +++++---- packages/frontend/package.json | 2 +- packages/frontend/src/actors/TokenActor.ts | 101 +++++++++++------- 5 files changed, 95 insertions(+), 66 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index b1c3403..4da8d67 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -48,5 +48,5 @@ "start": "ts-node ./src/index.ts", "test": "npm test" }, - "version": "1.0.1" + "version": "1.0.2" } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3df2921..c973061 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -119,13 +119,19 @@ const start = async () => { app.use(router.routes()); app.use(router.allowedMethods()); + console.log("Registered routes:"); + router.stack.forEach((layer) => { + const names = layer.stack.map(fn => fn.name || ""); + console.log(`${layer.methods.join(",")} ${layer.path} → [${names.join(", ")}]`); + }); + const httpServer = app.listen(3123, "0.0.0.0"); const distributor = new WebsocketDistributor(systemName, { server: httpServer, authenticationMiddleware, headers: { - Authorization: "apikey="+Container.get("api-keys")[0] + Authorization: "apikey=" + Container.get("api-keys")[0] }, }); const system = await DistributedActorSystem.create({ diff --git a/packages/backend/src/middlewares/authRoutes.ts b/packages/backend/src/middlewares/authRoutes.ts index 261c0c4..3476afb 100644 --- a/packages/backend/src/middlewares/authRoutes.ts +++ b/packages/backend/src/middlewares/authRoutes.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import jwt from "jsonwebtoken"; import { Issuer, Client, ClientMetadata } from "openid-client"; import Container from "typedi"; @@ -63,7 +62,7 @@ export const authTempAccount = async (ctx: koa.Context): Promise => { const userStore = createActorUri("UserStore"); const fpStore = createActorUri("FingerprintStore"); const uid = v4() as Id; - + try { let fpData: Fingerprint | Error = await system.ask(fpStore, FingerprintStoreMessages.Get(fingerprint as Id)); console.log("New fingerprint", fingerprint, fpData); @@ -81,8 +80,8 @@ export const authTempAccount = async (ctx: koa.Context): Promise => { }; await system.send(fpStore, FingerprintStoreMessages.StoreFingerprint(fp)); fpData = fp; - } - await system.send(fpStore, FingerprintStoreMessages.IncreaseCount({fingerprint: fingerprint as Id, userUid: uid as Id, initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined})); + } + await system.send(fpStore, FingerprintStoreMessages.IncreaseCount({ fingerprint: fingerprint as Id, userUid: uid as Id, initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined })); if (fpData.blocked) { console.debug("Fingerprint was blocked", fingerprint); ctx.redirect((process.env.FRONTEND_URI ?? "http://localhost:5173") + "?error=userdeactivated"); @@ -91,18 +90,18 @@ export const authTempAccount = async (ctx: koa.Context): Promise => { } catch (e) { console.error(e); console.debug("A new fingerprint has been found", fingerprint); - const fp: Fingerprint = { - uid: fingerprint as Id, - created: toTimestamp(), - updated: toTimestamp(), - lastSeen: toTimestamp(), - usageCount: 1, - blocked: false, - userUid: uid, - initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined, - }; - await system.send(fpStore, FingerprintStoreMessages.StoreFingerprint(fp)); - await system.send(fpStore, FingerprintStoreMessages.IncreaseCount({fingerprint: fingerprint as Id, userUid: uid as Id, initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined})); + const fp: Fingerprint = { + uid: fingerprint as Id, + created: toTimestamp(), + updated: toTimestamp(), + lastSeen: toTimestamp(), + usageCount: 1, + blocked: false, + userUid: uid, + initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined, + }; + await system.send(fpStore, FingerprintStoreMessages.StoreFingerprint(fp)); + await system.send(fpStore, FingerprintStoreMessages.IncreaseCount({ fingerprint: fingerprint as Id, userUid: uid as Id, initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined })); } try { const existingUser: User | Error = await system.ask(userStore, UserStoreMessages.GetByFingerprint(fingerprint)); @@ -299,7 +298,7 @@ export const authLogout = async (ctx: koa.Context): Promise => { }, () => ctx.throw(401, "Unable to sign out.") ); - + ctx.set("Set-Cookie", `bearer=; path=/; max-age=0`); ctx.redirect(process.env.FRONTEND_URI ?? "http://localhost:5173"); }; @@ -313,7 +312,7 @@ export const authRefresh = async (ctx: koa.Context): Promise => { async idToken => { try { const { sub } = jwt.decode(idToken) as jwt.JwtPayload; - + // Refresh the token const client = await getOidc(); const system = Container.get("actor-system"); @@ -333,7 +332,7 @@ export const authRefresh = async (ctx: koa.Context): Promise => { const fpStore = createActorUri("FingerprintStore"); try { let fpData: Fingerprint = await system.ask(fpStore, FingerprintStoreMessages.Get(session.fingerprint as Id)); - await system.ask(fpStore, FingerprintStoreMessages.IncreaseCount({fingerprint: session.fingerprint as Id, userUid: session.uid, initialQuiz: undefined})); + await system.ask(fpStore, FingerprintStoreMessages.IncreaseCount({ fingerprint: session.fingerprint as Id, userUid: session.uid, initialQuiz: undefined })); if (fpData.blocked) { system.send(sessionStore, SessionStoreMessages.RemoveSession(sub as Id)); ctx.set("Set-Cookie", `bearer=; path=/`); @@ -350,7 +349,7 @@ export const authRefresh = async (ctx: koa.Context): Promise => { ctx.body = "O.K."; return; } - + try { const newTokenSet = await client.refresh(session.refreshToken); const decoded = jwt.decode(newTokenSet.id_token ?? "") as jwt.JwtPayload; @@ -369,9 +368,14 @@ export const authRefresh = async (ctx: koa.Context): Promise => { refreshExpires: toTimestamp(refreshExpires), }) ); - console.log(`User ${sub} token was refreshed`); + console.log(`User ${sub} token was refreshed.`); ctx.set("Set-Cookie", `bearer=${newTokenSet.id_token}; path=/; expires=${refreshExpires.toHTTP()}`); - ctx.body = "O.K."; + // ctx.body = "O.K."; + ctx.body = { + expires_at: expires.toISO(), + refresh_expires: refreshExpires.toISO() + }; + } catch (e) { console.error("Failed to renew token", e); system.send(sessionStore, SessionStoreMessages.RemoveSession(sub as Id)); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ee10955..4a1c11c 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@recapp/frontend", "private": true, - "version": "1.6.3", + "version": "1.6.4", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/frontend/src/actors/TokenActor.ts b/packages/frontend/src/actors/TokenActor.ts index 60ffb7d..1921792 100644 --- a/packages/frontend/src/actors/TokenActor.ts +++ b/packages/frontend/src/actors/TokenActor.ts @@ -6,45 +6,64 @@ import Axios from "axios"; import { cookie } from "../utils"; export class TokenActor extends Actor { - public interval: any; - private expiresAt: Date; - - public constructor(name: string, system: ActorSystem) { - super(name, system); - this.expiresAt = new Date(); // Initialize with a default value - } - - public override async afterStart(): Promise { - this.updateToken(); - } - - public override async beforeShutdown(): Promise { - clearTimeout(this.interval); - } - - private updateToken = () => { - const hasToken = !!cookie("bearer"); - if (hasToken) { - Axios.get(import.meta.env.VITE_BACKEND_URI + "/auth/refresh", { withCredentials: true }) - .then(response => { - this.expiresAt = new Date(response.data.expires_at); - this.scheduleNextUpdate(); - }) - .catch(error => { - console.error("Failed to refresh token:", error); - setTimeout(this.updateToken, 5000); // Retry after 5 seconds - }); - } - }; - - private scheduleNextUpdate = () => { - const buffer = 30000; // 30 seconds before expiry - const delay = this.expiresAt.getTime() - Date.now() - buffer; - clearTimeout(this.interval); // Clear previous timeout - this.interval = setTimeout(this.updateToken, delay); - }; - - public async receive(_from: ActorRef, _message: unknown): Promise { - return unit(); - } + public interval: any; + private expiresAt: Date; + + public constructor(name: string, system: ActorSystem) { + super(name, system); + this.expiresAt = new Date(); // Initialize with the current dte and time + } + + public override async afterStart(): Promise { + this.updateToken(); + } + + public override async beforeShutdown(): Promise { + clearTimeout(this.interval); + } + + private updateToken = () => { + const hasToken = !!cookie("bearer"); + if (hasToken) { + Axios.get(import.meta.env.VITE_BACKEND_URI + "/auth/refresh", { withCredentials: true }) + .then(response => { + console.debug("[TokenActor] /auth/refresh response.data:", response.data); + // assume response.data.expires_at is ISO or epoch-string + this.expiresAt = new Date(response.data.expires_at); + this.scheduleNextUpdate(); + }) + .catch(error => { + console.error("[TokenActor] Failed to refresh token:", error); + setTimeout(this.updateToken, 5000); // Retry after 5 seconds + }); + } + }; + + private scheduleNextUpdate = () => { + const bufferMs = 30000; // 30 seconds before expiry + const now = Date.now(); + const expiryMs = this.expiresAt.getTime(); + + const delay = expiryMs - now - bufferMs; + + // --- DEBUG LOGGING START --- + console.debug("[TokenActor] now =", new Date(now).toISOString()); + console.debug("[TokenActor] expiresAt =", this.expiresAt.toISOString()); + console.debug("[TokenActor] bufferMs =", bufferMs, "ms"); + console.debug("[TokenActor] raw delay =", delay, "ms"); + // --- DEBUG LOGGING END --- + + clearTimeout(this.interval); // Clear previous timeout + + // clamp to at least 1 s to avoid tight loops + const safeDelay = Math.max(delay, 1_000); + if (delay <= 0) { + console.warn("[TokenActor] Computed delay <= 0, forcing retry in 1 s"); + } + this.interval = setTimeout(this.updateToken, safeDelay); + }; + + public async receive(_from: ActorRef, _message: unknown): Promise { + return unit(); + } } \ No newline at end of file