pivot: migrate V1 mobile from Docker to Netlify serverless

- Switch Prisma schema from SQLite to PostgreSQL
- Add netlify.toml for Netlify deployment config
- Add @netlify/plugin-nextjs for serverless Next.js
- Remove Docker build files (Dockerfile, docker-compose, start.sh)
- Remove auto-healer and scripts directories
- Update next.config.ts (remove standalone output)
- Database: falah_mobile_v1 on Contabo Postgres
This commit is contained in:
2026-06-28 23:02:48 +02:00
parent 5b08f8b041
commit a1c5fecd83
117 changed files with 8619 additions and 2837 deletions
+1
View File
@@ -2,3 +2,4 @@
prisma/dev.db*
prisma/*.db-wal
prisma/*.db-shm
.env
+66
View File
@@ -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`
-54
View File
@@ -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"]
+228
View File
@@ -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** — 35 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
35 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}`).
-25
View File
@@ -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"]
-598
View File
@@ -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
-26
View File
@@ -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"
BIN
View File
Binary file not shown.
View File
-41
View File
@@ -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:
-36
View File
@@ -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:
+723
View File
@@ -0,0 +1,723 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Falah Mobile — Micro Learning Handover</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #07090C;
color: #E8E0D0;
font-family: 'Inter', -apple-system, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 24px;
}
.container {
max-width: 1200px;
width: 100%;
}
/* Header */
.header {
text-align: center;
margin-bottom: 48px;
position: relative;
}
.header::after {
content: '';
position: absolute;
bottom: -24px;
left: 50%;
transform: translateX(-50%);
width: 120px;
height: 2px;
background: linear-gradient(90deg, transparent, #C9A84C, transparent);
}
.badge {
display: inline-block;
padding: 4px 16px;
border-radius: 20px;
background: rgba(201, 168, 76, 0.1);
border: 1px solid rgba(201, 168, 76, 0.25);
color: #C9A84C;
font-size: 12px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-bottom: 16px;
}
.header h1 {
font-size: 42px;
font-weight: 800;
letter-spacing: -1px;
line-height: 1.15;
margin-bottom: 8px;
}
.header h1 span.gold { color: #C9A84C; }
.header h1 span.green { color: #00C48C; }
.header p {
color: #8A8478;
font-size: 16px;
max-width: 600px;
margin: 12px auto 0;
line-height: 1.6;
}
/* Repo Bar */
.repo-bar {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 20px;
padding: 12px 24px;
background: #0F1219;
border: 1px solid #1A1F2E;
border-radius: 12px;
font-size: 13px;
color: #8A8478;
}
.repo-bar .label {
color: #C9A84C;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
}
.repo-bar code {
font-family: 'JetBrains Mono', monospace;
color: #E8E0D0;
background: #1A1F2E;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.repo-bar .dot {
color: #1A1F2E;
}
/* Bento Grid */
.bento {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-top: 32px;
}
.card {
background: #0F1219;
border: 1px solid #1A1F2E;
border-radius: 16px;
padding: 24px;
position: relative;
overflow: hidden;
transition: border-color 0.2s, transform 0.15s;
}
.card:hover {
border-color: rgba(201, 168, 76, 0.3);
transform: translateY(-2px);
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 16px 16px 0 0;
}
.card.gold::before { background: linear-gradient(90deg, #C9A84C, #E8C84C); }
.card.green::before { background: linear-gradient(90deg, #00C48C, #00E8A0); }
.card.blue::before { background: linear-gradient(90deg, #4A90D9, #6BB5FF); }
.card.purple::before { background: linear-gradient(90deg, #9B59B6, #C084FC); }
.card.orange::before { background: linear-gradient(90deg, #E67E22, #F0A050); }
.card.teal::before { background: linear-gradient(90deg, #1ABC9C, #48D1B0); }
/* Hero card — spans 2 columns */
.card.hero {
grid-column: span 2;
background: linear-gradient(135deg, #0F1219 0%, #151A28 100%);
border-color: rgba(201, 168, 76, 0.15);
}
.card.hero::before { background: linear-gradient(90deg, #C9A84C, #00C48C, #4A90D9); }
/* Side card — right column */
.card.side {
grid-column: span 1;
}
/* Full width */
.card.full {
grid-column: span 3;
}
/* Double width */
.card.double {
grid-column: span 2;
}
.card-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-bottom: 16px;
}
.card-icon.gold { background: rgba(201, 168, 76, 0.12); }
.card-icon.green { background: rgba(0, 196, 140, 0.12); }
.card-icon.blue { background: rgba(74, 144, 217, 0.12); }
.card-icon.purple { background: rgba(155, 89, 182, 0.12); }
.card-icon.orange { background: rgba(230, 126, 34, 0.12); }
.card-icon.teal { background: rgba(26, 188, 156, 0.12); }
.card h2 {
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.card h3 {
font-size: 13px;
font-weight: 600;
color: #8A8478;
margin-bottom: 14px;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.card p {
font-size: 13px;
line-height: 1.6;
color: #A8A090;
}
.card ul {
list-style: none;
margin-top: 8px;
}
.card ul li {
font-size: 13px;
line-height: 1.5;
color: #A8A090;
padding: 6px 0;
border-bottom: 1px solid rgba(26, 31, 46, 0.6);
display: flex;
align-items: flex-start;
gap: 8px;
}
.card ul li:last-child { border-bottom: none; }
.card ul li .bullet {
color: #C9A84C;
flex-shrink: 0;
font-size: 14px;
line-height: 1.5;
}
.card ul li strong { color: #E8E0D0; font-weight: 600; }
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 8px;
}
.stat-item {
background: rgba(0,0,0,0.2);
border-radius: 10px;
padding: 12px 14px;
text-align: center;
}
.stat-number {
font-size: 28px;
font-weight: 800;
letter-spacing: -1px;
line-height: 1;
}
.stat-number.gold { color: #C9A84C; }
.stat-number.green { color: #00C48C; }
.stat-number.blue { color: #4A90D9; }
.stat-label {
font-size: 11px;
color: #8A8478;
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.tech-pill {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
margin: 2px 3px;
background: rgba(201, 168, 76, 0.08);
color: #C9A84C;
border: 1px solid rgba(201, 168, 76, 0.15);
}
.tech-pill.green {
background: rgba(0, 196, 140, 0.08);
color: #00C48C;
border-color: rgba(0, 196, 140, 0.15);
}
.tech-pill.blue {
background: rgba(74, 144, 217, 0.08);
color: #6BB5FF;
border-color: rgba(74, 144, 217, 0.15);
}
.tech-pill.purple {
background: rgba(155, 89, 182, 0.08);
color: #C084FC;
border-color: rgba(155, 89, 182, 0.15);
}
.tech-pill.orange {
background: rgba(230, 126, 34, 0.08);
color: #F0A050;
border-color: rgba(230, 126, 34, 0.15);
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.green { background: #00C48C; box-shadow: 0 0 8px rgba(0,196,140,0.4); }
.status-dot.yellow { background: #C9A84C; box-shadow: 0 0 8px rgba(201,168,76,0.4); }
.status-dot.red { background: #E74C3C; box-shadow: 0 0 8px rgba(231,76,60,0.4); }
.route-block {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
line-height: 1.8;
color: #6BB5FF;
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 12px 14px;
margin-top: 8px;
}
.route-block .comment { color: #5A5A5A; }
.route-block .method { color: #00C48C; }
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 8px;
}
.cmd-box {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 10px 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: #8A8478;
margin-top: 6px;
line-height: 1.8;
}
.cmd-box .prompt { color: #00C48C; }
.cmd-box .cmd { color: #E8E0D0; }
.issue-list li {
font-size: 12px;
padding: 7px 0 !important;
}
.issue-list li .issue-id {
color: #C9A84C;
font-weight: 600;
font-size: 11px;
}
.hero-content {
display: flex;
flex-direction: column;
gap: 14px;
}
.hero-content .row {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.hero-content .info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.hero-content .info-item .key {
color: #8A8478;
font-weight: 500;
}
.hero-content .info-item .val {
color: #E8E0D0;
font-weight: 600;
}
.hero-content .info-item .val.code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: #1A1F2E;
padding: 2px 8px;
border-radius: 4px;
}
.section-tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 700;
margin-bottom: 10px;
opacity: 0.7;
}
.progress-bar {
height: 4px;
background: #1A1F2E;
border-radius: 2px;
margin: 10px 0 4px;
overflow: hidden;
}
.progress-bar .fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, #C9A84C, #00C48C);
}
.progress-label {
font-size: 11px;
color: #8A8478;
display: flex;
justify-content: space-between;
}
.price-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(0, 196, 140, 0.1);
border: 1px solid rgba(0, 196, 140, 0.2);
border-radius: 6px;
font-size: 12px;
color: #00C48C;
font-weight: 600;
}
.price-badge.warn {
background: rgba(201, 168, 76, 0.08);
border-color: rgba(201, 168, 76, 0.2);
color: #C9A84C;
}
.price-badge.danger {
background: rgba(231, 76, 60, 0.08);
border-color: rgba(231, 76, 60, 0.2);
color: #E74C3C;
}
/* Footer */
.footer {
text-align: center;
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid #1A1F2E;
color: #5A5A5A;
font-size: 12px;
letter-spacing: 0.3px;
}
/* Responsive */
@media (max-width: 900px) {
.bento { grid-template-columns: 1fr 1fr; }
.card.hero { grid-column: span 2; }
.card.full { grid-column: span 2; }
.card.double { grid-column: span 1; }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.bento { grid-template-columns: 1fr; }
.card.hero, .card.full, .card.double { grid-column: span 1; }
.header h1 { font-size: 28px; }
body { padding: 20px 12px; }
.stat-grid { grid-template-columns: 1fr; }
.repo-bar { flex-direction: column; text-align: center; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="badge">📚 Micro Learning Platform</div>
<h1><span class="gold">Falah Mobile</span> <span style="color:#5A5A5A;font-weight:300;"></span> <span class="green">Handover</span></h1>
<p>Complete E2E system for browsing, purchasing, consuming, and certifying bite-sized (5 min) courses derived from books.</p>
<div class="repo-bar">
<span class="label">Repository</span>
<code>git.falahos.my/wmj/falah-mobile.git</code>
<span class="dot">·</span>
<span class="label">Source</span>
<code>/root/falah-mobile</code>
<span class="dot">·</span>
<span class="label">Branch</span>
<code>main</code>
<span class="dot">·</span>
<span class="label">Commit</span>
<code>bfd3509</code>
<span class="dot">·</span>
<span class="label">GitHub</span>
<code>maifors/falah-mobile</code>
</div>
</div>
<!-- Bento Grid -->
<div class="bento">
<!-- Hero Card: Overview -->
<div class="card hero">
<div class="hero-content">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:4px;">
<div class="card-icon gold" style="margin-bottom:0;">📊</div>
<div>
<h2 style="font-size:20px;">Phase 1 Complete — Live in Production</h2>
<p style="color:#8A8478;font-size:13px;">Production: <a href="https://falahos.my/mobile" style="color:#00C48C;text-decoration:none;">falahos.my/mobile</a> &nbsp;·&nbsp; Staging: <a href="https://falahos.my:4014/mobile" style="color:#C9A84C;text-decoration:none;">falahos.my:4014/mobile</a></p>
</div>
</div>
<div class="row">
<div class="info-item"><span class="key">Deployment</span> <span class="val code">Docker + Traefik</span></div>
<div class="info-item"><span class="key">Host</span> <span class="val code">Contabo VPS (Ubuntu 24.04)</span></div>
<div class="info-item"><span class="key">Auth</span> <span class="val code">Ummah ID (JWT)</span></div>
<div class="info-item"><span class="key">Payments</span> <span class="val code">Polar.sh</span></div>
</div>
</div>
</div>
<!-- Cell: ✅ Complete — Courses -->
<div class="card gold">
<div class="card-icon gold"></div>
<h2>Courses Built</h2>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-number gold">4</div>
<div class="stat-label">Courses Live</div>
</div>
<div class="stat-item">
<div class="stat-number green">21</div>
<div class="stat-label">Modules</div>
</div>
<div class="stat-item">
<div class="stat-number blue">6</div>
<div class="stat-label">Categories</div>
</div>
<div class="stat-item">
<div class="stat-number" style="color:#F0A050;">35</div>
<div class="stat-label">Courses Left</div>
</div>
</div>
<div style="margin-top:12px;">
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<span class="tech-pill">Atomic Habits</span>
<span class="tech-pill green">Defining Decade</span>
<span class="tech-pill blue">Simplicity Parenting</span>
<span class="tech-pill orange">Tao Te Ching</span>
</div>
<div class="progress-bar">
<div class="fill" style="width:10%"></div>
</div>
<div class="progress-label">
<span>Library completion</span>
<span>4 / 39 courses (10%)</span>
</div>
</div>
<p style="margin-top:10px;font-size:12px;">All 4 in <strong>Self Improvement</strong>. Quran, Hadith, Prayer, History, Finance categories empty — ready for content.</p>
</div>
<!-- Cell: ✅ Complete — Features -->
<div class="card green">
<div class="card-icon green">🎓</div>
<h2>Delivery Features</h2>
<ul>
<li><span class="bullet"></span> <strong>Course catalog</strong> — search bar + category filter tabs</li>
<li><span class="bullet"></span> <strong>Module reader</strong> — markdown content + progress bar + quiz badges</li>
<li><span class="bullet"></span> <strong>TTS audio</strong> — Web Speech API, Google UK English Female (preferred)</li>
<li><span class="bullet"></span> <strong>Markdown stripping</strong> — 11 patterns clean text for TTS</li>
<li><span class="bullet"></span> <strong>Quiz system</strong> — 35 MCQs per module, submit/score/retry</li>
<li><span class="bullet"></span> <strong>Certificate issuance</strong> — auto-issued on 100% completion, PDF + serial</li>
</ul>
</div>
<!-- Cell: 💳 Payments -->
<div class="card purple">
<div class="card-icon purple">💳</div>
<h2>Payments &amp; Subscription</h2>
<ul>
<li>
<span class="bullet"></span>
<span class="price-badge">$9.99/mo</span> <strong>Learn Pass Monthly</span></strong> <span style="color:#00C48C;">✅ Live</span>
</li>
<li>
<span class="bullet"></span>
<span class="price-badge">$79.99/yr</span> <strong>Learn Pass Annual</span></strong> <span style="color:#00C48C;">✅ Live</span>
</li>
<li>
<span class="bullet"></span>
<span class="price-badge danger">$4.99 each</span> <strong>One-time course purchases</strong> <span style="color:#E74C3C;">❌ Not mapped</span>
</li>
<li style="font-size:12px;color:#C9A84C;padding-top:8px;">
<strong>Action:</strong> Add Polar price IDs for 4 new courses to <code style="font-size:11px;">checkout/route.ts</code> PRODUCTS map
</li>
</ul>
</div>
<!-- Cell: 📋 Pending -->
<div class="card orange">
<div class="card-icon orange">📋</div>
<h2>Priority Backlog</h2>
<ul>
<li><span class="bullet"></span> <strong>35 more courses</strong> — build &amp; seed across 6 categories</li>
<li><span class="bullet"></span> <strong>Checkout mapping</strong> — Polar price IDs for each new course</li>
<li><span class="bullet"></span> <strong>Course thumbnails</strong> — replace emoji/gradient with real cover art</li>
<li><span class="bullet"></span> <strong>Gitea sync</strong> — test course sync script</li>
<li><span class="bullet"></span> <strong>Server-side TTS</strong> — Azure / ElevenLabs for higher quality</li>
<li><span class="bullet"></span> <strong>Admin dashboard</strong> — course management UI</li>
<li><span class="bullet"></span> <strong>Analytics</strong> — completion rates, quiz scores</li>
</ul>
</div>
<!-- Cell: 📦 Tech Stack (double width) -->
<div class="card blue double">
<div class="card-icon blue">📦</div>
<h2>Tech Stack</h2>
<div style="margin-top:8px;">
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px;">
<span class="tech-pill green">Next.js 16</span>
<span class="tech-pill">TypeScript</span>
<span class="tech-pill blue">SQLite</span>
<span class="tech-pill purple">Prisma v5.22</span>
<span class="tech-pill orange">Tailwind CSS</span>
<span class="tech-pill green">Polar.sh</span>
<span class="tech-pill blue">Docker</span>
<span class="tech-pill">Traefik</span>
<span class="tech-pill green">Web Speech API</span>
<span class="tech-pill">Gitea</span>
</div>
<div class="two-col">
<div>
<p style="color:#8A8478;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Database</p>
<p style="font-size:12px;">SQLite (<code style="font-family:'JetBrains Mono',monospace;font-size:11px;">/app/data/dev.db</code>) via Prisma ORM</p>
<p style="font-size:12px;margin-top:6px;">Schema: <code style="font-family:'JetBrains Mono',monospace;font-size:11px;color:#C9A84C;">prisma/schema.prisma</code></p>
</div>
<div>
<p style="color:#8A8478;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Deployment</p>
<p style="font-size:12px;">Source → <code style="font-family:'JetBrains Mono',monospace;font-size:11px;">npm run build</code> → Docker → Traefik reverse proxy</p>
<p style="font-size:12px;margin-top:6px;">Host: Contabo VPS</p>
</div>
</div>
</div>
</div>
<!-- Cell: 🏗 Architecture (full width) -->
<div class="card full">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<div class="card-icon teal" style="margin-bottom:0;">🏗</div>
<h2>Route Architecture</h2>
</div>
<div class="route-block">
<span class="comment"># Next.js App Router — basePath: "/mobile"</span><br>
<span class="method">GET</span> /mobile/learn <span class="comment">← Course catalog (search + category tabs)</span><br>
<span class="method">GET</span> /mobile/learn/<span style="color:#E8E0D0;">[slug]</span> <span class="comment">← Course detail + module timeline</span><br>
<span class="method">GET</span> /mobile/learn/<span style="color:#E8E0D0;">[slug]/[moduleId]</span> <span class="comment">← Module reader + TTS + Quiz</span><br>
<span class="method">GET</span> /mobile/api/learn/courses <span class="comment">← ?category=&search=</span><br>
<span class="method">GET</span> /mobile/api/learn/courses/<span style="color:#E8E0D0;">[slug]</span> <span class="comment">← Course detail API</span><br>
<span class="method">GET</span> /mobile/api/learn/categories <span class="comment">← List categories</span><br>
<span class="method">POST</span> /mobile/api/learn/checkout <span class="comment">← Polar.sh checkout</span><br>
<span class="method">POST</span> /mobile/api/learn/progress <span class="comment">← Mark module complete + auto-certificate</span><br>
<span class="method">GET</span> /mobile/api/learn/certificates <span class="comment">← User certificates</span><br>
<span class="method">POST</span> /mobile/api/learn/sync <span class="comment">← Gitea sync</span>
</div>
<div style="margin-top:16px;">
<p style="color:#8A8478;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">Key Source Files (26 files)</p>
<div style="display:flex;flex-wrap:wrap;gap:4px;">
<span class="tech-pill" style="font-family:'JetBrains Mono',monospace;">prisma/schema.prisma</span>
<span class="tech-pill green" style="font-family:'JetBrains Mono',monospace;">learn/page.tsx</span>
<span class="tech-pill blue" style="font-family:'JetBrains Mono',monospace;">[slug]/page.tsx</span>
<span class="tech-pill orange" style="font-family:'JetBrains Mono',monospace;">checkout/route.ts</span>
<span class="tech-pill" style="font-family:'JetBrains Mono',monospace;">progress/route.ts</span>
<span class="tech-pill green" style="font-family:'JetBrains Mono',monospace;">courses/route.ts</span>
<span class="tech-pill purple" style="font-family:'JetBrains Mono',monospace;">lib/learn.ts</span>
<span class="tech-pill" style="font-family:'JetBrains Mono',monospace;">Dockerfile</span>
<span class="tech-pill blue" style="font-family:'JetBrains Mono',monospace;">LearnPassCard.tsx</span>
</div>
</div>
</div>
<!-- Cell: ⚙️ Commands -->
<div class="card gold">
<div class="card-icon gold">⚙️</div>
<h2>Common Commands</h2>
<div class="cmd-box">
<span class="prompt">$</span> <span class="cmd">cd /root/falah-mobile</span><br>
<span class="prompt">$</span> <span class="cmd">npm run build</span><br>
<span class="prompt">$</span> <span class="cmd">docker build -t falah-mobile:latest .</span><br>
<span class="prompt">$</span> <span class="cmd">docker compose up -d --no-deps falah-mobile</span><br>
<span class="prompt">$</span> <span class="cmd">npx prisma db push</span>
</div>
<p style="margin-top:10px;font-size:12px;">DB ops via <code style="font-size:11px;">docker cp</code> + <code style="font-size:11px;">sqlite3</code> for quick data fixes</p>
</div>
<!-- Cell: 🐛 Known Issues -->
<div class="card double" style="border-color:rgba(231,76,60,0.2);">
<div class="card-icon orange" style="background:rgba(231,76,60,0.12);">🐛</div>
<h2 style="color:#E74C3C;">Known Issues</h2>
<ul class="issue-list">
<li><span class="issue-id">#1</span> <span class="bullet">🟡</span> <strong>Health check slow</strong> — 30s+ but API works immediately</li>
<li><span class="issue-id">#2</span> <span class="bullet">🟡</span> <strong>.env permission error</strong><code style="font-size:11px;">EACCES</code> in logs (env still loads correctly)</li>
<li><span class="issue-id">#3</span> <span class="bullet">🔴</span> <strong>Checkout missing courses</strong> — Only 3 old courses in PRODUCTS map</li>
<li><span class="issue-id">#4</span> <span class="bullet">🟡</span> <strong>Staging DB sharing</strong> — prod & staging share same volume</li>
<li><span class="issue-id">#5</span> <span class="bullet">🟢</span> <strong>Missing thumbnails</strong> — emoji/gradient placeholders</li>
<li><span class="issue-id">#6</span> <span class="bullet">🟢</span> <strong>Course card navigation</strong><code style="font-size:11px;">stopPropagation</code> edge case</li>
</ul>
<div style="margin-top:10px;display:flex;gap:12px;font-size:12px;">
<span><span class="status-dot green"></span> Low</span>
<span><span class="status-dot yellow"></span> Medium</span>
<span><span class="status-dot red"></span> High</span>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
FalahOS · Generated 2026 · falahos.my/mobile · <span style="color:#C9A84C;">Next: Build 35 more courses →</span>
</div>
</div>
</body>
</html>
+32
View File
@@ -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"
-2
View File
@@ -1,9 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
basePath: "/mobile",
assetPrefix: "/mobile",
typescript: {
ignoreBuildErrors: true,
},
+148 -115
View File
@@ -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"
+3 -2
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -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?
+124
View File
@@ -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
```
+120
View File
@@ -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
```
+183
View File
@@ -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
```
+30
View File
@@ -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
```
+25
View File
@@ -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
```
+34
View File
@@ -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
```
+183
View File
@@ -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
```
+170
View File
@@ -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
```
+183
View File
@@ -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
```
+183
View File
@@ -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
```
+183
View File
@@ -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
```
+25
View File
@@ -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
```
+25
View File
@@ -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
```
+67
View File
@@ -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
```
+25
View File
@@ -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
```
+65
View File
@@ -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
```
+48
View File
@@ -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
```
+25
View File
@@ -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
```
+48
View File
@@ -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
```
+25
View File
@@ -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
```
+48
View File
@@ -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
```
+26
View File
@@ -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
```
+48
View File
@@ -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
```
+26
View File
@@ -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
```
+48
View File
@@ -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
```
+25
View File
@@ -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
```
+67
View File
@@ -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
```
+19
View File
@@ -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
```
+43
View File
@@ -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
```
+19
View File
@@ -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
```
+43
View File
@@ -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
```
-162
View File
@@ -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 "$@"
-132
View File
@@ -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();
-156
View File
@@ -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."
-328
View File
@@ -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 <html>", "<html" in r["text"])
check("Learn page: has <body>", "<body" in r["text"])
# "undefined" in script content is normal for Next.js bundles — skip
# "This page could not be found" in Next.js shell title is normal before hydration
# Check the actual HTTP response code instead
check("Learn page: HTTP 200", r["status"] == 200, f"Got {r['status']}")
# Health page should be simple response
r = req("/api/health")
check("Health: no error in body", "error" not in r["text"].lower() or "error" not in r["text"])
# Verify page renders properly
r = req("/verify/TEST-12345")
check("Verify page has <html>", "<html" in r["text"])
check("Verify page: HTTP 200", r["status"] == 200, f"Got {r['status']}")
# ════════════════════════════════════════
# LAYER 4: SCENARIO — User Journeys
# ════════════════════════════════════════
section("LAYER 4: SCENARIO — User Journeys")
# 4a. Unauthenticated user browses courses
r = req("/learn")
check("4a: Browse courses page loads", r["status"] == 200)
check("4a: Has 'Falah' or 'Learn' in page", "Falah" in r["text"] or "Learn" in r["text"] or "learn" in r["text"])
# 4b. Authenticated user fetches course detail
# Attempt login — may fail in dev env without seeded test user, that's OK
try:
login_data = json.dumps({"email": "demo@falah.com", "password": "demo123"}).encode()
login_req = urllib.request.Request(
f"{BASE_URL}/mobile/api/auth/login",
data=login_data,
headers={"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"},
method="POST",
)
login_resp = urllib.request.urlopen(login_req, timeout=10, context=ctx)
login_text = login_resp.read().decode()
login_json = json.loads(login_text)
token = login_json.get("token", "")
check("4b: Auth login works", bool(token), "No token returned")
auth_available = bool(token)
except Exception as e:
check("4b: Auth login (expected in dev)", True) # Dev env may not have test user
token = ""
auth_available = False
if auth_available and token:
# Fetch course detail with auth
r = req(f"/api/learn/courses/atomic-habits", headers={"Authorization": f"Bearer {token}"}, expect_json=True)
data = json_body(r)
check("4c: Course detail with auth returns 200", r["status"] == 200, f"Got {r['status']}")
if data and r["status"] == 200:
course = data.get("course", {})
modules = data.get("modules", [])
check("4c: Has course object", bool(course))
check("4c: Has modules array", isinstance(modules, list))
check("4c: Course title matches Atomic Habits", course.get("title") == "Atomic Habits",
f"Got: {course.get('title')}")
if modules:
check("4c: Module has title", bool(modules[0].get("title")))
check("4c: Module has content", bool(modules[0].get("content")))
check("4c: Module has quizData", bool(modules[0].get("quizData")))
# Parse quiz
try:
quiz = json.loads(modules[0]["quizData"]) if isinstance(modules[0]["quizData"], str) else modules[0]["quizData"]
check("4c: Quiz is valid JSON/array", isinstance(quiz, list))
if isinstance(quiz, list) and len(quiz) > 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)
-296
View File
@@ -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();
});
-640
View File
@@ -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();
});
-87
View File
@@ -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"
-111
View File
@@ -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 "══════════════════════════════════════════════════════"
+305
View File
@@ -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<!-- Full lesson content to be added in future updates -->`,
},
});
}
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());
+1
View File
@@ -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,
},
});
+1
View File
@@ -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,
+1
View File
@@ -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,
},
});
+34
View File
@@ -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 });
}
}
+10
View File
@@ -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 }); }
}
@@ -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 });
}
}
+11
View File
@@ -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 }); }
}
+10
View File
@@ -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 }); }
}
+20
View File
@@ -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 });
}
}
+14
View File
@@ -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 });
}
}
+20
View File
@@ -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 });
}
}
+14
View File
@@ -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 });
}
}
+23
View File
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
+20
View File
@@ -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 });
}
}
+11
View File
@@ -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 }); }
}
+11
View File
@@ -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 }); }
}
@@ -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 }); }
}
+8
View File
@@ -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 }); }
}
+24
View File
@@ -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 }
);
}
}
+53
View File
@@ -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 }
);
}
}
+25
View File
@@ -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 }
);
}
}
+31
View File
@@ -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 }
);
}
}
+14
View File
@@ -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 });
}
}
+25
View File
@@ -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 }
);
}
}
+1 -1
View File
@@ -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)
+65
View File
@@ -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 }
);
}
}
+5 -5
View File
@@ -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) {
+1 -1
View File
@@ -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!" },
],
},
@@ -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 }
);
}
}
+182
View File
@@ -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<string, unknown> = {};
// 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<string, number> = {};
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<string, unknown>[];
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 }
);
}
}
@@ -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<string, unknown> = {};
// 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<string, string[]> = {
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<Record<string, unknown>>)
: [];
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 }
);
}
}
+213
View File
@@ -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<string, unknown> = {};
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 }
);
}
}
+113
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+11 -3
View File
@@ -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);
+45
View File
@@ -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 });
}
}
+7
View File
@@ -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)");
}
+502
View File
@@ -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<SentHibah[]>([]);
const [sentLoading, setSentLoading] = useState(true);
const [sentError, setSentError] = useState<string | null>(null);
// Received
const [received, setReceived] = useState<ReceivedHibah[]>([]);
const [receivedLoading, setReceivedLoading] = useState(true);
const [receivedError, setReceivedError] = useState<string | null>(null);
// Accept/Reject
const [actionLoadingId, setActionLoadingId] = useState<string | null>(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 (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
);
}
if (!user) return null;
const statusIcon = (status: string) => {
switch (status) {
case "accepted":
return <Check size={14} className="text-emerald-400" />;
case "rejected":
return <X size={14} className="text-red-400" />;
default:
return <Clock size={14} className="text-amber-400" />;
}
};
const statusColor = (status: string) => {
switch (status) {
case "accepted":
return "text-emerald-400";
case "rejected":
return "text-red-400";
default:
return "text-amber-400";
}
};
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
<Link
href="/flh"
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ArrowLeft size={16} className="text-gray-500" />
</Link>
<div>
<h1 className="text-xl font-bold text-white">Hibah</h1>
<p className="text-xs text-gray-500">Give gifts to loved ones</p>
</div>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* a) Send Hibah */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Send size={16} className="text-rose-400" />
Send Hibah
</h2>
<form onSubmit={handleSend} className="space-y-3">
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Recipient Email
</label>
<input
type="email"
value={toEmail}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Amount (FLH)
</label>
<input
type="number"
min={1}
step={1}
value={sendAmount}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Message (optional)
</label>
<textarea
value={sendMessage}
onChange={(e) => setSendMessage(e.target.value)}
placeholder="A kind note..."
rows={2}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-rose-800/50 focus:ring-1 focus:ring-rose-800/30 transition resize-none"
/>
</div>
<button
type="submit"
disabled={sendLoading}
className="w-full px-4 py-4 rounded-xl bg-rose-600 text-white font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center justify-center gap-2"
>
{sendLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Heart size={16} />
Send Hibah
</>
)}
</button>
</form>
{sendMessageState && sendMessageState.type === "success" && (
<div className="mt-3 flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{sendMessageState.text}
</div>
)}
{sendMessageState && sendMessageState.type === "error" && (
<ErrorFeedback
error={sendMessageState.text}
kind="default"
onRetry={() => setSendMessageState(null)}
context="flh"
/>
)}
</div>
{/* b) Sent Gifts */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<ArrowUpRight size={16} className="text-gray-500" />
Sent Gifts
</h2>
{sentLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : sentError ? (
<ErrorFeedback
error={sentError}
kind="default"
onRetry={fetchSent}
context="flh"
/>
) : sent.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<ArrowUpRight size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No gifts sent yet</p>
<p className="text-xs text-gray-700 mt-1">
Your outgoing hibah will appear here
</p>
</div>
) : (
<div className="space-y-2">
{sent.map((gift) => (
<div
key={gift.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-rose-900/20 flex items-center justify-center">
<ArrowUpRight size={14} className="text-rose-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
{gift.amount.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500">
To: {gift.toEmail ?? gift.toUserId ?? "Unknown"} {" "}
{new Date(gift.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{statusIcon(gift.status)}
<span
className={`text-xs font-medium capitalize ${statusColor(gift.status)}`}
>
{gift.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* c) Received Gifts */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<ArrowDownLeft size={16} className="text-gray-500" />
Received Gifts
</h2>
{receivedLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : receivedError ? (
<ErrorFeedback
error={receivedError}
kind="default"
onRetry={fetchReceived}
context="flh"
/>
) : received.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<ArrowDownLeft
size={20}
className="mx-auto text-gray-700 mb-2"
/>
<p className="text-sm text-gray-500">No gifts received yet</p>
<p className="text-xs text-gray-700 mt-1">
When someone sends you a hibah, it will appear here
</p>
</div>
) : (
<div className="space-y-2">
{received.map((gift) => (
<div
key={gift.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-900/20 flex items-center justify-center">
<ArrowDownLeft
size={14}
className="text-emerald-400"
/>
</div>
<div>
<p className="text-sm font-medium text-white">
{gift.amount.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500">
From: {gift.fromName ?? gift.fromEmail ?? "Unknown"}{" "}
{" "}
{new Date(gift.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{statusIcon(gift.status)}
<span
className={`text-xs font-medium capitalize ${statusColor(gift.status)}`}
>
{gift.status}
</span>
</div>
</div>
{gift.message && (
<p className="text-xs text-gray-500 ml-11 mb-2 italic">
&ldquo;{gift.message}&rdquo;
</p>
)}
{gift.status === "pending" && (
<div className="flex gap-2 ml-11">
<button
onClick={() => handleAccept(gift.id)}
disabled={actionLoadingId === gift.id}
className="flex-1 px-3 py-2 rounded-lg bg-emerald-600 text-white text-xs font-medium active:scale-95 transition disabled:opacity-50 flex items-center justify-center gap-1"
>
{actionLoadingId === gift.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<>
<Check size={12} />
Accept
</>
)}
</button>
<button
onClick={() => handleReject(gift.id)}
disabled={actionLoadingId === gift.id}
className="flex-1 px-3 py-2 rounded-lg bg-red-900/20 border border-red-800/30 text-red-400 text-xs font-medium active:scale-95 transition disabled:opacity-50 flex items-center justify-center gap-1"
>
{actionLoadingId === gift.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<>
<X size={12} />
Reject
</>
)}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
<div className="h-4" />
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More