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,10 +19,10 @@ sudo cp server_build.exe server.exe
|
||||||
rm server_build.exe
|
rm server_build.exe
|
||||||
|
|
||||||
# Проверяем наличие Let's Encrypt сертификатов для двойного режима
|
# Проверяем наличие 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)"
|
echo "✅ Let's Encrypt сертификаты найдены. Запускаем в двойном режиме (HTTP + HTTPS)"
|
||||||
# Обновляем systemd сервис для двойного режима
|
# Обновляем 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
|
else
|
||||||
echo "⚠️ Let's Encrypt сертификаты не найдены. Пытаемся создать..."
|
echo "⚠️ Let's Encrypt сертификаты не найдены. Пытаемся создать..."
|
||||||
|
|
||||||
|
|
@ -30,16 +30,16 @@ else
|
||||||
sudo systemctl stop mnemo_cards_server 2>/dev/null || true
|
sudo systemctl stop mnemo_cards_server 2>/dev/null || true
|
||||||
|
|
||||||
# Пытаемся получить Let's Encrypt сертификат
|
# Пытаемся получить Let's Encrypt сертификат
|
||||||
echo "🔐 Получаем Let's Encrypt сертификат для домена 1592725-cf88967.twc1.net..."
|
echo "🔐 Получаем Let's Encrypt сертификат для домена api.memo-cards.online..."
|
||||||
if sudo certbot certonly --standalone -d 1592725-cf88967.twc1.net --non-interactive --agree-tos --email admin@1592725-cf88967.twc1.net; then
|
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 "✅ Let's Encrypt сертификат успешно создан!"
|
||||||
echo "🚀 Запускаем в двойном режиме (HTTP + HTTPS)"
|
echo "🚀 Запускаем в двойном режиме (HTTP + HTTPS)"
|
||||||
# Обновляем systemd сервис для двойного режима
|
# Обновляем 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
|
else
|
||||||
echo "❌ Не удалось создать Let's Encrypt сертификат. Запускаем в HTTP режиме"
|
echo "❌ Не удалось создать Let's Encrypt сертификат. Запускаем в HTTP режиме"
|
||||||
echo "💡 Убедитесь, что:"
|
echo "💡 Убедитесь, что:"
|
||||||
echo " - Домен 1592725-cf88967.twc1.net указывает на этот сервер"
|
echo " - Домен api.memo-cards.online указывает на этот сервер"
|
||||||
echo " - Порт 80 доступен извне"
|
echo " - Порт 80 доступен извне"
|
||||||
echo " - Firewall разрешает входящие соединения на порт 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:mnemo_cards_backend/packs/pack_manager.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
||||||
Middleware accessMiddleware(AccessService accessService, PackManager packManager) {
|
Middleware accessMiddleware(
|
||||||
|
AccessService accessService, PackManager packManager) {
|
||||||
return (Handler inner) {
|
return (Handler inner) {
|
||||||
return (Request request) async {
|
return (Request request) async {
|
||||||
final loader = ResourceLoader(packManager);
|
final loader = ResourceLoader(packManager);
|
||||||
|
|
@ -30,5 +31,3 @@ AccessService buildAccessService(PackManager packManager) {
|
||||||
final adminPolicy = AdminAccessPolicy();
|
final adminPolicy = AdminAccessPolicy();
|
||||||
return AccessService(packPolicy, adminPolicy);
|
return AccessService(packPolicy, adminPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ class AccessService {
|
||||||
required int packId,
|
required int packId,
|
||||||
UserModel? user,
|
UserModel? user,
|
||||||
}) async {
|
}) 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;
|
return decision.allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,16 +29,19 @@ class AccessService {
|
||||||
required int packId,
|
required int packId,
|
||||||
UserModel? user,
|
UserModel? user,
|
||||||
}) async {
|
}) 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) {
|
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 {
|
Future<void> requireAdmin(AdminAction action, {UserModel? user}) async {
|
||||||
final decision = adminPolicy.check(action: action, user: user);
|
final decision = adminPolicy.check(action: action, user: user);
|
||||||
if (!decision.allowed) {
|
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
|
@override
|
||||||
String toString() => 'AccessDenied($status): $message';
|
String toString() => 'AccessDenied($status): $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,10 @@ class AccessDecision {
|
||||||
httpStatus = 200,
|
httpStatus = 200,
|
||||||
reason = null;
|
reason = null;
|
||||||
|
|
||||||
const AccessDecision.denied(this.httpStatus, this.reason)
|
const AccessDecision.denied(this.httpStatus, this.reason) : allowed = false;
|
||||||
: allowed = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AccessContext {
|
class AccessContext {
|
||||||
final Object? user;
|
final Object? user;
|
||||||
const AccessContext({this.user});
|
const AccessContext({this.user});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,3 @@ class AdminAccessPolicy {
|
||||||
return const AccessDecision.denied(403, 'Admin access required');
|
return const AccessDecision.denied(403, 'Admin access required');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ class PackAccessPolicy {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public preview packs can be viewed without auth
|
// Public preview packs can be viewed without auth
|
||||||
if (publicPackIds.contains(packId) && {
|
if (publicPackIds.contains(packId) &&
|
||||||
|
{
|
||||||
PackAccessAction.view,
|
PackAccessAction.view,
|
||||||
PackAccessAction.cards,
|
PackAccessAction.cards,
|
||||||
PackAccessAction.tests,
|
PackAccessAction.tests,
|
||||||
|
|
@ -63,5 +64,3 @@ class PackAccessPolicy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,3 @@ class ResourceLoader {
|
||||||
return model?.enabled == true;
|
return model?.enabled == true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,5 +45,3 @@ Future<Response> guardPack(
|
||||||
return accessErrorResponse(e);
|
return accessErrorResponse(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ class AdminUsersApiV2 {
|
||||||
|
|
||||||
Future<Response> _ensureAdmin(Request request) async {
|
Future<Response> _ensureAdmin(Request request) async {
|
||||||
try {
|
try {
|
||||||
await request.access!.requireAdmin(AdminAction.access, user: request.user);
|
await request.access!
|
||||||
|
.requireAdmin(AdminAction.access, user: request.user);
|
||||||
return Response.ok(null);
|
return Response.ok(null);
|
||||||
} on AccessDenied catch (e) {
|
} on AccessDenied catch (e) {
|
||||||
return Response(e.status, body: e.message);
|
return Response(e.status, body: e.message);
|
||||||
|
|
@ -79,7 +80,9 @@ class AdminUsersApiV2 {
|
||||||
});
|
});
|
||||||
|
|
||||||
final dtos = await Future.wait(
|
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});
|
return _json({'users': dtos});
|
||||||
|
|
@ -95,11 +98,7 @@ class AdminUsersApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
final ids = await backend_main.isar.txn(
|
final ids = await backend_main.isar.txn(
|
||||||
() => backend_main.isar.userModels
|
() => backend_main.isar.userModels.where().anyId().idProperty().findAll(),
|
||||||
.where()
|
|
||||||
.anyId()
|
|
||||||
.idProperty()
|
|
||||||
.findAll(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return _json({'ids': ids.map((id) => id.toString()).join(',')});
|
return _json({'ids': ids.map((id) => id.toString()).join(',')});
|
||||||
|
|
@ -118,7 +117,8 @@ class AdminUsersApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
final payments = await _paymentManager.getUserPayments(userId);
|
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()});
|
return _json({'payments': dtos.map((p) => p.toJson()).toList()});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,4 +187,3 @@ class AdminUsersApiV2 {
|
||||||
|
|
||||||
Router get router => _$AdminUsersApiV2Router(this);
|
Router get router => _$AdminUsersApiV2Router(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,4 +166,3 @@ class AdsApiV2 {
|
||||||
|
|
||||||
Router get router => _$AdsApiV2Router(this);
|
Router get router => _$AdsApiV2Router(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ const _publicGetPrefixes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
final List<RegExp> _strictAuthRegexps = [
|
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
|
/// Middleware for API v2 Bearer token authentication
|
||||||
|
|
@ -62,7 +63,6 @@ Middleware authorizeV2(UserManager userManager, JwtService jwtService) {
|
||||||
normalizedPath.startsWith('$prefix/'),
|
normalizedPath.startsWith('$prefix/'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Extract Bearer token from Authorization header
|
// Extract Bearer token from Authorization header
|
||||||
final authHeader = request.headers['authorization'];
|
final authHeader = request.headers['authorization'];
|
||||||
final hasBearerToken =
|
final hasBearerToken =
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ class DiscountsApiV2 {
|
||||||
|
|
||||||
Future<Response> _ensureAdmin(Request request) async {
|
Future<Response> _ensureAdmin(Request request) async {
|
||||||
try {
|
try {
|
||||||
await request.access!.requireAdmin(AdminAction.access, user: request.user);
|
await request.access!
|
||||||
|
.requireAdmin(AdminAction.access, user: request.user);
|
||||||
return Response.ok(null);
|
return Response.ok(null);
|
||||||
} on AccessDenied catch (e) {
|
} on AccessDenied catch (e) {
|
||||||
return Response(e.status, body: e.message);
|
return Response(e.status, body: e.message);
|
||||||
|
|
@ -131,4 +132,3 @@ class DiscountsApiV2 {
|
||||||
|
|
||||||
Router get router => _$DiscountsApiV2Router(this);
|
Router get router => _$DiscountsApiV2Router(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,8 +233,7 @@ class PacksApiV2 {
|
||||||
final packJson = pack.toJson();
|
final packJson = pack.toJson();
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
await user.packs.load();
|
await user.packs.load();
|
||||||
final packModel =
|
final packModel = await backend_main.isar.cardPackModels.get(packIdInt);
|
||||||
await backend_main.isar.cardPackModels.get(packIdInt);
|
|
||||||
final isPurchased = packModel != null &&
|
final isPurchased = packModel != null &&
|
||||||
(user.packs.contains(packId) ||
|
(user.packs.contains(packId) ||
|
||||||
(await _hasSubscriptionAccess(user)));
|
(await _hasSubscriptionAccess(user)));
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ class PromocodesApiV2 {
|
||||||
|
|
||||||
Future<Response> _ensureAdmin(Request request) async {
|
Future<Response> _ensureAdmin(Request request) async {
|
||||||
try {
|
try {
|
||||||
await request.access!.requireAdmin(AdminAction.access, user: request.user);
|
await request.access!
|
||||||
|
.requireAdmin(AdminAction.access, user: request.user);
|
||||||
return Response.ok(null);
|
return Response.ok(null);
|
||||||
} on AccessDenied catch (e) {
|
} on AccessDenied catch (e) {
|
||||||
return Response(e.status, body: e.message);
|
return Response(e.status, body: e.message);
|
||||||
|
|
@ -183,4 +184,3 @@ class PromocodesApiV2 {
|
||||||
|
|
||||||
Router get router => _$PromocodesApiV2Router(this);
|
Router get router => _$PromocodesApiV2Router(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,6 @@ class TasksApiV2 {
|
||||||
// Build query
|
// Build query
|
||||||
var builder = isar.userTaskModels.where().filter();
|
var builder = isar.userTaskModels.where().filter();
|
||||||
|
|
||||||
|
|
||||||
if (type != null) {
|
if (type != null) {
|
||||||
builder = builder.typeEqualTo(type);
|
builder = builder.typeEqualTo(type);
|
||||||
}
|
}
|
||||||
|
|
@ -110,10 +109,8 @@ class TasksApiV2 {
|
||||||
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
||||||
}
|
}
|
||||||
|
|
||||||
final tasks = await builder.idIsNotNull()
|
final tasks =
|
||||||
.offset(offset)
|
await builder.idIsNotNull().offset(offset).limit(limit).findAll();
|
||||||
.limit(limit)
|
|
||||||
.findAll();
|
|
||||||
|
|
||||||
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -164,7 +161,8 @@ class TasksApiV2 {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user progress
|
// Update user progress
|
||||||
await _updateUserProgress(userId.toString(), taskId.toString(), 'in_progress');
|
await _updateUserProgress(
|
||||||
|
userId.toString(), taskId.toString(), 'in_progress');
|
||||||
|
|
||||||
return _json({
|
return _json({
|
||||||
'task': updatedTask.toJson(),
|
'task': updatedTask.toJson(),
|
||||||
|
|
@ -223,7 +221,8 @@ class TasksApiV2 {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user progress with rewards
|
// Update user progress with rewards
|
||||||
await _updateUserProgress(userId.toString(), taskId.toString(), 'completed',
|
await _updateUserProgress(
|
||||||
|
userId.toString(), taskId.toString(), 'completed',
|
||||||
rewards: updatedTask.rewards);
|
rewards: updatedTask.rewards);
|
||||||
|
|
||||||
return _json({
|
return _json({
|
||||||
|
|
@ -324,7 +323,8 @@ class TasksApiV2 {
|
||||||
final achievements = List<String>.from(progress.achievements);
|
final achievements = List<String>.from(progress.achievements);
|
||||||
|
|
||||||
if (status == 'completed' && rewards != null) {
|
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();
|
completedTasks[taskId] = DateTime.now();
|
||||||
|
|
||||||
for (final reward in rewards) {
|
for (final reward in rewards) {
|
||||||
|
|
@ -345,11 +345,14 @@ class TasksApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
final updatedProgress = progress.copyWith(
|
final updatedProgress = progress.copyWith(
|
||||||
taskStatusesJson: UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
taskStatusesJson:
|
||||||
completedTasksJson: UserTaskProgressModel.completedTasksToJson(completedTasks),
|
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||||
|
completedTasksJson:
|
||||||
|
UserTaskProgressModel.completedTasksToJson(completedTasks),
|
||||||
totalXp: totalXp,
|
totalXp: totalXp,
|
||||||
totalCoins: totalCoins,
|
totalCoins: totalCoins,
|
||||||
achievementsJson: UserTaskProgressModel.achievementsToJson(achievements),
|
achievementsJson:
|
||||||
|
UserTaskProgressModel.achievementsToJson(achievements),
|
||||||
lastUpdated: DateTime.now(),
|
lastUpdated: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -358,7 +361,8 @@ class TasksApiV2 {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
final updatedProgress = progress.copyWith(
|
final updatedProgress = progress.copyWith(
|
||||||
taskStatusesJson: UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
taskStatusesJson:
|
||||||
|
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||||
lastUpdated: DateTime.now(),
|
lastUpdated: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -287,8 +287,10 @@ class UsersApiV2 {
|
||||||
try {
|
try {
|
||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
final packId = request.url.queryParameters['packId'];
|
final packId = request.url.queryParameters['packId'];
|
||||||
final limit = int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
|
final limit =
|
||||||
final offset = int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
|
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 sortBy = request.url.queryParameters['sortBy'] ?? 'difficulty';
|
||||||
final needsReview = request.url.queryParameters['needsReview'] == 'true';
|
final needsReview = request.url.queryParameters['needsReview'] == 'true';
|
||||||
|
|
||||||
|
|
@ -340,8 +342,10 @@ class UsersApiV2 {
|
||||||
break;
|
break;
|
||||||
case 'recent':
|
case 'recent':
|
||||||
words.sort((a, b) {
|
words.sort((a, b) {
|
||||||
final aTime = a.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
final aTime =
|
||||||
final bTime = b.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
a.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bTime =
|
||||||
|
b.lastReviewed ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
return bTime.compareTo(aTime); // Most recent first
|
return bTime.compareTo(aTime); // Most recent first
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
@ -460,7 +464,10 @@ class UsersApiV2 {
|
||||||
return _json({'result': true, 'sessionId': sessionDto.sessionId});
|
return _json({'result': true, 'sessionId': sessionDto.sessionId});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return _json(
|
return _json(
|
||||||
{'error': 'bad_request', 'message': 'Invalid session data: ${e.toString()}'},
|
{
|
||||||
|
'error': 'bad_request',
|
||||||
|
'message': 'Invalid session data: ${e.toString()}'
|
||||||
|
},
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +488,8 @@ class UsersApiV2 {
|
||||||
return _json([]);
|
return _json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final achievements = _statisticsCalculator.calculateAchievementProgress(userData);
|
final achievements =
|
||||||
|
_statisticsCalculator.calculateAchievementProgress(userData);
|
||||||
return _json(achievements.map((a) => a.toJson()).toList());
|
return _json(achievements.map((a) => a.toJson()).toList());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return _json(
|
return _json(
|
||||||
|
|
@ -493,4 +501,3 @@ class UsersApiV2 {
|
||||||
|
|
||||||
Router get router => _$UsersApiV2Router(this);
|
Router get router => _$UsersApiV2Router(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@ import 'auth_code_storage.dart';
|
||||||
class TelegramAuthCodeService {
|
class TelegramAuthCodeService {
|
||||||
final AuthCodeStorage _storage;
|
final AuthCodeStorage _storage;
|
||||||
|
|
||||||
TelegramAuthCodeService()
|
TelegramAuthCodeService() : _storage = AuthCodeStorage();
|
||||||
: _storage = AuthCodeStorage();
|
|
||||||
|
|
||||||
/// Generate a new authentication code for a Telegram user (legacy bot flow).
|
/// Generate a new authentication code for a Telegram user (legacy bot flow).
|
||||||
String generateCodeForTelegramUser({
|
String generateCodeForTelegramUser({
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ class TasksSeederTask with Task {
|
||||||
// App Internal Tasks
|
// App Internal Tasks
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Пройди 3 теста сегодня',
|
title: 'Пройди 3 теста сегодня',
|
||||||
description: 'Заверши три тестовые сессии в приложении для улучшения навыков испанского.',
|
description:
|
||||||
|
'Заверши три тестовые сессии в приложении для улучшения навыков испанского.',
|
||||||
type: 'app_internal',
|
type: 'app_internal',
|
||||||
difficulty: 'easy',
|
difficulty: 'easy',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -42,7 +43,8 @@ class TasksSeederTask with Task {
|
||||||
|
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Достигни 80% правильных ответов в тесте',
|
title: 'Достигни 80% правильных ответов в тесте',
|
||||||
description: 'Пройди тест с результатом не менее 80% правильных ответов.',
|
description:
|
||||||
|
'Пройди тест с результатом не менее 80% правильных ответов.',
|
||||||
type: 'app_internal',
|
type: 'app_internal',
|
||||||
difficulty: 'medium',
|
difficulty: 'medium',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -58,7 +60,8 @@ class TasksSeederTask with Task {
|
||||||
// External Tasks
|
// External Tasks
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Подпишись на Telegram канал',
|
title: 'Подпишись на Telegram канал',
|
||||||
description: 'Подпишись на наш Telegram канал @mnemo_cards для получения обновлений и советов по изучению испанского.',
|
description:
|
||||||
|
'Подпишись на наш Telegram канал @mnemo_cards для получения обновлений и советов по изучению испанского.',
|
||||||
type: 'external',
|
type: 'external',
|
||||||
difficulty: 'easy',
|
difficulty: 'easy',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -73,24 +76,30 @@ class TasksSeederTask with Task {
|
||||||
|
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Закажи еду на испанском',
|
title: 'Закажи еду на испанском',
|
||||||
description: 'Сделай заказ в ресторане или кафе, используя испанский язык. Запиши процесс на видео для подтверждения.',
|
description:
|
||||||
|
'Сделай заказ в ресторане или кафе, используя испанский язык. Запиши процесс на видео для подтверждения.',
|
||||||
type: 'external',
|
type: 'external',
|
||||||
difficulty: 'hard',
|
difficulty: 'hard',
|
||||||
rewards: [
|
rewards: [
|
||||||
TaskRewardModel(type: 'xp', amount: 200),
|
TaskRewardModel(type: 'xp', amount: 200),
|
||||||
TaskRewardModel(type: 'coins', amount: 50),
|
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',
|
status: 'available',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: weekFromNow.add(const Duration(days: 14)), // 3 weeks
|
expiresAt: weekFromNow.add(const Duration(days: 14)), // 3 weeks
|
||||||
instructions: 'Запиши видео, где ты делаешь заказ на испанском. Убедись, что официант понимает тебя.',
|
instructions:
|
||||||
|
'Запиши видео, где ты делаешь заказ на испанском. Убедись, что официант понимает тебя.',
|
||||||
tags: ['speaking', 'restaurant', 'video', 'real_world'],
|
tags: ['speaking', 'restaurant', 'video', 'real_world'],
|
||||||
),
|
),
|
||||||
|
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Посмотри испанский фильм с субтитрами',
|
title: 'Посмотри испанский фильм с субтитрами',
|
||||||
description: 'Посмотри испанский фильм или сериал с испанскими субтитрами в течение 30 минут.',
|
description:
|
||||||
|
'Посмотри испанский фильм или сериал с испанскими субтитрами в течение 30 минут.',
|
||||||
type: 'external',
|
type: 'external',
|
||||||
difficulty: 'medium',
|
difficulty: 'medium',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -106,7 +115,8 @@ class TasksSeederTask with Task {
|
||||||
// Social Tasks
|
// Social Tasks
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Поделись прогрессом в соцсетях',
|
title: 'Поделись прогрессом в соцсетях',
|
||||||
description: 'Поделись своим прогрессом в изучении испанского в социальных сетях с хэштегом #MnemoCards.',
|
description:
|
||||||
|
'Поделись своим прогрессом в изучении испанского в социальных сетях с хэштегом #MnemoCards.',
|
||||||
type: 'social',
|
type: 'social',
|
||||||
difficulty: 'easy',
|
difficulty: 'easy',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -121,7 +131,8 @@ class TasksSeederTask with Task {
|
||||||
|
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Найди партнёра для практики',
|
title: 'Найди партнёра для практики',
|
||||||
description: 'Найди собеседника для практики испанского языка (друга, коллегу или через языковые приложения).',
|
description:
|
||||||
|
'Найди собеседника для практики испанского языка (друга, коллегу или через языковые приложения).',
|
||||||
type: 'social',
|
type: 'social',
|
||||||
difficulty: 'medium',
|
difficulty: 'medium',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -137,13 +148,17 @@ class TasksSeederTask with Task {
|
||||||
// Additional App Tasks
|
// Additional App Tasks
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Изучай слова ежедневно 7 дней подряд',
|
title: 'Изучай слова ежедневно 7 дней подряд',
|
||||||
description: 'Учи новые слова в приложении каждый день в течение недели.',
|
description:
|
||||||
|
'Учи новые слова в приложении каждый день в течение недели.',
|
||||||
type: 'app_internal',
|
type: 'app_internal',
|
||||||
difficulty: 'hard',
|
difficulty: 'hard',
|
||||||
rewards: [
|
rewards: [
|
||||||
TaskRewardModel(type: 'xp', amount: 150),
|
TaskRewardModel(type: 'xp', amount: 150),
|
||||||
TaskRewardModel(type: 'coins', amount: 35),
|
TaskRewardModel(type: 'coins', amount: 35),
|
||||||
TaskRewardModel(type: 'achievement', amount: 0, achievementId: 'consistency_master'),
|
TaskRewardModel(
|
||||||
|
type: 'achievement',
|
||||||
|
amount: 0,
|
||||||
|
achievementId: 'consistency_master'),
|
||||||
],
|
],
|
||||||
status: 'available',
|
status: 'available',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|
@ -153,7 +168,8 @@ class TasksSeederTask with Task {
|
||||||
|
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Пройди тест по грамматике',
|
title: 'Пройди тест по грамматике',
|
||||||
description: 'Заверши специализированный тест по грамматике испанского языка.',
|
description:
|
||||||
|
'Заверши специализированный тест по грамматике испанского языка.',
|
||||||
type: 'app_internal',
|
type: 'app_internal',
|
||||||
difficulty: 'medium',
|
difficulty: 'medium',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -168,7 +184,8 @@ class TasksSeederTask with Task {
|
||||||
|
|
||||||
UserTaskModel(
|
UserTaskModel(
|
||||||
title: 'Практикуй произношение',
|
title: 'Практикуй произношение',
|
||||||
description: 'Заверши урок по произношению или запиши себя, читая текст на испанском.',
|
description:
|
||||||
|
'Заверши урок по произношению или запиши себя, читая текст на испанском.',
|
||||||
type: 'app_internal',
|
type: 'app_internal',
|
||||||
difficulty: 'easy',
|
difficulty: 'easy',
|
||||||
rewards: [
|
rewards: [
|
||||||
|
|
@ -189,7 +206,6 @@ class TasksSeederTask with Task {
|
||||||
});
|
});
|
||||||
|
|
||||||
print('Successfully seeded ${tasks.length} user tasks');
|
print('Successfully seeded ${tasks.length} user tasks');
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error seeding tasks: $e');
|
print('Error seeding tasks: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ class AchievementManager {
|
||||||
AchievementManager(this._isar);
|
AchievementManager(this._isar);
|
||||||
|
|
||||||
/// Get all available achievements with their definitions
|
/// Get all available achievements with their definitions
|
||||||
List<AchievementDefinition> get allAchievementDefinitions => _achievementDefinitions;
|
List<AchievementDefinition> get allAchievementDefinitions =>
|
||||||
|
_achievementDefinitions;
|
||||||
|
|
||||||
/// Check and update achievements for a user
|
/// Check and update achievements for a user
|
||||||
///
|
///
|
||||||
|
|
@ -35,7 +36,8 @@ class AchievementManager {
|
||||||
|
|
||||||
for (final definition in _achievementDefinitions) {
|
for (final definition in _achievementDefinitions) {
|
||||||
// Skip if already unlocked
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +121,9 @@ class AchievementManager {
|
||||||
final user = await _isar.userModels.get(userId);
|
final user = await _isar.userModels.get(userId);
|
||||||
if (user?.userData.value == null) return [];
|
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
|
/// Save achievement to user data
|
||||||
|
|
@ -137,7 +141,8 @@ class AchievementManager {
|
||||||
model,
|
model,
|
||||||
];
|
];
|
||||||
|
|
||||||
final updatedUserData = userData.copyWith(achievements: updatedAchievements);
|
final updatedUserData =
|
||||||
|
userData.copyWith(achievements: updatedAchievements);
|
||||||
|
|
||||||
await _isar.writeTxn(() async {
|
await _isar.writeTxn(() async {
|
||||||
await _isar.userDataModels.put(updatedUserData);
|
await _isar.userDataModels.put(updatedUserData);
|
||||||
|
|
@ -170,7 +175,8 @@ class AchievementManager {
|
||||||
title: 'First Pack',
|
title: 'First Pack',
|
||||||
description: 'Complete your first pack',
|
description: 'Complete your first pack',
|
||||||
type: AchievementType.firstPackCompleted,
|
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,
|
supportsProgress: false,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -182,7 +188,8 @@ class AchievementManager {
|
||||||
type: AchievementType.streak3Days,
|
type: AchievementType.streak3Days,
|
||||||
evaluate: (data) => Future.value(data.currentStreak >= 3),
|
evaluate: (data) => Future.value(data.currentStreak >= 3),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -192,7 +199,8 @@ class AchievementManager {
|
||||||
type: AchievementType.streak7Days,
|
type: AchievementType.streak7Days,
|
||||||
evaluate: (data) => Future.value(data.currentStreak >= 7),
|
evaluate: (data) => Future.value(data.currentStreak >= 7),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -202,7 +210,8 @@ class AchievementManager {
|
||||||
type: AchievementType.streak30Days,
|
type: AchievementType.streak30Days,
|
||||||
evaluate: (data) => Future.value(data.currentStreak >= 30),
|
evaluate: (data) => Future.value(data.currentStreak >= 30),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -212,7 +221,8 @@ class AchievementManager {
|
||||||
type: AchievementType.streak100Days,
|
type: AchievementType.streak100Days,
|
||||||
evaluate: (data) => Future.value(data.currentStreak >= 100),
|
evaluate: (data) => Future.value(data.currentStreak >= 100),
|
||||||
supportsProgress: true,
|
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
|
// Words Mastery
|
||||||
|
|
@ -223,7 +233,8 @@ class AchievementManager {
|
||||||
type: AchievementType.words10Learned,
|
type: AchievementType.words10Learned,
|
||||||
evaluate: (data) => Future.value(data.words.length >= 10),
|
evaluate: (data) => Future.value(data.words.length >= 10),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -233,7 +244,8 @@ class AchievementManager {
|
||||||
type: AchievementType.words50Learned,
|
type: AchievementType.words50Learned,
|
||||||
evaluate: (data) => Future.value(data.words.length >= 50),
|
evaluate: (data) => Future.value(data.words.length >= 50),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -243,7 +255,8 @@ class AchievementManager {
|
||||||
type: AchievementType.words100Learned,
|
type: AchievementType.words100Learned,
|
||||||
evaluate: (data) => Future.value(data.words.length >= 100),
|
evaluate: (data) => Future.value(data.words.length >= 100),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -253,7 +266,8 @@ class AchievementManager {
|
||||||
type: AchievementType.words500Learned,
|
type: AchievementType.words500Learned,
|
||||||
evaluate: (data) => Future.value(data.words.length >= 500),
|
evaluate: (data) => Future.value(data.words.length >= 500),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -263,7 +277,8 @@ class AchievementManager {
|
||||||
type: AchievementType.words1000Learned,
|
type: AchievementType.words1000Learned,
|
||||||
evaluate: (data) => Future.value(data.words.length >= 1000),
|
evaluate: (data) => Future.value(data.words.length >= 1000),
|
||||||
supportsProgress: true,
|
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
|
// Performance Achievements
|
||||||
|
|
@ -275,7 +290,9 @@ class AchievementManager {
|
||||||
evaluate: (data) => Future.value(
|
evaluate: (data) => Future.value(
|
||||||
data.testsStatistics.any((test) => test.attempts.any((attempt) {
|
data.testsStatistics.any((test) => test.attempts.any((attempt) {
|
||||||
final totalWords = attempt.words.length;
|
final totalWords = attempt.words.length;
|
||||||
final correctWords = attempt.words.where((word) => word.correct > word.incorrect).length;
|
final correctWords = attempt.words
|
||||||
|
.where((word) => word.correct > word.incorrect)
|
||||||
|
.length;
|
||||||
return totalWords > 0 && correctWords == totalWords;
|
return totalWords > 0 && correctWords == totalWords;
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
|
@ -304,7 +321,8 @@ class AchievementManager {
|
||||||
type: AchievementType.dedicatedLearner,
|
type: AchievementType.dedicatedLearner,
|
||||||
evaluate: (data) => Future.value(data.totalStudyTimeMinutes >= 100 * 60),
|
evaluate: (data) => Future.value(data.totalStudyTimeMinutes >= 100 * 60),
|
||||||
supportsProgress: true,
|
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
|
// Time-based Achievements
|
||||||
|
|
@ -338,7 +356,8 @@ class AchievementManager {
|
||||||
type: AchievementType.consistentLearner,
|
type: AchievementType.consistentLearner,
|
||||||
evaluate: (data) => Future.value(data.longestStreak >= 30),
|
evaluate: (data) => Future.value(data.longestStreak >= 30),
|
||||||
supportsProgress: true,
|
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(
|
AchievementDefinition(
|
||||||
|
|
@ -351,7 +370,8 @@ class AchievementManager {
|
||||||
),
|
),
|
||||||
supportsProgress: true,
|
supportsProgress: true,
|
||||||
calculateProgress: (data) => Future.value(
|
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.
|
/// Returns the session ID of the active session.
|
||||||
/// If no active session exists, creates a new one.
|
/// If no active session exists, creates a new one.
|
||||||
Future<String> getOrCreateSession(int userId, {
|
Future<String> getOrCreateSession(
|
||||||
|
int userId, {
|
||||||
String? packId,
|
String? packId,
|
||||||
String? testId,
|
String? testId,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ class StatisticsCalculator {
|
||||||
|
|
||||||
var streak = 0;
|
var streak = 0;
|
||||||
var currentDate = DateTime.now();
|
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
|
// Check if today or yesterday has activity
|
||||||
final hasRecentActivity = normalizedDates.any((date) {
|
final hasRecentActivity = normalizedDates.any((date) {
|
||||||
|
|
@ -113,10 +114,11 @@ class StatisticsCalculator {
|
||||||
|
|
||||||
// Then add other difficult words if we haven't reached the limit
|
// Then add other difficult words if we haven't reached the limit
|
||||||
if (result.length < limit) {
|
if (result.length < limit) {
|
||||||
final remaining = words.where((word) =>
|
final remaining = words
|
||||||
|
.where((word) =>
|
||||||
!word.needsReview &&
|
!word.needsReview &&
|
||||||
!result.any((added) => added.word == word.word)
|
!result.any((added) => added.word == word.word))
|
||||||
).take(limit - result.length);
|
.take(limit - result.length);
|
||||||
|
|
||||||
result.addAll(remaining);
|
result.addAll(remaining);
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +206,8 @@ class StatisticsCalculator {
|
||||||
// Calculate aggregates
|
// Calculate aggregates
|
||||||
final totalDays = endDate.difference(startDate).inDays + 1;
|
final totalDays = endDate.difference(startDate).inDays + 1;
|
||||||
final activeDays = periodDailyTime.length;
|
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;
|
final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0;
|
||||||
|
|
||||||
// Calculate streak in period
|
// Calculate streak in period
|
||||||
|
|
@ -222,7 +225,8 @@ class StatisticsCalculator {
|
||||||
'dailyActivity': periodDailyTime.map(
|
'dailyActivity': periodDailyTime.map(
|
||||||
(date, minutes) => MapEntry(date.toIso8601String(), minutes),
|
(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) {
|
int calculateUserLevel(UserDataModel data) {
|
||||||
final wordsLearned = data.words.length;
|
final wordsLearned = data.words.length;
|
||||||
final studyHours = data.totalStudyTimeMinutes / 60.0;
|
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
|
// Simple level calculation
|
||||||
final score = wordsLearned + (studyHours * 2) + (packsCompleted * 10);
|
final score = wordsLearned + (studyHours * 2) + (packsCompleted * 10);
|
||||||
|
|
@ -337,8 +342,10 @@ class StatisticsCalculator {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate accuracy
|
// Calculate accuracy
|
||||||
final totalCorrect = data.words.fold<double>(0, (sum, word) => sum + word.correct);
|
final totalCorrect =
|
||||||
final totalIncorrect = data.words.fold<double>(0, (sum, word) => sum + word.incorrect);
|
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);
|
final accuracy = totalCorrect / (totalCorrect + totalIncorrect);
|
||||||
|
|
||||||
// Calculate average difficulty (simplified)
|
// Calculate average difficulty (simplified)
|
||||||
|
|
@ -346,7 +353,8 @@ class StatisticsCalculator {
|
||||||
final total = word.correct + word.incorrect + word.skipped;
|
final total = word.correct + word.incorrect + word.skipped;
|
||||||
if (total == 0) return sum;
|
if (total == 0) return sum;
|
||||||
return sum + (word.incorrect / total);
|
return sum + (word.incorrect / total);
|
||||||
}) / totalWords;
|
}) /
|
||||||
|
totalWords;
|
||||||
|
|
||||||
// Calculate consistency (based on streak)
|
// Calculate consistency (based on streak)
|
||||||
final consistency = min(1.0, data.currentStreak / 30.0);
|
final consistency = min(1.0, data.currentStreak / 30.0);
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ extension UserDataModelExtension on UserDataModel {
|
||||||
currentStreak: currentStreak,
|
currentStreak: currentStreak,
|
||||||
longestStreak: longestStreak,
|
longestStreak: longestStreak,
|
||||||
packProgress: {
|
packProgress: {
|
||||||
for (final progress in packProgress)
|
for (final progress in packProgress) progress.packId: progress.toDto()
|
||||||
progress.packId: progress.toDto()
|
|
||||||
},
|
},
|
||||||
studyDates: List<DateTime>.from(studyDates),
|
studyDates: List<DateTime>.from(studyDates),
|
||||||
categoryMinutes: Map<String, int>.from(categoryMinutes),
|
categoryMinutes: Map<String, int>.from(categoryMinutes),
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,8 @@ class UserManager {
|
||||||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||||||
|
|
||||||
// Add today's study date if not already present
|
// Add today's study date if not already present
|
||||||
if (!studyDates.any((date) => date.year == todayNormalized.year &&
|
if (!studyDates.any((date) =>
|
||||||
|
date.year == todayNormalized.year &&
|
||||||
date.month == todayNormalized.month &&
|
date.month == todayNormalized.month &&
|
||||||
date.day == todayNormalized.day)) {
|
date.day == todayNormalized.day)) {
|
||||||
studyDates.add(todayNormalized);
|
studyDates.add(todayNormalized);
|
||||||
|
|
@ -257,10 +258,12 @@ class UserManager {
|
||||||
|
|
||||||
// Calculate total study time (simplified - add time from this test)
|
// Calculate total study time (simplified - add time from this test)
|
||||||
final testDurationMinutes = 5; // Assume 5 minutes per test as default
|
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
|
// 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
|
// Update pack progress if test was for a specific pack
|
||||||
if (testStat.testId != null) {
|
if (testStat.testId != null) {
|
||||||
|
|
@ -316,13 +319,15 @@ class UserManager {
|
||||||
UserDataModel userData,
|
UserDataModel userData,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final newlyUnlockedAchievements = await _achievementManager.checkAndUnlockAchievements(
|
final newlyUnlockedAchievements =
|
||||||
|
await _achievementManager.checkAndUnlockAchievements(
|
||||||
user.id!,
|
user.id!,
|
||||||
userData,
|
userData,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newlyUnlockedAchievements.isNotEmpty) {
|
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;
|
return newlyUnlockedAchievements;
|
||||||
|
|
@ -367,7 +372,8 @@ class UserManager {
|
||||||
accuracy: accuracy,
|
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) {
|
} catch (e, s) {
|
||||||
print('Error updating session tracking: $e\n$s');
|
print('Error updating session tracking: $e\n$s');
|
||||||
// Don't fail the main operation if session tracking fails
|
// Don't fail the main operation if session tracking fails
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,16 @@ flutter run -d chrome --web-port=8080
|
||||||
|
|
||||||
### Вариант 3: Production CORS (для деплоя)
|
### Вариант 3: Production CORS (для деплоя)
|
||||||
|
|
||||||
Для production замените `'*'` на конкретный домен:
|
Для production версии:
|
||||||
|
|
||||||
```dart
|
1. **Backend** уже настроен с правильными CORS настройками ✅
|
||||||
'Access-Control-Allow-Origin': 'https://your-domain.com',
|
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"
|
export SERVER_USER="root"
|
||||||
|
|
||||||
# Domain configuration
|
# Domain configuration
|
||||||
export DOMAIN="5492281-cf88967.twc1.net"
|
export DOMAIN="memo-cards.online"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# APPLICATION CONFIGURATION
|
# APPLICATION CONFIGURATION
|
||||||
|
|
@ -37,9 +37,9 @@ export NGINX_ENABLED="/etc/nginx/sites-enabled/$APP_NAME"
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# API endpoints
|
# 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="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
|
# SSL CONFIGURATION
|
||||||
|
|
@ -69,9 +69,10 @@ export X_CONTENT_TYPE_OPTIONS="nosniff"
|
||||||
export REFERRER_POLICY="no-referrer-when-downgrade"
|
export REFERRER_POLICY="no-referrer-when-downgrade"
|
||||||
export STRICT_TRANSPORT_SECURITY="max-age=31536000; includeSubDomains"
|
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 COOP_POLICY="same-origin"
|
||||||
export COEP_POLICY="require-corp"
|
export COEP_POLICY="require-corp"
|
||||||
|
# Note: COEP/COOP headers are disabled in nginx.conf to allow CORS requests to API
|
||||||
|
|
||||||
# Cache settings
|
# Cache settings
|
||||||
export CACHE_EXPIRES="1y"
|
export CACHE_EXPIRES="1y"
|
||||||
|
|
@ -98,7 +99,7 @@ export WEB_PERMISSIONS="755"
|
||||||
# EMAIL CONFIGURATION (for Let's Encrypt)
|
# EMAIL CONFIGURATION (for Let's Encrypt)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
export LETSENCRYPT_EMAIL="admin@$DOMAIN"
|
export LETSENCRYPT_EMAIL="admin@memo-cards.online"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# FIREWALL CONFIGURATION
|
# FIREWALL CONFIGURATION
|
||||||
|
|
@ -112,7 +113,8 @@ export FIREWALL_ALLOW_SSH="ssh"
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
export CRON_RENEWAL_TIMES="0 12 * * * 0 0 * * *"
|
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
|
# COLORS FOR OUTPUT
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,29 @@ ssh "$SERVER_USER@$SERVER_IP" << EOF
|
||||||
chown -R $WEB_USER:$WEB_GROUP $WEB_ROOT
|
chown -R $WEB_USER:$WEB_GROUP $WEB_ROOT
|
||||||
chmod -R $WEB_PERMISSIONS $WEB_ROOT
|
chmod -R $WEB_PERMISSIONS $WEB_ROOT
|
||||||
|
|
||||||
# Install SSL certificate first (self-signed for now)
|
# 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
|
if [ ! -f "$SSL_SELF_CERT" ]; then
|
||||||
echo "Generating self-signed SSL certificate..."
|
|
||||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
-keyout $SSL_SELF_KEY \
|
-keyout $SSL_SELF_KEY \
|
||||||
-out $SSL_SELF_CERT \
|
-out $SSL_SELF_CERT \
|
||||||
-subj "/C=RU/ST=Moscow/L=Moscow/O=MnemoCards/OU=IT/CN=$SERVER_IP"
|
-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
|
fi
|
||||||
|
|
||||||
# Configure nginx
|
# Configure nginx
|
||||||
|
|
@ -83,9 +99,9 @@ ssh "$SERVER_USER@$SERVER_IP" << EOF
|
||||||
ufw --force enable
|
ufw --force enable
|
||||||
|
|
||||||
echo "Deployment completed successfully!"
|
echo "Deployment completed successfully!"
|
||||||
echo "Application is available at: https://$SERVER_IP"
|
echo "Application is available at: https://memo-cards.online"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
print_success "Deployment completed successfully! 🎉"
|
print_success "Deployment completed successfully! 🎉"
|
||||||
print_success "Your app is now available at: https://$SERVER_IP"
|
print_success "Your app is now available at: https://memo-cards.online"
|
||||||
print_warning "Note: The SSL certificate is self-signed. For production, consider using Let's Encrypt or a commercial certificate."
|
print_info "SSL certificate: Let's Encrypt (preferred) or self-signed (fallback)"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name 147.45.152.129;
|
server_name memo-cards.online;
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS
|
# Redirect HTTP to HTTPS
|
||||||
return 301 https://$server_name$request_uri;
|
return 301 https://$server_name$request_uri;
|
||||||
|
|
@ -8,11 +8,15 @@ server {
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name 147.45.152.129;
|
server_name memo-cards.online;
|
||||||
|
|
||||||
# SSL configuration
|
# SSL configuration - Let's Encrypt (preferred)
|
||||||
ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt;
|
ssl_certificate /etc/letsencrypt/live/memo-cards.online/fullchain.pem;
|
||||||
ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key;
|
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_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_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;
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
@ -50,8 +54,9 @@ server {
|
||||||
location ~* \.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp";
|
# Removed COEP/COOP headers that interfere with CORS requests to API
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin";
|
# add_header Cross-Origin-Embedder-Policy "require-corp";
|
||||||
|
# add_header Cross-Origin-Opener-Policy "same-origin";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security - deny access to hidden files
|
# 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)\$ {
|
location ~* \\.(wasm|js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
|
||||||
expires $CACHE_EXPIRES;
|
expires $CACHE_EXPIRES;
|
||||||
add_header Cache-Control "$CACHE_CONTROL";
|
add_header Cache-Control "$CACHE_CONTROL";
|
||||||
add_header Cross-Origin-Embedder-Policy "$COEP_POLICY";
|
# Disabled COEP/COOP headers to allow CORS requests to API
|
||||||
add_header Cross-Origin-Opener-Policy "$COOP_POLICY";
|
# add_header Cross-Origin-Embedder-Policy "$COEP_POLICY";
|
||||||
|
# add_header Cross-Origin-Opener-Policy "$COOP_POLICY";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security - deny access to hidden files
|
# Security - deny access to hidden files
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue