diff --git a/apps/cards/.github/workflows/deploy-backend.yml b/apps/cards/.github/workflows/deploy-backend.yml deleted file mode 100644 index 2046b03d8..000000000 --- a/apps/cards/.github/workflows/deploy-backend.yml +++ /dev/null @@ -1,342 +0,0 @@ -name: Deploy Backend to Cloud Run - -on: - push: - branches: [main] - paths: - - 'backend/**' - - '.github/workflows/deploy-backend.yml' - workflow_dispatch: - inputs: - environment: - description: 'Environment to deploy to' - type: choice - required: true - default: 'production' - options: - - production - - staging - -env: - PROJECT_ID: mana-core-453821 - REGION: europe-west3 - ARTIFACT_REGISTRY: europe-west3-docker.pkg.dev - SERVICE_NAME: cards-backend - REPOSITORY_NAME: cards-backend - WORKING_DIR: backend - -jobs: - test: - name: Test & Build Verification - runs-on: ubuntu-latest - permissions: - contents: read - packages: read - defaults: - run: - working-directory: ${{ env.WORKING_DIR }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Configure git for private packages - env: - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - run: | - git config --global url."https://${GH_TOKEN}@github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://${GH_TOKEN}@github.com/".insteadOf git@github.com: - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: ${{ env.WORKING_DIR }}/package-lock.json - - - name: Patch package-lock.json with authenticated URLs - env: - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - working-directory: ${{ env.WORKING_DIR }} - run: | - # Handle both SSH and HTTPS URLs - if grep -q "git+ssh://git@github.com" package-lock.json; then - echo "⚠️ SSH URLs found - patching to HTTPS with token..." - sed -i "s|git+ssh://git@github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json - echo "✓ Lockfile patched successfully" - else - echo "⚠️ HTTPS URLs found - injecting token..." - sed -i "s|git+https://github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json - echo "✓ Token injected successfully" - fi - - - name: Install dependencies - working-directory: ${{ env.WORKING_DIR }} - run: npm ci --legacy-peer-deps - - - name: Type check & build - working-directory: ${{ env.WORKING_DIR }} - run: npm run build - - - name: Run tests - working-directory: ${{ env.WORKING_DIR }} - run: npm test - continue-on-error: true - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: ${{ env.WORKING_DIR }}/dist - retention-days: 1 - - build-and-deploy: - name: Build & Deploy to Cloud Run - needs: test - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Google Cloud Auth - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ secrets.GCP_SA_KEY_PROD }} - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 - with: - version: 'latest' - - - name: Configure Docker for Artifact Registry - run: | - gcloud auth configure-docker ${{ env.ARTIFACT_REGISTRY }} - - - name: Generate version tag - id: version - run: | - SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) - TIMESTAMP=$(date +%Y%m%d-%H%M%S) - VERSION="v${TIMESTAMP}-${SHORT_SHA}" - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT - - - name: Build and Push Docker Image - env: - DOCKER_BUILDKIT: 1 - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - working-directory: ${{ env.WORKING_DIR }} - run: | - IMAGE_NAME="${{ env.ARTIFACT_REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY_NAME }}/${{ env.SERVICE_NAME }}" - IMAGE_TAG="${IMAGE_NAME}:${{ steps.version.outputs.version }}" - LATEST_TAG="${IMAGE_NAME}:latest" - SHA_TAG="${IMAGE_NAME}:${{ steps.version.outputs.short_sha }}" - - echo "Building image: ${IMAGE_TAG}" - - docker build \ - -t ${IMAGE_TAG} \ - -t ${LATEST_TAG} \ - -t ${SHA_TAG} \ - --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ - --build-arg VCS_REF=${{ github.sha }} \ - --build-arg VERSION=${{ steps.version.outputs.version }} \ - --secret id=github_token,env=GH_TOKEN \ - . - - echo "Pushing images..." - docker push ${IMAGE_TAG} - docker push ${LATEST_TAG} - docker push ${SHA_TAG} - - echo "image_tag=${IMAGE_TAG}" >> $GITHUB_ENV - echo "version=${{ steps.version.outputs.version }}" >> $GITHUB_ENV - - - name: Deploy to Cloud Run - id: deploy - run: | - echo "Deploying to Cloud Run..." - echo "Service account: ${{ secrets.CLOUD_RUN_SERVICE_ACCOUNT }}" - gcloud run deploy ${{ env.SERVICE_NAME }} \ - --image="${{ env.image_tag }}" \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --platform=managed \ - --allow-unauthenticated \ - --min-instances=0 \ - --max-instances=10 \ - --memory=512Mi \ - --cpu=1 \ - --timeout=300 \ - --concurrency=80 \ - --port=8080 \ - --service-account=${{ secrets.CLOUD_RUN_SERVICE_ACCOUNT }} \ - --set-env-vars="NODE_ENV=production" \ - --set-secrets="MANA_SERVICE_URL=MANA_SERVICE_URL:latest,APP_ID=CARDS_APP_ID:latest,MANA_SUPABASE_SECRET_KEY=MANA_SUPABASE_SECRET_KEY:latest,SUPABASE_URL=CARDS_SUPABASE_URL:latest,SUPABASE_ANON_KEY=CARDS_SUPABASE_ANON_KEY:latest,SUPABASE_SERVICE_KEY=CARDS_SUPABASE_SERVICE_KEY:latest" \ - --labels="environment=production,commit=${{ steps.version.outputs.short_sha }},version=${{ env.version }}" - - # Ensure 100% traffic goes to the new revision - echo "Routing traffic to latest revision..." - gcloud run services update-traffic ${{ env.SERVICE_NAME }} \ - --to-latest \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --platform=managed - - echo "✅ Traffic routed to latest revision" - - - name: Get Service URL - id: service-url - run: | - SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE_NAME }} \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --format='value(status.url)') - - echo "service_url=${SERVICE_URL}" >> $GITHUB_OUTPUT - - echo "## 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Service**: \`${{ env.SERVICE_NAME }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **URL**: ${SERVICE_URL}" >> $GITHUB_STEP_SUMMARY - echo "- **Version**: \`${{ env.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Image**: \`${{ env.image_tag }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Region**: \`${{ env.REGION }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Commit**: \`${{ steps.version.outputs.short_sha }}\`" >> $GITHUB_STEP_SUMMARY - - - name: Wait for deployment - run: sleep 15 - - - name: Health Check - id: health-check - run: | - SERVICE_URL="${{ steps.service-url.outputs.service_url }}" - - echo "Testing health endpoint: ${SERVICE_URL}/health/live" - - MAX_RETRIES=5 - RETRY_COUNT=0 - SUCCESS=false - - while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" ${SERVICE_URL}/health/live || echo "000") - - if [ "$HTTP_CODE" = "200" ]; then - echo "✅ Health check passed (HTTP $HTTP_CODE)" - SUCCESS=true - break - else - echo "⚠️ Health check attempt $((RETRY_COUNT + 1))/$MAX_RETRIES failed (HTTP $HTTP_CODE)" - RETRY_COUNT=$((RETRY_COUNT + 1)) - if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then - echo "Retrying in 10 seconds..." - sleep 10 - fi - fi - done - - if [ "$SUCCESS" = false ]; then - echo "❌ Health check failed after $MAX_RETRIES attempts" - exit 1 - fi - - - name: Liveness Check - run: | - SERVICE_URL="${{ steps.service-url.outputs.service_url }}" - - echo "Testing liveness endpoint: ${SERVICE_URL}/health/live" - RESPONSE=$(curl -s ${SERVICE_URL}/health/live) - echo "Response: $RESPONSE" - - STATUS=$(echo $RESPONSE | jq -r '.status') - if [ "$STATUS" = "ok" ]; then - echo "✅ Liveness check passed" - else - echo "❌ Liveness check failed" - exit 1 - fi - - - name: Deployment Notification - if: success() - run: | - echo "## ✅ Deployment Successful" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Service is healthy and ready to receive traffic." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Quick Links" >> $GITHUB_STEP_SUMMARY - echo "- [Service URL](${{ steps.service-url.outputs.service_url }})" >> $GITHUB_STEP_SUMMARY - echo "- [Health Check](${{ steps.service-url.outputs.service_url }}/health)" >> $GITHUB_STEP_SUMMARY - echo "- [Cloud Run Console](https://console.cloud.google.com/run/detail/${{ env.REGION }}/${{ env.SERVICE_NAME }}/metrics?project=${{ env.PROJECT_ID }})" >> $GITHUB_STEP_SUMMARY - - rollback: - name: Rollback on Failure - runs-on: ubuntu-latest - if: failure() && needs.build-and-deploy.result == 'failure' - needs: build-and-deploy - - steps: - - name: Google Cloud Auth - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ secrets.GCP_SA_KEY_PROD }} - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 - with: - version: 'latest' - - - name: Get Previous Revision - id: get-revision - run: | - REVISIONS=$(gcloud run revisions list \ - --service=${{ env.SERVICE_NAME }} \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --format="value(name)" \ - --limit=2) - - PREV_REVISION=$(echo "$REVISIONS" | tail -n 1) - - if [ -z "$PREV_REVISION" ]; then - echo "❌ No previous revision found for rollback" - exit 1 - fi - - echo "prev_revision=${PREV_REVISION}" >> $GITHUB_OUTPUT - echo "Found previous revision: ${PREV_REVISION}" - - - name: Rollback to Previous Revision - run: | - echo "Rolling back to revision: ${{ steps.get-revision.outputs.prev_revision }}" - - gcloud run services update-traffic ${{ env.SERVICE_NAME }} \ - --to-revisions=${{ steps.get-revision.outputs.prev_revision }}=100 \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} - - echo "## ⚠️ Deployment Failed - Rollback Executed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Service has been rolled back to revision: \`${{ steps.get-revision.outputs.prev_revision }}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Please check the logs and fix the issues before redeploying." >> $GITHUB_STEP_SUMMARY - - - name: Verify Rollback - run: | - sleep 10 - - SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE_NAME }} \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --format='value(status.url)') - - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" ${SERVICE_URL}/health/live) - - if [ $HTTP_CODE -eq 200 ]; then - echo "✅ Rollback successful - service is healthy" - else - echo "❌ Rollback verification failed - service may be unhealthy" - exit 1 - fi diff --git a/apps/cards/.gitignore b/apps/cards/.gitignore deleted file mode 100644 index e797ac22d..000000000 --- a/apps/cards/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -node_modules/ -.expo/ -dist/ -npm-debug.* -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision -*.orig.* -web-build/ -# expo router -expo-env.d.ts - -# firebase/supabase/vexo -.env - -ios -android - -# macOS -.DS_Store - -# Temporary files created by Metro to check the health of the file watcher -.metro-health-check* - - -backend/node_modules -apps/mobile/node_modules -apps/web/node_modules -apps/landing/node_modules \ No newline at end of file diff --git a/apps/cards/CI_CD_SETUP_GUIDE.md b/apps/cards/CI_CD_SETUP_GUIDE.md deleted file mode 100644 index e0ebb26f9..000000000 --- a/apps/cards/CI_CD_SETUP_GUIDE.md +++ /dev/null @@ -1,1246 +0,0 @@ -# Complete CI/CD Setup Guide for NestJS Backend with Private Packages - -**Last Updated**: 2025-09-30 -**Project**: Cards Backend -**Stack**: NestJS + Private GitHub Packages + Google Cloud Run - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Prerequisites](#prerequisites) -3. [Architecture](#architecture) -4. [Step-by-Step Setup](#step-by-step-setup) -5. [Common Issues & Solutions](#common-issues--solutions) -6. [Testing & Verification](#testing--verification) -7. [Maintenance & Updates](#maintenance--updates) - ---- - -## Overview - -This guide documents the complete CI/CD setup for deploying a NestJS backend that depends on **private GitHub npm packages** to Google Cloud Run. It includes all lessons learned, common pitfalls, and their solutions. - -### What We're Building - -- **Automated deployment** on push to `main` branch -- **Private package authentication** using GitHub Personal Access Token -- **Multi-stage Docker builds** with security best practices -- **Zero-downtime deployments** to Cloud Run -- **Automatic rollback** on deployment failure -- **Health checks** and smoke tests - -### Key Challenges Solved - -1. ✅ npm ci authentication with private GitHub packages (SSH vs HTTPS URLs) -2. ✅ Docker build secrets for private package access -3. ✅ Cross-project GCP secret management -4. ✅ Traffic routing without deprecated `--traffic` flag -5. ✅ Artifact Registry repository creation -6. ✅ Service account permissions and IAM setup - ---- - -## Prerequisites - -### Required Tools - -- `gcloud` CLI (authenticated) -- `git` CLI -- `node` v18+ and `npm` -- GitHub account with repo access -- GCP project with billing enabled - -### Required Access - -- **GitHub**: Admin access to repository -- **GCP Project 1** (`memo-2c4c4`): Deployment target -- **GCP Project 2** (`mana-core-453821`): Secrets storage -- **GitHub Personal Access Token**: `repo` scope - ---- - -## Architecture - -### Project Structure - -``` -cards/ -├── .github/ -│ └── workflows/ -│ └── deploy-backend.yml # GitHub Actions workflow -├── backend/ -│ ├── src/ # NestJS source code -│ ├── Dockerfile # Multi-stage build -│ ├── package.json # Dependencies (with private packages) -│ ├── package-lock.json # May contain SSH or HTTPS URLs -│ ├── create-secrets.sh # GCP secrets setup script -│ ├── setup-github-secrets.sh # GitHub secrets setup script -│ └── SSH_LOCKFILE_SOLUTION.md # Private package auth docs -├── DEPLOYMENT_CHECKLIST.md # Quick reference -└── CI_CD_SETUP_GUIDE.md # This file -``` - -### Two-Layer Authentication Strategy - -#### Layer 1: CI Test Stage -**Purpose**: Install dependencies for testing (lint, build, test) - -```yaml -# Patch package-lock.json at runtime -- if SSH URLs found: Convert to HTTPS with token -- if HTTPS URLs found: Inject token -- Run: npm ci --legacy-peer-deps -``` - -**Why**: npm ci reads URLs directly from lockfile, ignores git config - -#### Layer 2: Docker Build -**Purpose**: Create production image with baked-in dependencies - -```dockerfile -# Clone private repo using Docker secret -# Build and package as tarball (.tgz) -# Replace git URL with file: reference -# Install from local tarball -``` - -**Why**: Self-contained image, no git dependency at runtime, more secure - ---- - -## Step-by-Step Setup - -### Phase 1: GCP Infrastructure Setup - -#### 1.1 Create Service Account - -**IMPORTANT**: Service account should be in the same project as Cloud Run deployment. - -```bash -PROJECT_ID="mana-core-453821" -SA_NAME="cards-backend-sa" -SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" - -# Create service account -gcloud iam service-accounts create $SA_NAME \ - --display-name="Cards Backend Service Account" \ - --project=$PROJECT_ID - -# Grant required roles -gcloud projects add-iam-policy-binding $PROJECT_ID \ - --member="serviceAccount:${SA_EMAIL}" \ - --role="roles/run.admin" \ - --condition=None - -gcloud projects add-iam-policy-binding $PROJECT_ID \ - --member="serviceAccount:${SA_EMAIL}" \ - --role="roles/iam.serviceAccountUser" \ - --condition=None - -gcloud projects add-iam-policy-binding $PROJECT_ID \ - --member="serviceAccount:${SA_EMAIL}" \ - --role="roles/artifactregistry.writer" \ - --condition=None -``` - -**Common Issue**: Permission denied errors during deployment -**Solution**: Ensure all three roles are granted with `--condition=None` - -#### 1.2 Create Artifact Registry Repository - -```bash -gcloud artifacts repositories create cards-backend \ - --repository-format=docker \ - --location=europe-west3 \ - --project=mana-core-453821 \ - --description="Docker images for Cards Backend" -``` - -**Common Issue**: `name unknown: Repository "cards-backend" not found` -**Solution**: Create repository before first deployment (workflow will fail without it) - -#### 1.3 Create GCP Secrets (Same Project as Cloud Run) - -**IMPORTANT**: Secrets must be in the **same project** as the Cloud Run service. When using `--set-secrets`, Cloud Run looks for secrets in the deployment project. - -Run the interactive script: - -```bash -cd backend -./create-secrets.sh -``` - -Or create manually: - -```bash -# Use the SAME project as Cloud Run deployment -PROJECT_ID="mana-core-453821" - -# Generate service key (used for Mana authentication) -SERVICE_KEY=$(openssl rand -base64 32) - -# Create secrets in the SAME project where Cloud Run will be deployed -echo "your-app-id" | gcloud secrets create CARDS_APP_ID \ - --data-file=- \ - --project=$PROJECT_ID - -echo "$SERVICE_KEY" | gcloud secrets create CARDS_SERVICE_KEY \ - --data-file=- \ - --project=$PROJECT_ID - -echo "https://xxx.supabase.co" | gcloud secrets create CARDS_SUPABASE_URL \ - --data-file=- \ - --project=$PROJECT_ID - -echo "your-anon-key" | gcloud secrets create CARDS_SUPABASE_ANON_KEY \ - --data-file=- \ - --project=$PROJECT_ID - -echo "your-service-role-key" | gcloud secrets create CARDS_SUPABASE_SERVICE_KEY \ - --data-file=- \ - --project=$PROJECT_ID -``` - -**Important**: The `MANA_SERVICE_URL` secret should already exist in `mana-core-453821` - -#### 1.4 Grant Secret Access to Service Account - -Since Cloud Run and secrets are in the **same project**, the service account automatically has access if it has the Cloud Run Admin role. However, it's best practice to explicitly grant access: - -```bash -SA_EMAIL="cards-backend-sa@mana-core-453821.iam.gserviceaccount.com" -PROJECT_ID="mana-core-453821" - -for SECRET in MANA_SERVICE_URL CARDS_APP_ID CARDS_SERVICE_KEY CARDS_SUPABASE_URL CARDS_SUPABASE_ANON_KEY CARDS_SUPABASE_SERVICE_KEY; do - gcloud secrets add-iam-policy-binding $SECRET \ - --member="serviceAccount:${SA_EMAIL}" \ - --role="roles/secretmanager.secretAccessor" \ - --project=$PROJECT_ID -done -``` - -**Common Issue**: `'projects/XXX/secrets/YYY' is not a valid secret name` -**Solution**: Ensure secrets are in the same project as Cloud Run deployment. Use `--set-secrets="ENV_VAR=SECRET_NAME:latest"` format (not full path) - ---- - -### Phase 2: GitHub Setup - -#### 2.1 Create GitHub Personal Access Token - -1. Go to: https://github.com/settings/tokens -2. Click **"Generate new token (classic)"** -3. Settings: - - **Name**: `Cards CI/CD` - - **Expiration**: Choose appropriate timeframe (90 days, 1 year, or no expiration) - - **Scopes**: ✅ `repo` (Full control of private repositories) -4. Click **"Generate token"** -5. **Copy the token immediately** (you won't see it again) - -**Common Issue**: Token expires and deployments start failing -**Solution**: Set calendar reminder before expiration, rotate token in GitHub secrets - -#### 2.2 Generate Service Account Key - -```bash -gcloud iam service-accounts keys create cards-sa-key.json \ - --iam-account=cards-backend-sa@memo-2c4c4.iam.gserviceaccount.com \ - --project=memo-2c4c4 - -# Display the JSON (copy entire output) -cat cards-sa-key.json - -# IMPORTANT: Delete after adding to GitHub -rm cards-sa-key.json -``` - -**Security Note**: Never commit this JSON to git. Delete local copy after adding to GitHub secrets. - -#### 2.3 Add GitHub Repository Secrets - -Go to: `https://github.com/Memo-2023/cards/settings/secrets/actions` - -Add these secrets: - -| Secret Name | Value | Where to Get It | -|-------------|-------|-----------------| -| `GH_PERSONAL_TOKEN` | `ghp_xxxxxxxxxxxx` | From step 2.1 | -| `GCP_SA_KEY_PROD` | `{"type":"service_account",...}` | From step 2.2 (entire JSON) | -| `CLOUD_RUN_SERVICE_ACCOUNT` | `cards-backend-sa@mana-core-453821.iam.gserviceaccount.com` | Service account email | - -**Common Issue**: Forgot which secret is which -**Solution**: Use descriptive names and document in DEPLOYMENT_CHECKLIST.md - ---- - -### Phase 3: Private Package Integration - -This is the most complex part. We use a **two-layer approach**. - -#### 3.1 Update package.json - -```json -{ - "dependencies": { - "@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git" - } -} -``` - -**Important**: Use `git+https://` format (not `git+ssh://`) - -#### 3.2 Understanding the package-lock.json Problem - -**The Issue**: -- Your local git config may convert HTTPS → SSH during `npm install` -- This bakes SSH URLs into `package-lock.json` -- CI/CD can't authenticate via SSH (no SSH keys configured) -- `npm ci` reads URLs directly from lockfile, ignores git config - -**Example of problematic lockfile**: -```json -{ - "packages": { - "node_modules/@mana-core/nestjs-integration": { - "resolved": "git+ssh://git@github.com/Memo-2023/mana-core-nestjs-package.git#abc123" - } - } -} -``` - -**What We Tried (That Didn't Work)**: - -❌ **Approach 1**: Configure git to use HTTPS -```bash -git config --global url."https://github.com/".insteadOf "git@github.com:" -``` -**Why it failed**: npm's internal git client doesn't reliably honor git config - -❌ **Approach 2**: Use invalid `.npmrc` settings -``` -git-ssh-url = https://github.com/ -``` -**Why it failed**: `git-ssh-url` is not a valid npm configuration option - -❌ **Approach 3**: Try to fix lockfile locally with git config overrides -**Why it failed**: npm subprocess bypasses local git config, still generates SSH URLs - -#### 3.3 The Solution: Two-Layer Approach - -##### Layer 1: CI Test Stage (GitHub Actions) - -Use conditional sed patching that handles both URL formats: - -```yaml -- name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false # Don't let default GITHUB_TOKEN override PAT - -- name: Configure git for private packages - env: - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - run: | - git config --global url."https://${GH_TOKEN}@github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://${GH_TOKEN}@github.com/".insteadOf git@github.com: - -- name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: backend/package-lock.json - -- name: Patch package-lock.json with authenticated URLs - env: - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - working-directory: backend - run: | - # Handle both SSH and HTTPS URLs - if grep -q "git+ssh://git@github.com" package-lock.json; then - echo "⚠️ SSH URLs found - patching to HTTPS with token..." - sed -i "s|git+ssh://git@github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json - echo "✓ Lockfile patched successfully" - else - echo "⚠️ HTTPS URLs found - injecting token..." - sed -i "s|git+https://github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json - echo "✓ Token injected successfully" - fi - -- name: Install dependencies - working-directory: backend - run: npm ci --legacy-peer-deps -``` - -**Why this works**: -- Token is embedded directly in the URL that npm reads -- Works regardless of SSH or HTTPS format in lockfile -- No reliance on git config (which npm ignores) -- `persist-credentials: false` prevents default GITHUB_TOKEN from interfering - -**Common Issue**: "Permission denied" during npm ci -**Solution**: Verify GH_PERSONAL_TOKEN has `repo` scope and hasn't expired - -##### Layer 2: Docker Build (Production Image) - -Use Docker secrets to clone, build, and package as tarball: - -```dockerfile -# syntax=docker/dockerfile:1 -FROM node:18-alpine AS builder - -WORKDIR /app - -# Install build dependencies -RUN apk add --no-cache python3 make g++ git openssh-client - -# Configure git to use HTTPS -RUN git config --global url."https://github.com/".insteadOf "git@github.com:" && \ - git config --global url."https://".insteadOf "git://" - -# Clone, build and package mana-core as a tarball -RUN --mount=type=secret,id=github_token \ - if [ -f /run/secrets/github_token ]; then \ - export GITHUB_TOKEN=$(cat /run/secrets/github_token) && \ - echo "Using GitHub token for private repo access" && \ - git clone https://${GITHUB_TOKEN}@github.com/Memo-2023/mana-core-nestjs-package.git /tmp/mana-core; \ - else \ - echo "No GitHub token provided, attempting public clone" && \ - git clone https://github.com/Memo-2023/mana-core-nestjs-package.git /tmp/mana-core; \ - fi && \ - cd /tmp/mana-core && \ - npm install --force && \ - npm run build && \ - npm pack && \ - mv *.tgz /app/mana-core.tgz && \ - echo "Mana-core packaged as tarball at /app/mana-core.tgz" - -# Copy package.json -COPY package.json ./ - -# Replace GitHub URL with the tarball -RUN sed -i 's|"git+https://github.com/Memo-2023/mana-core-nestjs-package.git"|"file:mana-core.tgz"|g' package.json || \ - sed -i 's|"github:Memo-2023/mana-core-nestjs-package"|"file:mana-core.tgz"|g' package.json - -# Verify replacement -RUN echo "=== Verifying tarball and package.json ===" && \ - ls -la mana-core.tgz && \ - grep -n "mana-core" package.json - -# Install dependencies from tarball -RUN npm install --legacy-peer-deps - -# Copy source and build -COPY . . -RUN npm run build - -# Production stage -FROM node:18-alpine - -RUN apk add --no-cache dumb-init - -RUN addgroup -g 1001 -S nodejs && \ - adduser -S nodejs -u 1001 - -WORKDIR /app - -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/package.json ./ -COPY --from=builder /app/node_modules ./node_modules - -RUN chown -R nodejs:nodejs /app - -USER nodejs - -EXPOSE 8080 - -ENV NODE_ENV=production - -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:' + (process.env.PORT || 8080) + '/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})" || exit 1 - -ENTRYPOINT ["dumb-init", "--"] - -CMD ["node", "dist/main"] -``` - -**GitHub Actions**: Pass token as Docker build secret: - -```yaml -- name: Build and Push Docker Image - env: - DOCKER_BUILDKIT: 1 - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - working-directory: backend - run: | - docker build \ - --secret id=github_token,env=GH_TOKEN \ - -t ${IMAGE_TAG} \ - . -``` - -**Why this is better**: -- Private package is baked into the Docker image -- No git dependency at runtime -- More secure (no tokens in final image) -- Self-contained production artifact - -**Common Issue**: "failed to solve: invalid file path" during Docker build -**Solution**: Ensure `# syntax=docker/dockerfile:1` is first line and DOCKER_BUILDKIT=1 is set - -#### 3.4 Handling Peer Dependency Warnings - -The `@mana-core/nestjs-integration` package expects NestJS v10, but the project uses v11. - -**Solution**: Use `--legacy-peer-deps` flag: - -```bash -npm ci --legacy-peer-deps -npm install --legacy-peer-deps -``` - -**Common Issue**: Deployment fails with peer dependency errors -**Solution**: Always include `--legacy-peer-deps` in both CI and Dockerfile - ---- - -### Phase 4: GitHub Actions Workflow - -Create `.github/workflows/deploy-backend.yml`: - -```yaml -name: Deploy Backend to Cloud Run - -on: - push: - branches: [main] - paths: - - 'backend/**' - - '.github/workflows/deploy-backend.yml' - workflow_dispatch: - inputs: - environment: - description: 'Environment to deploy to' - type: choice - required: true - default: 'production' - options: - - production - - staging - -env: - PROJECT_ID: mana-core-453821 - REGION: europe-west3 - ARTIFACT_REGISTRY: europe-west3-docker.pkg.dev - SERVICE_NAME: cards-backend - REPOSITORY_NAME: cards-backend - WORKING_DIR: backend - -jobs: - test: - name: Test & Build Verification - runs-on: ubuntu-latest - permissions: - contents: read - packages: read - defaults: - run: - working-directory: ${{ env.WORKING_DIR }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Configure git for private packages - env: - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - run: | - git config --global url."https://${GH_TOKEN}@github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://${GH_TOKEN}@github.com/".insteadOf git@github.com: - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: ${{ env.WORKING_DIR }}/package-lock.json - - - name: Patch package-lock.json with authenticated URLs - env: - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - working-directory: ${{ env.WORKING_DIR }} - run: | - # Handle both SSH and HTTPS URLs - if grep -q "git+ssh://git@github.com" package-lock.json; then - echo "⚠️ SSH URLs found - patching to HTTPS with token..." - sed -i "s|git+ssh://git@github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json - echo "✓ Lockfile patched successfully" - else - echo "⚠️ HTTPS URLs found - injecting token..." - sed -i "s|git+https://github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json - echo "✓ Token injected successfully" - fi - - - name: Install dependencies - working-directory: ${{ env.WORKING_DIR }} - run: npm ci --legacy-peer-deps - - - name: Run linter - working-directory: ${{ env.WORKING_DIR }} - run: npm run lint - - - name: Type check & build - working-directory: ${{ env.WORKING_DIR }} - run: npm run build - - - name: Run tests - working-directory: ${{ env.WORKING_DIR }} - run: npm test - continue-on-error: true - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: ${{ env.WORKING_DIR }}/dist - retention-days: 1 - - build-and-deploy: - name: Build & Deploy to Cloud Run - needs: test - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Google Cloud Auth - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ secrets.GCP_SA_KEY_PROD }} - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 - with: - version: 'latest' - - - name: Configure Docker for Artifact Registry - run: | - gcloud auth configure-docker ${{ env.ARTIFACT_REGISTRY }} - - - name: Generate version tag - id: version - run: | - SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) - TIMESTAMP=$(date +%Y%m%d-%H%M%S) - VERSION="v${TIMESTAMP}-${SHORT_SHA}" - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT - - - name: Build and Push Docker Image - env: - DOCKER_BUILDKIT: 1 - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - working-directory: ${{ env.WORKING_DIR }} - run: | - IMAGE_NAME="${{ env.ARTIFACT_REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY_NAME }}/${{ env.SERVICE_NAME }}" - IMAGE_TAG="${IMAGE_NAME}:${{ steps.version.outputs.version }}" - LATEST_TAG="${IMAGE_NAME}:latest" - SHA_TAG="${IMAGE_NAME}:${{ steps.version.outputs.short_sha }}" - - echo "Building image: ${IMAGE_TAG}" - - docker build \ - -t ${IMAGE_TAG} \ - -t ${LATEST_TAG} \ - -t ${SHA_TAG} \ - --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ - --build-arg VCS_REF=${{ github.sha }} \ - --build-arg VERSION=${{ steps.version.outputs.version }} \ - --secret id=github_token,env=GH_TOKEN \ - . - - echo "Pushing images..." - docker push ${IMAGE_TAG} - docker push ${LATEST_TAG} - docker push ${SHA_TAG} - - echo "image_tag=${IMAGE_TAG}" >> $GITHUB_ENV - echo "version=${{ steps.version.outputs.version }}" >> $GITHUB_ENV - - - name: Deploy to Cloud Run - id: deploy - run: | - echo "Deploying to Cloud Run..." - gcloud run deploy ${{ env.SERVICE_NAME }} \ - --image="${{ env.image_tag }}" \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --platform=managed \ - --allow-unauthenticated \ - --min-instances=0 \ - --max-instances=10 \ - --memory=512Mi \ - --cpu=1 \ - --timeout=300 \ - --concurrency=80 \ - --port=8080 \ - --service-account=${{ secrets.CLOUD_RUN_SERVICE_ACCOUNT }} \ - --set-env-vars="NODE_ENV=production" \ - --update-secrets="MANA_SERVICE_URL=projects/mana-core-453821/secrets/MANA_SERVICE_URL:latest,APP_ID=projects/mana-core-453821/secrets/CARDS_APP_ID:latest,SERVICE_KEY=projects/mana-core-453821/secrets/CARDS_SERVICE_KEY:latest,SUPABASE_URL=projects/mana-core-453821/secrets/CARDS_SUPABASE_URL:latest,SUPABASE_ANON_KEY=projects/mana-core-453821/secrets/CARDS_SUPABASE_ANON_KEY:latest,SUPABASE_SERVICE_KEY=projects/mana-core-453821/secrets/CARDS_SUPABASE_SERVICE_KEY:latest,SIGNUP_REDIRECT_URL=projects/mana-core-453821/secrets/CARDS_SIGNUP_REDIRECT_URL:latest" \ - --labels="environment=production,commit=${{ steps.version.outputs.short_sha }},version=${{ env.version }}" - - # Ensure 100% traffic goes to the new revision - echo "Routing traffic to latest revision..." - gcloud run services update-traffic ${{ env.SERVICE_NAME }} \ - --to-latest \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --platform=managed - - echo "✅ Traffic routed to latest revision" - - - name: Get Service URL - id: service-url - run: | - SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE_NAME }} \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --format='value(status.url)') - - echo "service_url=${SERVICE_URL}" >> $GITHUB_OUTPUT - - echo "## 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Service**: \`${{ env.SERVICE_NAME }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **URL**: ${SERVICE_URL}" >> $GITHUB_STEP_SUMMARY - echo "- **Version**: \`${{ env.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Image**: \`${{ env.image_tag }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Region**: \`${{ env.REGION }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Commit**: \`${{ steps.version.outputs.short_sha }}\`" >> $GITHUB_STEP_SUMMARY - - - name: Wait for deployment - run: sleep 15 - - - name: Health Check - id: health-check - run: | - SERVICE_URL="${{ steps.service-url.outputs.service_url }}" - - echo "Testing health endpoint: ${SERVICE_URL}/health" - - MAX_RETRIES=5 - RETRY_COUNT=0 - SUCCESS=false - - while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" ${SERVICE_URL}/health || echo "000") - - if [ "$HTTP_CODE" = "200" ]; then - echo "✅ Health check passed (HTTP $HTTP_CODE)" - SUCCESS=true - break - else - echo "⚠️ Health check attempt $((RETRY_COUNT + 1))/$MAX_RETRIES failed (HTTP $HTTP_CODE)" - RETRY_COUNT=$((RETRY_COUNT + 1)) - if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then - echo "Retrying in 10 seconds..." - sleep 10 - fi - fi - done - - if [ "$SUCCESS" = false ]; then - echo "❌ Health check failed after $MAX_RETRIES attempts" - exit 1 - fi - - - name: Deployment Notification - if: success() - run: | - echo "## ✅ Deployment Successful" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Service is healthy and ready to receive traffic." >> $GITHUB_STEP_SUMMARY - - rollback: - name: Rollback on Failure - runs-on: ubuntu-latest - if: failure() && needs.build-and-deploy.result == 'failure' - needs: build-and-deploy - - steps: - - name: Google Cloud Auth - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ secrets.GCP_SA_KEY_PROD }} - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 - with: - version: 'latest' - - - name: Get Previous Revision - id: get-revision - run: | - REVISIONS=$(gcloud run revisions list \ - --service=${{ env.SERVICE_NAME }} \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} \ - --format="value(name)" \ - --limit=2) - - PREV_REVISION=$(echo "$REVISIONS" | tail -n 1) - - if [ -z "$PREV_REVISION" ]; then - echo "❌ No previous revision found for rollback" - exit 1 - fi - - echo "prev_revision=${PREV_REVISION}" >> $GITHUB_OUTPUT - echo "Found previous revision: ${PREV_REVISION}" - - - name: Rollback to Previous Revision - run: | - echo "Rolling back to revision: ${{ steps.get-revision.outputs.prev_revision }}" - - gcloud run services update-traffic ${{ env.SERVICE_NAME }} \ - --to-revisions=${{ steps.get-revision.outputs.prev_revision }}=100 \ - --project=${{ env.PROJECT_ID }} \ - --region=${{ env.REGION }} - - echo "## ⚠️ Deployment Failed - Rollback Executed" >> $GITHUB_STEP_SUMMARY -``` - -**Common Issue**: `--traffic=100` flag doesn't exist -**Solution**: Use separate `update-traffic` command after deployment - ---- - -## Common Issues & Solutions - -### Issue 1: npm ci Fails with SSH Authentication - -**Error**: -``` -npm ERR! An ssh url was requested, but git is not set up for ssh -npm ERR! fatal: Authentication failed -``` - -**Root Cause**: package-lock.json contains SSH URLs (`git+ssh://...`) - -**Solution**: Use conditional sed patching in workflow (see Phase 3.3 Layer 1) - -**Prevention**: Accept that lockfile may have SSH URLs, patch at runtime - ---- - -### Issue 2: Docker Build Fails - Repository Not Found - -**Error**: -``` -name unknown: Repository "cards-backend" not found -``` - -**Root Cause**: Artifact Registry repository doesn't exist - -**Solution**: -```bash -gcloud artifacts repositories create cards-backend \ - --repository-format=docker \ - --location=europe-west3 \ - --project=memo-2c4c4 -``` - -**Prevention**: Create repository before first deployment - ---- - -### Issue 3: Invalid Secret Name Format - -**Error**: -``` -'projects/mana-core-453821/secrets/MANA_SERVICE_URL' is not a valid secret name -``` - -**Root Cause**: Used full project path format when secrets are in same project - -**Solution**: Use simple format `SECRET_NAME:latest` when secrets are in same project as Cloud Run: -```yaml ---set-secrets="MANA_SERVICE_URL=MANA_SERVICE_URL:latest,APP_ID=CARDS_APP_ID:latest" -``` - -**Prevention**: Always keep secrets in the same project as Cloud Run deployment. Use `--set-secrets="ENV_VAR=SECRET_NAME:version"` format (not full path) - ---- - -### Issue 4: Traffic Routing Fails - -**Error**: -``` -ERROR: (gcloud.run.deploy) unrecognized arguments: --traffic=100 -``` - -**Root Cause**: `--traffic` flag was removed from `gcloud run deploy` - -**Solution**: Use separate command: -```bash -gcloud run services update-traffic SERVICE_NAME \ - --to-latest \ - --project=PROJECT_ID \ - --region=REGION -``` - -**Prevention**: Always use `update-traffic` for traffic management - ---- - -### Issue 5: Peer Dependency Conflicts - -**Error**: -``` -npm ERR! peer dep missing: @nestjs/common@^10.0.0 -``` - -**Root Cause**: Private package expects NestJS v10, project uses v11 - -**Solution**: Use `--legacy-peer-deps`: -```bash -npm ci --legacy-peer-deps -npm install --legacy-peer-deps -``` - -**Prevention**: Add flag to all npm commands in workflow and Dockerfile - ---- - -### Issue 6: GitHub Token Expired - -**Error**: -``` -fatal: Authentication failed for 'https://github.com/...' -``` - -**Root Cause**: GitHub Personal Access Token expired - -**Solution**: -1. Create new token at https://github.com/settings/tokens -2. Update `GH_PERSONAL_TOKEN` secret in GitHub repository - -**Prevention**: Set calendar reminder before token expiration - ---- - -### Issue 7: Health Check Fails After Deployment - -**Error**: -``` -Health check failed with HTTP 503 -``` - -**Common Causes**: -1. Missing environment variables -2. Can't connect to Supabase -3. Can't connect to Mana -4. Application error on startup - -**Debugging**: -```bash -# Check service logs -gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=cards-backend" \ - --project=memo-2c4c4 \ - --limit=50 - -# Check if secrets are accessible -gcloud run services describe cards-backend \ - --project=memo-2c4c4 \ - --region=europe-west3 \ - --format=yaml -``` - -**Solution**: Fix the underlying issue (usually missing/incorrect environment variable or secret) - ---- - -### Issue 8: Docker Build Secret Not Working - -**Error**: -``` -fatal: could not read Username for 'https://github.com': No such device or address -``` - -**Root Cause**: Docker BuildKit not enabled or secret not passed correctly - -**Solution**: Ensure both are set: -```yaml -env: - DOCKER_BUILDKIT: 1 - GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} - -run: | - docker build \ - --secret id=github_token,env=GH_TOKEN \ - . -``` - -**Prevention**: Always set `DOCKER_BUILDKIT=1` and use `# syntax=docker/dockerfile:1` - ---- - -## Testing & Verification - -### Local Testing - -#### 1. Test Docker Build Locally - -```bash -cd backend - -# Export GitHub token -export GH_TOKEN="your-github-token" - -# Build with secret -DOCKER_BUILDKIT=1 docker build \ - --secret id=github_token,env=GH_TOKEN \ - -t cards-backend:test \ - . - -# Run locally -docker run -p 8080:8080 \ - -e NODE_ENV=development \ - cards-backend:test -``` - -#### 2. Test npm ci Authentication - -```bash -cd backend - -# Simulate CI environment -export GH_TOKEN="your-github-token" - -# Patch lockfile -if grep -q "git+ssh://git@github.com" package-lock.json; then - sed -i.bak "s|git+ssh://git@github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json -else - sed -i.bak "s|git+https://github.com/Memo-2023/|git+https://${GH_TOKEN}@github.com/Memo-2023/|g" package-lock.json -fi - -# Test install -npm ci --legacy-peer-deps - -# Restore original -mv package-lock.json.bak package-lock.json -``` - -### CI/CD Verification - -#### 1. Verify GitHub Secrets - -Go to: `https://github.com/Memo-2023/cards/settings/secrets/actions` - -Confirm these exist: -- ✅ `GH_PERSONAL_TOKEN` -- ✅ `GCP_SA_KEY_PROD` -- ✅ `CLOUD_RUN_SERVICE_ACCOUNT` - -#### 2. Trigger Test Deployment - -```bash -# Push to main to trigger workflow -git push origin main - -# Or use workflow_dispatch -gh workflow run deploy-backend.yml -``` - -#### 3. Monitor Deployment - -```bash -# Watch GitHub Actions -gh run watch - -# Or view in browser -# https://github.com/Memo-2023/cards/actions -``` - -#### 4. Check Deployment - -```bash -# Get service URL -SERVICE_URL=$(gcloud run services describe cards-backend \ - --project=memo-2c4c4 \ - --region=europe-west3 \ - --format='value(status.url)') - -# Test health endpoint -curl ${SERVICE_URL}/health - -# Test liveness -curl ${SERVICE_URL}/health/live - -# View logs -gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=cards-backend" \ - --project=memo-2c4c4 \ - --limit=20 -``` - ---- - -## Maintenance & Updates - -### Rotating GitHub Personal Access Token - -**When**: Before token expires (set calendar reminder) - -**Steps**: -1. Create new token at https://github.com/settings/tokens -2. Update `GH_PERSONAL_TOKEN` in GitHub repository secrets -3. Test with a deployment -4. Revoke old token - -### Updating Private Package Version - -**Option 1**: Update to specific commit -```bash -cd backend -npm install git+https://github.com/Memo-2023/mana-core-nestjs-package.git#commit-sha -``` - -**Option 2**: Update to latest -```bash -cd backend -npm install git+https://github.com/Memo-2023/mana-core-nestjs-package.git -``` - -**Important**: Commit the updated `package-lock.json` - -### Adding New GCP Secrets - -```bash -# 1. Create secret -echo "secret-value" | gcloud secrets create NEW_SECRET_NAME \ - --data-file=- \ - --project=mana-core-453821 - -# 2. Grant access -gcloud secrets add-iam-policy-binding NEW_SECRET_NAME \ - --member="serviceAccount:cards-backend-sa@memo-2c4c4.iam.gserviceaccount.com" \ - --role="roles/secretmanager.secretAccessor" \ - --project=mana-core-453821 - -# 3. Update workflow -# Add to --update-secrets in deploy-backend.yml ---update-secrets="...,NEW_SECRET_NAME=projects/mana-core-453821/secrets/NEW_SECRET_NAME:latest" -``` - -### Scaling Configuration - -Adjust in workflow: -```yaml ---min-instances=1 # Increase for faster cold starts ---max-instances=20 # Increase for higher traffic ---memory=1Gi # Increase for memory-intensive apps ---cpu=2 # Increase for CPU-intensive apps -``` - -### Monitoring - -**Cloud Run Metrics**: -- https://console.cloud.google.com/run/detail/europe-west3/cards-backend/metrics?project=memo-2c4c4 - -**Key Metrics**: -- Request count -- Request latency -- Container instance count -- CPU utilization -- Memory utilization - -**Alerts** (recommended): -- Health check failures -- Error rate > 5% -- P95 latency > 1s -- Memory utilization > 80% - ---- - -## Quick Reference - -### Environment Variables Summary - -| Variable | Location | Purpose | -|----------|----------|---------| -| `NODE_ENV` | Cloud Run | Set to `production` | -| `MANA_SERVICE_URL` | GCP Secret | Mana API URL | -| `APP_ID` | GCP Secret | Cards app identifier | -| `SERVICE_KEY` | GCP Secret | Mana auth key | -| `SUPABASE_URL` | GCP Secret | Supabase project URL | -| `SUPABASE_ANON_KEY` | GCP Secret | Supabase anonymous key | -| `SUPABASE_SERVICE_KEY` | GCP Secret | Supabase service role key | -| `SIGNUP_REDIRECT_URL` | GCP Secret | Post-signup redirect URL | - -### GitHub Secrets Summary - -| Secret | Purpose | Format | -|--------|---------|--------| -| `GH_PERSONAL_TOKEN` | Private package access | `ghp_xxxx` | -| `GCP_SA_KEY_PROD` | Cloud Run deployment | JSON object | -| `CLOUD_RUN_SERVICE_ACCOUNT` | Service identity | Email address | - -### GCP Projects - -| Project ID | Purpose | -|------------|---------| -| `mana-core-453821` | All resources (Cloud Run, Artifact Registry, Service Account, Secrets) | - -**Note**: Keeping everything in one project simplifies secret management. Cloud Run's `--set-secrets` requires secrets in the same project. - -### Important URLs - -- **Repository**: https://github.com/Memo-2023/cards -- **GitHub Actions**: https://github.com/Memo-2023/cards/actions -- **GitHub Secrets**: https://github.com/Memo-2023/cards/settings/secrets/actions -- **Cloud Run Console**: https://console.cloud.google.com/run?project=memo-2c4c4 -- **Artifact Registry**: https://console.cloud.google.com/artifacts?project=memo-2c4c4 -- **Secret Manager**: https://console.cloud.google.com/security/secret-manager?project=mana-core-453821 - ---- - -## Troubleshooting Checklist - -When deployment fails, check in this order: - -1. ✅ **GitHub Secrets exist and are correct** -2. ✅ **GH_PERSONAL_TOKEN hasn't expired** -3. ✅ **Artifact Registry repository exists** -4. ✅ **Service account has required IAM roles** -5. ✅ **Service account can access secrets in mana-core-453821** -6. ✅ **package-lock.json is committed** -7. ✅ **Dockerfile has correct syntax** -8. ✅ **All GCP secrets exist with correct values** -9. ✅ **Health endpoint returns 200** -10. ✅ **Check Cloud Run logs for errors** - ---- - -## Next Steps - -After successful deployment: - -1. ✅ Set up monitoring and alerts -2. ✅ Configure custom domain (if needed) -3. ✅ Set up staging environment -4. ✅ Document API endpoints -5. ✅ Set up automated tests -6. ✅ Configure error tracking (Sentry, etc.) -7. ✅ Set up log aggregation -8. ✅ Create runbook for common operations - ---- - -**Last Updated**: 2025-09-30 -**Maintained By**: Development Team -**Questions?**: Check GitHub Issues or DEPLOYMENT_CHECKLIST.md diff --git a/apps/cards/CREDIT_SYSTEM.md b/apps/cards/CREDIT_SYSTEM.md deleted file mode 100644 index d21b6a1a3..000000000 --- a/apps/cards/CREDIT_SYSTEM.md +++ /dev/null @@ -1,506 +0,0 @@ -# Cards Credit System - -This document explains how the Mana credit system is integrated into Cards. - -## Overview - -Cards uses **Mana** as its credit currency to charge for operations like deck creation, card generation, and AI features. The credit system is powered by [Mana](https://github.com/Memo-2023/mana-core-nestjs-package), which provides: - -- Credit validation before operations -- Credit consumption after successful operations -- Real-time balance tracking -- Transaction history -- App-level usage tracking - -## Credit Costs - -| Operation | Cost (Mana) | Description | -|-----------|-------------|-------------| -| Deck Creation | 10 | Create a new deck | -| Card Creation | 2 | Add a single card to a deck | -| AI Card Generation | 5 | Generate a card using AI | -| Deck Export | 3 | Export a deck to various formats | - -These costs are defined in `backend/src/config/credit-operations.ts`. - -## Architecture - -``` -┌─────────────┐ -│ Frontend │ -│ (React Native) -└─────┬───────┘ - │ 1. Create Deck Request - │ POST /api/decks - │ { name, description } - ▼ -┌─────────────┐ -│ Backend │ -│ (NestJS) │ -├─────────────┤ -│ API │ 2. Validate Credits (10 mana) -│ Controller │ ├─ Has credits? → Continue -│ │ └─ No credits? → Return 400 -│ │ -│ │ 3. Create Deck (business logic) -│ │ -│ │ 4. Consume Credits (10 mana) -│ │ -│ │ 5. Return Success + Credits Used -└─────┬───────┘ - │ - ▼ -┌─────────────┐ -│ Mana │ - Validate balance -│ Service │ - Deduct credits -│ │ - Record transaction -└─────────────┘ -``` - -## Backend Integration - -### 1. Service Key Configuration - -The backend needs a service key from Mana to perform credit operations. - -**backend/.env**: -```env -SERVICE_KEY=your-service-key-from-mana-core -``` - -**backend/src/app.module.ts**: -```typescript -ManaModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => ({ - serviceKey: configService.get('SERVICE_KEY', ''), - // ... other config - }), - inject: [ConfigService], -}) -``` - -### 2. Credit Operations - -**backend/src/config/credit-operations.ts**: -```typescript -export enum CreditOperationType { - DECK_CREATION = 'deck_creation', - CARD_CREATION = 'card_creation', - // Add more as needed -} - -export const CREDIT_COSTS: Record = { - [CreditOperationType.DECK_CREATION]: 10, - [CreditOperationType.CARD_CREATION]: 2, -}; -``` - -### 3. Controller Implementation - -**backend/src/controllers/api.controller.ts**: -```typescript -import { CreditClientService } from '@mana-core/nestjs-integration/services'; -import { CreditOperationType, getCreditCost } from '../config/credit-operations'; - -@Controller('api') -@UseGuards(AuthGuard) -export class ApiController { - constructor(private readonly creditClient: CreditClientService) {} - - @Post('decks') - async createDeck(@CurrentUser() user: any, @Body() deckData: any) { - const operationType = CreditOperationType.DECK_CREATION; - const creditCost = getCreditCost(operationType); - - // 1. Validate credits BEFORE operation - const validation = await this.creditClient.validateCredits( - user.id, - operationType, - creditCost, - ); - - if (!validation.hasCredits) { - throw new BadRequestException({ - error: 'insufficient_credits', - message: `Insufficient mana. Required: ${creditCost}, Available: ${validation.availableCredits}`, - requiredCredits: creditCost, - availableCredits: validation.availableCredits, - }); - } - - // 2. Perform the operation - const newDeck = await this.createDeckInDatabase(deckData); - - // 3. Consume credits AFTER success - await this.creditClient.consumeCredits( - user.id, - operationType, - creditCost, - `Created deck: ${deckData.name}`, - { deckId: newDeck.id }, - ); - - return { success: true, deck: newDeck, creditsUsed: creditCost }; - } -} -``` - -### 4. Credit Balance Endpoint - -Get user's current credit balance: - -```typescript -@Get('credits/balance') -async getCreditBalance(@CurrentUser() user: any) { - const balance = await this.creditClient.getCreditBalance(user.id); - return { - userId: user.id, - balance: balance.balance || 0, - currency: 'mana', - }; -} -``` - -## Frontend Integration - -### 1. Types - -**apps/mobile/types/credits.ts**: -```typescript -export interface InsufficientCreditsError { - error: 'insufficient_credits'; - message: string; - requiredCredits: number; - availableCredits: number; - operation?: string; -} - -export function isInsufficientCreditsError(error: any): boolean { - return error && error.error === 'insufficient_credits'; -} -``` - -### 2. Credit Service - -**apps/mobile/services/creditService.ts**: -```typescript -export const creditService = { - async getBalance(): Promise { - const response = await get(`${API_URL}/api/credits/balance`); - return response.balance || 0; - }, -}; -``` - -### 3. Insufficient Credits Modal - -**apps/mobile/components/InsufficientCreditsModal.tsx**: - -A pre-built modal component that displays: -- Required vs available credits -- Shortfall amount -- "Get More Mana" button (optional) -- Cancel button - -### 4. Hook for Easy Integration - -**apps/mobile/hooks/useInsufficientCredits.ts**: -```typescript -export function useInsufficientCredits() { - // ... state management - - return { - visible, - requiredCredits, - availableCredits, - operation, - handleCreditError, // Automatically shows modal for credit errors - hideInsufficientCredits, - }; -} -``` - -### 5. Usage Example - -**In any screen that creates a deck**: -```typescript -import { useInsufficientCredits } from '../hooks/useInsufficientCredits'; -import { InsufficientCreditsModal } from '../components/InsufficientCreditsModal'; -import { creditService } from '../services/creditService'; - -function DeckCreationScreen() { - const [creditBalance, setCreditBalance] = useState(0); - const insufficientCredits = useInsufficientCredits(); - - // Load balance - useEffect(() => { - creditService.getBalance().then(setCreditBalance); - }, []); - - const handleCreateDeck = async () => { - try { - const response = await post('/api/decks', deckData); - Alert.alert('Success', `Deck created! ${response.creditsUsed} mana used.`); - - // Refresh balance - const newBalance = await creditService.getBalance(); - setCreditBalance(newBalance); - } catch (error) { - // Automatically shows modal if it's a credit error - if (!insufficientCredits.handleCreditError(error)) { - // Handle other errors - Alert.alert('Error', error.message); - } - } - }; - - return ( - - {/* Show balance */} - Your Mana: {creditBalance} ⚡ - - {/* Create deck button */} - - - - - - - - - - diff --git a/apps/cards/apps/landing/src/env.d.ts b/apps/cards/apps/landing/src/env.d.ts deleted file mode 100644 index f964fe0cf..000000000 --- a/apps/cards/apps/landing/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/apps/cards/apps/landing/src/layouts/Layout.astro b/apps/cards/apps/landing/src/layouts/Layout.astro deleted file mode 100644 index 144e4ea62..000000000 --- a/apps/cards/apps/landing/src/layouts/Layout.astro +++ /dev/null @@ -1,63 +0,0 @@ ---- -import '../styles/global.css'; -import Analytics from '@mana/shared-landing-ui/atoms/Analytics.astro'; - -interface Props { - title: string; - description?: string; -} - -const { - title, - description = 'Cards - Dein KI-gestützter Lernpartner für Karteikarten und effektives Lernen', -} = Astro.props; ---- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && ( - + +
+
+ {@html view.prompt} +
+ + {#if isTypeIn} + onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)} + disabled={showBack} + /> + {/if} + + {#if showBack} +
+ {@html view.answer} +
+ {/if} +
diff --git a/apps/cards/apps/web/src/lib/components/CardsLogo.svelte b/apps/cards/apps/web/src/lib/components/CardsLogo.svelte new file mode 100644 index 000000000..556e51700 --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/CardsLogo.svelte @@ -0,0 +1,13 @@ + + + + + + diff --git a/apps/cards/apps/web/src/lib/data/crypto.ts b/apps/cards/apps/web/src/lib/data/crypto.ts new file mode 100644 index 000000000..07df39beb --- /dev/null +++ b/apps/cards/apps/web/src/lib/data/crypto.ts @@ -0,0 +1,56 @@ +/** + * Encryption wrapper — Phase-1 stub. + * + * The full Mana crypto stack (vault server roundtrip, KEK-wrapped + * master key, recovery codes, zero-knowledge mode) lives in the mana + * web app under `apps/mana/.../data/crypto/`. Lifting it intact into + * the standalone Cards app is a Phase-2 task — it requires a vault + * client, key provider, and boot-race handling that aren't worth + * dragging in until we have the deployment story for them. + * + * For Phase 1 these helpers are intentionally identity functions: + * data lands in IndexedDB and on `mana-sync` as plaintext. Everything + * is wired up at the right call sites (stores → write, queries → read, + * sync.applyServerChanges → apply) so flipping to real encryption is a + * single-file change here, not a sweep through every store. + * + * Allowlist is the contract with the future vault. It mirrors the + * mana-modul registry exactly so when sync converges, the same fields + * are protected on both ends. + */ + +const ENCRYPTED_FIELDS: Record = { + cards: ['front', 'back', 'fields'], + cardDecks: ['name', 'description'], +}; + +/** + * Phase-1 identity. Phase-2 swap-in: import `wrapValue` from + * `@mana/shared-crypto`, fetch master key from the vault, encrypt + * each allowlisted field in place. + */ +export async function encryptRecord(tableName: string, record: T): Promise { + void ENCRYPTED_FIELDS[tableName]; + return record; +} + +export async function decryptRecord(_tableName: string, record: T): Promise { + return record; +} + +export async function decryptRecords( + tableName: string, + records: T[] +): Promise { + if (records.length === 0) return records; + return Promise.all(records.map((r) => decryptRecord(tableName, r))); +} + +/** + * Reports the fields that *will* be encrypted once the vault is on. + * Stays exported so the GUIDELINES audit script can prove parity with + * the mana-modul registry. + */ +export function encryptedFieldsFor(tableName: string): readonly string[] { + return ENCRYPTED_FIELDS[tableName] ?? []; +} diff --git a/apps/cards/apps/web/src/lib/data/database.ts b/apps/cards/apps/web/src/lib/data/database.ts new file mode 100644 index 000000000..3a77d3391 --- /dev/null +++ b/apps/cards/apps/web/src/lib/data/database.ts @@ -0,0 +1,150 @@ +/** + * Standalone Cards Dexie database. + * + * Phase-1 sync: every write to a sync-relevant table fires a Dexie hook + * that records a row into `_pendingChanges`. The sync engine drains + * that queue against `mana-sync` (POST /sync/cards). When server changes + * come back, they're applied with `beginApplying(table)` set so the + * hooks suppress queueing for those rows — otherwise client and server + * would ping-pong forever. + * + * Encryption is intentionally NOT wired here. Phase-1 ships plaintext; + * Etappe 3c.3 turns it on once the vault client is in place. + */ + +import Dexie, { type Table } from 'dexie'; +import type { LocalDeck, LocalCard, LocalCardReview, LocalCardStudyBlock } from '@mana/cards-core'; + +interface DeckTag { + id: string; + deckId: string; + tagId: string; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; +} + +/** Server protocol expects this shape on push. */ +export interface FieldChange { + value: unknown; + at: string; +} + +export type ChangeOp = 'insert' | 'update' | 'delete'; + +export interface PendingChange { + /** Auto-increment PK (Dexie ++id). */ + pk?: number; + table: string; + id: string; + op: ChangeOp; + fields?: Record; + data?: Record; + deletedAt?: string; + queuedAt: string; +} + +/** Tables whose writes are mirrored to mana-sync. */ +const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags'] as const; + +class CardsDatabase extends Dexie { + cardDecks!: Table; + cards!: Table; + cardReviews!: Table; + cardStudyBlocks!: Table; + deckTags!: Table; + _pendingChanges!: Table; + + constructor() { + super('cards'); + this.version(1).stores({ + cardDecks: 'id, lastStudied', + cards: 'id, deckId, order, [deckId+order]', + cardReviews: 'id, cardId, due, [cardId+subIndex], state', + cardStudyBlocks: 'id, date', + deckTags: 'id, deckId, tagId', + _pendingChanges: '++pk, table, queuedAt', + }); + } +} + +export const db = new CardsDatabase(); + +export const cardDeckTable = db.cardDecks; +export const cardTable = db.cards; +export const cardReviewTable = db.cardReviews; +export const cardStudyBlockTable = db.cardStudyBlocks; +export const pendingChangesTable = db._pendingChanges; + +// ─── Server-apply suppression ────────────────────────────── + +const applying = new Set(); + +/** Mark a table as "currently applying server changes" — hooks skip + * queueing for the duration. Caller must always pair with `endApplying`. */ +export function beginApplying(tableName: string) { + applying.add(tableName); +} +export function endApplying(tableName: string) { + applying.delete(tableName); +} + +// ─── Field-meta diff ─────────────────────────────────────── + +function diffToFields( + previous: Record, + next: Record +): Record { + const at = new Date().toISOString(); + const out: Record = {}; + for (const key of Object.keys(next)) { + if (key.startsWith('_') || key === 'updatedAt') continue; + if (previous[key] === next[key]) continue; + out[key] = { value: next[key], at }; + } + return out; +} + +function snapshotForInsert(row: Record): Record { + const out: Record = {}; + for (const key of Object.keys(row)) { + if (key.startsWith('_')) continue; + out[key] = row[key]; + } + return out; +} + +// ─── Hook installation ───────────────────────────────────── + +function installSyncHooks(table: Table, name: string) { + table.hook('creating', (_pk, row) => { + if (applying.has(name)) return; + void db._pendingChanges.add({ + table: name, + id: row.id, + op: 'insert', + data: snapshotForInsert(row), + queuedAt: new Date().toISOString(), + }); + }); + + table.hook('updating', (mods, _pk, prev) => { + if (applying.has(name)) return; + const next = { ...prev, ...mods }; + const fields = diffToFields(prev, next); + if (Object.keys(fields).length === 0 && !('deletedAt' in mods)) return; + const isDelete = (mods as { deletedAt?: string }).deletedAt; + void db._pendingChanges.add({ + table: name, + id: prev.id, + op: isDelete ? 'delete' : 'update', + fields: Object.keys(fields).length > 0 ? fields : undefined, + deletedAt: isDelete ?? undefined, + queuedAt: new Date().toISOString(), + }); + }); +} + +for (const name of SYNC_TABLES) { + installSyncHooks(db.table(name), name); +} diff --git a/apps/cards/apps/web/src/lib/data/sync.ts b/apps/cards/apps/web/src/lib/data/sync.ts new file mode 100644 index 000000000..8889d1f54 --- /dev/null +++ b/apps/cards/apps/web/src/lib/data/sync.ts @@ -0,0 +1,290 @@ +/** + * Cards sync engine — talks to mana-sync (POST /sync/cards, GET /sync/cards/pull). + * + * Two loops, both polling-based for the Phase-1 MVP. WebSocket + * notifications can replace the pull poll later without changing + * anything outside this file. + * + * Push: drain `_pendingChanges` every 1s when there's anything queued. + * On success, delete drained rows and apply any server-changes + * the response carried back. Failures keep the rows queued — + * the next tick retries. + * + * Pull: every 5s, ask each sync table for changes since its cursor. + * Apply with suppression so the apply doesn't re-enqueue a push. + * Cursor lives in localStorage per table. + * + * Cursor format: ISO timestamp string. The server returns + * `syncedUntil` on push and we store that as a global push cursor; pull + * uses one cursor per collection. + */ + +import { browser } from '$app/environment'; +import { + beginApplying, + endApplying, + db, + pendingChangesTable, + type PendingChange, +} from './database'; +import { encryptRecord } from './crypto'; + +const APP_ID = 'cards'; +const PUSH_INTERVAL_MS = 1_000; +const PULL_INTERVAL_MS = 5_000; +const SYNC_TABLES = ['cardDecks', 'cards', 'cardReviews', 'cardStudyBlocks', 'deckTags']; + +// ─── URL + Auth wiring ───────────────────────────────────── + +function getSyncUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_SYNC_URL__?: string }) + .__PUBLIC_MANA_SYNC_URL__; + if (injected) return injected; + } + return import.meta.env.DEV ? 'http://localhost:3050' : ''; +} + +interface AuthLike { + getValidToken?: () => Promise; + readonly isAuthenticated: boolean; +} + +let authProvider: AuthLike | null = null; + +// ─── Client ID ───────────────────────────────────────────── + +const CLIENT_ID_KEY = 'mana.cards.clientId'; + +function getClientId(): string { + if (!browser) return 'ssr'; + let id = localStorage.getItem(CLIENT_ID_KEY); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(CLIENT_ID_KEY, id); + } + return id; +} + +// ─── Cursors ─────────────────────────────────────────────── + +const PUSH_CURSOR_KEY = 'mana.cards.pushCursor'; +const PULL_CURSOR_KEY = (table: string) => `mana.cards.pullCursor.${table}`; + +function getPushCursor(): string { + if (!browser) return ''; + return localStorage.getItem(PUSH_CURSOR_KEY) || '1970-01-01T00:00:00.000Z'; +} +function setPushCursor(at: string) { + if (browser) localStorage.setItem(PUSH_CURSOR_KEY, at); +} +function getPullCursor(table: string): string { + if (!browser) return ''; + return localStorage.getItem(PULL_CURSOR_KEY(table)) || '1970-01-01T00:00:00.000Z'; +} +function setPullCursor(table: string, at: string) { + if (browser) localStorage.setItem(PULL_CURSOR_KEY(table), at); +} + +// ─── Server-Change shape ─────────────────────────────────── + +interface ServerChange { + eventId?: string; + schemaVersion?: number; + table: string; + id: string; + op: 'insert' | 'update' | 'delete'; + fields?: Record; + data?: Record; + deletedAt?: string; +} + +interface SyncResponse { + serverChanges: ServerChange[]; + conflicts: unknown[]; + syncedUntil: string; + hasMore?: boolean; +} + +// ─── Apply server changes ────────────────────────────────── + +async function applyServerChanges(changes: ServerChange[]) { + if (changes.length === 0) return; + const byTable = new Map(); + for (const c of changes) { + const arr = byTable.get(c.table) ?? []; + arr.push(c); + byTable.set(c.table, arr); + } + + for (const [table, list] of byTable) { + if (!SYNC_TABLES.includes(table)) continue; + const t = db.table(table); + beginApplying(table); + try { + for (const c of list) { + try { + if (c.op === 'delete') { + await t.update(c.id, { deletedAt: c.deletedAt ?? new Date().toISOString() }); + continue; + } + if (c.op === 'insert' && c.data) { + const row = { ...c.data, id: c.id }; + // Server data may already be ciphertext-on-the-wire when + // encryption flips on. Re-running encryptRecord on it is a + // safe no-op today (Phase-1 stub) and the right hook in + // Phase-2 because existing-ciphertext values are detected + // upstream via `isEncrypted(...)`. + await encryptRecord(table, row); + await t.put(row); + continue; + } + // update — merge fields + if (c.fields) { + const existing = (await t.get(c.id)) ?? { id: c.id }; + const merged: Record = { ...existing }; + for (const [k, v] of Object.entries(c.fields)) { + merged[k] = v.value; + } + await encryptRecord(table, merged); + await t.put(merged); + } + } catch (err) { + console.error('[cards-sync] apply failed', { table, id: c.id, op: c.op, err }); + } + } + } finally { + endApplying(table); + } + } +} + +// ─── Push ────────────────────────────────────────────────── + +async function flushPush(): Promise { + if (!authProvider?.isAuthenticated) return; + + const queued = await pendingChangesTable.orderBy('queuedAt').limit(500).toArray(); + if (queued.length === 0) return; + + const token = (await authProvider.getValidToken?.()) ?? null; + if (!token) return; + + const since = getPushCursor(); + const body = { + clientId: getClientId(), + appId: APP_ID, + since, + schemaVersion: 1, + changes: queued.map(toWireChange), + }; + + let res: Response; + try { + res = await fetch(`${getSyncUrl()}/sync/${APP_ID}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Id': getClientId(), + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + } catch (err) { + console.warn('[cards-sync] push network error', err); + return; + } + + if (!res.ok) { + console.warn('[cards-sync] push HTTP', res.status, await res.text().catch(() => '')); + return; + } + + const json = (await res.json()) as SyncResponse; + await pendingChangesTable.bulkDelete(queued.map((q) => q.pk!).filter((pk) => pk !== undefined)); + setPushCursor(json.syncedUntil); + await applyServerChanges(json.serverChanges ?? []); +} + +function toWireChange(p: PendingChange): ServerChange { + const out: ServerChange = { table: p.table, id: p.id, op: p.op }; + if (p.fields) out.fields = p.fields; + if (p.data) out.data = p.data; + if (p.deletedAt) out.deletedAt = p.deletedAt; + return out; +} + +// ─── Pull ────────────────────────────────────────────────── + +async function pollPull(): Promise { + if (!authProvider?.isAuthenticated) return; + const token = (await authProvider.getValidToken?.()) ?? null; + if (!token) return; + + for (const table of SYNC_TABLES) { + const since = getPullCursor(table); + const url = + `${getSyncUrl()}/sync/${APP_ID}/pull?collection=${encodeURIComponent(table)}` + + `&since=${encodeURIComponent(since)}`; + + let res: Response; + try { + res = await fetch(url, { + headers: { + 'X-Client-Id': getClientId(), + Authorization: `Bearer ${token}`, + }, + }); + } catch (err) { + console.warn('[cards-sync] pull network error', err); + continue; + } + + if (!res.ok) { + console.warn('[cards-sync] pull HTTP', res.status, table); + continue; + } + + const json = (await res.json()) as SyncResponse; + await applyServerChanges(json.serverChanges ?? []); + if (json.syncedUntil) setPullCursor(table, json.syncedUntil); + } +} + +// ─── Lifecycle ───────────────────────────────────────────── + +let pushTimer: ReturnType | null = null; +let pullTimer: ReturnType | null = null; +let pushBusy = false; +let pullBusy = false; + +export function startSync(authStore: AuthLike) { + authProvider = authStore; + if (!browser) return; + stopSync(); + pushTimer = setInterval(async () => { + if (pushBusy) return; + pushBusy = true; + try { + await flushPush(); + } finally { + pushBusy = false; + } + }, PUSH_INTERVAL_MS); + pullTimer = setInterval(async () => { + if (pullBusy) return; + pullBusy = true; + try { + await pollPull(); + } finally { + pullBusy = false; + } + }, PULL_INTERVAL_MS); +} + +export function stopSync() { + if (pushTimer) clearInterval(pushTimer); + if (pullTimer) clearInterval(pullTimer); + pushTimer = null; + pullTimer = null; +} diff --git a/apps/cards/apps/web/src/lib/index.ts b/apps/cards/apps/web/src/lib/index.ts new file mode 100644 index 000000000..648b5d03a --- /dev/null +++ b/apps/cards/apps/web/src/lib/index.ts @@ -0,0 +1,2 @@ +// place files you want to import through the `$lib` alias in this folder. +export {}; diff --git a/apps/cards/apps/web/src/lib/queries.ts b/apps/cards/apps/web/src/lib/queries.ts new file mode 100644 index 000000000..5bff895a6 --- /dev/null +++ b/apps/cards/apps/web/src/lib/queries.ts @@ -0,0 +1,154 @@ +/** + * Reactive queries — standalone. + * + * Wraps Dexie's liveQuery so Svelte components get auto-updates whenever + * the underlying tables change. Type converters mirror the mana-modul + * shape so component code stays portable. + */ + +import { liveQuery } from 'dexie'; +import { db, cardDeckTable, cardTable, cardReviewTable } from './data/database'; +import { decryptRecord, decryptRecords } from './data/crypto'; +import type { + CardFields, + CardType, + Card, + CardReview, + Deck, + LocalCard, + LocalCardReview, + LocalDeck, +} from '@mana/cards-core'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toDeck(local: LocalDeck): Deck { + return { + id: local.id, + title: local.name, + description: local.description ?? undefined, + color: local.color, + visibility: local.visibility ?? 'private', + tags: [], + cardCount: local.cardCount, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(), + }; +} + +export function toLogicalCard(local: LocalCard): { + type: CardType; + fields: CardFields; + front: string; + back: string; +} { + const type: CardType = local.type ?? 'basic'; + const fields: CardFields = local.fields ?? { + front: local.front ?? '', + back: local.back ?? '', + }; + const front = fields.front ?? local.front ?? ''; + const back = fields.back ?? local.back ?? ''; + return { type, fields, front, back }; +} + +export function toCard(local: LocalCard): Card { + const { type, fields, front, back } = toLogicalCard(local); + return { + id: local.id, + deckId: local.deckId, + type, + fields, + front, + back, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? local.createdAt ?? new Date().toISOString(), + }; +} + +function toCardReview(r: LocalCardReview): CardReview { + return { + id: r.id, + cardId: r.cardId, + subIndex: r.subIndex, + state: r.state, + stability: r.stability, + difficulty: r.difficulty, + due: r.due, + reps: r.reps, + lapses: r.lapses, + lastReview: r.lastReview, + elapsedDays: r.elapsedDays, + scheduledDays: r.scheduledDays, + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllDecks() { + return liveQuery(async () => { + const all = await cardDeckTable.toArray(); + const visible = all.filter((d) => !d.deletedAt); + const decrypted = await decryptRecords('cardDecks', visible); + return decrypted.map(toDeck); + }); +} + +export function useDeck(deckId: string) { + return liveQuery(async () => { + const local = await cardDeckTable.get(deckId); + if (!local || local.deletedAt) return null; + const decrypted = await decryptRecord('cardDecks', { ...local }); + return toDeck(decrypted); + }); +} + +export function useCardsByDeck(deckId: string) { + return liveQuery(async () => { + const visible = (await cardTable.where('deckId').equals(deckId).sortBy('order')).filter( + (c) => !c.deletedAt + ); + const decrypted = await decryptRecords('cards', visible); + return decrypted.map(toCard); + }); +} + +/** + * All reviews due now (or overdue) optionally filtered by deck. Joined + * with the parent card so the learn session can render immediately. + */ +export function useDueReviews(deckId?: string) { + return liveQuery(async () => { + const nowIso = new Date().toISOString(); + const due = await cardReviewTable.where('due').belowOrEqual(nowIso).toArray(); + const live = due.filter((r) => !r.deletedAt); + if (live.length === 0) return [] as { review: CardReview; card: Card }[]; + + const cardIds = [...new Set(live.map((r) => r.cardId))]; + const cardRows = await db.cards.where('id').anyOf(cardIds).toArray(); + const decryptedCards = await decryptRecords( + 'cards', + cardRows.filter((c) => !c.deletedAt) + ); + const cardById = new Map(decryptedCards.map((c) => [c.id, toCard(c)] as const)); + + return live + .filter((r) => { + const c = cardById.get(r.cardId); + if (!c) return false; + if (deckId && c.deckId !== deckId) return false; + return true; + }) + .sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0)) + .map((r) => ({ review: toCardReview(r), card: cardById.get(r.cardId)! })); + }); +} + +export function useReview(reviewId: string) { + return liveQuery(async () => { + const r = await cardReviewTable.get(reviewId); + if (!r || r.deletedAt) return null; + return toCardReview(r); + }); +} diff --git a/apps/cards/apps/web/src/lib/stores/auth.svelte.ts b/apps/cards/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..e5ba92be9 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,12 @@ +/** + * Auth Store — uses the shared Mana auth factory. + * + * SSO: tokens land in the shared `*.mana.how` storage so a user already + * signed into mana.how / cards.mana.how lands directly in the app + * without re-typing credentials. The factory wires up the token + * manager + refresh + storage adapter for us. + */ + +import { createManaAuthStore } from '@mana/shared-auth-ui'; + +export const authStore = createManaAuthStore(); diff --git a/apps/cards/apps/web/src/lib/stores/cards.svelte.ts b/apps/cards/apps/web/src/lib/stores/cards.svelte.ts new file mode 100644 index 000000000..079ae5c62 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/cards.svelte.ts @@ -0,0 +1,165 @@ +/** + * Card Store — standalone. + * + * Writes the {type, fields} shape directly. Legacy mirror (front/back + * columns) kept on for cross-compat with the mana-modul data model + * once sync flips on. No encryption, no domain events — that's the + * deliberate Phase-1 simplification. + */ + +import { cardTable, cardDeckTable } from '../data/database'; +import { encryptRecord, decryptRecord } from '../data/crypto'; +import { reviewStore } from './reviews.svelte'; +import { + type CardFields, + type CardType, + type LocalCard, + type CreateCardInput, + type UpdateCardInput, +} from '@mana/cards-core'; + +let error = $state(null); + +function resolveTypeAndFields(input: CreateCardInput): { + type: CardType; + fields: CardFields; +} { + const type = input.type ?? 'basic'; + if (input.fields) return { type, fields: input.fields }; + if (type === 'cloze') return { type, fields: { text: input.front ?? '' } }; + return { type, fields: { front: input.front ?? '', back: input.back ?? '' } }; +} + +function legacyMirror(type: CardType, fields: CardFields): { front?: string; back?: string } { + if (type === 'basic' || type === 'basic-reverse' || type === 'type-in') { + return { front: fields.front ?? '', back: fields.back ?? '' }; + } + if (type === 'cloze') { + return { front: fields.text ?? '', back: '' }; + } + return {}; +} + +export const cardStore = { + get error() { + return error; + }, + + async createCard( + input: CreateCardInput, + currentCardCount: number = 0 + ): Promise { + error = null; + try { + const { type, fields } = resolveTypeAndFields(input); + const legacy = legacyMirror(type, fields); + const now = new Date().toISOString(); + + const newLocal: LocalCard = { + id: crypto.randomUUID(), + deckId: input.deckId, + type, + fields, + order: currentCardCount, + createdAt: now, + updatedAt: now, + ...legacy, + }; + + await encryptRecord('cards', newLocal); + await cardTable.add(newLocal); + + const deck = await cardDeckTable.get(input.deckId); + if (deck) { + await cardDeckTable.update(input.deckId, { + cardCount: (deck.cardCount || 0) + 1, + updatedAt: now, + }); + } + + await reviewStore.ensureReviewsForCard({ id: newLocal.id, type, fields }); + return newLocal; + } catch (err: any) { + error = err.message || 'Failed to create card'; + console.error('Create card error:', err); + return null; + } + }, + + async updateCard(id: string, updates: UpdateCardInput) { + error = null; + try { + const existingRaw = await cardTable.get(id); + if (!existingRaw) return; + const existing = await decryptRecord('cards', { ...existingRaw }); + + const currentType: CardType = existing.type ?? 'basic'; + const currentFields: CardFields = existing.fields ?? { + front: existing.front ?? '', + back: existing.back ?? '', + }; + + const nextType: CardType = updates.type ?? currentType; + const nextFields: CardFields = updates.fields + ? updates.fields + : updates.front !== undefined || updates.back !== undefined + ? nextType === 'cloze' + ? { ...currentFields, text: updates.front ?? currentFields.text ?? '' } + : { + ...currentFields, + front: updates.front ?? currentFields.front ?? '', + back: updates.back ?? currentFields.back ?? '', + } + : currentFields; + + const legacy = legacyMirror(nextType, nextFields); + const diff: Partial = { + type: nextType, + fields: nextFields, + updatedAt: new Date().toISOString(), + ...legacy, + }; + if (updates.order !== undefined) diff.order = updates.order; + + await encryptRecord('cards', diff as Record); + await cardTable.update(id, diff); + + const structuralChange = + updates.type !== undefined || + updates.fields !== undefined || + (nextType === 'cloze' && updates.front !== undefined); + if (structuralChange) { + await reviewStore.ensureReviewsForCard({ id, type: nextType, fields: nextFields }); + } + } catch (err: any) { + error = err.message || 'Failed to update card'; + console.error('Update card error:', err); + } + }, + + async deleteCard(id: string, deckId?: string) { + error = null; + try { + const now = new Date().toISOString(); + await cardTable.update(id, { deletedAt: now }); + await reviewStore.softDeleteForCard(id); + + if (deckId) { + const deck = await cardDeckTable.get(deckId); + if (deck) { + await cardDeckTable.update(deckId, { + cardCount: Math.max(0, (deck.cardCount || 0) - 1), + updatedAt: now, + }); + } + } + } catch (err: any) { + error = err.message || 'Failed to delete card'; + console.error('Delete card error:', err); + } + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/decks.svelte.ts b/apps/cards/apps/web/src/lib/stores/decks.svelte.ts new file mode 100644 index 000000000..79fcd0f01 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/decks.svelte.ts @@ -0,0 +1,78 @@ +/** + * Deck Store — standalone. + * + * Slim version of the mana-modul decks store: no time-blocks, no + * domain-events, no Mana-wide visibility hooks. Just CRUD against the + * standalone Dexie DB. + */ + +import { cardDeckTable, cardTable, db } from '../data/database'; +import { encryptRecord } from '../data/crypto'; +import type { CreateDeckInput, UpdateDeckInput, LocalDeck } from '@mana/cards-core'; + +let error = $state(null); + +export const deckStore = { + get error() { + return error; + }, + + async createDeck(input: CreateDeckInput): Promise { + error = null; + try { + const now = new Date().toISOString(); + const newLocal: LocalDeck = { + id: crypto.randomUUID(), + name: input.title, + description: input.description ?? null, + color: '#6366f1', + cardCount: 0, + visibility: 'private', + createdAt: now, + updatedAt: now, + }; + await encryptRecord('cardDecks', newLocal); + await cardDeckTable.add(newLocal); + return newLocal; + } catch (err: any) { + error = err.message || 'Failed to create deck'; + console.error('Create deck error:', err); + return null; + } + }, + + async updateDeck(id: string, updates: UpdateDeckInput) { + error = null; + try { + const diff: Partial = { updatedAt: new Date().toISOString() }; + if (updates.title !== undefined) diff.name = updates.title; + if (updates.description !== undefined) diff.description = updates.description; + await encryptRecord('cardDecks', diff as Record); + await cardDeckTable.update(id, diff); + } catch (err: any) { + error = err.message || 'Failed to update deck'; + console.error('Update deck error:', err); + } + }, + + async deleteDeck(id: string) { + error = null; + try { + const now = new Date().toISOString(); + await db.transaction('rw', cardDeckTable, cardTable, async () => { + const cards = await cardTable.where('deckId').equals(id).toArray(); + for (const card of cards) { + await cardTable.update(card.id, { deletedAt: now }); + } + await cardDeckTable.update(id, { deletedAt: now }); + }); + } catch (err: any) { + error = err.message || 'Failed to delete deck'; + console.error('Delete deck error:', err); + } + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/reviews.svelte.ts b/apps/cards/apps/web/src/lib/stores/reviews.svelte.ts new file mode 100644 index 000000000..7caa295d1 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/reviews.svelte.ts @@ -0,0 +1,90 @@ +/** + * Card-Review Store — standalone. + * + * Plaintext, no encryption hook (Phase 1). Fan-out logic comes from + * @mana/cards-core; the only standalone bit is which Dexie table to write to. + */ + +import { cardReviewTable } from '../data/database'; +import { + newReview, + gradeReview as fsrsGrade, + subIndexesFor, + type CardFields, + type CardType, + type LocalCardReview, + type ReviewGrade, +} from '@mana/cards-core'; + +let error = $state(null); + +export const reviewStore = { + get error() { + return error; + }, + + async ensureReviewsForCard(card: { + id: string; + type: CardType; + fields: CardFields; + }): Promise { + error = null; + try { + const existing = await cardReviewTable.where('cardId').equals(card.id).toArray(); + const live = existing.filter((r) => !r.deletedAt); + const liveByIdx = new Map(live.map((r) => [r.subIndex, r])); + + const wanted = subIndexesFor(card); + const wantedSet = new Set(wanted); + const nowIso = new Date().toISOString(); + + for (const subIndex of wanted) { + if (!liveByIdx.has(subIndex)) { + const r = newReview({ cardId: card.id, subIndex }); + await cardReviewTable.add(r); + liveByIdx.set(subIndex, r); + } + } + + for (const r of live) { + if (!wantedSet.has(r.subIndex)) { + await cardReviewTable.update(r.id, { deletedAt: nowIso }); + liveByIdx.delete(r.subIndex); + } + } + + return [...liveByIdx.values()].sort((a, b) => a.subIndex - b.subIndex); + } catch (err: any) { + error = err.message || 'Failed to ensure reviews'; + console.error('Ensure reviews error:', err); + return []; + } + }, + + async grade(reviewId: string, grade: ReviewGrade): Promise { + error = null; + try { + const existing = await cardReviewTable.get(reviewId); + if (!existing) return null; + const next = fsrsGrade(existing, grade); + await cardReviewTable.put(next); + return next; + } catch (err: any) { + error = err.message || 'Failed to grade review'; + console.error('Grade review error:', err); + return null; + } + }, + + async softDeleteForCard(cardId: string): Promise { + const reviews = await cardReviewTable.where('cardId').equals(cardId).toArray(); + const now = new Date().toISOString(); + for (const r of reviews) { + if (!r.deletedAt) await cardReviewTable.update(r.id, { deletedAt: now }); + } + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts b/apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts new file mode 100644 index 000000000..91fb0b517 --- /dev/null +++ b/apps/cards/apps/web/src/lib/stores/study-blocks.svelte.ts @@ -0,0 +1,65 @@ +/** + * Study-Block Store — standalone. + * + * Local daily-aggregate row for streak + per-day-stats. + */ + +import { cardStudyBlockTable } from '../data/database'; +import type { LocalCardStudyBlock } from '@mana/cards-core'; + +let error = $state(null); + +function localDateKey(d: Date = new Date()): string { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export const studyBlockStore = { + get error() { + return error; + }, + + async recordReview(durationMs: number, count: number = 1): Promise { + error = null; + try { + const date = localDateKey(); + const existing = await cardStudyBlockTable.where('date').equals(date).first(); + if (existing && !existing.deletedAt) { + await cardStudyBlockTable.update(existing.id, { + cardsReviewed: existing.cardsReviewed + count, + durationMs: existing.durationMs + durationMs, + }); + } else { + const row: LocalCardStudyBlock = { + id: crypto.randomUUID(), + date, + cardsReviewed: count, + durationMs, + }; + await cardStudyBlockTable.add(row); + } + } catch (err: any) { + error = err.message || 'Failed to record review'; + console.error('Record review error:', err); + } + }, + + async getRecentStreak(): Promise { + const today = new Date(); + let streak = 0; + for (let i = 0; i < 365; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const row = await cardStudyBlockTable.where('date').equals(localDateKey(d)).first(); + if (!row || row.deletedAt || row.cardsReviewed <= 0) break; + streak++; + } + return streak; + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/cards/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..a66745b96 --- /dev/null +++ b/apps/cards/apps/web/src/routes/+layout.svelte @@ -0,0 +1,55 @@ + + +{#if isPublic} + {@render children()} +{:else} + +
+
+ + 🃏 Cards + +
+ {#if authStore.user?.email} + + {/if} + +
+
+
+ + {@render children()} +
+{/if} diff --git a/apps/cards/apps/web/src/routes/+page.svelte b/apps/cards/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..b23098055 --- /dev/null +++ b/apps/cards/apps/web/src/routes/+page.svelte @@ -0,0 +1,132 @@ + + + + Cards + + +
+
+
+

Cards

+

+ {decks.length} + {decks.length === 1 ? 'Deck' : 'Decks'} +

+
+ +
+ + {#if showNew} +
{ + e.preventDefault(); + handleCreate(); + }} + > + + + +
+ + +
+
+ {/if} + + {#if decks.length === 0 && !showNew} +
+
🃏
+

Noch keine Decks. Leg dein erstes an.

+ +
+ {:else} + + {/if} + +

+ Phase 1 Vorschau · noch keine Sync zur Mana-App +

+
diff --git a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte new file mode 100644 index 000000000..48ea52074 --- /dev/null +++ b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte @@ -0,0 +1,326 @@ + + + + {deck?.title ?? 'Deck'} — Cards + + +
+ ← Decks + + {#if deck} +
+
+
+ +

{deck.title}

+
+ {#if deck.description} +

{deck.description}

+ {/if} +
+ +
+ +
+ + {#if dueCount === 0 && cards.length > 0} + Heute alles gelernt — schau später wieder rein. + {/if} +
+ +
+
+
{cards.length}
+
Karten
+
+
+
{dueCount}
+
Fällig
+
+
+ +
+ +
+ + {#if showNew} +
+

Neue Karte

+ +
+ {#each cardTypeOptions as opt (opt.value)} + + {/each} +
+ +
+ {#if newType === 'cloze'} +
+ + + +

+ Markiere mit + {{c1::Wort}} + — optional Hinweis: ::Hinweis. +

+
+ {:else} +
+ + + +
+
+ + +
+ {/if} +
+ + +
+
+
+ {/if} + +
+

+ Karten ({cards.length}) +

+ {#if cards.length === 0} +
+ Noch keine Karten. Erstelle deine erste! +
+ {:else} +
    + {#each cards as card, i (card.id)} + {@const p = preview(card)} +
  • + {i + 1}. +
    +
    + {@html renderMarkdown(p.primary)} +
    + {#if p.secondary} +
    + {@html renderMarkdown(p.secondary)} +
    + {/if} +
    +
    + + {typeBadge(card.type)} + + +
    +
  • + {/each} +
+ {/if} +
+ + {#if confirmDelete} +
(confirmDelete = false)} + onkeydown={(e) => e.key === 'Escape' && (confirmDelete = false)} + role="presentation" + > + + +
e.stopPropagation()} + > +

Deck löschen?

+

+ "{deck.title}" wird mit allen Karten gelöscht. +

+
+ + +
+
+
+ {/if} + {:else} +
+ Deck nicht gefunden. + zurück +
+ {/if} +
diff --git a/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte new file mode 100644 index 000000000..3d730a667 --- /dev/null +++ b/apps/cards/apps/web/src/routes/learn/[deckId]/+page.svelte @@ -0,0 +1,180 @@ + + + + Lernen — {deckTitle} — Cards + + +
+
+
+ +

Lernen

+
+ {#if queue.length > 0 && !finished} +
+ {Math.min(currentIndex + 1, queue.length)} / {queue.length} +
+ {/if} +
+ + {#if empty} +
+
Alles gelernt
+

+ Komm später wieder — fällige Karten erscheinen automatisch. +

+ +
+ {:else if finished} +
+
Session abgeschlossen
+

+ {sessionCount} Karten in {Math.round((Date.now() - sessionStartedAt) / 1000)} s. +

+ +
+ {:else if current} + (typedAnswer = v)} + /> + + {#if !showBack} + + {:else} +
+ + + + +
+ {/if} + {:else} +
Lade…
+ {/if} +
diff --git a/apps/cards/apps/web/src/routes/login/+page.svelte b/apps/cards/apps/web/src/routes/login/+page.svelte new file mode 100644 index 000000000..b09129a41 --- /dev/null +++ b/apps/cards/apps/web/src/routes/login/+page.svelte @@ -0,0 +1,35 @@ + + + diff --git a/apps/cards/apps/web/src/routes/register/+page.svelte b/apps/cards/apps/web/src/routes/register/+page.svelte new file mode 100644 index 000000000..098ca4162 --- /dev/null +++ b/apps/cards/apps/web/src/routes/register/+page.svelte @@ -0,0 +1,27 @@ + + + diff --git a/apps/cards/apps/web/static/favicon.svg b/apps/cards/apps/web/static/favicon.svg new file mode 100644 index 000000000..1f160f709 --- /dev/null +++ b/apps/cards/apps/web/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/cards/apps/web/svelte.config.js b/apps/cards/apps/web/svelte.config.js new file mode 100644 index 000000000..fc92816a8 --- /dev/null +++ b/apps/cards/apps/web/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, +}; + +export default config; diff --git a/apps/cards/apps/web/tsconfig.json b/apps/cards/apps/web/tsconfig.json new file mode 100644 index 000000000..9637d322e --- /dev/null +++ b/apps/cards/apps/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true + } +} diff --git a/apps/cards/apps/web/vite.config.ts b/apps/cards/apps/web/vite.config.ts new file mode 100644 index 000000000..da1f9251e --- /dev/null +++ b/apps/cards/apps/web/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], +}); diff --git a/apps/cards/package.json b/apps/cards/package.json new file mode 100644 index 000000000..be4804ca0 --- /dev/null +++ b/apps/cards/package.json @@ -0,0 +1,9 @@ +{ + "name": "cards", + "version": "0.1.0", + "private": true, + "description": "Cards — Spaced-Repetition flashcards on cards.mana.how. Standalone Phase-1 frontend; data shared with the mana cards module via mana-sync.", + "scripts": { + "dev": "pnpm run --filter=@cards/* --parallel dev" + } +} diff --git a/apps/cards/supabase/migrations/remove_study_sessions_user_fkey.sql b/apps/cards/supabase/migrations/remove_study_sessions_user_fkey.sql deleted file mode 100644 index 3e74a8a2d..000000000 --- a/apps/cards/supabase/migrations/remove_study_sessions_user_fkey.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Remove foreign key constraint on study_sessions.user_id --- This allows storing Mana Core user IDs without requiring a local users table -ALTER TABLE study_sessions DROP CONSTRAINT IF EXISTS study_sessions_user_id_fkey; - --- Remove foreign key constraint on card_progress.user_id -ALTER TABLE card_progress DROP CONSTRAINT IF EXISTS card_progress_user_id_fkey; - --- Add comments to document that user_id references Mana Core users -COMMENT ON COLUMN study_sessions.user_id IS 'User ID from Mana Core authentication system'; -COMMENT ON COLUMN card_progress.user_id IS 'User ID from Mana Core authentication system'; diff --git a/apps/cards/supabase/migrations/remove_user_fkey.sql b/apps/cards/supabase/migrations/remove_user_fkey.sql deleted file mode 100644 index b9aab2551..000000000 --- a/apps/cards/supabase/migrations/remove_user_fkey.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Remove foreign key constraint on decks.user_id --- This allows storing Mana Core user IDs without requiring a local users table -ALTER TABLE decks DROP CONSTRAINT IF EXISTS decks_user_id_fkey; - --- Optional: Add a comment to document that user_id references Mana Core users -COMMENT ON COLUMN decks.user_id IS 'User ID from Mana Core authentication system'; diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index 9d1a08bad..58bd77768 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -58,15 +58,16 @@ "@mana/qr-export": "workspace:*", "@mana/shared-ai": "workspace:*", "@mana/shared-auth": "workspace:*", - "@mana/shared-crypto": "workspace:*", "@mana/shared-auth-ui": "workspace:*", "@mana/shared-branding": "workspace:*", + "@mana/shared-crypto": "workspace:*", "@mana/shared-error-tracking": "workspace:*", "@mana/shared-i18n": "workspace:*", "@mana/shared-icons": "workspace:*", "@mana/shared-links": "workspace:*", "@mana/shared-llm": "workspace:*", "@mana/shared-privacy": "workspace:*", + "@mana/cards-core": "workspace:*", "@mana/shared-stores": "workspace:*", "@mana/shared-tags": "workspace:*", "@mana/shared-tailwind": "workspace:*", @@ -89,6 +90,7 @@ "@types/suncalc": "^1.9.2", "date-fns": "^4.1.0", "dexie": "^4.0.11", + "isomorphic-dompurify": "^3.7.1", "marked": "^17.0.5", "pako": "^2.1.0", "pdf-lib": "^1.17.1", @@ -98,6 +100,7 @@ "svelte-i18n": "^4.0.0", "svelte-sonner": "^1.0.5", "swissqrbill": "^4.3.0", + "ts-fsrs": "^5.3.2", "zod": "^3.25.76" }, "type": "module" diff --git a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts index 2c5704f93..6802c6cdc 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/plaintext-allowlist.ts @@ -30,6 +30,8 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [ 'budgets', // TODO: audit 'calculations', // TODO: audit 'calendars', // TODO: audit + 'cardReviews', // FSRS scheduling state (numbers + due timestamp). No user-typed text — query path scans by `due` to find what's fällig today. + 'cardStudyBlocks', // Daily activity aggregate (date + counters). No user-typed content. 'ccFavorites', // TODO: audit 'ccLocationTags', // TODO: audit 'ccLocations', // TODO: audit diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 87729de1f..9e46f757e 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -224,10 +224,13 @@ export const ENCRYPTION_REGISTRY: Record = { plants: { enabled: true, fields: ['name', 'careNotes', 'temperature', 'soilType'] }, // ─── Cards ─────────────────────────────────────────────── - // `cards` has no `notes` column on LocalCard — only front + back are - // user content. cardDecks uses `name` (not `title`) on the schema - // even though the public DTO translates it to `title`. - cards: { enabled: true, fields: ['front', 'back'] }, + // User-typed content lives in three places on LocalCard: + // - legacy `front`/`back` columns (pre-Phase-0 rows still use them) + // - new `fields` map (Phase 0+, holds basic.{front,back} or cloze.{text,extra}) + // All three encrypt; wrapValue handles object payloads transparently. + // cardDecks uses `name` (not `title`) on the schema even though the + // public DTO translates it to `title`. + cards: { enabled: true, fields: ['front', 'back', 'fields'] }, cardDecks: { enabled: true, fields: ['name', 'description'] }, // ─── Presi ─────────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 95f89dae5..77b7e5d38 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1493,6 +1493,28 @@ db.version(60).stores({ articleImportItems: 'id, jobId, [jobId+state], idx', }); +// Schema version 61 — Cards Phase 0: FSRS scheduling. +// +// Two new tables back the new spaced-repetition pipeline: +// +// - `cardReviews`: FSRS state per learnable unit. A basic card has one +// row (subIndex=0); basic-reverse has two; cloze has one per cluster. +// Indexes: `cardId` for "all reviews of this card", `due` for the +// hot "what's fällig now" query, `[cardId+subIndex]` for the +// direct lookup the scheduler needs after a rating, `state` for +// deck-stats panels. +// - `cardStudyBlocks`: per-day aggregate (cardsReviewed + durationMs). +// `date` is the only secondary index — the streak query scans the +// last N days. +// +// `cards` itself doesn't change — `type` and `fields` are non-indexed +// columns. Existing v1 indexes (`difficulty`, `nextReview`, `order`, +// `[deckId+order]`) stay for backwards-compat with the legacy renderer. +db.version(61).stores({ + cardReviews: 'id, cardId, due, [cardId+subIndex], state', + cardStudyBlocks: 'id, date', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/modules/cards/ListView.svelte b/apps/mana/apps/web/src/lib/modules/cards/ListView.svelte index 94ecff4bf..09de2b16d 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/cards/ListView.svelte @@ -1,18 +1,18 @@ + +
+
+ {@html view.prompt} +
+ + {#if isTypeIn} + onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)} + disabled={showBack} + /> + {/if} + + {#if showBack} +
+ {@html view.answer} +
+ {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/cards/fsrs.ts b/apps/mana/apps/web/src/lib/modules/cards/fsrs.ts new file mode 100644 index 000000000..bf1264557 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/fsrs.ts @@ -0,0 +1,6 @@ +/** + * Cards module — FSRS wrapper is now sourced from `@mana/cards-core`. + * Thin re-export so existing local imports keep working. + */ + +export { newReview, gradeReview } from '@mana/cards-core'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/module.config.ts b/apps/mana/apps/web/src/lib/modules/cards/module.config.ts index 26d3c2222..98818d4d5 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/module.config.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/module.config.ts @@ -2,5 +2,11 @@ import type { ModuleConfig } from '$lib/data/module-registry'; export const cardsModuleConfig: ModuleConfig = { appId: 'cards', - tables: [{ name: 'cardDecks', syncName: 'decks' }, { name: 'cards' }, { name: 'deckTags' }], + tables: [ + { name: 'cardDecks', syncName: 'decks' }, + { name: 'cards' }, + { name: 'deckTags' }, + { name: 'cardReviews' }, + { name: 'cardStudyBlocks' }, + ], }; diff --git a/apps/mana/apps/web/src/lib/modules/cards/queries.ts b/apps/mana/apps/web/src/lib/modules/cards/queries.ts index 1bdf613e0..166e18145 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/queries.ts @@ -9,7 +9,16 @@ import { deriveUpdatedAt } from '$lib/data/sync'; import { db } from '$lib/data/database'; import { scopedForModule } from '$lib/data/scope'; import { decryptRecord, decryptRecords } from '$lib/data/crypto'; -import type { LocalDeck, LocalCard, Deck, Card } from './types'; +import type { + CardFields, + CardType, + LocalDeck, + LocalCard, + LocalCardReview, + Deck, + Card, + CardReview, +} from './types'; // ─── Type Converters ─────────────────────────────────────── @@ -27,18 +36,49 @@ export function toDeck(local: LocalDeck): Deck { }; } +/** + * Promote any LocalCard row — including legacy pre-Phase-0 ones — to + * the canonical {type, fields} shape. Readers must go through this so + * the rest of the app sees one schema. + * + * - Phase-0+ rows: returned as-is, with `front`/`back` derived from + * fields for the convenience accessors on the DTO. + * - Legacy rows (only `front`/`back` set): synthesised as + * {type: 'basic', fields: {front, back}}. + */ +export function toLogicalCard(local: LocalCard): { + type: CardType; + fields: CardFields; + front: string; + back: string; +} { + const type: CardType = local.type ?? 'basic'; + const fields: CardFields = local.fields ?? { + front: local.front ?? '', + back: local.back ?? '', + }; + const front = fields.front ?? local.front ?? ''; + const back = fields.back ?? local.back ?? ''; + return { type, fields, front, back }; +} + export function toCard(local: LocalCard): Card { + const { type, fields, front, back } = toLogicalCard(local); return { id: local.id, deckId: local.deckId, - front: local.front, - back: local.back, - difficulty: local.difficulty, - nextReview: local.nextReview ?? undefined, - reviewCount: local.reviewCount, + type, + fields, + front, + back, order: local.order, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local), + // Legacy fields surfaced for pre-Phase-0 UI. Populated only when the + // underlying row carries them. + difficulty: local.difficulty, + nextReview: local.nextReview ?? undefined, + reviewCount: local.reviewCount, }; } @@ -76,6 +116,71 @@ export function useCardsByDeck(deckId: string) { }); } +/** + * All reviews that are due now (or overdue), optionally filtered by + * deck. Joined with the parent card so the UI can render the prompt + * immediately without a second lookup. + * + * Sorted by `due` ascending so the oldest-due learnable unit comes + * first — that's the natural session order. + */ +export function useDueReviews(deckId?: string) { + return liveQuery(async () => { + const nowIso = new Date().toISOString(); + const due = await db + .table('cardReviews') + .where('due') + .belowOrEqual(nowIso) + .toArray(); + const live = due.filter((r) => !r.deletedAt); + if (live.length === 0) return [] as { review: CardReview; card: Card }[]; + + const cardIds = [...new Set(live.map((r) => r.cardId))]; + const cardRows = await db.table('cards').where('id').anyOf(cardIds).toArray(); + const decryptedCards = await decryptRecords( + 'cards', + cardRows.filter((c) => !c.deletedAt) + ); + const cardById = new Map(decryptedCards.map((c) => [c.id, toCard(c)] as const)); + + return live + .filter((r) => { + const c = cardById.get(r.cardId); + if (!c) return false; + if (deckId && c.deckId !== deckId) return false; + return true; + }) + .sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0)) + .map((r) => ({ review: toCardReview(r), card: cardById.get(r.cardId)! })); + }); +} + +/** Just the reviews row, no card join — useful in the session UI mid-grade. */ +export function useReview(reviewId: string) { + return liveQuery(async () => { + const r = await db.table('cardReviews').get(reviewId); + if (!r || r.deletedAt) return null; + return toCardReview(r); + }); +} + +function toCardReview(r: LocalCardReview): CardReview { + return { + id: r.id, + cardId: r.cardId, + subIndex: r.subIndex, + state: r.state, + stability: r.stability, + difficulty: r.difficulty, + due: r.due, + reps: r.reps, + lapses: r.lapses, + lastReview: r.lastReview, + elapsedDays: r.elapsedDays, + scheduledDays: r.scheduledDays, + }; +} + // ─── Pure Helper Functions ───────────────────────────────── export function getDeckById(decks: Deck[], id: string): Deck | undefined { diff --git a/apps/mana/apps/web/src/lib/modules/cards/render.ts b/apps/mana/apps/web/src/lib/modules/cards/render.ts new file mode 100644 index 000000000..d37a1fcb5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/render.ts @@ -0,0 +1,7 @@ +/** + * Cards module — Markdown render helper is now sourced from + * `@mana/cards-core`. Thin re-export so existing local imports keep + * working. + */ + +export { renderMarkdown, type RenderOptions } from '@mana/cards-core'; diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts index 762073c7a..bde4e6ff0 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/cards.svelte.ts @@ -3,17 +3,58 @@ * * Reads come from liveQuery hooks in queries.ts. * This store only handles writes to IndexedDB via the unified database. + * + * Phase 0+: writes the new {type, fields} shape AND mirrors basic-card + * content to the legacy front/back columns so older mana builds keep + * rendering. Every create/update fans out to cardReviews via + * reviewStore.ensureReviewsForCard(). */ import { CardsEvents } from '@mana/shared-utils/analytics'; import { cardTable, cardDeckTable } from '../collections'; -import { toCard } from '../queries'; -import { encryptRecord } from '$lib/data/crypto'; +import { toCard, toLogicalCard } from '../queries'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; -import type { LocalCard, Card, CreateCardInput, UpdateCardInput } from '../types'; +import type { + CardFields, + CardType, + LocalCard, + Card, + CreateCardInput, + UpdateCardInput, +} from '../types'; +import { reviewStore } from './reviews.svelte'; let error = $state(null); +/** + * Build the {type, fields} pair from a CreateCardInput. Accepts the + * convenience `front`/`back` shortcut for basic cards and falls back + * to an explicit `fields` map for cloze and friends. + */ +function resolveTypeAndFields(input: CreateCardInput): { + type: CardType; + fields: CardFields; +} { + const type = input.type ?? 'basic'; + if (input.fields) return { type, fields: input.fields }; + if (type === 'cloze') return { type, fields: { text: input.front ?? '' } }; + return { type, fields: { front: input.front ?? '', back: input.back ?? '' } }; +} + +/** Mirror basic-card text into the legacy columns for older clients. */ +function legacyMirror(type: CardType, fields: CardFields): { front?: string; back?: string } { + if (type === 'basic' || type === 'basic-reverse' || type === 'type-in') { + return { front: fields.front ?? '', back: fields.back ?? '' }; + } + if (type === 'cloze') { + // Surface the cloze source on `front` so legacy list-views show + // something meaningful rather than an empty row. + return { front: fields.text ?? '', back: '' }; + } + return {}; +} + export const cardStore = { get error() { return error; @@ -22,21 +63,22 @@ export const cardStore = { async createCard(input: CreateCardInput, currentCardCount: number = 0): Promise { error = null; try { + const { type, fields } = resolveTypeAndFields(input); + const legacy = legacyMirror(type, fields); + const newLocal: LocalCard = { id: crypto.randomUUID(), deckId: input.deckId, - front: input.front, - back: input.back, - difficulty: 1, - reviewCount: 0, + type, + fields, order: currentCardCount, + ...legacy, }; const plaintextSnapshot = toCard(newLocal); await encryptRecord('cards', newLocal); await cardTable.add(newLocal); - // Update deck card count const deck = await cardDeckTable.get(input.deckId); if (deck) { await cardDeckTable.update(input.deckId, { @@ -44,6 +86,8 @@ export const cardStore = { }); } + await reviewStore.ensureReviewsForCard({ id: newLocal.id, type, fields }); + emitDomainEvent('CardCreated', 'cards', 'cards', newLocal.id, { cardId: newLocal.id, deckId: input.deckId, @@ -60,17 +104,43 @@ export const cardStore = { async updateCard(id: string, updates: UpdateCardInput) { error = null; try { - const localUpdates: Partial = {}; - if (updates.front !== undefined) localUpdates.front = updates.front; - if (updates.back !== undefined) localUpdates.back = updates.back; - if (updates.difficulty !== undefined) localUpdates.difficulty = updates.difficulty; - if (updates.order !== undefined) localUpdates.order = updates.order; + const existing = await cardTable.get(id); + if (!existing) return; + const decrypted = await decryptRecord('cards', { ...existing }); + const current = toLogicalCard(decrypted as LocalCard); + const nextType: CardType = updates.type ?? current.type; + const nextFields: CardFields = updates.fields + ? updates.fields + : updates.front !== undefined || updates.back !== undefined + ? nextType === 'cloze' + ? { ...current.fields, text: updates.front ?? current.fields.text ?? '' } + : { + ...current.fields, + front: updates.front ?? current.fields.front ?? '', + back: updates.back ?? current.fields.back ?? '', + } + : current.fields; + + const legacy = legacyMirror(nextType, nextFields); const diff: Partial = { - ...localUpdates, + type: nextType, + fields: nextFields, + ...legacy, }; + if (updates.order !== undefined) diff.order = updates.order; + if (updates.difficulty !== undefined) diff.difficulty = updates.difficulty; + await encryptRecord('cards', diff); await cardTable.update(id, diff); + + const structuralChange = + updates.type !== undefined || + updates.fields !== undefined || + (nextType === 'cloze' && updates.front !== undefined); + if (structuralChange) { + await reviewStore.ensureReviewsForCard({ id, type: nextType, fields: nextFields }); + } } catch (err: any) { error = err.message || 'Failed to update card'; console.error('Update card error:', err); @@ -82,9 +152,9 @@ export const cardStore = { try { const now = new Date().toISOString(); await cardTable.update(id, { deletedAt: now }); + await reviewStore.softDeleteForCard(id); CardsEvents.cardDeleted(); - // Update deck card count if (deckId) { const deck = await cardDeckTable.get(deckId); if (deck) { @@ -102,7 +172,6 @@ export const cardStore = { async reorderCards(cardIds: string[]) { error = null; try { - const now = new Date().toISOString(); for (let i = 0; i < cardIds.length; i++) { await cardTable.update(cardIds[i], { order: i }); } diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/reviews.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/reviews.svelte.ts new file mode 100644 index 000000000..a7b3e69d0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/reviews.svelte.ts @@ -0,0 +1,97 @@ +/** + * Card-Review Store — FSRS scheduling state. + * + * Reviews are plaintext (no encryptRecord) — cardReviews is in + * `plaintext-allowlist.ts` because the scheduler must query by `due` + * to find what's fällig today. + * + * Three operations the rest of the module needs: + * - ensureReviewsForCard: create the right number of subIndex rows + * for a card, soft-delete obsolete ones (e.g. when a cloze cluster + * gets removed). Idempotent — safe to call after every card edit. + * - grade: apply a user rating, persist the next FSRS state. + * - softDeleteForCard: cascade soft-delete when a card is deleted. + */ + +import { cardReviewTable } from '../collections'; +import { newReview, gradeReview as fsrsGrade } from '../fsrs'; +import { subIndexesFor } from '../card-reviews'; +import type { CardFields, CardType, LocalCardReview, ReviewGrade } from '../types'; + +let error = $state(null); + +export const reviewStore = { + get error() { + return error; + }, + + /** + * Reconcile the cardReviews rows for a card with what the card + * structurally needs. New subIndexes get a fresh review; obsolete + * ones get soft-deleted. Returns the live set of reviews. + */ + async ensureReviewsForCard(card: { + id: string; + type: CardType; + fields: CardFields; + }): Promise { + error = null; + try { + const existing = await cardReviewTable.where('cardId').equals(card.id).toArray(); + const live = existing.filter((r) => !r.deletedAt); + const liveByIdx = new Map(live.map((r) => [r.subIndex, r])); + + const wanted = subIndexesFor(card); + const wantedSet = new Set(wanted); + const nowIso = new Date().toISOString(); + + for (const subIndex of wanted) { + if (!liveByIdx.has(subIndex)) { + const r = newReview({ cardId: card.id, subIndex }); + await cardReviewTable.add(r); + liveByIdx.set(subIndex, r); + } + } + + for (const r of live) { + if (!wantedSet.has(r.subIndex)) { + await cardReviewTable.update(r.id, { deletedAt: nowIso }); + liveByIdx.delete(r.subIndex); + } + } + + return [...liveByIdx.values()].sort((a, b) => a.subIndex - b.subIndex); + } catch (err: any) { + error = err.message || 'Failed to ensure reviews'; + console.error('Ensure reviews error:', err); + return []; + } + }, + + async grade(reviewId: string, grade: ReviewGrade): Promise { + error = null; + try { + const existing = await cardReviewTable.get(reviewId); + if (!existing) return null; + const next = fsrsGrade(existing, grade); + await cardReviewTable.put(next); + return next; + } catch (err: any) { + error = err.message || 'Failed to grade review'; + console.error('Grade review error:', err); + return null; + } + }, + + async softDeleteForCard(cardId: string): Promise { + const reviews = await cardReviewTable.where('cardId').equals(cardId).toArray(); + const now = new Date().toISOString(); + for (const r of reviews) { + if (!r.deletedAt) await cardReviewTable.update(r.id, { deletedAt: now }); + } + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/study-blocks.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/study-blocks.svelte.ts new file mode 100644 index 000000000..888d537b0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/study-blocks.svelte.ts @@ -0,0 +1,83 @@ +/** + * Study-Block Store — daily aggregate of learning activity. + * + * One row per local date with counters. The streak query walks back + * from today; finding a gap (no row, or cardsReviewed=0) ends the + * streak. Plaintext, no encryption. + * + * Why a daily aggregate row instead of just summing cardReviews? + * Because the streak is a UI-hot read — we want it cheap (≤ 30 row + * lookups) regardless of how many reviews exist in total. + */ + +import { cardStudyBlockTable } from '../collections'; +import type { LocalCardStudyBlock } from '../types'; + +let error = $state(null); + +function localDateKey(d: Date = new Date()): string { + // YYYY-MM-DD in the user's local timezone — matches LocalCardStudyBlock.date. + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export const studyBlockStore = { + get error() { + return error; + }, + + /** + * Record one review against today's block. Creates the row on the + * first review of the day. Idempotent across concurrent calls only + * within a Dexie transaction — for now we accept the small chance of + * an off-by-one race; real users grade one card at a time. + */ + async recordReview(durationMs: number, count: number = 1): Promise { + error = null; + try { + const date = localDateKey(); + const existing = await cardStudyBlockTable.where('date').equals(date).first(); + if (existing && !existing.deletedAt) { + await cardStudyBlockTable.update(existing.id, { + cardsReviewed: existing.cardsReviewed + count, + durationMs: existing.durationMs + durationMs, + }); + } else { + const row: LocalCardStudyBlock = { + id: crypto.randomUUID(), + date, + cardsReviewed: count, + durationMs, + }; + await cardStudyBlockTable.add(row); + } + } catch (err: any) { + error = err.message || 'Failed to record review'; + console.error('Record review error:', err); + } + }, + + /** + * Walk back from today; return how many consecutive days have at + * least one reviewed card. Stops at the first gap. Caps at 365 days + * to keep the worst case bounded. + */ + async getRecentStreak(): Promise { + const today = new Date(); + let streak = 0; + for (let i = 0; i < 365; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const row = await cardStudyBlockTable.where('date').equals(localDateKey(d)).first(); + if (!row || row.deletedAt || row.cardsReviewed <= 0) break; + streak++; + } + return streak; + }, + + clearError() { + error = null; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cards/types.ts b/apps/mana/apps/web/src/lib/modules/cards/types.ts index be2d5aaeb..d450b73c1 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/types.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/types.ts @@ -1,78 +1,24 @@ /** - * Cards module types for the unified app. + * Cards module — types are now sourced from `@mana/cards-core` so the + * standalone cards.mana.how app and this in-mana module stay in sync. + * + * This file is a thin re-export to keep existing + * `from './types'` / `from '$lib/modules/cards/types'` imports working. */ -import type { BaseRecord } from '@mana/local-store'; -import type { VisibilityLevel } from '@mana/shared-privacy'; - -export interface LocalDeck extends BaseRecord { - name: string; - description?: string | null; - color: string; - cardCount: number; - lastStudied?: string | null; - visibility?: VisibilityLevel; - visibilityChangedAt?: string; - visibilityChangedBy?: string; - activeStudyBlockId?: string | null; -} - -export interface LocalCard extends BaseRecord { - deckId: string; - front: string; - back: string; - difficulty: number; // 1-5 - nextReview?: string | null; - reviewCount: number; - order: number; -} - -// ─── View Types (inline to avoid @cards/shared dependency) ── - -export interface Deck { - id: string; - title: string; - description?: string; - color: string; - visibility: VisibilityLevel; - tags: string[]; - cardCount: number; - createdAt: string; - updatedAt: string; -} - -export interface Card { - id: string; - deckId: string; - front: string; - back: string; - difficulty: number; - nextReview?: string; - reviewCount: number; - order: number; - createdAt: string; - updatedAt: string; -} - -export interface CreateDeckInput { - title: string; - description?: string; -} - -export interface UpdateDeckInput { - title?: string; - description?: string; -} - -export interface CreateCardInput { - deckId: string; - front: string; - back: string; -} - -export interface UpdateCardInput { - front?: string; - back?: string; - difficulty?: number; - order?: number; -} +export type { + CardType, + CardFields, + LocalDeck, + LocalCard, + LocalCardReview, + LocalCardStudyBlock, + Deck, + Card, + CardReview, + CreateDeckInput, + UpdateDeckInput, + CreateCardInput, + UpdateCardInput, + ReviewGrade, +} from '@mana/cards-core'; diff --git a/apps/mana/apps/web/src/routes/(app)/cards/decks/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/decks/[id]/+page.svelte index dc837bf67..fb1ca5de6 100644 --- a/apps/mana/apps/web/src/routes/(app)/cards/decks/[id]/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/cards/decks/[id]/+page.svelte @@ -1,11 +1,11 @@ - {deck?.title || 'Deck'} - Cards - Mana + {deck?.title || 'Deck'} — Cards — Mana {#if deck}
- @@ -92,9 +143,8 @@
{#if deck.visibility === 'public'} - - Offentlich - + Öffentlich {/if}
+ +
+ + {#if dueCount === 0 && cards.length > 0} + + Heute alles gelernt — schau später wieder rein. + + {/if} +
+ -
+
{cards.length}
Karten gesamt
-
- {cards.filter((c) => c.difficulty <= 2).length} -
-
Einfach
-
-
-
- {cards.filter((c) => c.difficulty >= 4).length} -
-
Schwierig
+
{dueCount}
+
Fällig
@@ -148,32 +209,71 @@ {#if showNewCardForm}

Neue Karte

+ + +
+ {#each cardTypeOptions as opt} + + {/each} +
+
-
- - - -
-
- - -
+ {#if newCardType === 'cloze'} +
+ + + +

+ Markiere mit + {{c1::Wort}} + — optional Hinweis: ::Hinweis. +

+
+ {:else} +
+ + + +
+
+ + +
+ {/if}
@@ -210,32 +311,33 @@ class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white" onclick={() => (showNewCardForm = true)} > - Karte hinzufugen + Karte hinzufügen
{:else}
{#each cards as card, i (card.id)} + {@const preview = previewSummary(card)}
{i + 1}. -
-
{card.front}
-
{card.back}
+
+
+ {@html renderMarkdown(preview.primary)} +
+ {#if preview.secondary} +
+ {@html renderMarkdown(preview.secondary)} +
+ {/if}
- - {card.difficulty}/5 + + {typeBadge(card.type)} @@ -261,10 +363,10 @@ class="mx-4 w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl" onclick={(e) => e.stopPropagation()} > -

Deck loschen?

+

Deck löschen?

- Mochtest du "{deck.title}" wirklich loschen? Diese Aktion kann nicht ruckgangig - gemacht werden und loscht auch alle Karten in diesem Deck. + Möchtest du "{deck.title}" wirklich löschen? Diese Aktion kann nicht rückgängig + gemacht werden und löscht auch alle Karten in diesem Deck.

@@ -292,12 +394,11 @@ class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white" onclick={() => goto('/cards/decks')} > - Zuruck zu Decks + Zurück zu Decks
{/if} - (showShare = false)} diff --git a/apps/mana/apps/web/src/routes/(app)/cards/learn/[deckId]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cards/learn/[deckId]/+page.svelte new file mode 100644 index 000000000..5a16ba8dc --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cards/learn/[deckId]/+page.svelte @@ -0,0 +1,175 @@ + + +
+
+
+ +

Lernen

+
+ {#if queue.length > 0 && !finished} +
+ {Math.min(currentIndex + 1, queue.length)} / {queue.length} +
+ {/if} +
+ + {#if empty} +
+
Alles gelernt
+

+ Komm später wieder — fällige Karten erscheinen automatisch. +

+ +
+ {:else if finished} +
+
Session abgeschlossen
+

+ {sessionCount} Karten in {Math.round((Date.now() - sessionStartedAt) / 1000)} s. +

+ +
+ {:else if current} + (typedAnswer = v)} + /> + + {#if !showBack} + + {:else} +
+ + + + +
+ {/if} + {:else} +
Lade…
+ {/if} +
diff --git a/cloudflared-config.yml b/cloudflared-config.yml index 6b8aa66fd..531c5842c 100644 --- a/cloudflared-config.yml +++ b/cloudflared-config.yml @@ -56,8 +56,11 @@ ingress: service: http://localhost:5000 - hostname: plants.mana.how service: http://localhost:5000 + # cards.mana.how → standalone Cards SvelteKit container (apps/cards/apps/web). + # Was pointed at :5000 (the unified mana-web) until the standalone spinoff + # landed. mana.how/cards still serves the in-mana cards module. - hostname: cards.mana.how - service: http://localhost:5000 + service: http://localhost:5180 - hostname: storage.mana.how service: http://localhost:5000 - hostname: presi.mana.how diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 6c1a15d79..edc3b87ae 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -296,7 +296,7 @@ services: # Enforced by services/mana-auth/src/auth/sso-config.spec.ts. # All productivity modules now live under mana.how (path-based) — # no per-module subdomain entries required here. - CORS_ORIGINS: https://mana.how,https://auth.mana.how,https://whopxl.mana.how + CORS_ORIGINS: https://mana.how,https://auth.mana.how,https://whopxl.mana.how,https://cards.mana.how ports: - "3001:3001" healthcheck: @@ -1057,6 +1057,36 @@ services: retries: 3 start_period: 45s + cards-web: + # Standalone Cards frontend on cards.mana.how — separate SvelteKit + # container that consumes the same mana-sync 'cards' app-id as the + # in-mana cards module. See apps/cards/GUIDELINES.md. + build: + context: . + dockerfile: apps/cards/apps/web/Dockerfile + image: cards-web:local + container_name: mana-app-cards-web + restart: always + mem_limit: 128m + depends_on: + mana-auth: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 5180 + PUBLIC_MANA_AUTH_URL: http://mana-auth:3001 + PUBLIC_MANA_AUTH_URL_CLIENT: https://auth.mana.how + PUBLIC_MANA_SYNC_URL: http://mana-sync:3050 + PUBLIC_MANA_SYNC_URL_CLIENT: https://sync.mana.how + ports: + - "5180:5180" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5180/"] + interval: 180s + timeout: 10s + retries: 3 + start_period: 45s + uload-server: build: context: apps/uload/apps/server diff --git a/packages/cards-core/package.json b/packages/cards-core/package.json new file mode 100644 index 000000000..3743a16ad --- /dev/null +++ b/packages/cards-core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@mana/cards-core", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "description": "Pure utilities for the Cards product: types, FSRS wrapper, Cloze parser, Markdown render. Consumed by both the mana cards module and the cards.mana.how standalone app.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "dependencies": { + "@mana/local-store": "workspace:*", + "@mana/shared-privacy": "workspace:*", + "isomorphic-dompurify": "^3.7.1", + "marked": "^17.0.5", + "ts-fsrs": "^5.3.2" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.9.3", + "vitest": "^4.1.3" + } +} diff --git a/packages/cards-core/src/card-reviews.test.ts b/packages/cards-core/src/card-reviews.test.ts new file mode 100644 index 000000000..86db2bd73 --- /dev/null +++ b/packages/cards-core/src/card-reviews.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { subIndexesFor } from './card-reviews'; + +describe('subIndexesFor', () => { + it('basic → [0]', () => { + expect(subIndexesFor({ type: 'basic', fields: { front: 'a', back: 'b' } })).toEqual([0]); + }); + + it('type-in → [0]', () => { + expect(subIndexesFor({ type: 'type-in', fields: { front: 'a', back: 'b' } })).toEqual([0]); + }); + + it('basic-reverse → [0, 1]', () => { + expect(subIndexesFor({ type: 'basic-reverse', fields: { front: 'a', back: 'b' } })).toEqual([ + 0, 1, + ]); + }); + + it('cloze → cluster indexes', () => { + expect( + subIndexesFor({ + type: 'cloze', + fields: { text: '{{c1::Berlin}} ist Hauptstadt von {{c2::Deutschland}}.' }, + }) + ).toEqual([1, 2]); + }); + + it('cloze with no clusters falls back to [1]', () => { + expect(subIndexesFor({ type: 'cloze', fields: { text: '' } })).toEqual([1]); + expect(subIndexesFor({ type: 'cloze', fields: { text: 'no clozes here' } })).toEqual([1]); + }); + + it('cloze deduplicates repeated clusters', () => { + expect( + subIndexesFor({ + type: 'cloze', + fields: { text: '{{c1::a}} und {{c1::b}} und {{c2::c}}' }, + }) + ).toEqual([1, 2]); + }); + + it('phase-2 types stub to [0] (no crash)', () => { + expect(subIndexesFor({ type: 'image-occlusion', fields: {} })).toEqual([0]); + expect(subIndexesFor({ type: 'audio', fields: {} })).toEqual([0]); + expect(subIndexesFor({ type: 'multiple-choice', fields: {} })).toEqual([0]); + }); +}); diff --git a/packages/cards-core/src/card-reviews.ts b/packages/cards-core/src/card-reviews.ts new file mode 100644 index 000000000..0c1186cd4 --- /dev/null +++ b/packages/cards-core/src/card-reviews.ts @@ -0,0 +1,30 @@ +/** + * Per-card-type review fan-out. + * + * Different card types produce different numbers of learnable units: + * - basic / type-in: one (subIndex 0) + * - basic-reverse: two (0=front→back, 1=back→front) + * - cloze: one per distinct cluster (subIndex = cluster idx) + */ + +import { clusterIndexes } from './cloze'; +import type { CardFields, CardType } from './types'; + +export function subIndexesFor(input: { type: CardType; fields: CardFields }): number[] { + switch (input.type) { + case 'basic': + case 'type-in': + return [0]; + case 'basic-reverse': + return [0, 1]; + case 'cloze': { + const text = input.fields.text ?? ''; + const idx = clusterIndexes(text); + return idx.length > 0 ? idx : [1]; + } + case 'image-occlusion': + case 'audio': + case 'multiple-choice': + return [0]; + } +} diff --git a/packages/cards-core/src/cloze.test.ts b/packages/cards-core/src/cloze.test.ts new file mode 100644 index 000000000..11c08e523 --- /dev/null +++ b/packages/cards-core/src/cloze.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { tokenize, clusterIndexes, clusters, renderCloze } from './cloze'; + +describe('tokenize', () => { + it('splits plain text and clusters', () => { + const tokens = tokenize('A {{c1::B}} C'); + expect(tokens).toEqual([ + { kind: 'text', value: 'A ' }, + { kind: 'cluster', index: 1, answer: 'B', hint: undefined }, + { kind: 'text', value: ' C' }, + ]); + }); + + it('captures hints', () => { + const [t] = tokenize('{{c1::Berlin::Hauptstadt}}'); + expect(t).toEqual({ kind: 'cluster', index: 1, answer: 'Berlin', hint: 'Hauptstadt' }); + }); + + it('handles multiple clusters in one source', () => { + const tokens = tokenize('{{c1::Berlin}} ist die Hauptstadt von {{c2::Deutschland}}.'); + const indexes = tokens.filter((t) => t.kind === 'cluster').map((t) => (t as any).index); + expect(indexes).toEqual([1, 2]); + }); + + it('passes through unmatched braces', () => { + const tokens = tokenize('foo {bar} baz'); + expect(tokens).toEqual([{ kind: 'text', value: 'foo {bar} baz' }]); + }); + + it('survives multi-line input', () => { + const tokens = tokenize('Line 1\n{{c1::x}}\nLine 3'); + expect(tokens.length).toBe(3); + expect((tokens[1] as any).answer).toBe('x'); + }); +}); + +describe('clusterIndexes', () => { + it('returns ascending unique indexes', () => { + expect(clusterIndexes('{{c2::a}} {{c1::b}} {{c2::c}} {{c3::d}}')).toEqual([1, 2, 3]); + }); + + it('returns empty for plain text', () => { + expect(clusterIndexes('no clozes here')).toEqual([]); + }); +}); + +describe('clusters', () => { + it('groups answers under their cluster', () => { + const result = clusters('{{c1::a}} {{c1::b}} {{c2::c}}'); + expect(result).toEqual([ + { index: 1, answers: ['a', 'b'] }, + { index: 2, answers: ['c'] }, + ]); + }); +}); + +describe('renderCloze', () => { + it('blanks the hidden cluster on front, reveals on back', () => { + const r = renderCloze('{{c1::Berlin}} ist die Hauptstadt von {{c2::Deutschland}}.', 1); + expect(r.front).toContain('[…]'); + expect(r.front).toContain('Deutschland'); + expect(r.back).toContain('Berlin'); + expect(r.back).toContain('cloze-active'); + expect(r.answer).toBe('Berlin'); + }); + + it('uses hint when present', () => { + const r = renderCloze('{{c1::Berlin::Hauptstadt}} ist eine Stadt.', 1); + expect(r.front).toContain('[Hauptstadt]'); + }); + + it('blanks every occurrence of the hidden cluster', () => { + const r = renderCloze('{{c1::x}} und {{c1::x}}', 1); + const blanks = r.front.match(/cloze-blank/g) ?? []; + expect(blanks.length).toBe(2); + }); + + it('escapes HTML in user content', () => { + const r = renderCloze('{{c1::