From a1c5fecd838ef799e1c04e9b43d29ad9515e11c0 Mon Sep 17 00:00:00 2001 From: WMJ Ismail Date: Sun, 28 Jun 2026 23:02:48 +0200 Subject: [PATCH] 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 --- .dockerignore | 1 + .hermes/plans/souq-native-embed.md | 66 ++ Dockerfile | 54 -- HANDOVER.md | 228 ++++++ auto-healer/Dockerfile | 25 - auto-healer/auto-heal-agent.sh | 598 --------------- auto-healer/docker-compose.healer.yml | 26 - data/dev.db | Bin 0 -> 282624 bytes dev.db | 0 docker-compose.staging.yml | 41 - docker-compose.yml | 36 - handover-infographic.html | 723 ++++++++++++++++++ netlify.toml | 32 + next.config.ts | 2 - package-lock.json | 263 ++++--- package.json | 5 +- prisma/dev.db | Bin 0 -> 421888 bytes prisma/dev.db.bak | Bin 0 -> 442368 bytes prisma/schema.prisma | 3 +- qa-prod-report-20260624_063144.md | 124 +++ qa-prod-report-20260624_063317.md | 120 +++ qa-report-20260618_161010.md | 183 +++++ qa-report-20260618_162540.md | 30 + qa-report-20260618_162541.md | 25 + qa-report-20260618_162545.md | 34 + qa-report-20260618_165415.md | 183 +++++ qa-report-20260618_173318.md | 170 ++++ qa-report-20260618_193920.md | 183 +++++ qa-report-20260619_001148.md | 183 +++++ qa-report-20260624_132621.md | 183 +++++ qa-report-20260625_155601.md | 25 + qa-report-20260625_155706.md | 25 + qa-report-20260625_155732.md | 67 ++ qa-report-20260625_160149.md | 25 + qa-report-20260625_170813.md | 65 ++ qa-report-20260625_170914.md | 48 ++ qa-report-20260625_173329.md | 25 + qa-report-20260625_173359.md | 48 ++ qa-report-20260625_180303.md | 25 + qa-report-20260625_180329.md | 48 ++ qa-report-20260625_181854.md | 26 + qa-report-20260625_181922.md | 48 ++ qa-report-20260626_070054.md | 26 + qa-report-20260626_070109.md | 48 ++ qa-report-20260627_070045.md | 25 + qa-report-20260627_070058.md | 67 ++ qa-report-20260628_070015.md | 19 + qa-report-20260628_070023.md | 43 ++ qa-report-20260628_080046.md | 19 + qa-report-20260628_080055.md | 43 ++ scripts/auto-heal.sh | 162 ---- scripts/generate-tts-local.js | 132 ---- scripts/init-gitea-courses.sh | 156 ---- scripts/qa-learn.py | 328 -------- scripts/seed-courses.ts | 296 ------- scripts/seed-souq.ts | 640 ---------------- scripts/setup-sso.sh | 87 --- scripts/watchdog.sh | 111 --- seed-data.mjs | 305 ++++++++ src/app/api/auth/login/route.ts | 1 + src/app/api/auth/me/route.ts | 1 + src/app/api/auth/register/route.ts | 1 + src/app/api/flh/hibah/accept/route.ts | 34 + src/app/api/flh/hibah/received/route.ts | 10 + src/app/api/flh/hibah/reject/[id]/route.ts | 28 + src/app/api/flh/hibah/send/route.ts | 11 + src/app/api/flh/hibah/sent/route.ts | 10 + src/app/api/flh/hibah/summary/route.ts | 20 + src/app/api/flh/sadaqah/causes/route.ts | 14 + src/app/api/flh/sadaqah/history/route.ts | 20 + src/app/api/flh/sadaqah/pool/route.ts | 14 + src/app/api/flh/sadaqah/send/route.ts | 23 + src/app/api/flh/sadaqah/subscribe/route.ts | 23 + .../api/flh/sadaqah/subscriptions/route.ts | 20 + src/app/api/flh/sadaqah/unsubscribe/route.ts | 23 + src/app/api/flh/summary/route.ts | 20 + src/app/api/flh/waqf/contribute/route.ts | 11 + src/app/api/flh/waqf/create/route.ts | 11 + .../api/flh/waqf/my-contributions/route.ts | 10 + src/app/api/flh/waqf/pools/route.ts | 8 + src/app/api/flh/zakat/agents/route.ts | 24 + src/app/api/flh/zakat/calculate/route.ts | 53 ++ src/app/api/flh/zakat/history/route.ts | 25 + src/app/api/flh/zakat/pay/route.ts | 31 + src/app/api/flh/zakat/pool/route.ts | 14 + src/app/api/flh/zakat/status/route.ts | 25 + src/app/api/halal/places/route.ts | 2 +- src/app/api/health/db/route.ts | 65 ++ src/app/api/learn/progress/route.ts | 10 +- src/app/api/seed/route.ts | 2 +- .../api/souq/listings/[id]/route.ts.backup | 56 ++ src/app/api/souq/listings/route.ts.backup | 182 +++++ src/app/api/souq/orders/[id]/route.ts.backup | 250 ++++++ src/app/api/souq/orders/route.ts.backup | 213 ++++++ src/app/api/souq/reviews/route.ts.backup | 113 +++ src/app/api/souq/sellers/[id]/route.ts.backup | 84 ++ src/app/api/wallet/route.ts | 14 +- src/app/api/wallet/ton-address/route.ts | 45 ++ src/app/api/webhooks/polar/route.ts | 7 + src/app/flh/hibah/page.tsx | 502 ++++++++++++ src/app/flh/page.tsx | 225 ++++++ src/app/flh/sadaqah/page.tsx | 548 +++++++++++++ src/app/flh/waqf/page.tsx | 383 ++++++++++ src/app/flh/zakat/page.tsx | 484 ++++++++++++ src/app/learn/[slug]/[moduleId]/page.tsx | 2 +- src/app/manifest.json/route.ts | 67 ++ src/app/qibla/page.tsx | 5 +- src/components/BottomNav.tsx | 13 +- src/components/LearnPassCard.tsx | 304 ++++++++ src/components/PWARegister.tsx | 4 +- src/lib/ummahid-client.ts | 579 ++++++++++++++ src/types/ios-orientation.d.ts | 4 + src/types/pdfkit.d.ts | 1 + start.sh | 8 - .../__pycache__/qa-learn-prod.cpython-312.pyc | Bin 0 -> 37722 bytes tests/falah-broken-link-checker.py | 294 +++++++ tests/qa-test-suite.sh | 2 +- 117 files changed, 8619 insertions(+), 2837 deletions(-) create mode 100644 .hermes/plans/souq-native-embed.md delete mode 100644 Dockerfile create mode 100644 HANDOVER.md delete mode 100644 auto-healer/Dockerfile delete mode 100644 auto-healer/auto-heal-agent.sh delete mode 100644 auto-healer/docker-compose.healer.yml create mode 100644 data/dev.db create mode 100644 dev.db delete mode 100644 docker-compose.staging.yml delete mode 100644 docker-compose.yml create mode 100644 handover-infographic.html create mode 100644 netlify.toml create mode 100644 prisma/dev.db create mode 100644 prisma/dev.db.bak create mode 100644 qa-prod-report-20260624_063144.md create mode 100644 qa-prod-report-20260624_063317.md create mode 100644 qa-report-20260618_161010.md create mode 100644 qa-report-20260618_162540.md create mode 100644 qa-report-20260618_162541.md create mode 100644 qa-report-20260618_162545.md create mode 100644 qa-report-20260618_165415.md create mode 100644 qa-report-20260618_173318.md create mode 100644 qa-report-20260618_193920.md create mode 100644 qa-report-20260619_001148.md create mode 100644 qa-report-20260624_132621.md create mode 100644 qa-report-20260625_155601.md create mode 100644 qa-report-20260625_155706.md create mode 100644 qa-report-20260625_155732.md create mode 100644 qa-report-20260625_160149.md create mode 100644 qa-report-20260625_170813.md create mode 100644 qa-report-20260625_170914.md create mode 100644 qa-report-20260625_173329.md create mode 100644 qa-report-20260625_173359.md create mode 100644 qa-report-20260625_180303.md create mode 100644 qa-report-20260625_180329.md create mode 100644 qa-report-20260625_181854.md create mode 100644 qa-report-20260625_181922.md create mode 100644 qa-report-20260626_070054.md create mode 100644 qa-report-20260626_070109.md create mode 100644 qa-report-20260627_070045.md create mode 100644 qa-report-20260627_070058.md create mode 100644 qa-report-20260628_070015.md create mode 100644 qa-report-20260628_070023.md create mode 100644 qa-report-20260628_080046.md create mode 100644 qa-report-20260628_080055.md delete mode 100644 scripts/auto-heal.sh delete mode 100644 scripts/generate-tts-local.js delete mode 100644 scripts/init-gitea-courses.sh delete mode 100644 scripts/qa-learn.py delete mode 100644 scripts/seed-courses.ts delete mode 100644 scripts/seed-souq.ts delete mode 100644 scripts/setup-sso.sh delete mode 100644 scripts/watchdog.sh create mode 100644 seed-data.mjs create mode 100644 src/app/api/flh/hibah/accept/route.ts create mode 100644 src/app/api/flh/hibah/received/route.ts create mode 100644 src/app/api/flh/hibah/reject/[id]/route.ts create mode 100644 src/app/api/flh/hibah/send/route.ts create mode 100644 src/app/api/flh/hibah/sent/route.ts create mode 100644 src/app/api/flh/hibah/summary/route.ts create mode 100644 src/app/api/flh/sadaqah/causes/route.ts create mode 100644 src/app/api/flh/sadaqah/history/route.ts create mode 100644 src/app/api/flh/sadaqah/pool/route.ts create mode 100644 src/app/api/flh/sadaqah/send/route.ts create mode 100644 src/app/api/flh/sadaqah/subscribe/route.ts create mode 100644 src/app/api/flh/sadaqah/subscriptions/route.ts create mode 100644 src/app/api/flh/sadaqah/unsubscribe/route.ts create mode 100644 src/app/api/flh/summary/route.ts create mode 100644 src/app/api/flh/waqf/contribute/route.ts create mode 100644 src/app/api/flh/waqf/create/route.ts create mode 100644 src/app/api/flh/waqf/my-contributions/route.ts create mode 100644 src/app/api/flh/waqf/pools/route.ts create mode 100644 src/app/api/flh/zakat/agents/route.ts create mode 100644 src/app/api/flh/zakat/calculate/route.ts create mode 100644 src/app/api/flh/zakat/history/route.ts create mode 100644 src/app/api/flh/zakat/pay/route.ts create mode 100644 src/app/api/flh/zakat/pool/route.ts create mode 100644 src/app/api/flh/zakat/status/route.ts create mode 100644 src/app/api/health/db/route.ts create mode 100644 src/app/api/souq/listings/[id]/route.ts.backup create mode 100644 src/app/api/souq/listings/route.ts.backup create mode 100644 src/app/api/souq/orders/[id]/route.ts.backup create mode 100644 src/app/api/souq/orders/route.ts.backup create mode 100644 src/app/api/souq/reviews/route.ts.backup create mode 100644 src/app/api/souq/sellers/[id]/route.ts.backup create mode 100644 src/app/api/wallet/ton-address/route.ts create mode 100644 src/app/flh/hibah/page.tsx create mode 100644 src/app/flh/page.tsx create mode 100644 src/app/flh/sadaqah/page.tsx create mode 100644 src/app/flh/waqf/page.tsx create mode 100644 src/app/flh/zakat/page.tsx create mode 100644 src/app/manifest.json/route.ts create mode 100644 src/components/LearnPassCard.tsx create mode 100644 src/lib/ummahid-client.ts create mode 100644 src/types/ios-orientation.d.ts create mode 100644 src/types/pdfkit.d.ts delete mode 100644 start.sh create mode 100644 tests/__pycache__/qa-learn-prod.cpython-312.pyc create mode 100755 tests/falah-broken-link-checker.py diff --git a/.dockerignore b/.dockerignore index b0d2bb2..118e49e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,4 @@ prisma/dev.db* prisma/*.db-wal prisma/*.db-shm +.env diff --git a/.hermes/plans/souq-native-embed.md b/.hermes/plans/souq-native-embed.md new file mode 100644 index 0000000..e348330 --- /dev/null +++ b/.hermes/plans/souq-native-embed.md @@ -0,0 +1,66 @@ +# Souq Native Embed — Implementation Plan + +**Goal:** Replace the external redirect at `/souq` with native Next.js pages that use Prisma + AuthContext. Single auth, single DB, no domain switch. + +**Architecture:** +- Souq API routes live under `/mobile/api/souq/*` — same pattern as `/wallet`, `/chat`, `/gamification` +- Listings stored in Prisma `Listing` model (already exists, needs a few fields added) +- Orders tracked via Prisma `Purchase` model (already exists, needs status/messages fields) +- Auth flows through existing `AuthContext` + `flh_token` — no relogin +- Navigation updated to point internally, not to souq.falahos.my + +**Data Migration:** +- Netlify `/tmp/*.json` seed data is demo/fake — not migrated +- Real listings will be created via the new `/souq/sell` flow directly into Prisma +- Total sync required: ZERO — one DB, one app, one auth + +## Task Breakdown + +### Batch 1: Schema + Backend (parallel) +| # | Task | Files | +|---|------|-------| +| 1 | Update Prisma: add `packages`, `tags`, `images`, `deliveryDays`, `rating`, `reviewCount`, `salesCount` to `Listing`; add `status`, `packageName`, `messages`, `deliveryFiles`, `updatedAt` to `Purchase` | `prisma/schema.prisma` | +| 2 | Create seed script | `scripts/seed-souq.ts` | +| 3 | Create API route: `GET /api/souq/listings` + `POST /api/souq/listings` | `src/app/api/souq/listings/route.ts` | +| 4 | Create API route: `GET /api/souq/listings/[id]` | `src/app/api/souq/listings/[id]/route.ts` | +| 5 | Create API route: `GET /api/souq/orders` + `POST /api/souq/orders` | `src/app/api/souq/orders/route.ts` | +| 6 | Create API route: `GET /api/souq/orders/[id]` + `PATCH /api/souq/orders/[id]` | `src/app/api/souq/orders/[id]/route.ts` | +| 7 | Create API route: `GET /api/souq/categories` | `src/app/api/souq/categories/route.ts` | + +### Batch 2: Frontend Pages (parallel) +| # | Task | Files | +|---|------|-------| +| 8 | Build `/souq` browse page with category grid + featured listings + search | `src/app/souq/page.tsx` | +| 9 | Build `/souq/[id]` listing detail with package selection + order flow | `src/app/souq/[id]/page.tsx` | +| 10 | Build `/souq/orders` page (buyer + seller order lists) | `src/app/souq/orders/page.tsx` | +| 11 | Build `/souq/orders/[id]` order tracking page (stepper + chat + files) | `src/app/souq/orders/[id]/page.tsx` | +| 12 | Build `/souq/sell` create listing form | `src/app/souq/sell/page.tsx` | + +### Batch 3: Integration +| # | Task | Files | +|---|------|-------| +| 13 | Update BottomNav: Souq → internal `/souq`, remove external | `src/components/BottomNav.tsx` | +| 14 | Update Home page: Souq quick action → internal link | `src/app/page.tsx` | +| 15 | Run Prisma migration + seed, test build | terminal | + +### Batch 4: Netlify Redirect +| # | Task | Files | +|---|------|-------| +| 16 | Add `_redirects` to souq.falahos.my Netlify deploy | `falah-core/apps/souq/_redirects` | + +## Key Design Decisions + +1. **Packages as JSON** — `Listing.packages` stores `[{name, description, price, deliveryDays, revisions}]` as a JSON string. Avoids a separate model for MVP. +2. **Order messages as JSON** — `Purchase.messages` stores `[{from, text, timestamp}]` as JSON. Same reasoning. +3. **Categories hardcoded** — Categories defined as a constant in the API route, not a DB table (can promote later). +4. **FLH-only pricing** — All listings priced in FLH, matching the existing wallet system. The 1.5% platform fee matches the Netlify Souq. +5. **Use existing AuthContext** — No new auth flow. `requireAuth()` on API routes, `useAuth()` on pages. + +## Verification + +- `curl /mobile/api/souq/listings` returns seed listings +- Navigate to `/souq` in browser — browse grid renders +- Click a listing → detail page with packages +- Place order → appears in `/souq/orders` +- BottomNav Souq links to `/souq`, not external +- souq.falahos.my redirects to `/mobile/souq` diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8d1617b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# ── Falah Mobile — Production Dockerfile ────────────────────────────── -# Requires npm run build to be run first on the host (Next.js standalone) -# Build sequence: -# cd /app && npm ci && npm run build -# docker build -t falah-mobile:latest . - -FROM node:20-alpine - -RUN apk add --no-cache openssl ca-certificates curl - -WORKDIR /app - -RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs - -# Copy standalone build (pre-built outside Docker) -COPY .next/standalone/ ./ -COPY .next/static ./.next/static -COPY public ./public - -# Install production dependencies inside Docker -COPY package*.json ./ -RUN npm ci --omit=dev && npm cache clean --force -COPY prisma ./prisma - -# Fix Turbopack's hashed Prisma client path (if .next inside standalone) -RUN if [ -d "/app/.next/node_modules/@prisma" ]; then \ - for d in /app/.next/node_modules/@prisma/client-*; do \ - name=$(basename "$d"); \ - rm -f "/app/node_modules/@prisma/$name" 2>/dev/null; \ - cp -r /app/node_modules/@prisma/client "/app/node_modules/@prisma/$name"; \ - done; \ - fi - -RUN mkdir -p /app/data && chown nextjs:nodejs /app/data - -# Fix permissions so nextjs user can run prisma CLI and read schema -RUN chown -R nextjs:nodejs /app/node_modules/@prisma /app/node_modules/prisma /app/node_modules/.prisma /app/prisma 2>/dev/null || true - -# Startup script — migrate volumes DB then launch server -COPY start.sh /app/start.sh -RUN chmod +x /app/start.sh - -# ── Docker HEALTHCHECK ────────────────────────────────────────── -# Auto-restarts container if health endpoint fails 3 times (30s interval, 10s timeout) -HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ - CMD curl -sf http://localhost:3000/mobile/api/health || exit 1 - -USER nextjs -EXPOSE 3000 - -ENV NODE_ENV=production -ENV DATABASE_URL="file:/app/data/dev.db" - -CMD ["/app/start.sh"] diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..5d1774d --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,228 @@ +# Falah Mobile — Micro Learning Platform Handover + +## Project Overview + +Falah Mobile is a Next.js 16 PWA serving a full Islamic lifestyle platform (Nur AI coaching, Halal marketplace, community, wallet, and **micro learning courses**). The micro learning module is the focus of this handover — a complete E2E system for browsing, purchasing, consuming, and certifying bite-sized (5 min) courses derived from books. + +Production URL: `https://falahos.my/mobile` +Staging URL: `https://falahos.my:4014/mobile` + +### Repository + +| Detail | Value | +|:-------|:------| +| **Source path (server)** | `/root/falah-mobile` | +| **Gitea remote** | `http://git.falahos.my/wmj/falah-mobile.git` (internal: `localhost:3080/wmj/falah-mobile.git`) | +| **Gitea repo ID** | `1` | +| **Full name** | `wmj/falah-mobile` | +| **Default branch** | `main` | +| **Last commit** | `bfd3509` — "docs: update README with full CE feature docs, architecture, and deployment guide" | +| **GitHub mirror** | `https://github.com/maifors/falah-mobile.git` (auto-synced via Gitea cron) | + + +## ✅ What Is Complete (Live) + +### Course Library — 4 Courses, 21 Modules, 6 Categories + +| Course | Author | Modules | Category | +|--------|--------|:-------:|:---------| +| Atomic Habits | James Clear | 5 | Self Improvement | +| The Defining Decade | Meg Jay | 6 | Self Improvement | +| Simplicity Parenting | Kim John Payne | 5 | Self Improvement | +| Tao Te Ching | Lao Tzu | 5 | Self Improvement | + +6 categories defined (Self Improvement ✅, Basic Quran/Hadith/Prayer/History/Finance — empty, ready for content). + +### Course Content Delivery +- **Course listing** with category badges + search bar + category filter tabs +- **Course detail page** with module timeline, progress bar, quiz badges +- **Module page** with lesson content (markdown), Listen 🎧 button, Quiz section +- **TTS audio** using browser Web Speech API with female voice selection (preferred: Google UK English Female → Samantha → Microsoft Zira → fallback) +- **Markdown stripping** for TTS (strips `#`, `##`, `**bold**`, `*italic*`, `[links]`, `> quotes`, `- lists`, `1. lists`, `` `code` ``) +- **Quiz system** — 3–5 multiple-choice questions per module, submit/score/retry +- **Certificate issuance** — auto-issued when all modules completed, with PDF generation and serial number. Displayed as 🎓 View Certificate badge on course detail page. + +### Payments & Subscription +| Product | Price | Status | +|---------|:-----:|:------:| +| Atomic Habits (one-time) | $4.99 | ❌ Not mapped in checkout (old courses only have 3 mapped) | +| The Defining Decade (one-time) | $4.99 | ❌ Not mapped in checkout | +| Simplicity Parenting (one-time) | $4.99 | ❌ Not mapped in checkout | +| Tao Te Ching (one-time) | $4.99 | ❌ Not mapped in checkout | +| Learn Pass Monthly | $9.99/mo | ✅ Live on Polar | +| Learn Pass Annual | $79.99/yr | ✅ Live on Polar | +| In-app toggle (monthly/annual) | — | ✅ LearnPassCard component | + +**IMPORTANT:** The checkout API at `src/app/api/learn/checkout/route.ts` only has `PRODUCTS` mapping for the *original 3 courses* (atomic-habits, deep-work, 7-habits). New courses (defining-decade, simplicity-parenting, tao-te-ching) need their Polar price IDs added to this map for individual purchase to work. + +### Demo Credentials +- `demo@falahos.my` / `password123` (Premium, FLH 10,000) +- `premium@falahos.my` / `Premium123!` (Premium, FLH 50,000) + +### TTS Voice +- Client-side browser SpeechSynthesis with voice selection +- Pitch: 1.15, Rate: 0.9 + +--- + +## 📋 Pending / Next Steps + +### Priority: Phase 5+ Course Content (27 more courses) +39-course library planned. Only 4 built. Remaining 35 courses span 6 categories: +- **Self Improvement** (14 more needed) — Deep Work, 7 Habits, Mindset, Learning How to Learn, You Are Awesome, Al-Ghazali, Rumi, Love Languages, Whole-Brain Child, Crucial Conversations, How to Talk So Kids Will Listen, etc. +- **Basic Quran** (0/8 courses) — need content creation +- **Basic Hadith** (0/6 courses) — need content creation +- **Basic Prayer & Worship** (0/6 courses) — need content creation +- **Basic Islamic History** (0/5 courses) — need content creation +- **Basic Islamic Finance** (0/4 courses) — need content creation + +Each course follows the micro-learning template: +``` +5 modules × 5 min each +3–5 multiple-choice questions per module (format: {questions: [{question, options[], correctIndex}]}) +Key takeaway per module +``` + +Course seed file template: `/root/falah-mobile/scripts/seed-phase4.ts` + +### Priority: Add New Courses to Checkout +The `PRODUCTS` map in `src/app/api/learn/checkout/route.ts` needs entries for each new course that has a Polar price. Create Polar products first, then add `"course-{slug}": { priceId, name, metadata, tier: "one_time" }` to the map. + +### Priority: Course Image / Thumbnail Art +Course cards have emoji + gradient placeholders. Need proper cover images stored in `public/courses/` or served via Gitea. + +### Other Backlog +- **Gitea course sync** — `/root/falah-mobile/scripts/init-gitea-courses.sh` syncs course content from Gitea. Script exists but needs testing. +- **Server-side TTS generation** — Current Web Speech API is client-side. Could use Azure Cognitive Services or ElevenLabs for higher quality. +- **Certificate PDF design** — Current PDF generation in `src/lib/learn.ts` (functions: `generateCertificatePdf`, `generateSerialNumber`, `buildVerifyUrl`). Design may need polish. +- **Admin dashboard** — No admin UI for course management. Courses are seeded via DB scripts. +- **Course progress analytics** — No analytics on completion rates, quiz scores, popular courses. + +--- + +## 📦 Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Framework** | Next.js 16 (App Router, React Server Components) | +| **Language** | TypeScript | +| **Database** | SQLite (production: `/app/data/dev.db` on Docker volume) | +| **ORM** | Prisma v5.22 (schema at `prisma/schema.prisma`) | +| **Auth** | Ummah ID (external service) — JWT delegation | +| **Styling** | Tailwind CSS, dark theme (`#07090C` bg, `#C9A84C` gold, `#00C48C` green) | +| **Payments** | Polar.sh (checkout API, webhooks) | +| **TTS** | Browser Web Speech API (`SpeechSynthesisUtterance`) | +| **Certificate PDF** | Generated server-side (see `src/lib/learn.ts`) | +| **Deployment** | Docker (standalone Next.js output) | +| **Reverse Proxy** | Traefik (routes `/mobile/*` → `falah-mobile:3000`) | +| **Host** | Contabo VPS (Ubuntu 24.04) | +| **Git** | Gitea at `git.falahos.my` | + +--- + +## 🏗 Architecture + +### Deployment Flow +``` +Source: /root/falah-mobile/ + → npm run build (produces .next/standalone) + → docker build -t falah-mobile:latest . + → docker compose up -d --no-deps falah-mobile +``` + +### Route Structure (Next.js App Router, basePath: "/mobile") +``` +/mobile + /learn ← Course catalog with search + category tabs (client) + /learn/[slug] ← Course detail with module timeline (client) + /learn/[slug]/[moduleId] ← Module reader: content + TTS + quiz (client) + /api/learn + /courses ← GET: list courses (supports ?category=&search=) + /courses/[slug] ← GET: course detail + modules + enrollment + /categories ← GET: list all categories + /categories/seed ← POST: seed categories (dev only) + /checkout ← POST: create Polar.sh checkout or mock purchase + /progress ← POST: mark module complete (auto-issues certificate at 100%) + /tts ← GET: module text; POST: request audio + /certificates ← GET: user's certificates + /certificates/[serial] ← GET: single certificate + /sync ← POST: sync from Gitea +``` + +### Key Source Files +| File | Purpose | +|------|---------| +| `prisma/schema.prisma` | All data models (LearnCourse, LearnModule, LearnEnrollment, etc.) | +| `src/app/learn/page.tsx` | Course catalog with category tabs + search | +| `src/app/learn/[slug]/page.tsx` | Course detail + timeline + certificate badge | +| `src/app/learn/[slug]/[moduleId]/page.tsx` | Module reader + TTS + quiz | +| `src/app/api/learn/courses/route.ts` | Course listing API with category/search/filter | +| `src/app/api/learn/courses/[slug]/route.ts` | Course detail API | +| `src/app/api/learn/categories/route.ts` | Categories API | +| `src/app/api/learn/checkout/route.ts` | Polar checkout (update PRODUCTS map for new courses!) | +| `src/app/api/learn/progress/route.ts` | Progress + certificate issuance | +| `src/app/api/learn/tts/route.ts` | TTS text endpoint | +| `src/app/api/learn/certificates/route.ts` | User certificates | +| `src/lib/learn.ts` | Certificate PDF generation, serial number | +| `src/lib/prisma.ts` | Prisma client singleton | +| `src/lib/auth.ts` | JWT auth middleware | +| `src/components/LearnPassCard.tsx` | Subscription monthly/annual toggle | +| `scripts/seed-phase4.ts` | Course seed template | +| `Dockerfile` | Builds standalone Next.js production image | + +--- + +## 🔧 Key Skills for Continuation + +1. **Prisma ORM with SQLite** — schema changes (`db push`), seed scripts, query patterns +2. **Next.js 16 App Router** — file-based routing, server/client components, API routes +3. **React (useState, useEffect, useCallback, useMemo)** — all frontend pages are client components +4. **Polar.sh API** — checkout creation, webhook handling, subscription management +5. **Docker deployment** — multi-stage build, volume mounts, compose orchestration +6. **Tailwind CSS** — theming (dark mode, custom color palette) +7. **Web Speech API** — browser TTS voice selection +8. **SQLite** — direct DB manipulation for quick data fixes (seed scripts, format migrations) + +--- + +## ⚙️ Common Commands + +```bash +cd /root/falah-mobile + +# Build +npm run build + +# Deploy +docker build -t falah-mobile:latest . +docker compose up -d --no-deps falah-mobile + +# DB operations +docker cp falah-mobile-falah-mobile-1:/app/data/dev.db /tmp/dev.db +# edit /tmp/dev.db with sqlite3 +docker cp /tmp/dev.db falah-mobile-falah-mobile-1:/app/data/dev.db +docker restart falah-mobile-falah-mobile-1 + +# Prisma schema change +npx prisma db push # applies schema changes to SQLite directly + +# Check logs +docker logs falah-mobile-falah-mobile-1 + +# Seed categories endpoint (dev only, on rebuild) +curl -X POST https://falahos.my/mobile/api/learn/categories/seed + +# API test +curl -s https://falahos.my/mobile/api/learn/courses | python3 -m json.tool +``` + +--- + +## 🐛 Known Issues + +1. **Health check slow** — Docker health check for mobile container takes 30s+ to become "healthy", but API works immediately (HTTP 200). +2. **`.env` permission error** — Container logs show `EACCES: permission denied, open '/app/.env'` despite env vars being loaded correctly via Docker compose. +3. **New courses not individually purchasable** — Checkout `PRODUCTS` map only has 3 old courses mapped. New courses need Polar price IDs added. +4. **Staging shares volume** — `falah-mobile-staging` and `falah-mobile` production share the same DB volume. Restarting staging after production writes can cause DB contention. +5. **No category icon for course thumbnails** — Thumbnail uses category's icon/color gradient, but some courses may have missing visuals. +6. **Course card click doesn't navigate on course detail page** — Module cards have `router.push()` but the nested "Listen" button's `stopPropagation()` may cause issues. Direct URL navigation works (`/mobile/learn/{slug}/{module-slug}`). diff --git a/auto-healer/Dockerfile b/auto-healer/Dockerfile deleted file mode 100644 index 2eaa20f..0000000 --- a/auto-healer/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# ── Falah Mobile — Auto-Healer Agent ────────────────────────── -# Dedicated container that monitors the app stack and auto-fixes faults. -# Runs alongside the main app container with access to Docker socket. -# ────────────────────────────────────────────────────────────── - -FROM alpine:3.19 - -RUN apk add --no-cache curl docker-cli bash jq - -WORKDIR /app - -COPY auto-heal-agent.sh /app/auto-heal-agent.sh -RUN chmod +x /app/auto-heal-agent.sh - -# Runtime state -RUN mkdir -p /var/log/falah /var/state/falah - -# Monitor interval in seconds (default 30) -ENV CHECK_INTERVAL=30 -ENV CONTAINER_NAME=falah-mobile-staging -ENV HEALTH_URL=http://localhost:4014/mobile/api/health -ENV GATEWAY_URL=http://localhost:7878/mobile/api/health -ENV MAX_RESTARTS_PER_HOUR=3 - -CMD ["/app/auto-heal-agent.sh"] diff --git a/auto-healer/auto-heal-agent.sh b/auto-healer/auto-heal-agent.sh deleted file mode 100644 index f9bc170..0000000 --- a/auto-healer/auto-heal-agent.sh +++ /dev/null @@ -1,598 +0,0 @@ -#!/bin/bash -# ============================================================ -# Falah Mobile — SRE Auto-Healing Agent -# ============================================================ -# Log-aware fault diagnosis + restore protocol. -# -# On fault: -# 1. FETCH logs from failing container -# 2. ANALYZE logs against error knowledge base -# 3. DECIDE: quick hotfix or restore protocol? -# 4. RESTORE PROTOCOL: rollback to known-good image -# 5. While rolling back, capture full diagnostics for later fix -# -# This is NOT a blind restarter — it's a diagnostic-first SRE agent. -# ============================================================ - -# ── Configuration ──────────────────────────────────────────── - -CONTAINER_NAME="${CONTAINER_NAME:-falah-mobile-staging}" -HEALTH_URL="${HEALTH_URL:-http://localhost:4014/mobile/api/health}" -GATEWAY_URL="${GATEWAY_URL:-http://localhost:7878/mobile/api/health}" -CHECK_INTERVAL="${CHECK_INTERVAL:-30}" -MAX_RESTARTS="${MAX_RESTARTS_PER_HOUR:-3}" -ROLLBACK_IMAGE="${ROLLBACK_IMAGE:-falah-mobile:rollback}" - -AGENT_LOG="/var/log/falah/agent.log" -STATE_DIR="/var/state/falah" -STATE_FILE="$STATE_DIR/restart-count" -INCIDENTS_DIR="/var/log/falah/incidents" - -mkdir -p "$STATE_DIR" "$(dirname "$AGENT_LOG")" "$INCIDENTS_DIR" - -# ── Logging ────────────────────────────────────────────────── - -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] [agent] $*" >> "$AGENT_LOG" -} - -alert() { - local severity="$1" msg="$2" - log "[$severity] $msg" - # Future: webhook, ntfy, Slack, PagerDuty -} - -# ── Restart Tracking (sliding window) ──────────────────────── - -count_restarts() { - [ -f "$STATE_FILE" ] || echo 0 > "$STATE_FILE" - local now window_start - now=$(date +%s) - window_start=$((now - 3600)) - awk -v ws="$window_start" '$1 > ws' "$STATE_FILE" 2>/dev/null | wc -l -} - -record_restart() { - date +%s >> "$STATE_FILE" - local now window_start - now=$(date +%s) - window_start=$((now - 3600)) - awk -v ws="$window_start" '$1 > ws' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" -} - -# ── Basic Health Checks ────────────────────────────────────── - -check_http() { - local url="$1" - local body code - code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000") - body=$(curl -sf --max-time 10 "$url" 2>/dev/null) || true - [ "$code" = "200" ] && { echo "$body"; return 0; } || return 1 -} - -check_disk() { - df / | awk 'NR==2 {gsub(/%/,"",$5); print $5}' -} - -# ── Phase 1: Detect Fault ──────────────────────────────────── - -detect_fault() { - # Cooldown: skip detection if restore happened within 60s - local cooldown=120 - if [ -f /var/state/falah/last_restore_epoch ]; then - local last_restore - last_restore=$(cat /var/state/falah/last_restore_epoch 2>/dev/null || echo 0) - local now - now=$(date +%s) - local elapsed=$((now - last_restore)) - if [ $elapsed -lt $cooldown ]; then - log " (cooldown active — last restore ${elapsed}s ago, skipping detection)" - echo "healthy" - return - fi - fi - - # Returns: diagnosis string (or "healthy") - - # 0a. Check if CONTAINER_NAME is a Swarm service - local is_swarm_service=false - if docker service ls 2>/dev/null | awk '{print $2}' | grep -x "${CONTAINER_NAME}"; then - is_swarm_service=true - fi - - if [ "$is_swarm_service" = true ]; then - # Swarm service mode — check via docker service ps - local unhealthy - unhealthy=$(docker service ps "$CONTAINER_NAME" --filter 'desired-state=running' --format '{{.CurrentState}}' 2>/dev/null | grep -v "^Running" | head -1) - if [ -n "$unhealthy" ]; then - echo "swarm_service_unhealthy" - return - fi - # Service is running, now check HTTP health - local health_body - health_body=$(check_http "$HEALTH_URL") || { echo "health_down"; return; } - echo "$health_body" | grep -q '"db":false' && { echo "db_down"; return; } - echo "$health_body" | grep -q '"status":"ok"' || { echo "health_invalid"; return; } - echo "healthy" - return - fi - - # 1. Container exists? (standalone mode) - if ! docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "${CONTAINER_NAME}"; then - echo "container_missing" - return - fi - - # 2. Container running? - local status exit_code oom - status=$(docker inspect "$CONTAINER_NAME" --format '{{.State.Status}}' 2>/dev/null || echo "unknown") - - if [ "$status" != "running" ]; then - exit_code=$(docker inspect "$CONTAINER_NAME" --format '{{.State.ExitCode}}' 2>/dev/null || echo "0") - oom=$(docker inspect "$CONTAINER_NAME" --format '{{.State.OOMKilled}}' 2>/dev/null || echo "false") - [ "$oom" = "true" ] && { echo "oom_killed"; return; } - [ "$exit_code" = "137" ] && { echo "sigkill"; return; } - echo "crashed_exit_$exit_code" - return - fi - - # 3. Health endpoint responds? - local health_body - health_body=$(check_http "$HEALTH_URL") || { echo "health_down"; return; } - - # 4. DB connected? - echo "$health_body" | grep -q '"db":false' && { echo "db_down"; return; } - - # 5. Status ok? - echo "$health_body" | grep -q '"status":"ok"' || { echo "health_invalid"; return; } - - # 6. Gateway? - check_http "$GATEWAY_URL" > /dev/null || { echo "gateway_down"; return; } - - echo "healthy" -} - -# ── Phase 2: Fetch Logs ────────────────────────────────────── - -fetch_logs() { - local lines="${1:-100}" - docker logs "$CONTAINER_NAME" --tail "$lines" 2>&1 || echo "LOG_FETCH_FAILED" -} - -# ── Phase 3: Log Analysis (Knowledge Base) ─────────────────── - -analyze_logs() { - local diagnosis="$1" - local logs="$2" - - # Build a diagnostic report - local root_cause="unknown" - local certainty="low" - local suggested_action="rollback" - local details="" - - # ── Error Pattern Knowledge Base ─────────────────────────── - # Each pattern maps to: root_cause | certainty | suggested_action - - # Node.js crashes - if echo "$logs" | grep -qi "Cannot find module"; then - root_cause="missing_dependency" - certainty="high" - details=$(echo "$logs" | grep -i "Cannot find module" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "Module not found"; then - root_cause="missing_dependency" - certainty="high" - details=$(echo "$logs" | grep -i "Module not found" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "unexpected token"; then - root_cause="js_parse_error" - certainty="high" - suggested_action="rollback" - details=$(echo "$logs" | grep -i "unexpected token" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "SyntaxError"; then - root_cause="js_syntax_error" - certainty="high" - details=$(echo "$logs" | grep -i "SyntaxError" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "ReferenceError"; then - root_cause="js_reference_error" - certainty="high" - details=$(echo "$logs" | grep -i "ReferenceError" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "TypeError"; then - root_cause="js_type_error" - certainty="high" - details=$(echo "$logs" | grep -i "TypeError" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "heap out of memory\|FATAL ERROR\|Allocation failed"; then - root_cause="out_of_memory" - certainty="high" - details=$(echo "$logs" | grep -i "heap out of memory\|FATAL ERROR\|Allocation failed" | head -3 | tr '\n' '; ') - - # Prisma / DB errors - elif echo "$logs" | grep -qi "PrismaClientInitializationError\|prisma.*connect.*ECONNREFUSED"; then - root_cause="db_connection_refused" - certainty="high" - suggested_action="restart_container" - details=$(echo "$logs" | grep -i "PrismaClientInitializationError\|ECONNREFUSED" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "prisma.*ENOENT\|prisma.*does not exist"; then - root_cause="prisma_schema_mismatch" - certainty="high" - details=$(echo "$logs" | grep -i "prisma.*ENOENT\|does not exist" | head -3 | tr '\n' '; ') - - elif echo "$logs" | grep -qi "Can't reach database server\|getaddrinfo ENOTFOUND.*db\|postgres.*connect"; then - root_cause="db_unreachable" - certainty="high" - suggested_action="restart_container" - details=$(echo "$logs" | grep -i "ENOTFOUND\|Can't reach database" | head -3 | tr '\n' '; ') - - # Network / connectivity - elif echo "$logs" | grep -qi "ECONNREFUSED\|ECONNRESET\|ETIMEDOUT\|ENOTFOUND"; then - root_cause="downstream_unreachable" - certainty="medium" - suggested_action="restart_container" - details=$(echo "$logs" | grep -i "ECONNREFUSED\|ECONNRESET\|ETIMEDOUT\|ENOTFOUND" | head -3 | tr '\n' '; ') - - # Rate limiting - elif echo "$logs" | grep -qi "rate limit\|too many requests\|429"; then - root_cause="rate_limit_exceeded" - certainty="medium" - suggested_action="hotfix_rate_limit" - details=$(echo "$logs" | grep -i "rate limit\|too many" | head -3 | tr '\n' '; ') - - # Auth / config - elif echo "$logs" | grep -qi "jwt expired\|invalid token\|Unauthorized"; then - root_cause="auth_config_error" - certainty="medium" - suggested_action="check_config" - details=$(echo "$logs" | grep -i "jwt expired\|invalid token\|Unauthorized" | head -3 | tr '\n' '; ') - - # 5xx patterns from health endpoint - elif echo "$logs" | grep -qi "500\|Internal Server Error\|unhandled rejection\|uncaught exception"; then - root_cause="unhandled_exception" - certainty="medium" - details=$(echo "$logs" | grep -i "unhandled\|uncaught\|500\|Internal Server Error" | head -3 | tr '\n' '; ') - - # Next.js specific - elif echo "$logs" | grep -qi "next.*error\|Error:.*page\|render.*error\|application.*error"; then - root_cause="application_error" - certainty="medium" - details=$(echo "$logs" | grep -i "Error:\|render.*error" | head -3 | tr '\n' '; ') - fi - - # If diagnosis gives strong signal, incorporate it - case "$diagnosis" in - oom_killed|sigkill) - [ "$root_cause" = "unknown" ] && root_cause="container_killed_sigkill" - suggested_action="rollback" - ;; - db_down) - [ "$root_cause" = "unknown" ] && root_cause="database_disconnected" - suggested_action="restart_container" - ;; - health_down) - [ "$root_cause" = "unknown" ] && root_cause="container_unresponsive" - ;; - gateway_down) - root_cause="nginx_gateway_down" - suggested_action="reload_gateway" - certainty="high" - ;; - esac - - # Return structured report as JSON-like lines - echo "ROOT_CAUSE=$root_cause" - echo "CERTAINTY=$certainty" - echo "ACTION=$suggested_action" - echo "DETAILS=$details" -} - -# ── Phase 4: Restore Protocol ──────────────────────────────── -# Fast rollback to last known-good state, while capturing -# full diagnostics for permanent fix later. - -restore_protocol() { - local diagnosis="$1" - local root_cause="$2" - local action="$3" - local details="$4" - local logs="$5" - local incident_id - incident_id="incident_$(date +%Y%m%d_%H%M%S)" - - log "🚨 RESTORE PROTOCOL triggered — incident: $incident_id" - log " Diagnosis: $diagnosis" - log " Root cause: $root_cause" - log " Action: $action" - log " Details: $details" - - # ── Step 0: Save full diagnostic context for later fix ───── - { - echo "=== INCIDENT REPORT: $incident_id ===" - echo "Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" - echo "Container: $CONTAINER_NAME" - echo "Diagnosis: $diagnosis" - echo "Root cause: $root_cause" - echo "Certainty: $certainty" - echo "Action: $action" - echo "Details: $details" - echo "" - echo "=== Container Logs ===" - echo "$logs" - echo "" - echo "=== Container Inspect ===" - docker inspect "$CONTAINER_NAME" 2>/dev/null || docker service inspect "$CONTAINER_NAME" 2>/dev/null || echo "INSPECT_FAILED" - echo "" - echo "=== Docker Events (last 5 min) ===" - timeout 5 docker events --since "${CHECK_INTERVAL}s" --until "$(date +%s)" \ - --filter "container=$CONTAINER_NAME" \ - --filter "service=$CONTAINER_NAME" \ - --format '{{.Time}} {{.Type}} {{.Action}} {{.Status}}' 2>/dev/null || echo "EVENTS_FAILED" - echo "" - echo "=== System Resources ===" - df -h / | tail -1 - free -m 2>/dev/null || echo "free_unavailable" - echo "=== END INCIDENT REPORT ===" - } > "$INCIDENTS_DIR/$incident_id.log" - - log "📝 Incident saved to $INCIDENTS_DIR/$incident_id.log" - alert "INFO" "Incident $incident_id logged — root cause: $root_cause" - - # Always save cooldown timestamp to prevent detection loops - date +%s > /var/state/falah/last_restore_epoch - - # ── Step 1: Check if this is a Swarm service (don't manage containers directly) ── - if docker service ls 2>/dev/null | awk '{print $2}' | grep -x "${CONTAINER_NAME}"; then - log "ℹ️ Swarm service detected — skipping container management, Swarm handles recovery" - date +%s > /var/state/falah/last_restore_epoch - log "🔄 RESTORE: forcing service update to trigger redeploy..." - docker service update --force "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - alert "INFO" "Swarm service $CONTAINER_NAME force-updated (incident: $incident_id)" - return 0 - fi - - # ── Step 2: Apply action ─────────────────────────────────── - case "$action" in - rollback) - log "🔄 RESTORE: rolling back to $ROLLBACK_IMAGE..." - if docker images "$ROLLBACK_IMAGE" --format '{{.ID}}' | head -1 | grep -q .; then - # Remove old container - docker stop "$CONTAINER_NAME" 2>/dev/null || true - sleep 2 - docker rm -f "$CONTAINER_NAME" 2>/dev/null || true - sleep 2 - - # Recreate from rollback image (omit --network; default bridge works reliably) - docker run -d \ - --name "$CONTAINER_NAME" \ - --restart unless-stopped \ - -p 4014:3000 \ - -v /var/services/homes/admin/falah-mobile/data:/app/data \ - "$ROLLBACK_IMAGE" >> "$AGENT_LOG" 2>&1 - - sleep 15 - if check_http "$HEALTH_URL" > /dev/null 2>&1; then - date +%s > /var/state/falah/last_restore_epoch - log "✅ RESTORE: rollback successful — service restored" - alert "INFO" "Restore protocol: rolled back to $ROLLBACK_IMAGE (incident: $incident_id)" - record_restart - return 0 - else - log "❌ RESTORE: rollback also failed — escalation needed" - alert "CRITICAL" "Rollback failed for incident $incident_id — manual intervention" - return 1 - fi - else - log "⚠️ RESTORE: no rollback image found — attempting restart instead" - docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - sleep 10 - if check_http "$HEALTH_URL" > /dev/null 2>&1; then - date +%s > /var/state/falah/last_restore_epoch - log "✅ RESTORE: restart successful (fallback)" - record_restart - return 0 - fi - return 1 - fi - ;; - - restart_container) - log "🔄 RESTORE: restarting container..." - docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - record_restart - sleep 10 - if check_http "$HEALTH_URL" > /dev/null 2>&1; then - log "✅ RESTORE: restart successful" - return 0 - else - log "⚠️ RESTORE: restart didn't fix — escalating to rollback" - restore_protocol "$diagnosis" "restart_failed" "rollback" "$details" "$logs" - fi - ;; - - reload_gateway) - log "🔄 RESTORE: reloading nginx gateway..." - local gw - gw=$(docker ps --format '{{.Names}}' | grep -i "gateway\|nginx" | head -1) - [ -n "$gw" ] && docker exec "$gw" nginx -s reload >> "$AGENT_LOG" 2>&1 - sleep 3 - check_http "$GATEWAY_URL" > /dev/null && log "✅ RESTORE: gateway restored" || log "⚠️ Gateway still down" - ;; - - hotfix_rate_limit) - log "🔄 RESTORE: rate limit issue — applying hotfix" - # Rate limiter auto-clears stale entries every 5 min (code-level fix) - # For immediate relief, restart container - docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - record_restart - sleep 10 - ;; - - check_config) - log "🔄 RESTORE: config issue detected — rolling back to safe state" - restore_protocol "$diagnosis" "config_error" "rollback" "$details" "$logs" - ;; - - *) - log "🔄 RESTORE: unknown action '$action' — attempting restart as safe default" - docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - record_restart - sleep 10 - ;; - esac -} - -# ── Phase 5: Apply Quick Hotfix (when possible) ────────────── - -apply_hotfix() { - local root_cause="$1" - local logs="$2" - - case "$root_cause" in - rate_limit_exceeded) - log "🔧 HOTFIX: rate limit exceeded — restarting to clear state" - docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - sleep 5 - log "🔧 HOTFIX: rate limiter now auto-cleans every 5 min (code fix in place)" - ;; - - db_connection_refused|db_unreachable) - log "🔧 HOTFIX: DB connection issue — restarting container" - docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - sleep 10 - ;; - - *) - log "🔧 HOTFIX: no quick fix for '$root_cause' — relying on restore protocol" - return 1 - ;; - esac - return 0 -} - -# ── Periodic Maintenance ──────────────────────────────────── - -periodic_maintenance() { - local disk_pct - disk_pct=$(check_disk) - if [ "$disk_pct" -gt 90 ]; then - log "⚠️ Disk at ${disk_pct}% — running prune" - docker system prune -f >> "$AGENT_LOG" 2>&1 - log "✅ Prune completed" - fi -} - -# ── Main Loop ──────────────────────────────────────────────── - -log "══════════════════════════════════════════════════════" -log "🚀 SRE Auto-Healing Agent starting" -log " Container: $CONTAINER_NAME" -log " Health URL: $HEALTH_URL" -log " Gateway URL: $GATEWAY_URL" -log " Interval: ${CHECK_INTERVAL}s" -log " Max restarts: $MAX_RESTARTS/hour" -log " Incidents: $INCIDENTS_DIR" -log "══════════════════════════════════════════════════════" - -CYCLE=0 -HEALTHY_CYCLES=0 - -while true; do - # ── Detect ────────────────────────────────────────────── - DIAGNOSIS=$(detect_fault) - - if [ "$DIAGNOSIS" = "healthy" ]; then - # Stability counter — reset restart tracking after 5 min healthy - HEALTHY_CYCLES=$((HEALTHY_CYCLES + 1)) - if [ "$HEALTHY_CYCLES" -ge 10 ]; then - HEALTHY_CYCLES=0 - echo 0 > "$STATE_FILE" 2>/dev/null || true - fi - else - # ── FAULT DETECTED ──────────────────────────────────── - HEALTHY_CYCLES=0 - log "" - log "⚠️ FAULT DETECTED: $DIAGNOSIS" - - # Step 1: Fetch logs - log "📋 Fetching container logs..." - CONTAINER_LOGS=$(fetch_logs 100) - - # Step 2: Analyze logs against knowledge base - log "🔍 Analyzing logs for root cause..." - ANALYSIS=$(analyze_logs "$DIAGNOSIS" "$CONTAINER_LOGS") - - # Parse analysis output - ROOT_CAUSE=$(echo "$ANALYSIS" | grep "^ROOT_CAUSE=" | cut -d= -f2-) - CERTAINTY=$(echo "$ANALYSIS" | grep "^CERTAINTY=" | cut -d= -f2-) - ACTION=$(echo "$ANALYSIS" | grep "^ACTION=" | cut -d= -f2-) - DETAILS=$(echo "$ANALYSIS" | grep "^DETAILS=" | cut -d= -f2-) - - log " Root cause: $ROOT_CAUSE (certainty: $CERTAINTY)" - log " Action: $ACTION" - [ -n "$DETAILS" ] && log " Details: $DETAILS" - - # Step 3: Decision — can we hotfix or need restore protocol? - restarts=$(count_restarts) - - if [ "$restarts" -ge "$MAX_RESTARTS" ]; then - # Too many restarts — go straight to restore protocol - log "⚠️ $restarts restarts in last hour — exceeding limit of $MAX_RESTARTS" - log "🚨 SKIPPING hotfix attempt — going directly to restore protocol" - restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "rollback" "$DETAILS" "$CONTAINER_LOGS" - - elif [ "$ACTION" = "rollback" ]; then - # Knowledge base says rollback — run restore protocol - restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "$ACTION" "$DETAILS" "$CONTAINER_LOGS" - - elif [ "$ACTION" = "restart_container" ]; then - # Try restart first, escalate if fails - log "🔄 Attempting restart (restart #$restarts)..." - apply_hotfix "$ROOT_CAUSE" "$CONTAINER_LOGS" - sleep 10 - if [ "$(detect_fault)" != "healthy" ]; then - log "⚠️ Restart didn't resolve — escalating to restore protocol" - restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "rollback" "$DETAILS" "$CONTAINER_LOGS" - fi - - elif [ "$ACTION" = "reload_gateway" ]; then - restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "$ACTION" "$DETAILS" "$CONTAINER_LOGS" - - elif [ "$ACTION" = "hotfix_rate_limit" ]; then - apply_hotfix "$ROOT_CAUSE" "$CONTAINER_LOGS" - - else - # No specific action — safe default: restart, then rollback - log "🔄 No specific fix for '$ROOT_CAUSE' — attempting restart" - docker restart "$CONTAINER_NAME" >> "$AGENT_LOG" 2>&1 - record_restart - sleep 10 - [ "$(detect_fault)" != "healthy" ] && restore_protocol "$DIAGNOSIS" "$ROOT_CAUSE" "rollback" "$DETAILS" "$CONTAINER_LOGS" - fi - fi - - # ── Periodic maintenance (every 10 cycles = 5 min) ───── - CYCLE=$((CYCLE + 1)) - [ "$CYCLE" -ge 10 ] && { CYCLE=0; periodic_maintenance; } - - # ── Cross-check: DNS healer alive? ──────────────────── - if ! docker ps --filter 'name=falah-dns-healer' --format '{{.Status}}' 2>/dev/null | grep -q '^Up'; then - log "⚠️ Sibling DNS healer DOWN — restarting..." - docker restart falah-dns-healer 2>/dev/null || \ - docker run -d --name falah-dns-healer --restart unless-stopped \ - --network host \ - -v /var/run/docker.sock:/var/run/docker.sock:ro \ - -v /var/log/falah:/var/log/falah \ - -e CHECK_INTERVAL=60 \ - falah-dns-healer:latest >> "$AGENT_LOG" 2>&1 - sleep 3 - if docker ps --filter 'name=falah-dns-healer' --format '{{.Status}}' | grep -q '^Up'; then - alert "INFO" "Cross-heal: restarted DNS healer" - fi - fi - - sleep "$CHECK_INTERVAL" -done diff --git a/auto-healer/docker-compose.healer.yml b/auto-healer/docker-compose.healer.yml deleted file mode 100644 index 25b17b1..0000000 --- a/auto-healer/docker-compose.healer.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: "3.8" -services: - falah-auto-healer: - build: - context: ./auto-healer - dockerfile: Dockerfile - image: falah-auto-healer:latest - container_name: falah-auto-healer - restart: unless-stopped - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - - /var/log/falah:/var/log/falah - - /var/state/falah:/var/state/falah - environment: - - CONTAINER_NAME=falah-mobile-staging - - HEALTH_URL=http://192.168.0.11:4014/mobile/api/health - - GATEWAY_URL=http://192.168.0.11:7878/mobile/api/health - - CHECK_INTERVAL=30 - - MAX_RESTARTS_PER_HOUR=3 - - ROLLBACK_IMAGE=falah-mobile:rollback - network_mode: "host" - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" diff --git a/data/dev.db b/data/dev.db new file mode 100644 index 0000000000000000000000000000000000000000..51f1af14f0aa8ad0c124f24ea78f44493659c32b GIT binary patch literal 282624 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 literal 0 HcmV?d00001 diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml deleted file mode 100644 index 5872264..0000000 --- a/docker-compose.staging.yml +++ /dev/null @@ -1,41 +0,0 @@ -version: "3.8" -services: - falah-mobile-staging: - build: - context: . - dockerfile: Dockerfile - image: falah-mobile:staging - ports: - - "4017:3000" - environment: - - NODE_ENV=production - - HOSTNAME=0.0.0.0 - - NEXT_PUBLIC_APP_URL=https://falahos.my/mobile-staging - - JWT_SECRET=${JWT_SECRET} - - DATABASE_URL=file:/app/data/staging.db - - LLM_API_KEY=${LLM_API_KEY:-} - - LLM_BASE_URL=${LLM_BASE_URL:-} - - LLM_MODEL=${LLM_MODEL:-qwen3.6-plus} - - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} - # Polar.sh payments (optional — mock mode used when unset) - - POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-} - - POLAR_FLH_500=${POLAR_FLH_500:-} - - POLAR_FLH_1000=${POLAR_FLH_1000:-} - - POLAR_FLH_5000=${POLAR_FLH_5000:-} - - POLAR_FLH_10000=${POLAR_FLH_10000:-} - - POLAR_FLH_50000=${POLAR_FLH_50000:-} - - POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET:-} - volumes: - - falah-mobile-staging-data:/app/data - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/mobile/api/health"] - interval: 30s - timeout: 10s - retries: 3 - -volumes: - falah-mobile-staging-data: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index eac0a18..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: "3.8" -services: - falah-mobile: - build: . - image: falah-mobile:latest - ports: - - "4013:3000" - environment: - - NODE_ENV=production - - HOSTNAME=0.0.0.0 - - NEXT_PUBLIC_APP_URL=https://falahos.my/mobile - - JWT_SECRET=${JWT_SECRET} - - DATABASE_URL=file:/app/data/dev.db - - UMMAHID_URL=https://ummahid.falahos.my - - LLM_API_KEY=${LLM_API_KEY:-} - - LLM_BASE_URL=${LLM_BASE_URL:-} - - LLM_MODEL=${LLM_MODEL:-qwen3.6-plus} - # Polar.sh payments (optional — mock mode used when unset) - - POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-} - - POLAR_FLH_500=${POLAR_FLH_500:-} - - POLAR_FLH_1000=${POLAR_FLH_1000:-} - - POLAR_FLH_5000=${POLAR_FLH_5000:-} - - POLAR_FLH_10000=${POLAR_FLH_10000:-} - - POLAR_FLH_50000=${POLAR_FLH_50000:-} - - POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET:-} - volumes: - - falah-mobile-data:/app/data - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/mobile/api/health"] - interval: 30s - timeout: 10s - retries: 3 - -volumes: - falah-mobile-data: diff --git a/handover-infographic.html b/handover-infographic.html new file mode 100644 index 0000000..3efd281 --- /dev/null +++ b/handover-infographic.html @@ -0,0 +1,723 @@ + + + + + +Falah Mobile — Micro Learning Handover + + + +
+ + +
+
📚 Micro Learning Platform
+

Falah Mobile Handover

+

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

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

Phase 1 Complete — Live in Production

+

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

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

Courses Built

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

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

+
+ + +
+
🎓
+

Delivery Features

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

Payments & Subscription

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

Priority Backlog

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

Tech Stack

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

Database

+

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

+

Schema: prisma/schema.prisma

+
+
+

Deployment

+

Source → npm run build → Docker → Traefik reverse proxy

+

Host: Contabo VPS

+
+
+
+
+ + +
+
+
🏗
+

Route Architecture

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

Key Source Files (26 files)

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

Common Commands

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

DB ops via docker cp + sqlite3 for quick data fixes

+
+ + +
+
🐛
+

Known Issues

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

Hibah

+

Give gifts to loved ones

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

+ + Send Hibah +

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