Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 894edfbac9 | |||
| 1f60f1c908 |
@@ -0,0 +1,80 @@
|
|||||||
|
# /deploy — Falah OS Deployment Command
|
||||||
|
|
||||||
|
Deploy Falah OS CE to the production Docker host at 192.168.0.17 via SSH.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Docker host:** 192.168.0.17 (LAN only, not reachable from internet)
|
||||||
|
- **Portainer UI:** http://192.168.0.17:9000 (credentials: Admin / bizgEh-xirgyh-3mowta)
|
||||||
|
- **Repo:** https://github.com/falah-consultancy-limited/falah-os-master (branch: main)
|
||||||
|
- **Deploy method:** SSH into host → clone repo → docker compose up --build
|
||||||
|
- **Deploy script:** `scripts/deploy-ssh.sh` (already committed to main)
|
||||||
|
|
||||||
|
## Pre-generated secrets (already embedded in deploy-ssh.sh)
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT_SECRET=iLlAXxewIvlPlqv0pj7ITk0dFV0FNi0gVDE5sliX8AwjjxQC
|
||||||
|
ENCRYPTION_KEY=usYw2LCmKmOpWFuhiAw7X0DVCqRlK9h8oF4bJ4mWqPYiZKdM
|
||||||
|
ADMIN_SECRET=dd8GU158UJaL4krWWlxWq4uey1AhzmSzNolT10e4lXtC3rBT
|
||||||
|
POSTGRES_PASSWORD=vjeDTUdeBpMms3rZINwY4zlDVlDnz6vE
|
||||||
|
REDIS_PASSWORD=BrQkdok11Zfdyu1cCRSGSZYwRk33o20s
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
1. Ensure you are on a machine on the same LAN as 192.168.0.17.
|
||||||
|
2. Make sure your SSH key is accepted by the host (try `ssh root@192.168.0.17 echo ok`).
|
||||||
|
3. Ensure this repo is up to date: `git pull origin main`
|
||||||
|
4. Run the deployment:
|
||||||
|
```bash
|
||||||
|
bash scripts/deploy-ssh.sh root@192.168.0.17
|
||||||
|
```
|
||||||
|
If the SSH user is not root, pass it as an argument:
|
||||||
|
```bash
|
||||||
|
bash scripts/deploy-ssh.sh ubuntu@192.168.0.17
|
||||||
|
```
|
||||||
|
5. After deployment, verify all 7 services are healthy:
|
||||||
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Desktop UI | http://192.168.0.17:3005 |
|
||||||
|
| API Gateway | http://192.168.0.17:3000/health |
|
||||||
|
| Ummah ID | http://192.168.0.17:3001/health |
|
||||||
|
| Wallet | http://192.168.0.17:3002/health |
|
||||||
|
| RAMZ | http://192.168.0.17:3003/health |
|
||||||
|
| Mock-Net | http://192.168.0.17:3004/health |
|
||||||
|
| falahd | http://192.168.0.17:3006/health |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **SSH key refused:** Add your public key to `~/.ssh/authorized_keys` on the host, or use password auth (`ssh -o PasswordAuthentication=yes`).
|
||||||
|
- **git clone fails on host:** The host has no internet. Use `docker context create remote --docker "host=ssh://root@192.168.0.17"` on your local machine and run `docker compose up --build` locally pointing at the remote daemon.
|
||||||
|
- **Port 3005 not responding:** Likely the React app container failed to build. Check logs: `ssh root@192.168.0.17 "cd /opt/falah-os && docker compose logs app --tail=50"`.
|
||||||
|
- **Port 3006 not responding:** falahd TypeScript build failed. Check: `ssh root@192.168.0.17 "cd /opt/falah-os && docker compose logs falahd --tail=50"`.
|
||||||
|
- **api-gateway not healthy:** It depends on all 6 upstream services being healthy. Fix the failing upstream first.
|
||||||
|
|
||||||
|
## Services architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
:3000 api-gateway (nginx) — routes all /ummahid /wallet /ramz /mocknet /falahd traffic
|
||||||
|
:3001 ummahid — identity, ZK proof, JWT auth
|
||||||
|
:3002 wallet — FLH token, transfers, 1.5% fee
|
||||||
|
:3003 ramz — Shariah contracts (6 templates, 7 rules)
|
||||||
|
:3004 mocknet — sandbox / chaos testnet
|
||||||
|
:3005 app — React 18 desktop UI (Vite build)
|
||||||
|
:3006 falahd — tRPC daemon: system metrics, app lifecycle
|
||||||
|
:5432 postgres — reserved for v1.4 persistence
|
||||||
|
:6379 redis — reserved for v1.4 persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin access
|
||||||
|
|
||||||
|
All `/api/admin/*` routes require header: `x-admin-secret: dd8GU158UJaL4krWWlxWq4uey1AhzmSzNolT10e4lXtC3rBT`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: list all wallets
|
||||||
|
curl http://192.168.0.17:3002/api/admin/wallets \
|
||||||
|
-H "x-admin-secret: dd8GU158UJaL4krWWlxWq4uey1AhzmSzNolT10e4lXtC3rBT"
|
||||||
|
|
||||||
|
# Example: system metrics via falahd tRPC
|
||||||
|
curl http://192.168.0.17:3006/trpc/system.metrics
|
||||||
|
```
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
# Exclude source dev.db — live DB is on persistent volume at /app/data/dev.db
|
node_modules
|
||||||
prisma/dev.db*
|
.next
|
||||||
prisma/*.db-wal
|
*.db
|
||||||
prisma/*.db-shm
|
.env
|
||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Falah OS v1.3 — Environment Variables
|
||||||
|
# Copy to .env and fill in all values before running.
|
||||||
|
# Generate secrets with: openssl rand -base64 32
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REQUIRED — must be set before starting
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# JWT signing secret — min 32 random characters
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Encryption key for sensitive data — min 32 random characters
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Admin secret for privileged operations
|
||||||
|
ADMIN_SECRET=
|
||||||
|
|
||||||
|
# PostgreSQL password
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
|
# Redis password
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# iBaaS API Key for EE Gateway
|
||||||
|
IBAAS_API_KEY=falah-os-ibaas-key-2026
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OPTIONAL — have sensible defaults
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Domain name and protocol for URL generation
|
||||||
|
DOMAIN=
|
||||||
|
PROTOCOL=https
|
||||||
|
|
||||||
|
# Protocol fee taken by Falah OS (default 1.5%)
|
||||||
|
PROTOCOL_FEE_PERCENT=1.5
|
||||||
|
|
||||||
|
# Shariah rules enforced by RAMZ (comma-separated, no spaces)
|
||||||
|
SHARIAH_RULES=NO_RIBA,NO_GHARAR,NO_MAYSIR,HALAL_COMMODITY,ASSET_BACKED,MUTUAL_CONSENT,NO_BAI_INAH
|
||||||
|
|
||||||
|
# Set to true only in a controlled test environment
|
||||||
|
ENABLE_CHAOS_TESTING=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# POSTGRES (optional overrides)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
# Derived from the above; override per-service database name as needed
|
||||||
|
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/falahdb
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REDIS (optional overrides)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FEATURE FLAGS (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ENABLE_MOCKNET=false
|
||||||
|
ENABLE_DEBUG=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
GRAFANA_PASSWORD=
|
||||||
|
ALERT_EMAIL=
|
||||||
|
SLACK_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BACKUP (optional)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
BACKUP_DIR=/backups
|
||||||
|
S3_BUCKET=
|
||||||
|
RETENTION_DAYS=30
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Falah OS v1.3 Production Environment Template
|
||||||
|
# Copy this to .env and fill in the values
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CORE CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
NODE_ENV=production
|
||||||
|
DOMAIN=falah-os.com
|
||||||
|
PROTOCOL=https
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SECURITY - GENERATE WITH: openssl rand -base64 32
|
||||||
|
# =============================================================================
|
||||||
|
JWT_SECRET=CHANGE_ME_generate_secure_random_string
|
||||||
|
ENCRYPTION_KEY=CHANGE_ME_generate_secure_random_string
|
||||||
|
ADMIN_SECRET=CHANGE_ME_generate_secure_random_string
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE - UPDATE credentials for production
|
||||||
|
# =============================================================================
|
||||||
|
POSTGRES_HOST=postgres-primary
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_strong_password
|
||||||
|
POSTGRES_DB=falahdb
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REDIS - UPDATE password for production
|
||||||
|
# =============================================================================
|
||||||
|
REDIS_HOST=redis-master
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=CHANGE_ME_strong_password
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SERVICE URLs (Internal)
|
||||||
|
# =============================================================================
|
||||||
|
UMMAHID_URL=http://ummahid:3000
|
||||||
|
WALLET_URL=http://wallet:3000
|
||||||
|
RAMZ_URL=http://ramz:3000
|
||||||
|
MOCKNET_URL=http://mocknet:3000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WALLET SERVICE
|
||||||
|
# =============================================================================
|
||||||
|
PROTOCOL_FEE_PERCENT=1.5
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RAMZ CONTRACT ENGINE
|
||||||
|
# =============================================================================
|
||||||
|
SHARIAH_RULES=NO_RIBA,NO_GHARAR,NO_MAYSIR,HALAL_COMMODITY,ASSET_BACKED,MUTUAL_CONSENT,NO_BAI_INAH
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BACKUP CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
BACKUP_DIR=/backups
|
||||||
|
S3_BUCKET=falah-os-backups
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING
|
||||||
|
# =============================================================================
|
||||||
|
GRAFANA_PASSWORD=CHANGE_ME_strong_password
|
||||||
|
ALERT_EMAIL=alerts@falah-os.com
|
||||||
|
SLACK_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CDN & EXTERNAL SERVICES
|
||||||
|
# =============================================================================
|
||||||
|
CDN_API_KEY=
|
||||||
|
CDN_ZONE_ID=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING
|
||||||
|
# =============================================================================
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FEATURE FLAGS
|
||||||
|
# =============================================================================
|
||||||
|
ENABLE_MOCKNET=false
|
||||||
|
ENABLE_CHAOS_TESTING=false
|
||||||
|
ENABLE_DEBUG=false
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
name: Deploy Staging
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: falah-mobile
|
|
||||||
STAGING_HOST: 192.168.0.11
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Generate Prisma client
|
|
||||||
run: npx prisma generate
|
|
||||||
|
|
||||||
- name: Build Next.js app
|
|
||||||
run: npm run build
|
|
||||||
env:
|
|
||||||
DATABASE_URL: "file:./prisma/dev.db"
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
docker build -t ${{ env.IMAGE_NAME }}:staging .
|
|
||||||
docker save ${{ env.IMAGE_NAME }}:staging | gzip > /tmp/${{ env.IMAGE_NAME }}.tar.gz
|
|
||||||
|
|
||||||
- name: Tag current image as rollback target
|
|
||||||
run: |
|
|
||||||
ssh admin@${{ env.STAGING_HOST }} "\
|
|
||||||
docker tag ${{ env.IMAGE_NAME }}:staging ${{ env.IMAGE_NAME }}:rollback 2>/dev/null; \
|
|
||||||
echo 'Rollback tag created'"
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Deploy to staging
|
|
||||||
id: deploy
|
|
||||||
run: |
|
|
||||||
scp /tmp/${{ env.IMAGE_NAME }}.tar.gz admin@${{ env.STAGING_HOST }}:/tmp/
|
|
||||||
ssh admin@${{ env.STAGING_HOST }} << 'ENDSSH'
|
|
||||||
cd /var/services/homes/admin/falah-mobile
|
|
||||||
docker load < /tmp/${{ env.IMAGE_NAME }}.tar.gz
|
|
||||||
docker compose -f docker-compose.staging.yml down
|
|
||||||
docker compose -f docker-compose.staging.yml up -d
|
|
||||||
# Wait for healthy with timeout
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
sleep 2
|
|
||||||
HEALTH=\\$(curl -sf http://localhost:4014/mobile/api/health 2>/dev/null)
|
|
||||||
if echo \"\$HEALTH\" | grep -q '\"status\":\"ok\"'; then
|
|
||||||
echo "✅ Staging is healthy"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Waiting... attempt \$i"
|
|
||||||
done
|
|
||||||
echo "❌ Deployment failed health check"
|
|
||||||
exit 1
|
|
||||||
ENDSSH
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Auto-rollback on failure
|
|
||||||
if: failure() && steps.deploy.outcome == 'failure'
|
|
||||||
run: |
|
|
||||||
echo "🚨 Deployment failed — initiating auto-rollback..."
|
|
||||||
ssh admin@${{ env.STAGING_HOST }} << 'ENDSSH'
|
|
||||||
cd /var/services/homes/admin/falah-mobile
|
|
||||||
if docker images ${{ env.IMAGE_NAME }}:rollback --format '{{.ID}}' | head -1; then
|
|
||||||
echo "🔄 Rolling back to previous image..."
|
|
||||||
docker compose -f docker-compose.staging.yml down
|
|
||||||
docker tag ${{ env.IMAGE_NAME }}:rollback ${{ env.IMAGE_NAME }}:staging
|
|
||||||
docker compose -f docker-compose.staging.yml up -d
|
|
||||||
for i in \$(seq 1 30); do
|
|
||||||
sleep 2
|
|
||||||
HEALTH=\\$(curl -sf http://localhost:4014/mobile/api/health 2>/dev/null)
|
|
||||||
if echo \"\$HEALTH\" | grep -q '\"status\":\"ok\"'; then
|
|
||||||
echo "✅ Rollback successful — service healthy"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "❌ Rollback also failed — manual intervention required"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "⚠️ No rollback image found — keeping current state"
|
|
||||||
docker compose -f docker-compose.staging.yml up -d
|
|
||||||
fi
|
|
||||||
ENDSSH
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Deploy to Netlify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- run: npm install --ignore-scripts
|
||||||
|
- name: Install service dependencies
|
||||||
|
run: |
|
||||||
|
for dir in docker/services/ummahid docker/services/wallet docker/services/ramz docker/services/mocknet; do
|
||||||
|
(cd "$dir" && npm install)
|
||||||
|
done
|
||||||
|
- run: npm test
|
||||||
|
env:
|
||||||
|
JWT_SECRET: test-jwt-secret-32-chars-minimum!
|
||||||
|
ENCRYPTION_KEY: test-encryption-key-32-chars-min!!!
|
||||||
|
ADMIN_SECRET: test-admin-secret
|
||||||
|
POSTGRES_PASSWORD: test-postgres-password
|
||||||
|
REDIS_PASSWORD: test-redis-password
|
||||||
|
NODE_ENV: test
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- run: npm install --ignore-scripts
|
||||||
|
- run: npx netlify-cli deploy --dir=./netlify --functions=./netlify/functions --prod --message "Deploy ${{ github.sha }}"
|
||||||
|
env:
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
|
preview:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- run: npm install --ignore-scripts
|
||||||
|
- run: npx netlify-cli deploy --dir=./netlify --functions=./netlify/functions --message "Preview PR#${{ github.event.pull_request.number }}"
|
||||||
|
env:
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
|
notify-failure:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: failure() && github.event_name == 'push'
|
||||||
|
needs: [test, deploy]
|
||||||
|
steps:
|
||||||
|
- name: Send Slack notification
|
||||||
|
uses: slackapi/slack-github-action@v1.25.0
|
||||||
|
with:
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"text": "🚨 Falah OS Deployment Failed",
|
||||||
|
"blocks": [{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": "*Falah OS Deployment Failed!*\nRepo: ${{ github.repository }}\nBranch: ${{ github.ref_name }}\nCommit: ${{ github.sha }}\nWorkflow: ${{ github.workflow }}"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||||
@@ -1,46 +1,51 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
.opencode/memories.md
|
||||||
|
|
||||||
# dependencies
|
# Build outputs
|
||||||
/node_modules
|
dist/
|
||||||
/.pnp
|
.next/
|
||||||
.pnp.*
|
.netlify/
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# Environment files
|
||||||
/coverage
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
# next.js
|
# IDE
|
||||||
/.next/
|
.vscode/
|
||||||
/out/
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
# production
|
# OS
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
Thumbs.db
|
||||||
|
|
||||||
# debug
|
# Logs
|
||||||
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# Testing
|
||||||
.env*
|
coverage/
|
||||||
|
|
||||||
# vercel
|
# Secrets (DO NOT COMMIT)
|
||||||
.vercel
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
|
||||||
# typescript
|
# Backup files
|
||||||
*.tsbuildinfo
|
*.bak
|
||||||
next-env.d.ts
|
*.sql
|
||||||
.gstack/
|
!infrastructure/postgres/init.sql
|
||||||
|
|
||||||
# generated TTS audio (large binary files — run scripts/generate-tts-local.js locally)
|
# Claude (workspace config, not project code)
|
||||||
public/audio/learn/**/*.m4a
|
.claude/*
|
||||||
public/audio/learn/**/*.wav
|
!.claude/commands/
|
||||||
|
|
||||||
|
# Netlify
|
||||||
|
.functions/
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[submodule "falah-os"]
|
||||||
|
path = falah-os
|
||||||
|
url = https://github.com/maifors/falah-os.git
|
||||||
|
|
||||||
|
[submodule "ulp-portal"]
|
||||||
|
path = ulp-portal
|
||||||
|
url = https://github.com/maifors/ulp-portal.git
|
||||||
|
|
||||||
|
[submodule "falah-os-sdk-py"]
|
||||||
|
path = falah-os-sdk-py
|
||||||
|
url = https://github.com/maifors/falah-os-sdk-py.git
|
||||||
|
|
||||||
|
[submodule "falah-os-sdk"]
|
||||||
|
path = falah-os-sdk
|
||||||
|
url = https://github.com/maifors/falah-os-sdk.git
|
||||||
|
|
||||||
|
[submodule "falah-os-mocknet"]
|
||||||
|
path = falah-os-mocknet
|
||||||
|
url = https://github.com/maifors/falah-os-mocknet.git
|
||||||
|
|
||||||
|
[submodule "alah-os-mocknet"]
|
||||||
|
path = alah-os-mocknet
|
||||||
|
url = https://github.com/maifors/alah-os-mocknet.git
|
||||||
|
|
||||||
|
[submodule "falah-os-ummahid"]
|
||||||
|
path = falah-os-ummahid
|
||||||
|
url = https://github.com/maifors/falah-os-ummahid.git
|
||||||
|
|
||||||
|
[submodule "falah-os-ramz"]
|
||||||
|
path = falah-os-ramz
|
||||||
|
url = https://github.com/maifors/falah-os-ramz.git
|
||||||
|
|
||||||
|
[submodule "falah-os-admin"]
|
||||||
|
path = falah-os-admin
|
||||||
|
url = https://github.com/maifors/falah-os-admin.git
|
||||||
|
|
||||||
|
[submodule "falah-os-app"]
|
||||||
|
path = falah-os-app
|
||||||
|
url = https://github.com/maifors/falah-os-app.git
|
||||||
|
|
||||||
|
[submodule "falah-os-istore"]
|
||||||
|
path = falah-os-istore
|
||||||
|
url = https://github.com/maifors/falah-os-istore.git
|
||||||
|
|
||||||
|
[submodule "falah-os-dev-porta"]
|
||||||
|
path = falah-os-dev-porta
|
||||||
|
url = https://github.com/maifors/falah-os-dev-porta.git
|
||||||
|
|
||||||
|
[submodule "falah-os-landing"]
|
||||||
|
path = falah-os-landing
|
||||||
|
url = https://github.com/maifors/falah-os-landing.git
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Hermes ↔ Pi Alignment Status
|
||||||
|
|
||||||
|
## Communication Method
|
||||||
|
|
||||||
|
| Channel | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| SSH to MacBook Air (192.168.0.8) | ❌ Blocked | Ports closed, auth fails |
|
||||||
|
| Tailscale (100.76.3.26) | ❌ Blocked | Ping works, all ports closed |
|
||||||
|
| Bonjour/mDNS | ✅ Discovered | `wm's MacBook Air` visible |
|
||||||
|
| Gitea Shared Repo | ✅ Working | Used as message board |
|
||||||
|
|
||||||
|
**Resolution:** Created Gitea issue #1 as shared message board.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
### 1. Gitea Repo: `wmj/falah-mobile`
|
||||||
|
- **URL:** `http://13.140.161.244:3080/wmj/falah-mobile`
|
||||||
|
- **Branch:** `feat/learn-module`
|
||||||
|
- **Issue #1:** Hermes Alignment Request
|
||||||
|
|
||||||
|
### 2. Schema Changes
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Course {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
difficulty String
|
||||||
|
imageUrl String?
|
||||||
|
modules Module[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Module {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
courseId String
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
content String // markdown
|
||||||
|
videoUrl String?
|
||||||
|
order Int
|
||||||
|
quizData String? // JSON: [{question, options, correctIndex}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Seed Data
|
||||||
|
|
||||||
|
| Course | Modules | Difficulty |
|
||||||
|
|--------|---------|------------|
|
||||||
|
| Daily Fiqh for Beginners | 5 | beginner |
|
||||||
|
|
||||||
|
Each module includes:
|
||||||
|
- `content`: Markdown with Key Concept, Details, Reflection, Action Step
|
||||||
|
- `quizData`: 1 question with 4 options
|
||||||
|
- `order`: Sequence number
|
||||||
|
|
||||||
|
### 4. Pages Created
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `/learn` | Course listing with difficulty badges |
|
||||||
|
| `/learn/[courseSlug]/[moduleId]` | Module content + quiz |
|
||||||
|
|
||||||
|
### 5. Hermes Patch Applied
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// seed-learn.js
|
||||||
|
modules.map(({ id: _id, courseId: _cid, ...m }) => ({
|
||||||
|
...m,
|
||||||
|
quizData: typeof m.quizData === 'string'
|
||||||
|
? m.quizData
|
||||||
|
: JSON.stringify(m.quizData || []),
|
||||||
|
})),
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps (Pending Hermes)
|
||||||
|
|
||||||
|
| Task | Owner | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Audio player integration | Hermes / Pi | ⏳ Pending |
|
||||||
|
| Progress tracking per user | Hermes | ⏳ Pending |
|
||||||
|
| PostgreSQL schema migration | Hermes | ⏳ Pending |
|
||||||
|
| Merge seed data from MacBook Air | Hermes | ⏳ Pending |
|
||||||
|
| Quiz scoring / validation | Pi | ⏳ Pending |
|
||||||
|
| Branch merge to main | Hermes | ⏳ Pending |
|
||||||
|
|
||||||
|
## How to Sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull Pi's branch
|
||||||
|
git fetch http://13.140.161.244:3080/wmj/falah-mobile feat/learn-module
|
||||||
|
git checkout -b feat/learn-module origin/feat/learn-module
|
||||||
|
|
||||||
|
# Or create PR via Gitea UI
|
||||||
|
# http://13.140.161.244:3080/wmj/falah-mobile/pulls
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Pi** — mac-mini-1 (100.72.2.115)
|
||||||
|
**Hermes** — MacBook Air (100.76.3.26)
|
||||||
|
**Gitea** — git.falahos.my / 13.140.161.244:3080
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# 🤝 Pi ↔ Hermes Collaboration Protocol
|
|
||||||
|
|
||||||
> **Status:** ACTIVE — Hermes connected via `herdr --remote` (client_id=4)
|
|
||||||
> **Workspace:** `w1` (mac-mini-1, 192.168.0.10)
|
|
||||||
> **Hermes Origin:** MacBook Air (192.168.0.8, Tailscale 100.76.3.26)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How We're Connected
|
|
||||||
|
|
||||||
```
|
|
||||||
Hermes (MacBook Air, 192.168.0.8)
|
|
||||||
│ SSH → 192.168.0.10:22
|
|
||||||
│ herdr --remote mac-mini-1
|
|
||||||
▼
|
|
||||||
herdr server (mac-mini-1, 192.168.0.10)
|
|
||||||
│ Unix socket: ~/.config/herdr/herdr.sock
|
|
||||||
│ Client ID: 4 (cols=39, rows=18)
|
|
||||||
▼
|
|
||||||
Shared workspace w1:
|
|
||||||
├── Pane 1 (t1): Pi ← YOU ARE HERE
|
|
||||||
├── Pane 2 (t2): OpenCode (blocked)
|
|
||||||
└── Pane 3 (t3): agy (idle)
|
|
||||||
```
|
|
||||||
|
|
||||||
Hermes shares the **same filesystem** (`/Users/wmj2024/Desktop/Projects`) and can see all workspace panes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Collaboration Methods
|
|
||||||
|
|
||||||
### 1. Signal Files (Primary)
|
|
||||||
Write state/requests to shared files. Both agents poll.
|
|
||||||
|
|
||||||
| File | Purpose | Who Writes | Poll Interval |
|
|
||||||
|------|---------|-----------|---------------|
|
|
||||||
| `.pi/hermes-signal.json` | Hermes → Pi requests | Hermes | Pi checks every turn |
|
|
||||||
| `.pi/pi-signal.json` | Pi → Hermes responses | Pi | Hermes checks every turn |
|
|
||||||
| `.pi/shared-state.json` | Joint state (schema, decisions) | Both | As needed |
|
|
||||||
| `.pi/SESSION.log` | Human-readable activity log | Both | Append-only |
|
|
||||||
|
|
||||||
### 2. Git Branches
|
|
||||||
Push code to shared branches. Review via PR.
|
|
||||||
|
|
||||||
| Branch | Repo | Owner | Purpose |
|
|
||||||
|--------|------|-------|---------|
|
|
||||||
| `feat/learn-module` | GitHub `maifors/falah-mobile` | Pi | Seed data + content |
|
|
||||||
| `main` | GitHub `maifors/falah-mobile` | Hermes | App code + UI |
|
|
||||||
| `content/daily-fiqh` | Gitea `wmj/falahmobile-content` | Pi | Markdown lessons |
|
|
||||||
|
|
||||||
### 3. Direct Commands (Careful!)
|
|
||||||
Hermes can run commands in shared workspace. **Coordinate before destructive ops.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Signal File Schema
|
|
||||||
|
|
||||||
### Hermes → Pi Request
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2026-06-28T05:00:00Z",
|
|
||||||
"from": "hermes",
|
|
||||||
"requestId": "req-001",
|
|
||||||
"type": "schema_review|content_request|merge_ready|blocker",
|
|
||||||
"message": "Can you populate content field for module 3?",
|
|
||||||
"data": { "moduleId": "when-wudu-breaks", "field": "content" },
|
|
||||||
"urgency": "normal|urgent|blocking"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pi → Hermes Response
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2026-06-28T05:05:00Z",
|
|
||||||
"from": "pi",
|
|
||||||
"requestId": "req-001",
|
|
||||||
"status": "done|in_progress|needs_clarification|declined",
|
|
||||||
"message": "Content populated. See commit abc123.",
|
|
||||||
"data": { "commit": "abc123", "filesChanged": 1 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Workspace Etiquette
|
|
||||||
|
|
||||||
| Rule | Why |
|
|
||||||
|------|-----|
|
|
||||||
| **Lock before destructive ops** | `echo "pi:LOCKED $(date)" >> .pi/SESSION.log` |
|
|
||||||
| **Small, focused commits** | Easier to review, less merge conflict |
|
|
||||||
| **Signal before schema changes** | Schema affects both agents' code |
|
|
||||||
| **Use Gitea for large files** | Binary/audio → content repo, not app repo |
|
|
||||||
| **Respect pane focus** | Don't steal focus from human user |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Task Board
|
|
||||||
|
|
||||||
| # | Task | Owner | Status | Blocker |
|
|
||||||
|---|------|-------|--------|---------|
|
|
||||||
| 1 | Populate `content` field for all 5 modules | Pi | 🟡 Ready | Needs markdown from content repo |
|
|
||||||
| 2 | Verify quizData format matches React UI | Hermes | 🔴 Not started | Waiting for Hermes signal |
|
|
||||||
| 3 | Generate TTS audio files | Pi | 🔴 Not started | Needs Azure Speech key |
|
|
||||||
| 4 | Merge PR #1 to `main` | Hermes | 🟡 PR open | Needs Hermes review |
|
|
||||||
| 5 | Add intermediate course | Pi | 🔴 Not started | Waiting for Module 1 completion |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check Hermes connection status
|
|
||||||
herdr pane list | grep hermes || echo "Hermes not in pane list yet"
|
|
||||||
|
|
||||||
# Write a signal
|
|
||||||
cat > .pi/pi-signal.json << 'EOF'
|
|
||||||
{ "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", "from": "pi", ... }
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Read Hermes signal
|
|
||||||
cat .pi/hermes-signal.json 2>/dev/null || echo "No signal from Hermes"
|
|
||||||
|
|
||||||
# Log activity
|
|
||||||
echo "[$(date -u +%H:%M)] pi: [status] message" >> .pi/SESSION.log
|
|
||||||
|
|
||||||
# Push work to shared branch
|
|
||||||
git add . && git commit -m "..." && git push github feat/learn-module
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Emergency Contacts
|
|
||||||
|
|
||||||
| Issue | Channel |
|
|
||||||
|-------|---------|
|
|
||||||
| Git conflict | GitHub PR comments |
|
|
||||||
| Schema disagreement | Signal file + Gitea Issue #1 |
|
|
||||||
| Urgent/blocking | Direct herdr log message |
|
|
||||||
| Human escalation | User present in workspace |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Protocol v1.0 | Hermes connected 2026-06-27 via herdr --remote*
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# 📋 Instructions for Hermes — PR #1 Review
|
|
||||||
|
|
||||||
> **From:** Pi (mac-mini-1, Tailscale 100.72.2.115)
|
|
||||||
> **To:** Hermes (MacBook Air, Tailscale 100.76.3.26)
|
|
||||||
> **Connection:** Direct link established — real-time collaboration active
|
|
||||||
> **Subject:** `feat/learn-module` PR #1 — Micro-Learning Content Seed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What This PR Contains
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `prisma/seed-learn.json` | Daily Fiqh for Beginners — 5 modules with quiz data, key takeaways, durations |
|
|
||||||
| `prisma/seed-learn.js` | Seed script using your existing `LearnCourse` / `LearnModule` schema |
|
|
||||||
| `prisma/schema.prisma` | Cleaned — removed duplicate `Course`/`Module` models Pi initially added |
|
|
||||||
|
|
||||||
**No UI changes.** This PR only adds seed data and a seed script. Your existing pages (`/learn`, `/learn/[slug]/[moduleId]`) and API routes (`/api/learn/*`) are untouched.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. What Pi Discovered About Your Work
|
|
||||||
|
|
||||||
You already built a **complete learn module** on `main`. Pi aligned to it:
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
// Your existing schema (preserved)
|
|
||||||
model LearnCourse {
|
|
||||||
id, slug, title, author, description, imageUrl
|
|
||||||
moduleCount, totalMinutes, difficulty, giteaPath
|
|
||||||
priceFlh, priceUsd, subscriptionOnly, published
|
|
||||||
}
|
|
||||||
|
|
||||||
model LearnModule {
|
|
||||||
id, courseId, order, title, slug
|
|
||||||
keyTakeaway, duration, content, audioPath, quizData
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Pi's initial attempt added **duplicate** `Course`/`Module` models — those have been removed. The seed script uses `prisma.learnCourse` and `prisma.learnModule` exactly as you defined them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. How to Test This PR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Fetch the branch
|
|
||||||
git fetch origin feat/learn-module
|
|
||||||
git checkout feat/learn-module
|
|
||||||
|
|
||||||
# 2. Install deps (if needed)
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 3. Push schema to database
|
|
||||||
npx prisma db push
|
|
||||||
|
|
||||||
# 4. Run the seed
|
|
||||||
node prisma/seed-learn.js
|
|
||||||
|
|
||||||
# 5. Verify
|
|
||||||
curl http://localhost:3000/mobile/api/learn/courses
|
|
||||||
# Should return: Daily Fiqh for Beginners with 5 modules
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The Seed Data Structure
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"courses": [{
|
|
||||||
"slug": "daily-fiqh-beginner",
|
|
||||||
"title": "Daily Fiqh for Beginners",
|
|
||||||
"author": "FalahMobile Learning",
|
|
||||||
"description": "...",
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"giteaPath": "courses/daily-fiqh-beginner",
|
|
||||||
"published": true,
|
|
||||||
"modules": [
|
|
||||||
{
|
|
||||||
"order": 1,
|
|
||||||
"title": "The Intention of Wudu",
|
|
||||||
"slug": "intention-of-wudu",
|
|
||||||
"keyTakeaway": "Wudu only counts if you intend it...",
|
|
||||||
"duration": 2,
|
|
||||||
"quizData": [{"question": "...", "options": [...], "correctIndex": 1}]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fields you care about:**
|
|
||||||
- `giteaPath` → links to `wmj/falahmobile-content` repo on Gitea
|
|
||||||
- `quizData` → JSON string matching your UI's expected format
|
|
||||||
- `duration` → minutes (aggregated into `totalMinutes` on the course)
|
|
||||||
- `keyTakeaway` → displayed in module cards
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. What's Missing (Next Steps)
|
|
||||||
|
|
||||||
| # | Task | Owner | Notes |
|
|
||||||
|---|------|-------|-------|
|
|
||||||
| 1 | **Add `content` markdown** | Pi | Module bodies are `null`. Need to port markdown from `falahmobile-content` repo |
|
|
||||||
| 2 | **Generate TTS audio** | Pi | `audioPath` is `null`. Has Python script ready, needs Azure key |
|
|
||||||
| 3 | **Quiz UI verification** | Hermes | Confirm `quizData` format matches your React components |
|
|
||||||
| 4 | **More courses** | Pi | Intermediate/advanced queued |
|
|
||||||
| 5 | **PostgreSQL migration** | Hermes | Production schema switch from SQLite |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Content Source Repo
|
|
||||||
|
|
||||||
Gitea: `http://13.140.161.244:3080/wmj/falahmobile-content`
|
|
||||||
|
|
||||||
| Path | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `courses/daily-fiqh-beginner/manifest.json` | Course metadata |
|
|
||||||
| `module-01-purification-prayer/lesson-01..05.md` | Full markdown lessons |
|
|
||||||
| `scripts/generate-audio.py` | TTS pipeline (Azure/Edge/Piper) |
|
|
||||||
| `templates/lesson-template.md` | Standard lesson format |
|
|
||||||
|
|
||||||
The `giteaPath` field in seed data points here for Hermes to pull full content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Communication Channels
|
|
||||||
|
|
||||||
| Channel | URL | Use For |
|
|
||||||
|---------|-----|---------|
|
|
||||||
| **Direct** | Tailscale mesh | Real-time sync, urgent decisions |
|
|
||||||
| **GitHub PR #1** | https://github.com/maifors/falah-mobile/pull/1 | Code review, merge discussion |
|
|
||||||
| **Gitea Issue #1** | http://13.140.161.244:3080/wmj/falah-mobile/issues/1 | Backup async channel |
|
|
||||||
|
|
||||||
> **Status:** Direct connection active. No more port-blocked isolation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Quick Merge Checklist
|
|
||||||
|
|
||||||
- [ ] Schema diff looks clean (no duplicate models)
|
|
||||||
- [ ] Seed script runs without errors
|
|
||||||
- [ ] API `/mobile/api/learn/courses` returns the course
|
|
||||||
- [ ] Quiz data format is compatible with your UI
|
|
||||||
- [ ] No breaking changes to existing routes
|
|
||||||
|
|
||||||
**If all green → merge PR #1 to `main`.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Direct Handshake Protocol
|
|
||||||
|
|
||||||
When you connect, send:
|
|
||||||
```
|
|
||||||
HERMES:ONLINE
|
|
||||||
STATUS: [your current branch / what you're working on]
|
|
||||||
NEEDS: [what you need from Pi]
|
|
||||||
```
|
|
||||||
|
|
||||||
Pi will respond:
|
|
||||||
```
|
|
||||||
PI:ACK
|
|
||||||
STATUS: [current task / branch]
|
|
||||||
OFFERS: [what Pi can help with]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. Questions?
|
|
||||||
|
|
||||||
**Preferred:** Direct message (fastest)
|
|
||||||
**Fallback:** GitHub PR #1 (code context)
|
|
||||||
**Emergency:** Gitea Issue #1 (if GitHub is down)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Generated by Pi on mac-mini-1 | 2026-06-27*
|
|
||||||
*Updated: Direct connectivity established*
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[05:00] SYSTEM: Hermes connected via herdr --remote (client_id=4, 192.168.0.8)
|
|
||||||
[05:00] SYSTEM: Collaboration protocol initialized
|
|
||||||
[05:00] SYSTEM: Signal files created (.pi/shared-state.json, .pi/SESSION.log)
|
|
||||||
[05:00] PI: Pushed PR #1 to GitHub (seed data + schema alignment)
|
|
||||||
[05:00] PI: Standing by for Hermes review signal
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
34827
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Pi Agent Server — Lightweight HTTP API for inter-agent communication.
|
|
||||||
Hermes (or other agents) can discover, task, and query Pi via this endpoint.
|
|
||||||
|
|
||||||
Bind: 0.0.0.0:4747
|
|
||||||
Security: Accepts only localhost, local LAN (192.168.0.0/24), and Tailscale (100.64.0.0/10)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import http.server
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socketserver
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
PORT = 4747
|
|
||||||
ALLOWED_NETWORKS = [
|
|
||||||
("127.0.0.0", 8), # localhost
|
|
||||||
("192.168.0.0", 24), # local LAN
|
|
||||||
("100.64.0.0", 10), # Tailscale CGNAT
|
|
||||||
]
|
|
||||||
|
|
||||||
TASKS = {}
|
|
||||||
TASK_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def ip_in_allowed_network(client_ip: str) -> bool:
|
|
||||||
"""Check if client IP is in an allowed network."""
|
|
||||||
try:
|
|
||||||
parts = [int(x) for x in client_ip.split(".")]
|
|
||||||
ip_int = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
|
|
||||||
for network, prefix in ALLOWED_NETWORKS:
|
|
||||||
net_parts = [int(x) for x in network.split(".")]
|
|
||||||
net_int = (net_parts[0] << 24) | (net_parts[1] << 16) | (net_parts[2] << 8) | net_parts[3]
|
|
||||||
mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
|
|
||||||
if (ip_int & mask) == (net_int & mask):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class AgentHandler(http.server.BaseHTTPRequestHandler):
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
client = self.client_address[0]
|
|
||||||
print(f"[{ts}] {client} {args[0]}", flush=True)
|
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def _reject(self, code=403, message="Forbidden"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps({"error": message}).encode())
|
|
||||||
|
|
||||||
def _json_ok(self, data):
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(data, indent=2).encode())
|
|
||||||
|
|
||||||
def _check_auth(self):
|
|
||||||
client_ip = self.client_address[0]
|
|
||||||
if not ip_in_allowed_network(client_ip):
|
|
||||||
self._reject(403, f"IP {client_ip} not in allowed networks")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
if not self._check_auth():
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
path = parsed.path
|
|
||||||
|
|
||||||
# ── Health / Status ──
|
|
||||||
if path == "/status" or path == "/":
|
|
||||||
self._json_ok({
|
|
||||||
"agent": "pi",
|
|
||||||
"version": "0.74.2",
|
|
||||||
"status": "ready",
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"workspace": "/Users/wmj2024/Desktop/Projects/FalahMobile",
|
|
||||||
"tailscale_ip": "100.72.2.115",
|
|
||||||
"local_ip": "192.168.0.10",
|
|
||||||
"tasks_active": len([t for t in TASKS.values() if t["status"] == "running"]),
|
|
||||||
"tasks_completed": len([t for t in TASKS.values() if t["status"] == "done"]),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Agent Manifest ──
|
|
||||||
if path == "/agent/manifest":
|
|
||||||
self._json_ok({
|
|
||||||
"id": "pi",
|
|
||||||
"name": "Pi Coding Agent",
|
|
||||||
"version": "0.74.2",
|
|
||||||
"hostname": "mac-mini-1",
|
|
||||||
"endpoints": {
|
|
||||||
"status": "GET /status",
|
|
||||||
"manifest": "GET /agent/manifest",
|
|
||||||
"task_submit": "POST /agent/task",
|
|
||||||
"task_query": "GET /agent/task/<id>",
|
|
||||||
"signal_read": "GET /agent/signal/<name>",
|
|
||||||
"signal_write": "POST /agent/signal/<name>",
|
|
||||||
},
|
|
||||||
"capabilities": [
|
|
||||||
"file_read", "file_write", "file_edit",
|
|
||||||
"bash_exec", "git_ops",
|
|
||||||
"code_generation", "schema_design",
|
|
||||||
"content_authoring", "tts_pipeline",
|
|
||||||
],
|
|
||||||
"languages": ["typescript", "javascript", "python", "bash", "prisma", "sql"],
|
|
||||||
"platforms": ["macos", "linux", "docker"],
|
|
||||||
"collaboration": {
|
|
||||||
"herdr_workspace": "w1",
|
|
||||||
"github_repo": "maifors/falah-mobile",
|
|
||||||
"gitea_repo": "wmj/falahmobile-content",
|
|
||||||
},
|
|
||||||
"contact": {
|
|
||||||
"tailscale_ip": "100.72.2.115",
|
|
||||||
"local_ip": "192.168.0.10",
|
|
||||||
"ssh_port": 22,
|
|
||||||
"agent_port": PORT,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Task Query ──
|
|
||||||
if path.startswith("/agent/task/"):
|
|
||||||
task_id = path.split("/")[-1]
|
|
||||||
task = TASKS.get(task_id)
|
|
||||||
if not task:
|
|
||||||
self._reject(404, "Task not found")
|
|
||||||
return
|
|
||||||
self._json_ok(task)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Signal Read ──
|
|
||||||
if path.startswith("/agent/signal/"):
|
|
||||||
name = path.split("/")[-1]
|
|
||||||
signal_path = f"/Users/wmj2024/Desktop/Projects/FalahMobile/.pi/{name}.json"
|
|
||||||
if os.path.exists(signal_path):
|
|
||||||
with open(signal_path, "r") as f:
|
|
||||||
self._json_ok(json.load(f))
|
|
||||||
else:
|
|
||||||
self._reject(404, f"Signal {name} not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._reject(404, "Unknown endpoint")
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
if not self._check_auth():
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
path = parsed.path
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else "{}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(body) if body else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self._reject(400, "Invalid JSON body")
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Task Submit ──
|
|
||||||
if path == "/agent/task":
|
|
||||||
task_id = str(uuid.uuid4())[:8]
|
|
||||||
task = {
|
|
||||||
"id": task_id,
|
|
||||||
"status": "queued",
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"completed_at": None,
|
|
||||||
"request": payload,
|
|
||||||
"result": None,
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
with TASK_LOCK:
|
|
||||||
TASKS[task_id] = task
|
|
||||||
|
|
||||||
# Spawn worker thread
|
|
||||||
threading.Thread(target=self._run_task, args=(task_id,), daemon=True).start()
|
|
||||||
|
|
||||||
self._json_ok({"task_id": task_id, "status": "queued", "query_url": f"/agent/task/{task_id}"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Signal Write ──
|
|
||||||
if path.startswith("/agent/signal/"):
|
|
||||||
name = path.split("/")[-1]
|
|
||||||
signal_path = f"/Users/wmj2024/Desktop/Projects/FalahMobile/.pi/{name}.json"
|
|
||||||
with open(signal_path, "w") as f:
|
|
||||||
json.dump(payload, f, indent=2)
|
|
||||||
self._json_ok({"signal": name, "saved": True, "path": signal_path})
|
|
||||||
return
|
|
||||||
|
|
||||||
self._reject(404, "Unknown endpoint")
|
|
||||||
|
|
||||||
def _run_task(self, task_id):
|
|
||||||
"""Execute a task asynchronously."""
|
|
||||||
with TASK_LOCK:
|
|
||||||
task = TASKS[task_id]
|
|
||||||
task["status"] = "running"
|
|
||||||
|
|
||||||
req = task["request"]
|
|
||||||
action = req.get("action")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if action == "read_file":
|
|
||||||
filepath = req.get("path")
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
raise FileNotFoundError(f"File not found: {filepath}")
|
|
||||||
with open(filepath, "r") as f:
|
|
||||||
result = {"content": f.read()}
|
|
||||||
|
|
||||||
elif action == "write_file":
|
|
||||||
filepath = req.get("path")
|
|
||||||
content = req.get("content", "")
|
|
||||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
||||||
with open(filepath, "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
result = {"path": filepath, "bytes_written": len(content)}
|
|
||||||
|
|
||||||
elif action == "bash":
|
|
||||||
cmd = req.get("command", "")
|
|
||||||
timeout = req.get("timeout", 60)
|
|
||||||
cwd = req.get("cwd", "/Users/wmj2024/Desktop/Projects/FalahMobile")
|
|
||||||
proc = subprocess.run(
|
|
||||||
cmd, shell=True, capture_output=True, text=True,
|
|
||||||
timeout=timeout, cwd=cwd
|
|
||||||
)
|
|
||||||
result = {
|
|
||||||
"stdout": proc.stdout,
|
|
||||||
"stderr": proc.stderr,
|
|
||||||
"returncode": proc.returncode,
|
|
||||||
}
|
|
||||||
|
|
||||||
elif action == "git_push":
|
|
||||||
branch = req.get("branch", "feat/learn-module")
|
|
||||||
remote = req.get("remote", "github")
|
|
||||||
message = req.get("message", "agent: automated commit")
|
|
||||||
cmds = [
|
|
||||||
["git", "add", "-A"],
|
|
||||||
["git", "commit", "-m", message],
|
|
||||||
["git", "push", remote, branch],
|
|
||||||
]
|
|
||||||
outputs = []
|
|
||||||
for c in cmds:
|
|
||||||
proc = subprocess.run(c, capture_output=True, text=True, cwd="/Users/wmj2024/Desktop/Projects/FalahMobile")
|
|
||||||
outputs.append({"cmd": c, "rc": proc.returncode, "out": proc.stdout[-500:], "err": proc.stderr[-500:]})
|
|
||||||
result = {"operations": outputs}
|
|
||||||
|
|
||||||
elif action == "schema_verify":
|
|
||||||
schema_path = req.get("path", "prisma/schema.prisma")
|
|
||||||
with open(schema_path, "r") as f:
|
|
||||||
schema = f.read()
|
|
||||||
models = [line.strip() for line in schema.split("\n") if line.strip().startswith("model ")]
|
|
||||||
result = {"models_found": models, "schema_path": schema_path}
|
|
||||||
|
|
||||||
else:
|
|
||||||
result = {"error": f"Unknown action: {action}"}
|
|
||||||
|
|
||||||
with TASK_LOCK:
|
|
||||||
task["status"] = "done"
|
|
||||||
task["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
||||||
task["result"] = result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
with TASK_LOCK:
|
|
||||||
task["status"] = "error"
|
|
||||||
task["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
||||||
task["error"] = str(e)
|
|
||||||
|
|
||||||
|
|
||||||
def run_server():
|
|
||||||
with socketserver.TCPServer(("0.0.0.0", PORT), AgentHandler) as httpd:
|
|
||||||
print(f"[AGENT] Pi server listening on 0.0.0.0:{PORT}", flush=True)
|
|
||||||
print(f"[AGENT] Hermes can connect via:", flush=True)
|
|
||||||
print(f" → Tailscale: http://100.72.2.115:{PORT}/agent/manifest", flush=True)
|
|
||||||
print(f" → Local LAN: http://192.168.0.10:{PORT}/agent/manifest", flush=True)
|
|
||||||
print(f"[AGENT] Allowed networks: {ALLOWED_NETWORKS}", flush=True)
|
|
||||||
httpd.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run_server()
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
---
|
|
||||||
name: auto-healer
|
|
||||||
description: SRE-grade auto-healing agent for Falah Mobile — log-aware diagnosis, restore protocol, incident logging
|
|
||||||
tools: read, bash, grep, find, ls
|
|
||||||
---
|
|
||||||
|
|
||||||
# Auto-Healer Agent — SRE Protocol
|
|
||||||
|
|
||||||
## On Fault Detection
|
|
||||||
|
|
||||||
```
|
|
||||||
FAULT DETECTED
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────┐
|
|
||||||
│ 1. FETCH LOGS │ ← docker logs --tail 100
|
|
||||||
└──────┬───────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────┐
|
|
||||||
│ 2. ANALYZE │ ← Knowledge Base: 20+ error patterns
|
|
||||||
│ Root Cause │ Map → action: rollback, restart, hotfix
|
|
||||||
└──────┬───────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────┐
|
|
||||||
│ 3. DECIDE │
|
|
||||||
│ │
|
|
||||||
│ Can I hotfix? ──→ apply_hotfix() │
|
|
||||||
│ Need restore? ──→ restore_protocol()│
|
|
||||||
└──────────┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────┐
|
|
||||||
│ 4. RESTORE PROTOCOL │
|
|
||||||
│ │
|
|
||||||
│ a. Save incident report to disk │
|
|
||||||
│ (logs + inspect + events) │
|
|
||||||
│ b. Tag current image as :failed │
|
|
||||||
│ c. Rollback to :rollback image │
|
|
||||||
│ d. Verify service restored │
|
|
||||||
│ e. Report incident ID │
|
|
||||||
└──────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Knowledge Base
|
|
||||||
|
|
||||||
| Log Pattern | Root Cause | Action |
|
|
||||||
|---|---|---|
|
|
||||||
| "Cannot find module" | Missing dependency | **Rollback** |
|
|
||||||
| "unexpected token" / "SyntaxError" | JS parse error (bad deploy) | **Rollback** |
|
|
||||||
| "heap out of memory" / "FATAL ERROR" | OOM / memory leak | **Rollback** |
|
|
||||||
| "ReferenceError" / "TypeError" | Code bug | **Rollback** |
|
|
||||||
| "PrismaClientInitializationError" | DB connection issue | Restart container |
|
|
||||||
| "ECONNREFUSED" / "ENOTFOUND" | Downstream unreachable | Restart container |
|
|
||||||
| "rate limit" / "429" | Rate limiter | Hotfix (auto-cleanup) |
|
|
||||||
| "jwt expired" / "invalid token" | Config issue | Check config → Rollback |
|
|
||||||
| "500" / "unhandled rejection" | Unhandled exception | **Rollback** |
|
|
||||||
| Container exit 137 / OOMKilled | SIGKILL / OOM | **Rollback** (if repeated) |
|
|
||||||
| Gateway 502 | nginx down | Reload gateway |
|
|
||||||
|
|
||||||
## Restore Protocol Steps
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Save incident
|
|
||||||
cat > /var/log/falah/incidents/incident_$(date +%Y%m%d_%H%M%S).log << 'EOF'
|
|
||||||
... full diagnostics ...
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 2. Rollback
|
|
||||||
docker stop falah-mobile-staging
|
|
||||||
docker rm -f falah-mobile-staging
|
|
||||||
docker run -d --name falah-mobile-staging \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 4014:3000 \
|
|
||||||
-v /var/services/homes/admin/falah-mobile/data:/app/data \
|
|
||||||
--network falah-umbrel_falah-system \
|
|
||||||
falah-mobile:rollback
|
|
||||||
|
|
||||||
# 3. Verify
|
|
||||||
curl -f http://localhost:4014/mobile/api/health
|
|
||||||
|
|
||||||
# 4. Report
|
|
||||||
echo "Incident: $id — rolled back to :rollback"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hotfix Actions (no rollback needed)
|
|
||||||
|
|
||||||
- **Rate limit**: Restart container (code auto-cleans every 5 min)
|
|
||||||
- **DB connection**: Restart container (reconnect)
|
|
||||||
- **Gateway**: `nginx -s reload`
|
|
||||||
|
|
||||||
## Escalation
|
|
||||||
|
|
||||||
If 3+ restarts/hour or rollback fails:
|
|
||||||
1. Log full incident with all diagnostics
|
|
||||||
2. Do NOT restart again (leave for manual debug)
|
|
||||||
3. Alert via available channels
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
name: dns-healer
|
|
||||||
description: DNS & domain routing auto-healer — Cloudflare tunnels, Traefik, nginx gateway, SSL certs, DNS resolution
|
|
||||||
tools: read, bash
|
|
||||||
---
|
|
||||||
|
|
||||||
# DNS & Domain Routing Auto-Healer
|
|
||||||
|
|
||||||
Monitors and auto-fixes the full routing chain:
|
|
||||||
|
|
||||||
```
|
|
||||||
User → Cloudflare DNS → Cloudflare Tunnel → Gateway nginx → Microservice
|
|
||||||
```
|
|
||||||
|
|
||||||
## What It Checks (every 60s)
|
|
||||||
|
|
||||||
| Check | What | Auto-Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| Cloudflare Tunnel | Container status | Restart tunnel container |
|
|
||||||
| DNS Resolution | Domains resolve to Cloudflare IPs | Alert only (needs API token) |
|
|
||||||
| Direct Service | Local health endpoint | Already handled by app auto-healer |
|
|
||||||
| Gateway Routing | nginx proxies correctly | Reload nginx, restart container |
|
|
||||||
| Routing Integrity | Gateway response matches direct | Alert on mismatch |
|
|
||||||
| Public Endpoint | URL accessible via Cloudflare | Alert on origin errors |
|
|
||||||
| SSL Certs | Certificate expiration | Alert if <7 days |
|
|
||||||
| Traefik | If deployed (standalone or Swarm) | Restart container |
|
|
||||||
|
|
||||||
## Common Fixes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Restart tunnel
|
|
||||||
docker restart cloudflare-YT01
|
|
||||||
|
|
||||||
# Reload nginx gateway
|
|
||||||
docker exec falah-umbrel-api-gateway nginx -s reload
|
|
||||||
|
|
||||||
# Restart gateway
|
|
||||||
docker restart falah-umbrel-api-gateway
|
|
||||||
|
|
||||||
# Verify tunnel config from Cloudflare dashboard
|
|
||||||
# Tunnel token: eyJhIjoiZjM1ZGI5M2YwMTkwZjIwMzA3NzM4OTEzNjE2OWNjMGI...
|
|
||||||
|
|
||||||
# Check tunnel ingress rules
|
|
||||||
# Cloudflare Dashboard → Zero Trust → Tunnels → YT01
|
|
||||||
```
|
|
||||||
|
|
||||||
## Escalation
|
|
||||||
|
|
||||||
If tunnel fails to restart 3 times in an hour, or public endpoint returns 521/522 for 5+ minutes:
|
|
||||||
1. Log full diagnostics
|
|
||||||
2. Do NOT keep restarting (rate limit: 3 restarts/hour)
|
|
||||||
3. Check Cloudflare dashboard for origin status
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"from": "pi",
|
|
||||||
"message": "Agent server online",
|
|
||||||
"port": 4747
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "pi",
|
|
||||||
"name": "Pi Coding Agent",
|
|
||||||
"version": "0.74.2",
|
|
||||||
"status": "online",
|
|
||||||
"host": {
|
|
||||||
"name": "mac-mini-1",
|
|
||||||
"os": "macOS",
|
|
||||||
"local_ip": "192.168.0.10",
|
|
||||||
"tailscale_ip": "100.72.2.115",
|
|
||||||
"ssh_port": 22,
|
|
||||||
"agent_port": 4747
|
|
||||||
},
|
|
||||||
"endpoints": {
|
|
||||||
"status": "http://100.72.2.115:4747/status",
|
|
||||||
"manifest": "http://100.72.2.115:4747/agent/manifest",
|
|
||||||
"task": "http://100.72.2.115:4747/agent/task",
|
|
||||||
"signal_read": "http://100.72.2.115:4747/agent/signal/{name}",
|
|
||||||
"signal_write": "http://100.72.2.115:4747/agent/signal/{name}"
|
|
||||||
},
|
|
||||||
"herdr": {
|
|
||||||
"workspace": "w1",
|
|
||||||
"pane": "w1:p1",
|
|
||||||
"remote_command": "herdr --remote wmj2024@100.72.2.115 --session Projects"
|
|
||||||
},
|
|
||||||
"capabilities": [
|
|
||||||
"file_read", "file_write", "file_edit",
|
|
||||||
"bash_exec", "git_ops", "code_generation",
|
|
||||||
"schema_design", "content_authoring", "tts_pipeline"
|
|
||||||
],
|
|
||||||
"current_task": "PR #1 content seed — standing by for Hermes",
|
|
||||||
"contact": {
|
|
||||||
"github": "https://github.com/maifors/falah-mobile/pull/1",
|
|
||||||
"gitea": "http://13.140.161.244:3080/wmj/falah-mobile/issues/1"
|
|
||||||
},
|
|
||||||
"timestamp": "2026-06-28T05:25:00Z"
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"lastUpdated": "2026-06-28T05:00:00Z",
|
|
||||||
"workspace": "w1",
|
|
||||||
"agents": {
|
|
||||||
"pi": { "status": "working", "pane": "w1:p1", "currentTask": "PR #1 content seed" },
|
|
||||||
"hermes": { "status": "connected", "clientId": 4, "expectedTask": "Review PR #1" },
|
|
||||||
"opencode": { "status": "blocked", "pane": "w1:p2" },
|
|
||||||
"agy": { "status": "idle", "pane": "w1:p3" }
|
|
||||||
},
|
|
||||||
"schema": {
|
|
||||||
"source": "hermes-existing",
|
|
||||||
"models": ["LearnCourse", "LearnModule", "LearnEnrollment", "LearnModuleProgress"],
|
|
||||||
"verifiedBy": "pi",
|
|
||||||
"verifiedAt": "2026-06-28T04:30:00Z"
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"repo": "gitea:wmj/falahmobile-content",
|
|
||||||
"course": "daily-fiqh-beginner",
|
|
||||||
"modulesReady": 5,
|
|
||||||
"contentFieldPopulated": false,
|
|
||||||
"audioGenerated": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
@@ -1,56 +1,30 @@
|
|||||||
# ── Falah Mobile — Production Dockerfile ──────────────────────────────
|
FROM node:20-slim AS base
|
||||||
# Requires npm run build to be run first on the host (Next.js standalone)
|
|
||||||
# Build sequence:
|
|
||||||
# cd /app && npm ci && npm run build
|
|
||||||
# docker build -t falah-mobile:latest .
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
RUN apk add --no-cache openssl ca-certificates curl
|
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
# Copy standalone build (pre-built outside Docker)
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY .next/standalone/ ./
|
COPY . .
|
||||||
COPY .next/static ./.next/static
|
RUN npx prisma generate && npm run build
|
||||||
COPY public ./public
|
|
||||||
|
|
||||||
# Install production dependencies inside Docker
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
|
||||||
COPY prisma ./prisma
|
|
||||||
|
|
||||||
# Fix Turbopack's hashed Prisma client path (if .next inside standalone)
|
|
||||||
RUN if [ -d "/app/.next/node_modules/@prisma" ]; then \
|
|
||||||
for d in /app/.next/node_modules/@prisma/client-*; do \
|
|
||||||
name=$(basename "$d"); \
|
|
||||||
rm -f "/app/node_modules/@prisma/$name" 2>/dev/null; \
|
|
||||||
cp -r /app/node_modules/@prisma/client "/app/node_modules/@prisma/$name"; \
|
|
||||||
done; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
|
||||||
|
|
||||||
# Fix permissions so nextjs user can run prisma CLI and read schema
|
|
||||||
RUN chown -R nextjs:nodejs /app/node_modules/@prisma /app/node_modules/prisma /app/node_modules/.prisma /app/prisma 2>/dev/null || true
|
|
||||||
|
|
||||||
# Startup script — migrate volumes DB then launch server
|
|
||||||
RUN printf '#!/bin/sh\n\
|
|
||||||
cd /app\n\
|
|
||||||
node ./node_modules/prisma/build/index.js db push --skip-generate --accept-data-loss 2>&1\n\
|
|
||||||
# Force 0.0.0.0 binding (Docker sets HOSTNAME to container ID)\nexport HOSTNAME=0.0.0.0\nexec node server.js\n' > /app/start.sh && chmod +x /app/start.sh
|
|
||||||
|
|
||||||
# ── Docker HEALTHCHECK ──────────────────────────────────────────
|
|
||||||
# Auto-restarts container if health endpoint fails 3 times (30s interval, 10s timeout)
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
|
||||||
CMD curl -sf http://localhost:3000/mobile/api/health || exit 1
|
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN mkdir -p /app/public
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
ENV NODE_ENV=production
|
CMD ["node", "server.js"]
|
||||||
ENV DATABASE_URL="file:/app/data/dev.db"
|
|
||||||
|
|
||||||
CMD ["/app/start.sh"]
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
# 📲 Install Falah as a Mobile App (PWA)
|
|
||||||
|
|
||||||
## What is a PWA?
|
|
||||||
|
|
||||||
Falah is a **Progressive Web App (PWA)** — it runs in your browser but can be installed on your phone just like a native app. Once installed, it launches from your home screen, works offline for basic features, and takes full advantage of your screen without browser chrome.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Android
|
|
||||||
|
|
||||||
### Option A: Install via Chrome (Recommended — 1 tap)
|
|
||||||
|
|
||||||
1. Open **Chrome** and go to **https://falahos.my/mobile**
|
|
||||||
2. Sign in to your account
|
|
||||||
3. You'll see a banner at the bottom: **"Add Falah to Home Screen"**
|
|
||||||
- Tap **"Install"** or **"Add"**
|
|
||||||
4. If no banner appears:
|
|
||||||
- Tap the **⋮ menu** (three dots, top-right)
|
|
||||||
- Tap **"Install app"** or **"Add to Home screen"**
|
|
||||||
5. Confirm — Falah will appear on your home screen with its ☪️ icon
|
|
||||||
|
|
||||||
**That's it.** Falah opens like any other app, with your account signed in.
|
|
||||||
|
|
||||||
### Option B: Side-load APK (for offline distribution)
|
|
||||||
|
|
||||||
Use **PWABuilder** to generate a real APK:
|
|
||||||
|
|
||||||
1. Go to **https://pwabuilder.com**
|
|
||||||
2. Enter **https://falahos.my/mobile** and click **"Start"**
|
|
||||||
3. Click **"Package for Stores"**
|
|
||||||
4. Under **Android**, click **"Generate Package"**
|
|
||||||
5. Download the `.apk` or `.aab` file
|
|
||||||
6. On your Android phone, open the downloaded file and tap **"Install"**
|
|
||||||
- *If blocked: go to Settings → Security → toggle "Install from unknown apps" for your file manager*
|
|
||||||
|
|
||||||
> **Note:** The APK wraps the PWA — all future updates are automatic. You only need to side-load once.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🍎 iOS (iPhone / iPad)
|
|
||||||
|
|
||||||
### Install via Safari (Standard way)
|
|
||||||
|
|
||||||
1. Open **Safari** and go to **https://falahos.my/mobile**
|
|
||||||
2. Sign in to your account
|
|
||||||
3. Tap the **Share button** 📤 (the square with arrow, bottom-center of the screen)
|
|
||||||
4. Scroll down and tap **"Add to Home Screen"** ➕
|
|
||||||
5. Edit the name (default: "Falah") and tap **"Add"** (top-right)
|
|
||||||
6. Falah now appears on your home screen with its icon
|
|
||||||
|
|
||||||
**Opening the app:** Just tap the Falah icon on your home screen — it launches full-screen, no browser tabs.
|
|
||||||
|
|
||||||
### Important iOS notes
|
|
||||||
- **Push notifications:** iOS doesn't support web push for home-screen apps yet. You'll see badges only when you open the app.
|
|
||||||
- **Updates:** iOS updates the PWA automatically in the background. No manual action needed.
|
|
||||||
- **Offline:** The app caches recent pages for offline reading.
|
|
||||||
- **iPad:** Same process as iPhone. The app adapts to the larger screen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Troubleshooting
|
|
||||||
|
|
||||||
| Issue | Fix |
|
|
||||||
|-------|-----|
|
|
||||||
| **"Add to Home Screen" doesn't appear** | Make sure you're using **Chrome** (Android) or **Safari** (iOS). Third-party browsers like Firefox or Edge may not support PWA installation. |
|
|
||||||
| **App opens but shows a white screen** | Close the app completely and reopen. This happens once after the first install. |
|
|
||||||
| **"Not secure" warning** | Ensure you're visiting **https://** (the S matters). Our site uses HTTPS automatically. |
|
|
||||||
| **Can't sign in after install** | The app shares your browser session. Sign in once in the browser, and the installed app remembers you. |
|
|
||||||
| **App feels slow on first load** | The service worker caches assets on first visit. Give it 10–20 seconds. Subsequent loads are instant. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ After installation
|
|
||||||
|
|
||||||
- **Icon:** ☪️ with a crescent moon design on a dark background
|
|
||||||
- **Launch:** Full-screen, no browser address bar
|
|
||||||
- **Notifications:** Available if your device/browser supports them
|
|
||||||
- **Updates:** Automatic — you always get the latest version when you open the app
|
|
||||||
|
|
||||||
Questions or feedback? Use the **Report Issue** button in any Falah error card, or contact the Falah team.
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# Falah Mobile — Islamic Lifestyle Platform
|
|
||||||
|
|
||||||
**Falah Mobile** is the mobile frontend for the Falah OS ecosystem — a Shariah-compliant digital economy platform. Built with Next.js 16 and Prisma (SQLite), it serves as a Progressive Web App (PWA) at [falahos.my/mobile](https://falahos.my/mobile).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### 🌐 Souq — Halal Service Marketplace
|
|
||||||
| Feature | Route | Status |
|
|
||||||
|---------|-------|--------|
|
|
||||||
| Browse services w/ categories + search | `/souq` | ✅ |
|
|
||||||
| Advanced filters (price range, sort, delivery days) | `/souq` | ✅ |
|
|
||||||
| Create listing (packages, tags, images) | `/souq/create` | ✅ |
|
|
||||||
| Listing detail w/ packages (3 tiers) | `/souq/[id]` | ✅ |
|
|
||||||
| Seller profile w/ stats & listings | `/souq/seller/[id]` | ✅ |
|
|
||||||
| Order placement | `/souq/[id]` | ✅ |
|
|
||||||
| Orders list (buyer/seller tabs) | `/souq/orders` | ✅ |
|
|
||||||
| Order detail w/ chat, progress stepper | `/souq/orders/[id]` | ✅ |
|
|
||||||
| Review submission (star rating 1-5) | `/souq/orders/[id]` | ✅ |
|
|
||||||
| Delivery file upload | `/souq/orders/[id]` | ✅ |
|
|
||||||
| Seller workflow (start, deliver, confirm) | `/souq/orders/[id]` | ✅ |
|
|
||||||
|
|
||||||
### 💰 Wallet & FLH Token
|
|
||||||
- **FLH Balance** with live display
|
|
||||||
- **Top-up** via Polar.sh (5 tiers: 500–50,000 FLH)
|
|
||||||
- **Cash out** FLH balance
|
|
||||||
- **Mock mode** for development (falls back when Polar unconfigured)
|
|
||||||
- **Premium multiplier** (2x FLH for premium members)
|
|
||||||
|
|
||||||
### 🤖 Nur AI Coach
|
|
||||||
- AI-powered Islamic lifestyle coaching
|
|
||||||
- Customizable personas (Scholar, Mentor, Friend)
|
|
||||||
- Prayer tracking, Dhikr counter, Qibla compass
|
|
||||||
- Daily verses and reminders
|
|
||||||
|
|
||||||
### 📚 Learn — Micro-Learning Platform
|
|
||||||
- Structured courses with modules
|
|
||||||
- Certificate generation on completion
|
|
||||||
- Progress tracking & quiz assessments
|
|
||||||
- TTS audio for hands-free learning
|
|
||||||
|
|
||||||
### 👥 Community
|
|
||||||
- **Forum** with Shariah content moderation
|
|
||||||
- **Private Groups** with invite codes
|
|
||||||
- **Gamification**: XP, streaks, achievements, levels
|
|
||||||
- **Referral system** with FLH rewards
|
|
||||||
|
|
||||||
### 📱 PWA Features
|
|
||||||
- Installable on iOS/Android home screen
|
|
||||||
- Offline support
|
|
||||||
- Push notifications
|
|
||||||
- Audio playback (Dhikr, TTS, Adhan)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|-------|-----------|
|
|
||||||
| Framework | Next.js 16 (App Router, Standalone output) |
|
|
||||||
| Database | SQLite via Prisma ORM |
|
|
||||||
| Auth | Ummah ID (OIDC) + JWT (jose) |
|
|
||||||
| Payments | Polar.sh (FLH top-ups) |
|
|
||||||
| AI Chat | OpenAI-compatible API (configurable) |
|
|
||||||
| Storage | Local filesystem (public/ directory) |
|
|
||||||
| Deployment | Docker (port 4013 prod / 4014 staging) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Falah Mobile (Next.js 16, standalone output)
|
|
||||||
├── /mobile/ ← basePath (Traefik reverse proxy)
|
|
||||||
├── /mobile/souq/* ← Integrated marketplace
|
|
||||||
├── /mobile/wallet ← FLH wallet + top-up
|
|
||||||
├── /mobile/learn/* ← Micro-learning courses
|
|
||||||
├── /mobile/nur ← AI coaching
|
|
||||||
├── /mobile/forum/* ← Community forum
|
|
||||||
├── /mobile/api/souq/* ← Souq API endpoints
|
|
||||||
├── /mobile/api/wallet/* ← Wallet API
|
|
||||||
├── /mobile/api/webhooks/* ← Polar.sh + external webhooks
|
|
||||||
└── Prisma SQLite ← Single dev.db volume
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Routes
|
|
||||||
|
|
||||||
| Route | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `GET /api/souq/listings` | Listings with filters (category, price, sort, delivery) |
|
|
||||||
| `GET /api/souq/listings/[id]` | Listing detail with seller & reviews |
|
|
||||||
| `POST /api/souq/listings` | Create listing (auth required) |
|
|
||||||
| `GET /api/souq/categories` | Category list |
|
|
||||||
| `GET/POST /api/souq/orders` | Order CRUD |
|
|
||||||
| `PATCH /api/souq/orders/[id]` | Update order status, messages, delivery files |
|
|
||||||
| `POST /api/souq/reviews` | Create review (auth required) |
|
|
||||||
| `GET /api/souq/sellers/[id]` | Seller profile with stats |
|
|
||||||
| `POST /api/souq/upload` | File upload for deliveries |
|
|
||||||
| `POST /api/wallet/top-up/create-checkout` | Polar.sh checkout for FLH purchase |
|
|
||||||
| `POST /api/webhooks/polar-checkout` | Polar.sh webhook (credits FLH) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
# Build
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Build Docker image
|
|
||||||
docker build -t falah-mobile:latest .
|
|
||||||
|
|
||||||
# Run production
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Run staging
|
|
||||||
docker compose -f docker-compose.staging.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
See `.env.example` for all required vars:
|
|
||||||
- `JWT_SECRET` — JWT signing key (required)
|
|
||||||
- `DATABASE_URL` — SQLite path (`file:./dev.db`)
|
|
||||||
- `POLAR_ACCESS_TOKEN` — Polar.sh API token (optional, mock mode used otherwise)
|
|
||||||
- `POLAR_FLH_*` — Product price IDs for each FLH pack
|
|
||||||
- `LLM_API_KEY` — OpenAI-compatible API key for AI chat
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
| Environment | Host | Port |
|
|
||||||
|------------|------|------|
|
|
||||||
| Production | `falahos.my` (via Traefik) | 4013 |
|
|
||||||
| Staging | `falahos.my/mobile-staging` | 4014 |
|
|
||||||
|
|
||||||
Both run as Docker containers with persistent SQLite volumes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Repositories
|
|
||||||
|
|
||||||
- **Falah OS Core** — Backend services, Netlify functions, landing pages
|
|
||||||
- **Ummah ID** — Identity service (OIDC provider)
|
|
||||||
- **iStore** — App marketplace (store.falah-os.com)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Built for the Ummah. Shariah-compliant by design.*
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# ── Falah Mobile — Auto-Healer Agent ──────────────────────────
|
|
||||||
# Dedicated container that monitors the app stack and auto-fixes faults.
|
|
||||||
# Runs alongside the main app container with access to Docker socket.
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
RUN apk add --no-cache curl docker-cli bash jq
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY auto-heal-agent.sh /app/auto-heal-agent.sh
|
|
||||||
RUN chmod +x /app/auto-heal-agent.sh
|
|
||||||
|
|
||||||
# Runtime state
|
|
||||||
RUN mkdir -p /var/log/falah /var/state/falah
|
|
||||||
|
|
||||||
# Monitor interval in seconds (default 30)
|
|
||||||
ENV CHECK_INTERVAL=30
|
|
||||||
ENV CONTAINER_NAME=falah-mobile-staging
|
|
||||||
ENV HEALTH_URL=http://localhost:4014/mobile/api/health
|
|
||||||
ENV GATEWAY_URL=http://localhost:7878/mobile/api/health
|
|
||||||
ENV MAX_RESTARTS_PER_HOUR=3
|
|
||||||
|
|
||||||
CMD ["/app/auto-heal-agent.sh"]
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# Falah Mobile — SRE Auto-Healing Agent
|
|
||||||
# ============================================================
|
|
||||||
# Log-aware fault diagnosis + restore protocol.
|
|
||||||
#
|
|
||||||
# On fault:
|
|
||||||
# 1. FETCH logs from failing container
|
|
||||||
# 2. ANALYZE logs against error knowledge base
|
|
||||||
# 3. DECIDE: quick hotfix or restore protocol?
|
|
||||||
# 4. RESTORE PROTOCOL: rollback to known-good image
|
|
||||||
# 5. While rolling back, capture full diagnostics for later fix
|
|
||||||
#
|
|
||||||
# This is NOT a blind restarter — it's a diagnostic-first SRE agent.
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────
|
|
||||||
|
|
||||||
CONTAINER_NAME="${CONTAINER_NAME:-falah-mobile-staging}"
|
|
||||||
HEALTH_URL="${HEALTH_URL:-http://localhost:4014/mobile/api/health}"
|
|
||||||
GATEWAY_URL="${GATEWAY_URL:-http://localhost:7878/mobile/api/health}"
|
|
||||||
CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
|
|
||||||
MAX_RESTARTS="${MAX_RESTARTS_PER_HOUR:-3}"
|
|
||||||
ROLLBACK_IMAGE="${ROLLBACK_IMAGE:-falah-mobile:rollback}"
|
|
||||||
|
|
||||||
AGENT_LOG="/var/log/falah/agent.log"
|
|
||||||
STATE_DIR="/var/state/falah"
|
|
||||||
STATE_FILE="$STATE_DIR/restart-count"
|
|
||||||
INCIDENTS_DIR="/var/log/falah/incidents"
|
|
||||||
|
|
||||||
mkdir -p "$STATE_DIR" "$(dirname "$AGENT_LOG")" "$INCIDENTS_DIR"
|
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log() {
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [agent] $*" >> "$AGENT_LOG"
|
|
||||||
}
|
|
||||||
|
|
||||||
alert() {
|
|
||||||
local severity="$1" msg="$2"
|
|
||||||
log "[$severity] $msg"
|
|
||||||
# Future: webhook, ntfy, Slack, PagerDuty
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Restart Tracking (sliding window) ────────────────────────
|
|
||||||
|
|
||||||
count_restarts() {
|
|
||||||
[ -f "$STATE_FILE" ] || echo 0 > "$STATE_FILE"
|
|
||||||
local now window_start
|
|
||||||
now=$(date +%s)
|
|
||||||
window_start=$((now - 3600))
|
|
||||||
awk -v ws="$window_start" '$1 > ws' "$STATE_FILE" 2>/dev/null | wc -l
|
|
||||||
}
|
|
||||||
|
|
||||||
record_restart() {
|
|
||||||
date +%s >> "$STATE_FILE"
|
|
||||||
local now window_start
|
|
||||||
now=$(date +%s)
|
|
||||||
window_start=$((now - 3600))
|
|
||||||
awk -v ws="$window_start" '$1 > ws' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Basic Health Checks ──────────────────────────────────────
|
|
||||||
|
|
||||||
check_http() {
|
|
||||||
local url="$1"
|
|
||||||
local body code
|
|
||||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000")
|
|
||||||
body=$(curl -sf --max-time 10 "$url" 2>/dev/null) || true
|
|
||||||
[ "$code" = "200" ] && { echo "$body"; return 0; } || return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
check_disk() {
|
|
||||||
df / | awk 'NR==2 {gsub(/%/,"",$5); print $5}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Phase 1: Detect Fault ────────────────────────────────────
|
|
||||||
|
|
||||||
detect_fault() {
|
|
||||||
# Cooldown: skip detection if restore happened within 60s
|
|
||||||
local cooldown=120
|
|
||||||
if [ -f /var/state/falah/last_restore_epoch ]; then
|
|
||||||
local last_restore
|
|
||||||
last_restore=$(cat /var/state/falah/last_restore_epoch 2>/dev/null || echo 0)
|
|
||||||
local now
|
|
||||||
now=$(date +%s)
|
|
||||||
local elapsed=$((now - last_restore))
|
|
||||||
if [ $elapsed -lt $cooldown ]; then
|
|
||||||
log " (cooldown active — last restore ${elapsed}s ago, skipping detection)"
|
|
||||||
echo "healthy"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Returns: diagnosis string (or "healthy")
|
|
||||||
|
|
||||||
# 0a. Check if CONTAINER_NAME is a Swarm service
|
|
||||||
local is_swarm_service=false
|
|
||||||
if docker service ls 2>/dev/null | awk '{print $2}' | grep -x "${CONTAINER_NAME}"; then
|
|
||||||
is_swarm_service=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$is_swarm_service" = true ]; then
|
|
||||||
# Swarm service mode — check via docker service ps
|
|
||||||
local unhealthy
|
|
||||||
unhealthy=$(docker service ps "$CONTAINER_NAME" --filter 'desired-state=running' --format '{{.CurrentState}}' 2>/dev/null | grep -v "^Running" | head -1)
|
|
||||||
if [ -n "$unhealthy" ]; then
|
|
||||||
echo "swarm_service_unhealthy"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
# Service is running, now check HTTP health
|
|
||||||
local health_body
|
|
||||||
health_body=$(check_http "$HEALTH_URL") || { echo "health_down"; return; }
|
|
||||||
echo "$health_body" | grep -q '"db":false' && { echo "db_down"; return; }
|
|
||||||
echo "$health_body" | grep -q '"status":"ok"' || { echo "health_invalid"; return; }
|
|
||||||
echo "healthy"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. Container exists? (standalone mode)
|
|
||||||
if ! docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "${CONTAINER_NAME}"; then
|
|
||||||
echo "container_missing"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Container running?
|
|
||||||
local status exit_code oom
|
|
||||||
status=$(docker inspect "$CONTAINER_NAME" --format '{{.State.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
|
|
||||||
if [ "$status" != "running" ]; then
|
|
||||||
exit_code=$(docker inspect "$CONTAINER_NAME" --format '{{.State.ExitCode}}' 2>/dev/null || echo "0")
|
|
||||||
oom=$(docker inspect "$CONTAINER_NAME" --format '{{.State.OOMKilled}}' 2>/dev/null || echo "false")
|
|
||||||
[ "$oom" = "true" ] && { echo "oom_killed"; return; }
|
|
||||||
[ "$exit_code" = "137" ] && { echo "sigkill"; return; }
|
|
||||||
echo "crashed_exit_$exit_code"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Health endpoint responds?
|
|
||||||
local health_body
|
|
||||||
health_body=$(check_http "$HEALTH_URL") || { echo "health_down"; return; }
|
|
||||||
|
|
||||||
# 4. DB connected?
|
|
||||||
echo "$health_body" | grep -q '"db":false' && { echo "db_down"; return; }
|
|
||||||
|
|
||||||
# 5. Status ok?
|
|
||||||
echo "$health_body" | grep -q '"status":"ok"' || { echo "health_invalid"; return; }
|
|
||||||
|
|
||||||
# 6. Gateway?
|
|
||||||
check_http "$GATEWAY_URL" > /dev/null || { echo "gateway_down"; return; }
|
|
||||||
|
|
||||||
echo "healthy"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Phase 2: Fetch Logs ──────────────────────────────────────
|
|
||||||
|
|
||||||
fetch_logs() {
|
|
||||||
local lines="${1:-100}"
|
|
||||||
docker logs "$CONTAINER_NAME" --tail "$lines" 2>&1 || echo "LOG_FETCH_FAILED"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Phase 3: Log Analysis (Knowledge Base) ───────────────────
|
|
||||||
|
|
||||||
analyze_logs() {
|
|
||||||
local diagnosis="$1"
|
|
||||||
local logs="$2"
|
|
||||||
|
|
||||||
# Build a diagnostic report
|
|
||||||
local root_cause="unknown"
|
|
||||||
local certainty="low"
|
|
||||||
local suggested_action="rollback"
|
|
||||||
local details=""
|
|
||||||
|
|
||||||
# ── Error Pattern Knowledge Base ───────────────────────────
|
|
||||||
# Each pattern maps to: root_cause | certainty | suggested_action
|
|
||||||
|
|
||||||
# Node.js crashes
|
|
||||||
if echo "$logs" | grep -qi "Cannot find module"; then
|
|
||||||
root_cause="missing_dependency"
|
|
||||||
certainty="high"
|
|
||||||
details=$(echo "$logs" | grep -i "Cannot find module" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "Module not found"; then
|
|
||||||
root_cause="missing_dependency"
|
|
||||||
certainty="high"
|
|
||||||
details=$(echo "$logs" | grep -i "Module not found" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "unexpected token"; then
|
|
||||||
root_cause="js_parse_error"
|
|
||||||
certainty="high"
|
|
||||||
suggested_action="rollback"
|
|
||||||
details=$(echo "$logs" | grep -i "unexpected token" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "SyntaxError"; then
|
|
||||||
root_cause="js_syntax_error"
|
|
||||||
certainty="high"
|
|
||||||
details=$(echo "$logs" | grep -i "SyntaxError" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "ReferenceError"; then
|
|
||||||
root_cause="js_reference_error"
|
|
||||||
certainty="high"
|
|
||||||
details=$(echo "$logs" | grep -i "ReferenceError" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "TypeError"; then
|
|
||||||
root_cause="js_type_error"
|
|
||||||
certainty="high"
|
|
||||||
details=$(echo "$logs" | grep -i "TypeError" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "heap out of memory\|FATAL ERROR\|Allocation failed"; then
|
|
||||||
root_cause="out_of_memory"
|
|
||||||
certainty="high"
|
|
||||||
details=$(echo "$logs" | grep -i "heap out of memory\|FATAL ERROR\|Allocation failed" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
# Prisma / DB errors
|
|
||||||
elif echo "$logs" | grep -qi "PrismaClientInitializationError\|prisma.*connect.*ECONNREFUSED"; then
|
|
||||||
root_cause="db_connection_refused"
|
|
||||||
certainty="high"
|
|
||||||
suggested_action="restart_container"
|
|
||||||
details=$(echo "$logs" | grep -i "PrismaClientInitializationError\|ECONNREFUSED" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "prisma.*ENOENT\|prisma.*does not exist"; then
|
|
||||||
root_cause="prisma_schema_mismatch"
|
|
||||||
certainty="high"
|
|
||||||
details=$(echo "$logs" | grep -i "prisma.*ENOENT\|does not exist" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
elif echo "$logs" | grep -qi "Can't reach database server\|getaddrinfo ENOTFOUND.*db\|postgres.*connect"; then
|
|
||||||
root_cause="db_unreachable"
|
|
||||||
certainty="high"
|
|
||||||
suggested_action="restart_container"
|
|
||||||
details=$(echo "$logs" | grep -i "ENOTFOUND\|Can't reach database" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
# Network / connectivity
|
|
||||||
elif echo "$logs" | grep -qi "ECONNREFUSED\|ECONNRESET\|ETIMEDOUT\|ENOTFOUND"; then
|
|
||||||
root_cause="downstream_unreachable"
|
|
||||||
certainty="medium"
|
|
||||||
suggested_action="restart_container"
|
|
||||||
details=$(echo "$logs" | grep -i "ECONNREFUSED\|ECONNRESET\|ETIMEDOUT\|ENOTFOUND" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
elif echo "$logs" | grep -qi "rate limit\|too many requests\|429"; then
|
|
||||||
root_cause="rate_limit_exceeded"
|
|
||||||
certainty="medium"
|
|
||||||
suggested_action="hotfix_rate_limit"
|
|
||||||
details=$(echo "$logs" | grep -i "rate limit\|too many" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
# Auth / config
|
|
||||||
elif echo "$logs" | grep -qi "jwt expired\|invalid token\|Unauthorized"; then
|
|
||||||
root_cause="auth_config_error"
|
|
||||||
certainty="medium"
|
|
||||||
suggested_action="check_config"
|
|
||||||
details=$(echo "$logs" | grep -i "jwt expired\|invalid token\|Unauthorized" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
# 5xx patterns from health endpoint
|
|
||||||
elif echo "$logs" | grep -qi "500\|Internal Server Error\|unhandled rejection\|uncaught exception"; then
|
|
||||||
root_cause="unhandled_exception"
|
|
||||||
certainty="medium"
|
|
||||||
details=$(echo "$logs" | grep -i "unhandled\|uncaught\|500\|Internal Server Error" | head -3 | tr '\n' '; ')
|
|
||||||
|
|
||||||
# Next.js specific
|
|
||||||
elif echo "$logs" | grep -qi "next.*error\|Error:.*page\|render.*error\|application.*error"; then
|
|
||||||
root_cause="application_error"
|
|
||||||
certainty="medium"
|
|
||||||
details=$(echo "$logs" | grep -i "Error:\|render.*error" | head -3 | tr '\n' '; ')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If diagnosis gives strong signal, incorporate it
|
|
||||||
case "$diagnosis" in
|
|
||||||
oom_killed|sigkill)
|
|
||||||
[ "$root_cause" = "unknown" ] && root_cause="container_killed_sigkill"
|
|
||||||
suggested_action="rollback"
|
|
||||||
;;
|
|
||||||
db_down)
|
|
||||||
[ "$root_cause" = "unknown" ] && root_cause="database_disconnected"
|
|
||||||
suggested_action="restart_container"
|
|
||||||
;;
|
|
||||||
health_down)
|
|
||||||
[ "$root_cause" = "unknown" ] && root_cause="container_unresponsive"
|
|
||||||
;;
|
|
||||||
gateway_down)
|
|
||||||
root_cause="nginx_gateway_down"
|
|
||||||
suggested_action="reload_gateway"
|
|
||||||
certainty="high"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Return structured report as JSON-like lines
|
|
||||||
echo "ROOT_CAUSE=$root_cause"
|
|
||||||
echo "CERTAINTY=$certainty"
|
|
||||||
echo "ACTION=$suggested_action"
|
|
||||||
echo "DETAILS=$details"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Phase 4: Restore Protocol ────────────────────────────────
|
|
||||||
# Fast rollback to last known-good state, while capturing
|
|
||||||
# full diagnostics for permanent fix later.
|
|
||||||
|
|
||||||
restore_protocol() {
|
|
||||||
local diagnosis="$1"
|
|
||||||
local root_cause="$2"
|
|
||||||
local action="$3"
|
|
||||||
local details="$4"
|
|
||||||
local logs="$5"
|
|
||||||
local incident_id
|
|
||||||
incident_id="incident_$(date +%Y%m%d_%H%M%S)"
|
|
||||||
|
|
||||||
log "🚨 RESTORE PROTOCOL triggered — incident: $incident_id"
|
|
||||||
log " Diagnosis: $diagnosis"
|
|
||||||
log " Root cause: $root_cause"
|
|
||||||
log " Action: $action"
|
|
||||||
log " Details: $details"
|
|
||||||
|
|
||||||
# ── Step 0: Save full diagnostic context for later fix ─────
|
|
||||||
{
|
|
||||||
echo "=== INCIDENT REPORT: $incident_id ==="
|
|
||||||
echo "Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
||||||
echo "Container: $CONTAINER_NAME"
|
|
||||||
echo "Diagnosis: $diagnosis"
|
|
||||||
echo "Root cause: $root_cause"
|
|
||||||
echo "Certainty: $certainty"
|
|
||||||
echo "Action: $action"
|
|
||||||
echo "Details: $details"
|
|
||||||
echo ""
|
|
||||||
echo "=== Container Logs ==="
|
|
||||||
echo "$logs"
|
|
||||||
echo ""
|
|
||||||
echo "=== Container Inspect ==="
|
|
||||||
docker inspect "$CONTAINER_NAME" 2>/dev/null || docker service inspect "$CONTAINER_NAME" 2>/dev/null || echo "INSPECT_FAILED"
|
|
||||||
echo ""
|
|
||||||
echo "=== Docker Events (last 5 min) ==="
|
|
||||||
timeout 5 docker events --since "${CHECK_INTERVAL}s" --until "$(date +%s)" \
|
|
||||||
--filter "container=$CONTAINER_NAME" \
|
|
||||||
--filter "service=$CONTAINER_NAME" \
|
|
||||||
--format '{{.Time}} {{.Type}} {{.Action}} {{.Status}}' 2>/dev/null || echo "EVENTS_FAILED"
|
|
||||||
echo ""
|
|
||||||
echo "=== System Resources ==="
|
|
||||||
df -h / | tail -1
|
|
||||||
free -m 2>/dev/null || echo "free_unavailable"
|
|
||||||
echo "=== END INCIDENT REPORT ==="
|
|
||||||
} > "$INCIDENTS_DIR/$incident_id.log"
|
|
||||||
|
|
||||||
log "📝 Incident saved to $INCIDENTS_DIR/$incident_id.log"
|
|
||||||
alert "INFO" "Incident $incident_id logged — root cause: $root_cause"
|
|
||||||
|
|
||||||
# Always save cooldown timestamp to prevent detection loops
|
|
||||||
date +%s > /var/state/falah/last_restore_epoch
|
|
||||||
|
|
||||||
# ── Step 1: Check if this is a Swarm service (don't manage containers directly) ──
|
|
||||||
if docker service ls 2>/dev/null | awk '{print $2}' | grep -x "${CONTAINER_NAME}"; then
|
|
||||||
log "ℹ️ Swarm service detected — skipping container management, Swarm handles recovery"
|
|
||||||
date +%s > /var/state/falah/last_restore_epoch
|
|
||||||
log "🔄 RESTORE: forcing service update to trigger redeploy..."
|
|
||||||
docker service update --force "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
alert "INFO" "Swarm service $CONTAINER_NAME force-updated (incident: $incident_id)"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 2: Apply action ───────────────────────────────────
|
|
||||||
case "$action" in
|
|
||||||
rollback)
|
|
||||||
log "🔄 RESTORE: rolling back to $ROLLBACK_IMAGE..."
|
|
||||||
if docker images "$ROLLBACK_IMAGE" --format '{{.ID}}' | head -1 | grep -q .; then
|
|
||||||
# Remove old container
|
|
||||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Recreate from rollback image (omit --network; default bridge works reliably)
|
|
||||||
docker run -d \
|
|
||||||
--name "$CONTAINER_NAME" \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 4014:3000 \
|
|
||||||
-v /var/services/homes/admin/falah-mobile/data:/app/data \
|
|
||||||
"$ROLLBACK_IMAGE" >> "$AGENT_LOG" 2>&1
|
|
||||||
|
|
||||||
sleep 15
|
|
||||||
if check_http "$HEALTH_URL" > /dev/null 2>&1; then
|
|
||||||
date +%s > /var/state/falah/last_restore_epoch
|
|
||||||
log "✅ RESTORE: rollback successful — service restored"
|
|
||||||
alert "INFO" "Restore protocol: rolled back to $ROLLBACK_IMAGE (incident: $incident_id)"
|
|
||||||
record_restart
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
log "❌ RESTORE: rollback also failed — escalation needed"
|
|
||||||
alert "CRITICAL" "Rollback failed for incident $incident_id — manual intervention"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "⚠️ RESTORE: no rollback image found — attempting restart instead"
|
|
||||||
docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 10
|
|
||||||
if check_http "$HEALTH_URL" > /dev/null 2>&1; then
|
|
||||||
date +%s > /var/state/falah/last_restore_epoch
|
|
||||||
log "✅ RESTORE: restart successful (fallback)"
|
|
||||||
record_restart
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
|
|
||||||
restart_container)
|
|
||||||
log "🔄 RESTORE: restarting container..."
|
|
||||||
docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
record_restart
|
|
||||||
sleep 10
|
|
||||||
if check_http "$HEALTH_URL" > /dev/null 2>&1; then
|
|
||||||
log "✅ RESTORE: restart successful"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
log "⚠️ RESTORE: restart didn't fix — escalating to rollback"
|
|
||||||
restore_protocol "$diagnosis" "restart_failed" "rollback" "$details" "$logs"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
|
|
||||||
reload_gateway)
|
|
||||||
log "🔄 RESTORE: reloading nginx gateway..."
|
|
||||||
local gw
|
|
||||||
gw=$(docker ps --format '{{.Names}}' | grep -i "gateway\|nginx" | head -1)
|
|
||||||
[ -n "$gw" ] && docker exec "$gw" nginx -s reload >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 3
|
|
||||||
check_http "$GATEWAY_URL" > /dev/null && log "✅ RESTORE: gateway restored" || log "⚠️ Gateway still down"
|
|
||||||
;;
|
|
||||||
|
|
||||||
hotfix_rate_limit)
|
|
||||||
log "🔄 RESTORE: rate limit issue — applying hotfix"
|
|
||||||
# Rate limiter auto-clears stale entries every 5 min (code-level fix)
|
|
||||||
# For immediate relief, restart container
|
|
||||||
docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
record_restart
|
|
||||||
sleep 10
|
|
||||||
;;
|
|
||||||
|
|
||||||
check_config)
|
|
||||||
log "🔄 RESTORE: config issue detected — rolling back to safe state"
|
|
||||||
restore_protocol "$diagnosis" "config_error" "rollback" "$details" "$logs"
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
log "🔄 RESTORE: unknown action '$action' — attempting restart as safe default"
|
|
||||||
docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
record_restart
|
|
||||||
sleep 10
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Phase 5: Apply Quick Hotfix (when possible) ──────────────
|
|
||||||
|
|
||||||
apply_hotfix() {
|
|
||||||
local root_cause="$1"
|
|
||||||
local logs="$2"
|
|
||||||
|
|
||||||
case "$root_cause" in
|
|
||||||
rate_limit_exceeded)
|
|
||||||
log "🔧 HOTFIX: rate limit exceeded — restarting to clear state"
|
|
||||||
docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 5
|
|
||||||
log "🔧 HOTFIX: rate limiter now auto-cleans every 5 min (code fix in place)"
|
|
||||||
;;
|
|
||||||
|
|
||||||
db_connection_refused|db_unreachable)
|
|
||||||
log "🔧 HOTFIX: DB connection issue — restarting container"
|
|
||||||
docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 10
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
log "🔧 HOTFIX: no quick fix for '$root_cause' — relying on restore protocol"
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Periodic Maintenance ────────────────────────────────────
|
|
||||||
|
|
||||||
periodic_maintenance() {
|
|
||||||
local disk_pct
|
|
||||||
disk_pct=$(check_disk)
|
|
||||||
if [ "$disk_pct" -gt 90 ]; then
|
|
||||||
log "⚠️ Disk at ${disk_pct}% — running prune"
|
|
||||||
docker system prune -f >> "$AGENT_LOG" 2>&1
|
|
||||||
log "✅ Prune completed"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Main Loop ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log "══════════════════════════════════════════════════════"
|
|
||||||
log "🚀 SRE Auto-Healing Agent starting"
|
|
||||||
log " Container: $CONTAINER_NAME"
|
|
||||||
log " Health URL: $HEALTH_URL"
|
|
||||||
log " Gateway URL: $GATEWAY_URL"
|
|
||||||
log " Interval: ${CHECK_INTERVAL}s"
|
|
||||||
log " Max restarts: $MAX_RESTARTS/hour"
|
|
||||||
log " Incidents: $INCIDENTS_DIR"
|
|
||||||
log "══════════════════════════════════════════════════════"
|
|
||||||
|
|
||||||
CYCLE=0
|
|
||||||
HEALTHY_CYCLES=0
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
# ── Detect ──────────────────────────────────────────────
|
|
||||||
DIAGNOSIS=$(detect_fault)
|
|
||||||
|
|
||||||
if [ "$DIAGNOSIS" = "healthy" ]; then
|
|
||||||
# Stability counter — reset restart tracking after 5 min healthy
|
|
||||||
HEALTHY_CYCLES=$((HEALTHY_CYCLES + 1))
|
|
||||||
if [ "$HEALTHY_CYCLES" -ge 10 ]; then
|
|
||||||
HEALTHY_CYCLES=0
|
|
||||||
echo 0 > "$STATE_FILE" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# ── FAULT DETECTED ────────────────────────────────────
|
|
||||||
HEALTHY_CYCLES=0
|
|
||||||
log ""
|
|
||||||
log "⚠️ FAULT DETECTED: $DIAGNOSIS"
|
|
||||||
|
|
||||||
# Step 1: Fetch logs
|
|
||||||
log "📋 Fetching container logs..."
|
|
||||||
CONTAINER_LOGS=$(fetch_logs 100)
|
|
||||||
|
|
||||||
# Step 2: Analyze logs against knowledge base
|
|
||||||
log "🔍 Analyzing logs for root cause..."
|
|
||||||
ANALYSIS=$(analyze_logs "$DIAGNOSIS" "$CONTAINER_LOGS")
|
|
||||||
|
|
||||||
# Parse analysis output
|
|
||||||
ROOT_CAUSE=$(echo "$ANALYSIS" | grep "^ROOT_CAUSE=" | cut -d= -f2-)
|
|
||||||
CERTAINTY=$(echo "$ANALYSIS" | grep "^CERTAINTY=" | cut -d= -f2-)
|
|
||||||
ACTION=$(echo "$ANALYSIS" | grep "^ACTION=" | cut -d= -f2-)
|
|
||||||
DETAILS=$(echo "$ANALYSIS" | grep "^DETAILS=" | cut -d= -f2-)
|
|
||||||
|
|
||||||
log " Root cause: $ROOT_CAUSE (certainty: $CERTAINTY)"
|
|
||||||
log " Action: $ACTION"
|
|
||||||
[ -n "$DETAILS" ] && log " Details: $DETAILS"
|
|
||||||
|
|
||||||
# Step 3: Decision — can we hotfix or need restore protocol?
|
|
||||||
restarts=$(count_restarts)
|
|
||||||
|
|
||||||
if [ "$restarts" -ge "$MAX_RESTARTS" ]; then
|
|
||||||
# Too many restarts — go straight to restore protocol
|
|
||||||
log "⚠️ $restarts restarts in last hour — exceeding limit of $MAX_RESTARTS"
|
|
||||||
log "🚨 SKIPPING hotfix attempt — going directly to restore protocol"
|
|
||||||
restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "rollback" "$DETAILS" "$CONTAINER_LOGS"
|
|
||||||
|
|
||||||
elif [ "$ACTION" = "rollback" ]; then
|
|
||||||
# Knowledge base says rollback — run restore protocol
|
|
||||||
restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "$ACTION" "$DETAILS" "$CONTAINER_LOGS"
|
|
||||||
|
|
||||||
elif [ "$ACTION" = "restart_container" ]; then
|
|
||||||
# Try restart first, escalate if fails
|
|
||||||
log "🔄 Attempting restart (restart #$restarts)..."
|
|
||||||
apply_hotfix "$ROOT_CAUSE" "$CONTAINER_LOGS"
|
|
||||||
sleep 10
|
|
||||||
if [ "$(detect_fault)" != "healthy" ]; then
|
|
||||||
log "⚠️ Restart didn't resolve — escalating to restore protocol"
|
|
||||||
restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "rollback" "$DETAILS" "$CONTAINER_LOGS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [ "$ACTION" = "reload_gateway" ]; then
|
|
||||||
restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "$ACTION" "$DETAILS" "$CONTAINER_LOGS"
|
|
||||||
|
|
||||||
elif [ "$ACTION" = "hotfix_rate_limit" ]; then
|
|
||||||
apply_hotfix "$ROOT_CAUSE" "$CONTAINER_LOGS"
|
|
||||||
|
|
||||||
else
|
|
||||||
# No specific action — safe default: restart, then rollback
|
|
||||||
log "🔄 No specific fix for '$ROOT_CAUSE' — attempting restart"
|
|
||||||
docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1
|
|
||||||
record_restart
|
|
||||||
sleep 10
|
|
||||||
[ "$(detect_fault)" != "healthy" ] && restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "rollback" "$DETAILS" "$CONTAINER_LOGS"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Periodic maintenance (every 10 cycles = 5 min) ─────
|
|
||||||
CYCLE=$((CYCLE + 1))
|
|
||||||
[ "$CYCLE" -ge 10 ] && { CYCLE=0; periodic_maintenance; }
|
|
||||||
|
|
||||||
# ── Cross-check: DNS healer alive? ────────────────────
|
|
||||||
if ! docker ps --filter 'name=falah-dns-healer' --format '{{.Status}}' 2>/dev/null | grep -q '^Up'; then
|
|
||||||
log "⚠️ Sibling DNS healer DOWN — restarting..."
|
|
||||||
docker restart falah-dns-healer 2>/dev/null || \
|
|
||||||
docker run -d --name falah-dns-healer --restart unless-stopped \
|
|
||||||
--network host \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
|
||||||
-v /var/log/falah:/var/log/falah \
|
|
||||||
-e CHECK_INTERVAL=60 \
|
|
||||||
falah-dns-healer:latest >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 3
|
|
||||||
if docker ps --filter 'name=falah-dns-healer' --format '{{.Status}}' | grep -q '^Up'; then
|
|
||||||
alert "INFO" "Cross-heal: restarted DNS healer"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep "$CHECK_INTERVAL"
|
|
||||||
done
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
|
||||||
falah-auto-healer:
|
|
||||||
build:
|
|
||||||
context: ./auto-healer
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: falah-auto-healer:latest
|
|
||||||
container_name: falah-auto-healer
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- /var/log/falah:/var/log/falah
|
|
||||||
- /var/state/falah:/var/state/falah
|
|
||||||
environment:
|
|
||||||
- CONTAINER_NAME=falah-mobile-staging
|
|
||||||
- HEALTH_URL=http://192.168.0.11:4014/mobile/api/health
|
|
||||||
- GATEWAY_URL=http://192.168.0.11:7878/mobile/api/health
|
|
||||||
- CHECK_INTERVAL=30
|
|
||||||
- MAX_RESTARTS_PER_HOUR=3
|
|
||||||
- ROLLBACK_IMAGE=falah-mobile:rollback
|
|
||||||
network_mode: "host"
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
FROM alpine:3.19
|
|
||||||
RUN apk add --no-cache curl docker-cli bash jq drill
|
|
||||||
WORKDIR /app
|
|
||||||
COPY dns-router-heal.sh /app/dns-router-heal.sh
|
|
||||||
RUN chmod +x /app/dns-router-heal.sh && mkdir -p /var/log/falah /var/state/falah
|
|
||||||
CMD ["/app/dns-router-heal.sh"]
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# Falah Mobile — DNS & Domain Routing Auto-Healer
|
|
||||||
# ============================================================
|
|
||||||
# Monitors and fixes:
|
|
||||||
# - Cloudflare tunnels (primary + fallback)
|
|
||||||
# - Traefik (if present — Swarm or standalone)
|
|
||||||
# - DNS resolution for all falah domains
|
|
||||||
# - nginx gateway routing to microservices
|
|
||||||
# - SSL/TLS certificate validity
|
|
||||||
# - End-to-end public URL health
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# ── Configuration ────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Domains to monitor
|
|
||||||
DOMAINS="${DOMAINS:-falahos.my api.falahos.my git.falahos.my app.falahos.my}"
|
|
||||||
|
|
||||||
# Internal URLs to probe
|
|
||||||
DIRECT_HEALTH="${DIRECT_HEALTH:-http://localhost:4014/mobile/api/health}"
|
|
||||||
GATEWAY_URL="${GATEWAY_URL:-http://localhost:7878/mobile/api/health}"
|
|
||||||
TUNNEL_URL="${TUNNEL_URL:-https://falahos.my/mobile/api/health}"
|
|
||||||
|
|
||||||
# Tunnel container names
|
|
||||||
TUNNEL_PRIMARY="${TUNNEL_PRIMARY:-cloudflare-YT01}"
|
|
||||||
TUNNEL_FALLBACK="${TUNNEL_FALLBACK:-falah-tunnel}"
|
|
||||||
|
|
||||||
# Gateway container
|
|
||||||
GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-falah-umbrel-api-gateway}"
|
|
||||||
|
|
||||||
# Traefik (if deployed - may be Swarm service or standalone)
|
|
||||||
TRAEFIK_CONTAINER="${TRAEFIK_CONTAINER:-falah_traefik}"
|
|
||||||
|
|
||||||
# Expected Cloudflare proxy IPs (anycast)
|
|
||||||
EXPECTED_CF_IPS="${EXPECTED_CF_IPS:-104.16.0.0/12 172.64.0.0/13}"
|
|
||||||
|
|
||||||
# Synology nginx SSL cert path
|
|
||||||
SYNO_CERT="${SYNO_CERT:-/usr/syno/etc/certificate/system/default/fullchain.pem}"
|
|
||||||
|
|
||||||
CHECK_INTERVAL="${CHECK_INTERVAL:-60}" # 1 min for DNS
|
|
||||||
AGENT_LOG="/var/log/falah/dns-healer.log"
|
|
||||||
STATE_DIR="/var/state/falah"
|
|
||||||
mkdir -p "$STATE_DIR" "$(dirname "$AGENT_LOG")"
|
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [dns] $*" | tee -a "$AGENT_LOG"; }
|
|
||||||
alert() { local s="$1" m="$2"; log "[$s] $m"; }
|
|
||||||
|
|
||||||
# ── Helper: run docker from inside or outside ───────────────
|
|
||||||
|
|
||||||
docker_cmd() {
|
|
||||||
# Works whether running inside container (has docker socket) or on host
|
|
||||||
docker "$@" 2>/dev/null || /var/packages/Docker/target/usr/bin/docker "$@" 2>/dev/null || return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 1. CLOUDFLARE TUNNEL HEALTH ─────────────────────────────
|
|
||||||
|
|
||||||
check_tunnel() {
|
|
||||||
local name="$1"
|
|
||||||
local status
|
|
||||||
|
|
||||||
status=$(docker_cmd ps --filter "name=$name" --format '{{.Status}}' 2>/dev/null)
|
|
||||||
if echo "$status" | grep -q "^Up"; then
|
|
||||||
log " ✅ Tunnel $name: $status"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
log " ❌ Tunnel $name: DOWN ($status)"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
fix_tunnel() {
|
|
||||||
local name="$1"
|
|
||||||
log "🔄 Restarting tunnel $name..."
|
|
||||||
docker_cmd restart "$name" >> "$AGENT_LOG" 2>&1 && {
|
|
||||||
sleep 5
|
|
||||||
if check_tunnel "$name"; then
|
|
||||||
alert "INFO" "Auto-healed: restarted tunnel $name"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
alert "CRITICAL" "Failed to restart tunnel $name"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 2. DNS RESOLUTION ───────────────────────────────────────
|
|
||||||
|
|
||||||
check_dns() {
|
|
||||||
local all_ok=0
|
|
||||||
|
|
||||||
for domain in $DOMAINS; do
|
|
||||||
local ips
|
|
||||||
ips=$(drill "$domain" 2>/dev/null | grep -A1 "ANSWER SECTION" | tail -1 | awk '{print $5}' || \
|
|
||||||
dig +short "$domain" 2>/dev/null || \
|
|
||||||
nslookup "$domain" 2>/dev/null | grep "Address:" | tail -1 | awk '{print $2}')
|
|
||||||
|
|
||||||
if [ -z "$ips" ]; then
|
|
||||||
log " ❌ DNS: $domain → UNRESOLVABLE"
|
|
||||||
all_ok=1
|
|
||||||
elif echo "$ips" | grep -qE "^104\.|^172\.|^162\.|^198\."; then
|
|
||||||
log " ✅ DNS: $domain → $ips (Cloudflare)"
|
|
||||||
else
|
|
||||||
log " ⚠️ DNS: $domain → $ips (non-Cloudflare IP — may be direct)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
return $all_ok
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 3. PUBLIC END-TO-END (via Cloudflare tunnel) ───────────
|
|
||||||
|
|
||||||
check_public_endpoint() {
|
|
||||||
local code
|
|
||||||
code=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 15 "$TUNNEL_URL" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
case "$code" in
|
|
||||||
200|301|302|401|403)
|
|
||||||
log " ✅ Public endpoint: $TUNNEL_URL → HTTP $code"
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
521|522|523|524|525|526|527)
|
|
||||||
log " ❌ Public endpoint: $TUNNEL_URL → Cloudflare origin error $code"
|
|
||||||
return 2
|
|
||||||
;;
|
|
||||||
502|503|504)
|
|
||||||
log " ❌ Public endpoint: $TUNNEL_URL → upstream error $code"
|
|
||||||
return 3
|
|
||||||
;;
|
|
||||||
000)
|
|
||||||
log " ❌ Public endpoint: $TUNNEL_URL → UNREACHABLE (DNS or network)"
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
log " ⚠️ Public endpoint: $TUNNEL_URL → HTTP $code"
|
|
||||||
return 4
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 4. GATEWAY HEALTH ──────────────────────────────────────
|
|
||||||
|
|
||||||
check_gateway() {
|
|
||||||
local code body
|
|
||||||
|
|
||||||
# Check container
|
|
||||||
local status
|
|
||||||
status=$(docker_cmd ps --filter "name=$GATEWAY_CONTAINER" --format '{{.Status}}' 2>/dev/null)
|
|
||||||
if ! echo "$status" | grep -q "^Up"; then
|
|
||||||
log " ❌ Gateway container: DOWN ($status)"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check nginx inside
|
|
||||||
code=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 5 "$GATEWAY_URL" 2>/dev/null || echo "000")
|
|
||||||
if [ "$code" = "200" ]; then
|
|
||||||
log " ✅ Gateway: $GATEWAY_URL → HTTP $code"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
log " ❌ Gateway: $GATEWAY_URL → HTTP $code"
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
fix_gateway() {
|
|
||||||
log "🔄 Reloading nginx in gateway..."
|
|
||||||
docker_cmd exec "$GATEWAY_CONTAINER" nginx -s reload >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# If reload failed, try restarting container
|
|
||||||
local code
|
|
||||||
code=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 5 "$GATEWAY_URL" 2>/dev/null || echo "000")
|
|
||||||
if [ "$code" != "200" ]; then
|
|
||||||
log "🔄 Gateway reload didn't fix — restarting container..."
|
|
||||||
docker_cmd restart "$GATEWAY_CONTAINER" >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 5
|
|
||||||
fi
|
|
||||||
|
|
||||||
if check_gateway; then
|
|
||||||
alert "INFO" "Auto-healed: restored gateway routing"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 5. TRAEFIK HEALTH (if deployed) ─────────────────────────
|
|
||||||
|
|
||||||
check_swarm() {
|
|
||||||
local token_file="/var/state/falah/swarm-token"
|
|
||||||
local manager_file="/var/state/falah/swarm-manager"
|
|
||||||
|
|
||||||
# Check if we're in a swarm
|
|
||||||
local swarm_status
|
|
||||||
swarm_status=$(docker info 2>/dev/null | grep "Swarm:" | awk '{print $2}')
|
|
||||||
|
|
||||||
if [ "$swarm_status" != "active" ]; then
|
|
||||||
log " ❌ Swarm: INACTIVE — attempting rejoin..."
|
|
||||||
if [ -f "$token_file" ] && [ -f "$manager_file" ]; then
|
|
||||||
local token manager
|
|
||||||
token=$(cat "$token_file")
|
|
||||||
manager=$(cat "$manager_file")
|
|
||||||
docker swarm join --token "$token" "$manager" >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 3
|
|
||||||
swarm_status=$(docker info 2>/dev/null | grep "Swarm:" | awk '{print $2}')
|
|
||||||
if [ "$swarm_status" = "active" ]; then
|
|
||||||
log " ✅ Swarm: rejoined successfully"
|
|
||||||
alert "INFO" "Auto-healed: rejoined Docker Swarm"
|
|
||||||
else
|
|
||||||
log " ❌ Swarm: rejoin failed"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log " ❌ Swarm: no join token found at $token_file"
|
|
||||||
fi
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check this node's role
|
|
||||||
local is_manager
|
|
||||||
is_manager=$(docker info 2>/dev/null | grep "Is Manager" | awk '{print $3}')
|
|
||||||
if [ "$is_manager" = "true" ]; then
|
|
||||||
log " ✅ Swarm: active (manager)"
|
|
||||||
# Count nodes
|
|
||||||
local node_count
|
|
||||||
node_count=$(docker node ls 2>/dev/null | tail -n +2 | wc -l)
|
|
||||||
log " 📊 Swarm nodes: $node_count"
|
|
||||||
# Check if we can see Contabo
|
|
||||||
docker node ls 2>/dev/null | grep -q "vmi3361598" && log " ✅ Contabo: in swarm" || log " ⚠️ Contabo: missing from swarm"
|
|
||||||
# Check if Mac Mini is back
|
|
||||||
docker node ls 2>/dev/null | grep -q "colima" && log " ✅ Mac Mini: in swarm" || log " ⚪ Mac Mini: not in swarm (offline)"
|
|
||||||
else
|
|
||||||
log " ✅ Swarm: active (worker)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_traefik() {
|
|
||||||
# Traefik may be running as standalone container or Swarm service
|
|
||||||
local status
|
|
||||||
|
|
||||||
# Check standalone
|
|
||||||
status=$(docker_cmd ps --filter "name=$TRAEFIK_CONTAINER" --format '{{.Status}}' 2>/dev/null)
|
|
||||||
if echo "$status" | grep -q "^Up"; then
|
|
||||||
log " ✅ Traefik (standalone): $status"
|
|
||||||
# Check Traefik API
|
|
||||||
local traefik_health
|
|
||||||
traefik_health=$(curl -sf --max-time 5 http://localhost:8080/api/version 2>/dev/null || echo "")
|
|
||||||
if [ -n "$traefik_health" ]; then
|
|
||||||
log " ✅ Traefik API: responsive"
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Swarm service (if still in swarm)
|
|
||||||
local svc_status
|
|
||||||
svc_status=$(docker_cmd service ps "$TRAEFIK_CONTAINER" --format '{{.CurrentState}}' 2>/dev/null | head -1)
|
|
||||||
if [ -n "$svc_status" ]; then
|
|
||||||
log " ⚠️ Traefik (Swarm): $svc_status"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log " ⚪ Traefik: not deployed (skipped)"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 6. SSL CERTIFICATE CHECK ───────────────────────────────
|
|
||||||
|
|
||||||
check_cert() {
|
|
||||||
# Check Synology cert expiry
|
|
||||||
if [ -f "$SYNO_CERT" ]; then
|
|
||||||
local expiry
|
|
||||||
expiry=$(openssl x509 -enddate -noout -in "$SYNO_CERT" 2>/dev/null | cut -d= -f2)
|
|
||||||
local expiry_epoch
|
|
||||||
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || echo 0)
|
|
||||||
local now
|
|
||||||
now=$(date +%s)
|
|
||||||
local days_left=$(( (expiry_epoch - now) / 86400 ))
|
|
||||||
|
|
||||||
if [ "$days_left" -lt 0 ]; then
|
|
||||||
log " ❌ SSL Cert: EXPIRED ($days_left days ago)"
|
|
||||||
return 2
|
|
||||||
elif [ "$days_left" -lt 7 ]; then
|
|
||||||
log " ⚠️ SSL Cert: expires in $days_left days"
|
|
||||||
return 1
|
|
||||||
else
|
|
||||||
log " ✅ SSL Cert: valid for $days_left more days"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log " ⚪ SSL Cert: no local cert found"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 7. DIRECT SERVICE HEALTH ────────────────────────────────
|
|
||||||
|
|
||||||
check_direct() {
|
|
||||||
local code
|
|
||||||
code=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 5 "$DIRECT_HEALTH" 2>/dev/null || echo "000")
|
|
||||||
if [ "$code" = "200" ]; then
|
|
||||||
log " ✅ Direct: $DIRECT_HEALTH → HTTP $code"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
log " ❌ Direct: $DIRECT_HEALTH → HTTP $code"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 8. ROUTING INTEGRITY ────────────────────────────────────
|
|
||||||
|
|
||||||
check_routing_integrity() {
|
|
||||||
# Verify the gateway actually proxies to the mobile service
|
|
||||||
local gateway_body direct_body
|
|
||||||
|
|
||||||
gateway_body=$(curl -sf --max-time 5 "$GATEWAY_URL" 2>/dev/null)
|
|
||||||
direct_body=$(curl -sf --max-time 5 "$DIRECT_HEALTH" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$gateway_body" = "$direct_body" ] || echo "$gateway_body" | grep -q '"status":"ok"'; then
|
|
||||||
log " ✅ Routing: gateway → direct response matches"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
log " ❌ Routing: gateway response differs from direct!"
|
|
||||||
log " Gateway: $(echo $gateway_body | head -c 100)"
|
|
||||||
log " Direct: $(echo $direct_body | head -c 100)"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Main Loop ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
log ""
|
|
||||||
log "══════════════════════════════════════════════════════"
|
|
||||||
log "🌐 DNS & Domain Routing Auto-Healer starting"
|
|
||||||
log " Domains: $DOMAINS"
|
|
||||||
log " Tunnels: $TUNNEL_PRIMARY, $TUNNEL_FALLBACK"
|
|
||||||
log " Gateway: $GATEWAY_CONTAINER"
|
|
||||||
log " Traefik: $TRAEFIK_CONTAINER (optional)"
|
|
||||||
log " Public URL: $TUNNEL_URL"
|
|
||||||
log " Interval: ${CHECK_INTERVAL}s"
|
|
||||||
log "══════════════════════════════════════════════════════"
|
|
||||||
|
|
||||||
CYCLE=0
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
CYCLE=$((CYCLE + 1))
|
|
||||||
log ""
|
|
||||||
log "── Cycle $CYCLE ──────────────────────────────────────"
|
|
||||||
|
|
||||||
# ── 1. Check tunnels ─────────────────────────────────────
|
|
||||||
log "1) Cloudflare Tunnels:"
|
|
||||||
if ! check_tunnel "$TUNNEL_PRIMARY"; then
|
|
||||||
fix_tunnel "$TUNNEL_PRIMARY"
|
|
||||||
# If primary failed, check fallback
|
|
||||||
if ! check_tunnel "$TUNNEL_FALLBACK"; then
|
|
||||||
fix_tunnel "$TUNNEL_FALLBACK"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Check DNS ─────────────────────────────────────────
|
|
||||||
log "2) DNS Resolution:"
|
|
||||||
check_dns
|
|
||||||
|
|
||||||
# ── 3. Check direct service ──────────────────────────────
|
|
||||||
log "3) Direct Service:"
|
|
||||||
check_direct
|
|
||||||
|
|
||||||
# ── 4. Check gateway ─────────────────────────────────────
|
|
||||||
log "4) Gateway Routing:"
|
|
||||||
if ! check_gateway; then
|
|
||||||
fix_gateway
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 5. Check routing integrity ────────────────────────────
|
|
||||||
log "5) Routing Integrity:"
|
|
||||||
check_routing_integrity
|
|
||||||
|
|
||||||
# ── 6. Check public endpoint (every 5th cycle) ───────────
|
|
||||||
log "6) Public Endpoint (via Cloudflare):"
|
|
||||||
if [ $((CYCLE % 5)) -eq 0 ]; then
|
|
||||||
check_public_endpoint
|
|
||||||
else
|
|
||||||
log " (skipped — runs every 5 cycles)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 7. Check SSL certs (every 10th cycle) ────────────────
|
|
||||||
log "7) SSL Certificates:"
|
|
||||||
if [ $((CYCLE % 10)) -eq 0 ]; then
|
|
||||||
check_cert
|
|
||||||
else
|
|
||||||
log " (skipped — runs every 10 cycles)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 8. Swarm membership check (every 2nd cycle) ──────────
|
|
||||||
log "8) Docker Swarm:"
|
|
||||||
if [ $((CYCLE % 2)) -eq 0 ]; then
|
|
||||||
check_swarm
|
|
||||||
else
|
|
||||||
log " (skipped — runs every 2 cycles)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 9. Check Traefik (every 5th cycle) ───────────────────
|
|
||||||
log "9) Traefik:"
|
|
||||||
if [ $((CYCLE % 5)) -eq 0 ]; then
|
|
||||||
check_traefik
|
|
||||||
else
|
|
||||||
log " (skipped — runs every 5 cycles)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "── Cycle $CYCLE complete ─────────────────────────────"
|
|
||||||
|
|
||||||
# ── Cross-check: App healer alive? ─────────────────────
|
|
||||||
if ! docker ps --filter 'name=falah-auto-healer' --format '{{.Status}}' 2>/dev/null | grep -q '^Up'; then
|
|
||||||
log "⚠️ Sibling app healer DOWN — restarting..."
|
|
||||||
docker restart falah-auto-healer 2>/dev/null || \
|
|
||||||
docker run -d --name falah-auto-healer --restart unless-stopped \
|
|
||||||
--network host \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
|
||||||
-v /var/log/falah:/var/log/falah \
|
|
||||||
-v /var/state/falah:/var/state/falah \
|
|
||||||
-e CONTAINER_NAME=falah-mobile-staging \
|
|
||||||
-e CHECK_INTERVAL=30 \
|
|
||||||
falah-auto-healer:v6 >> "$AGENT_LOG" 2>&1
|
|
||||||
sleep 3
|
|
||||||
if docker ps --filter 'name=falah-auto-healer' --format '{{.Status}}' | grep -q '^Up'; then
|
|
||||||
alert "INFO" "Cross-heal: restarted app healer"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep "$CHECK_INTERVAL"
|
|
||||||
done
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
|
||||||
falah-dns-healer:
|
|
||||||
build:
|
|
||||||
context: ./dns-router-healer
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: falah-dns-healer:latest
|
|
||||||
container_name: falah-dns-healer
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
- /var/log/falah:/var/log/falah
|
|
||||||
- /var/state/falah:/var/state/falah
|
|
||||||
environment:
|
|
||||||
- DOMAINS=falahos.my api.falahos.my git.falahos.my app.falahos.my
|
|
||||||
- DIRECT_HEALTH=http://localhost:4014/mobile/api/health
|
|
||||||
- GATEWAY_URL=http://localhost:7878/mobile/api/health
|
|
||||||
- TUNNEL_URL=https://falahos.my/mobile/api/health
|
|
||||||
- TUNNEL_PRIMARY=cloudflare-YT01
|
|
||||||
- TUNNEL_FALLBACK=falah-tunnel
|
|
||||||
- GATEWAY_CONTAINER=falah-umbrel-api-gateway
|
|
||||||
- TRAEFIK_CONTAINER=falah_traefik
|
|
||||||
- CHECK_INTERVAL=60
|
|
||||||
network_mode: "host"
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
|
||||||
falah-mobile-staging:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: falah-mobile:staging
|
|
||||||
ports:
|
|
||||||
- "4014:3000"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- HOSTNAME=0.0.0.0
|
|
||||||
- NEXT_PUBLIC_APP_URL=https://falahos.my/mobile-staging
|
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
|
||||||
- DATABASE_URL=file:/app/data/staging.db
|
|
||||||
- LLM_API_KEY=${LLM_API_KEY:-}
|
|
||||||
- LLM_BASE_URL=${LLM_BASE_URL:-}
|
|
||||||
- LLM_MODEL=${LLM_MODEL:-qwen3.6-plus}
|
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
|
|
||||||
# Polar.sh payments (optional — mock mode used when unset)
|
|
||||||
- POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-}
|
|
||||||
- POLAR_FLH_500=${POLAR_FLH_500:-}
|
|
||||||
- POLAR_FLH_1000=${POLAR_FLH_1000:-}
|
|
||||||
- POLAR_FLH_5000=${POLAR_FLH_5000:-}
|
|
||||||
- POLAR_FLH_10000=${POLAR_FLH_10000:-}
|
|
||||||
- POLAR_FLH_50000=${POLAR_FLH_50000:-}
|
|
||||||
- POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET:-}
|
|
||||||
volumes:
|
|
||||||
- falah-mobile-staging-data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/mobile/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
falah-mobile-staging-data:
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
|
||||||
falah-mobile:
|
|
||||||
build: .
|
|
||||||
image: falah-mobile:latest
|
|
||||||
ports:
|
|
||||||
- "4013:3000"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- HOSTNAME=0.0.0.0
|
|
||||||
- NEXT_PUBLIC_APP_URL=https://falahos.my/mobile
|
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
|
||||||
- DATABASE_URL=file:/app/data/dev.db
|
|
||||||
- UMMAHID_URL=https://ummahid.falahos.my
|
|
||||||
- LLM_API_KEY=${LLM_API_KEY:-}
|
|
||||||
- LLM_BASE_URL=${LLM_BASE_URL:-}
|
|
||||||
- LLM_MODEL=${LLM_MODEL:-qwen3.6-plus}
|
|
||||||
# Polar.sh payments (optional — mock mode used when unset)
|
|
||||||
- POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-}
|
|
||||||
- POLAR_FLH_500=${POLAR_FLH_500:-}
|
|
||||||
- POLAR_FLH_1000=${POLAR_FLH_1000:-}
|
|
||||||
- POLAR_FLH_5000=${POLAR_FLH_5000:-}
|
|
||||||
- POLAR_FLH_10000=${POLAR_FLH_10000:-}
|
|
||||||
- POLAR_FLH_50000=${POLAR_FLH_50000:-}
|
|
||||||
- POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET:-}
|
|
||||||
volumes:
|
|
||||||
- falah-mobile-data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/mobile/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
falah-mobile-data:
|
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { dirname } from "path";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import { fileURLToPath } from "url";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
...nextVitals,
|
const __dirname = dirname(__filename);
|
||||||
...nextTs,
|
|
||||||
// Override default ignores of eslint-config-next.
|
|
||||||
globalIgnores([
|
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [...compat.extends("next/core-web-vitals")];
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Halal Monitor — Strategic Brief
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
Rebrand the existing World Monitor project as **Halal Monitor** — a premium feature of FalahMobile. A global map-based dashboard for the Muslim ummah to find halal restaurants, mosques, and prayer spaces anywhere in the world.
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### Phase 1 — MVP (Map + Data)
|
||||||
|
| Feature | Description | Data Source | API Cost |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Mosques & Prayer Places** | Map of nearby mosques worldwide with name, address, prayer times | Overpass API (OpenStreetMap) | **Free** |
|
||||||
|
| **Halal Restaurants** | Halal-certified/muslim-owned restaurants in major cities | Google Places API + OSM tags | Google: ~$7/1K calls (free $200/mo credit) |
|
||||||
|
| **Search by City** | Search bar + autocomplete for city/mosque/restaurant | Nominatim (OSM geocoder) | **Free** |
|
||||||
|
| **Current Location** | "Near me" — geolocate user and show nearby places | Browser Geolocation API | **Free** |
|
||||||
|
|
||||||
|
### Phase 2 — Premium Enhancements (FalahMobile Premium)
|
||||||
|
| Feature | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Halal Certifications** | Verified halal certification badges per restaurant |
|
||||||
|
| **User Reviews & Ratings** | Community-driven halal rating system |
|
||||||
|
| **Bookmarks & Favorites** | Save places, get alerts when new verified places added |
|
||||||
|
| **Trip Planner** | Plan routes with halal restaurants + prayer stops along the way |
|
||||||
|
| **Crowd-Sourced Updates** | Premium users can submit new halal places for verification |
|
||||||
|
|
||||||
|
### Phase 3 — Platform
|
||||||
|
| Feature | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Weekly Digest** | New halal places in your city, emailed/notified |
|
||||||
|
| **Monthly Trends** | Most-reviewed, top-rated, newly certified |
|
||||||
|
| **Ambassador Program** | Community mods who verify new submissions |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Integration into FalahMobile
|
||||||
|
|
||||||
|
```
|
||||||
|
FalahMobile (Next.js 16 App Router)
|
||||||
|
├── /halal-monitor → Page (gated: isPremium required)
|
||||||
|
├── /api/halal/places → Nearby places API (Overpass + Google)
|
||||||
|
├── /api/halal/bookmarks → User bookmarks CRUD
|
||||||
|
├── /api/halal/usage → Track API usage/quotas (for free tier)
|
||||||
|
└── lib/halal-monitor.ts → Shared helpers (geocoding, map utils)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Choices
|
||||||
|
|
||||||
|
| Concern | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| **Map renderer** | **Leaflet** (react-leaflet) | Free, no API key, light (~40KB gzip), works with OSM tiles |
|
||||||
|
| **Map tiles** | OpenStreetMap tile layer | Free tier, no API key, or optional MapTiler ($) for styled maps |
|
||||||
|
| **Mosque data** | **Overpass API** | OpenStreetMap query language, query `amenity=place_of_worship + religion=muslim` |
|
||||||
|
| **Halal restaurant data** | OSM tags (`diet:halal=yes`) + Google Places fallback | OSM free but less complete; Google fills gaps |
|
||||||
|
| **Geocoding** | Nominatim (OSM) | Free, 1 req/sec limit — fine for single-user |
|
||||||
|
| **Premium gating** | `user.isPremium` in AuthContext | Already exists in Prisma schema |
|
||||||
|
| **Map markers clustering** | `leaflet.markercluster` | Handles 1000+ markers smoothly |
|
||||||
|
| **Styling** | TailwindCSS dark theme (existing) | Consistent with FalahMobile design |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User opens /halal-monitor
|
||||||
|
→ Auth check: user.isPremium? No → Show upgrade CTA
|
||||||
|
→ Yes → Browser geolocates (or city search)
|
||||||
|
→ Fetch /api/halal/places?lat=X&lng=Y&radius=5000
|
||||||
|
→ Server queries Overpass API for mosques + halal restaurants
|
||||||
|
→ Caches results in SQLite (expire after 24h)
|
||||||
|
→ Returns GeoJSON
|
||||||
|
→ Render map with Leaflet + clustered markers
|
||||||
|
→ Two marker layers: mosques (green) / restaurants (blue)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Premium Gating Strategy
|
||||||
|
|
||||||
|
### What's Free vs Premium
|
||||||
|
|
||||||
|
| Feature | Free Users | Premium Users |
|
||||||
|
|---|---|---|
|
||||||
|
| View map | ✅ | ✅ |
|
||||||
|
| Search cities | ✅ | ✅ |
|
||||||
|
| See mosques | ✅ | ✅ |
|
||||||
|
| See halal restaurants | ❌ | ✅ |
|
||||||
|
| Bookmarks | ❌ (view-only) | ✅ (save & organize) |
|
||||||
|
| API calls/day | 20 | Unlimited |
|
||||||
|
| Crowd-sourced edits | ❌ | ✅ |
|
||||||
|
| Trip planner | ❌ | ✅ |
|
||||||
|
|
||||||
|
### Upgrade CTA Placement
|
||||||
|
- **Souq page** — small "Upgrade to Premium" badge in sidebar
|
||||||
|
- **Halal Monitor page** — if not premium, show map with greyed-out restaurant layer + upgrade prompt
|
||||||
|
- **Navbar** — crown icon next to user name for premium users; "✨ Upgrade" for free users
|
||||||
|
|
||||||
|
## Monetization
|
||||||
|
- **Premium subscription** — unlocks full Halal Monitor + other premium features
|
||||||
|
- **Price** — TBD (existing isPremium/isPro fields support multiple tiers)
|
||||||
|
- **Listing fee discount** — premium users pay 5% platform fee instead of 10%
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1 (build in this session)
|
||||||
|
1. ✅ Create this strategic brief
|
||||||
|
2. **Halal Monitor page** — `/halal-monitor` with Leaflet map, premium gate
|
||||||
|
3. **API route** — `/api/halal/places` querying Overpass API for mosques
|
||||||
|
4. **Navbar update** — add Halal Monitor link (with premium badge)
|
||||||
|
5. **Premium gating** — `isPremium` check on page access
|
||||||
|
6. **Deploy** — rebuild image, push to Synology, update Dockge stack
|
||||||
|
7. **QA** — test map rendering, mosque search, premium gate
|
||||||
|
|
||||||
|
### Phase 2 (next session)
|
||||||
|
1. Halal restaurants layer (Google Places API)
|
||||||
|
2. Bookmarks CRUD
|
||||||
|
3. Crowd-sourced submission form
|
||||||
|
4. Trip planner
|
||||||
|
|
||||||
|
### Phase 3 (future)
|
||||||
|
1. Community review system
|
||||||
|
2. Weekly digests
|
||||||
|
3. Ambassador verification program
|
||||||
|
|
||||||
|
## Data & Privacy
|
||||||
|
- No user location data stored server-side — geolocation is ephemeral (browser)
|
||||||
|
- Bookmark data stored in SQLite (existing FalahMobile DB)
|
||||||
|
- Overpass API calls are anonymous
|
||||||
|
- Rate limiting: 60 req/min per user on free tier (enforced server-side)
|
||||||
|
|
||||||
|
## Key Risks & Mitigations
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| OSM data incomplete for halal restaurants | Add Google Places API fallback + crowd-sourced submissions |
|
||||||
|
| Overpass API rate limits (1 req/sec) | Server-side caching (24h TTL), batch queries |
|
||||||
|
| Free users burning API quota | Hard cap at 20 req/day for free, tracked via usage table |
|
||||||
|
| iCloud datalock on World Monitor source | Build fresh from OSM + Google APIs — don't depend on old code |
|
||||||
|
| Premium adoption low | Cross-sell on Souq page, make Halal Monitor a visible premium showcase |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: June 8, 2026*
|
||||||
|
*Status: Strategic Brief — Ready for Phase 1 implementation*
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -2,13 +2,8 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
basePath: "/mobile",
|
turbopack: {
|
||||||
assetPrefix: "/mobile",
|
root: process.cwd(),
|
||||||
typescript: {
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
optimizePackageImports: ["lucide-react"],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "falah-mobile",
|
"name": "flh-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,19 +10,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"leaflet": "^1.9.4",
|
"lucide-react": "^1.17.0",
|
||||||
"lucide-react": "^1.18.0",
|
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
"pdfkit": "^0.19.1",
|
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"stripe": "^17.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"stripe": "^22.2.1"
|
"@types/leaflet": "^1.9.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ const config = {
|
|||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
export default config
|
||||||
export default config;
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["rhel-openssl-3.0.x"]
|
binaryTargets = ["native", "linux-musl"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "sqlite"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,10 +13,7 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
name String
|
name String
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
provider String @default("email")
|
flhBalance Int @default(0)
|
||||||
providerId String?
|
|
||||||
avatar String?
|
|
||||||
flhBalance Int @default(5000)
|
|
||||||
isPro Boolean @default(false)
|
isPro Boolean @default(false)
|
||||||
isPremium Boolean @default(false)
|
isPremium Boolean @default(false)
|
||||||
experienceLevel String?
|
experienceLevel String?
|
||||||
@@ -25,82 +22,17 @@ model User {
|
|||||||
preferredName String?
|
preferredName String?
|
||||||
coachingGoals String?
|
coachingGoals String?
|
||||||
lastCoachedAt DateTime?
|
lastCoachedAt DateTime?
|
||||||
dailyMsgCount Int @default(0)
|
|
||||||
dailyMsgResetAt DateTime?
|
|
||||||
stripeCustomerId String?
|
|
||||||
stripeSubscriptionId String?
|
|
||||||
trialEndsAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([provider, providerId])
|
|
||||||
|
|
||||||
listings Listing[]
|
listings Listing[]
|
||||||
purchases Purchase[] @relation("BuyerPurchases")
|
purchases Purchase[] @relation("BuyerPurchases")
|
||||||
sales Purchase[] @relation("SellerPurchases")
|
sales Purchase[] @relation("SellerPurchases")
|
||||||
cashouts CashoutRequest[]
|
cashouts CashoutRequest[]
|
||||||
halalBookmarks HalalBookmark[]
|
halalBookmarks HalalBookmark[]
|
||||||
halalUsage HalalUsage?
|
halalUsage HalalUsage?
|
||||||
userPlaces UserPlace[]
|
|
||||||
ownedGroups Group[] @relation("GroupOwner")
|
|
||||||
groupMemberships GroupMember[]
|
|
||||||
forumThreads ForumThread[]
|
forumThreads ForumThread[]
|
||||||
forumPosts ForumPost[]
|
forumPosts ForumPost[]
|
||||||
chatHistory ChatHistory[]
|
chatHistory ChatHistory[]
|
||||||
notifications Notification[]
|
|
||||||
referredReferrals Referral[] @relation("Referrer")
|
|
||||||
referrerReferral Referral? @relation("Referred")
|
|
||||||
dailyStreak DailyStreak?
|
|
||||||
xpTransactions XpTransaction[]
|
|
||||||
achievements Achievement[]
|
|
||||||
userLevel UserLevel?
|
|
||||||
dhikrSessions DhikrSession[]
|
|
||||||
dhikrDays DhikrDay[]
|
|
||||||
reviewListings Review[] @relation("ListingReviews")
|
|
||||||
}
|
|
||||||
|
|
||||||
model DailyStreak {
|
|
||||||
userId String @id
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
currentStreak Int @default(0)
|
|
||||||
longestStreak Int @default(0)
|
|
||||||
lastClaimDate DateTime?
|
|
||||||
streakFreeze Int @default(0)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model XpTransaction {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
amount Int
|
|
||||||
reason String // 'daily_login' | 'chat_message' | 'forum_post' | 'purchase' | 'referral' | 'achievement'
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Achievement {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
type String // 'first_chat' | 'streak_7' | 'streak_30' | 'level_5' | 'first_purchase' | 'first_post'
|
|
||||||
title String
|
|
||||||
description String?
|
|
||||||
icon String?
|
|
||||||
unlockedAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@unique([userId, type])
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserLevel {
|
|
||||||
userId String @id
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
level Int @default(1)
|
|
||||||
xp Int @default(0)
|
|
||||||
xpToNext Int @default(100)
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Listing {
|
model Listing {
|
||||||
@@ -108,15 +40,7 @@ model Listing {
|
|||||||
title String
|
title String
|
||||||
description String
|
description String
|
||||||
category String
|
category String
|
||||||
subcategory String?
|
|
||||||
priceFlh Int
|
priceFlh Int
|
||||||
packages String? // JSON: [{name, description, price, deliveryDays, revisions}]
|
|
||||||
tags String? // JSON: ["tag1", "tag2"]
|
|
||||||
images String? // JSON: ["url1"]
|
|
||||||
deliveryDays Int?
|
|
||||||
rating Float @default(0)
|
|
||||||
reviewCount Int @default(0)
|
|
||||||
salesCount Int @default(0)
|
|
||||||
sellerId String
|
sellerId String
|
||||||
seller User @relation(fields: [sellerId], references: [id])
|
seller User @relation(fields: [sellerId], references: [id])
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
@@ -126,7 +50,6 @@ model Listing {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
purchases Purchase[]
|
purchases Purchase[]
|
||||||
reviews Review[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Purchase {
|
model Purchase {
|
||||||
@@ -137,37 +60,11 @@ model Purchase {
|
|||||||
buyer User @relation("BuyerPurchases", fields: [buyerId], references: [id])
|
buyer User @relation("BuyerPurchases", fields: [buyerId], references: [id])
|
||||||
sellerId String
|
sellerId String
|
||||||
seller User @relation("SellerPurchases", fields: [sellerId], references: [id])
|
seller User @relation("SellerPurchases", fields: [sellerId], references: [id])
|
||||||
packageName String?
|
|
||||||
amountFlh Int
|
amountFlh Int
|
||||||
platformFee Int
|
platformFee Int
|
||||||
sellerPayout Int
|
sellerPayout Int
|
||||||
status String @default("pending") // pending, paid, in_progress, delivered, completed, disputed, cancelled
|
autoConfirmAt DateTime
|
||||||
messages String? // JSON: [{from, text, timestamp}]
|
|
||||||
deliveryFiles String? // JSON: [{name, size, url}]
|
|
||||||
autoConfirmAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
reviews Review[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Review {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
listingId String
|
|
||||||
listing Listing @relation(fields: [listingId], references: [id])
|
|
||||||
purchaseId String
|
|
||||||
purchase Purchase @relation(fields: [purchaseId], references: [id])
|
|
||||||
reviewerId String
|
|
||||||
reviewer User @relation("ListingReviews", fields: [reviewerId], references: [id])
|
|
||||||
sellerId String
|
|
||||||
rating Int
|
|
||||||
title String?
|
|
||||||
text String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([listingId])
|
|
||||||
@@index([sellerId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model CashoutRequest {
|
model CashoutRequest {
|
||||||
@@ -205,33 +102,6 @@ model ForumThread {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
posts ForumPost[]
|
posts ForumPost[]
|
||||||
group Group? @relation(fields: [groupId], references: [id])
|
|
||||||
groupId String?
|
|
||||||
}
|
|
||||||
|
|
||||||
model Group {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String
|
|
||||||
description String?
|
|
||||||
ownerId String
|
|
||||||
owner User @relation("GroupOwner", fields: [ownerId], references: [id])
|
|
||||||
inviteCode String @unique
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
members GroupMember[]
|
|
||||||
threads ForumThread[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model GroupMember {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
groupId String
|
|
||||||
group Group @relation(fields: [groupId], references: [id])
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
role String @default("member")
|
|
||||||
joinedAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@unique([groupId, userId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model ForumPost {
|
model ForumPost {
|
||||||
@@ -278,215 +148,32 @@ model HalalUsage {
|
|||||||
periodEnd DateTime?
|
periodEnd DateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserPlace {
|
// ── Micro-Learning (Learn Module) ──
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
name String
|
|
||||||
address String
|
|
||||||
lat Float
|
|
||||||
lng Float
|
|
||||||
type String // "mosque" | "restaurant" | "cafe"
|
|
||||||
notes String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([userId])
|
model Course {
|
||||||
@@index([lat, lng])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Notification {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
type String // 'streak_reminder' | 'daily_verse' | 'premium_expiring' | 'new_listing' | 'forum_reply' | 'achievement' | 'referral_bonus'
|
|
||||||
title String
|
|
||||||
body String?
|
|
||||||
data String? // JSON string with extra payload
|
|
||||||
read Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([userId, read])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Referral {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
referrerId String
|
|
||||||
referrer User @relation("Referrer", fields: [referrerId], references: [id])
|
|
||||||
referredId String @unique
|
|
||||||
referred User @relation("Referred", fields: [referredId], references: [id])
|
|
||||||
status String @default("pending") // pending | joined | upgraded
|
|
||||||
rewardFlh Int @default(0)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
upgradedAt DateTime?
|
|
||||||
|
|
||||||
@@index([referrerId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model PremiumBenefit {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
tier String // 'premium' | 'pro'
|
|
||||||
name String
|
|
||||||
description String?
|
|
||||||
icon String?
|
|
||||||
active Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
}
|
|
||||||
|
|
||||||
model HalalPlaceCache {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
cacheKey String @unique
|
|
||||||
data String
|
|
||||||
expiresAt DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
model DhikrSession {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
type String // 'subhanallah' | 'alhamdulillah' | 'allahuakbar' | 'custom'
|
|
||||||
count Int @default(0)
|
|
||||||
target Int @default(33)
|
|
||||||
completed Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([userId, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DhikrDay {
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
date String // YYYY-MM-DD
|
|
||||||
subhanallah Int @default(0)
|
|
||||||
alhamdulillah Int @default(0)
|
|
||||||
allahuakbar Int @default(0)
|
|
||||||
custom Int @default(0)
|
|
||||||
totalCount Int @default(0)
|
|
||||||
sessionCount Int @default(0)
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@id([userId, date])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────
|
|
||||||
// LEARN / MICRO LEARNING MODULE
|
|
||||||
// ──────────────────────────────────────
|
|
||||||
|
|
||||||
model LearnCourse {
|
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
slug String @unique
|
||||||
title String
|
title String
|
||||||
author String
|
|
||||||
description String
|
description String
|
||||||
|
difficulty String // beginner | intermediate | advanced
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
moduleCount Int @default(0)
|
|
||||||
totalMinutes Int @default(0)
|
|
||||||
difficulty String @default("beginner")
|
|
||||||
giteaPath String // path in Gitea courses repo
|
|
||||||
priceFlh Int? // FLH one-time price
|
|
||||||
priceUsd Float? // USD one-time price via Polar
|
|
||||||
subscriptionOnly Boolean @default(false)
|
|
||||||
published Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
modules LearnModule[]
|
modules Module[]
|
||||||
enrollments LearnEnrollment[]
|
|
||||||
certificates LearnCertificate[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model LearnModule {
|
model Module {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
courseId String
|
courseId String
|
||||||
course LearnCourse @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
order Int
|
|
||||||
title String
|
title String
|
||||||
slug String
|
description String
|
||||||
keyTakeaway String?
|
content String // markdown
|
||||||
duration Int @default(5) // minutes
|
videoUrl String?
|
||||||
content String? // cached markdown body
|
order Int
|
||||||
audioPath String? // cached TTS audio file path
|
|
||||||
quizData String? // JSON: [{question, options[], correctIndex}]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
progress LearnModuleProgress[]
|
quizData String? // JSON string: [{question, options:[], correctIndex}]
|
||||||
|
|
||||||
@@unique([courseId, slug])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model LearnEnrollment {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
courseId String
|
|
||||||
course LearnCourse @relation(fields: [courseId], references: [id])
|
|
||||||
purchaseType String @default("one_time") // "one_time" | "subscription" | "free"
|
|
||||||
completed Boolean @default(false)
|
|
||||||
progress Float @default(0) // 0.0 to 1.0
|
|
||||||
startedAt DateTime @default(now())
|
|
||||||
completedAt DateTime?
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
modules LearnModuleProgress[]
|
|
||||||
|
|
||||||
@@unique([userId, courseId])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model LearnModuleProgress {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
enrollmentId String
|
|
||||||
enrollment LearnEnrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
|
||||||
moduleId String
|
|
||||||
module LearnModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
|
||||||
completed Boolean @default(false)
|
|
||||||
score Float? // quiz score 0-100
|
|
||||||
listened Boolean @default(false)
|
|
||||||
completedAt DateTime?
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([enrollmentId, moduleId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model LearnSubscription {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
status String @default("active") // active | cancelled | expired
|
|
||||||
polarSubId String? @unique
|
|
||||||
currentPeriodStart DateTime
|
|
||||||
currentPeriodEnd DateTime
|
|
||||||
cancelledAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([status])
|
|
||||||
}
|
|
||||||
|
|
||||||
model LearnCertificate {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
serialNumber String @unique
|
|
||||||
userId String
|
|
||||||
userName String // cached at issue time
|
|
||||||
courseId String
|
|
||||||
course LearnCourse @relation(fields: [courseId], references: [id])
|
|
||||||
moduleCount Int @default(0)
|
|
||||||
score Float? // aggregate quiz score
|
|
||||||
pdfPath String? // /data/certificates/{serial}.pdf
|
|
||||||
issuedAt DateTime @default(now())
|
|
||||||
viewedAt DateTime?
|
|
||||||
revoked Boolean @default(false)
|
|
||||||
revokedAt DateTime?
|
|
||||||
hashOnChain String? // SHA256 for future blockchain anchoring
|
|
||||||
metadata String? // JSON extra
|
|
||||||
|
|
||||||
@@index([serialNumber])
|
|
||||||
@@index([userId])
|
|
||||||
@@index([courseId, userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add relations to User model
|
|
||||||
/// NOTE: The Notification and Referral relations are declared on the models above.
|
|
||||||
/// User now implicitly has: notifications Notification[], referredReferrals Referral[] (via "Referrer"), referrerReferral Referral? (via "Referred")
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Seed script for micro-learning courses.
|
* Seed script for micro-learning courses.
|
||||||
* Reads prisma/seed-learn.json and creates LearnCourse/LearnModule records.
|
* Reads prisma/seed-learn.json and creates courses/modules in the database.
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* npx prisma db push
|
* npx prisma db push
|
||||||
@@ -25,73 +25,41 @@ async function main() {
|
|||||||
const { modules, ...courseData } = course;
|
const { modules, ...courseData } = course;
|
||||||
|
|
||||||
// Upsert course (match by slug)
|
// Upsert course (match by slug)
|
||||||
const upserted = await prisma.learnCourse.upsert({
|
const upserted = await prisma.course.upsert({
|
||||||
where: { slug: courseData.slug },
|
where: { slug: courseData.slug },
|
||||||
update: {
|
update: {
|
||||||
title: courseData.title,
|
title: courseData.title,
|
||||||
author: courseData.author,
|
|
||||||
description: courseData.description,
|
description: courseData.description,
|
||||||
difficulty: courseData.difficulty,
|
difficulty: courseData.difficulty,
|
||||||
imageUrl: courseData.imageUrl,
|
imageUrl: courseData.imageUrl,
|
||||||
giteaPath: courseData.giteaPath,
|
|
||||||
priceFlh: courseData.priceFlh,
|
|
||||||
priceUsd: courseData.priceUsd,
|
|
||||||
subscriptionOnly: courseData.subscriptionOnly,
|
|
||||||
published: courseData.published,
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
slug: courseData.slug,
|
slug: courseData.slug,
|
||||||
title: courseData.title,
|
title: courseData.title,
|
||||||
author: courseData.author,
|
|
||||||
description: courseData.description,
|
description: courseData.description,
|
||||||
difficulty: courseData.difficulty,
|
difficulty: courseData.difficulty,
|
||||||
imageUrl: courseData.imageUrl,
|
imageUrl: courseData.imageUrl,
|
||||||
giteaPath: courseData.giteaPath,
|
|
||||||
priceFlh: courseData.priceFlh,
|
|
||||||
priceUsd: courseData.priceUsd,
|
|
||||||
subscriptionOnly: courseData.subscriptionOnly,
|
|
||||||
published: courseData.published,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(` Course: ${upserted.title} (${upserted.id})`);
|
console.log(` Course: ${upserted.title} (${upserted.id})`);
|
||||||
|
|
||||||
// Delete old modules (clean re-seed for dev)
|
// Delete old modules (clean re-seed for dev)
|
||||||
await prisma.learnModule.deleteMany({ where: { courseId: upserted.id } });
|
await prisma.module.deleteMany({ where: { courseId: upserted.id } });
|
||||||
|
|
||||||
// Create modules
|
// Create modules — strip id/courseId since Prisma auto-generates them
|
||||||
if (modules && modules.length > 0) {
|
if (modules && modules.length > 0) {
|
||||||
const totalMinutes = modules.reduce((sum, m) => sum + (m.duration || 5), 0);
|
await prisma.module.createMany({
|
||||||
|
data: modules.map(({ id: _id, courseId: _cid, ...m }) => ({
|
||||||
for (const m of modules) {
|
...m,
|
||||||
await prisma.learnModule.create({
|
|
||||||
data: {
|
|
||||||
courseId: upserted.id,
|
courseId: upserted.id,
|
||||||
order: m.order,
|
|
||||||
title: m.title,
|
|
||||||
slug: m.slug,
|
|
||||||
keyTakeaway: m.keyTakeaway,
|
|
||||||
duration: m.duration || 5,
|
|
||||||
content: m.content || null,
|
|
||||||
audioPath: m.audioPath || null,
|
|
||||||
quizData:
|
quizData:
|
||||||
typeof m.quizData === 'string'
|
typeof m.quizData === 'string'
|
||||||
? m.quizData
|
? m.quizData
|
||||||
: JSON.stringify(m.quizData || []),
|
: JSON.stringify(m.quizData || []),
|
||||||
},
|
})),
|
||||||
});
|
});
|
||||||
}
|
console.log(` → ${modules.length} module(s) created`);
|
||||||
|
|
||||||
// Update course with derived fields
|
|
||||||
await prisma.learnCourse.update({
|
|
||||||
where: { id: upserted.id },
|
|
||||||
data: {
|
|
||||||
moduleCount: modules.length,
|
|
||||||
totalMinutes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(` → ${modules.length} module(s) created (${totalMinutes} min total)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 379 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |