f
This commit is contained in:
parent
a0c508883d
commit
5fd160e851
36 changed files with 1125 additions and 169 deletions
39
.forgejo/README.md
Normal file
39
.forgejo/README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Forgejo CI/CD для Mnemo Cards
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. **Настройте секреты в Forgejo:**
|
||||
```
|
||||
DEPLOY_SSH_PRIVATE_KEY # SSH ключ для деплоя
|
||||
```
|
||||
|
||||
2. **Протестируйте локально:**
|
||||
```bash
|
||||
./test_ci_locally.sh
|
||||
```
|
||||
|
||||
3. **Push в main/master:**
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Workflows
|
||||
|
||||
- `ci-main.yml` - Главный pipeline
|
||||
- `ci-backend.yml` - Backend (Dart)
|
||||
- `ci-web.yml` - Web app (Flutter)
|
||||
- `ci-mobile.yml` - Mobile app (Flutter)
|
||||
- `cd-deploy.yml` - Автоматический деплой
|
||||
- `code-quality.yml` - Контроль качества
|
||||
- `release.yml` - Автоматические релизы
|
||||
|
||||
## Создание релиза
|
||||
|
||||
```bash
|
||||
git tag v1.2.3
|
||||
git push origin v1.2.3
|
||||
```
|
||||
|
||||
## Подробная документация
|
||||
|
||||
Смотрите [CI_CD_SETUP.md](../CI_CD_SETUP.md) для полной информации.
|
||||
100
.forgejo/workflows/cd-deploy.yml
Normal file
100
.forgejo/workflows/cd-deploy.yml
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
name: Deploy Applications
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
workflow_run:
|
||||
workflows: ["Backend CI", "Web App CI"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy-backend:
|
||||
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download backend artifact
|
||||
if: github.event_name == 'workflow_run'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-binary
|
||||
path: mnemo_cards_backend/
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H 147.45.152.129 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy backend
|
||||
run: |
|
||||
chmod +x mnemo_cards_backend/build_app.sh
|
||||
./mnemo_cards_backend/build_app.sh
|
||||
|
||||
deploy-web:
|
||||
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download web artifact
|
||||
if: github.event_name == 'workflow_run'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-build
|
||||
path: mnemo_cards_web_v2/build/web/
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H 147.45.152.129 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy web app
|
||||
run: |
|
||||
cd mnemo_cards_web_v2
|
||||
chmod +x deploy/deploy.sh
|
||||
./deploy/deploy.sh
|
||||
|
||||
deploy-mobile:
|
||||
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Download iOS build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-build
|
||||
path: ./artifacts/ios/
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
./artifacts/app-release.apk
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
127
.forgejo/workflows/ci-backend.yml
Normal file
127
.forgejo/workflows/ci-backend.yml
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
name: Backend CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'mnemo_cards_backend/**'
|
||||
- 'mnemo_cards_common/**'
|
||||
- 'mnemo_cards_common_backend/**'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'mnemo_cards_backend/**'
|
||||
- 'mnemo_cards_common/**'
|
||||
- 'mnemo_cards_common_backend/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container: dart:stable
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Dart SDK
|
||||
uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: stable
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
mnemo_cards_backend/.dart_tool
|
||||
mnemo_cards_common/.dart_tool
|
||||
mnemo_cards_common_backend/.dart_tool
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
|
||||
- name: Install common dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common
|
||||
dart pub get
|
||||
|
||||
- name: Install common backend dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common_backend
|
||||
dart pub get
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
cd mnemo_cards_backend
|
||||
dart pub get
|
||||
|
||||
- name: Run code generation
|
||||
run: |
|
||||
cd mnemo_cards_backend
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
cd mnemo_cards_backend
|
||||
dart analyze lib/
|
||||
dart format --set-exit-if-changed lib/
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd mnemo_cards_backend
|
||||
dart test --coverage=coverage
|
||||
dart pub global activate coverage
|
||||
dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.dart_tool/package_config.json
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./mnemo_cards_backend/coverage/lcov.info
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
container: dart:stable
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Dart SDK
|
||||
uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: stable
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
mnemo_cards_backend/.dart_tool
|
||||
mnemo_cards_common/.dart_tool
|
||||
mnemo_cards_common_backend/.dart_tool
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common
|
||||
dart pub get
|
||||
cd ../mnemo_cards_common_backend
|
||||
dart pub get
|
||||
cd ../mnemo_cards_backend
|
||||
dart pub get
|
||||
|
||||
- name: Build backend executable
|
||||
run: |
|
||||
cd mnemo_cards_backend
|
||||
dart compile exe lib/main.dart -o server.exe
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-binary
|
||||
path: mnemo_cards_backend/server.exe
|
||||
28
.forgejo/workflows/ci-main.yml
Normal file
28
.forgejo/workflows/ci-main.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: Main CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
uses: ./.forgejo/workflows/code-quality.yml
|
||||
|
||||
backend-ci:
|
||||
needs: code-quality
|
||||
uses: ./.forgejo/workflows/ci-backend.yml
|
||||
|
||||
web-ci:
|
||||
needs: code-quality
|
||||
uses: ./.forgejo/workflows/ci-web.yml
|
||||
|
||||
mobile-ci:
|
||||
needs: code-quality
|
||||
uses: ./.forgejo/workflows/ci-mobile.yml
|
||||
|
||||
deploy:
|
||||
needs: [backend-ci, web-ci, mobile-ci]
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
|
||||
uses: ./.forgejo/workflows/cd-deploy.yml
|
||||
216
.forgejo/workflows/ci-mobile.yml
Normal file
216
.forgejo/workflows/ci-mobile.yml
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
name: Mobile App CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'mnemo_cards/**'
|
||||
- 'mnemo_cards_common/**'
|
||||
- 'mnemo_cards_frontend_common/**'
|
||||
- 'chat/mnemo_cards_chat/**'
|
||||
- 'games/packages/**'
|
||||
- 'funny_letters/**'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'mnemo_cards/**'
|
||||
- 'mnemo_cards_common/**'
|
||||
- 'mnemo_cards_frontend_common/**'
|
||||
- 'chat/mnemo_cards_chat/**'
|
||||
- 'games/packages/**'
|
||||
- 'funny_letters/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Cache Flutter dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
mnemo_cards/.dart_tool
|
||||
mnemo_cards_common/.dart_tool
|
||||
mnemo_cards_frontend_common/.dart_tool
|
||||
chat/mnemo_cards_chat/.dart_tool
|
||||
funny_letters/.dart_tool
|
||||
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-flutter-
|
||||
|
||||
- name: Install common dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common
|
||||
flutter pub get
|
||||
|
||||
- name: Install frontend common dependencies
|
||||
run: |
|
||||
cd mnemo_cards_frontend_common
|
||||
flutter pub get
|
||||
|
||||
- name: Install chat dependencies
|
||||
run: |
|
||||
cd chat/mnemo_cards_chat
|
||||
flutter pub get
|
||||
|
||||
- name: Install funny letters dependencies
|
||||
run: |
|
||||
cd funny_letters
|
||||
flutter pub get
|
||||
|
||||
- name: Install mobile app dependencies
|
||||
run: |
|
||||
cd mnemo_cards
|
||||
flutter pub get
|
||||
|
||||
- name: Run code generation
|
||||
run: |
|
||||
cd mnemo_cards
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
cd mnemo_cards
|
||||
flutter analyze lib/
|
||||
flutter format --set-exit-if-changed lib/
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd mnemo_cards
|
||||
flutter test --coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./mnemo_cards/coverage/lcov.info
|
||||
flags: mobile
|
||||
name: mobile-coverage
|
||||
|
||||
build-android:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Flutter dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
mnemo_cards/.dart_tool
|
||||
mnemo_cards_common/.dart_tool
|
||||
mnemo_cards_frontend_common/.dart_tool
|
||||
chat/mnemo_cards_chat/.dart_tool
|
||||
funny_letters/.dart_tool
|
||||
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-flutter-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common
|
||||
flutter pub get
|
||||
cd ../mnemo_cards_frontend_common
|
||||
flutter pub get
|
||||
cd ../chat/mnemo_cards_chat
|
||||
flutter pub get
|
||||
cd ../funny_letters
|
||||
flutter pub get
|
||||
cd ../mnemo_cards
|
||||
flutter pub get
|
||||
|
||||
- name: Build Android APK
|
||||
run: |
|
||||
cd mnemo_cards
|
||||
flutter build apk --release
|
||||
|
||||
- name: Upload Android APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: mnemo_cards/build/app/outputs/flutter-apk/app-release.apk
|
||||
|
||||
build-ios:
|
||||
needs: test
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Cache Flutter dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
mnemo_cards/.dart_tool
|
||||
mnemo_cards_common/.dart_tool
|
||||
mnemo_cards_frontend_common/.dart_tool
|
||||
chat/mnemo_cards_chat/.dart_tool
|
||||
funny_letters/.dart_tool
|
||||
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-flutter-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common
|
||||
flutter pub get
|
||||
cd ../mnemo_cards_frontend_common
|
||||
flutter pub get
|
||||
cd ../chat/mnemo_cards_chat
|
||||
flutter pub get
|
||||
cd ../funny_letters
|
||||
flutter pub get
|
||||
cd ../mnemo_cards
|
||||
flutter pub get
|
||||
|
||||
- name: Build iOS
|
||||
run: |
|
||||
cd mnemo_cards
|
||||
flutter build ios --release --no-codesign
|
||||
|
||||
- name: Upload iOS build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-build
|
||||
path: mnemo_cards/build/ios/iphoneos/
|
||||
138
.forgejo/workflows/ci-web.yml
Normal file
138
.forgejo/workflows/ci-web.yml
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
name: Web App CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'mnemo_cards_web_v2/**'
|
||||
- 'mnemo_cards_common/**'
|
||||
- 'mnemo_cards_frontend_common/**'
|
||||
- 'chat/mnemo_cards_chat/**'
|
||||
- 'games/packages/**'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'mnemo_cards_web_v2/**'
|
||||
- 'mnemo_cards_common/**'
|
||||
- 'mnemo_cards_frontend_common/**'
|
||||
- 'chat/mnemo_cards_chat/**'
|
||||
- 'games/packages/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Cache Flutter dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
mnemo_cards_web_v2/.dart_tool
|
||||
mnemo_cards_common/.dart_tool
|
||||
mnemo_cards_frontend_common/.dart_tool
|
||||
chat/mnemo_cards_chat/.dart_tool
|
||||
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-flutter-
|
||||
|
||||
- name: Install common dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common
|
||||
flutter pub get
|
||||
|
||||
- name: Install frontend common dependencies
|
||||
run: |
|
||||
cd mnemo_cards_frontend_common
|
||||
flutter pub get
|
||||
|
||||
- name: Install chat dependencies
|
||||
run: |
|
||||
cd chat/mnemo_cards_chat
|
||||
flutter pub get
|
||||
|
||||
- name: Install web app dependencies
|
||||
run: |
|
||||
cd mnemo_cards_web_v2
|
||||
flutter pub get
|
||||
|
||||
- name: Run code generation
|
||||
run: |
|
||||
cd mnemo_cards_web_v2
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
cd mnemo_cards_web_v2
|
||||
flutter analyze lib/
|
||||
flutter format --set-exit-if-changed lib/
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd mnemo_cards_web_v2
|
||||
flutter test --coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./mnemo_cards_web_v2/coverage/lcov.info
|
||||
flags: web
|
||||
name: web-coverage
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Cache Flutter dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
mnemo_cards_web_v2/.dart_tool
|
||||
mnemo_cards_common/.dart_tool
|
||||
mnemo_cards_frontend_common/.dart_tool
|
||||
chat/mnemo_cards_chat/.dart_tool
|
||||
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-flutter-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd mnemo_cards_common
|
||||
flutter pub get
|
||||
cd ../mnemo_cards_frontend_common
|
||||
flutter pub get
|
||||
cd ../chat/mnemo_cards_chat
|
||||
flutter pub get
|
||||
cd ../../mnemo_cards_web_v2
|
||||
flutter pub get
|
||||
|
||||
- name: Build web app
|
||||
run: |
|
||||
cd mnemo_cards_web_v2
|
||||
flutter build web --release --dart-define=API_BASE_URL=https://1592725-cf88967.twc1.net:8081
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-build
|
||||
path: mnemo_cards_web_v2/build/web/
|
||||
129
.forgejo/workflows/code-quality.yml
Normal file
129
.forgejo/workflows/code-quality.yml
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
name: Code Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
container: dart:stable
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
**/.dart_tool
|
||||
key: ${{ runner.os }}-deps-${{ hashFiles('**/pubspec.lock') }}
|
||||
|
||||
- name: Install all dependencies
|
||||
run: |
|
||||
find . -name "pubspec.yaml" -exec dirname {} \; | xargs -I {} sh -c 'cd {} && flutter pub get || dart pub get'
|
||||
|
||||
- name: Run code generation
|
||||
run: |
|
||||
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards")
|
||||
for project in "${projects[@]}"; do
|
||||
if [ -d "$project" ]; then
|
||||
cd $project
|
||||
flutter pub run build_runner build --delete-conflicting-outputs || true
|
||||
cd ..
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Lint all projects
|
||||
run: |
|
||||
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards" "mnemo_cards_common" "mnemo_cards_common_backend" "mnemo_cards_frontend_common")
|
||||
for project in "${projects[@]}"; do
|
||||
if [ -d "$project" ]; then
|
||||
echo "Linting $project..."
|
||||
cd $project
|
||||
flutter analyze lib/ || dart analyze lib/ || echo "Analysis failed for $project"
|
||||
flutter format --set-exit-if-changed lib/ || echo "Format check failed for $project"
|
||||
cd ..
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards")
|
||||
for project in "${projects[@]}"; do
|
||||
if [ -d "$project" ]; then
|
||||
echo "Testing $project..."
|
||||
cd $project
|
||||
flutter test --coverage || dart test --coverage || echo "Tests failed for $project"
|
||||
cd ..
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./mnemo_cards_backend/coverage/lcov.info,./mnemo_cards_web_v2/coverage/lcov.info,./mnemo_cards/coverage/lcov.info
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
- name: Quality gate check
|
||||
run: |
|
||||
# Check if coverage is above threshold
|
||||
if [ -f "coverage/coverage.json" ]; then
|
||||
COVERAGE=$(jq '.coverage' coverage/coverage.json 2>/dev/null || echo "0")
|
||||
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
|
||||
echo "Coverage is below 80%: $COVERAGE%"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for critical linting issues
|
||||
if grep -r "error:" .github/workflows/code-quality.log 2>/dev/null; then
|
||||
echo "Found critical linting errors"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Quality gate passed"
|
||||
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
container: dart:stable
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run security audit
|
||||
run: |
|
||||
# Check for known vulnerabilities in dependencies
|
||||
projects=("mnemo_cards_backend" "mnemo_cards_web_v2" "mnemo_cards" "mnemo_cards_common" "mnemo_cards_common_backend" "mnemo_cards_frontend_common")
|
||||
for project in "${projects[@]}"; do
|
||||
if [ -d "$project" ]; then
|
||||
cd $project
|
||||
echo "Auditing $project..."
|
||||
flutter pub audit || dart pub audit || echo "Audit failed for $project"
|
||||
cd ..
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Check for secrets
|
||||
run: |
|
||||
# Simple check for potential secrets
|
||||
if grep -r -i "password\|secret\|key\|token" --include="*.dart" --include="*.yaml" --include="*.json" . | grep -v "example\|test\|mock\|fake" | grep -v "pubspec"; then
|
||||
echo "⚠️ Potential secrets found in code"
|
||||
echo "Please review and ensure no real secrets are committed"
|
||||
else
|
||||
echo "✅ No obvious secrets found"
|
||||
fi
|
||||
101
.forgejo/workflows/release.yml
Normal file
101
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get previous tag
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "")
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
echo "Generating changelog from $PREVIOUS_TAG to ${GITHUB_REF#refs/tags/}"
|
||||
|
||||
# Generate changelog
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD | head -20)
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
CHANGELOG="No changes since last release"
|
||||
fi
|
||||
|
||||
# Escape newlines for GitHub output
|
||||
CHANGELOG="${CHANGELOG//'%'/'%25'}"
|
||||
CHANGELOG="${CHANGELOG//$'\n'/'%0A'}"
|
||||
CHANGELOG="${CHANGELOG//$'\r'/'%0D'}"
|
||||
|
||||
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "Release ${{ steps.get_version.outputs.version }}"
|
||||
body: |
|
||||
## What's Changed
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
## Downloads
|
||||
|
||||
- Backend binary: Download from CI artifacts
|
||||
- Web app: Automatically deployed
|
||||
- Mobile app: Download APK from CI artifacts
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-version:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update version in pubspec files
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating version to $VERSION"
|
||||
|
||||
# Update main app version
|
||||
sed -i "s/version: .*/version: $VERSION+$(date +%s)/" mnemo_cards/pubspec.yaml
|
||||
|
||||
# Update backend version
|
||||
sed -i "s/version: .*/version: $VERSION+$(date +%s)/" mnemo_cards_backend/pubspec.yaml
|
||||
|
||||
# Update web version
|
||||
sed -i "s/version: .*/version: $VERSION+$(date +%s)/" mnemo_cards_web_v2/pubspec.yaml
|
||||
|
||||
- name: Commit version update
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ github.ref_name }}" || echo "No changes to commit"
|
||||
|
||||
- name: Push version update
|
||||
run: |
|
||||
git push origin main || git push origin master
|
||||
|
|
@ -19,27 +19,27 @@ sudo cp server_build.exe server.exe
|
|||
rm server_build.exe
|
||||
|
||||
# Проверяем наличие Let's Encrypt сертификатов для двойного режима
|
||||
if [ -d "/etc/letsencrypt/live/1592725-cf88967.twc1.net" ]; then
|
||||
if [ -d "/etc/letsencrypt/live/api.memo-cards.online" ]; then
|
||||
echo "✅ Let's Encrypt сертификаты найдены. Запускаем в двойном режиме (HTTP + HTTPS)"
|
||||
# Обновляем systemd сервис для двойного режима
|
||||
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/1592725-cf88967.twc1.net -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
|
||||
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/api.memo-cards.online -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
|
||||
else
|
||||
echo "⚠️ Let's Encrypt сертификаты не найдены. Пытаемся создать..."
|
||||
|
||||
|
||||
# Останавливаем сервер временно для получения сертификата
|
||||
sudo systemctl stop mnemo_cards_server 2>/dev/null || true
|
||||
|
||||
|
||||
# Пытаемся получить Let's Encrypt сертификат
|
||||
echo "🔐 Получаем Let's Encrypt сертификат для домена 1592725-cf88967.twc1.net..."
|
||||
if sudo certbot certonly --standalone -d 1592725-cf88967.twc1.net --non-interactive --agree-tos --email admin@1592725-cf88967.twc1.net; then
|
||||
echo "🔐 Получаем Let's Encrypt сертификат для домена api.memo-cards.online..."
|
||||
if sudo certbot certonly --standalone -d api.memo-cards.online --non-interactive --agree-tos --email admin@api.memo-cards.online; then
|
||||
echo "✅ Let's Encrypt сертификат успешно создан!"
|
||||
echo "🚀 Запускаем в двойном режиме (HTTP + HTTPS)"
|
||||
# Обновляем systemd сервис для двойного режима
|
||||
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/1592725-cf88967.twc1.net -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
|
||||
sudo sed -i 's|ExecStart=/root/mnemo_cards_backend/server.exe.*|ExecStart=/root/mnemo_cards_backend/server.exe --dual --certs /etc/letsencrypt/live/api.memo-cards.online -a 0.0.0.0 -p 8080|' /etc/systemd/system/mnemo_cards_server.service
|
||||
else
|
||||
echo "❌ Не удалось создать Let's Encrypt сертификат. Запускаем в HTTP режиме"
|
||||
echo "💡 Убедитесь, что:"
|
||||
echo " - Домен 1592725-cf88967.twc1.net указывает на этот сервер"
|
||||
echo " - Домен api.memo-cards.online указывает на этот сервер"
|
||||
echo " - Порт 80 доступен извне"
|
||||
echo " - Firewall разрешает входящие соединения на порт 80"
|
||||
# Возвращаем обычный режим
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import 'package:mnemo_cards_backend/api/authorize/resource_loader.dart';
|
|||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
Middleware accessMiddleware(AccessService accessService, PackManager packManager) {
|
||||
Middleware accessMiddleware(
|
||||
AccessService accessService, PackManager packManager) {
|
||||
return (Handler inner) {
|
||||
return (Request request) async {
|
||||
final loader = ResourceLoader(packManager);
|
||||
|
|
@ -30,5 +31,3 @@ AccessService buildAccessService(PackManager packManager) {
|
|||
final adminPolicy = AdminAccessPolicy();
|
||||
return AccessService(packPolicy, adminPolicy);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ class AccessService {
|
|||
required int packId,
|
||||
UserModel? user,
|
||||
}) async {
|
||||
final decision = await packPolicy.check(action: action, packId: packId, user: user);
|
||||
final decision =
|
||||
await packPolicy.check(action: action, packId: packId, user: user);
|
||||
return decision.allowed;
|
||||
}
|
||||
|
||||
|
|
@ -28,16 +29,19 @@ class AccessService {
|
|||
required int packId,
|
||||
UserModel? user,
|
||||
}) async {
|
||||
final decision = await packPolicy.check(action: action, packId: packId, user: user);
|
||||
final decision =
|
||||
await packPolicy.check(action: action, packId: packId, user: user);
|
||||
if (!decision.allowed) {
|
||||
throw AccessDenied(decision.httpStatus, decision.reason ?? 'Access denied');
|
||||
throw AccessDenied(
|
||||
decision.httpStatus, decision.reason ?? 'Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requireAdmin(AdminAction action, {UserModel? user}) async {
|
||||
final decision = adminPolicy.check(action: action, user: user);
|
||||
if (!decision.allowed) {
|
||||
throw AccessDenied(decision.httpStatus, decision.reason ?? 'Access denied');
|
||||
throw AccessDenied(
|
||||
decision.httpStatus, decision.reason ?? 'Access denied');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,5 +53,3 @@ class AccessDenied implements Exception {
|
|||
@override
|
||||
String toString() => 'AccessDenied($status): $message';
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,10 @@ class AccessDecision {
|
|||
httpStatus = 200,
|
||||
reason = null;
|
||||
|
||||
const AccessDecision.denied(this.httpStatus, this.reason)
|
||||
: allowed = false;
|
||||
const AccessDecision.denied(this.httpStatus, this.reason) : allowed = false;
|
||||
}
|
||||
|
||||
class AccessContext {
|
||||
final Object? user;
|
||||
const AccessContext({this.user});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,3 @@ class AdminAccessPolicy {
|
|||
return const AccessDecision.denied(403, 'Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,13 +20,14 @@ class PackAccessPolicy {
|
|||
}
|
||||
|
||||
// Public preview packs can be viewed without auth
|
||||
if (publicPackIds.contains(packId) && {
|
||||
PackAccessAction.view,
|
||||
PackAccessAction.cards,
|
||||
PackAccessAction.tests,
|
||||
PackAccessAction.list,
|
||||
PackAccessAction.buy,
|
||||
}.contains(action)) {
|
||||
if (publicPackIds.contains(packId) &&
|
||||
{
|
||||
PackAccessAction.view,
|
||||
PackAccessAction.cards,
|
||||
PackAccessAction.tests,
|
||||
PackAccessAction.list,
|
||||
PackAccessAction.buy,
|
||||
}.contains(action)) {
|
||||
final enabled = await loader.isPackEnabled(packId);
|
||||
if (!enabled) {
|
||||
return const AccessDecision.denied(404, 'Pack not found');
|
||||
|
|
@ -63,5 +64,3 @@ class PackAccessPolicy {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,5 +27,3 @@ class ResourceLoader {
|
|||
return model?.enabled == true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,5 +45,3 @@ Future<Response> guardPack(
|
|||
return accessErrorResponse(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ class AdminUsersApiV2 {
|
|||
|
||||
Future<Response> _ensureAdmin(Request request) async {
|
||||
try {
|
||||
await request.access!.requireAdmin(AdminAction.access, user: request.user);
|
||||
await request.access!
|
||||
.requireAdmin(AdminAction.access, user: request.user);
|
||||
return Response.ok(null);
|
||||
} on AccessDenied catch (e) {
|
||||
return Response(e.status, body: e.message);
|
||||
|
|
@ -79,7 +80,9 @@ class AdminUsersApiV2 {
|
|||
});
|
||||
|
||||
final dtos = await Future.wait(
|
||||
users.whereType<UserModel>().map((model) async => (await model.toDto()).toJson()),
|
||||
users
|
||||
.whereType<UserModel>()
|
||||
.map((model) async => (await model.toDto()).toJson()),
|
||||
);
|
||||
|
||||
return _json({'users': dtos});
|
||||
|
|
@ -95,11 +98,7 @@ class AdminUsersApiV2 {
|
|||
}
|
||||
|
||||
final ids = await backend_main.isar.txn(
|
||||
() => backend_main.isar.userModels
|
||||
.where()
|
||||
.anyId()
|
||||
.idProperty()
|
||||
.findAll(),
|
||||
() => backend_main.isar.userModels.where().anyId().idProperty().findAll(),
|
||||
);
|
||||
|
||||
return _json({'ids': ids.map((id) => id.toString()).join(',')});
|
||||
|
|
@ -118,7 +117,8 @@ class AdminUsersApiV2 {
|
|||
}
|
||||
|
||||
final payments = await _paymentManager.getUserPayments(userId);
|
||||
final dtos = payments.map((payment) => payment.toDto(withToken: true)).toList();
|
||||
final dtos =
|
||||
payments.map((payment) => payment.toDto(withToken: true)).toList();
|
||||
return _json({'payments': dtos.map((p) => p.toJson()).toList()});
|
||||
}
|
||||
|
||||
|
|
@ -187,4 +187,3 @@ class AdminUsersApiV2 {
|
|||
|
||||
Router get router => _$AdminUsersApiV2Router(this);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,4 +166,3 @@ class AdsApiV2 {
|
|||
|
||||
Router get router => _$AdsApiV2Router(this);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ const _publicGetPrefixes = {
|
|||
};
|
||||
|
||||
final List<RegExp> _strictAuthRegexps = [
|
||||
RegExp(r'^/packs/\d+/tests'), // Pack tests require authentication (except for public packs)
|
||||
RegExp(
|
||||
r'^/packs/\d+/tests'), // Pack tests require authentication (except for public packs)
|
||||
];
|
||||
|
||||
/// Middleware for API v2 Bearer token authentication
|
||||
|
|
@ -62,7 +63,6 @@ Middleware authorizeV2(UserManager userManager, JwtService jwtService) {
|
|||
normalizedPath.startsWith('$prefix/'),
|
||||
);
|
||||
|
||||
|
||||
// Extract Bearer token from Authorization header
|
||||
final authHeader = request.headers['authorization'];
|
||||
final hasBearerToken =
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ class DiscountsApiV2 {
|
|||
|
||||
Future<Response> _ensureAdmin(Request request) async {
|
||||
try {
|
||||
await request.access!.requireAdmin(AdminAction.access, user: request.user);
|
||||
await request.access!
|
||||
.requireAdmin(AdminAction.access, user: request.user);
|
||||
return Response.ok(null);
|
||||
} on AccessDenied catch (e) {
|
||||
return Response(e.status, body: e.message);
|
||||
|
|
@ -131,4 +132,3 @@ class DiscountsApiV2 {
|
|||
|
||||
Router get router => _$DiscountsApiV2Router(this);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -233,8 +233,7 @@ class PacksApiV2 {
|
|||
final packJson = pack.toJson();
|
||||
if (user != null) {
|
||||
await user.packs.load();
|
||||
final packModel =
|
||||
await backend_main.isar.cardPackModels.get(packIdInt);
|
||||
final packModel = await backend_main.isar.cardPackModels.get(packIdInt);
|
||||
final isPurchased = packModel != null &&
|
||||
(user.packs.contains(packId) ||
|
||||
(await _hasSubscriptionAccess(user)));
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ class PromocodesApiV2 {
|
|||
|
||||
Future<Response> _ensureAdmin(Request request) async {
|
||||
try {
|
||||
await request.access!.requireAdmin(AdminAction.access, user: request.user);
|
||||
await request.access!
|
||||
.requireAdmin(AdminAction.access, user: request.user);
|
||||
return Response.ok(null);
|
||||
} on AccessDenied catch (e) {
|
||||
return Response(e.status, body: e.message);
|
||||
|
|
@ -183,4 +184,3 @@ class PromocodesApiV2 {
|
|||
|
||||
Router get router => _$PromocodesApiV2Router(this);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ class TasksApiV2 {
|
|||
|
||||
// Build query
|
||||
var builder = isar.userTaskModels.where().filter();
|
||||
|
||||
|
||||
if (type != null) {
|
||||
builder = builder.typeEqualTo(type);
|
||||
|
|
@ -110,10 +109,8 @@ class TasksApiV2 {
|
|||
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
||||
}
|
||||
|
||||
final tasks = await builder.idIsNotNull()
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.findAll();
|
||||
final tasks =
|
||||
await builder.idIsNotNull().offset(offset).limit(limit).findAll();
|
||||
|
||||
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
||||
} catch (e) {
|
||||
|
|
@ -164,7 +161,8 @@ class TasksApiV2 {
|
|||
});
|
||||
|
||||
// Update user progress
|
||||
await _updateUserProgress(userId.toString(), taskId.toString(), 'in_progress');
|
||||
await _updateUserProgress(
|
||||
userId.toString(), taskId.toString(), 'in_progress');
|
||||
|
||||
return _json({
|
||||
'task': updatedTask.toJson(),
|
||||
|
|
@ -223,7 +221,8 @@ class TasksApiV2 {
|
|||
});
|
||||
|
||||
// Update user progress with rewards
|
||||
await _updateUserProgress(userId.toString(), taskId.toString(), 'completed',
|
||||
await _updateUserProgress(
|
||||
userId.toString(), taskId.toString(), 'completed',
|
||||
rewards: updatedTask.rewards);
|
||||
|
||||
return _json({
|
||||
|
|
@ -324,7 +323,8 @@ class TasksApiV2 {
|
|||
final achievements = List<String>.from(progress.achievements);
|
||||
|
||||
if (status == 'completed' && rewards != null) {
|
||||
final completedTasks = Map<String, DateTime>.from(progress.completedTasks);
|
||||
final completedTasks =
|
||||
Map<String, DateTime>.from(progress.completedTasks);
|
||||
completedTasks[taskId] = DateTime.now();
|
||||
|
||||
for (final reward in rewards) {
|
||||
|
|
@ -345,11 +345,14 @@ class TasksApiV2 {
|
|||
}
|
||||
|
||||
final updatedProgress = progress.copyWith(
|
||||
taskStatusesJson: UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||
completedTasksJson: UserTaskProgressModel.completedTasksToJson(completedTasks),
|
||||
taskStatusesJson:
|
||||
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||
completedTasksJson:
|
||||
UserTaskProgressModel.completedTasksToJson(completedTasks),
|
||||
totalXp: totalXp,
|
||||
totalCoins: totalCoins,
|
||||
achievementsJson: UserTaskProgressModel.achievementsToJson(achievements),
|
||||
achievementsJson:
|
||||
UserTaskProgressModel.achievementsToJson(achievements),
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
|
||||
|
|
@ -358,7 +361,8 @@ class TasksApiV2 {
|
|||
});
|
||||
} else {
|
||||
final updatedProgress = progress.copyWith(
|
||||
taskStatusesJson: UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||
taskStatusesJson:
|
||||
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -287,8 +287,10 @@ class UsersApiV2 {
|
|||
try {
|
||||
// Parse query parameters
|
||||
final packId = request.url.queryParameters['packId'];
|
||||
final limit = int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
|
||||
final offset = int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
|
||||
final limit =
|
||||
int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
|
||||
final offset =
|
||||
int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
|
||||
final sortBy = request.url.queryParameters['sortBy'] ?? 'difficulty';
|
||||
final needsReview = request.url.queryParameters['needsReview'] == 'true';
|
||||
|
||||
|
|
@ -340,8 +342,10 @@ class UsersApiV2 {
|
|||
break;
|
||||
case 'recent':
|
||||
words.sort((a, b) {
|
||||
final aTime = a.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bTime = b.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final aTime =
|
||||
a.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bTime =
|
||||
b.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return bTime.compareTo(aTime); // Most recent first
|
||||
});
|
||||
break;
|
||||
|
|
@ -460,7 +464,10 @@ class UsersApiV2 {
|
|||
return _json({'result': true, 'sessionId': sessionDto.sessionId});
|
||||
} catch (e) {
|
||||
return _json(
|
||||
{'error': 'bad_request', 'message': 'Invalid session data: ${e.toString()}'},
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Invalid session data: ${e.toString()}'
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
|
@ -481,7 +488,8 @@ class UsersApiV2 {
|
|||
return _json([]);
|
||||
}
|
||||
|
||||
final achievements = _statisticsCalculator.calculateAchievementProgress(userData);
|
||||
final achievements =
|
||||
_statisticsCalculator.calculateAchievementProgress(userData);
|
||||
return _json(achievements.map((a) => a.toJson()).toList());
|
||||
} catch (e) {
|
||||
return _json(
|
||||
|
|
@ -493,4 +501,3 @@ class UsersApiV2 {
|
|||
|
||||
Router get router => _$UsersApiV2Router(this);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import 'auth_code_storage.dart';
|
|||
class TelegramAuthCodeService {
|
||||
final AuthCodeStorage _storage;
|
||||
|
||||
TelegramAuthCodeService()
|
||||
: _storage = AuthCodeStorage();
|
||||
TelegramAuthCodeService() : _storage = AuthCodeStorage();
|
||||
|
||||
/// Generate a new authentication code for a Telegram user (legacy bot flow).
|
||||
String generateCodeForTelegramUser({
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ class TasksSeederTask with Task {
|
|||
// App Internal Tasks
|
||||
UserTaskModel(
|
||||
title: 'Пройди 3 теста сегодня',
|
||||
description: 'Заверши три тестовые сессии в приложении для улучшения навыков испанского.',
|
||||
description:
|
||||
'Заверши три тестовые сессии в приложении для улучшения навыков испанского.',
|
||||
type: 'app_internal',
|
||||
difficulty: 'easy',
|
||||
rewards: [
|
||||
|
|
@ -42,7 +43,8 @@ class TasksSeederTask with Task {
|
|||
|
||||
UserTaskModel(
|
||||
title: 'Достигни 80% правильных ответов в тесте',
|
||||
description: 'Пройди тест с результатом не менее 80% правильных ответов.',
|
||||
description:
|
||||
'Пройди тест с результатом не менее 80% правильных ответов.',
|
||||
type: 'app_internal',
|
||||
difficulty: 'medium',
|
||||
rewards: [
|
||||
|
|
@ -58,7 +60,8 @@ class TasksSeederTask with Task {
|
|||
// External Tasks
|
||||
UserTaskModel(
|
||||
title: 'Подпишись на Telegram канал',
|
||||
description: 'Подпишись на наш Telegram канал @mnemo_cards для получения обновлений и советов по изучению испанского.',
|
||||
description:
|
||||
'Подпишись на наш Telegram канал @mnemo_cards для получения обновлений и советов по изучению испанского.',
|
||||
type: 'external',
|
||||
difficulty: 'easy',
|
||||
rewards: [
|
||||
|
|
@ -73,24 +76,30 @@ class TasksSeederTask with Task {
|
|||
|
||||
UserTaskModel(
|
||||
title: 'Закажи еду на испанском',
|
||||
description: 'Сделай заказ в ресторане или кафе, используя испанский язык. Запиши процесс на видео для подтверждения.',
|
||||
description:
|
||||
'Сделай заказ в ресторане или кафе, используя испанский язык. Запиши процесс на видео для подтверждения.',
|
||||
type: 'external',
|
||||
difficulty: 'hard',
|
||||
rewards: [
|
||||
TaskRewardModel(type: 'xp', amount: 200),
|
||||
TaskRewardModel(type: 'coins', amount: 50),
|
||||
TaskRewardModel(type: 'achievement', amount: 0, achievementId: 'real_world_practice'),
|
||||
TaskRewardModel(
|
||||
type: 'achievement',
|
||||
amount: 0,
|
||||
achievementId: 'real_world_practice'),
|
||||
],
|
||||
status: 'available',
|
||||
createdAt: now,
|
||||
expiresAt: weekFromNow.add(const Duration(days: 14)), // 3 weeks
|
||||
instructions: 'Запиши видео, где ты делаешь заказ на испанском. Убедись, что официант понимает тебя.',
|
||||
instructions:
|
||||
'Запиши видео, где ты делаешь заказ на испанском. Убедись, что официант понимает тебя.',
|
||||
tags: ['speaking', 'restaurant', 'video', 'real_world'],
|
||||
),
|
||||
|
||||
UserTaskModel(
|
||||
title: 'Посмотри испанский фильм с субтитрами',
|
||||
description: 'Посмотри испанский фильм или сериал с испанскими субтитрами в течение 30 минут.',
|
||||
description:
|
||||
'Посмотри испанский фильм или сериал с испанскими субтитрами в течение 30 минут.',
|
||||
type: 'external',
|
||||
difficulty: 'medium',
|
||||
rewards: [
|
||||
|
|
@ -106,7 +115,8 @@ class TasksSeederTask with Task {
|
|||
// Social Tasks
|
||||
UserTaskModel(
|
||||
title: 'Поделись прогрессом в соцсетях',
|
||||
description: 'Поделись своим прогрессом в изучении испанского в социальных сетях с хэштегом #MnemoCards.',
|
||||
description:
|
||||
'Поделись своим прогрессом в изучении испанского в социальных сетях с хэштегом #MnemoCards.',
|
||||
type: 'social',
|
||||
difficulty: 'easy',
|
||||
rewards: [
|
||||
|
|
@ -121,7 +131,8 @@ class TasksSeederTask with Task {
|
|||
|
||||
UserTaskModel(
|
||||
title: 'Найди партнёра для практики',
|
||||
description: 'Найди собеседника для практики испанского языка (друга, коллегу или через языковые приложения).',
|
||||
description:
|
||||
'Найди собеседника для практики испанского языка (друга, коллегу или через языковые приложения).',
|
||||
type: 'social',
|
||||
difficulty: 'medium',
|
||||
rewards: [
|
||||
|
|
@ -137,13 +148,17 @@ class TasksSeederTask with Task {
|
|||
// Additional App Tasks
|
||||
UserTaskModel(
|
||||
title: 'Изучай слова ежедневно 7 дней подряд',
|
||||
description: 'Учи новые слова в приложении каждый день в течение недели.',
|
||||
description:
|
||||
'Учи новые слова в приложении каждый день в течение недели.',
|
||||
type: 'app_internal',
|
||||
difficulty: 'hard',
|
||||
rewards: [
|
||||
TaskRewardModel(type: 'xp', amount: 150),
|
||||
TaskRewardModel(type: 'coins', amount: 35),
|
||||
TaskRewardModel(type: 'achievement', amount: 0, achievementId: 'consistency_master'),
|
||||
TaskRewardModel(
|
||||
type: 'achievement',
|
||||
amount: 0,
|
||||
achievementId: 'consistency_master'),
|
||||
],
|
||||
status: 'available',
|
||||
createdAt: now,
|
||||
|
|
@ -153,7 +168,8 @@ class TasksSeederTask with Task {
|
|||
|
||||
UserTaskModel(
|
||||
title: 'Пройди тест по грамматике',
|
||||
description: 'Заверши специализированный тест по грамматике испанского языка.',
|
||||
description:
|
||||
'Заверши специализированный тест по грамматике испанского языка.',
|
||||
type: 'app_internal',
|
||||
difficulty: 'medium',
|
||||
rewards: [
|
||||
|
|
@ -168,7 +184,8 @@ class TasksSeederTask with Task {
|
|||
|
||||
UserTaskModel(
|
||||
title: 'Практикуй произношение',
|
||||
description: 'Заверши урок по произношению или запиши себя, читая текст на испанском.',
|
||||
description:
|
||||
'Заверши урок по произношению или запиши себя, читая текст на испанском.',
|
||||
type: 'app_internal',
|
||||
difficulty: 'easy',
|
||||
rewards: [
|
||||
|
|
@ -189,7 +206,6 @@ class TasksSeederTask with Task {
|
|||
});
|
||||
|
||||
print('Successfully seeded ${tasks.length} user tasks');
|
||||
|
||||
} catch (e) {
|
||||
print('Error seeding tasks: $e');
|
||||
rethrow;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ class AchievementManager {
|
|||
AchievementManager(this._isar);
|
||||
|
||||
/// Get all available achievements with their definitions
|
||||
List<AchievementDefinition> get allAchievementDefinitions => _achievementDefinitions;
|
||||
List<AchievementDefinition> get allAchievementDefinitions =>
|
||||
_achievementDefinitions;
|
||||
|
||||
/// Check and update achievements for a user
|
||||
///
|
||||
|
|
@ -35,7 +36,8 @@ class AchievementManager {
|
|||
|
||||
for (final definition in _achievementDefinitions) {
|
||||
// Skip if already unlocked
|
||||
if (currentAchievements.any((a) => a.id == definition.id && a.isUnlocked)) {
|
||||
if (currentAchievements
|
||||
.any((a) => a.id == definition.id && a.isUnlocked)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +121,9 @@ class AchievementManager {
|
|||
final user = await _isar.userModels.get(userId);
|
||||
if (user?.userData.value == null) return [];
|
||||
|
||||
return user!.userData.value!.achievements.map((model) => model.toDto()).toList();
|
||||
return user!.userData.value!.achievements
|
||||
.map((model) => model.toDto())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Save achievement to user data
|
||||
|
|
@ -137,7 +141,8 @@ class AchievementManager {
|
|||
model,
|
||||
];
|
||||
|
||||
final updatedUserData = userData.copyWith(achievements: updatedAchievements);
|
||||
final updatedUserData =
|
||||
userData.copyWith(achievements: updatedAchievements);
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.userDataModels.put(updatedUserData);
|
||||
|
|
@ -170,7 +175,8 @@ class AchievementManager {
|
|||
title: 'First Pack',
|
||||
description: 'Complete your first pack',
|
||||
type: AchievementType.firstPackCompleted,
|
||||
evaluate: (data) => Future.value(data.packProgress.any((p) => p.progress >= 1.0)),
|
||||
evaluate: (data) =>
|
||||
Future.value(data.packProgress.any((p) => p.progress >= 1.0)),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
|
|
@ -182,7 +188,8 @@ class AchievementManager {
|
|||
type: AchievementType.streak3Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 3),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.currentStreak / 3.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 3.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -192,7 +199,8 @@ class AchievementManager {
|
|||
type: AchievementType.streak7Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 7),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.currentStreak / 7.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 7.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -202,7 +210,8 @@ class AchievementManager {
|
|||
type: AchievementType.streak30Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 30),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.currentStreak / 30.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 30.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -212,7 +221,8 @@ class AchievementManager {
|
|||
type: AchievementType.streak100Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 100),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.currentStreak / 100.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 100.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
// Words Mastery
|
||||
|
|
@ -223,7 +233,8 @@ class AchievementManager {
|
|||
type: AchievementType.words10Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 10),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.words.length / 10.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 10.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -233,7 +244,8 @@ class AchievementManager {
|
|||
type: AchievementType.words50Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 50),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.words.length / 50.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 50.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -243,7 +255,8 @@ class AchievementManager {
|
|||
type: AchievementType.words100Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 100),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.words.length / 100.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 100.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -253,7 +266,8 @@ class AchievementManager {
|
|||
type: AchievementType.words500Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 500),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.words.length / 500.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 500.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -263,7 +277,8 @@ class AchievementManager {
|
|||
type: AchievementType.words1000Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 1000),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.words.length / 1000.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 1000.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
// Performance Achievements
|
||||
|
|
@ -274,10 +289,12 @@ class AchievementManager {
|
|||
type: AchievementType.perfectTestScore,
|
||||
evaluate: (data) => Future.value(
|
||||
data.testsStatistics.any((test) => test.attempts.any((attempt) {
|
||||
final totalWords = attempt.words.length;
|
||||
final correctWords = attempt.words.where((word) => word.correct > word.incorrect).length;
|
||||
return totalWords > 0 && correctWords == totalWords;
|
||||
})),
|
||||
final totalWords = attempt.words.length;
|
||||
final correctWords = attempt.words
|
||||
.where((word) => word.correct > word.incorrect)
|
||||
.length;
|
||||
return totalWords > 0 && correctWords == totalWords;
|
||||
})),
|
||||
),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
|
@ -289,9 +306,9 @@ class AchievementManager {
|
|||
type: AchievementType.speedLearner,
|
||||
evaluate: (data) => Future.value(
|
||||
data.packProgress.any((pack) =>
|
||||
pack.progress >= 1.0 &&
|
||||
pack.studyTimeMinutes < 24 * 60 // Less than 24 hours
|
||||
),
|
||||
pack.progress >= 1.0 &&
|
||||
pack.studyTimeMinutes < 24 * 60 // Less than 24 hours
|
||||
),
|
||||
),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
|
@ -304,7 +321,8 @@ class AchievementManager {
|
|||
type: AchievementType.dedicatedLearner,
|
||||
evaluate: (data) => Future.value(data.totalStudyTimeMinutes >= 100 * 60),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.totalStudyTimeMinutes / (100 * 60)).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) => Future.value(
|
||||
(data.totalStudyTimeMinutes / (100 * 60)).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
// Time-based Achievements
|
||||
|
|
@ -338,7 +356,8 @@ class AchievementManager {
|
|||
type: AchievementType.consistentLearner,
|
||||
evaluate: (data) => Future.value(data.longestStreak >= 30),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value((data.longestStreak / 30.0).clamp(0.0, 1.0)),
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.longestStreak / 30.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
|
|
@ -351,7 +370,8 @@ class AchievementManager {
|
|||
),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value(
|
||||
(data.packProgress.where((p) => p.progress >= 1.0).length / 5.0).clamp(0.0, 1.0),
|
||||
(data.packProgress.where((p) => p.progress >= 1.0).length / 5.0)
|
||||
.clamp(0.0, 1.0),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ class SessionTracker {
|
|||
///
|
||||
/// Returns the session ID of the active session.
|
||||
/// If no active session exists, creates a new one.
|
||||
Future<String> getOrCreateSession(int userId, {
|
||||
Future<String> getOrCreateSession(
|
||||
int userId, {
|
||||
String? packId,
|
||||
String? testId,
|
||||
}) async {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ class StatisticsCalculator {
|
|||
|
||||
var streak = 0;
|
||||
var currentDate = DateTime.now();
|
||||
currentDate = DateTime(currentDate.year, currentDate.month, currentDate.day);
|
||||
currentDate =
|
||||
DateTime(currentDate.year, currentDate.month, currentDate.day);
|
||||
|
||||
// Check if today or yesterday has activity
|
||||
final hasRecentActivity = normalizedDates.any((date) {
|
||||
|
|
@ -113,10 +114,11 @@ class StatisticsCalculator {
|
|||
|
||||
// Then add other difficult words if we haven't reached the limit
|
||||
if (result.length < limit) {
|
||||
final remaining = words.where((word) =>
|
||||
!word.needsReview &&
|
||||
!result.any((added) => added.word == word.word)
|
||||
).take(limit - result.length);
|
||||
final remaining = words
|
||||
.where((word) =>
|
||||
!word.needsReview &&
|
||||
!result.any((added) => added.word == word.word))
|
||||
.take(limit - result.length);
|
||||
|
||||
result.addAll(remaining);
|
||||
}
|
||||
|
|
@ -204,7 +206,8 @@ class StatisticsCalculator {
|
|||
// Calculate aggregates
|
||||
final totalDays = endDate.difference(startDate).inDays + 1;
|
||||
final activeDays = periodDailyTime.length;
|
||||
final totalMinutes = periodDailyTime.values.fold<int>(0, (sum, time) => sum + time);
|
||||
final totalMinutes =
|
||||
periodDailyTime.values.fold<int>(0, (sum, time) => sum + time);
|
||||
final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0;
|
||||
|
||||
// Calculate streak in period
|
||||
|
|
@ -222,7 +225,8 @@ class StatisticsCalculator {
|
|||
'dailyActivity': periodDailyTime.map(
|
||||
(date, minutes) => MapEntry(date.toIso8601String(), minutes),
|
||||
),
|
||||
'studyDates': periodStudyDates.map((date) => date.toIso8601String()).toList(),
|
||||
'studyDates':
|
||||
periodStudyDates.map((date) => date.toIso8601String()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +306,8 @@ class StatisticsCalculator {
|
|||
int calculateUserLevel(UserDataModel data) {
|
||||
final wordsLearned = data.words.length;
|
||||
final studyHours = data.totalStudyTimeMinutes / 60.0;
|
||||
final packsCompleted = data.packProgress.where((p) => p.progress >= 1.0).length;
|
||||
final packsCompleted =
|
||||
data.packProgress.where((p) => p.progress >= 1.0).length;
|
||||
|
||||
// Simple level calculation
|
||||
final score = wordsLearned + (studyHours * 2) + (packsCompleted * 10);
|
||||
|
|
@ -337,16 +342,19 @@ class StatisticsCalculator {
|
|||
}
|
||||
|
||||
// Calculate accuracy
|
||||
final totalCorrect = data.words.fold<double>(0, (sum, word) => sum + word.correct);
|
||||
final totalIncorrect = data.words.fold<double>(0, (sum, word) => sum + word.incorrect);
|
||||
final totalCorrect =
|
||||
data.words.fold<double>(0, (sum, word) => sum + word.correct);
|
||||
final totalIncorrect =
|
||||
data.words.fold<double>(0, (sum, word) => sum + word.incorrect);
|
||||
final accuracy = totalCorrect / (totalCorrect + totalIncorrect);
|
||||
|
||||
// Calculate average difficulty (simplified)
|
||||
final averageDifficulty = data.words.fold<double>(0, (sum, word) {
|
||||
final total = word.correct + word.incorrect + word.skipped;
|
||||
if (total == 0) return sum;
|
||||
return sum + (word.incorrect / total);
|
||||
}) / totalWords;
|
||||
final total = word.correct + word.incorrect + word.skipped;
|
||||
if (total == 0) return sum;
|
||||
return sum + (word.incorrect / total);
|
||||
}) /
|
||||
totalWords;
|
||||
|
||||
// Calculate consistency (based on streak)
|
||||
final consistency = min(1.0, data.currentStreak / 30.0);
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ extension UserDataModelExtension on UserDataModel {
|
|||
currentStreak: currentStreak,
|
||||
longestStreak: longestStreak,
|
||||
packProgress: {
|
||||
for (final progress in packProgress)
|
||||
progress.packId: progress.toDto()
|
||||
for (final progress in packProgress) progress.packId: progress.toDto()
|
||||
},
|
||||
studyDates: List<DateTime>.from(studyDates),
|
||||
categoryMinutes: Map<String, int>.from(categoryMinutes),
|
||||
|
|
|
|||
|
|
@ -241,9 +241,10 @@ class UserManager {
|
|||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||||
|
||||
// Add today's study date if not already present
|
||||
if (!studyDates.any((date) => date.year == todayNormalized.year &&
|
||||
date.month == todayNormalized.month &&
|
||||
date.day == todayNormalized.day)) {
|
||||
if (!studyDates.any((date) =>
|
||||
date.year == todayNormalized.year &&
|
||||
date.month == todayNormalized.month &&
|
||||
date.day == todayNormalized.day)) {
|
||||
studyDates.add(todayNormalized);
|
||||
}
|
||||
|
||||
|
|
@ -257,10 +258,12 @@ class UserManager {
|
|||
|
||||
// Calculate total study time (simplified - add time from this test)
|
||||
final testDurationMinutes = 5; // Assume 5 minutes per test as default
|
||||
final totalStudyTimeMinutes = currentData.totalStudyTimeMinutes + testDurationMinutes;
|
||||
final totalStudyTimeMinutes =
|
||||
currentData.totalStudyTimeMinutes + testDurationMinutes;
|
||||
|
||||
// Calculate pack progress if we have pack info
|
||||
final packProgress = List<PackProgressDto>.from(currentData.packProgress);
|
||||
final packProgress =
|
||||
List<PackProgressDto>.from(currentData.packProgress);
|
||||
|
||||
// Update pack progress if test was for a specific pack
|
||||
if (testStat.testId != null) {
|
||||
|
|
@ -316,13 +319,15 @@ class UserManager {
|
|||
UserDataModel userData,
|
||||
) async {
|
||||
try {
|
||||
final newlyUnlockedAchievements = await _achievementManager.checkAndUnlockAchievements(
|
||||
final newlyUnlockedAchievements =
|
||||
await _achievementManager.checkAndUnlockAchievements(
|
||||
user.id!,
|
||||
userData,
|
||||
);
|
||||
|
||||
if (newlyUnlockedAchievements.isNotEmpty) {
|
||||
print('New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}');
|
||||
print(
|
||||
'New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}');
|
||||
}
|
||||
|
||||
return newlyUnlockedAchievements;
|
||||
|
|
@ -367,7 +372,8 @@ class UserManager {
|
|||
accuracy: accuracy,
|
||||
);
|
||||
|
||||
print('Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}');
|
||||
print(
|
||||
'Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}');
|
||||
} catch (e, s) {
|
||||
print('Error updating session tracking: $e\n$s');
|
||||
// Don't fail the main operation if session tracking fails
|
||||
|
|
|
|||
|
|
@ -96,11 +96,16 @@ flutter run -d chrome --web-port=8080
|
|||
|
||||
### Вариант 3: Production CORS (для деплоя)
|
||||
|
||||
Для production замените `'*'` на конкретный домен:
|
||||
Для production версии:
|
||||
|
||||
```dart
|
||||
'Access-Control-Allow-Origin': 'https://your-domain.com',
|
||||
```
|
||||
1. **Backend** уже настроен с правильными CORS настройками ✅
|
||||
2. **Frontend** использует `api.memo-cards.online` ✅
|
||||
3. **Nginx** настроен без конфликтующих COEP/COOP headers ✅
|
||||
|
||||
Текущая конфигурация позволяет:
|
||||
- ✅ Запросы с `memo-cards.online` на `api.memo-cards.online`
|
||||
- ✅ Все необходимые HTTP методы и headers
|
||||
- ✅ Preflight OPTIONS запросы
|
||||
|
||||
## Диагностика
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export SERVER_IP="147.45.152.129"
|
|||
export SERVER_USER="root"
|
||||
|
||||
# Domain configuration
|
||||
export DOMAIN="5492281-cf88967.twc1.net"
|
||||
export DOMAIN="memo-cards.online"
|
||||
|
||||
# =============================================================================
|
||||
# APPLICATION CONFIGURATION
|
||||
|
|
@ -37,9 +37,9 @@ export NGINX_ENABLED="/etc/nginx/sites-enabled/$APP_NAME"
|
|||
# =============================================================================
|
||||
|
||||
# API endpoints
|
||||
export API_BASE_URL="https://1592725-cf88967.twc1.net:8081"
|
||||
export API_BASE_URL="https://api.memo-cards.online:8081"
|
||||
#export API_BASE_URL_DEV="http://localhost:8000"
|
||||
export API_BASE_URL_DEV="https://1592725-cf88967.twc1.net:8080"
|
||||
export API_BASE_URL_DEV="https://api.memo-cards.online:8080"
|
||||
|
||||
# =============================================================================
|
||||
# SSL CONFIGURATION
|
||||
|
|
@ -69,9 +69,10 @@ export X_CONTENT_TYPE_OPTIONS="nosniff"
|
|||
export REFERRER_POLICY="no-referrer-when-downgrade"
|
||||
export STRICT_TRANSPORT_SECURITY="max-age=31536000; includeSubDomains"
|
||||
|
||||
# CORS headers for Flutter web assets
|
||||
# CORS headers for Flutter web assets (disabled to avoid conflicts with API CORS)
|
||||
export COOP_POLICY="same-origin"
|
||||
export COEP_POLICY="require-corp"
|
||||
# Note: COEP/COOP headers are disabled in nginx.conf to allow CORS requests to API
|
||||
|
||||
# Cache settings
|
||||
export CACHE_EXPIRES="1y"
|
||||
|
|
@ -98,7 +99,7 @@ export WEB_PERMISSIONS="755"
|
|||
# EMAIL CONFIGURATION (for Let's Encrypt)
|
||||
# =============================================================================
|
||||
|
||||
export LETSENCRYPT_EMAIL="admin@$DOMAIN"
|
||||
export LETSENCRYPT_EMAIL="admin@memo-cards.online"
|
||||
|
||||
# =============================================================================
|
||||
# FIREWALL CONFIGURATION
|
||||
|
|
@ -112,7 +113,8 @@ export FIREWALL_ALLOW_SSH="ssh"
|
|||
# =============================================================================
|
||||
|
||||
export CRON_RENEWAL_TIMES="0 12 * * * 0 0 * * *"
|
||||
export CRON_RENEWAL_COMMAND="certbot renew --quiet --post-hook \"systemctl reload nginx\""
|
||||
export CRON_RENEWAL_COMMAND="certbot renew --quiet --post-hook \"systemctl reload nginx\" --cert-name memo-cards.online"
|
||||
export CRON_API_RENEWAL_COMMAND="certbot renew --quiet --cert-name api.memo-cards.online"
|
||||
|
||||
# =============================================================================
|
||||
# COLORS FOR OUTPUT
|
||||
|
|
|
|||
|
|
@ -42,14 +42,30 @@ ssh "$SERVER_USER@$SERVER_IP" << EOF
|
|||
# Set proper permissions
|
||||
chown -R $WEB_USER:$WEB_GROUP $WEB_ROOT
|
||||
chmod -R $WEB_PERMISSIONS $WEB_ROOT
|
||||
|
||||
# Install SSL certificate first (self-signed for now)
|
||||
if [ ! -f "$SSL_SELF_CERT" ]; then
|
||||
echo "Generating self-signed SSL certificate..."
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout $SSL_SELF_KEY \
|
||||
-out $SSL_SELF_CERT \
|
||||
-subj "/C=RU/ST=Moscow/L=Moscow/O=MnemoCards/OU=IT/CN=$SERVER_IP"
|
||||
|
||||
# Install SSL certificate - try Let's Encrypt first
|
||||
if [ ! -d "/etc/letsencrypt/live/memo-cards.online" ]; then
|
||||
echo "🔐 Attempting to get Let's Encrypt SSL certificate for memo-cards.online..."
|
||||
|
||||
# Stop nginx temporarily for certificate issuance
|
||||
systemctl stop nginx 2>/dev/null || true
|
||||
|
||||
if certbot certonly --standalone -d memo-cards.online --non-interactive --agree-tos --email $LETSENCRYPT_EMAIL; then
|
||||
echo "✅ Let's Encrypt certificate obtained successfully!"
|
||||
else
|
||||
echo "❌ Failed to get Let's Encrypt certificate. Generating self-signed certificate..."
|
||||
if [ ! -f "$SSL_SELF_CERT" ]; then
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout $SSL_SELF_KEY \
|
||||
-out $SSL_SELF_CERT \
|
||||
-subj "/C=RU/ST=Moscow/L=Moscow/O=MnemoCards/OU=IT/CN=memo-cards.online"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start nginx again
|
||||
systemctl start nginx 2>/dev/null || true
|
||||
else
|
||||
echo "✅ Let's Encrypt certificate already exists for memo-cards.online"
|
||||
fi
|
||||
|
||||
# Configure nginx
|
||||
|
|
@ -83,9 +99,9 @@ ssh "$SERVER_USER@$SERVER_IP" << EOF
|
|||
ufw --force enable
|
||||
|
||||
echo "Deployment completed successfully!"
|
||||
echo "Application is available at: https://$SERVER_IP"
|
||||
echo "Application is available at: https://memo-cards.online"
|
||||
EOF
|
||||
|
||||
print_success "Deployment completed successfully! 🎉"
|
||||
print_success "Your app is now available at: https://$SERVER_IP"
|
||||
print_warning "Note: The SSL certificate is self-signed. For production, consider using Let's Encrypt or a commercial certificate."
|
||||
print_success "Your app is now available at: https://memo-cards.online"
|
||||
print_info "SSL certificate: Let's Encrypt (preferred) or self-signed (fallback)"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name 147.45.152.129;
|
||||
|
||||
server_name memo-cards.online;
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name 147.45.152.129;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt;
|
||||
ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key;
|
||||
server_name memo-cards.online;
|
||||
|
||||
# SSL configuration - Let's Encrypt (preferred)
|
||||
ssl_certificate /etc/letsencrypt/live/memo-cards.online/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/memo-cards.online/privkey.pem;
|
||||
|
||||
# Fallback to self-signed certificates if Let's Encrypt fails
|
||||
# ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt;
|
||||
# ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
|
@ -50,8 +54,9 @@ server {
|
|||
location ~* \.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp";
|
||||
add_header Cross-Origin-Opener-Policy "same-origin";
|
||||
# Removed COEP/COOP headers that interfere with CORS requests to API
|
||||
# add_header Cross-Origin-Embedder-Policy "require-corp";
|
||||
# add_header Cross-Origin-Opener-Policy "same-origin";
|
||||
}
|
||||
|
||||
# Security - deny access to hidden files
|
||||
|
|
|
|||
|
|
@ -116,8 +116,9 @@ server {
|
|||
location ~* \\.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
|
||||
expires $CACHE_EXPIRES;
|
||||
add_header Cache-Control "$CACHE_CONTROL";
|
||||
add_header Cross-Origin-Embedder-Policy "$COEP_POLICY";
|
||||
add_header Cross-Origin-Opener-Policy "$COOP_POLICY";
|
||||
# Disabled COEP/COOP headers to allow CORS requests to API
|
||||
# add_header Cross-Origin-Embedder-Policy "$COEP_POLICY";
|
||||
# add_header Cross-Origin-Opener-Policy "$COOP_POLICY";
|
||||
}
|
||||
|
||||
# Security - deny access to hidden files
|
||||
|
|
|
|||
Loading…
Reference in a new issue