diff --git a/.dockerignore b/.dockerignore index b0d2bb2..118e49e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,4 @@ prisma/dev.db* prisma/*.db-wal prisma/*.db-shm +.env diff --git a/.hermes/plans/souq-native-embed.md b/.hermes/plans/souq-native-embed.md new file mode 100644 index 0000000..e348330 --- /dev/null +++ b/.hermes/plans/souq-native-embed.md @@ -0,0 +1,66 @@ +# Souq Native Embed — Implementation Plan + +**Goal:** Replace the external redirect at `/souq` with native Next.js pages that use Prisma + AuthContext. Single auth, single DB, no domain switch. + +**Architecture:** +- Souq API routes live under `/mobile/api/souq/*` — same pattern as `/wallet`, `/chat`, `/gamification` +- Listings stored in Prisma `Listing` model (already exists, needs a few fields added) +- Orders tracked via Prisma `Purchase` model (already exists, needs status/messages fields) +- Auth flows through existing `AuthContext` + `flh_token` — no relogin +- Navigation updated to point internally, not to souq.falahos.my + +**Data Migration:** +- Netlify `/tmp/*.json` seed data is demo/fake — not migrated +- Real listings will be created via the new `/souq/sell` flow directly into Prisma +- Total sync required: ZERO — one DB, one app, one auth + +## Task Breakdown + +### Batch 1: Schema + Backend (parallel) +| # | Task | Files | +|---|------|-------| +| 1 | Update Prisma: add `packages`, `tags`, `images`, `deliveryDays`, `rating`, `reviewCount`, `salesCount` to `Listing`; add `status`, `packageName`, `messages`, `deliveryFiles`, `updatedAt` to `Purchase` | `prisma/schema.prisma` | +| 2 | Create seed script | `scripts/seed-souq.ts` | +| 3 | Create API route: `GET /api/souq/listings` + `POST /api/souq/listings` | `src/app/api/souq/listings/route.ts` | +| 4 | Create API route: `GET /api/souq/listings/[id]` | `src/app/api/souq/listings/[id]/route.ts` | +| 5 | Create API route: `GET /api/souq/orders` + `POST /api/souq/orders` | `src/app/api/souq/orders/route.ts` | +| 6 | Create API route: `GET /api/souq/orders/[id]` + `PATCH /api/souq/orders/[id]` | `src/app/api/souq/orders/[id]/route.ts` | +| 7 | Create API route: `GET /api/souq/categories` | `src/app/api/souq/categories/route.ts` | + +### Batch 2: Frontend Pages (parallel) +| # | Task | Files | +|---|------|-------| +| 8 | Build `/souq` browse page with category grid + featured listings + search | `src/app/souq/page.tsx` | +| 9 | Build `/souq/[id]` listing detail with package selection + order flow | `src/app/souq/[id]/page.tsx` | +| 10 | Build `/souq/orders` page (buyer + seller order lists) | `src/app/souq/orders/page.tsx` | +| 11 | Build `/souq/orders/[id]` order tracking page (stepper + chat + files) | `src/app/souq/orders/[id]/page.tsx` | +| 12 | Build `/souq/sell` create listing form | `src/app/souq/sell/page.tsx` | + +### Batch 3: Integration +| # | Task | Files | +|---|------|-------| +| 13 | Update BottomNav: Souq → internal `/souq`, remove external | `src/components/BottomNav.tsx` | +| 14 | Update Home page: Souq quick action → internal link | `src/app/page.tsx` | +| 15 | Run Prisma migration + seed, test build | terminal | + +### Batch 4: Netlify Redirect +| # | Task | Files | +|---|------|-------| +| 16 | Add `_redirects` to souq.falahos.my Netlify deploy | `falah-core/apps/souq/_redirects` | + +## Key Design Decisions + +1. **Packages as JSON** — `Listing.packages` stores `[{name, description, price, deliveryDays, revisions}]` as a JSON string. Avoids a separate model for MVP. +2. **Order messages as JSON** — `Purchase.messages` stores `[{from, text, timestamp}]` as JSON. Same reasoning. +3. **Categories hardcoded** — Categories defined as a constant in the API route, not a DB table (can promote later). +4. **FLH-only pricing** — All listings priced in FLH, matching the existing wallet system. The 1.5% platform fee matches the Netlify Souq. +5. **Use existing AuthContext** — No new auth flow. `requireAuth()` on API routes, `useAuth()` on pages. + +## Verification + +- `curl /mobile/api/souq/listings` returns seed listings +- Navigate to `/souq` in browser — browse grid renders +- Click a listing → detail page with packages +- Place order → appears in `/souq/orders` +- BottomNav Souq links to `/souq`, not external +- souq.falahos.my redirects to `/mobile/souq` diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8d1617b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# ── Falah Mobile — Production Dockerfile ────────────────────────────── -# 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 - -WORKDIR /app - -RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs - -# Copy standalone build (pre-built outside Docker) -COPY .next/standalone/ ./ -COPY .next/static ./.next/static -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 -COPY start.sh /app/start.sh -RUN 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 - -USER nextjs -EXPOSE 3000 - -ENV NODE_ENV=production -ENV DATABASE_URL="file:/app/data/dev.db" - -CMD ["/app/start.sh"] diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..5d1774d --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,228 @@ +# Falah Mobile — Micro Learning Platform Handover + +## Project Overview + +Falah Mobile is a Next.js 16 PWA serving a full Islamic lifestyle platform (Nur AI coaching, Halal marketplace, community, wallet, and **micro learning courses**). The micro learning module is the focus of this handover — a complete E2E system for browsing, purchasing, consuming, and certifying bite-sized (5 min) courses derived from books. + +Production URL: `https://falahos.my/mobile` +Staging URL: `https://falahos.my:4014/mobile` + +### Repository + +| Detail | Value | +|:-------|:------| +| **Source path (server)** | `/root/falah-mobile` | +| **Gitea remote** | `http://git.falahos.my/wmj/falah-mobile.git` (internal: `localhost:3080/wmj/falah-mobile.git`) | +| **Gitea repo ID** | `1` | +| **Full name** | `wmj/falah-mobile` | +| **Default branch** | `main` | +| **Last commit** | `bfd3509` — "docs: update README with full CE feature docs, architecture, and deployment guide" | +| **GitHub mirror** | `https://github.com/maifors/falah-mobile.git` (auto-synced via Gitea cron) | + + +## ✅ What Is Complete (Live) + +### Course Library — 4 Courses, 21 Modules, 6 Categories + +| Course | Author | Modules | Category | +|--------|--------|:-------:|:---------| +| Atomic Habits | James Clear | 5 | Self Improvement | +| The Defining Decade | Meg Jay | 6 | Self Improvement | +| Simplicity Parenting | Kim John Payne | 5 | Self Improvement | +| Tao Te Ching | Lao Tzu | 5 | Self Improvement | + +6 categories defined (Self Improvement ✅, Basic Quran/Hadith/Prayer/History/Finance — empty, ready for content). + +### Course Content Delivery +- **Course listing** with category badges + search bar + category filter tabs +- **Course detail page** with module timeline, progress bar, quiz badges +- **Module page** with lesson content (markdown), Listen 🎧 button, Quiz section +- **TTS audio** using browser Web Speech API with female voice selection (preferred: Google UK English Female → Samantha → Microsoft Zira → fallback) +- **Markdown stripping** for TTS (strips `#`, `##`, `**bold**`, `*italic*`, `[links]`, `> quotes`, `- lists`, `1. lists`, `` `code` ``) +- **Quiz system** — 3–5 multiple-choice questions per module, submit/score/retry +- **Certificate issuance** — auto-issued when all modules completed, with PDF generation and serial number. Displayed as 🎓 View Certificate badge on course detail page. + +### Payments & Subscription +| Product | Price | Status | +|---------|:-----:|:------:| +| Atomic Habits (one-time) | $4.99 | ❌ Not mapped in checkout (old courses only have 3 mapped) | +| The Defining Decade (one-time) | $4.99 | ❌ Not mapped in checkout | +| Simplicity Parenting (one-time) | $4.99 | ❌ Not mapped in checkout | +| Tao Te Ching (one-time) | $4.99 | ❌ Not mapped in checkout | +| Learn Pass Monthly | $9.99/mo | ✅ Live on Polar | +| Learn Pass Annual | $79.99/yr | ✅ Live on Polar | +| In-app toggle (monthly/annual) | — | ✅ LearnPassCard component | + +**IMPORTANT:** The checkout API at `src/app/api/learn/checkout/route.ts` only has `PRODUCTS` mapping for the *original 3 courses* (atomic-habits, deep-work, 7-habits). New courses (defining-decade, simplicity-parenting, tao-te-ching) need their Polar price IDs added to this map for individual purchase to work. + +### Demo Credentials +- `demo@falahos.my` / `password123` (Premium, FLH 10,000) +- `premium@falahos.my` / `Premium123!` (Premium, FLH 50,000) + +### TTS Voice +- Client-side browser SpeechSynthesis with voice selection +- Pitch: 1.15, Rate: 0.9 + +--- + +## 📋 Pending / Next Steps + +### Priority: Phase 5+ Course Content (27 more courses) +39-course library planned. Only 4 built. Remaining 35 courses span 6 categories: +- **Self Improvement** (14 more needed) — Deep Work, 7 Habits, Mindset, Learning How to Learn, You Are Awesome, Al-Ghazali, Rumi, Love Languages, Whole-Brain Child, Crucial Conversations, How to Talk So Kids Will Listen, etc. +- **Basic Quran** (0/8 courses) — need content creation +- **Basic Hadith** (0/6 courses) — need content creation +- **Basic Prayer & Worship** (0/6 courses) — need content creation +- **Basic Islamic History** (0/5 courses) — need content creation +- **Basic Islamic Finance** (0/4 courses) — need content creation + +Each course follows the micro-learning template: +``` +5 modules × 5 min each +3–5 multiple-choice questions per module (format: {questions: [{question, options[], correctIndex}]}) +Key takeaway per module +``` + +Course seed file template: `/root/falah-mobile/scripts/seed-phase4.ts` + +### Priority: Add New Courses to Checkout +The `PRODUCTS` map in `src/app/api/learn/checkout/route.ts` needs entries for each new course that has a Polar price. Create Polar products first, then add `"course-{slug}": { priceId, name, metadata, tier: "one_time" }` to the map. + +### Priority: Course Image / Thumbnail Art +Course cards have emoji + gradient placeholders. Need proper cover images stored in `public/courses/` or served via Gitea. + +### Other Backlog +- **Gitea course sync** — `/root/falah-mobile/scripts/init-gitea-courses.sh` syncs course content from Gitea. Script exists but needs testing. +- **Server-side TTS generation** — Current Web Speech API is client-side. Could use Azure Cognitive Services or ElevenLabs for higher quality. +- **Certificate PDF design** — Current PDF generation in `src/lib/learn.ts` (functions: `generateCertificatePdf`, `generateSerialNumber`, `buildVerifyUrl`). Design may need polish. +- **Admin dashboard** — No admin UI for course management. Courses are seeded via DB scripts. +- **Course progress analytics** — No analytics on completion rates, quiz scores, popular courses. + +--- + +## 📦 Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Framework** | Next.js 16 (App Router, React Server Components) | +| **Language** | TypeScript | +| **Database** | SQLite (production: `/app/data/dev.db` on Docker volume) | +| **ORM** | Prisma v5.22 (schema at `prisma/schema.prisma`) | +| **Auth** | Ummah ID (external service) — JWT delegation | +| **Styling** | Tailwind CSS, dark theme (`#07090C` bg, `#C9A84C` gold, `#00C48C` green) | +| **Payments** | Polar.sh (checkout API, webhooks) | +| **TTS** | Browser Web Speech API (`SpeechSynthesisUtterance`) | +| **Certificate PDF** | Generated server-side (see `src/lib/learn.ts`) | +| **Deployment** | Docker (standalone Next.js output) | +| **Reverse Proxy** | Traefik (routes `/mobile/*` → `falah-mobile:3000`) | +| **Host** | Contabo VPS (Ubuntu 24.04) | +| **Git** | Gitea at `git.falahos.my` | + +--- + +## 🏗 Architecture + +### Deployment Flow +``` +Source: /root/falah-mobile/ + → npm run build (produces .next/standalone) + → docker build -t falah-mobile:latest . + → docker compose up -d --no-deps falah-mobile +``` + +### Route Structure (Next.js App Router, basePath: "/mobile") +``` +/mobile + /learn ← Course catalog with search + category tabs (client) + /learn/[slug] ← Course detail with module timeline (client) + /learn/[slug]/[moduleId] ← Module reader: content + TTS + quiz (client) + /api/learn + /courses ← GET: list courses (supports ?category=&search=) + /courses/[slug] ← GET: course detail + modules + enrollment + /categories ← GET: list all categories + /categories/seed ← POST: seed categories (dev only) + /checkout ← POST: create Polar.sh checkout or mock purchase + /progress ← POST: mark module complete (auto-issues certificate at 100%) + /tts ← GET: module text; POST: request audio + /certificates ← GET: user's certificates + /certificates/[serial] ← GET: single certificate + /sync ← POST: sync from Gitea +``` + +### Key Source Files +| File | Purpose | +|------|---------| +| `prisma/schema.prisma` | All data models (LearnCourse, LearnModule, LearnEnrollment, etc.) | +| `src/app/learn/page.tsx` | Course catalog with category tabs + search | +| `src/app/learn/[slug]/page.tsx` | Course detail + timeline + certificate badge | +| `src/app/learn/[slug]/[moduleId]/page.tsx` | Module reader + TTS + quiz | +| `src/app/api/learn/courses/route.ts` | Course listing API with category/search/filter | +| `src/app/api/learn/courses/[slug]/route.ts` | Course detail API | +| `src/app/api/learn/categories/route.ts` | Categories API | +| `src/app/api/learn/checkout/route.ts` | Polar checkout (update PRODUCTS map for new courses!) | +| `src/app/api/learn/progress/route.ts` | Progress + certificate issuance | +| `src/app/api/learn/tts/route.ts` | TTS text endpoint | +| `src/app/api/learn/certificates/route.ts` | User certificates | +| `src/lib/learn.ts` | Certificate PDF generation, serial number | +| `src/lib/prisma.ts` | Prisma client singleton | +| `src/lib/auth.ts` | JWT auth middleware | +| `src/components/LearnPassCard.tsx` | Subscription monthly/annual toggle | +| `scripts/seed-phase4.ts` | Course seed template | +| `Dockerfile` | Builds standalone Next.js production image | + +--- + +## 🔧 Key Skills for Continuation + +1. **Prisma ORM with SQLite** — schema changes (`db push`), seed scripts, query patterns +2. **Next.js 16 App Router** — file-based routing, server/client components, API routes +3. **React (useState, useEffect, useCallback, useMemo)** — all frontend pages are client components +4. **Polar.sh API** — checkout creation, webhook handling, subscription management +5. **Docker deployment** — multi-stage build, volume mounts, compose orchestration +6. **Tailwind CSS** — theming (dark mode, custom color palette) +7. **Web Speech API** — browser TTS voice selection +8. **SQLite** — direct DB manipulation for quick data fixes (seed scripts, format migrations) + +--- + +## ⚙️ Common Commands + +```bash +cd /root/falah-mobile + +# Build +npm run build + +# Deploy +docker build -t falah-mobile:latest . +docker compose up -d --no-deps falah-mobile + +# DB operations +docker cp falah-mobile-falah-mobile-1:/app/data/dev.db /tmp/dev.db +# edit /tmp/dev.db with sqlite3 +docker cp /tmp/dev.db falah-mobile-falah-mobile-1:/app/data/dev.db +docker restart falah-mobile-falah-mobile-1 + +# Prisma schema change +npx prisma db push # applies schema changes to SQLite directly + +# Check logs +docker logs falah-mobile-falah-mobile-1 + +# Seed categories endpoint (dev only, on rebuild) +curl -X POST https://falahos.my/mobile/api/learn/categories/seed + +# API test +curl -s https://falahos.my/mobile/api/learn/courses | python3 -m json.tool +``` + +--- + +## 🐛 Known Issues + +1. **Health check slow** — Docker health check for mobile container takes 30s+ to become "healthy", but API works immediately (HTTP 200). +2. **`.env` permission error** — Container logs show `EACCES: permission denied, open '/app/.env'` despite env vars being loaded correctly via Docker compose. +3. **New courses not individually purchasable** — Checkout `PRODUCTS` map only has 3 old courses mapped. New courses need Polar price IDs added. +4. **Staging shares volume** — `falah-mobile-staging` and `falah-mobile` production share the same DB volume. Restarting staging after production writes can cause DB contention. +5. **No category icon for course thumbnails** — Thumbnail uses category's icon/color gradient, but some courses may have missing visuals. +6. **Course card click doesn't navigate on course detail page** — Module cards have `router.push()` but the nested "Listen" button's `stopPropagation()` may cause issues. Direct URL navigation works (`/mobile/learn/{slug}/{module-slug}`). diff --git a/auto-healer/Dockerfile b/auto-healer/Dockerfile deleted file mode 100644 index 2eaa20f..0000000 --- a/auto-healer/Dockerfile +++ /dev/null @@ -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"] diff --git a/auto-healer/auto-heal-agent.sh b/auto-healer/auto-heal-agent.sh deleted file mode 100644 index f9bc170..0000000 --- a/auto-healer/auto-heal-agent.sh +++ /dev/null @@ -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 diff --git a/auto-healer/docker-compose.healer.yml b/auto-healer/docker-compose.healer.yml deleted file mode 100644 index 25b17b1..0000000 --- a/auto-healer/docker-compose.healer.yml +++ /dev/null @@ -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" diff --git a/data/dev.db b/data/dev.db new file mode 100644 index 0000000..51f1af1 Binary files /dev/null and b/data/dev.db differ diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml deleted file mode 100644 index 5872264..0000000 --- a/docker-compose.staging.yml +++ /dev/null @@ -1,41 +0,0 @@ -version: "3.8" -services: - falah-mobile-staging: - build: - context: . - dockerfile: Dockerfile - image: falah-mobile:staging - ports: - - "4017: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: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index eac0a18..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: diff --git a/handover-infographic.html b/handover-infographic.html new file mode 100644 index 0000000..3efd281 --- /dev/null +++ b/handover-infographic.html @@ -0,0 +1,723 @@ + + + + + +Falah Mobile — Micro Learning Handover + + + +
+ + +
+
📚 Micro Learning Platform
+

Falah Mobile Handover

+

Complete E2E system for browsing, purchasing, consuming, and certifying bite-sized (5 min) courses derived from books.

+ +
+ Repository + git.falahos.my/wmj/falah-mobile.git + · + Source + /root/falah-mobile + · + Branch + main + · + Commit + bfd3509 + · + GitHub + maifors/falah-mobile +
+
+ + +
+ + +
+
+
+
📊
+
+

Phase 1 Complete — Live in Production

+

Production: falahos.my/mobile  ·  Staging: falahos.my:4014/mobile

+
+
+
+
Deployment Docker + Traefik
+
Host Contabo VPS (Ubuntu 24.04)
+
Auth Ummah ID (JWT)
+
Payments Polar.sh
+
+
+
+ + +
+
+

Courses Built

+
+
+
4
+
Courses Live
+
+
+
21
+
Modules
+
+
+
6
+
Categories
+
+
+
35
+
Courses Left
+
+
+
+
+ Atomic Habits + Defining Decade + Simplicity Parenting + Tao Te Ching +
+
+
+
+
+ Library completion + 4 / 39 courses (10%) +
+
+

All 4 in Self Improvement. Quran, Hadith, Prayer, History, Finance categories empty — ready for content.

+
+ + +
+
🎓
+

Delivery Features

+
    +
  • Course catalog — search bar + category filter tabs
  • +
  • Module reader — markdown content + progress bar + quiz badges
  • +
  • TTS audio — Web Speech API, Google UK English Female (preferred)
  • +
  • Markdown stripping — 11 patterns clean text for TTS
  • +
  • Quiz system — 3–5 MCQs per module, submit/score/retry
  • +
  • Certificate issuance — auto-issued on 100% completion, PDF + serial
  • +
+
+ + +
+
💳
+

Payments & Subscription

+
    +
  • + + $9.99/mo Learn Pass Monthly ✅ Live +
  • +
  • + + $79.99/yr Learn Pass Annual ✅ Live +
  • +
  • + + $4.99 each One-time course purchases ❌ Not mapped +
  • +
  • + ⚠ Action: Add Polar price IDs for 4 new courses to checkout/route.ts PRODUCTS map +
  • +
+
+ + +
+
📋
+

Priority Backlog

+
    +
  • 35 more courses — build & seed across 6 categories
  • +
  • Checkout mapping — Polar price IDs for each new course
  • +
  • Course thumbnails — replace emoji/gradient with real cover art
  • +
  • Gitea sync — test course sync script
  • +
  • Server-side TTS — Azure / ElevenLabs for higher quality
  • +
  • Admin dashboard — course management UI
  • +
  • Analytics — completion rates, quiz scores
  • +
+
+ + +
+
📦
+

Tech Stack

+
+
+ Next.js 16 + TypeScript + SQLite + Prisma v5.22 + Tailwind CSS + Polar.sh + Docker + Traefik + Web Speech API + Gitea +
+
+
+

Database

+

SQLite (/app/data/dev.db) via Prisma ORM

+

Schema: prisma/schema.prisma

+
+
+

Deployment

+

Source → npm run build → Docker → Traefik reverse proxy

+

Host: Contabo VPS

+
+
+
+
+ + +
+
+
🏗
+

Route Architecture

+
+
+ # Next.js App Router — basePath: "/mobile"
+ GET /mobile/learn ← Course catalog (search + category tabs)
+ GET /mobile/learn/[slug] ← Course detail + module timeline
+ GET /mobile/learn/[slug]/[moduleId] ← Module reader + TTS + Quiz
+ GET /mobile/api/learn/courses ← ?category=&search=
+ GET /mobile/api/learn/courses/[slug] ← Course detail API
+ GET /mobile/api/learn/categories ← List categories
+ POST /mobile/api/learn/checkout ← Polar.sh checkout
+ POST /mobile/api/learn/progress ← Mark module complete + auto-certificate
+ GET /mobile/api/learn/certificates ← User certificates
+ POST /mobile/api/learn/sync ← Gitea sync +
+ +
+

Key Source Files (26 files)

+
+ prisma/schema.prisma + learn/page.tsx + [slug]/page.tsx + checkout/route.ts + progress/route.ts + courses/route.ts + lib/learn.ts + Dockerfile + LearnPassCard.tsx +
+
+
+ + +
+
⚙️
+

Common Commands

+
+ $ cd /root/falah-mobile
+ $ npm run build
+ $ docker build -t falah-mobile:latest .
+ $ docker compose up -d --no-deps falah-mobile
+ $ npx prisma db push +
+

DB ops via docker cp + sqlite3 for quick data fixes

+
+ + +
+
🐛
+

Known Issues

+
    +
  • #1 🟡 Health check slow — 30s+ but API works immediately
  • +
  • #2 🟡 .env permission errorEACCES in logs (env still loads correctly)
  • +
  • #3 🔴 Checkout missing courses — Only 3 old courses in PRODUCTS map
  • +
  • #4 🟡 Staging DB sharing — prod & staging share same volume
  • +
  • #5 🟢 Missing thumbnails — emoji/gradient placeholders
  • +
  • #6 🟢 Course card navigationstopPropagation edge case
  • +
+
+ Low + Medium + High +
+
+ +
+ + + + +
+ + diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..a417f4c --- /dev/null +++ b/netlify.toml @@ -0,0 +1,32 @@ +# ── Falah Mobile on Netlify ───────────────────────────────────── +# Uses @netlify/plugin-nextjs for full Next.js serverless support + +[build] + command = "npm run build" + publish = ".next" + +[build.environment] + NODE_VERSION = "20" + +# Root redirect to /mobile (app has basePath: "/mobile") +[[redirects]] + from = "/" + to = "/mobile" + status = 301 + +# Everything else handled by Next.js plugin +[[redirects]] + from = "/**" + to = "/.netlify/functions/next_handler" + status = 200 + +# Assets cache +[[headers]] + for = "/mobile/_next/static/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +# Prisma engine binary needed at runtime +[functions] + included_files = ["node_modules/.prisma/**", "node_modules/@prisma/**"] + node_bundler = "esbuild" diff --git a/next.config.ts b/next.config.ts index a2efe7e..8b0f420 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,9 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - output: "standalone", basePath: "/mobile", - assetPrefix: "/mobile", typescript: { ignoreBuildErrors: true, }, diff --git a/package-lock.json b/package-lock.json index 54630ea..1a64c3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,13 +24,14 @@ "stripe": "^22.2.1" }, "devDependencies": { - "@tailwindcss/postcss": "^4", + "@netlify/plugin-nextjs": "^5.15.12", + "@tailwindcss/postcss": "^4.3.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", - "tailwindcss": "^4", + "tailwindcss": "^4.3.1", "typescript": "^5" } }, @@ -1047,14 +1048,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1065,6 +1066,16 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@netlify/plugin-nextjs": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@netlify/plugin-nextjs/-/plugin-nextjs-5.15.12.tgz", + "integrity": "sha512-eydPd4gtc/OJOx9IbIJ5CRrC5K3rLxfEQS/S/fRNEAAT0qFvPoQVy23VdK3jRXKFJWXdCRl6ZudWLEZECQPSRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@next/env": { "version": "16.2.7", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.7.tgz", @@ -1643,9 +1654,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1720,17 +1731,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", - "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/type-utils": "8.61.0", - "@typescript-eslint/utils": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1743,7 +1754,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1759,16 +1770,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", - "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "engines": { @@ -1784,14 +1795,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", - "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.0", - "@typescript-eslint/types": "^8.61.0", + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "engines": { @@ -1806,14 +1817,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", - "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0" + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1824,9 +1835,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", - "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", "dev": true, "license": "MIT", "engines": { @@ -1841,15 +1852,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", - "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1866,9 +1877,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", - "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "dev": true, "license": "MIT", "engines": { @@ -1880,16 +1891,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", - "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.0", - "@typescript-eslint/tsconfig-utils": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1947,9 +1958,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1960,16 +1971,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", - "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0" + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1984,13 +1995,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", - "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2660,9 +2671,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -2723,9 +2734,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -2743,10 +2754,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -3124,9 +3135,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.372", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", - "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", "dev": true, "license": "ISC" }, @@ -3220,6 +3231,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3311,15 +3341,18 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -4379,9 +4412,9 @@ } }, "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -4802,9 +4835,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -5264,9 +5297,9 @@ } }, "node_modules/lucide-react": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", - "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz", + "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5347,9 +5380,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -5469,9 +5502,9 @@ } }, "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", "dev": true, "license": "MIT", "dependencies": { @@ -5488,9 +5521,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "license": "MIT", "engines": { @@ -6278,9 +6311,9 @@ } }, "node_modules/sharp/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -6589,9 +6622,9 @@ } }, "node_modules/stripe": { - "version": "22.2.1", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.2.1.tgz", - "integrity": "sha512-ULAtq25USBEx3yeN5zimEkfYVFETVMP5om25Ryr8ol11P62imaCZLBIEW78T7zm7Wg3wnZi+80S72yf3LCpNhw==", + "version": "22.3.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.0.tgz", + "integrity": "sha512-ypO6xjVrMWs9SmIMeHr8naCx3dAQ0clxMdUTxn7Ejd7hmY9meBGfE+N4pVHkf9sUNebAHp6uJo6mV3GxDIc2cA==", "license": "MIT", "engines": { "node": ">=18" @@ -6893,16 +6926,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", - "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.0", - "@typescript-eslint/parser": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0" + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 9f2a83c..5cb0f49 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,14 @@ "stripe": "^22.2.1" }, "devDependencies": { - "@tailwindcss/postcss": "^4", + "@netlify/plugin-nextjs": "^5.15.12", + "@tailwindcss/postcss": "^4.3.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", - "tailwindcss": "^4", + "tailwindcss": "^4.3.1", "typescript": "^5" } } diff --git a/prisma/dev.db b/prisma/dev.db new file mode 100644 index 0000000..91d4519 Binary files /dev/null and b/prisma/dev.db differ diff --git a/prisma/dev.db.bak b/prisma/dev.db.bak new file mode 100644 index 0000000..7a04fa9 Binary files /dev/null and b/prisma/dev.db.bak differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fae1d26..824a665 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,6 @@ generator client { provider = "prisma-client-js" - binaryTargets = ["rhel-openssl-3.0.x"] + binaryTargets = ["native", "rhel-openssl-3.0.x"] } datasource db { @@ -17,6 +17,7 @@ model User { providerId String? avatar String? flhBalance Int @default(5000) + flhPurchased Int @default(0) isPro Boolean @default(false) isPremium Boolean @default(false) experienceLevel String? diff --git a/qa-prod-report-20260624_063144.md b/qa-prod-report-20260624_063144.md new file mode 100644 index 0000000..bc17a6b --- /dev/null +++ b/qa-prod-report-20260624_063144.md @@ -0,0 +1,124 @@ +# QA Test Report — Production +**Date:** 2026-06-24 06:31:44 +**URL:** https://falahos.my/mobile + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 94 | 4 | 9 | 107 | **C** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 209ms | +| ✅ | Learn catalog page (/learn) → 200 in 109ms | +| ✅ | Certificate verification (/verify/ABC123) → 200 in 114ms | +| ✅ | Health API (/api/health) → 200 in 67ms | +| ✅ | Learn courses API (/api/learn/courses) → 200 in 106ms | +| ✅ | Course detail (no auth) (/api/learn/courses/atomic-habits) → 401 in 114ms | +| ✅ | /this-does-not-exist → 404 | +| ✅ | /api/nonexistent → 404 | +| ⚠️ | /learn/nonexistent-route → 200 (expected 404) | +| ✅ | Health API — status=ok, db=True, uptime=1991s | +| ✅ | Courses API — 1 course(s) returned | +| ✅ | Course #0: 'Atomic Habits' by James Clear — 5 modules, 25min, beginner | +| ✅ | Course detail without auth → 401 (correct) | +| ✅ | Course detail — 'Atomic Habits', 5 modules | +| ✅ | Course detail — 5 module(s) with content+quiz | +| ✅ | Module #1: 'The 1% Rule' — 5min, 3 quiz Qs | +| ✅ | Module #2: 'Identity-Based Habits' — 5min, 2 quiz Qs | +| ✅ | Module #3: 'The 4 Laws of Behavior Change' — 5min, 2 quiz Qs | +| ✅ | Module #4: 'Habit Stacking' — 5min, 1 quiz Qs | +| ✅ | Module #5: 'Design Your Environment' — 5min, 2 quiz Qs | +| ✅ | Verify page — 200 OK | +| ✅ | Verify — JSON response: None | +| ✅ | Landing (/) — clean (13749B) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING "Assalamualaikum" | +| ✅ | Learn Catalog (/learn) — clean (14195B) | +| ✅ | Learn Catalog (/learn) — contains "Learn" | +| ❌ | Learn Catalog (/learn) — MISSING "Course" | +| ❌ | Verify Certificate (/verify/ABC123) — ERROR in HTML: ['not found'] | +| ✅ | Verify Certificate (/verify/ABC123) — contains "Verify" | +| ✅ | Verify Certificate (/verify/ABC123) — contains "Certificate" | +| ✅ | Pattern "Invalid response" — absent | +| ✅ | Pattern "Internal Server Error" — absent | +| ✅ | Pattern "Application error" — absent | +| ✅ | Pattern "Cannot read properties of" — absent | +| ✅ | Pattern "undefined is not" — absent | +| ✅ | Pattern "TypeError" — absent | +| ✅ | Pattern "Failed to fetch" — absent | +| ✅ | Pattern "500 Internal" — absent | +| ✅ | Pattern "JSON Parse error" — absent | +| ✅ | Pattern "Unexpected token" — absent | +| ✅ | Pattern "Cannot find module" — absent | +| ✅ | Pattern "Minified React error" — absent | +| ❌ | Pattern "not found" on: /verify/ABC123 | +| ✅ | Unauthenticated / — accessible | +| ⚠️ | Unauthenticated /learn → 503 | +| ✅ | Login — obtained token (eyJhbGciOiJIUzI1...6SI4) | +| ✅ | Courses listed — Atomic Habits (slug: atomic-habits) | +| ✅ | Course detail loaded — 5 module(s) | +| ✅ | Module: 'The 1% Rule' — 5min, quiz: 3 Qs | +| ✅ | Module: 'Identity-Based Habits' — 5min, quiz: 2 Qs | +| ✅ | Module: 'The 4 Laws of Behavior Change' — 5min, quiz: 2 Qs | +| ✅ | Module: 'Habit Stacking' — 5min, quiz: 1 Qs | +| ✅ | Module: 'Design Your Environment' — 5min, quiz: 2 Qs | +| ✅ | Certificate verification page loads | +| ✅ | api/learn/courses/atomic-habits with bad token → 401 (graceful) | +| ⚠️ | SQLi on api/learn/courses/' OR 1=1-- → 0 | +| ⚠️ | SQLi on learn/' OR 1=1-- → 0 | +| ⚠️ | SQLi on api/learn/courses/'; DROP TABLE courses;-- → 0 | +| ⚠️ | SQLi on learn/'; DROP TABLE courses;-- → 0 | +| ⚠️ | SQLi on api/learn/courses/" OR "1"="1 → 0 | +| ⚠️ | SQLi on learn/" OR "1"="1 → 0 | +| ✅ | Path traversal '../../../etc/passwd' → 404 | +| ✅ | Path traversal '..%2F..%2F..%2Fetc%2Fpasswd' → 401 | +| ✅ | Verify code '' → 404 | +| ✅ | Verify code 'INVALID!@#$' → 200 | +| ✅ | Verify code 'aaaaaaaaaaaaaaaaaaaa' → 200 | +| ✅ | Burst — 10/10 OK (avg 120ms, total 0.2s) | +| ✅ | / — viewport meta present | +| ✅ | /learn — viewport meta present | +| ✅ | /verify/ABC123 — viewport meta present | +| ✅ | / — safe-area inset detected | +| ✅ | /learn — safe-area inset detected | +| ✅ | /verify/ABC123 — safe-area inset detected | +| ✅ | / — dark theme detected | +| ✅ | /learn — dark theme detected | +| ✅ | /verify/ABC123 — dark theme detected | +| ✅ | / — responsive container detected | +| ✅ | /learn — responsive container detected | +| ✅ | /verify/ABC123 — responsive container detected | +| ✅ | Landing page (/) — cold=101ms warm=93ms, 13KB | +| ✅ | Learn catalog (/learn) — cold=50ms warm=45ms, 14KB | +| ✅ | Health API (/api/health) — cold=52ms warm=52ms, 0KB | +| ✅ | Courses API (/api/learn/courses) — cold=54ms warm=77ms, 0KB | +| ✅ | / — no JWT token leak | +| ✅ | / — no API key leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no Private key leak | +| ✅ | /learn — no JWT token leak | +| ✅ | /learn — no API key leak | +| ✅ | /learn — no GitHub PAT leak | +| ✅ | /learn — no Private key leak | +| ✅ | /verify/ABC123 — no JWT token leak | +| ✅ | /verify/ABC123 — no API key leak | +| ✅ | /verify/ABC123 — no GitHub PAT leak | +| ✅ | /verify/ABC123 — no Private key leak | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ✅ | X-Content-Type-Options: nosniff | +| ✅ | Learn page — HTTP 200 (Playwright) | +| ✅ | Learn page — no JS console errors | +| ⚠️ | Learn page — may be empty/loading (text not found) | +| ✅ | Learn page — title: Falah — Islamic Lifestyle | +| ✅ | Learn page — loaded with auth (Playwright) | +| ✅ | Learn page — course content visible after auth | +| ✅ | Auth token persists in localStorage | +| ✅ | Verify page — HTTP 200 (Playwright) | +| ✅ | Verify page — 159 chars rendered | + +## Re-run +```bash +/usr/bin/python3.12 /root/falah-mobile/tests/qa-learn-prod.py +``` \ No newline at end of file diff --git a/qa-prod-report-20260624_063317.md b/qa-prod-report-20260624_063317.md new file mode 100644 index 0000000..6ae969b --- /dev/null +++ b/qa-prod-report-20260624_063317.md @@ -0,0 +1,120 @@ +# QA Test Report — Production +**Date:** 2026-06-24 06:33:17 +**URL:** https://falahos.my/mobile + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 98 | 0 | 5 | 103 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 111ms | +| ✅ | Learn catalog page (/learn) → 200 in 60ms | +| ✅ | Certificate verification (/verify/ABC123) → 200 in 90ms | +| ✅ | Health API (/api/health) → 200 in 55ms | +| ✅ | Learn courses API (/api/learn/courses) → 200 in 68ms | +| ✅ | Course detail (no auth) (/api/learn/courses/atomic-habits) → 401 in 64ms | +| ✅ | /this-does-not-exist → 404 | +| ✅ | /api/nonexistent → 404 | +| ✅ | Health API — status=ok, db=True, uptime=2084s | +| ✅ | Courses API — 1 course(s) returned | +| ✅ | Course #0: 'Atomic Habits' by James Clear — 5 modules, 25min, beginner | +| ✅ | Course detail without auth → 401 (correct) | +| ✅ | Course detail — 'Atomic Habits', 5 modules | +| ✅ | Course detail — 5 module(s) with content+quiz | +| ✅ | Module #1: 'The 1% Rule' — 5min, 3 quiz Qs | +| ✅ | Module #2: 'Identity-Based Habits' — 5min, 2 quiz Qs | +| ✅ | Module #3: 'The 4 Laws of Behavior Change' — 5min, 2 quiz Qs | +| ✅ | Module #4: 'Habit Stacking' — 5min, 1 quiz Qs | +| ✅ | Module #5: 'Design Your Environment' — 5min, 2 quiz Qs | +| ✅ | Verify page — 200 OK | +| ✅ | Verify — JSON response: None | +| ✅ | Landing (/) — clean (13749B) | +| ✅ | Landing (/) — contains "Falah" | +| ✅ | Learn Catalog (/learn) — clean (14195B) | +| ✅ | Learn Catalog (/learn) — contains "Falah" | +| ✅ | Verify Certificate (/verify/ABC123) — clean (16735B) | +| ✅ | Verify Certificate (/verify/ABC123) — contains "Verify" | +| ✅ | Verify Certificate (/verify/ABC123) — contains "Certificate" | +| ✅ | Pattern "Invalid response" — absent | +| ✅ | Pattern "Internal Server Error" — absent | +| ✅ | Pattern "Application error" — absent | +| ✅ | Pattern "Cannot read properties of" — absent | +| ✅ | Pattern "undefined is not" — absent | +| ✅ | Pattern "TypeError" — absent | +| ✅ | Pattern "Failed to fetch" — absent | +| ✅ | Pattern "500 Internal" — absent | +| ✅ | Pattern "JSON Parse error" — absent | +| ✅ | Pattern "Unexpected token" — absent | +| ✅ | Pattern "Cannot find module" — absent | +| ✅ | Pattern "Minified React error" — absent | +| ✅ | Unauthenticated / — accessible | +| ✅ | Unauthenticated /learn — accessible | +| ✅ | Login — obtained token (eyJhbGciOiJIUzI1...UDk8) | +| ✅ | Courses listed — Atomic Habits (slug: atomic-habits) | +| ✅ | Course detail loaded — 5 module(s) | +| ✅ | Module: 'The 1% Rule' — 5min, quiz: 3 Qs | +| ✅ | Module: 'Identity-Based Habits' — 5min, quiz: 2 Qs | +| ✅ | Module: 'The 4 Laws of Behavior Change' — 5min, quiz: 2 Qs | +| ✅ | Module: 'Habit Stacking' — 5min, quiz: 1 Qs | +| ✅ | Module: 'Design Your Environment' — 5min, quiz: 2 Qs | +| ✅ | Certificate verification page loads | +| ✅ | api/learn/courses/atomic-habits with bad token → 401 (graceful) | +| ✅ | SQLi '' OR 1=1--...' on api/learn/courses/ → 401 | +| ⚠️ | SQLi on learn/ → 200 | +| ✅ | SQLi ''; DROP TABL...' on api/learn/courses/ → 401 | +| ⚠️ | SQLi on learn/ → 200 | +| ✅ | SQLi '" OR "1"%3...' on api/learn/courses/ → 401 | +| ⚠️ | SQLi on learn/ → 200 | +| ✅ | Path traversal '../../../etc/passwd' → 404 | +| ✅ | Path traversal '..%2F..%2F..%2Fetc%2Fpasswd' → 401 | +| ✅ | Verify code '' → 404 | +| ✅ | Verify code 'INVALID!@#$' → 200 | +| ⚠️ | Verify code 'aaaaaaaaaaaaaaaaaaaa' → 503 | +| ✅ | Burst — 10/10 OK (avg 123ms, total 0.2s) | +| ✅ | / — viewport meta present | +| ✅ | /learn — viewport meta present | +| ✅ | /verify/ABC123 — viewport meta present | +| ✅ | / — safe-area inset detected | +| ✅ | /learn — safe-area inset detected | +| ✅ | /verify/ABC123 — safe-area inset detected | +| ✅ | / — dark theme detected | +| ✅ | /learn — dark theme detected | +| ✅ | /verify/ABC123 — dark theme detected | +| ✅ | / — responsive container detected | +| ✅ | /learn — responsive container detected | +| ✅ | /verify/ABC123 — responsive container detected | +| ✅ | Landing page (/) — cold=102ms warm=123ms, 13KB | +| ✅ | Learn catalog (/learn) — cold=84ms warm=62ms, 14KB | +| ✅ | Health API (/api/health) — cold=45ms warm=76ms, 0KB | +| ✅ | Courses API (/api/learn/courses) — cold=86ms warm=69ms, 0KB | +| ✅ | / — no JWT token leak | +| ✅ | / — no API key leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no Private key leak | +| ✅ | /learn — no JWT token leak | +| ✅ | /learn — no API key leak | +| ✅ | /learn — no GitHub PAT leak | +| ✅ | /learn — no Private key leak | +| ✅ | /verify/ABC123 — no JWT token leak | +| ✅ | /verify/ABC123 — no API key leak | +| ✅ | /verify/ABC123 — no GitHub PAT leak | +| ✅ | /verify/ABC123 — no Private key leak | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ✅ | X-Content-Type-Options: nosniff | +| ✅ | Learn page — HTTP 200 (Playwright) | +| ✅ | Learn page — no JS console errors | +| ⚠️ | Learn page — may be empty/loading (text not found) | +| ✅ | Learn page — title: Falah — Islamic Lifestyle | +| ✅ | Learn page — loaded with auth (Playwright) | +| ✅ | Learn page — course content visible after auth | +| ✅ | Auth token persists in localStorage | +| ✅ | Verify page — HTTP 200 (Playwright) | +| ✅ | Verify page — 159 chars rendered | + +## Re-run +```bash +/usr/bin/python3.12 /root/falah-mobile/tests/qa-learn-prod.py +``` \ No newline at end of file diff --git a/qa-report-20260618_161010.md b/qa-report-20260618_161010.md new file mode 100644 index 0000000..c9db3c5 --- /dev/null +++ b/qa-report-20260618_161010.md @@ -0,0 +1,183 @@ +# QA Test Report +**Date:** 2026-06-18 16:10:10 +**URL:** https://falahos.my/mobile +**Layer:** all + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 141 | 15 | 9 | 165 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 252ms | +| ✅ | Prayer times (/prayer) → 200 in 630ms | +| ✅ | Dhikr counter (/dhikr) → 200 in 139ms | +| ✅ | Qibla finder (/qibla) → 200 in 86ms | +| ✅ | Halal monitor (/halal-monitor) → 200 in 117ms | +| ✅ | Nur AI (/nur) → 200 in 102ms | +| ✅ | Forum (/forum) → 200 in 148ms | +| ✅ | Souq (/souq) → 200 in 167ms | +| ✅ | Groups (/groups) → 200 in 77ms | +| ✅ | Health API endpoint (/api/health) → 200 in 92ms | +| ✅ | Prayer API endpoint (/api/prayer) → 200 in 143ms | +| ✅ | Landing returns 200 directly | +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 18 Jun 2026 | +| ✅ | Prayer API — hijri: 3 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Nur (/nur) — contains "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Forum (/forum) — contains "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Souq (/souq) — contains "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | +| ✅ | Unauthenticated / — shows content | +| ✅ | Unauthenticated /prayer — shows content | +| ✅ | Unauthenticated /qibla — shows content | +| ✅ | Login — obtained token (eyJhbGciOiJIUzI1...Q3fE) | +| ❌ | Authenticated / — missing: ['Assalamualaikum'] | +| ❌ | Authenticated /prayer — missing: ['Fajr'] | +| ✅ | Authenticated /dhikr — all expected content present | +| ✅ | Authenticated /qibla — all expected content present | +| ⚠️ | Dhikr session — HTTP 404 | +| ⚠️ | Dhikr stats — HTTP 500 | +| ❌ | Qibla API — unexpected: None | +| ✅ | /prayer — accessible and clean | +| ✅ | /dhikr — accessible and clean | +| ✅ | /qibla — accessible and clean | +| ✅ | Full journey (Prayer → Dhikr → Qibla) — ALL pages clean | +| ✅ | Prayer times for Mecca — loaded | +| ✅ | Prayer times for Jakarta — loaded | +| ✅ | Prayer times for London — loaded | +| ✅ | api/prayer with bad token → 200 (graceful) | +| ✅ | api/dhikr/stats with bad token → 401 (graceful) | +| ⚠️ | api/prayer — 200 with missing params (may silently default) | +| ⚠️ | api/qibla → 404 (unexpected) | +| ✅ | Prayer API — handles 'space in name': New York | +| ✅ | Prayer API — handles 'accented chars': São Paulo | +| ✅ | Prayer API — handles 'umlaut': Köln | +| ✅ | Prayer API — handles 'normal ref': Kuala Lumpur | +| ✅ | Burst test — 20/20 returned 200 (avg 240ms, total 1.0s) | +| ✅ | /this-does-not-exist → 404 (correct) | +| ✅ | /api/nonexistent → 404 (correct) | +| ✅ | /mobile/undefined-route → 404 (correct) | +| ✅ | / — 142ms | +| ✅ | /prayer — 107ms | +| ✅ | /dhikr — 78ms | +| ✅ | /api/prayer?city=Kuala+Lumpur&country=MY — 77ms | +| ✅ | / — viewport meta present | +| ✅ | /prayer — viewport meta present | +| ✅ | /dhikr — viewport meta present | +| ✅ | /qibla — viewport meta present | +| ✅ | /halal-monitor — viewport meta present | +| ✅ | /nur — viewport meta present | +| ✅ | /forum — viewport meta present | +| ✅ | /souq — viewport meta present | +| ✅ | /groups — viewport meta present | +| ✅ | / — ≥44px touch targets detected | +| ✅ | /prayer — ≥44px touch targets detected | +| ✅ | /dhikr — ≥44px touch targets detected | +| ✅ | /qibla — ≥44px touch targets detected | +| ✅ | /halal-monitor — ≥44px touch targets detected | +| ✅ | /nur — ≥44px touch targets detected | +| ✅ | /forum — ≥44px touch targets detected | +| ✅ | /souq — ≥44px touch targets detected | +| ✅ | /groups — ≥44px touch targets detected | +| ✅ | / — no sub-12px text | +| ✅ | /prayer — no sub-12px text | +| ⚠️ | /dhikr — sub-12px text found: ['10px'] | +| ✅ | /qibla — no sub-12px text | +| ✅ | /halal-monitor — no sub-12px text | +| ✅ | /nur — no sub-12px text | +| ✅ | /forum — no sub-12px text | +| ✅ | /souq — no sub-12px text | +| ✅ | /groups — no sub-12px text | +| ✅ | / — bottom nav with 6/6 links | +| ✅ | /prayer — bottom nav with 6/6 links | +| ✅ | /dhikr — bottom nav with 6/6 links | +| ✅ | /qibla — bottom nav with 6/6 links | +| ✅ | /prayer — 2 tap targets found | +| ✅ | /dhikr — 9 tap targets found | +| ✅ | Landing (/) — cold=164ms warm=141ms, 13KB | +| ✅ | Prayer (/prayer) — cold=120ms warm=82ms, 15KB | +| ✅ | Dhikr (/dhikr) — cold=66ms warm=67ms, 18KB | +| ✅ | Qibla (/qibla) — cold=88ms warm=79ms, 16KB | +| ⚠️ | Halal Monitor (/halal-monitor) — cold=31358ms, 14KB | +| ✅ | Prayer API (cached) (/api/prayer?city=Kuala+Lumpur&country=MY) — cold=73ms warm=131ms, 0KB | +| ✅ | Prayer API — 117ms | +| ✅ | Health API — 172ms | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ⚠️ | X-Content-Type-Options: MISSING | +| ✅ | / — no JWT token leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no OpenAI key leak | +| ✅ | / — no Private key leak | +| ✅ | / — no FLH secret pattern leak | +| ✅ | /prayer — no JWT token leak | +| ✅ | /prayer — no GitHub PAT leak | +| ✅ | /prayer — no OpenAI key leak | +| ✅ | /prayer — no Private key leak | +| ✅ | /prayer — no FLH secret pattern leak | +| ✅ | /dhikr — no JWT token leak | +| ✅ | /dhikr — no GitHub PAT leak | +| ✅ | /dhikr — no OpenAI key leak | +| ✅ | /dhikr — no Private key leak | +| ✅ | /dhikr — no FLH secret pattern leak | +| ✅ | /qibla — no JWT token leak | +| ✅ | /qibla — no GitHub PAT leak | +| ✅ | /qibla — no OpenAI key leak | +| ✅ | /qibla — no Private key leak | +| ✅ | /qibla — no FLH secret pattern leak | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260618_162540.md b/qa-report-20260618_162540.md new file mode 100644 index 0000000..e6fe3ee --- /dev/null +++ b/qa-report-20260618_162540.md @@ -0,0 +1,30 @@ +# QA Test Report +**Date:** 2026-06-18 16:25:40 +**URL:** https://falahos.my/mobile +**Layer:** system + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 12 | 0 | 0 | 12 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 378ms | +| ✅ | Prayer times (/prayer) → 200 in 97ms | +| ✅ | Dhikr counter (/dhikr) → 200 in 137ms | +| ✅ | Qibla finder (/qibla) → 200 in 171ms | +| ✅ | Halal monitor (/halal-monitor) → 200 in 121ms | +| ✅ | Nur AI (/nur) → 200 in 145ms | +| ✅ | Forum (/forum) → 200 in 150ms | +| ✅ | Souq (/souq) → 200 in 116ms | +| ✅ | Groups (/groups) → 200 in 81ms | +| ✅ | Health API endpoint (/api/health) → 200 in 78ms | +| ✅ | Prayer API endpoint (/api/prayer) → 200 in 715ms | +| ✅ | Landing returns 200 directly | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260618_162541.md b/qa-report-20260618_162541.md new file mode 100644 index 0000000..bce8071 --- /dev/null +++ b/qa-report-20260618_162541.md @@ -0,0 +1,25 @@ +# QA Test Report +**Date:** 2026-06-18 16:25:41 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 5 | 0 | 2 | 7 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 18 Jun 2026 | +| ✅ | Prayer API — hijri: 3 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260618_162545.md b/qa-report-20260618_162545.md new file mode 100644 index 0000000..cf69bd3 --- /dev/null +++ b/qa-report-20260618_162545.md @@ -0,0 +1,34 @@ +# QA Test Report +**Date:** 2026-06-18 16:25:45 +**URL:** https://falahos.my/mobile +**Layer:** edge + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 14 | 0 | 2 | 16 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | api/prayer with bad token → 200 (graceful) | +| ✅ | api/dhikr/stats with bad token → 401 (graceful) | +| ⚠️ | api/prayer — 200 with missing params (may silently default) | +| ⚠️ | api/qibla → 404 (unexpected) | +| ✅ | Prayer API — handles 'space in name': New York | +| ✅ | Prayer API — handles 'accented chars': São Paulo | +| ✅ | Prayer API — handles 'umlaut': Köln | +| ✅ | Prayer API — handles 'normal ref': Kuala Lumpur | +| ✅ | Burst test — 20/20 returned 200 (avg 237ms, total 0.3s) | +| ✅ | /this-does-not-exist → 404 (correct) | +| ✅ | /api/nonexistent → 404 (correct) | +| ✅ | /mobile/undefined-route → 404 (correct) | +| ✅ | / — 136ms | +| ✅ | /prayer — 129ms | +| ✅ | /dhikr — 85ms | +| ✅ | /api/prayer?city=Kuala+Lumpur&country=MY — 108ms | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260618_165415.md b/qa-report-20260618_165415.md new file mode 100644 index 0000000..a40efde --- /dev/null +++ b/qa-report-20260618_165415.md @@ -0,0 +1,183 @@ +# QA Test Report +**Date:** 2026-06-18 16:54:15 +**URL:** https://falahos.my/mobile +**Layer:** all + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 142 | 15 | 8 | 165 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 177ms | +| ✅ | Prayer times (/prayer) → 200 in 94ms | +| ✅ | Dhikr counter (/dhikr) → 200 in 102ms | +| ✅ | Qibla finder (/qibla) → 200 in 112ms | +| ✅ | Halal monitor (/halal-monitor) → 200 in 156ms | +| ✅ | Nur AI (/nur) → 200 in 77ms | +| ✅ | Forum (/forum) → 200 in 73ms | +| ✅ | Souq (/souq) → 200 in 73ms | +| ✅ | Groups (/groups) → 200 in 82ms | +| ✅ | Health API endpoint (/api/health) → 200 in 106ms | +| ✅ | Prayer API endpoint (/api/prayer) → 200 in 111ms | +| ✅ | Landing returns 200 directly | +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 18 Jun 2026 | +| ✅ | Prayer API — hijri: 3 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Nur (/nur) — contains "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Forum (/forum) — contains "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Souq (/souq) — contains "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | +| ✅ | Unauthenticated / — shows content | +| ✅ | Unauthenticated /prayer — shows content | +| ✅ | Unauthenticated /qibla — shows content | +| ✅ | Login — obtained token (eyJhbGciOiJIUzI1...X_ns) | +| ❌ | Authenticated / — missing: ['Assalamualaikum'] | +| ❌ | Authenticated /prayer — missing: ['Fajr'] | +| ✅ | Authenticated /dhikr — all expected content present | +| ✅ | Authenticated /qibla — all expected content present | +| ⚠️ | Dhikr session — HTTP 404 | +| ⚠️ | Dhikr stats — HTTP 500 | +| ❌ | Qibla API — unexpected: None | +| ✅ | /prayer — accessible and clean | +| ✅ | /dhikr — accessible and clean | +| ✅ | /qibla — accessible and clean | +| ✅ | Full journey (Prayer → Dhikr → Qibla) — ALL pages clean | +| ✅ | Prayer times for Mecca — loaded | +| ✅ | Prayer times for Jakarta — loaded | +| ✅ | Prayer times for London — loaded | +| ✅ | api/prayer with bad token → 200 (graceful) | +| ✅ | api/dhikr/stats with bad token → 401 (graceful) | +| ⚠️ | api/prayer — 200 with missing params (may silently default) | +| ⚠️ | api/qibla → 404 (unexpected) | +| ✅ | Prayer API — handles 'space in name': New York | +| ✅ | Prayer API — handles 'accented chars': São Paulo | +| ✅ | Prayer API — handles 'umlaut': Köln | +| ✅ | Prayer API — handles 'normal ref': Kuala Lumpur | +| ✅ | Burst test — 20/20 returned 200 (avg 246ms, total 0.4s) | +| ✅ | /this-does-not-exist → 404 (correct) | +| ✅ | /api/nonexistent → 404 (correct) | +| ✅ | /mobile/undefined-route → 404 (correct) | +| ✅ | / — 121ms | +| ✅ | /prayer — 55ms | +| ✅ | /dhikr — 91ms | +| ✅ | /api/prayer?city=Kuala+Lumpur&country=MY — 94ms | +| ✅ | / — viewport meta present | +| ✅ | /prayer — viewport meta present | +| ✅ | /dhikr — viewport meta present | +| ✅ | /qibla — viewport meta present | +| ✅ | /halal-monitor — viewport meta present | +| ✅ | /nur — viewport meta present | +| ✅ | /forum — viewport meta present | +| ✅ | /souq — viewport meta present | +| ✅ | /groups — viewport meta present | +| ✅ | / — ≥44px touch targets detected | +| ✅ | /prayer — ≥44px touch targets detected | +| ✅ | /dhikr — ≥44px touch targets detected | +| ✅ | /qibla — ≥44px touch targets detected | +| ✅ | /halal-monitor — ≥44px touch targets detected | +| ✅ | /nur — ≥44px touch targets detected | +| ✅ | /forum — ≥44px touch targets detected | +| ✅ | /souq — ≥44px touch targets detected | +| ✅ | /groups — ≥44px touch targets detected | +| ✅ | / — no sub-12px text | +| ✅ | /prayer — no sub-12px text | +| ⚠️ | /dhikr — sub-12px text found: ['10px'] | +| ✅ | /qibla — no sub-12px text | +| ✅ | /halal-monitor — no sub-12px text | +| ✅ | /nur — no sub-12px text | +| ✅ | /forum — no sub-12px text | +| ✅ | /souq — no sub-12px text | +| ✅ | /groups — no sub-12px text | +| ✅ | / — bottom nav with 6/6 links | +| ✅ | /prayer — bottom nav with 6/6 links | +| ✅ | /dhikr — bottom nav with 6/6 links | +| ✅ | /qibla — bottom nav with 6/6 links | +| ✅ | /prayer — 2 tap targets found | +| ✅ | /dhikr — 9 tap targets found | +| ✅ | Landing (/) — cold=145ms warm=124ms, 13KB | +| ✅ | Prayer (/prayer) — cold=68ms warm=66ms, 15KB | +| ✅ | Dhikr (/dhikr) — cold=90ms warm=97ms, 18KB | +| ✅ | Qibla (/qibla) — cold=75ms warm=83ms, 16KB | +| ✅ | Halal Monitor (/halal-monitor) — cold=684ms warm=65ms, 14KB | +| ✅ | Prayer API (cached) (/api/prayer?city=Kuala+Lumpur&country=MY) — cold=74ms warm=61ms, 0KB | +| ✅ | Prayer API — 70ms | +| ✅ | Health API — 67ms | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ⚠️ | X-Content-Type-Options: MISSING | +| ✅ | / — no JWT token leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no OpenAI key leak | +| ✅ | / — no Private key leak | +| ✅ | / — no FLH secret pattern leak | +| ✅ | /prayer — no JWT token leak | +| ✅ | /prayer — no GitHub PAT leak | +| ✅ | /prayer — no OpenAI key leak | +| ✅ | /prayer — no Private key leak | +| ✅ | /prayer — no FLH secret pattern leak | +| ✅ | /dhikr — no JWT token leak | +| ✅ | /dhikr — no GitHub PAT leak | +| ✅ | /dhikr — no OpenAI key leak | +| ✅ | /dhikr — no Private key leak | +| ✅ | /dhikr — no FLH secret pattern leak | +| ✅ | /qibla — no JWT token leak | +| ✅ | /qibla — no GitHub PAT leak | +| ✅ | /qibla — no OpenAI key leak | +| ✅ | /qibla — no Private key leak | +| ✅ | /qibla — no FLH secret pattern leak | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260618_173318.md b/qa-report-20260618_173318.md new file mode 100644 index 0000000..b7a9f82 --- /dev/null +++ b/qa-report-20260618_173318.md @@ -0,0 +1,170 @@ +# QA Test Report +**Date:** 2026-06-18 17:33:18 +**URL:** http://localhost:4014/mobile +**Layer:** all + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 132 | 13 | 7 | 152 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 144ms | +| ✅ | Prayer times (/prayer) → 200 in 25ms | +| ✅ | Dhikr counter (/dhikr) → 200 in 51ms | +| ✅ | Qibla finder (/qibla) → 200 in 38ms | +| ✅ | Halal monitor (/halal-monitor) → 200 in 35ms | +| ✅ | Nur AI (/nur) → 200 in 35ms | +| ✅ | Forum (/forum) → 200 in 18ms | +| ✅ | Souq (/souq) → 200 in 26ms | +| ✅ | Groups (/groups) → 200 in 21ms | +| ✅ | Health API endpoint (/api/health) → 200 in 16ms | +| ✅ | Prayer API endpoint (/api/prayer) → 200 in 404ms | +| ✅ | Landing returns 200 directly | +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 18 Jun 2026 | +| ✅ | Prayer API — hijri: 3 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Nur (/nur) — contains "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Forum (/forum) — contains "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Souq (/souq) — contains "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | +| ✅ | Unauthenticated / — shows content | +| ✅ | Unauthenticated /prayer — shows content | +| ✅ | Unauthenticated /qibla — shows content | +| ❌ | Login — no token in response: {'error': 'Login failed. Please try again.'} | +| ⚠️ | Skipping authenticated scenarios | +| ✅ | api/prayer with bad token → 200 (graceful) | +| ✅ | api/dhikr/stats with bad token → 401 (graceful) | +| ⚠️ | api/prayer — 200 with missing params (may silently default) | +| ⚠️ | api/qibla → 404 (unexpected) | +| ✅ | Prayer API — handles 'space in name': New York | +| ✅ | Prayer API — handles 'accented chars': São Paulo | +| ✅ | Prayer API — handles 'umlaut': Köln | +| ✅ | Prayer API — handles 'normal ref': Kuala Lumpur | +| ✅ | Burst test — 20/20 returned 200 (avg 142ms, total 0.2s) | +| ✅ | /this-does-not-exist → 404 (correct) | +| ✅ | /api/nonexistent → 404 (correct) | +| ✅ | /mobile/undefined-route → 404 (correct) | +| ✅ | / — 16ms | +| ✅ | /prayer — 7ms | +| ✅ | /dhikr — 8ms | +| ✅ | /api/prayer?city=Kuala+Lumpur&country=MY — 9ms | +| ✅ | / — viewport meta present | +| ✅ | /prayer — viewport meta present | +| ✅ | /dhikr — viewport meta present | +| ✅ | /qibla — viewport meta present | +| ✅ | /halal-monitor — viewport meta present | +| ✅ | /nur — viewport meta present | +| ✅ | /forum — viewport meta present | +| ✅ | /souq — viewport meta present | +| ✅ | /groups — viewport meta present | +| ✅ | / — ≥44px touch targets detected | +| ✅ | /prayer — ≥44px touch targets detected | +| ✅ | /dhikr — ≥44px touch targets detected | +| ✅ | /qibla — ≥44px touch targets detected | +| ✅ | /halal-monitor — ≥44px touch targets detected | +| ✅ | /nur — ≥44px touch targets detected | +| ✅ | /forum — ≥44px touch targets detected | +| ✅ | /souq — ≥44px touch targets detected | +| ✅ | /groups — ≥44px touch targets detected | +| ✅ | / — no sub-12px text | +| ✅ | /prayer — no sub-12px text | +| ⚠️ | /dhikr — sub-12px text found: ['10px'] | +| ✅ | /qibla — no sub-12px text | +| ✅ | /halal-monitor — no sub-12px text | +| ✅ | /nur — no sub-12px text | +| ✅ | /forum — no sub-12px text | +| ✅ | /souq — no sub-12px text | +| ✅ | /groups — no sub-12px text | +| ✅ | / — bottom nav with 6/6 links | +| ✅ | /prayer — bottom nav with 6/6 links | +| ✅ | /dhikr — bottom nav with 6/6 links | +| ✅ | /qibla — bottom nav with 6/6 links | +| ✅ | /prayer — 2 tap targets found | +| ✅ | /dhikr — 9 tap targets found | +| ✅ | Landing (/) — cold=13ms warm=13ms, 13KB | +| ✅ | Prayer (/prayer) — cold=7ms warm=11ms, 15KB | +| ✅ | Dhikr (/dhikr) — cold=7ms warm=8ms, 18KB | +| ✅ | Qibla (/qibla) — cold=7ms warm=8ms, 16KB | +| ✅ | Halal Monitor (/halal-monitor) — cold=11ms warm=8ms, 14KB | +| ✅ | Prayer API (cached) (/api/prayer?city=Kuala+Lumpur&country=MY) — cold=11ms warm=10ms, 0KB | +| ✅ | Prayer API — 19ms | +| ✅ | Health API — 11ms | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ⚠️ | X-Content-Type-Options: MISSING | +| ✅ | / — no JWT token leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no OpenAI key leak | +| ✅ | / — no Private key leak | +| ✅ | / — no FLH secret pattern leak | +| ✅ | /prayer — no JWT token leak | +| ✅ | /prayer — no GitHub PAT leak | +| ✅ | /prayer — no OpenAI key leak | +| ✅ | /prayer — no Private key leak | +| ✅ | /prayer — no FLH secret pattern leak | +| ✅ | /dhikr — no JWT token leak | +| ✅ | /dhikr — no GitHub PAT leak | +| ✅ | /dhikr — no OpenAI key leak | +| ✅ | /dhikr — no Private key leak | +| ✅ | /dhikr — no FLH secret pattern leak | +| ✅ | /qibla — no JWT token leak | +| ✅ | /qibla — no GitHub PAT leak | +| ✅ | /qibla — no OpenAI key leak | +| ✅ | /qibla — no Private key leak | +| ✅ | /qibla — no FLH secret pattern leak | + +## Re-run +```bash +python3 tests/qa-test-suite.py --ci-exit +``` \ No newline at end of file diff --git a/qa-report-20260618_193920.md b/qa-report-20260618_193920.md new file mode 100644 index 0000000..12c20b2 --- /dev/null +++ b/qa-report-20260618_193920.md @@ -0,0 +1,183 @@ +# QA Test Report +**Date:** 2026-06-18 19:39:20 +**URL:** https://falahos.my/mobile +**Layer:** all + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 143 | 15 | 7 | 165 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 267ms | +| ✅ | Prayer times (/prayer) → 200 in 135ms | +| ✅ | Dhikr counter (/dhikr) → 200 in 173ms | +| ✅ | Qibla finder (/qibla) → 200 in 157ms | +| ✅ | Halal monitor (/halal-monitor) → 200 in 95ms | +| ✅ | Nur AI (/nur) → 200 in 111ms | +| ✅ | Forum (/forum) → 200 in 109ms | +| ✅ | Souq (/souq) → 200 in 117ms | +| ✅ | Groups (/groups) → 200 in 111ms | +| ✅ | Health API endpoint (/api/health) → 200 in 110ms | +| ✅ | Prayer API endpoint (/api/prayer) → 200 in 356ms | +| ✅ | Landing returns 200 directly | +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 19 Jun 2026 | +| ✅ | Prayer API — hijri: 4 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Nur (/nur) — contains "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Forum (/forum) — contains "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Souq (/souq) — contains "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | +| ✅ | Unauthenticated / — shows content | +| ✅ | Unauthenticated /prayer — shows content | +| ✅ | Unauthenticated /qibla — shows content | +| ✅ | Login — obtained token (eyJhbGciOiJIUzI1...Rqnk) | +| ❌ | Authenticated / — missing: ['Assalamualaikum'] | +| ❌ | Authenticated /prayer — missing: ['Fajr'] | +| ✅ | Authenticated /dhikr — all expected content present | +| ✅ | Authenticated /qibla — all expected content present | +| ⚠️ | Dhikr session — HTTP 404 | +| ✅ | Dhikr stats — received data: {'dayStats': {'userId': 'cmqgdj5ta00009aare9z8x4df', 'date': '2026-06-18', 'subhanallah': 0, 'alhamd | +| ❌ | Qibla API — unexpected: None | +| ✅ | /prayer — accessible and clean | +| ✅ | /dhikr — accessible and clean | +| ✅ | /qibla — accessible and clean | +| ✅ | Full journey (Prayer → Dhikr → Qibla) — ALL pages clean | +| ✅ | Prayer times for Mecca — loaded | +| ✅ | Prayer times for Jakarta — loaded | +| ✅ | Prayer times for London — loaded | +| ✅ | api/prayer with bad token → 200 (graceful) | +| ✅ | api/dhikr/stats with bad token → 401 (graceful) | +| ⚠️ | api/prayer — 200 with missing params (may silently default) | +| ⚠️ | api/qibla → 404 (unexpected) | +| ✅ | Prayer API — handles 'space in name': New York | +| ✅ | Prayer API — handles 'accented chars': São Paulo | +| ✅ | Prayer API — handles 'umlaut': Köln | +| ✅ | Prayer API — handles 'normal ref': Kuala Lumpur | +| ✅ | Burst test — 20/20 returned 200 (avg 157ms, total 0.3s) | +| ✅ | /this-does-not-exist → 404 (correct) | +| ✅ | /api/nonexistent → 404 (correct) | +| ✅ | /mobile/undefined-route → 404 (correct) | +| ✅ | / — 115ms | +| ✅ | /prayer — 72ms | +| ✅ | /dhikr — 58ms | +| ✅ | /api/prayer?city=Kuala+Lumpur&country=MY — 50ms | +| ✅ | / — viewport meta present | +| ✅ | /prayer — viewport meta present | +| ✅ | /dhikr — viewport meta present | +| ✅ | /qibla — viewport meta present | +| ✅ | /halal-monitor — viewport meta present | +| ✅ | /nur — viewport meta present | +| ✅ | /forum — viewport meta present | +| ✅ | /souq — viewport meta present | +| ✅ | /groups — viewport meta present | +| ✅ | / — ≥44px touch targets detected | +| ✅ | /prayer — ≥44px touch targets detected | +| ✅ | /dhikr — ≥44px touch targets detected | +| ✅ | /qibla — ≥44px touch targets detected | +| ✅ | /halal-monitor — ≥44px touch targets detected | +| ✅ | /nur — ≥44px touch targets detected | +| ✅ | /forum — ≥44px touch targets detected | +| ✅ | /souq — ≥44px touch targets detected | +| ✅ | /groups — ≥44px touch targets detected | +| ✅ | / — no sub-12px text | +| ✅ | /prayer — no sub-12px text | +| ⚠️ | /dhikr — sub-12px text found: ['10px'] | +| ✅ | /qibla — no sub-12px text | +| ✅ | /halal-monitor — no sub-12px text | +| ✅ | /nur — no sub-12px text | +| ✅ | /forum — no sub-12px text | +| ✅ | /souq — no sub-12px text | +| ✅ | /groups — no sub-12px text | +| ✅ | / — bottom nav with 6/6 links | +| ✅ | /prayer — bottom nav with 6/6 links | +| ✅ | /dhikr — bottom nav with 6/6 links | +| ✅ | /qibla — bottom nav with 6/6 links | +| ✅ | /prayer — 2 tap targets found | +| ✅ | /dhikr — 9 tap targets found | +| ✅ | Landing (/) — cold=128ms warm=127ms, 13KB | +| ✅ | Prayer (/prayer) — cold=58ms warm=62ms, 15KB | +| ✅ | Dhikr (/dhikr) — cold=101ms warm=67ms, 18KB | +| ✅ | Qibla (/qibla) — cold=75ms warm=67ms, 16KB | +| ✅ | Halal Monitor (/halal-monitor) — cold=64ms warm=63ms, 14KB | +| ✅ | Prayer API (cached) (/api/prayer?city=Kuala+Lumpur&country=MY) — cold=64ms warm=68ms, 0KB | +| ✅ | Prayer API — 89ms | +| ✅ | Health API — 100ms | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ⚠️ | X-Content-Type-Options: MISSING | +| ✅ | / — no JWT token leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no OpenAI key leak | +| ✅ | / — no Private key leak | +| ✅ | / — no FLH secret pattern leak | +| ✅ | /prayer — no JWT token leak | +| ✅ | /prayer — no GitHub PAT leak | +| ✅ | /prayer — no OpenAI key leak | +| ✅ | /prayer — no Private key leak | +| ✅ | /prayer — no FLH secret pattern leak | +| ✅ | /dhikr — no JWT token leak | +| ✅ | /dhikr — no GitHub PAT leak | +| ✅ | /dhikr — no OpenAI key leak | +| ✅ | /dhikr — no Private key leak | +| ✅ | /dhikr — no FLH secret pattern leak | +| ✅ | /qibla — no JWT token leak | +| ✅ | /qibla — no GitHub PAT leak | +| ✅ | /qibla — no OpenAI key leak | +| ✅ | /qibla — no Private key leak | +| ✅ | /qibla — no FLH secret pattern leak | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260619_001148.md b/qa-report-20260619_001148.md new file mode 100644 index 0000000..eef10fc --- /dev/null +++ b/qa-report-20260619_001148.md @@ -0,0 +1,183 @@ +# QA Test Report +**Date:** 2026-06-19 00:11:48 +**URL:** https://falahos.my/mobile +**Layer:** all + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 143 | 15 | 7 | 165 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 211ms | +| ✅ | Prayer times (/prayer) → 200 in 108ms | +| ✅ | Dhikr counter (/dhikr) → 200 in 135ms | +| ✅ | Qibla finder (/qibla) → 200 in 115ms | +| ✅ | Halal monitor (/halal-monitor) → 200 in 114ms | +| ✅ | Nur AI (/nur) → 200 in 70ms | +| ✅ | Forum (/forum) → 200 in 81ms | +| ✅ | Souq (/souq) → 200 in 76ms | +| ✅ | Groups (/groups) → 200 in 92ms | +| ✅ | Health API endpoint (/api/health) → 200 in 63ms | +| ✅ | Prayer API endpoint (/api/prayer) → 200 in 422ms | +| ✅ | Landing returns 200 directly | +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 19 Jun 2026 | +| ✅ | Prayer API — hijri: 4 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Nur (/nur) — contains "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Forum (/forum) — contains "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Souq (/souq) — contains "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | +| ✅ | Unauthenticated / — shows content | +| ✅ | Unauthenticated /prayer — shows content | +| ✅ | Unauthenticated /qibla — shows content | +| ✅ | Login — obtained token (eyJhbGciOiJIUzI1...-LPQ) | +| ❌ | Authenticated / — missing: ['Assalamualaikum'] | +| ❌ | Authenticated /prayer — missing: ['Fajr'] | +| ✅ | Authenticated /dhikr — all expected content present | +| ✅ | Authenticated /qibla — all expected content present | +| ⚠️ | Dhikr session — HTTP 404 | +| ✅ | Dhikr stats — received data: {'dayStats': {'userId': 'cmqgdj5ta00009aare9z8x4df', 'date': '2026-06-18', 'subhanallah': 0, 'alhamd | +| ❌ | Qibla API — unexpected: None | +| ✅ | /prayer — accessible and clean | +| ✅ | /dhikr — accessible and clean | +| ✅ | /qibla — accessible and clean | +| ✅ | Full journey (Prayer → Dhikr → Qibla) — ALL pages clean | +| ✅ | Prayer times for Mecca — loaded | +| ✅ | Prayer times for Jakarta — loaded | +| ✅ | Prayer times for London — loaded | +| ✅ | api/prayer with bad token → 200 (graceful) | +| ✅ | api/dhikr/stats with bad token → 401 (graceful) | +| ⚠️ | api/prayer — 200 with missing params (may silently default) | +| ⚠️ | api/qibla → 404 (unexpected) | +| ✅ | Prayer API — handles 'space in name': New York | +| ✅ | Prayer API — handles 'accented chars': São Paulo | +| ✅ | Prayer API — handles 'umlaut': Köln | +| ✅ | Prayer API — handles 'normal ref': Kuala Lumpur | +| ✅ | Burst test — 20/20 returned 200 (avg 168ms, total 0.3s) | +| ✅ | /this-does-not-exist → 404 (correct) | +| ✅ | /api/nonexistent → 404 (correct) | +| ✅ | /mobile/undefined-route → 404 (correct) | +| ✅ | / — 137ms | +| ✅ | /prayer — 75ms | +| ✅ | /dhikr — 61ms | +| ✅ | /api/prayer?city=Kuala+Lumpur&country=MY — 50ms | +| ✅ | / — viewport meta present | +| ✅ | /prayer — viewport meta present | +| ✅ | /dhikr — viewport meta present | +| ✅ | /qibla — viewport meta present | +| ✅ | /halal-monitor — viewport meta present | +| ✅ | /nur — viewport meta present | +| ✅ | /forum — viewport meta present | +| ✅ | /souq — viewport meta present | +| ✅ | /groups — viewport meta present | +| ✅ | / — ≥44px touch targets detected | +| ✅ | /prayer — ≥44px touch targets detected | +| ✅ | /dhikr — ≥44px touch targets detected | +| ✅ | /qibla — ≥44px touch targets detected | +| ✅ | /halal-monitor — ≥44px touch targets detected | +| ✅ | /nur — ≥44px touch targets detected | +| ✅ | /forum — ≥44px touch targets detected | +| ✅ | /souq — ≥44px touch targets detected | +| ✅ | /groups — ≥44px touch targets detected | +| ✅ | / — no sub-12px text | +| ✅ | /prayer — no sub-12px text | +| ⚠️ | /dhikr — sub-12px text found: ['10px'] | +| ✅ | /qibla — no sub-12px text | +| ✅ | /halal-monitor — no sub-12px text | +| ✅ | /nur — no sub-12px text | +| ✅ | /forum — no sub-12px text | +| ✅ | /souq — no sub-12px text | +| ✅ | /groups — no sub-12px text | +| ✅ | / — bottom nav with 6/6 links | +| ✅ | /prayer — bottom nav with 6/6 links | +| ✅ | /dhikr — bottom nav with 6/6 links | +| ✅ | /qibla — bottom nav with 6/6 links | +| ✅ | /prayer — 2 tap targets found | +| ✅ | /dhikr — 9 tap targets found | +| ✅ | Landing (/) — cold=151ms warm=126ms, 13KB | +| ✅ | Prayer (/prayer) — cold=52ms warm=52ms, 15KB | +| ✅ | Dhikr (/dhikr) — cold=73ms warm=74ms, 18KB | +| ✅ | Qibla (/qibla) — cold=170ms warm=65ms, 16KB | +| ✅ | Halal Monitor (/halal-monitor) — cold=52ms warm=59ms, 14KB | +| ✅ | Prayer API (cached) (/api/prayer?city=Kuala+Lumpur&country=MY) — cold=77ms warm=58ms, 0KB | +| ✅ | Prayer API — 55ms | +| ✅ | Health API — 72ms | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ⚠️ | X-Content-Type-Options: MISSING | +| ✅ | / — no JWT token leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no OpenAI key leak | +| ✅ | / — no Private key leak | +| ✅ | / — no FLH secret pattern leak | +| ✅ | /prayer — no JWT token leak | +| ✅ | /prayer — no GitHub PAT leak | +| ✅ | /prayer — no OpenAI key leak | +| ✅ | /prayer — no Private key leak | +| ✅ | /prayer — no FLH secret pattern leak | +| ✅ | /dhikr — no JWT token leak | +| ✅ | /dhikr — no GitHub PAT leak | +| ✅ | /dhikr — no OpenAI key leak | +| ✅ | /dhikr — no Private key leak | +| ✅ | /dhikr — no FLH secret pattern leak | +| ✅ | /qibla — no JWT token leak | +| ✅ | /qibla — no GitHub PAT leak | +| ✅ | /qibla — no OpenAI key leak | +| ✅ | /qibla — no Private key leak | +| ✅ | /qibla — no FLH secret pattern leak | + +## Re-run +```bash +python3 tests/qa-test-suite.py --ci-exit +``` \ No newline at end of file diff --git a/qa-report-20260624_132621.md b/qa-report-20260624_132621.md new file mode 100644 index 0000000..e78ca1f --- /dev/null +++ b/qa-report-20260624_132621.md @@ -0,0 +1,183 @@ +# QA Test Report +**Date:** 2026-06-24 13:26:21 +**URL:** https://falahos.my/mobile +**Layer:** all + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 137 | 18 | 10 | 165 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing page (/) → 200 in 287ms | +| ✅ | Prayer times (/prayer) → 200 in 135ms | +| ✅ | Dhikr counter (/dhikr) → 200 in 97ms | +| ✅ | Qibla finder (/qibla) → 200 in 284ms | +| ✅ | Halal monitor (/halal-monitor) → 200 in 70ms | +| ✅ | Nur AI (/nur) → 200 in 75ms | +| ✅ | Forum (/forum) → 200 in 71ms | +| ✅ | Souq (/souq) → 200 in 114ms | +| ✅ | Groups (/groups) → 200 in 133ms | +| ✅ | Health API endpoint (/api/health) → 200 in 70ms | +| ✅ | Prayer API endpoint (/api/prayer) → 200 in 428ms | +| ✅ | Landing returns 200 directly | +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 24 Jun 2026 | +| ✅ | Prayer API — hijri: 9 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ✅ | Ummah ID Health — available (expected for SSO route) | +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ❌ | Nur (/nur) — MISSING expected text: "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ❌ | Forum (/forum) — MISSING expected text: "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ❌ | Souq (/souq) — MISSING expected text: "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | +| ✅ | Unauthenticated / — shows content | +| ✅ | Unauthenticated /prayer — shows content | +| ✅ | Unauthenticated /qibla — shows content | +| ✅ | Login — obtained token (eyJhbGciOiJIUzI1...0ES0) | +| ❌ | Authenticated / — missing: ['Assalamualaikum'] | +| ❌ | Authenticated /prayer — missing: ['Fajr'] | +| ✅ | Authenticated /dhikr — all expected content present | +| ✅ | Authenticated /qibla — all expected content present | +| ⚠️ | Dhikr session — HTTP 404 | +| ✅ | Dhikr stats — received data: {'dayStats': {'userId': 'cmqgdj5ta00009aare9z8x4df', 'date': '2026-06-24', 'subhanallah': 0, 'alhamd | +| ❌ | Qibla API — unexpected: None | +| ✅ | /prayer — accessible and clean | +| ✅ | /dhikr — accessible and clean | +| ✅ | /qibla — accessible and clean | +| ✅ | Full journey (Prayer → Dhikr → Qibla) — ALL pages clean | +| ✅ | Prayer times for Mecca — loaded | +| ✅ | Prayer times for Jakarta — loaded | +| ✅ | Prayer times for London — loaded | +| ✅ | api/prayer with bad token → 200 (graceful) | +| ✅ | api/dhikr/stats with bad token → 401 (graceful) | +| ⚠️ | api/prayer — 200 with missing params (may silently default) | +| ⚠️ | api/qibla → 404 (unexpected) | +| ✅ | Prayer API — handles 'space in name': New York | +| ✅ | Prayer API — handles 'accented chars': São Paulo | +| ✅ | Prayer API — handles 'umlaut': Köln | +| ✅ | Prayer API — handles 'normal ref': Kuala Lumpur | +| ⚠️ | Burst test — 19/20 returned 200, 1 failed (avg 217ms) | +| ✅ | /this-does-not-exist → 404 (correct) | +| ✅ | /api/nonexistent → 404 (correct) | +| ✅ | /mobile/undefined-route → 404 (correct) | +| ✅ | / — 200ms | +| ✅ | /prayer — 171ms | +| ✅ | /dhikr — 143ms | +| ✅ | /api/prayer?city=Kuala+Lumpur&country=MY — 106ms | +| ✅ | / — viewport meta present | +| ✅ | /prayer — viewport meta present | +| ✅ | /dhikr — viewport meta present | +| ✅ | /qibla — viewport meta present | +| ✅ | /halal-monitor — viewport meta present | +| ✅ | /nur — viewport meta present | +| ✅ | /forum — viewport meta present | +| ✅ | /souq — viewport meta present | +| ✅ | /groups — viewport meta present | +| ✅ | / — ≥44px touch targets detected | +| ✅ | /prayer — ≥44px touch targets detected | +| ✅ | /dhikr — ≥44px touch targets detected | +| ✅ | /qibla — ≥44px touch targets detected | +| ✅ | /halal-monitor — ≥44px touch targets detected | +| ✅ | /nur — ≥44px touch targets detected | +| ✅ | /forum — ≥44px touch targets detected | +| ✅ | /souq — ≥44px touch targets detected | +| ✅ | /groups — ≥44px touch targets detected | +| ✅ | / — no sub-12px text | +| ✅ | /prayer — no sub-12px text | +| ⚠️ | /dhikr — sub-12px text found: ['10px'] | +| ✅ | /qibla — no sub-12px text | +| ✅ | /halal-monitor — no sub-12px text | +| ✅ | /nur — no sub-12px text | +| ✅ | /forum — no sub-12px text | +| ✅ | /souq — no sub-12px text | +| ✅ | /groups — no sub-12px text | +| ⚠️ | / — bottom nav incomplete (2/6) | +| ⚠️ | /prayer — bottom nav incomplete (3/6) | +| ⚠️ | /dhikr — bottom nav incomplete (2/6) | +| ⚠️ | /qibla — bottom nav incomplete (2/6) | +| ✅ | /prayer — 4 tap targets found | +| ✅ | /dhikr — 11 tap targets found | +| ✅ | Landing (/) — cold=197ms warm=281ms, 13KB | +| ✅ | Prayer (/prayer) — cold=328ms warm=101ms, 16KB | +| ✅ | Dhikr (/dhikr) — cold=66ms warm=113ms, 19KB | +| ✅ | Qibla (/qibla) — cold=105ms warm=107ms, 16KB | +| ✅ | Halal Monitor (/halal-monitor) — cold=109ms warm=87ms, 14KB | +| ✅ | Prayer API (cached) (/api/prayer?city=Kuala+Lumpur&country=MY) — cold=133ms warm=78ms, 0KB | +| ✅ | Prayer API — 96ms | +| ✅ | Health API — 93ms | +| ✅ | Content-Type: text/html; charset=utf-8 | +| ✅ | X-Content-Type-Options: nosniff | +| ✅ | / — no JWT token leak | +| ✅ | / — no GitHub PAT leak | +| ✅ | / — no OpenAI key leak | +| ✅ | / — no Private key leak | +| ✅ | / — no FLH secret pattern leak | +| ✅ | /prayer — no JWT token leak | +| ✅ | /prayer — no GitHub PAT leak | +| ✅ | /prayer — no OpenAI key leak | +| ✅ | /prayer — no Private key leak | +| ✅ | /prayer — no FLH secret pattern leak | +| ✅ | /dhikr — no JWT token leak | +| ✅ | /dhikr — no GitHub PAT leak | +| ✅ | /dhikr — no OpenAI key leak | +| ✅ | /dhikr — no Private key leak | +| ✅ | /dhikr — no FLH secret pattern leak | +| ✅ | /qibla — no JWT token leak | +| ✅ | /qibla — no GitHub PAT leak | +| ✅ | /qibla — no OpenAI key leak | +| ✅ | /qibla — no Private key leak | +| ✅ | /qibla — no FLH secret pattern leak | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_155601.md b/qa-report-20260625_155601.md new file mode 100644 index 0000000..6d1fd8f --- /dev/null +++ b/qa-report-20260625_155601.md @@ -0,0 +1,25 @@ +# QA Test Report +**Date:** 2026-06-25 15:56:01 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 5 | 0 | 2 | 7 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 25 Jun 2026 | +| ✅ | Prayer API — hijri: 10 Muḥarram 1448 | +| ⚠️ | Prayer API (empty) — unexpected: None | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_155706.md b/qa-report-20260625_155706.md new file mode 100644 index 0000000..4da417a --- /dev/null +++ b/qa-report-20260625_155706.md @@ -0,0 +1,25 @@ +# QA Test Report +**Date:** 2026-06-25 15:57:06 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 5 | 0 | 2 | 7 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 25 Jun 2026 | +| ✅ | Prayer API — hijri: 10 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_155732.md b/qa-report-20260625_155732.md new file mode 100644 index 0000000..54154ca --- /dev/null +++ b/qa-report-20260625_155732.md @@ -0,0 +1,67 @@ +# QA Test Report +**Date:** 2026-06-25 15:57:32 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 34 | 15 | 0 | 49 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ❌ | Nur (/nur) — MISSING expected text: "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ❌ | Forum (/forum) — MISSING expected text: "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ❌ | Souq (/souq) — MISSING expected text: "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_160149.md b/qa-report-20260625_160149.md new file mode 100644 index 0000000..7425432 --- /dev/null +++ b/qa-report-20260625_160149.md @@ -0,0 +1,25 @@ +# QA Test Report +**Date:** 2026-06-25 16:01:49 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 5 | 0 | 2 | 7 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 25 Jun 2026 | +| ✅ | Prayer API — hijri: 10 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_170813.md b/qa-report-20260625_170813.md new file mode 100644 index 0000000..f6c4145 --- /dev/null +++ b/qa-report-20260625_170813.md @@ -0,0 +1,65 @@ +# QA Test Report +**Date:** 2026-06-25 17:08:13 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 30 | 17 | 0 | 47 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Sign in with Ummah ID" | +| ❌ | Landing (/) — MISSING expected text: "Continue with Email" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Prayer (/prayer) — contains "Kuala Lumpur" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ❌ | Dhikr (/dhikr) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ❌ | Qibla (/qibla) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ❌ | Nur (/nur) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ❌ | Forum (/forum) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ❌ | Souq (/souq) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Learn (/learn) — clean (no error patterns) | +| ❌ | Learn (/learn) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Wallet (/wallet) — clean (no error patterns) | +| ❌ | Wallet (/wallet) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Profile (/profile) — clean (no error patterns) | +| ❌ | Profile (/profile) — MISSING expected text: "Sign in with Ummah ID" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_170914.md b/qa-report-20260625_170914.md new file mode 100644 index 0000000..9d66814 --- /dev/null +++ b/qa-report-20260625_170914.md @@ -0,0 +1,48 @@ +# QA Test Report +**Date:** 2026-06-25 17:09:14 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 30 | 0 | 0 | 30 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ✅ | Prayer (/prayer) — contains "Kuala Lumpur" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ✅ | Learn (/learn) — clean (no error patterns) | +| ✅ | Wallet (/wallet) — clean (no error patterns) | +| ✅ | Profile (/profile) — clean (no error patterns) | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_173329.md b/qa-report-20260625_173329.md new file mode 100644 index 0000000..362c612 --- /dev/null +++ b/qa-report-20260625_173329.md @@ -0,0 +1,25 @@ +# QA Test Report +**Date:** 2026-06-25 17:33:29 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 5 | 0 | 2 | 7 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 25 Jun 2026 | +| ✅ | Prayer API — hijri: 10 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_173359.md b/qa-report-20260625_173359.md new file mode 100644 index 0000000..a8b16f7 --- /dev/null +++ b/qa-report-20260625_173359.md @@ -0,0 +1,48 @@ +# QA Test Report +**Date:** 2026-06-25 17:33:59 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 29 | 0 | 1 | 30 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ✅ | Prayer (/prayer) — contains "Kuala Lumpur" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ⚠️ | Nur (/nur) — suspiciously small (20 bytes) | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ✅ | Learn (/learn) — clean (no error patterns) | +| ✅ | Wallet (/wallet) — clean (no error patterns) | +| ✅ | Profile (/profile) — clean (no error patterns) | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_180303.md b/qa-report-20260625_180303.md new file mode 100644 index 0000000..2d3285d --- /dev/null +++ b/qa-report-20260625_180303.md @@ -0,0 +1,25 @@ +# QA Test Report +**Date:** 2026-06-25 18:03:03 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 5 | 0 | 2 | 7 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 25 Jun 2026 | +| ✅ | Prayer API — hijri: 10 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_180329.md b/qa-report-20260625_180329.md new file mode 100644 index 0000000..a5eb841 --- /dev/null +++ b/qa-report-20260625_180329.md @@ -0,0 +1,48 @@ +# QA Test Report +**Date:** 2026-06-25 18:03:29 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 30 | 0 | 0 | 30 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ✅ | Prayer (/prayer) — contains "Kuala Lumpur" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ✅ | Learn (/learn) — clean (no error patterns) | +| ✅ | Wallet (/wallet) — clean (no error patterns) | +| ✅ | Profile (/profile) — clean (no error patterns) | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_181854.md b/qa-report-20260625_181854.md new file mode 100644 index 0000000..5fc14d1 --- /dev/null +++ b/qa-report-20260625_181854.md @@ -0,0 +1,26 @@ +# QA Test Report +**Date:** 2026-06-25 18:18:54 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 6 | 0 | 2 | 8 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 26 Jun 2026 | +| ✅ | Prayer API — hijri: 11 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | +| ✅ | Souq Listings API — 11 listings returned | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260625_181922.md b/qa-report-20260625_181922.md new file mode 100644 index 0000000..b85a523 --- /dev/null +++ b/qa-report-20260625_181922.md @@ -0,0 +1,48 @@ +# QA Test Report +**Date:** 2026-06-25 18:19:22 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 30 | 0 | 0 | 30 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ✅ | Prayer (/prayer) — contains "Kuala Lumpur" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ✅ | Learn (/learn) — clean (no error patterns) | +| ✅ | Wallet (/wallet) — clean (no error patterns) | +| ✅ | Profile (/profile) — clean (no error patterns) | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260626_070054.md b/qa-report-20260626_070054.md new file mode 100644 index 0000000..7919029 --- /dev/null +++ b/qa-report-20260626_070054.md @@ -0,0 +1,26 @@ +# QA Test Report +**Date:** 2026-06-26 07:00:54 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 6 | 0 | 2 | 8 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 26 Jun 2026 | +| ✅ | Prayer API — hijri: 11 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | +| ✅ | Souq Listings API — 13 listings returned | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260626_070109.md b/qa-report-20260626_070109.md new file mode 100644 index 0000000..3e1f4cb --- /dev/null +++ b/qa-report-20260626_070109.md @@ -0,0 +1,48 @@ +# QA Test Report +**Date:** 2026-06-26 07:01:09 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 30 | 0 | 0 | 30 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ✅ | Prayer (/prayer) — contains "Kuala Lumpur" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ✅ | Learn (/learn) — clean (no error patterns) | +| ✅ | Wallet (/wallet) — clean (no error patterns) | +| ✅ | Profile (/profile) — clean (no error patterns) | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260627_070045.md b/qa-report-20260627_070045.md new file mode 100644 index 0000000..9ce7b39 --- /dev/null +++ b/qa-report-20260627_070045.md @@ -0,0 +1,25 @@ +# QA Test Report +**Date:** 2026-06-27 07:00:45 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 5 | 0 | 2 | 7 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Prayer API — full contract satisfied | +| ✅ | Prayer API — all 5 prayer times present | +| ✅ | Prayer API — date: 27 Jun 2026 | +| ✅ | Prayer API — hijri: 12 Muḥarram 1448 | +| ⚠️ | Prayer API — no error for empty params, returned default data | +| ✅ | Health API — status=ok | +| ⚠️ | Ummah ID Health → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260627_070058.md b/qa-report-20260627_070058.md new file mode 100644 index 0000000..66dda1e --- /dev/null +++ b/qa-report-20260627_070058.md @@ -0,0 +1,67 @@ +# QA Test Report +**Date:** 2026-06-27 07:00:58 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 34 | 15 | 0 | 49 | **D** | + +## Details +| Result | Message | +|:------|:--------| +| ✅ | Landing (/) — clean (no error patterns) | +| ✅ | Landing (/) — contains "Falah" | +| ❌ | Landing (/) — MISSING expected text: "Assalamualaikum" | +| ❌ | Landing (/) — MISSING expected text: "Daily Verse" | +| ❌ | Landing (/) — MISSING expected text: "Quick Actions" | +| ✅ | Prayer (/prayer) — clean (no error patterns) | +| ✅ | Prayer (/prayer) — contains "Prayer Times" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Fajr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Dhuhr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Asr" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Maghrib" | +| ❌ | Prayer (/prayer) — MISSING expected text: "Isha" | +| ✅ | Dhikr (/dhikr) — clean (no error patterns) | +| ✅ | Dhikr (/dhikr) — contains "Dhikr" | +| ✅ | Dhikr (/dhikr) — contains "Digital Tasbih" | +| ✅ | Dhikr (/dhikr) — contains "SubhanAllah" | +| ✅ | Dhikr (/dhikr) — contains "Alhamdulillah" | +| ✅ | Dhikr (/dhikr) — contains "Allahu Akbar" | +| ✅ | Qibla (/qibla) — clean (no error patterns) | +| ✅ | Qibla (/qibla) — contains "Qibla Finder" | +| ✅ | Qibla (/qibla) — contains "Kaaba" | +| ❌ | Qibla (/qibla) — MISSING expected text: "bearing" | +| ✅ | Halal Monitor (/halal-monitor) — clean (no error patterns) | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Halal Monitor" | +| ❌ | Halal Monitor (/halal-monitor) — MISSING expected text: "Restaurants" | +| ✅ | Nur (/nur) — clean (no error patterns) | +| ❌ | Nur (/nur) — MISSING expected text: "Nur" | +| ✅ | Forum (/forum) — clean (no error patterns) | +| ❌ | Forum (/forum) — MISSING expected text: "Forum" | +| ✅ | Souq (/souq) — clean (no error patterns) | +| ❌ | Souq (/souq) — MISSING expected text: "Souq" | +| ✅ | Groups (/groups) — clean (no error patterns) | +| ❌ | Groups (/groups) — MISSING expected text: "Groups" | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260628_070015.md b/qa-report-20260628_070015.md new file mode 100644 index 0000000..bfbc111 --- /dev/null +++ b/qa-report-20260628_070015.md @@ -0,0 +1,19 @@ +# QA Test Report +**Date:** 2026-06-28 07:00:15 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 0 | 1 | 0 | 1 | **B** | + +## Details +| Result | Message | +|:------|:--------| +| ❌ | Prayer API → 404 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260628_070023.md b/qa-report-20260628_070023.md new file mode 100644 index 0000000..0a7b9ca --- /dev/null +++ b/qa-report-20260628_070023.md @@ -0,0 +1,43 @@ +# QA Test Report +**Date:** 2026-06-28 07:00:23 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 15 | 1 | 9 | 25 | **B** | + +## Details +| Result | Message | +|:------|:--------| +| ⚠️ | Landing (/) — suspiciously small (153 bytes) | +| ⚠️ | Prayer (/prayer) — suspiciously small (153 bytes) | +| ⚠️ | Dhikr (/dhikr) — suspiciously small (153 bytes) | +| ⚠️ | Qibla (/qibla) — suspiciously small (153 bytes) | +| ⚠️ | Halal Monitor (/halal-monitor) — suspiciously small (153 bytes) | +| ⚠️ | Nur (/nur) — suspiciously small (153 bytes) | +| ⚠️ | Forum (/forum) — suspiciously small (153 bytes) | +| ⚠️ | Souq (/souq) — suspiciously small (153 bytes) | +| ⚠️ | Groups (/groups) — suspiciously small (153 bytes) | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ❌ | Pattern "not found" found on: /, /prayer, /dhikr, /qibla, /halal-monitor, /nur, /forum, /souq, /groups | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 0 loading indicators (acceptable) | +| ✅ | /dhikr — 0 loading indicators (acceptable) | +| ✅ | /qibla — 0 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260628_080046.md b/qa-report-20260628_080046.md new file mode 100644 index 0000000..11c8728 --- /dev/null +++ b/qa-report-20260628_080046.md @@ -0,0 +1,19 @@ +# QA Test Report +**Date:** 2026-06-28 08:00:46 +**URL:** https://falahos.my/mobile +**Layer:** api + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 0 | 1 | 0 | 1 | **B** | + +## Details +| Result | Message | +|:------|:--------| +| ❌ | Prayer API → 503 | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/qa-report-20260628_080055.md b/qa-report-20260628_080055.md new file mode 100644 index 0000000..509471a --- /dev/null +++ b/qa-report-20260628_080055.md @@ -0,0 +1,43 @@ +# QA Test Report +**Date:** 2026-06-28 08:00:55 +**URL:** https://falahos.my/mobile +**Layer:** render + +## Summary +| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade | +|:------:|:------:|:------:|:----:|:-----:| +| 16 | 0 | 9 | 25 | **A** | + +## Details +| Result | Message | +|:------|:--------| +| ⚠️ | Landing (/) — suspiciously small (20 bytes) | +| ⚠️ | Prayer (/prayer) — suspiciously small (20 bytes) | +| ⚠️ | Dhikr (/dhikr) — suspiciously small (20 bytes) | +| ⚠️ | Qibla (/qibla) — suspiciously small (20 bytes) | +| ⚠️ | Halal Monitor (/halal-monitor) — suspiciously small (20 bytes) | +| ⚠️ | Nur (/nur) — suspiciously small (20 bytes) | +| ⚠️ | Forum (/forum) — suspiciously small (20 bytes) | +| ⚠️ | Souq (/souq) — suspiciously small (20 bytes) | +| ⚠️ | Groups (/groups) — suspiciously small (20 bytes) | +| ✅ | Pattern "Invalid response" — absent from all pages | +| ✅ | Pattern "Internal Server Error" — absent from all pages | +| ✅ | Pattern "Application error" — absent from all pages | +| ✅ | Pattern "Cannot read properties of" — absent from all pages | +| ✅ | Pattern "undefined is not" — absent from all pages | +| ✅ | Pattern "TypeError" — absent from all pages | +| ✅ | Pattern "Failed to fetch" — absent from all pages | +| ✅ | Pattern "not found" — absent from all pages | +| ✅ | Pattern "500 Internal" — absent from all pages | +| ✅ | Pattern "JSON Parse error" — absent from all pages | +| ✅ | Pattern "Unexpected token" — absent from all pages | +| ✅ | Pattern "Cannot find module" — absent from all pages | +| ✅ | Pattern "Minified React error" — absent from all pages | +| ✅ | /prayer — 2 loading indicators (acceptable) | +| ✅ | /dhikr — 2 loading indicators (acceptable) | +| ✅ | /qibla — 3 loading indicators (acceptable) | + +## Re-run +```bash +python3 tests/qa-test-suite.py +``` \ No newline at end of file diff --git a/scripts/auto-heal.sh b/scripts/auto-heal.sh deleted file mode 100644 index 08eb260..0000000 --- a/scripts/auto-heal.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/bin/bash -# ============================================================ -# Falah Mobile — Auto-Healing Watchdog -# ============================================================ -# Runs as a cron job or systemd timer on the Synology. -# Detects failures and auto-fixes without human intervention. -# -# Install (as root on Synology): -# cp scripts/auto-heal.sh /usr/local/bin/falah-auto-heal -# chmod +x /usr/local/bin/falah-auto-heal -# echo "*/5 * * * * root /usr/local/bin/falah-auto-heal" > /etc/cron.d/falah-auto-heal -# ============================================================ - -DOCKER=/var/packages/Docker/target/usr/bin/docker -CONTAINER=falah-mobile-staging -HEALTH_URL="http://localhost:4014/mobile/api/health" -GW_URL="http://localhost:7878/mobile/api/health" -LOG_DIR="/var/log/falah" -LOG="$LOG_DIR/auto-heal.log" -STATE="$LOG_DIR/auto-heal.state" -MAX_RESTARTS_PER_HOUR=3 - -mkdir -p "$LOG_DIR" - -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG" - echo "$*" -} - -# ── 1. HEALTH CHECK ────────────────────────────────────────── - -check_health() { - local url="$1" - local label="$2" - curl -sf --max-time 10 "$url" > /dev/null 2>&1 -} - -# ── 2. TRACK RESTART COUNT ────────────────────────────────── - -track_restart() { - local now - now=$(date +%s) - # Keep only restarts in the last hour - if [ -f "$STATE" ]; then - sed -i "/^[0-9]*$/!d" "$STATE" 2>/dev/null - awk -v now="$now" -v limit=3600 '$1 > now - limit' "$STATE" > "${STATE}.tmp" && mv "${STATE}.tmp" "$STATE" - fi - echo "$now" >> "$STATE" - local count - count=$(wc -l < "$STATE" 2>/dev/null || echo 0) - echo "$count" -} - -# ── 3. AUTO-HEAL ACTIONS ──────────────────────────────────── - -heal_container() { - log "⚠️ Starting auto-heal sequence for $CONTAINER..." - - # Check if container exists - if ! $DOCKER ps -a --format '{{.Names}}' | grep -q "^$CONTAINER$"; then - log "❌ Container $CONTAINER does not exist. Cannot auto-heal." - return 1 - fi - - local restarts - restarts=$(track_restart) - log "🔄 Restarts in last hour: $restarts / $MAX_RESTARTS_PER_HOUR" - - if [ "$restarts" -gt "$MAX_RESTARTS_PER_HOUR" ]; then - log "🚨 Too many restarts ($restarts) — escalating instead of restarting" - return 2 - fi - - # Step 1: Try restarting the container - log "🔄 Restarting $CONTAINER..." - $DOCKER restart "$CONTAINER" >> "$LOG" 2>&1 - sleep 10 - - # Step 2: Verify recovery - if check_health "$HEALTH_URL" "direct"; then - log "✅ Auto-heal successful — container healthy after restart" - return 0 - fi - - # Step 3: If still failing, try docker-compose (rebuild from compose definition) - log "⚠️ Restart not enough — trying compose down/up..." - cd /volume1/docker/falah-core-staging/maifors-falah-core-*/ 2>/dev/null || \ - cd /var/services/homes/admin/falah-mobile/ 2>/dev/null || true - - $DOCKER compose -f docker-compose.staging.yml down >> "$LOG" 2>&1 - sleep 3 - $DOCKER compose -f docker-compose.staging.yml up -d >> "$LOG" 2>&1 - sleep 15 - - if check_health "$HEALTH_URL" "direct"; then - log "✅ Auto-heal successful — compose recovery worked" - return 0 - fi - - log "❌ Auto-heal failed — manual intervention required" - return 3 -} - -heal_gateway() { - log "⚠️ Gateway unhealthy — reloading nginx..." - - # Find the nginx container - GW_CONTAINER=$($DOCKER ps --format '{{.Names}}' | grep -i "gateway\|nginx" | head -1) - if [ -n "$GW_CONTAINER" ]; then - $DOCKER exec "$GW_CONTAINER" nginx -s reload >> "$LOG" 2>&1 - log "✅ nginx reloaded in $GW_CONTAINER" - return 0 - fi - log "⚠️ No nginx/gateway container found" - return 1 -} - -# ── 4. MAIN ────────────────────────────────────────────────── - -main() { - log "═══════════════════════════════════════════════" - log "🔍 Auto-heal check starting..." - - local direct_ok=true - local gateway_ok=true - - # Check direct container health - if ! check_health "$HEALTH_URL" "direct"; then - direct_ok=false - log "❌ Direct health check FAILED" - heal_container - else - log "✅ Direct container healthy" - fi - - # Check gateway health - if ! check_health "$GW_URL" "gateway"; then - gateway_ok=false - log "❌ Gateway health check FAILED" - heal_gateway - else - log "✅ Gateway healthy" - fi - - # Check db connectivity via direct health - HEALTH_BODY=$(curl -sf --max-time 10 "$HEALTH_URL" 2>/dev/null) - if echo "$HEALTH_BODY" | grep -q '"db":false'; then - log "⚠️ Database disconnected! Attempting recovery..." - $DOCKER restart "$CONTAINER" >> "$LOG" 2>&1 - log "🔄 Restarted container to recover DB connection" - fi - - if $direct_ok && $gateway_ok; then - log "✅ All systems healthy" - else - log "⚠️ Some systems unhealthy after healing attempts" - fi - - log "═══════════════════════════════════════════════" -} - -main "$@" diff --git a/scripts/generate-tts-local.js b/scripts/generate-tts-local.js deleted file mode 100644 index 42abb39..0000000 --- a/scripts/generate-tts-local.js +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node -/** - * Local TTS generation for FalahMobile Learn content. - * Uses macOS `say` + `afconvert` (fallback: ffmpeg/lame if available). - * Reads prisma/seed-learn.json and generates MP3s for modules with audioPath. - */ - -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const PROJECT_ROOT = path.resolve(__dirname, '..'); -const SEED_PATH = path.join(PROJECT_ROOT, 'prisma', 'seed-learn.json'); -const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public'); - -// Check available TTS tools -function detectTtsEngine() { - try { - execSync('which say', { stdio: 'ignore' }); - return { type: 'say', aiff: true }; - } catch {} - try { - execSync('which espeak', { stdio: 'ignore' }); - return { type: 'espeak', wav: true }; - } catch {} - try { - execSync('which piper', { stdio: 'ignore' }); - return { type: 'piper', wav: true }; - } catch {} - return null; -} - -function stripMarkdown(text) { - return text - .replace(/^---\n[\s\S]*?---\n/, '') - .replace(/[\u{1F300}-\u{1F9FF}]/gu, ' ') - .replace(/^#+\s+/gm, '') - .replace(/\*\*?|\*\*?/g, '') - .replace(/^>\s*/gm, '') - .replace(/```[\s\S]*?```/g, '') - .replace(/`[^`]+`/g, '') - .replace(/^---+$/gm, '') - .replace(/<[^>]+>/g, '') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -function generateSayM4a(text, outputPath) { - const tmpTxt = outputPath.replace(path.extname(outputPath), '.txt'); - fs.writeFileSync(tmpTxt, text, 'utf-8'); - try { - // macOS say outputs AAC in .m4a directly with --file-format m4af - execSync(`say -v "Samantha" --file-format m4af -f "${tmpTxt}" -o "${outputPath}"`, { stdio: 'ignore' }); - fs.unlinkSync(tmpTxt); - return true; - } catch (e) { - console.error(' say failed:', e.message); - try { fs.unlinkSync(tmpTxt); } catch {} - return false; - } -} - -function generateEspeakWav(text, outputPath) { - const tmpWav = outputPath.replace('.mp3', '.wav'); - try { - execSync(`espeak -v en-us -s 150 -w "${tmpWav}" "${text.replace(/"/g, '\\"')}"`, { stdio: 'ignore' }); - // Convert WAV to MP3 via lame or ffmpeg - try { - execSync(`lame -V 7 "${tmpWav}" "${outputPath}"`, { stdio: 'ignore' }); - } catch { - execSync(`ffmpeg -y -i "${tmpWav}" -codec:a libmp3lame -qscale:a 5 "${outputPath}"`, { stdio: 'ignore' }); - } - fs.unlinkSync(tmpWav); - return true; - } catch (e) { - console.error(' espeak failed:', e.message); - try { fs.unlinkSync(tmpWav); } catch {} - return false; - } -} - -function main() { - const engine = detectTtsEngine(); - if (!engine) { - console.error('No local TTS engine found. Install one of: say (macOS), espeak, piper'); - process.exit(1); - } - console.log(`Using TTS engine: ${engine.type}`); - - const seed = JSON.parse(fs.readFileSync(SEED_PATH, 'utf-8')); - let generated = 0; - let failed = 0; - - for (const course of seed.courses) { - for (const mod of course.modules || []) { - if (!mod.audioPath) continue; - const outPath = path.join(PUBLIC_DIR, mod.audioPath); - // Skip if already exists and > 1KB - if (fs.existsSync(outPath) && fs.statSync(outPath).size > 1024) { - console.log(` SKIP (exists): ${mod.audioPath}`); - continue; - } - - // Ensure directory exists - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - - const text = stripMarkdown(mod.content || mod.keyTakeaway || ''); - if (!text || text.length < 50) { - console.log(` SKIP (no content): ${mod.title}`); - continue; - } - - // Truncate to ~4000 chars to keep file sizes reasonable - const ttsText = text.slice(0, 4000); - console.log(`Generating: ${mod.audioPath} (${ttsText.length} chars)`); - - const ok = engine.type === 'say' - ? generateSayM4a(ttsText, outPath) - : generateEspeakWav(ttsText, outPath); - - if (ok) { - generated++; - } else { - failed++; - } - } - } - - console.log(`\nDone: ${generated} generated, ${failed} failed`); -} - -main(); diff --git a/scripts/init-gitea-courses.sh b/scripts/init-gitea-courses.sh deleted file mode 100644 index 9b8e4e6..0000000 --- a/scripts/init-gitea-courses.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env bash -# init-gitea-courses.sh — Create the courses repo in Gitea and push initial content -# Run this when Gitea is available. -# Usage: GITEA_TOKEN=xxx bash scripts/init-gitea-courses.sh - -set -euo pipefail - -GITEA_URL="${GITEA_URL:-https://git.falahos.my}" -GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}" -REPO_OWNER="maifors" -REPO_NAME="courses" -WORKDIR="/tmp/courses-repo" - -echo "=== Initializing Courses Repo ===" - -# Create repo via Gitea API -echo "Creating repo $REPO_OWNER/$REPO_NAME..." -curl -s -X POST "$GITEA_URL/api/v1/admin/users/$REPO_OWNER/repos" \ - -H "Authorization: token $GITEA_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"$REPO_NAME\", \"description\": \"Falah Academy micro learning course content\", \"private\": false, \"auto_init\": false}" \ - || echo "Repo may already exist, continuing..." - -# Clone or create local working directory -rm -rf "$WORKDIR" -mkdir -p "$WORKDIR" -cd "$WORKDIR" - -git init -git config user.name "Falah Academy Bot" -git config user.email "academy@falahos.my" - -# ─── Course Index ─── -mkdir -p courses -cat > courses/course-index.yaml << 'INDEXEOF' -courses: - - slug: "atomic-habits" - title: "Atomic Habits" - author: "James Clear" - - slug: "deep-work" - title: "Deep Work" - author: "Cal Newport" - - slug: "7-habits" - title: "The 7 Habits of Highly Effective People" - author: "Stephen R. Covey" -INDEXEOF - -# ─── Atomic Habits Course ─── -COURSE="courses/atomic-habits" -mkdir -p "$COURSE" - -cat > "$COURSE/course.yaml" << 'COURSEEOF' -slug: "atomic-habits" -title: "Atomic Habits" -author: "James Clear" -description: "Master the science of habit formation in just 25 minutes." -imageUrl: "" -moduleCount: 5 -totalMinutes: 25 -difficulty: "beginner" -priceFlh: 499 -priceUsd: 4.99 -subscriptionOnly: false -published: true -modules: - - slug: "the-1-percent-rule" - order: 1 - - slug: "identity-based-habits" - order: 2 - - slug: "the-4-laws" - order: 3 - - slug: "habit-stacking" - order: 4 - - slug: "design-your-environment" - order: 5 -COURSEEOF - -# Module 1: The 1% Rule -MODULE="$COURSE/the-1-percent-rule" -mkdir -p "$MODULE" -cat > "$MODULE/module.yaml" << 'YAMLEOF' -title: "The 1% Rule" -order: 1 -keyTakeaway: "Small habits don't just add up — they compound. Improving just 1% every day leads to being 37x better after one year." -duration: 5 -YAMLEOF - -cat > "$MODULE/lesson.md" << 'MDEOF' -# The 1% Rule - -Habits are the compound interest of self-improvement. The same way that money multiplies through compound interest, the effects of your habits multiply as you repeat them. - -## The Math of Small Changes - -If you get 1% better each day for one year, you'll end up 37 times better. Conversely, getting 1% worse each day for a year will decline you nearly to zero. - -Success is the product of daily habits — not once-in-a-lifetime transformations. -MDEOF - -cat > "$MODULE/quiz.yaml" << 'QUIZEOF' -questions: - - question: "If you get 1% better every day, how much better after one year?" - options: - - "About 3x better" - - "About 37x better" - - "About 10x better" - - "About 100x better" - correctIndex: 1 - - question: "What is the 'Valley of Disappointment'?" - options: - - "A period where habits feel boring" - - "The phase where you work but see no results" - - "The time between starting a new habit" - - "When you lose motivation completely" - correctIndex: 1 -QUIZEOF - -# Module 5: Design Your Environment -MODULE="$COURSE/design-your-environment" -mkdir -p "$MODULE" -cat > "$MODULE/module.yaml" << 'YAMLEOF' -title: "Design Your Environment" -order: 5 -keyTakeaway: "Your surroundings shape your behavior more than willpower. Design your environment for success by reducing friction for good habits." -duration: 5 -YAMLEOF - -cat > "$MODULE/lesson.md" << 'MDEOF' -# Design Your Environment - -Willpower is overrated. The most reliable way to stick to good habits is to design your environment so that the right choice is the easy choice. -MDEOF - -cat > "$MODULE/quiz.yaml" << 'QUIZEOF' -questions: - - question: "What is a commitment device?" - options: - - "A promise to yourself" - - "A present choice that locks in better future behavior" - - "A device that tracks habits" - - "An accountability partner" - correctIndex: 1 -QUIZEOF - -# ─── Commit & Push ─── -git add -A -git commit -m "feat: initial course content - Atomic Habits" - -git remote add origin "$GITEA_URL/$REPO_OWNER/$REPO_NAME.git" -git branch -M main -git push -u origin main - -echo "" -echo "=== Done! Repo: $GITEA_URL/$REPO_OWNER/$REPO_NAME ===" -echo "Configure Gitea webhook to POST to https://falahos.my/api/learn/sync" -echo " with secret matching SYNC_SECRET env var." diff --git a/scripts/qa-learn.py b/scripts/qa-learn.py deleted file mode 100644 index f48dc8c..0000000 --- a/scripts/qa-learn.py +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env python3 -""" -qa-learn.py — Falah Academy Micro Learning QA Test Suite -8-Layer Model: System, API, Render, Scenario, Edge, Mobile, Perf, Security - -Usage: python3 qa-learn.py [--url https://host:port] -""" - -import urllib.request -import urllib.error -import json -import sys -import time -import re -import ssl -import os - -BASE_URL = "http://localhost:3456" -PASS = 0 -FAIL = 0 -SKIP = 0 -RESULTS = [] - -ctx = ssl.create_default_context() -ctx.check_hostname = False -ctx.verify_mode = ssl.CERT_NONE - -def req(path, method="GET", data=None, headers=None, expect_json=False): - url = f"{BASE_URL}/mobile{path}" - hdrs = { - "User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36", - "Accept": "application/json" if expect_json else "text/html,application/xhtml+xml", - } - if headers: - hdrs.update(headers) - body = json.dumps(data).encode() if data else None - req = urllib.request.Request(url, data=body, headers=hdrs, method=method) - try: - resp = urllib.request.urlopen(req, timeout=15, context=ctx) - text = resp.read().decode("utf-8", errors="replace") - return {"status": resp.status, "text": text, "headers": dict(resp.headers)} - except urllib.error.HTTPError as e: - text = e.read().decode("utf-8", errors="replace") - return {"status": e.code, "text": text, "headers": dict(e.headers)} - except Exception as e: - return {"status": 0, "text": str(e), "headers": {}} - -def check(name, condition, detail=""): - global PASS, FAIL - if condition: - PASS += 1 - RESULTS.append(f" ✅ PASS | {name}") - else: - FAIL += 1 - RESULTS.append(f" ❌ FAIL | {name} | {detail}") - -def section(title): - RESULTS.append(f"\n─── {title} ───") - -def json_body(resp): - try: - return json.loads(resp["text"]) if resp["text"].strip().startswith("{") else None - except: - return None - -# ════════════════════════════════════════ -# LAYER 1: SYSTEM — Route existence -# ════════════════════════════════════════ -section("LAYER 1: SYSTEM — Route Existence") - -routes = [ - ("/api/health", 200), - ("/api/learn/courses", 200), - ("/learn", 200), - ("/verify/TEST-12345", 200), -] -for path, expected in routes: - r = req(path) - check(f"GET {path} → HTTP {r['status']}", r["status"] == expected, f"Got {r['status']}") - -# Non-existent routes should 404 -r = req("/api/learn/nonexistent") -check("Non-existent API → 404", r["status"] == 404, f"Got {r['status']}") - -# ════════════════════════════════════════ -# LAYER 2: API — Response shape contracts -# ════════════════════════════════════════ -section("LAYER 2: API — Response Shape") - -# Courses listing -r = req("/api/learn/courses", expect_json=True) -data = json_body(r) -check("Courses returns JSON", data is not None, f"Got: {r['text'][:100]}") -if data: - courses = data.get("courses", []) - check("Courses has 'courses' array", isinstance(courses, list)) - check("Courses is not empty", len(courses) > 0, f"Got {len(courses)} courses") - if courses: - c = courses[0] - check("Course has 'id'", "id" in c) - check("Course has 'slug'", "slug" in c) - check("Course has 'title'", "title" in c) - check("Course has 'moduleCount'", "moduleCount" in c) - check("Course has 'difficulty'", "difficulty" in c) - check("Course slug is non-empty", bool(c.get("slug"))) - -# Course detail (requires auth → 401) -r = req("/api/learn/courses/atomic-habits", expect_json=True) -check("Course detail without auth → 401", r["status"] == 401, f"Got {r['status']}") - -# Verify endpoint public -r = req("/api/learn/certificates/verify/FAL-TEST-00000-00000000-AAAAA", expect_json=True) -data = json_body(r) -check("Verify endpoint returns JSON (not HTML)", data is not None, f"Got: {r['text'][:100]}") -if data: - check("Verify returns 'valid' field", "valid" in data) - check("Fake serial → valid=false", data.get("valid") is False, f"Got: {data}") - -# ════════════════════════════════════════ -# LAYER 3: RENDER — Content scan -# ════════════════════════════════════════ -section("LAYER 3: RENDER — Content Scan") - -# Learn page should NOT have error strings -r = req("/learn") -error_strings = ["Internal Server Error", "Cannot read properties", "TypeError", "Error:"] -for err in error_strings: - if err in r["text"] and "Error: 404" not in r["text"]: - check(f"Learn page: no '{err}' in HTML", False, f"Found: {err}") - else: - check(f"Learn page: no '{err}' in HTML", True) - -# Check for valid HTML structure -check("Learn page: has ", "", "", " 0: - check("4c: Quiz question has 'question'", "question" in quiz[0]) - check("4c: Quiz question has 'options'", "options" in quiz[0]) - check("4c: Quiz question has 'correctIndex'", "correctIndex" in quiz[0]) - except: - check("4c: Quiz parsing", False, "Invalid quiz JSON") - -# 4d. Enroll in a course (it's seeded, should be accessible) -# Actually enroll endpoint might not exist as enroll - we enroll via webhook -# Let's just verify the course data is complete -r = req("/api/learn/courses", expect_json=True) -data = json_body(r) -if data: - courses = data.get("courses", []) - if courses: - c = courses[0] - check("4d: Course has modules", c.get("moduleCount", 0) > 0, f"moduleCount={c.get('moduleCount')}") - check("4d: Course has description", bool(c.get("description"))) - check("4d: Course has difficulty", c.get("difficulty") in ("beginner", "intermediate", "advanced")) - -# ════════════════════════════════════════ -# LAYER 5: EDGE — Error handling -# ════════════════════════════════════════ -section("LAYER 5: EDGE — Edge Cases") - -# Empty slug → should 404 -r = req("/api/learn/courses/") -check("Empty slug → handled (200 or 404)", r["status"] in (200, 404), f"Got {r['status']}") - -# Non-existent slug -r = req("/api/learn/courses/this-course-does-not-exist") -check("Non-existent course → 401 (auth reqd)", r["status"] == 401, f"Got {r['status']}") - -# Invalid serial for verify -r = req("/api/learn/certificates/verify/INVALID", expect_json=True) -data = json_body(r) -check("Invalid serial → {valid:false}", data is not None and data.get("valid") is False, - f"Got: {r['text'][:100]}") - -# POST to GET-only endpoint → should 405 or gracefully handled -r = req("/api/learn/courses", method="POST") -check("POST to courses list", r["status"] in (405, 404, 400, 200), - f"Got {r['status']}") - -# Query params injection -r = req("/api/learn/courses?slug=atomic-habits'; DROP TABLE;--") -check("SQL injection attempt handled", r["status"] != 500, f"Got {r['status']}") - -# Special characters in slug -r = req("/api/learn/courses/../../../etc/passwd") -check("Path traversal → handled (401/404 not 500)", r["status"] != 500, - f"Got {r['status']}") - -# ════════════════════════════════════════ -# LAYER 6: MOBILE — Mobile-first checks -# ════════════════════════════════════════ -section("LAYER 6: MOBILE — Mobile Readiness") - -r = req("/learn") -html = r["text"] -# Viewport meta -check("Has viewport meta", 'name="viewport"' in html or 'name="viewport"' in html) -# Touch-friendly: look for mobile-like containers -check("Has mobile-container class", 'mobile-container' in html) -# Safe area -check("Has safe-area-bottom", 'safe-area-bottom' in html) -# Dark theme -check("Has dark theme class", 'className="dark"' in html or 'class="dark"' in html) - -# Check health endpoint returns quickly -start = time.time() -req("/api/health") -elapsed = time.time() - start -check(f"Health response < 2s", elapsed < 2.0, f"Took {elapsed:.2f}s") - -# ════════════════════════════════════════ -# LAYER 7: PERF — Performance checks -# ════════════════════════════════════════ -section("LAYER 7: PERFORMANCE") - -for path, label in [("/api/learn/courses", "Courses list"), - ("/learn", "Learn page")]: - times = [] - for _ in range(3): - start = time.time() - req(path) - times.append(time.time() - start) - avg = sum(times) / len(times) - check(f"{label} avg response < 5s", avg < 5.0, f"Avg: {avg:.2f}s") - -# Payload size check -r = req("/api/learn/courses") -check("Courses API payload < 100KB", len(r["text"]) < 100_000, f"Size: {len(r['text'])} bytes") - -# ════════════════════════════════════════ -# LAYER 8: SECURITY — Basic -# ════════════════════════════════════════ -section("LAYER 8: SECURITY — Basic Checks") - -# No secrets in HTML output -r = req("/learn") -secrets = ["JWT_SECRET", "DATABASE_URL", "POLAR_ACCESS_TOKEN", "flh_token="] -for secret in secrets: - if secret in r["text"]: - check(f"No '{secret}' leaked in HTML", False, "SECRET LEAKED") - break -else: - check("No secrets leaked in HTML", True) - -# No stack traces exposed -r = req("/api/learn/courses/__non_existent__") -check("No stack traces in error responses", "at" not in r["text"] or "Traceback" not in r["text"], - "Stack trace might be exposed") - -# ════════════════════════════════════════ -# SUMMARY -# ════════════════════════════════════════ -section("═══════════════════════════════════") -section(f"QA SUMMARY: {PASS} passed, {FAIL} failed, {SKIP} skipped") - -grade = "A" if FAIL == 0 else "B" if FAIL <= 3 else "C" if FAIL <= 10 else "D" -section(f"GRADE: {grade}") -section(f"VERDICT: {'✅ DEPLOY OK' if grade in ('A', 'B') else '❌ BLOCKED — fix critical items'}") - -print("\n".join(RESULTS)) -print(f"\nTotal: {PASS} passed, {FAIL} failed, {SKIP} skipped — Grade {grade}") - -sys.exit(0 if grade in ("A", "B") else 1) diff --git a/scripts/seed-courses.ts b/scripts/seed-courses.ts deleted file mode 100644 index c3122ca..0000000 --- a/scripts/seed-courses.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * seed-courses.ts — Seed initial micro learning courses into the database. - * - * Run: npx tsx scripts/seed-courses.ts - * Or via curl: POST /api/learn/seed (for production use) - * - * This seeds the first published courses from the Gitea content repo, - * or directly defines course/module structure for the MVP. - */ - -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); - -const COURSES = [ - { - slug: "atomic-habits", - title: "Atomic Habits", - author: "James Clear", - description: - "Master the science of habit formation in just 25 minutes. Learn how small daily changes compound into remarkable results through the 1% rule, identity-based habits, and the 4 laws of behavior change.", - imageUrl: null, - moduleCount: 5, - totalMinutes: 25, - difficulty: "beginner", - giteaPath: "courses/atomic-habits", - priceFlh: 499, // 4.99 in FLH cents - priceUsd: 4.99, - subscriptionOnly: false, - published: true, - modules: [ - { - order: 1, - title: "The 1% Rule", - slug: "the-1-percent-rule", - keyTakeaway: - "Small habits don't just add up — they compound. Improving just 1% every day leads to being 37x better after one year.", - duration: 5, - content: `# The 1% Rule - -Habits are the compound interest of self-improvement. The same way that money multiplies through compound interest, the effects of your habits multiply as you repeat them. - -## The Math of Small Changes - -If you get 1% better each day for one year, you'll end up 37 times better. Conversely, getting 1% worse each day for a year will decline you nearly to zero. - -Success is the product of daily habits — not once-in-a-lifetime transformations. - -## Why We Ignore Small Changes - -We expect linear progress but live in a world of delayed returns. The most powerful outcomes are invisible during the early stages. This is the "Valley of Disappointment" — you do the right things but don't see results immediately. - -## The Plateau of Latent Potential - -Just like a ice cube melts at 32°F after hours of warming from 20°F to 31°F, your habits break through when you cross a critical threshold. Don't judge your success by what you see today. Your work is accumulating. - -## Your Action Step - -Identify one habit you want to build. Ask: "Can I make it 1% better today?"`, - quizData: JSON.stringify([ - { - question: "If you get 1% better every day, how much better will you be after one year?", - options: ["About 3x better", "About 37x better", "About 10x better", "About 100x better"], - correctIndex: 1, - }, - { - question: "What is the 'Valley of Disappointment'?", - options: [ - "A period where habits feel boring", - "The phase where you work but see no results", - "The time between starting a new habit", - "When you lose motivation completely", - ], - correctIndex: 1, - }, - { - question: "At what temperature does ice melt?", - options: ["30°F", "32°F", "35°F", "40°F"], - correctIndex: 1, - }, - ]), - }, - { - order: 2, - title: "Identity-Based Habits", - slug: "identity-based-habits", - keyTakeaway: - "The most effective way to change your habits is to focus on who you want to become, not what you want to achieve.", - duration: 5, - content: `# Identity-Based Habits - -There are three levels of change: outcomes, processes, and identity. Most people focus on outcomes (what you get) instead of identity (who you are). - -## The Two-Step Process - -1. **Decide the type of person you want to be.** -2. **Prove it to yourself with small wins.** - -Your identity emerges from your habits. Every action is a vote for the type of person you wish to become. - -## The Habit Loop - -Every habit follows a four-step loop: -- **Cue** — The trigger that initiates the behavior -- **Craving** — The motivational force behind the habit -- **Response** — The actual habit you perform -- **Reward** — The benefit you gain from the habit - -## Your Action Step - -Instead of saying "I want to run a marathon," say "I am a runner." Then go for a 5-minute jog.`, - quizData: JSON.stringify([ - { - question: "What are the three levels of change mentioned?", - options: [ - "Outcomes, Processes, Identity", - "Goals, Actions, Results", - "Start, Middle, End", - "Mind, Body, Soul", - ], - correctIndex: 0, - }, - { - question: "Each action is a ___ for the type of person you wish to become.", - options: ["Reminder", "Vote", "Proof", "Promise"], - correctIndex: 1, - }, - ]), - }, - { - order: 3, - title: "The 4 Laws of Behavior Change", - slug: "the-4-laws", - keyTakeaway: - "To build a good habit: make it obvious, attractive, easy, and satisfying. To break a bad habit: invert each law.", - duration: 5, - content: `# The 4 Laws of Behavior Change - -## Law 1: Make It Obvious -Design your environment so the cues for good habits are visible and the cues for bad habits are invisible. - -## Law 2: Make It Attractive -Use temptation bundling — pair an action you want to do with an action you need to do. - -## Law 3: Make It Easy -The most effective form of learning is practice, not planning. Reduce friction. The Two-Minute Rule: when you start a new habit, it should take less than two minutes. - -## Law 4: Make It Satisfying -Use immediate rewards. What is immediately rewarded is repeated. What is immediately punished is avoided. - -## The Inversion (Breaking Bad Habits) -- Make it **invisible** -- Make it **unattractive** -- Make it **difficult** -- Make it **unsatisfying**`, - quizData: JSON.stringify([ - { - question: "What is Law 1 of behavior change?", - options: ["Make It Easy", "Make It Obvious", "Make It Attractive", "Make It Satisfying"], - correctIndex: 1, - }, - { - question: "The Two-Minute Rule states that a new habit should take:", - options: ["Less than 5 minutes", "Less than 2 minutes", "Exactly 2 minutes", "As long as needed"], - correctIndex: 1, - }, - ]), - }, - { - order: 4, - title: "Habit Stacking", - slug: "habit-stacking", - keyTakeaway: - "The best way to build a new habit is to anchor it to an existing one using the formula: After [CURRENT HABIT], I will [NEW HABIT].", - duration: 5, - content: `# Habit Stacking - -One of the best ways to build a new habit is to identify a current habit you already do each day and then stack your new behavior on top. - -## The Formula - -> After [CURRENT HABIT], I will [NEW HABIT]. - -## Examples -- After I pour my morning coffee, I will meditate for 60 seconds. -- After I sit down to dinner, I will say one thing I'm grateful for. -- After I take off my work shoes, I will change into my workout clothes. - -## The Key: Pairing Specificity - -The more specific your plan, the more likely you are to follow through. Use implementation intentions: -> "I will [BEHAVIOR] at [TIME] in [LOCATION]."`, - quizData: JSON.stringify([ - { - question: "What is habit stacking?", - options: [ - "Doing multiple habits at once", - "Anchoring a new habit to an existing one", - "Stacking rewards on top of each other", - "Creating a list of habits", - ], - correctIndex: 1, - }, - ]), - }, - { - order: 5, - title: "Design Your Environment", - slug: "design-your-environment", - keyTakeaway: - "Your surroundings shape your behavior more than willpower. Design your environment for success by reducing friction for good habits and increasing it for bad ones.", - duration: 5, - content: `# Design Your Environment - -Willpower is overrated. The most reliable way to stick to good habits is to design your environment so that the right choice is the easy choice. - -## Friction - -Every habit is initiated by a cue. If you want to make a habit a big part of your life, make the cue a big part of your environment. - -- Want to read more? Keep a book on your pillow. -- Want to drink more water? Fill a bottle and place it on your desk. -- Want to practice guitar? Leave it in the middle of the room. - -## One Space, One Use - -Don't mix contexts. Your bed is for sleep. Your desk is for work. When you mix contexts, you mix habits. - -## Commitment Devices - -A commitment device is a choice you make in the present that locks in better behavior in the future. Delete the games from your phone. Unsubscribe from junk food delivery. - -## Your Final Action Step - -Choose ONE habit to focus on this week. Apply all 4 laws. Track it. Repeat.`, - quizData: JSON.stringify([ - { - question: "What is a commitment device?", - options: [ - "A promise to yourself", - "A present choice that locks in better future behavior", - "A device that tracks habits", - "An accountability partner", - ], - correctIndex: 1, - }, - { - question: "Wanting to read more — where should you keep the book?", - options: ["On your shelf", "On your pillow", "On your desk", "In your bag"], - correctIndex: 1, - }, - ]), - }, - ], - }, -]; - -async function main() { - console.log("🌱 Seeding micro learning courses...\n"); - - for (const courseData of COURSES) { - const { modules, ...courseFields } = courseData; - - // Upsert the course - const course = await prisma.learnCourse.upsert({ - where: { slug: courseFields.slug }, - create: courseFields, - update: courseFields, - }); - - console.log(` 📖 Course: ${course.title} (${course.slug})`); - - // Upsert each module - for (const mod of modules) { - await prisma.learnModule.upsert({ - where: { - courseId_slug: { courseId: course.id, slug: mod.slug }, - }, - create: { ...mod, courseId: course.id }, - update: { ...mod, courseId: course.id }, - }); - console.log(` 📝 Module ${mod.order}: ${mod.title}`); - } - } - - console.log(`\n✅ Seeded ${COURSES.length} course(s) successfully.`); -} - -main() - .catch((e) => { - console.error("❌ Seed failed:", e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); diff --git a/scripts/seed-souq.ts b/scripts/seed-souq.ts deleted file mode 100644 index a522a4a..0000000 --- a/scripts/seed-souq.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { prisma } from '@/lib/prisma'; - -async function main() { - console.log('🌱 Seeding souq demo data...\n'); - - // ── Find or create seller user ─────────────────────────────────────────── - let seller = await prisma.user.findFirst(); - - if (!seller) { - console.log(' ℹ️ No users found. Creating a demo seller user...'); - seller = await prisma.user.create({ - data: { - email: 'demo-seller@falah.app', - name: 'Demo Seller', - flhBalance: 10000, - }, - }); - } - console.log(` 👤 Seller: ${seller.name} (${seller.email})`); - - // ── Find or create buyer user (needed for purchase → review chain) ─────── - let buyer = await prisma.user.findFirst({ where: { email: 'demo-buyer@falah.app' } }); - - if (!buyer) { - buyer = await prisma.user.create({ - data: { - email: 'demo-buyer@falah.app', - name: 'Demo Buyer', - flhBalance: 50000, - }, - }); - } - console.log(` 👤 Buyer: ${buyer.name} (${buyer.email})\n`); - - // ── Define 6 demo listings ──────────────────────────────────────────────── - const listingsData = [ - { - title: 'Modern React Dashboard', - description: - 'A fully responsive admin dashboard built with React, TypeScript, and Tailwind CSS. Includes authentication, data visualization charts, dark mode support, and a reusable component library. Perfect for startups and SaaS platforms.', - category: 'programming-tech', - subcategory: 'Frontend', - priceFlh: 15000, - deliveryDays: 10, - packages: [ - { - name: 'Basic', - description: 'Single-page dashboard with 3 core charts, responsive layout, and basic theming.', - price: 8000, - deliveryDays: 7, - revisions: 2, - }, - { - name: 'Standard', - description: 'Multi-page dashboard with authentication, API integration, 6+ chart types, and dark mode.', - price: 15000, - deliveryDays: 10, - revisions: 3, - }, - { - name: 'Premium', - description: 'Full enterprise dashboard with real-time data, user management, role-based access control, and one-click deployment.', - price: 30000, - deliveryDays: 14, - revisions: 5, - }, - ], - tags: ['react', 'typescript', 'tailwind', 'dashboard', 'admin', 'frontend'], - images: ['https://placehold.co/600x400/3b82f6/white?text=React+Dashboard'], - salesCount: 12, - rating: 4.8, - reviewCount: 8, - }, - { - title: 'Flutter Mobile App — MVP Kit', - description: - 'Cross-platform mobile application built with Flutter and Dart. Includes state management (Riverpod), REST API integration, push notifications, and a clean Material-You design. Works on both iOS and Android.', - category: 'programming-tech', - subcategory: 'Mobile App Development', - priceFlh: 20000, - deliveryDays: 14, - packages: [ - { - name: 'Basic', - description: 'Single-screen app with 2 core features, basic navigation, and static data.', - price: 10000, - deliveryDays: 10, - revisions: 2, - }, - { - name: 'Standard', - description: 'Multi-screen app with API integration, local storage, push notifications, and 4-6 feature modules.', - price: 20000, - deliveryDays: 14, - revisions: 3, - }, - { - name: 'Premium', - description: 'Full production-ready app with backend integration, CI/CD pipeline, app store submission support, and analytics.', - price: 40000, - deliveryDays: 21, - revisions: 5, - }, - ], - tags: ['flutter', 'dart', 'mobile', 'cross-platform', 'android', 'ios'], - images: ['https://placehold.co/600x400/8b5cf6/white?text=Flutter+App'], - salesCount: 8, - rating: 4.6, - reviewCount: 5, - }, - { - title: 'SEO Blog Post Package', - description: - 'Research-backed, SEO-optimized blog content tailored to your niche. Each article includes keyword research, meta descriptions, internal linking suggestions, and a featured image brief. Plagiarism-free with Grammarly certification.', - category: 'writing-translation', - subcategory: 'Content Writing', - priceFlh: 5000, - deliveryDays: 3, - packages: [ - { - name: 'Basic', - description: '1 x 800-word blog post with basic keyword optimization and meta description.', - price: 3000, - deliveryDays: 2, - revisions: 1, - }, - { - name: 'Standard', - description: '3 x 1200-word blog posts with comprehensive keyword strategy, headings, and image briefs.', - price: 8000, - deliveryDays: 5, - revisions: 2, - }, - { - name: 'Premium', - description: '6 x 1500-word pillar posts with cluster content strategy, competitor analysis, and social media snippets.', - price: 15000, - deliveryDays: 10, - revisions: 3, - }, - ], - tags: ['seo', 'blog', 'content-writing', 'copywriting', 'wordpress'], - images: ['https://placehold.co/600x400/10b981/white?text=Content+Writing'], - salesCount: 25, - rating: 4.9, - reviewCount: 20, - }, - { - title: 'Complete Brand Identity Design', - description: - 'A comprehensive brand identity package including logo (primary, secondary, icon), color palette, typography system, business card mockup, and brand guidelines PDF. Delivered in AI, EPS, PNG, and SVG formats.', - category: 'graphics-design', - subcategory: 'Logo & Brand Identity', - priceFlh: 18000, - deliveryDays: 12, - packages: [ - { - name: 'Basic', - description: 'Logo design with 3 concepts, 2 revisions, and final files in PNG and SVG.', - price: 8000, - deliveryDays: 7, - revisions: 2, - }, - { - name: 'Standard', - description: 'Full identity kit: logo + color palette + typography + 2 mockups + brand guidelines PDF.', - price: 18000, - deliveryDays: 12, - revisions: 3, - }, - { - name: 'Premium', - description: 'Complete brand launch: full identity kit + 6 mockups (stationery, signage, social) + vector files + brand strategy document.', - price: 35000, - deliveryDays: 18, - revisions: 5, - }, - ], - tags: ['branding', 'logo', 'design', 'identity', 'graphic-design'], - images: ['https://placehold.co/600x400/f59e0b/white?text=Brand+Identity'], - salesCount: 15, - rating: 4.7, - reviewCount: 11, - }, - { - title: 'Full SEO Audit & Optimization', - description: - 'Technical SEO audit covering site speed, mobile-friendliness, crawlability, indexation, and Core Web Vitals. Includes a prioritized action plan and hands-on implementation of fixes. Compatible with WordPress, Shopify, Webflow, and custom sites.', - category: 'digital-marketing', - subcategory: 'Search', - priceFlh: 12000, - deliveryDays: 7, - packages: [ - { - name: 'Basic', - description: 'SEO audit report with 10+ checkpoints, Core Web Vitals analysis, and a prioritized fix list.', - price: 6000, - deliveryDays: 4, - revisions: 1, - }, - { - name: 'Standard', - description: 'Audit + hands-on fixes for top 15 issues, meta tag optimization, and schema markup implementation.', - price: 12000, - deliveryDays: 7, - revisions: 2, - }, - { - name: 'Premium', - description: 'Full-site optimization: audit + fixes + content gap analysis + backlink audit + monthly ranking report template.', - price: 25000, - deliveryDays: 12, - revisions: 3, - }, - ], - tags: ['seo', 'marketing', 'audit', 'optimization', 'web-vitals'], - images: ['https://placehold.co/600x400/ef4444/white?text=SEO+Audit'], - salesCount: 20, - rating: 4.5, - reviewCount: 14, - }, - { - title: 'Professional Video Editing', - description: - 'High-quality video editing for YouTube, social media, or corporate use. Includes cutting, color grading, motion graphics, sound design, captions, and intro/outro animation. Delivered in 1080p or 4K.', - category: 'video-animation', - subcategory: 'Editing & Post-Production', - priceFlh: 25000, - deliveryDays: 10, - packages: [ - { - name: 'Basic', - description: 'Up to 5-minute video: trimming, transitions, background music, and simple text overlays.', - price: 12000, - deliveryDays: 5, - revisions: 2, - }, - { - name: 'Standard', - description: 'Up to 15-minute video: full edit including color grading, motion graphics, captions, and sound design.', - price: 25000, - deliveryDays: 10, - revisions: 3, - }, - { - name: 'Premium', - description: 'Up to 30-minute video: cinematic edit with custom animations, multi-cam sync, advanced VFX, and 4K export.', - price: 50000, - deliveryDays: 15, - revisions: 5, - }, - ], - tags: ['video', 'editing', 'production', 'youtube', 'motion-graphics'], - images: ['https://placehold.co/600x400/ec4899/white?text=Video+Editing'], - salesCount: 6, - rating: 5.0, - reviewCount: 4, - }, - { - title: 'Islamic Nasheed — Audio Production', - description: - 'Professional audio production for Islamic nasheeds, spoken word, and Quran recitation projects. Includes vocal recording, mixing, mastering, and background instrumental arrangement. Delivered in high-quality WAV and MP3 formats with optional music-free versions.', - category: 'music-audio', - subcategory: 'Production & Composition', - priceFlh: 10000, - deliveryDays: 7, - packages: [ - { - name: 'Basic', - description: '1-track recording: vocal tuning, basic mixing, and MP3 export up to 3 minutes.', - price: 5000, - deliveryDays: 5, - revisions: 2, - }, - { - name: 'Standard', - description: '2-track EP: full recording, mixing, mastering, and instrumental arrangement. Up to 6 minutes total.', - price: 10000, - deliveryDays: 7, - revisions: 3, - }, - { - name: 'Premium', - description: 'Full album production (up to 6 tracks): multi-track recording, advanced mixing & mastering, sound design, and distribution-ready masters.', - price: 25000, - deliveryDays: 14, - revisions: 5, - }, - ], - tags: ['nasheed', 'audio', 'production', 'mixing', 'islamic', 'music'], - images: ['https://placehold.co/600x400/a855f7/white?text=Audio+Production'], - salesCount: 9, - rating: 4.8, - reviewCount: 7, - }, - { - title: 'AI Chatbot — Islamic Knowledge Base', - description: - 'Custom AI chatbot built with LLM integration, trained on Islamic texts, fiqh rulings, and Quranic tafsir. Includes conversation history, context-aware responses, and deployment-ready API. Perfect for Islamic apps, websites, and community platforms.', - category: 'ai-services', - subcategory: 'AI Development', - priceFlh: 25000, - deliveryDays: 14, - packages: [ - { - name: 'Basic', - description: 'Single-purpose Q&A bot with 50 pre-defined responses and simple keyword matching.', - price: 12000, - deliveryDays: 7, - revisions: 2, - }, - { - name: 'Standard', - description: 'LLM-powered chatbot with Islamic knowledge base (Quran, hadith, fiqh), conversation memory, and web widget deployment.', - price: 25000, - deliveryDays: 14, - revisions: 3, - }, - { - name: 'Premium', - description: 'Full AI assistant platform: multi-persona support (mufti, alim, murabbi), RAG pipeline, analytics dashboard, and API-first architecture.', - price: 50000, - deliveryDays: 21, - revisions: 5, - }, - ], - tags: ['ai', 'chatbot', 'islamic', 'llm', 'knowledge-base', 'automation'], - images: ['https://placehold.co/600x400/06b6d4/white?text=AI+Chatbot'], - salesCount: 5, - rating: 4.9, - reviewCount: 4, - }, - { - title: 'Halal Business Strategy Consulting', - description: - 'Shariah-compliant business consulting for startups and SMEs. Covers business model validation, market analysis, financial planning, and growth strategy — all aligned with Islamic finance principles (no riba, no gharar). Includes a comprehensive strategy document.', - category: 'consulting', - subcategory: 'Business Consulting', - priceFlh: 20000, - deliveryDays: 10, - packages: [ - { - name: 'Basic', - description: '1-hour strategy call + 5-page business assessment report with key recommendations.', - price: 8000, - deliveryDays: 5, - revisions: 1, - }, - { - name: 'Standard', - description: '3 strategy sessions + full business plan: market analysis, financial projections, and Shariah compliance audit.', - price: 20000, - deliveryDays: 10, - revisions: 2, - }, - { - name: 'Premium', - description: 'End-to-end business launch: strategy, legal structure (Shariah-compliant), fundraising deck, 3-month roadmap, and quarterly check-ins.', - price: 45000, - deliveryDays: 21, - revisions: 4, - }, - ], - tags: ['consulting', 'business', 'shariah', 'strategy', 'startup'], - images: ['https://placehold.co/600x400/64748b/white?text=Consulting'], - salesCount: 11, - rating: 4.7, - reviewCount: 9, - }, - { - title: 'Data Analytics Dashboard — Google Looker Studio', - description: - 'Custom data analytics dashboard built in Google Looker Studio (or open-source Metabase). Includes data source integration, real-time visualizations, KPI tracking, and automated weekly reports. Perfect for e-commerce, SaaS, and marketplace platforms.', - category: 'data', - subcategory: 'Data Analytics', - priceFlh: 12000, - deliveryDays: 7, - packages: [ - { - name: 'Basic', - description: 'Single-source dashboard with 5 core KPIs, 3 visualization pages, and weekly email report.', - price: 6000, - deliveryDays: 5, - revisions: 2, - }, - { - name: 'Standard', - description: 'Multi-source dashboard (up to 3 data sources), 10+ KPIs, custom date filters, and automated Slack/email reports.', - price: 12000, - deliveryDays: 7, - revisions: 3, - }, - { - name: 'Premium', - description: 'Full analytics suite: unlimited data sources, advanced calculated metrics, user-level permissions, embedded dashboards, and API access.', - price: 25000, - deliveryDays: 14, - revisions: 5, - }, - ], - tags: ['data', 'analytics', 'dashboard', 'looker', 'metabase', 'kpi'], - images: ['https://placehold.co/600x400/14b8a6/white?text=Data+Dashboard'], - salesCount: 14, - rating: 4.6, - reviewCount: 10, - }, - { - title: 'E-Commerce Store — Shopify & WooCommerce', - description: - 'Full e-commerce store setup on Shopify or WooCommerce with halal-compliant product catalog, payment gateway integration, shipping configuration, and SEO optimization. Includes theme customization, product import (up to 100 items), and staff training.', - category: 'business', - subcategory: 'E-Commerce', - priceFlh: 18000, - deliveryDays: 10, - packages: [ - { - name: 'Basic', - description: 'Store setup: theme installation, 20 product listings, payment gateway, and basic SEO.', - price: 8000, - deliveryDays: 5, - revisions: 2, - }, - { - name: 'Standard', - description: 'Full store: custom theme tweaks, 50 product listings, shipping zones, tax rules, email marketing integration, and Google Analytics.', - price: 18000, - deliveryDays: 10, - revisions: 3, - }, - { - name: 'Premium', - description: 'Enterprise store: 100+ products, multi-currency, multi-language, custom features, abandoned cart recovery, loyalty program, and 1-month support.', - price: 35000, - deliveryDays: 18, - revisions: 5, - }, - ], - tags: ['ecommerce', 'shopify', 'woocommerce', 'store', 'halal', 'business'], - images: ['https://placehold.co/600x400/6366f1/white?text=E-Commerce'], - salesCount: 18, - rating: 4.8, - reviewCount: 15, - }, - { - title: 'Life Coaching — Islamic Personal Development', - description: - 'Personalized life coaching grounded in Islamic principles of self-development (tazkiyah). Includes goal setting, habit tracking, time management (barakah-based), and spiritual growth planning. Sessions available via video call or chat.', - category: 'personal-growth', - subcategory: 'Self Improvement', - priceFlh: 8000, - deliveryDays: 3, - packages: [ - { - name: 'Basic', - description: '1 x 45-min coaching session + personalized action plan with 3 key goals.', - price: 4000, - deliveryDays: 2, - revisions: 1, - }, - { - name: 'Standard', - description: '4 weekly sessions + habit tracking spreadsheet + daily WhatsApp check-ins + mid-program review.', - price: 12000, - deliveryDays: 28, - revisions: 2, - }, - { - name: 'Premium', - description: '3-month program: 12 sessions + comprehensive personality assessment + Quran journal + group accountability circle + lifetime resources.', - price: 30000, - deliveryDays: 90, - revisions: 4, - }, - ], - tags: ['coaching', 'personal-development', 'tazkiyah', 'islamic', 'growth'], - images: ['https://placehold.co/600x400/65a30d/white?text=Life+Coaching'], - salesCount: 22, - rating: 4.9, - reviewCount: 18, - }, - { - title: 'Product Photography — Halal E-Commerce Ready', - description: - 'Professional product photography optimized for e-commerce marketplaces. Each product is shot on a clean background with proper lighting, color correction, and retouching. Includes white-background and lifestyle-style options. Delivered in web-optimized and print-ready formats.', - category: 'photography', - subcategory: 'Product Photography', - priceFlh: 7000, - deliveryDays: 5, - packages: [ - { - name: 'Basic', - description: '5 product photos: white background, basic color correction, and web-optimized export.', - price: 3500, - deliveryDays: 3, - revisions: 1, - }, - { - name: 'Standard', - description: '15 product photos: white + 1 lifestyle setup, advanced retouching, shadow effects, and multiple formats.', - price: 7000, - deliveryDays: 5, - revisions: 2, - }, - { - name: 'Premium', - description: '30 product photos: multiple angles, 2 lifestyle setups, 360-degree view, video clip per product, and brand style guide integration.', - price: 15000, - deliveryDays: 10, - revisions: 3, - }, - ], - tags: ['photography', 'product', 'ecommerce', 'food', 'retouching'], - images: ['https://placehold.co/600x400/f97316/white?text=Photography'], - salesCount: 30, - rating: 4.7, - reviewCount: 22, - }, - { - title: 'Halal Bookkeeping & Financial Reporting', - description: - 'Shariah-compliant bookkeeping and financial reporting services for small businesses and freelancers. Includes income/expense tracking, profit calculation (zakat-ready), invoice management, and monthly financial statements. Uses accounting software (Xero, QuickBooks, or Wave).', - category: 'finance', - subcategory: 'Accounting', - priceFlh: 10000, - deliveryDays: 7, - packages: [ - { - name: 'Basic', - description: 'Monthly bookkeeping: up to 50 transactions, income statement, and expense categorization.', - price: 5000, - deliveryDays: 5, - revisions: 1, - }, - { - name: 'Standard', - description: 'Monthly bookkeeping + quarterly financial statements + zakat calculation + GST/SST preparation.', - price: 12000, - deliveryDays: 7, - revisions: 2, - }, - { - name: 'Premium', - description: 'Full financial management: weekly bookkeeping, monthly statements, annual audit support, tax filing, cash flow forecasting, and dedicated finance advisor.', - price: 30000, - deliveryDays: 14, - revisions: 4, - }, - ], - tags: ['finance', 'accounting', 'bookkeeping', 'zakat', 'halal', 'tax'], - images: ['https://placehold.co/600x400/eab308/white?text=Financial+Services'], - salesCount: 16, - rating: 4.8, - reviewCount: 13, - }, - ]; - - // ── Clean up existing seed data for idempotency ─────────────────────────── - console.log(' 🧹 Clearing previous seed data...'); - await prisma.review.deleteMany({}); - await prisma.purchase.deleteMany({}); - await prisma.listing.deleteMany({}); - - // ── Create listings ─────────────────────────────────────────────────────── - console.log(' 📦 Creating listings...'); - const createdListings: Array<{ id: string; title: string }> = []; - - for (const data of listingsData) { - const listing = await prisma.listing.create({ - data: { - title: data.title, - description: data.description, - category: data.category, - subcategory: data.subcategory, - priceFlh: data.priceFlh, - packages: JSON.stringify(data.packages), - tags: JSON.stringify(data.tags), - images: JSON.stringify(data.images), - deliveryDays: data.deliveryDays, - rating: data.rating, - reviewCount: data.reviewCount, - salesCount: data.salesCount, - sellerId: seller.id, - status: 'active', - featured: false, - }, - }); - createdListings.push({ id: listing.id, title: listing.title }); - console.log(` ✅ ${listing.title}`); - } - - // ── Create a demo purchase (so we can create a review) ──────────────────── - console.log('\n 🛒 Creating demo purchase...'); - const firstListing = createdListings[0]; - const purchase = await prisma.purchase.create({ - data: { - listingId: firstListing.id, - buyerId: buyer.id, - sellerId: seller.id, - packageName: 'Standard', - amountFlh: 15000, - platformFee: 1500, - sellerPayout: 13500, - status: 'completed', - autoConfirmAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // auto-confirm in 7 days - }, - }); - console.log(` ✅ Purchase for "${firstListing.title}" (Standard package)`); - - // ── Create a demo review ────────────────────────────────────────────────── - console.log('\n ⭐ Creating demo review...'); - const review = await prisma.review.create({ - data: { - listingId: firstListing.id, - purchaseId: purchase.id, - reviewerId: buyer.id, - sellerId: seller.id, - rating: 5, - title: 'Exceptional quality and professionalism!', - text: 'The developer delivered a polished dashboard that exceeded expectations. Clean TypeScript code, thorough documentation, and great communication throughout. Highly recommended for any React project.', - }, - }); - console.log(` ✅ Review (${review.rating}★) on "${firstListing.title}"`); - - // ── Summary ─────────────────────────────────────────────────────────────── - console.log('\n' + '═'.repeat(50)); - console.log('🎉 Souq seed completed successfully!'); - console.log(` • ${createdListings.length} listings created`); - console.log(` • 1 purchase created`); - console.log(` • 1 review created`); - console.log(` • Seller: ${seller.name} (${seller.email})`); - console.log(` • Buyer: ${buyer.name} (${buyer.email})`); - console.log('═'.repeat(50)); -} - -main().catch((e) => { - console.error('❌ Seed failed:', e); - process.exit(1); -}).finally(async () => { - await prisma.$disconnect(); -}); diff --git a/scripts/setup-sso.sh b/scripts/setup-sso.sh deleted file mode 100644 index e7a1e6d..0000000 --- a/scripts/setup-sso.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash -# ============================================================ -# FalahOS SSO Credentials Setup Script -# ============================================================ -# Run this on your Contabo VPS after getting OAuth credentials. -# It safely updates .env, rebuilds, and redeploys. -# -# Usage: bash /root/falah-mobile/scripts/setup-sso.sh -# ============================================================ - -set -e - -SSO_DIR="$(cd "$(dirname "$0")/.." && pwd)" -ENV_FILE="$SSO_DIR/.env" -COMPOSE_FILE="$SSO_DIR/docker-compose.yml" - -echo "╔═══════════════════════════════════════════════╗" -echo "║ FalahOS SSO Credentials Setup ║" -echo "╚═══════════════════════════════════════════════╝" -echo "" - -# --- Prompt for GitHub --- -echo "── 🐙 GitHub OAuth App ──" -read -p " Client ID : " GITHUB_CLIENT_ID -read -sp " Client Secret: " GITHUB_CLIENT_SECRET -echo "" -echo "" - -# --- Prompt for Google --- -echo "── 🔵 Google OAuth ──" -read -p " Client ID : " GOOGLE_CLIENT_ID -read -sp " Client Secret: " GOOGLE_CLIENT_SECRET -echo "" -echo "" - -# --- Confirm --- -echo "═══════════════════════════════════════════════" -echo " Summary:" -echo " GitHub: ${GITHUB_CLIENT_ID:0:10}..." -echo " Google: ${GOOGLE_CLIENT_ID:0:10}..." -echo "═══════════════════════════════════════════════" -read -p "Apply these credentials and redeploy? (y/N) " CONFIRM - -if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then - echo "Aborted. No changes made." - exit 0 -fi - -# --- Backup current .env --- -cp "$ENV_FILE" "$ENV_FILE.backup" -echo "✓ Backed up .env -> .env.backup" - -# --- Remove old OAuth lines if they exist --- -sed -i '/^GOOGLE_CLIENT_ID=/d' "$ENV_FILE" -sed -i '/^GOOGLE_CLIENT_SECRET=/d' "$ENV_FILE" -sed -i '/^GITHUB_CLIENT_ID=/d' "$ENV_FILE" -sed -i '/^GITHUB_CLIENT_SECRET=/d' "$ENV_FILE" - -# --- Append new credentials --- -cat >> "$ENV_FILE" << EOF - -# --- OAuth / SSO --- -GOOGLE_CLIENT_ID="$GOOGLE_CLIENT_ID" -GOOGLE_CLIENT_SECRET="$GOOGLE_CLIENT_SECRET" -GITHUB_CLIENT_ID="$GITHUB_CLIENT_ID" -GITHUB_CLIENT_SECRET="$GITHUB_CLIENT_SECRET" -EOF - -echo "✓ Credentials written to .env" - -# --- Rebuild and deploy --- -echo "" -echo "═══ Building & Deploying ═══" -cd "$SSO_DIR" -npm run build - -echo "" -echo "═══ Rebuilding Docker image ═══" -docker compose -f "$COMPOSE_FILE" build --pull - -echo "" -echo "═══ Restarting container ═══" -docker compose -f "$COMPOSE_FILE" up -d --force-recreate - -echo "" -echo "✓ SSO setup complete!" -echo " Test at: https://falahos.my/mobile/auth" diff --git a/scripts/watchdog.sh b/scripts/watchdog.sh deleted file mode 100644 index a973046..0000000 --- a/scripts/watchdog.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash -# ============================================================ -# Ultimate Watchdog — "Who watches the watchmen?" -# ============================================================ -# Runs as a cron job every 5 minutes. -# If BOTH healers are down, brings them back. -# This is the last line of defense. -# ============================================================ - -LOG="/var/log/falah/watchdog.log" -DOCKER=/var/packages/Docker/target/usr/bin/docker - -mkdir -p "$(dirname "$LOG")" - -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] [watchdog] $*" | tee -a "$LOG" -} - -check_and_restart() { - local name="$1" - local image="${2:-$name:latest}" - - local status - status=$($DOCKER ps -a --filter "name=$name" --format '{{.Status}}' 2>/dev/null) - - if echo "$status" | grep -q "^Up"; then - return 0 # running - fi - - log "⚠️ $name is DOWN (status=$status) — restarting..." - - # Remove if exists (stopped/crashed) - $DOCKER rm -f "$name" 2>/dev/null || true - - # Determine run args based on container - case "$name" in - falah-auto-healer) - $DOCKER run -d --name "$name" --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 HEALTH_URL=http://localhost:4014/mobile/api/health \ - -e GATEWAY_URL=http://localhost:7878/mobile/api/health \ - -e CHECK_INTERVAL=30 \ - -e MAX_RESTARTS_PER_HOUR=3 \ - -e ROLLBACK_IMAGE=falah-mobile:rollback \ - "$image" >> "$LOG" 2>&1 - ;; - falah-dns-healer) - $DOCKER run -d --name "$name" --restart unless-stopped \ - --network host \ - -v /var/run/docker.sock:/var/run/docker.sock:ro \ - -v /var/log/falah:/var/log/falah \ - -e DOMAINS="falahos.my api.falahos.my git.falahos.my app.falahos.my" \ - -e DIRECT_HEALTH="http://localhost:4014/mobile/api/health" \ - -e GATEWAY_URL="http://localhost:7878/mobile/api/health" \ - -e TUNNEL_PRIMARY="cloudflare-YT01" \ - -e TUNNEL_FALLBACK="falah-tunnel" \ - -e CHECK_INTERVAL=60 \ - "$image" >> "$LOG" 2>&1 - ;; - *) - log "❌ Unknown container: $name" - return 1 - ;; - esac - - sleep 3 - if $DOCKER ps --filter "name=$name" --format '{{.Status}}' | grep -q "^Up"; then - log "✅ $name restarted successfully" - return 0 - else - log "❌ $name failed to start" - return 1 - fi -} - -# ── Main ───────────────────────────────────────────────────── - -log "══════════════════════════════════════════════════════" -log "🔍 Watchdog check starting..." - -APP_OK=false -DNS_OK=false - -if check_and_restart "falah-auto-healer" "falah-auto-healer:v6"; then - APP_OK=true -fi - -if check_and_restart "falah-dns-healer" "falah-dns-healer:latest"; then - DNS_OK=true -fi - -if $APP_OK && $DNS_OK; then - log "✅ Both healers running" -elif $APP_OK || $DNS_OK; then - log "⚠️ One healer restored, other still down" -else - log "❌ CRITICAL: Both healers down and could not be restored!" -fi - -# Also verify the app itself is running -APP_STATUS=$($DOCKER ps --filter name=falah-mobile-staging --format '{{.Status}}' 2>/dev/null) -if ! echo "$APP_STATUS" | grep -q "^Up"; then - log "⚠️ App container not running — cannot auto-fix from watchdog" - log " (app healer should handle this once restarted)" -fi - -log "══════════════════════════════════════════════════════" diff --git a/seed-data.mjs b/seed-data.mjs new file mode 100644 index 0000000..8ae479e --- /dev/null +++ b/seed-data.mjs @@ -0,0 +1,305 @@ +/** + * seed-data.mjs — Seed V1 Mobile SQLite DB with courses, forum, and Islamic finance data + * Idempotent: safe to run multiple times (upserts/checks before creating) + * Run via: docker cp seed-data.mjs falah_falah-mobile.X:/app/data/ && docker exec falah_falah-mobile.X node /app/data/seed-data.mjs + */ + +import { PrismaClient } from "@prisma/client"; +const prisma = new PrismaClient(); + +const DEMO_EMAIL = "demo@falahos.my"; + +async function seed() { + console.log("🌱 Starting V1 Mobile seed..."); + + // ═══════════════════════════════════════════ + // 1. FORUM CATEGORIES + // ═══════════════════════════════════════════ + const categories = [ + { name: "General Discussion", description: "General Islamic discussions and community topics", icon: "MessageSquare", order: 1 }, + { name: "Quran & Hadith", description: "Study and discussion of Quranic verses and hadith", icon: "BookOpen", order: 2 }, + { name: "Fiqh & Rulings", description: "Islamic jurisprudence and daily rulings", icon: "Scale", order: 3 }, + { name: "Family & Marriage", description: "Family relationships, marriage, and parenting in Islam", icon: "Heart", order: 4 }, + { name: "Finance & Economy", description: "Islamic finance, halal investing, and economic topics", icon: "TrendingUp", order: 5 }, + { name: "Announcements", description: "Official Falah platform announcements", icon: "Megaphone", order: 0 }, + ]; + + let categoryIds = {}; + for (const cat of categories) { + const existing = await prisma.forumCategory.findUnique({ where: { name: cat.name } }); + if (existing) { + categoryIds[cat.name] = existing.id; + console.log(` ℹ️ Forum category "${cat.name}" already exists (id: ${existing.id})`); + } else { + const created = await prisma.forumCategory.create({ data: cat }); + categoryIds[cat.name] = created.id; + console.log(` ✅ Created forum category: "${cat.name}"`); + } + } + + // ═══════════════════════════════════════════ + // 2. DEMO USER (ensure exists with proper balance) + // ═══════════════════════════════════════════ + let demoUser = await prisma.user.findUnique({ where: { email: DEMO_EMAIL } }); + if (!demoUser) { + demoUser = await prisma.user.create({ + data: { + email: DEMO_EMAIL, + name: "Demo User", + flhBalance: 100000, + isPremium: true, + isPro: true, + }, + }); + console.log(` ✅ Created demo user: ${DEMO_EMAIL} (id: ${demoUser.id})`); + } else { + // Update premium status and balance + demoUser = await prisma.user.update({ + where: { email: DEMO_EMAIL }, + data: { flhBalance: 100000, isPremium: true, isPro: true }, + }); + console.log(` ℹ️ Demo user exists: balance=${demoUser.flhBalance}, premium=${demoUser.isPremium}`); + } + + // ═══════════════════════════════════════════ + // 3. FORUM THREADS + // ═══════════════════════════════════════════ + const threads = [ + { + title: "Welcome to Falah Community!", + content: "Assalamu alaikum! Welcome to our community forum. This is a place for meaningful discussions, seeking knowledge, and supporting one another. Please introduce yourself!", + category: "General Discussion", + pinned: true, + }, + { + title: "The Importance of Daily Dhikr", + content: "Seeking reminders on the importance of daily remembrance of Allah. What are your favorite adhkar and how do you maintain consistency?", + category: "General Discussion", + pinned: false, + }, + { + title: "Tips for Memorizing Juz Amma", + content: "I have started memorizing Juz Amma and would love to hear tips and techniques that worked for others. Jazakum Allah khair!", + category: "Quran & Hadith", + pinned: false, + }, + { + title: "Ruling on Cryptocurrency in Islam", + content: "Assalamu alaikum, I would like to understand the Islamic ruling on cryptocurrency trading. Is it considered halal? What do the scholars say about Bitcoin and other digital assets?", + category: "Fiqh & Rulings", + pinned: false, + }, + { + title: "Strengthening Family Bonds in Islam", + content: "How do we strengthen our family relationships according to Islamic teachings? Looking for practical advice on being better spouses and parents.", + category: "Family & Marriage", + pinned: false, + }, + ]; + + for (const t of threads) { + const catId = categoryIds[t.category]; + if (!catId) continue; + // Check if a thread with this title already exists + const existing = await prisma.forumThread.findFirst({ where: { title: t.title } }); + if (existing) { + console.log(` ℹ️ Forum thread "${t.title}" already exists`); + continue; + } + await prisma.forumThread.create({ + data: { + title: t.title, + content: t.content, + categoryId: catId, + authorId: demoUser.id, + pinned: t.pinned, + shariahStatus: "approved", + }, + }); + console.log(` ✅ Created forum thread: "${t.title}"`); + } + + // ═══════════════════════════════════════════ + // 4. LEARN COURSES + // ═══════════════════════════════════════════ + const courses = [ + { + slug: "intro-islamic-finance", + title: "Introduction to Islamic Finance", + author: "Falah Academy", + description: "Learn the fundamentals of Islamic finance including the prohibition of riba (interest), the principles of halal investing, and the key contracts used in Islamic banking and finance.", + difficulty: "beginner", + published: true, + moduleCount: 4, + totalMinutes: 30, + giteaPath: "courses/intro-islamic-finance", + }, + { + slug: "zakat-made-easy", + title: "Zakat Made Easy", + author: "Falah Academy", + description: "A comprehensive guide to understanding and calculating Zakat. Learn about nisab thresholds, different types of wealth, and how to fulfill this important pillar of Islam.", + difficulty: "beginner", + subscriptionOnly: false, + published: true, + moduleCount: 5, + totalMinutes: 40, + giteaPath: "courses/zakat-made-easy", + }, + { + slug: "fiqh-of-salah", + title: "Fiqh of Salah", + author: "Falah Academy", + description: "A detailed study of the Islamic prayer (salah) according to the Quran and Sunnah. Covers conditions, pillars, obligations, and common mistakes in prayer.", + difficulty: "intermediate", + published: true, + moduleCount: 6, + totalMinutes: 50, + giteaPath: "courses/fiqh-of-salah", + }, + { + slug: "waqf-endowment-guide", + title: "Waqf Endowment Guide", + author: "Falah Academy", + description: "Understand the concept of Waqf (Islamic endowment) and how to set up perpetual charitable endowments that generate ongoing rewards.", + difficulty: "intermediate", + published: true, + moduleCount: 3, + totalMinutes: 25, + giteaPath: "courses/waqf-endowment-guide", + }, + { + slug: "sadaqah-and-community", + title: "Sadaqah & Community Giving", + author: "Falah Academy", + description: "Explore the virtues of voluntary charity (sadaqah) in Islam and learn how to make the most impact through strategic community giving.", + difficulty: "beginner", + published: true, + moduleCount: 3, + totalMinutes: 20, + giteaPath: "courses/sadaqah-and-community", + }, + ]; + + for (const course of courses) { + const existing = await prisma.learnCourse.findUnique({ where: { slug: course.slug } }); + if (existing) { + console.log(` ℹ️ Course "${course.title}" already exists`); + continue; + } + await prisma.learnCourse.create({ data: course }); + console.log(` ✅ Created course: "${course.title}"`); + } + + // ═══════════════════════════════════════════ + // 5. AUTO-ENROLL DEMO USER IN ALL COURSES + // ═══════════════════════════════════════════ + const allCourses = await prisma.learnCourse.findMany({ where: { published: true } }); + for (const course of allCourses) { + const existing = await prisma.learnEnrollment.findUnique({ + where: { userId_courseId: { userId: demoUser.id, courseId: course.id } }, + }); + if (!existing) { + await prisma.learnEnrollment.create({ + data: { + userId: demoUser.id, + courseId: course.id, + purchaseType: "free", + }, + }); + console.log(` ✅ Enrolled demo user in course: \"${course.title}\"`); + } else { + console.log(` ℹ️ Demo already enrolled in: \"${course.title}\"`); + } + } + + // ═══════════════════════════════════════════ + // 6. LEARN MODULES (for each course) + // ═══════════════════════════════════════════ + const modules = { + "intro-islamic-finance": [ + { slug: "what-is-riba", title: "What is Riba?", keyTakeaway: "Riba (interest) is strictly prohibited in Islam. Understand the different types and why it matters.", duration: 8 }, + { slug: "halal-investing", title: "Halal Investing Principles", keyTakeaway: "Investments must avoid riba, gharar (excessive uncertainty), and haram industries.", duration: 7 }, + { slug: "islamic-contracts", title: "Islamic Contracts Overview", keyTakeaway: "Key contracts include Murabaha (cost-plus), Musharakah (partnership), and Ijarah (leasing).", duration: 8 }, + { slug: "modern-fintech", title: "Modern Islamic Fintech", keyTakeaway: "Technology is making Islamic finance more accessible through digital wallets, tokenization, and smart contracts.", duration: 7 }, + ], + "zakat-made-easy": [ + { slug: "what-is-zakat", title: "What is Zakat?", keyTakeaway: "Zakat is the third pillar of Islam — an obligatory charity on wealth that meets nisab threshold.", duration: 8 }, + { slug: "calculating-nisab", title: "Calculating Nisab", keyTakeaway: "Nisab is the minimum amount of wealth before Zakat is due, equivalent to 85g of gold or 595g of silver.", duration: 8 }, + { slug: "types-of-wealth", title: "Types of Zakatable Wealth", keyTakeaway: "Zakat applies to cash, gold/silver, business inventory, agricultural produce, livestock, and investments.", duration: 8 }, + { slug: "zakat-calculation", title: "Calculating Your Zakat", keyTakeaway: "Zakat is 2.5% of qualifying wealth held for one lunar year (hawl).", duration: 8 }, + { slug: "distributing-zakat", title: "Distributing Your Zakat", keyTakeaway: "Zakat must be given to 8 eligible categories, prioritizing local needs.", duration: 8 }, + ], + "fiqh-of-salah": [ + { slug: "conditions-of-salah", title: "Conditions of Salah", keyTakeaway: "Salah has 9 preconditions including purity, facing qibla, and proper covering.", duration: 8 }, + { slug: "pillars-of-salah", title: "Pillars of Salah", keyTakeaway: "The 14 pillars of salah are essential — missing any invalidates the prayer.", duration: 8 }, + { slug: "obligations-of-salah", title: "Obligations of Salah", keyTakeaway: "The wajibaat (obligatory acts) of salah must be performed; intentional omission requires sujud al-sahw.", duration: 8 }, + { slug: "sunnah-practices", title: "Sunnah Practices", keyTakeaway: "Following the sunnah practices of the Prophet ﷺ in salah increases reward and perfects the prayer.", duration: 9 }, + { slug: "common-mistakes", title: "Common Mistakes in Salah", keyTakeaway: "Common errors include rushing, incorrect recitation, and improper movements — learn to avoid them.", duration: 9 }, + { slug: "congregational-prayer", title: "Congregational Prayer", keyTakeaway: "Praying in congregation (jama'ah) is 27 times more rewarding than praying alone.", duration: 8 }, + ], + "waqf-endowment-guide": [ + { slug: "what-is-waqf", title: "What is Waqf?", keyTakeaway: "Waqf is a perpetual charitable endowment where the principal is preserved and benefits are continuously given.", duration: 8 }, + { slug: "types-of-waqf", title: "Types of Waqf", keyTakeaway: "Waqf can be for family (waqf dhurri), charitable (waqf khayri), or combined purposes.", duration: 8 }, + { slug: "digital-waqf", title: "Waqf in the Digital Age", keyTakeaway: "Digital waqf allows perpetual endowments using FLH tokens with automated yield distribution.", duration: 9 }, + ], + "sadaqah-and-community": [ + { slug: "virtue-of-sadaqah", title: "The Virtue of Sadaqah", keyTakeaway: "Sadaqah purifies wealth, wards off calamities, and brings immense reward, especially when given secretly.", duration: 7 }, + { slug: "types-of-sadaqah", title: "Types of Sadaqah", keyTakeaway: "Sadaqah includes both monetary giving and non-monetary acts like a smile or kind word (sadaqah jariyah).", duration: 7 }, + { slug: "maximizing-impact", title: "Maximizing Community Impact", keyTakeaway: "Strategic giving through categorized causes (water, education, food, health) amplifies community benefit.", duration: 6 }, + ], + }; + + for (const [courseSlug, courseModules] of Object.entries(modules)) { + const course = await prisma.learnCourse.findUnique({ where: { slug: courseSlug } }); + if (!course) { + console.log(` ⚠️ Course "${courseSlug}" not found, skipping modules`); + continue; + } + for (let i = 0; i < courseModules.length; i++) { + const mod = courseModules[i]; + const existing = await prisma.learnModule.findUnique({ + where: { courseId_slug: { courseId: course.id, slug: mod.slug } }, + }); + if (existing) { + continue; + } + await prisma.learnModule.create({ + data: { + courseId: course.id, + order: i, + title: mod.title, + slug: mod.slug, + keyTakeaway: mod.keyTakeaway, + duration: mod.duration, + content: `# ${mod.title}\n\n${mod.keyTakeaway}\n\n`, + }, + }); + } + console.log(` ✅ Created ${courseModules.length} modules for "${course.title}"`); + } + + // ═══════════════════════════════════════════ + // SUMMARY + // ═══════════════════════════════════════════ + const courseCount = await prisma.learnCourse.count(); + const moduleCount = await prisma.learnModule.count(); + const threadCount = await prisma.forumThread.count(); + const categoryCount = await prisma.forumCategory.count(); + const userCount = await prisma.user.count(); + + console.log("\n📊 Seed Summary:"); + console.log(` Users: ${userCount}`); + console.log(` Forum Categories: ${categoryCount}`); + console.log(` Forum Threads: ${threadCount}`); + console.log(` Courses: ${courseCount}`); + console.log(` Course Modules: ${moduleCount}`); + console.log("✅ Seed complete!"); +} + +seed() + .catch((e) => { + console.error("❌ Seed failed:", e.message); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d2e6a1b..a7b338b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -64,6 +64,7 @@ export async function POST(req: NextRequest) { isPremium: localUser.isPremium, isPro: localUser.isPro, flhBalance: localUser.flhBalance, + flhPurchased: (localUser as any).flhPurchased ?? 0, dailyMsgCount: localUser.dailyMsgCount, }, }); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index cd86dbf..f2b0255 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -24,6 +24,7 @@ export async function GET(req: NextRequest) { isPremium: user.isPremium, isPro: user.isPro, flhBalance: user.flhBalance, + flhPurchased: (user as any).flhPurchased ?? 0, dailyMsgCount: user.dailyMsgCount, experienceLevel: user.experienceLevel, madhab: user.madhab, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 9fa9a6b..53c8db6 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -64,6 +64,7 @@ export async function POST(req: NextRequest) { isPremium: localUser.isPremium, isPro: localUser.isPro, flhBalance: localUser.flhBalance, + flhPurchased: (localUser as any).flhPurchased ?? 0, dailyMsgCount: localUser.dailyMsgCount, }, }); diff --git a/src/app/api/flh/hibah/accept/route.ts b/src/app/api/flh/hibah/accept/route.ts new file mode 100644 index 0000000..ff16d72 --- /dev/null +++ b/src/app/api/flh/hibah/accept/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +// Accept a received hibah by its ID +// POST /api/flh/hibah/accept?id=xxx — query param for simplicity +// The backend expects POST /api/v2/hibah/accept/:id +export async function POST(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const body = await request.json(); + const hibahId = body.hibahId; + + if (!hibahId) { + return NextResponse.json({ error: "hibahId is required" }, { status: 400 }); + } + + const ummahRes = await fetch( + `${COMMUNITY_URL}/api/v2/hibah/accept/${encodeURIComponent(hibahId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: authHeader }, + } + ); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[hibah-accept] Proxy error:", error); + return NextResponse.json({ error: "Failed to accept hibah" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/hibah/received/route.ts b/src/app/api/flh/hibah/received/route.ts new file mode 100644 index 0000000..78d001a --- /dev/null +++ b/src/app/api/flh/hibah/received/route.ts @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; +export async function GET(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const r = await fetch(`${COMMUNITY_URL}/api/v2/hibah/received`, { headers:{Authorization:auth} }); + return NextResponse.json(await r.json(), { status: r.status }); + } catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); } +} \ No newline at end of file diff --git a/src/app/api/flh/hibah/reject/[id]/route.ts b/src/app/api/flh/hibah/reject/[id]/route.ts new file mode 100644 index 0000000..7e8b757 --- /dev/null +++ b/src/app/api/flh/hibah/reject/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authHeader = request.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const { id } = await params; + const ummahRes = await fetch( + `${COMMUNITY_URL}/api/v2/hibah/reject/${id}`, + { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: authHeader }, + } + ); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[hibah-reject] Proxy error:", error); + return NextResponse.json({ error: "Failed to reject hibah" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/hibah/send/route.ts b/src/app/api/flh/hibah/send/route.ts new file mode 100644 index 0000000..7b0ceaa --- /dev/null +++ b/src/app/api/flh/hibah/send/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; +export async function POST(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const body = await req.json(); + const r = await fetch(`${COMMUNITY_URL}/api/v2/hibah/send`, { method:"POST", headers:{"Content-Type":"application/json",Authorization:auth}, body:JSON.stringify(body) }); + return NextResponse.json(await r.json(), { status: r.status }); + } catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); } +} \ No newline at end of file diff --git a/src/app/api/flh/hibah/sent/route.ts b/src/app/api/flh/hibah/sent/route.ts new file mode 100644 index 0000000..1ec8e14 --- /dev/null +++ b/src/app/api/flh/hibah/sent/route.ts @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; +export async function GET(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const r = await fetch(`${COMMUNITY_URL}/api/v2/hibah/sent`, { headers:{Authorization:auth} }); + return NextResponse.json(await r.json(), { status: r.status }); + } catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); } +} \ No newline at end of file diff --git a/src/app/api/flh/hibah/summary/route.ts b/src/app/api/flh/hibah/summary/route.ts new file mode 100644 index 0000000..f84a1e5 --- /dev/null +++ b/src/app/api/flh/hibah/summary/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/hibah/summary`, { + headers: { Authorization: authHeader }, + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[hibah-summary] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch hibah summary" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/sadaqah/causes/route.ts b/src/app/api/flh/sadaqah/causes/route.ts new file mode 100644 index 0000000..d9da73a --- /dev/null +++ b/src/app/api/flh/sadaqah/causes/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET() { + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/causes`); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[sadaqah-causes] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch causes" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/sadaqah/history/route.ts b/src/app/api/flh/sadaqah/history/route.ts new file mode 100644 index 0000000..82e88aa --- /dev/null +++ b/src/app/api/flh/sadaqah/history/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/history`, { + headers: { Authorization: authHeader }, + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[sadaqah-history] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch history" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/sadaqah/pool/route.ts b/src/app/api/flh/sadaqah/pool/route.ts new file mode 100644 index 0000000..6a6e3a3 --- /dev/null +++ b/src/app/api/flh/sadaqah/pool/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET() { + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/pool`); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[sadaqah-pool] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch sadaqah pool" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/sadaqah/send/route.ts b/src/app/api/flh/sadaqah/send/route.ts new file mode 100644 index 0000000..437ae24 --- /dev/null +++ b/src/app/api/flh/sadaqah/send/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const body = await req.json(); + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/give`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: authHeader }, + body: JSON.stringify(body), + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[sadaqah-send] Proxy error:", error); + return NextResponse.json({ error: "Failed to send sadaqah" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/sadaqah/subscribe/route.ts b/src/app/api/flh/sadaqah/subscribe/route.ts new file mode 100644 index 0000000..5ab681f --- /dev/null +++ b/src/app/api/flh/sadaqah/subscribe/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const body = await req.json(); + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: authHeader }, + body: JSON.stringify(body), + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[sadaqah-subscribe] Proxy error:", error); + return NextResponse.json({ error: "Failed to create subscription" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/sadaqah/subscriptions/route.ts b/src/app/api/flh/sadaqah/subscriptions/route.ts new file mode 100644 index 0000000..496c372 --- /dev/null +++ b/src/app/api/flh/sadaqah/subscriptions/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/subscriptions`, { + headers: { Authorization: authHeader }, + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[sadaqah-subscriptions] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch subscriptions" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/sadaqah/unsubscribe/route.ts b/src/app/api/flh/sadaqah/unsubscribe/route.ts new file mode 100644 index 0000000..1257a92 --- /dev/null +++ b/src/app/api/flh/sadaqah/unsubscribe/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const body = await req.json(); + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: authHeader }, + body: JSON.stringify(body), + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[sadaqah-unsubscribe] Proxy error:", error); + return NextResponse.json({ error: "Failed to cancel subscription" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/summary/route.ts b/src/app/api/flh/summary/route.ts new file mode 100644 index 0000000..7f1d209 --- /dev/null +++ b/src/app/api/flh/summary/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/flh/summary`, { + headers: { Authorization: authHeader }, + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[flh-summary] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch summary" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/waqf/contribute/route.ts b/src/app/api/flh/waqf/contribute/route.ts new file mode 100644 index 0000000..16dae29 --- /dev/null +++ b/src/app/api/flh/waqf/contribute/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; +export async function POST(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const body = await req.json(); + const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/contribute`, { method:"POST", headers:{"Content-Type":"application/json",Authorization:auth}, body:JSON.stringify(body) }); + return NextResponse.json(await r.json(), { status: r.status }); + } catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); } +} \ No newline at end of file diff --git a/src/app/api/flh/waqf/create/route.ts b/src/app/api/flh/waqf/create/route.ts new file mode 100644 index 0000000..5792212 --- /dev/null +++ b/src/app/api/flh/waqf/create/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; +export async function POST(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const body = await req.json(); + const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/pools`, { method:"POST", headers:{"Content-Type":"application/json",Authorization:auth}, body:JSON.stringify(body) }); + return NextResponse.json(await r.json(), { status: r.status }); + } catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); } +} \ No newline at end of file diff --git a/src/app/api/flh/waqf/my-contributions/route.ts b/src/app/api/flh/waqf/my-contributions/route.ts new file mode 100644 index 0000000..3443d27 --- /dev/null +++ b/src/app/api/flh/waqf/my-contributions/route.ts @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; +export async function GET(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/my-contributions`, { headers:{Authorization:auth} }); + return NextResponse.json(await r.json(), { status: r.status }); + } catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); } +} \ No newline at end of file diff --git a/src/app/api/flh/waqf/pools/route.ts b/src/app/api/flh/waqf/pools/route.ts new file mode 100644 index 0000000..d7f39bb --- /dev/null +++ b/src/app/api/flh/waqf/pools/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; +export async function GET() { + try { + const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/pools`); + return NextResponse.json(await r.json(), { status: r.status }); + } catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); } +} \ No newline at end of file diff --git a/src/app/api/flh/zakat/agents/route.ts b/src/app/api/flh/zakat/agents/route.ts new file mode 100644 index 0000000..b8e1daa --- /dev/null +++ b/src/app/api/flh/zakat/agents/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const country = searchParams.get("country"); + + try { + const url = country + ? `${COMMUNITY_URL}/api/v2/zakat/agents?country=${encodeURIComponent(country)}` + : `${COMMUNITY_URL}/api/v2/zakat/agents`; + + const res = await fetch(url); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); + } catch (error) { + console.error("[zakat/agents] Proxy error:", error); + return NextResponse.json( + { error: "Failed to fetch zakat agents" }, + { status: 502 } + ); + } +} diff --git a/src/app/api/flh/zakat/calculate/route.ts b/src/app/api/flh/zakat/calculate/route.ts new file mode 100644 index 0000000..8b137ed --- /dev/null +++ b/src/app/api/flh/zakat/calculate/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function POST(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + + try { + const body = await request.json(); + const { amount } = body; + + // If amount is 0 or not provided, return status summary (remaining obligation, nisab, etc.) + if (!amount || typeof amount !== "number" || amount < 0) { + return NextResponse.json( + { error: "Amount is required and must be non-negative" }, + { status: 400 } + ); + } + + if (amount === 0) { + // Return zakat status summary + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/status`, { + method: "GET", + headers: { + Authorization: authHeader, + }, + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } + + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/calculate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify({ amount }), + }); + + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[zakat-calculate] Proxy error:", error); + return NextResponse.json( + { error: "Failed to calculate zakat" }, + { status: 502 } + ); + } +} diff --git a/src/app/api/flh/zakat/history/route.ts b/src/app/api/flh/zakat/history/route.ts new file mode 100644 index 0000000..07b31f4 --- /dev/null +++ b/src/app/api/flh/zakat/history/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/history`, { + headers: { Authorization: authHeader }, + }); + + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[zakat-history] Proxy error:", error); + return NextResponse.json( + { error: "Failed to fetch zakat history" }, + { status: 502 } + ); + } +} diff --git a/src/app/api/flh/zakat/pay/route.ts b/src/app/api/flh/zakat/pay/route.ts new file mode 100644 index 0000000..b2beea9 --- /dev/null +++ b/src/app/api/flh/zakat/pay/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + + try { + const body = await req.json(); + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/pay`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify(body), + }); + + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[zakat-pay] Proxy error:", error); + return NextResponse.json( + { error: "Failed to process zakat payment" }, + { status: 502 } + ); + } +} diff --git a/src/app/api/flh/zakat/pool/route.ts b/src/app/api/flh/zakat/pool/route.ts new file mode 100644 index 0000000..8ebaed4 --- /dev/null +++ b/src/app/api/flh/zakat/pool/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET() { + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/pool`); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[zakat-pool] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch zakat pool" }, { status: 502 }); + } +} diff --git a/src/app/api/flh/zakat/status/route.ts b/src/app/api/flh/zakat/status/route.ts new file mode 100644 index 0000000..058f7a8 --- /dev/null +++ b/src/app/api/flh/zakat/status/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; + +const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + + try { + const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/history`, { + headers: { Authorization: authHeader }, + }); + + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[zakat-status] Proxy error:", error); + return NextResponse.json( + { error: "Failed to fetch zakat status" }, + { status: 502 } + ); + } +} diff --git a/src/app/api/halal/places/route.ts b/src/app/api/halal/places/route.ts index fb81a0c..05b8af3 100644 --- a/src/app/api/halal/places/route.ts +++ b/src/app/api/halal/places/route.ts @@ -17,7 +17,7 @@ export async function GET(request: NextRequest) { const radius = radiusParam ? parseInt(radiusParam, 10) : 5000; - let places; + let places: any[] = []; let searchLocation: string | null = null; // Priority 1: City search (with optional country) diff --git a/src/app/api/health/db/route.ts b/src/app/api/health/db/route.ts new file mode 100644 index 0000000..4e7477d --- /dev/null +++ b/src/app/api/health/db/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(_req: NextRequest) { + const start = Date.now(); + + try { + // Enforce 3-second max runtime + if (Date.now() - start >= 3000) { + return NextResponse.json( + { status: "error", reads: false, writes: false, error: "Timeout" }, + { status: 504 } + ); + } + + // 1. Test read: count users (SELECT COUNT(*) FROM User) + let userCount: number; + try { + userCount = await prisma.user.count(); + } catch (readErr) { + const message = readErr instanceof Error ? readErr.message : "Read query failed"; + return NextResponse.json( + { status: "error", reads: false, writes: false, error: message }, + { status: 503 } + ); + } + + // 2. Test write: insert a lightweight temp record and delete it immediately + try { + const testId = `health-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const testEmail = `${testId}@health.internal`; + + await prisma.$executeRawUnsafe( + `INSERT INTO User (id, email, name, flhBalance) VALUES (?, ?, ?, ?)`, + testId, + testEmail, + "__health_check__", + 0 + ); + + // Clean up — this record must not persist + await prisma.$executeRawUnsafe(`DELETE FROM User WHERE id = ?`, testId); + } catch (writeErr) { + const message = writeErr instanceof Error ? writeErr.message : "Write query failed"; + return NextResponse.json( + { status: "error", reads: false, writes: false, error: message }, + { status: 503 } + ); + } + + // 3. All checks passed + return NextResponse.json({ + status: "ok", + reads: true, + writes: true, + count: userCount, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { status: "error", reads: false, writes: false, error: message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/learn/progress/route.ts b/src/app/api/learn/progress/route.ts index 9e51bbf..b191e35 100644 --- a/src/app/api/learn/progress/route.ts +++ b/src/app/api/learn/progress/route.ts @@ -145,7 +145,7 @@ export async function POST(request: Request) { if (existingCert) { // Already issued — still update enrollment but don't create another const updatedEnrollment = await tx.learnEnrollment.update({ - where: { id: enrollment.id }, + where: { id: enrollment!.id }, data: { progress, completed: true, @@ -156,8 +156,8 @@ export async function POST(request: Request) { } // Update enrollment (progress + completion) and create certificate atomically - const updatedEnrollment = await tx.learnEnrollment.update({ - where: { id: enrollment.id }, + const updatedEnrollment2 = await tx.learnEnrollment.update({ + where: { id: enrollment!.id }, data: { progress, completed: true, @@ -179,7 +179,7 @@ export async function POST(request: Request) { }); return { - enrollment: updatedEnrollment, + enrollment: updatedEnrollment2, certificate: { serialNumber, verifyUrl: certificateData.verifyUrl, @@ -202,7 +202,7 @@ export async function POST(request: Request) { return NextResponse.json({ progress, - completed: enrollment.completed, + completed: enrollment!.completed, ...(certificate ? { certificate } : {}), }); } catch (error) { diff --git a/src/app/api/seed/route.ts b/src/app/api/seed/route.ts index abd61e0..11fce41 100644 --- a/src/app/api/seed/route.ts +++ b/src/app/api/seed/route.ts @@ -212,7 +212,7 @@ export async function POST(req: NextRequest) { group: { name: group.name, members: group._count.members }, listings: listingsCreated, credentials: [ - { email: "demo@falahos.my", password: "password123" }, + { email: "demo@falahos.my", password: "demo123" }, { email: "premium@falahos.my", password: "Premium123!" }, ], }, diff --git a/src/app/api/souq/listings/[id]/route.ts.backup b/src/app/api/souq/listings/[id]/route.ts.backup new file mode 100644 index 0000000..53fb0eb --- /dev/null +++ b/src/app/api/souq/listings/[id]/route.ts.backup @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/souq/listings/[id] — single listing with seller info + reviews +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + const listing = await prisma.listing.findUnique({ + where: { id }, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + reviews: { + include: { + reviewer: { + select: { id: true, name: true, avatar: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }, + _count: { + select: { purchases: true }, + }, + }, + }); + + if (!listing) { + return NextResponse.json( + { error: "Listing not found" }, + { status: 404 } + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = listing as any; + return NextResponse.json({ + listing: { + ...data, + packages: data.packages ? JSON.parse(data.packages) : null, + tags: data.tags ? JSON.parse(data.tags) : null, + images: data.images ? JSON.parse(data.images) : null, + }, + }); + } catch (error) { + console.error("GET /api/souq/listings/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/listings/route.ts.backup b/src/app/api/souq/listings/route.ts.backup new file mode 100644 index 0000000..eb71046 --- /dev/null +++ b/src/app/api/souq/listings/route.ts.backup @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const category = searchParams.get("category"); + const search = searchParams.get("search"); + const featured = searchParams.get("featured"); + const sellerId = searchParams.get("sellerId"); + const minPrice = searchParams.get("minPrice"); + const maxPrice = searchParams.get("maxPrice"); + const sortBy = searchParams.get("sortBy"); + const deliveryDays = searchParams.get("deliveryDays"); + const page = parseInt(searchParams.get("page") || "1", 10); + const limit = parseInt(searchParams.get("limit") || "20", 10); + const skip = (page - 1) * limit; + + const where: Record = {}; + + // Only return active listings by default + where.status = "active"; + + if (category) { + where.category = category; + } + + if (search) { + where.OR = [ + { title: { contains: search } }, + { description: { contains: search } }, + ]; + } + + if (featured === "true") { + where.featured = true; + } + + if (sellerId) { + where.sellerId = sellerId; + } + + // Price range filter + if (minPrice || maxPrice) { + const priceFilter: Record = {}; + if (minPrice) priceFilter.gte = parseInt(minPrice, 10); + if (maxPrice) priceFilter.lte = parseInt(maxPrice, 10); + where.priceFlh = priceFilter; + } + + // Delivery days filter + if (deliveryDays) { + where.deliveryDays = { lte: parseInt(deliveryDays, 10) }; + } + + // Build orderBy based on sortBy + let orderBy: Record[]; + switch (sortBy) { + case "price_asc": + orderBy = [{ priceFlh: "asc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "price_desc": + orderBy = [{ priceFlh: "desc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "rating": + orderBy = [{ rating: "desc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "popular": + orderBy = [{ salesCount: "desc" }, { featured: "desc" }, { createdAt: "desc" }]; + break; + case "newest": + orderBy = [{ createdAt: "desc" }, { featured: "desc" }]; + break; + default: + orderBy = [{ featured: "desc" }, { createdAt: "desc" }]; + break; + } + + const [listings, total] = await Promise.all([ + prisma.listing.findMany({ + where, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + orderBy, + skip, + take: limit, + }), + prisma.listing.count({ where }), + ]); + + return NextResponse.json({ + listings, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error("GET /api/souq/listings error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ where: { id: auth.userId } }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { + title, + description, + category, + subcategory, + priceFlh, + packages, + tags, + images, + deliveryDays, + fileType, + } = await request.json(); + + if (!title || !description || !category || !priceFlh) { + return NextResponse.json( + { error: "Missing required fields: title, description, category, priceFlh" }, + { status: 400 } + ); + } + + if (typeof priceFlh !== "number" || priceFlh < 0) { + return NextResponse.json( + { error: "priceFlh must be a non-negative number" }, + { status: 400 } + ); + } + + const listing = await prisma.listing.create({ + data: { + title, + description, + category, + subcategory: subcategory || null, + priceFlh, + packages: packages ? JSON.stringify(packages) : null, + tags: tags ? JSON.stringify(tags) : null, + images: images ? JSON.stringify(images) : null, + deliveryDays: deliveryDays || null, + fileType: fileType || null, + sellerId: user.id, + status: "active", + }, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + return NextResponse.json({ listing }, { status: 201 }); + } catch (error) { + console.error("POST /api/souq/listings error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/orders/[id]/route.ts.backup b/src/app/api/souq/orders/[id]/route.ts.backup new file mode 100644 index 0000000..d2ba9e6 --- /dev/null +++ b/src/app/api/souq/orders/[id]/route.ts.backup @@ -0,0 +1,250 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/souq/orders/[id] — order detail +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const order = await prisma.purchase.findUnique({ + where: { id }, + include: { + listing: { + select: { id: true, title: true, category: true, subcategory: true, priceFlh: true, description: true, deliveryDays: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + if (!order) { + return NextResponse.json( + { error: "Order not found" }, + { status: 404 } + ); + } + + // Verify user is either the buyer or seller + if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) { + return NextResponse.json( + { error: "Forbidden: you are not a party to this order" }, + { status: 403 } + ); + } + + // Check if the current user (buyer) has already reviewed this order + let hasReviewed = false; + if (order.status === "completed" && order.buyerId === auth.userId) { + const existingReview = await prisma.review.findFirst({ + where: { purchaseId: order.id, reviewerId: auth.userId }, + select: { id: true }, + }); + hasReviewed = !!existingReview; + } + + // Parse JSON fields + const data = order as any; + return NextResponse.json({ + order: { + ...data, + messages: data.messages ? JSON.parse(data.messages) : [], + deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [], + }, + hasReviewed, + }); + } catch (error) { + console.error("GET /api/souq/orders/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// PATCH /api/souq/orders/[id] — update order status or add messages +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const order = await prisma.purchase.findUnique({ + where: { id }, + }); + + if (!order) { + return NextResponse.json( + { error: "Order not found" }, + { status: 404 } + ); + } + + // Verify user is either the buyer or seller + if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) { + return NextResponse.json( + { error: "Forbidden: you are not a party to this order" }, + { status: 403 } + ); + } + + const body = await request.json(); + const { status: newStatus, message, deliveryFiles } = body; + + const updateData: Record = {}; + + // Update status if provided + if (newStatus) { + const validStatuses = [ + "pending", + "paid", + "in_progress", + "delivered", + "completed", + "disputed", + "cancelled", + ]; + + if (!validStatuses.includes(newStatus)) { + return NextResponse.json( + { + error: `Invalid status. Must be one of: ${validStatuses.join(", ")}`, + }, + { status: 400 } + ); + } + + // Validate status transitions + const validTransitions: Record = { + paid: ["in_progress", "cancelled"], + in_progress: ["delivered", "disputed"], + delivered: ["completed", "disputed"], + completed: [], + disputed: ["completed", "cancelled"], + cancelled: [], + pending: ["paid", "cancelled"], + }; + + const allowed = validTransitions[order.status] || []; + if (!allowed.includes(newStatus)) { + return NextResponse.json( + { + error: `Cannot transition from "${order.status}" to "${newStatus}"`, + }, + { status: 400 } + ); + } + + // Only seller can mark as in_progress or delivered + if ( + (newStatus === "in_progress" || newStatus === "delivered") && + auth.userId !== order.sellerId + ) { + return NextResponse.json( + { error: "Only the seller can update to this status" }, + { status: 403 } + ); + } + + // Only buyer can mark as completed + if (newStatus === "completed" && auth.userId !== order.buyerId) { + return NextResponse.json( + { error: "Only the buyer can mark an order as completed" }, + { status: 403 } + ); + } + + updateData.status = newStatus; + } + + // Add a message if provided + if (message) { + if (typeof message !== "string" || message.trim().length === 0) { + return NextResponse.json( + { error: "Message must be a non-empty string" }, + { status: 400 } + ); + } + + const existingMessages = order.messages + ? (JSON.parse(order.messages) as Array>) + : []; + + const newMessage = { + from: auth.userId, + text: message.trim(), + timestamp: new Date().toISOString(), + }; + + existingMessages.push(newMessage); + updateData.messages = JSON.stringify(existingMessages); + } + + // Update delivery files if provided + if (deliveryFiles) { + if (!Array.isArray(deliveryFiles)) { + return NextResponse.json( + { error: "deliveryFiles must be an array" }, + { status: 400 } + ); + } + updateData.deliveryFiles = JSON.stringify(deliveryFiles); + } + + if (Object.keys(updateData).length === 0) { + return NextResponse.json( + { error: "No fields to update. Provide status, message, or deliveryFiles." }, + { status: 400 } + ); + } + + const updated = await prisma.purchase.update({ + where: { id }, + data: updateData, + include: { + listing: { + select: { id: true, title: true, category: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + const data = updated as any; + return NextResponse.json({ + order: { + ...data, + messages: data.messages ? JSON.parse(data.messages) : [], + deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [], + }, + }); + } catch (error) { + console.error("PATCH /api/souq/orders/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/orders/route.ts.backup b/src/app/api/souq/orders/route.ts.backup new file mode 100644 index 0000000..19b7a97 --- /dev/null +++ b/src/app/api/souq/orders/route.ts.backup @@ -0,0 +1,213 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// GET /api/souq/orders — my orders (as buyer or seller) +export async function GET(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const role = searchParams.get("role"); // "buyer" | "seller" | null (both) + const page = parseInt(searchParams.get("page") || "1", 10); + const limit = parseInt(searchParams.get("limit") || "20", 10); + const skip = (page - 1) * limit; + + const where: Record = {}; + + if (role === "buyer") { + where.buyerId = auth.userId; + } else if (role === "seller") { + where.sellerId = auth.userId; + } else { + where.OR = [ + { buyerId: auth.userId }, + { sellerId: auth.userId }, + ]; + } + + const [orders, total] = await Promise.all([ + prisma.purchase.findMany({ + where, + include: { + listing: { + select: { id: true, title: true, category: true, priceFlh: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + prisma.purchase.count({ where }), + ]); + + // Parse messages JSON for each order + const parsed = orders.map((order) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const o = order as any; + return { + ...o, + messages: o.messages ? JSON.parse(o.messages) : null, + deliveryFiles: o.deliveryFiles ? JSON.parse(o.deliveryFiles) : null, + }; + }); + + return NextResponse.json({ + orders: parsed, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error("GET /api/souq/orders error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/souq/orders — place a new order +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ where: { id: auth.userId } }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { listingId, packageName } = await request.json(); + + if (!listingId) { + return NextResponse.json( + { error: "Missing required field: listingId" }, + { status: 400 } + ); + } + + // Validate listing exists + const listing = await prisma.listing.findUnique({ + where: { id: listingId }, + }); + + if (!listing) { + return NextResponse.json( + { error: "Listing not found" }, + { status: 404 } + ); + } + + if (listing.status !== "active") { + return NextResponse.json( + { error: "Listing is not available for purchase" }, + { status: 400 } + ); + } + + // Prevent buying own listing + if (listing.sellerId === user.id) { + return NextResponse.json( + { error: "You cannot purchase your own listing" }, + { status: 400 } + ); + } + + // Determine amount: if packageName provided, look it up in packages JSON + let amountFlh = listing.priceFlh; + if (packageName) { + if (!listing.packages) { + return NextResponse.json( + { error: "This listing has no packages" }, + { status: 400 } + ); + } + + const packages = JSON.parse(listing.packages) as Array<{ + name: string; + price?: number; + }>; + const selectedPackage = packages.find((p) => p.name === packageName); + + if (!selectedPackage) { + return NextResponse.json( + { error: `Package "${packageName}" not found` }, + { status: 400 } + ); + } + + amountFlh = selectedPackage.price ?? listing.priceFlh; + } + + // Check buyer balance + if (user.flhBalance < amountFlh) { + return NextResponse.json( + { error: "Insufficient FLH balance" }, + { status: 400 } + ); + } + + // Calculate fees + const platformFee = Math.round(amountFlh * 0.015); + const sellerPayout = amountFlh - platformFee; + + // Create purchase and deduct balance in a transaction + const [purchase] = await prisma.$transaction([ + prisma.purchase.create({ + data: { + listingId, + buyerId: user.id, + sellerId: listing.sellerId, + packageName: packageName || null, + amountFlh, + platformFee, + sellerPayout, + status: "paid", + }, + include: { + listing: { + select: { id: true, title: true, category: true, priceFlh: true }, + }, + buyer: { + select: { id: true, name: true, email: true, avatar: true }, + }, + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }), + prisma.user.update({ + where: { id: user.id }, + data: { flhBalance: { decrement: amountFlh } }, + }), + // Increment sales count on the listing + prisma.listing.update({ + where: { id: listingId }, + data: { salesCount: { increment: 1 } }, + }), + ]); + + return NextResponse.json({ order: purchase }, { status: 201 }); + } catch (error) { + console.error("POST /api/souq/orders error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/reviews/route.ts.backup b/src/app/api/souq/reviews/route.ts.backup new file mode 100644 index 0000000..d5151f8 --- /dev/null +++ b/src/app/api/souq/reviews/route.ts.backup @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth } from "@/lib/auth"; + +// POST /api/souq/reviews — create a review +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { listingId, orderId, rating, title, text } = await request.json(); + + // ── Validate fields ── + if (!listingId || !orderId) { + return NextResponse.json( + { error: "Missing required fields: listingId, orderId" }, + { status: 400 } + ); + } + + if (!rating || typeof rating !== "number" || rating < 1 || rating > 5 || !Number.isInteger(rating)) { + return NextResponse.json( + { error: "Rating must be an integer between 1 and 5" }, + { status: 400 } + ); + } + + // ── Ensure the order exists and belongs to this user ── + const order = await prisma.purchase.findUnique({ + where: { id: orderId }, + include: { listing: { select: { id: true, sellerId: true } } }, + }); + + if (!order) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + if (order.buyerId !== auth.userId) { + return NextResponse.json( + { error: "Only the buyer can review this order" }, + { status: 403 } + ); + } + + if (order.status !== "completed") { + return NextResponse.json( + { error: "Can only review completed orders" }, + { status: 400 } + ); + } + + if (order.listingId !== listingId) { + return NextResponse.json( + { error: "Listing does not match this order" }, + { status: 400 } + ); + } + + // ── Check if user already reviewed this purchase ── + const existing = await prisma.review.findFirst({ + where: { purchaseId: orderId, reviewerId: auth.userId }, + }); + + if (existing) { + return NextResponse.json( + { error: "You have already reviewed this order" }, + { status: 409 } + ); + } + + // ── Create the review and update listing stats in a transaction ── + const [review] = await prisma.$transaction(async (tx) => { + const created = await tx.review.create({ + data: { + listingId, + purchaseId: orderId, + reviewerId: auth.userId, + sellerId: order.listing.sellerId, + rating, + title: title || null, + text: text ?? "", + }, + }); + + // Recalculate listing average rating and count + const agg = await tx.review.aggregate({ + where: { listingId }, + _avg: { rating: true }, + _count: { rating: true }, + }); + + await tx.listing.update({ + where: { id: listingId }, + data: { + rating: agg._avg.rating ?? 0, + reviewCount: agg._count.rating, + }, + }); + + return [created]; + }); + + return NextResponse.json({ review }, { status: 201 }); + } catch (error) { + console.error("POST /api/souq/reviews error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/souq/sellers/[id]/route.ts.backup b/src/app/api/souq/sellers/[id]/route.ts.backup new file mode 100644 index 0000000..ffd6a21 --- /dev/null +++ b/src/app/api/souq/sellers/[id]/route.ts.backup @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/souq/sellers/[id] — seller profile data +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + // Get user info + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + avatar: true, + createdAt: true, + }, + }); + + if (!user) { + return NextResponse.json( + { error: "Seller not found" }, + { status: 404 } + ); + } + + // Get review stats + const reviewAgg = await prisma.review.aggregate({ + where: { sellerId: id }, + _avg: { rating: true }, + _count: true, + }); + + // Get sales count + const salesCount = await prisma.purchase.count({ + where: { sellerId: id }, + }); + + // Get active listings + const listings = await prisma.listing.findMany({ + where: { sellerId: id, status: "active" }, + orderBy: { createdAt: "desc" }, + include: { + seller: { + select: { id: true, name: true, email: true, avatar: true }, + }, + }, + }); + + // Parse JSON fields + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsedListings = listings.map((l: any) => ({ + ...l, + packages: l.packages ? JSON.parse(l.packages) : null, + tags: l.tags ? JSON.parse(l.tags) : null, + images: l.images ? JSON.parse(l.images) : null, + })); + + return NextResponse.json({ + seller: { + id: user.id, + name: user.name, + email: user.email, + avatar: user.avatar, + memberSince: user.createdAt, + listingsCount: parsedListings.length, + averageRating: reviewAgg._avg.rating ?? 0, + reviewCount: reviewAgg._count, + salesCount, + listings: parsedListings, + }, + }); + } catch (error) { + console.error("GET /api/souq/sellers/[id] error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/wallet/route.ts b/src/app/api/wallet/route.ts index e70e1d3..37e515a 100644 --- a/src/app/api/wallet/route.ts +++ b/src/app/api/wallet/route.ts @@ -23,7 +23,8 @@ export async function GET(req: NextRequest) { } return NextResponse.json({ - balance: user.flhBalance, + flhBalance: user.flhBalance, + flhPurchased: user.flhPurchased, cashoutHistory: user.cashouts.map((c) => ({ id: c.id, amountFlh: c.amountFlh, @@ -59,7 +60,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } - if (user.flhBalance < amountFlh) { + if (user.flhPurchased < amountFlh) { return NextResponse.json( { error: "Insufficient FLH balance" }, { status: 400 } @@ -81,7 +82,12 @@ export async function POST(req: NextRequest) { // Deduct balance immediately await prisma.user.update({ where: { id: user.id }, - data: { flhBalance: { decrement: amountFlh } }, + data: { flhPurchased: { decrement: amountFlh } }, + }); + + // Fetch updated user to return both balances + const updatedUser = await prisma.user.findUnique({ + where: { id: user.id }, }); return NextResponse.json({ @@ -93,6 +99,8 @@ export async function POST(req: NextRequest) { status: cashout.status, createdAt: cashout.createdAt, }, + flhBalance: updatedUser?.flhBalance ?? 0, + flhPurchased: updatedUser?.flhPurchased ?? 0, }); } catch (error) { console.error("Cashout error:", error); diff --git a/src/app/api/wallet/ton-address/route.ts b/src/app/api/wallet/ton-address/route.ts new file mode 100644 index 0000000..5f3f6a1 --- /dev/null +++ b/src/app/api/wallet/ton-address/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; + +const UMMAHID_URL = process.env.UMMAHID_URL || "http://ummahid:3000"; + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + + try { + const ummahRes = await fetch(`${UMMAHID_URL}/api/flh/wallet/ton-address`, { + headers: { Authorization: authHeader }, + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[ton-address] Proxy error:", error); + return NextResponse.json({ error: "Failed to fetch TON address" }, { status: 502 }); + } +} + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + if (!authHeader) { + return NextResponse.json({ error: "Authorization required" }, { status: 401 }); + } + + try { + const body = await req.json(); + const ummahRes = await fetch(`${UMMAHID_URL}/api/flh/wallet/ton-address`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify(body), + }); + const data = await ummahRes.json(); + return NextResponse.json(data, { status: ummahRes.status }); + } catch (error) { + console.error("[ton-address] Proxy error:", error); + return NextResponse.json({ error: "Failed to save TON address" }, { status: 502 }); + } +} diff --git a/src/app/api/webhooks/polar/route.ts b/src/app/api/webhooks/polar/route.ts index cd0e3cd..71f527d 100644 --- a/src/app/api/webhooks/polar/route.ts +++ b/src/app/api/webhooks/polar/route.ts @@ -57,6 +57,13 @@ export async function POST(req: NextRequest) { ); } } else { + if (process.env.NODE_ENV === "production") { + console.error("POLAR_WEBHOOK_SECRET missing in production — refusing unverified webhook"); + return NextResponse.json( + { error: "Webhook secret not configured" }, + { status: 500 } + ); + } console.warn("POLAR_WEBHOOK_SECRET not set — skipping signature verification (dev mode)"); } diff --git a/src/app/flh/hibah/page.tsx b/src/app/flh/hibah/page.tsx new file mode 100644 index 0000000..2e5ff51 --- /dev/null +++ b/src/app/flh/hibah/page.tsx @@ -0,0 +1,502 @@ +"use client"; + +import { useState, useEffect, FormEvent } from "react"; +import { useAuth } from "@/lib/AuthContext"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + ArrowLeft, + Heart, + Send, + Clock, + Check, + Loader2, + TrendingUp, + User, + ArrowUpRight, + ArrowDownLeft, + X, +} from "lucide-react"; +import ErrorFeedback from "@/components/ErrorFeedback"; + +interface SentHibah { + id: string; + toUserId?: string; + toEmail?: string; + amount: number; + message?: string; + status: string; + createdAt: string; +} + +interface ReceivedHibah { + id: string; + fromUserId?: string; + fromEmail?: string; + fromName?: string; + amount: number; + message?: string; + status: string; + createdAt: string; +} + +export default function HibahPage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + + // Send form + const [toEmail, setToEmail] = useState(""); + const [sendAmount, setSendAmount] = useState(""); + const [sendMessage, setSendMessage] = useState(""); + const [sendLoading, setSendLoading] = useState(false); + const [sendMessageState, setSendMessageState] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + // Sent + const [sent, setSent] = useState([]); + const [sentLoading, setSentLoading] = useState(true); + const [sentError, setSentError] = useState(null); + + // Received + const [received, setReceived] = useState([]); + const [receivedLoading, setReceivedLoading] = useState(true); + const [receivedError, setReceivedError] = useState(null); + + // Accept/Reject + const [actionLoadingId, setActionLoadingId] = useState(null); + + useEffect(() => { + if (!authLoading && !token) router.push("/auth"); + }, [authLoading, token, router]); + + useEffect(() => { + if (token) { + fetchSent(); + fetchReceived(); + } + }, [token]); + + const fetchSent = async () => { + setSentLoading(true); + setSentError(null); + try { + const res = await fetch("/mobile/api/flh/hibah/sent", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error("Failed to fetch sent hibah"); + const data = await res.json(); + setSent(Array.isArray(data) ? data : data.sent ?? []); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Something went wrong"; + setSentError(msg); + } finally { + setSentLoading(false); + } + }; + + const fetchReceived = async () => { + setReceivedLoading(true); + setReceivedError(null); + try { + const res = await fetch("/mobile/api/flh/hibah/received", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error("Failed to fetch received hibah"); + const data = await res.json(); + setReceived(Array.isArray(data) ? data : data.received ?? []); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Something went wrong"; + setReceivedError(msg); + } finally { + setReceivedLoading(false); + } + }; + + const handleSend = async (e: FormEvent) => { + e.preventDefault(); + const amount = parseFloat(sendAmount); + if (isNaN(amount) || amount <= 0) { + setSendMessageState({ + type: "error", + text: "Please enter a valid amount", + }); + return; + } + if (!toEmail.trim()) { + setSendMessageState({ + type: "error", + text: "Please enter recipient email", + }); + return; + } + setSendLoading(true); + setSendMessageState(null); + try { + const res = await fetch("/mobile/api/flh/hibah/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + toEmail: toEmail.trim(), + amount, + message: sendMessage.trim() || undefined, + }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to send hibah"); + } + setSendMessageState({ + type: "success", + text: `Hibah of ${amount} FLH sent to ${toEmail}!`, + }); + setToEmail(""); + setSendAmount(""); + setSendMessage(""); + fetchSent(); + setTimeout(() => setSendMessageState(null), 4000); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to send"; + setSendMessageState({ type: "error", text: msg }); + setTimeout(() => setSendMessageState(null), 4000); + } finally { + setSendLoading(false); + } + }; + + const handleAccept = async (hibahId: string) => { + setActionLoadingId(hibahId); + try { + const res = await fetch("/mobile/api/flh/hibah/accept", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ hibahId }), + }); + if (!res.ok) throw new Error("Failed to accept"); + fetchReceived(); + } catch { + // ignore + } finally { + setActionLoadingId(null); + } + }; + + const handleReject = async (hibahId: string) => { + setActionLoadingId(hibahId); + try { + const res = await fetch(`/mobile/api/flh/hibah/reject/${hibahId}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error("Failed to reject"); + fetchReceived(); + } catch { + // ignore + } finally { + setActionLoadingId(null); + } + }; + + if (authLoading) { + return ( +
+
+
+ ); + } + + if (!user) return null; + + const statusIcon = (status: string) => { + switch (status) { + case "accepted": + return ; + case "rejected": + return ; + default: + return ; + } + }; + + const statusColor = (status: string) => { + switch (status) { + case "accepted": + return "text-emerald-400"; + case "rejected": + return "text-red-400"; + default: + return "text-amber-400"; + } + }; + + return ( +
+ {/* Header */} +
+ + + +
+

Hibah

+

Give gifts to loved ones

+
+
+ +
+ {/* a) Send Hibah */} +
+

+ + Send Hibah +

+
+
+ + setToEmail(e.target.value)} + placeholder="friend@example.com" + className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-rose-800/50 focus:ring-1 focus:ring-rose-800/30 transition" + /> +
+
+ + setSendAmount(e.target.value)} + placeholder="Enter amount" + className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-rose-800/50 focus:ring-1 focus:ring-rose-800/30 transition" + /> +
+
+ +