Compare commits
11 Commits
d8de0a973a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 523aacb37a | |||
| 88260e1e9a | |||
| 7ef16ff4c7 | |||
| 2236725965 | |||
| 77bac590b3 | |||
| 4d7f00be1f | |||
| 5ebea6b0d6 | |||
| 379d9aa13c | |||
| 50e25e3ee8 | |||
| 4607af8def | |||
| 1c545c93b4 |
@@ -18,3 +18,14 @@ CORS_ORIGIN=http://localhost:3000
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# ── Security ────────────────────────────────
|
||||
# Required in production: protects /api/cron/* endpoints
|
||||
CRON_SECRET=generate-a-random-secret-here
|
||||
|
||||
# ── Optional Services ───────────────────────
|
||||
# Email notifications (Resend — free tier: 3000 emails/mo)
|
||||
RESEND_API_KEY=re_your_resend_key
|
||||
|
||||
# Lighthouse backend URL (for automated scans)
|
||||
LIGHTHOUSE_SERVICE_URL=http://localhost:5000
|
||||
|
||||
@@ -4,16 +4,16 @@ on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- "website-monitoring-backend/**"
|
||||
- "backend/**"
|
||||
- ".github/workflows/backend.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "website-monitoring-backend/**"
|
||||
- "backend/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: website-monitoring-backend
|
||||
working-directory: backend
|
||||
|
||||
jobs:
|
||||
lint-test-build:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18, 20]
|
||||
node-version: [20, 22]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: website-monitoring-backend/package-lock.json
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -52,5 +52,5 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: website-monitoring-backend/coverage/
|
||||
path: backend/coverage/
|
||||
retention-days: 7
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push-images:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set image namespace
|
||||
id: vars
|
||||
run: echo "owner_lc=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-backend:latest
|
||||
ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-backend:${{ github.sha }}
|
||||
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=placeholder-service-key
|
||||
tags: |
|
||||
ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-frontend:latest
|
||||
ghcr.io/${{ steps.vars.outputs.owner_lc }}/cloudlense-frontend:${{ github.sha }}
|
||||
|
||||
deploy-to-server:
|
||||
name: Deploy on Server
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push-images
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Upload production compose file
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT || '22' }}
|
||||
source: "devops/docker-compose.prod.yml"
|
||||
target: ${{ secrets.DEPLOY_PATH }}
|
||||
strip_components: 1
|
||||
|
||||
- name: Upload deploy script
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT || '22' }}
|
||||
source: "devops/scripts/deploy-prod.sh"
|
||||
target: ${{ secrets.DEPLOY_PATH }}
|
||||
strip_components: 2
|
||||
|
||||
- name: Deploy containers
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
env:
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
|
||||
GHCR_READ_TOKEN: ${{ secrets.GHCR_READ_TOKEN }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT || '22' }}
|
||||
envs: DEPLOY_PATH,GHCR_USERNAME,GHCR_READ_TOKEN,IMAGE_TAG,GHCR_OWNER
|
||||
script_stop: true
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd "${DEPLOY_PATH}"
|
||||
chmod +x deploy-prod.sh
|
||||
GHCR_OWNER="$(echo "${GHCR_OWNER}" | tr '[:upper:]' '[:lower:]')" ./deploy-prod.sh
|
||||
curl -fsS "http://localhost:5000/health" >/dev/null
|
||||
curl -fsS "http://localhost:3000" >/dev/null
|
||||
@@ -15,17 +15,21 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build all services
|
||||
run: docker compose -f website-monitoring-devops/docker-compose.yml build
|
||||
run: docker compose -f devops/docker-compose.yml build
|
||||
|
||||
- name: Start services
|
||||
run: |
|
||||
docker compose -f website-monitoring-devops/docker-compose.yml up -d db backend
|
||||
sleep 15
|
||||
docker compose -f devops/docker-compose.yml up -d db backend frontend
|
||||
sleep 30
|
||||
|
||||
- name: Verify backend health
|
||||
run: |
|
||||
docker compose -f website-monitoring-devops/docker-compose.yml exec -T backend curl -f http://localhost:5000/health || exit 1
|
||||
docker compose -f devops/docker-compose.yml exec -T backend node -e "const h=require('http');h.get('http://localhost:5000/health',(r)=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{console.log(d);process.exit(r.statusCode===200?0:1)})}).on('error',e=>{console.error(e);process.exit(1)})"
|
||||
|
||||
- name: Verify frontend health
|
||||
run: |
|
||||
curl -fsS http://localhost:3000 >/dev/null
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker compose -f website-monitoring-devops/docker-compose.yml down -v
|
||||
run: docker compose -f devops/docker-compose.yml down -v
|
||||
|
||||
@@ -4,16 +4,16 @@ on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- "website-monitoring-frontend/**"
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "website-monitoring-frontend/**"
|
||||
- "frontend/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: website-monitoring-frontend
|
||||
working-directory: frontend
|
||||
|
||||
jobs:
|
||||
lint-test-build:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18, 20]
|
||||
node-version: [20, 22]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: website-monitoring-frontend/package-lock.json
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -49,3 +49,4 @@ jobs:
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-key
|
||||
SUPABASE_SERVICE_ROLE_KEY: placeholder-service-key
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Check if backend files changed
|
||||
if git diff --cached --name-only | grep -q "^website-monitoring-backend/"; then
|
||||
if git diff --cached --name-only | grep -q "^backend/"; then
|
||||
echo "🔍 Linting backend..."
|
||||
cd website-monitoring-backend && npx eslint src/ || exit 1
|
||||
cd backend && npx eslint src/ || exit 1
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if frontend files changed
|
||||
if git diff --cached --name-only | grep -q "^website-monitoring-frontend/"; then
|
||||
if git diff --cached --name-only | grep -q "^frontend/"; then
|
||||
echo "🔍 Linting frontend..."
|
||||
cd website-monitoring-frontend && npx next lint || exit 1
|
||||
cd frontend && npx next lint || exit 1
|
||||
cd ..
|
||||
fi
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ cd website-monitoring
|
||||
npm install
|
||||
|
||||
# Install project dependencies
|
||||
cd website-monitoring-backend && npm install && cd ..
|
||||
cd website-monitoring-frontend && npm install && cd ..
|
||||
cd backend && npm install && cd ..
|
||||
cd frontend && npm install && cd ..
|
||||
|
||||
# Start everything
|
||||
npm run dev
|
||||
@@ -71,8 +71,8 @@ npm run dev
|
||||
|
||||
## Testing
|
||||
|
||||
- **Backend**: Jest + Supertest — `cd website-monitoring-backend && npm test`
|
||||
- **Frontend**: Jest + Testing Library — `cd website-monitoring-frontend && npm test`
|
||||
- **Backend**: Jest + Supertest — `cd backend && npm test`
|
||||
- **Frontend**: Jest + Testing Library — `cd frontend && npm test`
|
||||
- Aim for tests on all API endpoints and critical business logic
|
||||
- Use meaningful test names: `it("should return 400 when URL is missing")`
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# 🔍 Website Monitoring Platform
|
||||
# ⬡ CloudLense
|
||||
|
||||
Full-stack website monitoring platform that uses **Google Lighthouse** to audit performance, SEO, accessibility, and best practices — with real-time progress tracking, team collaboration, and alerting.
|
||||
Full-stack website monitoring & performance auditing platform powered by **Google Lighthouse** — with real-time progress tracking, team collaboration, billing, and alerting.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Website Monitoring │
|
||||
│ │
|
||||
│ CloudLense │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ Frontend │───▶│ Backend │───▶│ PostgreSQL (DB) │ │
|
||||
│ │ Next.js │ │ Express │ │ via Supabase │ │
|
||||
│ │ Port 3000 │◀───│ Port 5000│ └──────────────────┘ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────┐ │
|
||||
│ │ │Lighthouse│ │
|
||||
│ └─── SSE ──│ + Chrome │ │
|
||||
│ (progress) │ Headless │ │
|
||||
│ └──────────┘ │
|
||||
│ │ Frontend │───▶│ Backend │───▶│ PostgreSQL (DB) │ │
|
||||
│ │ Next.js │ │ Express │ │ via Supabase │ │
|
||||
│ │ Port 3000│◀───│ Port 5000│ └──────────────────┘ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────┐ │
|
||||
│ │ │Lighthouse│ │
|
||||
│ └─── SSE ──│ + Chrome │ │
|
||||
│ (progress) │ Headless │ │
|
||||
│ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -32,9 +32,9 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit
|
||||
| **SEO Analysis** | ✅ Real | SEO score tracking and recommendations |
|
||||
| **Uptime Monitoring** | ✅ Real | HTTP HEAD checks every 5 min, response time + SSL tracking |
|
||||
| **Alert Engine** | ✅ Real | Evaluates scans against thresholds, auto-resolves on recovery |
|
||||
| **Notifications** | ✅ Real | Email (Resend) + webhook delivery with debouncing |
|
||||
| **Admin Dashboard** | ✅ Real | System stats, user CRUD, org management (role-protected) |
|
||||
| **Billing & Usage** | ✅ Real | 4 tiers (free/starter/pro/enterprise), usage bars, limit enforcement |
|
||||
| **Notifications** | ✅ Real | SMTP email (info@dk0.dev) + webhook delivery with debouncing |
|
||||
| **Admin Dashboard** | ✅ Real | System stats, user CRUD, org management, payments, coupons, credits, invoices |
|
||||
| **Billing & Usage** | ✅ Real | 4 tiers (free/starter/pro/enterprise), usage bars, limit enforcement, coupon system |
|
||||
| **Competitor Analysis** | ✅ Real | Lighthouse comparison + response time benchmarking |
|
||||
| **Team/Organization** | ✅ Real | Multi-user orgs with 4-level RBAC |
|
||||
| **Authentication** | ✅ Real | Supabase Auth (email, OAuth) |
|
||||
@@ -55,7 +55,7 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit
|
||||
| Containers | Docker + Docker Compose | Free |
|
||||
| Linting | ESLint + Prettier | Free (OSS) |
|
||||
| Testing | Jest + Supertest + Testing Library | Free (OSS) |
|
||||
| Email | Resend | Free tier (3000/mo) |
|
||||
| Email | SMTP (nodemailer) | Self-hosted |
|
||||
| Pre-commit | Husky | Free (OSS) |
|
||||
|
||||
## 🚀 Quick Start
|
||||
@@ -71,20 +71,20 @@ Full-stack website monitoring platform that uses **Google Lighthouse** to audit
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone <repo-url>
|
||||
cd website-monitoring
|
||||
cd cloudlense
|
||||
|
||||
# Install root dependencies (Husky, concurrently)
|
||||
npm install
|
||||
|
||||
# Setup backend
|
||||
cd website-monitoring-backend
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# Setup frontend
|
||||
cd website-monitoring-frontend
|
||||
cd frontend
|
||||
cp .env.example .env # Fill in your Supabase keys
|
||||
npm install
|
||||
cd ..
|
||||
@@ -108,8 +108,8 @@ npm run docker:up
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
website-monitoring/
|
||||
├── website-monitoring-backend/ # Express.js API + Lighthouse engine
|
||||
cloudlense/
|
||||
├── backend/ # Express.js API + Lighthouse engine
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Server entry, health check, routing
|
||||
│ │ └── routes/
|
||||
@@ -117,7 +117,7 @@ website-monitoring/
|
||||
│ ├── Dockerfile
|
||||
│ └── package.json
|
||||
│
|
||||
├── website-monitoring-frontend/ # Next.js 15 dashboard
|
||||
├── frontend/ # Next.js 15 dashboard
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # Pages & API routes (20+ endpoints)
|
||||
│ │ ├── components/ # React components (dashboard, UI, auth)
|
||||
@@ -126,7 +126,7 @@ website-monitoring/
|
||||
│ ├── Dockerfile
|
||||
│ └── package.json
|
||||
│
|
||||
├── website-monitoring-devops/ # Infrastructure
|
||||
├── devops/ # Infrastructure
|
||||
│ ├── docker-compose.yml # Full stack orchestration
|
||||
│ └── .devcontainer/ # VS Code Dev Container config
|
||||
│
|
||||
@@ -161,7 +161,37 @@ npm run test:frontend
|
||||
|----------|---------|-------------|
|
||||
| `backend.yml` | Push/PR to backend | Lint → Test → Build (Node 18 & 20) |
|
||||
| `frontend.yml` | Push/PR to frontend | Lint → Test → Build (Node 18 & 20) |
|
||||
| `docker.yml` | Push/PR to main | Docker Compose build → Backend health check |
|
||||
| `docker.yml` | Push/PR to main | Docker Compose build → Backend + Frontend health checks |
|
||||
| `deploy.yml` | Push to `main` / manual dispatch | Build + push Docker images to GHCR, then deploy on your server via SSH |
|
||||
|
||||
## 🚢 Production Deployment (Own Server)
|
||||
|
||||
The repository now includes a full CI/CD path for server deployment:
|
||||
|
||||
1. `deploy.yml` builds both images (`cloudlense-backend`, `cloudlense-frontend`) and pushes them to GHCR.
|
||||
2. The same workflow uploads `devops/docker-compose.prod.yml` + `devops/scripts/deploy-prod.sh` to your server.
|
||||
3. It then pulls the new image tag (`github.sha`) and restarts the stack with Docker Compose.
|
||||
|
||||
Server setup once:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/cloudlense
|
||||
cd /opt/cloudlense
|
||||
cp /path/to/repo/devops/.env.production.example .env
|
||||
# fill .env with real values
|
||||
```
|
||||
|
||||
Required GitHub repository secrets for deployment:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `DEPLOY_HOST` | Server hostname or IP |
|
||||
| `DEPLOY_PORT` | SSH port (usually `22`) |
|
||||
| `DEPLOY_USER` | SSH user with Docker permissions |
|
||||
| `DEPLOY_SSH_KEY` | Private SSH key for `DEPLOY_USER` |
|
||||
| `DEPLOY_PATH` | Deployment directory on server (e.g. `/opt/cloudlense`) |
|
||||
| `GHCR_USERNAME` | GitHub username for GHCR pull |
|
||||
| `GHCR_READ_TOKEN` | GitHub token/PAT with `read:packages` |
|
||||
|
||||
## 🔑 Key API Routes
|
||||
|
||||
@@ -173,6 +203,10 @@ npm run test:frontend
|
||||
| `/api/admin/stats` | GET | System-wide stats (admin only) |
|
||||
| `/api/admin/users` | GET/PATCH/DELETE | User management (admin only) |
|
||||
| `/api/admin/organizations` | GET/PATCH | Organization management (admin only) |
|
||||
| `/api/admin/payments` | GET/POST | Payment tracking & recording (admin only) |
|
||||
| `/api/admin/coupons` | GET/POST/PATCH/DELETE | Coupon management (admin only) |
|
||||
| `/api/admin/credits` | GET/POST | Account credit management (admin only) |
|
||||
| `/api/admin/invoices` | GET/POST/PATCH | Invoice creation, sending & management (admin only) |
|
||||
| `/api/billing/usage` | GET | Current org usage vs tier limits |
|
||||
| `/api/competitor-analysis` | GET/POST | Competitor benchmarking |
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# --- Stage 1: Build ---
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: Production ---
|
||||
FROM node:20-slim AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends chromium \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV CHROME_BIN=/usr/bin/chromium
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN groupadd -r app && useradd -r -g app -d /app app
|
||||
|
||||
COPY --from=builder --chown=app:app /app/dist ./dist
|
||||
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=app:app /app/package.json ./
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "const h=require('http');h.get('http://localhost:5000/health',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -33,7 +33,8 @@
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.6"
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -669,6 +670,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -3923,6 +4366,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -4704,6 +5189,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/get-uri": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz",
|
||||
@@ -7401,6 +7899,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/robots-parser": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz",
|
||||
@@ -8358,6 +8866,26 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "website-monitoring-backend",
|
||||
"name": "cloudlense-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsc --watch",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --passWithNoTests",
|
||||
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
|
||||
"lint": "eslint src/",
|
||||
@@ -50,6 +50,7 @@
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.6"
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,36 @@ import lighthouseRouter from "./routes/lighthouse.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Rate limiting (simple in-memory for single instance)
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
|
||||
const RATE_LIMIT_MAX = 30; // requests per window
|
||||
|
||||
function rateLimit(req: Request, res: Response, next: () => void) {
|
||||
const ip = req.ip || req.headers["x-forwarded-for"] || "unknown";
|
||||
const key = String(ip);
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(key);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||
return next();
|
||||
}
|
||||
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
res.status(429).json({ error: "Too many requests" });
|
||||
return;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
next();
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || "*" }));
|
||||
app.use(express.json());
|
||||
app.use(rateLimit);
|
||||
|
||||
app.get("/health", (_req: Request, res: Response) => {
|
||||
res.status(200).json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${GREEN}🚀 Website Monitoring — Local Development Setup${NC}"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# 1. Check prerequisites
|
||||
echo -e "${YELLOW}[1/6] Checking prerequisites...${NC}"
|
||||
command -v node >/dev/null 2>&1 || { echo -e "${RED}❌ Node.js not found. Install it from https://nodejs.org${NC}"; exit 1; }
|
||||
command -v docker >/dev/null 2>&1 || { echo -e "${RED}❌ Docker not found. Install Docker Desktop.${NC}"; exit 1; }
|
||||
command -v supabase >/dev/null 2>&1 || { echo -e "${RED}❌ Supabase CLI not found. Run: brew install supabase/tap/supabase${NC}"; exit 1; }
|
||||
echo " ✅ Node $(node --version), Docker, Supabase CLI found"
|
||||
|
||||
# 2. Start Supabase (if not already running)
|
||||
echo ""
|
||||
echo -e "${YELLOW}[2/6] Starting local Supabase...${NC}"
|
||||
cd frontend
|
||||
if supabase status 2>&1 | grep -q "API URL"; then
|
||||
echo " ✅ Supabase already running"
|
||||
else
|
||||
echo " ⏳ Starting Supabase (first time takes ~5 min to pull images)..."
|
||||
supabase start
|
||||
fi
|
||||
|
||||
# Extract keys from supabase status
|
||||
API_URL=$(supabase status 2>&1 | grep "API URL" | awk '{print $NF}')
|
||||
ANON_KEY=$(supabase status 2>&1 | grep "anon key" | awk '{print $NF}')
|
||||
SERVICE_KEY=$(supabase status 2>&1 | grep "service_role key" | awk '{print $NF}')
|
||||
STUDIO_URL=$(supabase status 2>&1 | grep "Studio URL" | awk '{print $NF}')
|
||||
|
||||
echo " ✅ Supabase running:"
|
||||
echo " API: $API_URL"
|
||||
echo " Studio: $STUDIO_URL"
|
||||
|
||||
# 3. Create frontend .env.local
|
||||
echo ""
|
||||
echo -e "${YELLOW}[3/6] Configuring frontend environment...${NC}"
|
||||
cat > .env.local << EOF
|
||||
# Auto-generated by dev-setup.sh — local Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=${API_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY=${SERVICE_KEY}
|
||||
LIGHTHOUSE_SERVICE_URL=http://localhost:5000
|
||||
CRON_SECRET=local-dev-secret
|
||||
EOF
|
||||
echo " ✅ Created frontend/.env.local"
|
||||
cd ..
|
||||
|
||||
# 4. Create backend .env
|
||||
echo ""
|
||||
echo -e "${YELLOW}[4/6] Configuring backend environment...${NC}"
|
||||
cat > backend/.env << EOF
|
||||
PORT=5000
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
EOF
|
||||
echo " ✅ Created backend/.env"
|
||||
|
||||
# 5. Install dependencies
|
||||
echo ""
|
||||
echo -e "${YELLOW}[5/6] Installing dependencies...${NC}"
|
||||
npm install --silent 2>/dev/null
|
||||
cd backend && npm install --silent 2>/dev/null && cd ..
|
||||
cd frontend && npm install --silent 2>/dev/null && cd ..
|
||||
echo " ✅ Dependencies installed"
|
||||
|
||||
# 6. Run database migrations
|
||||
echo ""
|
||||
echo -e "${YELLOW}[6/6] Applying database migrations...${NC}"
|
||||
cd frontend
|
||||
supabase db reset --no-seed 2>/dev/null || echo " ⚠️ Migrations may need manual review (run: cd frontend && supabase db reset)"
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}✅ Setup complete! Start developing:${NC}"
|
||||
echo ""
|
||||
echo " npm run dev"
|
||||
echo ""
|
||||
echo -e " Frontend: ${GREEN}http://localhost:3000${NC}"
|
||||
echo -e " Backend: ${GREEN}http://localhost:5000${NC}"
|
||||
echo -e " Supabase: ${GREEN}${STUDIO_URL}${NC} (admin DB browser)"
|
||||
echo ""
|
||||
echo -e "${YELLOW}📋 Quick commands:${NC}"
|
||||
echo " npm test — run all tests"
|
||||
echo " npm run build — build everything"
|
||||
echo " npm run lint — lint everything"
|
||||
echo " supabase stop — stop local DB (in frontend/)"
|
||||
echo ""
|
||||
echo -e "${YELLOW}🔑 Test cron endpoints locally:${NC}"
|
||||
echo " curl -H 'Authorization: Bearer local-dev-secret' http://localhost:3000/api/cron/uptime"
|
||||
echo ""
|
||||
@@ -0,0 +1,34 @@
|
||||
# Required image/deployment settings
|
||||
GHCR_OWNER=denshooter
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Host ports
|
||||
FRONTEND_PORT=3000
|
||||
BACKEND_PORT=5000
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=monitoring
|
||||
POSTGRES_PASSWORD=replace-with-strong-password
|
||||
POSTGRES_DB=monitoring
|
||||
|
||||
# App URLs and backend CORS
|
||||
NEXT_PUBLIC_APP_URL=https://monitoring.example.com
|
||||
CORS_ORIGIN=https://monitoring.example.com
|
||||
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
DATABASE_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres
|
||||
|
||||
# Security
|
||||
CRON_SECRET=replace-with-random-secret
|
||||
|
||||
# Optional notifications
|
||||
LIGHTHOUSE_SERVICE_URL=http://backend:5000
|
||||
RESEND_API_KEY=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
@@ -0,0 +1,70 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
image: ghcr.io/${GHCR_OWNER}/cloudlense-backend:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${BACKEND_PORT:-5000}:5000"
|
||||
environment:
|
||||
PORT: 5000
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:5000/health',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/${GHCR_OWNER}/cloudlense-frontend:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN}
|
||||
CRON_SECRET: ${CRON_SECRET}
|
||||
LIGHTHOUSE_SERVICE_URL: ${LIGHTHOUSE_SERVICE_URL:-http://backend:5000}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:3000',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../website-monitoring-backend
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:5000/health || exit 1"]
|
||||
test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:5000/health',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../website-monitoring-frontend
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:-}
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3000 || exit 1"]
|
||||
test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:3000',(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
require_env() {
|
||||
local name="$1"
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "Missing required environment variable: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env "GHCR_USERNAME"
|
||||
require_env "GHCR_READ_TOKEN"
|
||||
require_env "GHCR_OWNER"
|
||||
require_env "IMAGE_TAG"
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
echo "Missing .env in deployment directory. Create it from devops/.env.production.example." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${GHCR_READ_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG}" GHCR_OWNER="${GHCR_OWNER}" docker compose -f docker-compose.prod.yml --env-file .env pull
|
||||
IMAGE_TAG="${IMAGE_TAG}" GHCR_OWNER="${GHCR_OWNER}" docker compose -f docker-compose.prod.yml --env-file .env up -d --remove-orphans
|
||||
|
||||
docker compose -f docker-compose.prod.yml --env-file .env ps
|
||||
@@ -2,3 +2,4 @@ node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
scanner-worker
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
DEPLOYMENT_URL="${DEPLOYMENT_URL:-https://your-domain.com}"
|
||||
echo "Running uptime checks at: $DEPLOYMENT_URL/api/cron/uptime"
|
||||
|
||||
response=$(curl -s -w "\n%{http_code}" "$DEPLOYMENT_URL/api/cron/uptime")
|
||||
response=$(curl -s -w "\n%{http_code}" -H "Authorization: Bearer $CRON_SECRET" "$DEPLOYMENT_URL/api/cron/uptime")
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{ secrets.DEPLOYMENT_URL }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }} CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
DEPLOYMENT_URL="${DEPLOYMENT_URL:-https://your-domain.com}"
|
||||
echo "Triggering scan at: $DEPLOYMENT_URL/api/cron/scan?mode=all"
|
||||
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST "$DEPLOYMENT_URL/api/cron/scan?mode=all")
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $CRON_SECRET" "$DEPLOYMENT_URL/api/cron/scan?mode=all")
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# --- Stage 1: Dependencies ---
|
||||
FROM node:20-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# --- Stage 2: Build ---
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TAILWIND_DISABLE_OXIDE=1
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-key
|
||||
ARG SUPABASE_SERVICE_ROLE_KEY=placeholder-service-key
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 3: Production ---
|
||||
FROM node:20-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
RUN groupadd -r app && useradd -r -g app -d /app app
|
||||
|
||||
COPY --from=builder --chown=app:app /app/.next/standalone ./
|
||||
COPY --from=builder --chown=app:app /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=app:app /app/public ./public
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "const h=require('http');h.get('http://localhost:3000',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -25,7 +25,7 @@ This project is a modern website monitoring platform built with Next.js (App Rou
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd website-monitoring-frontend
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
@@ -47,6 +47,6 @@ This will:
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
cd website-monitoring-frontend
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
@@ -203,4 +203,8 @@ CREATE TABLE IF NOT EXISTS alert_configurations (
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add template_hash to pages table for layout deduplication
|
||||
ALTER TABLE pages ADD COLUMN IF NOT EXISTS template_hash VARCHAR;
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_template_hash ON pages(template_hash) WHERE template_hash IS NOT NULL;
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
eslint: {
|
||||
// Do not fail production builds due to ESLint errors
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -28,6 +28,7 @@
|
||||
"lighthouse": "^12.6.1",
|
||||
"lucide-react": "^0.477.0",
|
||||
"next": "^15.2.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"postcss": "^8.5.3",
|
||||
"puppeteer": "^24.7.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -48,6 +49,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.1.2",
|
||||
@@ -3754,6 +3756,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
@@ -11705,6 +11717,15 @@
|
||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "website-monitoring",
|
||||
"name": "cloudlense-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -31,6 +31,7 @@
|
||||
"lighthouse": "^12.6.1",
|
||||
"lucide-react": "^0.477.0",
|
||||
"next": "^15.2.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"postcss": "^8.5.3",
|
||||
"puppeteer": "^24.7.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -51,6 +52,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.1.2",
|
||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 425 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
@@ -207,6 +207,7 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||
title VARCHAR,
|
||||
description TEXT,
|
||||
content_hash VARCHAR,
|
||||
template_hash VARCHAR,
|
||||
content_type VARCHAR,
|
||||
status_code INTEGER,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
@@ -0,0 +1,178 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
import type { Coupon } from "@/types/billing";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const activeOnly = searchParams.get("activeOnly") === "true";
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from("coupons")
|
||||
.select("*, creator:users!coupons_created_by_fkey(id, email, name)", { count: "exact" })
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (activeOnly) query = query.eq("is_active", true);
|
||||
|
||||
const { data, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const coupons = (data || []) as unknown as Coupon[];
|
||||
|
||||
return NextResponse.json({
|
||||
coupons,
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching coupons:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch coupons" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
code,
|
||||
description,
|
||||
discount_type,
|
||||
discount_value,
|
||||
currency,
|
||||
max_redemptions,
|
||||
applicable_tiers,
|
||||
valid_from,
|
||||
valid_until,
|
||||
} = body;
|
||||
|
||||
if (!code || !discount_type || discount_value === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "code, discount_type, and discount_value are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (discount_type === "percentage" && (discount_value < 1 || discount_value > 100)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Percentage discount must be between 1 and 100" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("coupons")
|
||||
.insert({
|
||||
code: code.toUpperCase().trim(),
|
||||
description,
|
||||
discount_type,
|
||||
discount_value: Math.round(discount_value),
|
||||
currency: currency || "EUR",
|
||||
max_redemptions: max_redemptions || null,
|
||||
applicable_tiers: applicable_tiers || ["free", "starter", "professional"],
|
||||
valid_from: valid_from || new Date().toISOString(),
|
||||
valid_until: valid_until || null,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === "23505") {
|
||||
return NextResponse.json({ error: "A coupon with this code already exists" }, { status: 409 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const coupon = data as unknown as Coupon;
|
||||
|
||||
return NextResponse.json({ coupon });
|
||||
} catch (error) {
|
||||
console.error("Error creating coupon:", error);
|
||||
return NextResponse.json({ error: "Failed to create coupon" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, ...updates } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Coupon id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedFields = [
|
||||
"description",
|
||||
"discount_type",
|
||||
"discount_value",
|
||||
"max_redemptions",
|
||||
"applicable_tiers",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"is_active",
|
||||
];
|
||||
|
||||
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
for (const key of allowedFields) {
|
||||
if (key in updates) safeUpdates[key] = updates[key];
|
||||
}
|
||||
|
||||
if (updates.code) safeUpdates.code = updates.code.toUpperCase().trim();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("coupons")
|
||||
.update(safeUpdates)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const coupon = data as unknown as Coupon;
|
||||
|
||||
return NextResponse.json({ coupon });
|
||||
} catch (error) {
|
||||
console.error("Error updating coupon:", error);
|
||||
return NextResponse.json({ error: "Failed to update coupon" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Coupon id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("coupons").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting coupon:", error);
|
||||
return NextResponse.json({ error: "Failed to delete coupon" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
import type { CreditTransaction } from "@/types/billing";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const orgId = searchParams.get("orgId");
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
if (orgId) {
|
||||
// Get specific org's credit balance and transaction history
|
||||
const [orgResult, transactionsResult] = await Promise.all([
|
||||
supabase
|
||||
.from("organizations")
|
||||
.select("id, name, credit_balance")
|
||||
.eq("id", orgId)
|
||||
.single(),
|
||||
supabase
|
||||
.from("credit_transactions")
|
||||
.select(
|
||||
"*, creator:users!credit_transactions_created_by_fkey(id, email, name)",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.eq("organization_id", orgId)
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1),
|
||||
]);
|
||||
|
||||
if (orgResult.error) throw orgResult.error;
|
||||
|
||||
return NextResponse.json({
|
||||
organization: orgResult.data,
|
||||
transactions: (transactionsResult.data || []) as unknown as CreditTransaction[],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: transactionsResult.count || 0,
|
||||
pages: Math.ceil((transactionsResult.count || 0) / limit),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// List all organizations with credit balances
|
||||
const { data: orgs, count, error } = await supabase
|
||||
.from("organizations")
|
||||
.select("id, name, credit_balance, subscription_tier", { count: "exact" })
|
||||
.order("credit_balance", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({
|
||||
organizations: orgs || [],
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching credits:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch credits" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { organization_id, amount, type, reason, notes } = body;
|
||||
|
||||
if (!organization_id || amount === undefined || !type || !reason) {
|
||||
return NextResponse.json(
|
||||
{ error: "organization_id, amount, type, and reason are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!["credit", "debit"].includes(type)) {
|
||||
return NextResponse.json({ error: "type must be 'credit' or 'debit'" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current balance
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.select("id, name, credit_balance")
|
||||
.eq("id", organization_id)
|
||||
.single();
|
||||
|
||||
if (orgError || !org) {
|
||||
return NextResponse.json({ error: "Organization not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const absAmount = Math.abs(Math.round(amount));
|
||||
const signedAmount = type === "credit" ? absAmount : -absAmount;
|
||||
const currentBalance = typeof org.credit_balance === "number" ? org.credit_balance : 0;
|
||||
const newBalance = currentBalance + signedAmount;
|
||||
|
||||
if (newBalance < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Insufficient balance. Current: ${currentBalance}, debit: ${absAmount}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Insert transaction and update balance atomically
|
||||
const { data, error: txError } = await supabase
|
||||
.from("credit_transactions")
|
||||
.insert({
|
||||
organization_id,
|
||||
amount: signedAmount,
|
||||
balance_after: newBalance,
|
||||
type,
|
||||
reason,
|
||||
notes,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (txError) throw txError;
|
||||
const transaction = data as unknown as CreditTransaction;
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("organizations")
|
||||
.update({ credit_balance: newBalance })
|
||||
.eq("id", organization_id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
return NextResponse.json({
|
||||
transaction,
|
||||
new_balance: newBalance,
|
||||
organization: { id: org.id, name: org.name },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing credit transaction:", error);
|
||||
return NextResponse.json({ error: "Failed to process credit transaction" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import { invoiceEmail } from "@/lib/email-templates";
|
||||
import type { Invoice } from "@/types/billing";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const orgId = searchParams.get("orgId");
|
||||
const status = searchParams.get("status");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from("invoices")
|
||||
.select(
|
||||
"*, organization:organizations(id, name, billing_email), creator:users!invoices_created_by_fkey(id, email, name)",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (orgId) query = query.eq("organization_id", orgId);
|
||||
if (status) query = query.eq("status", status);
|
||||
|
||||
const { data, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const invoices = (data || []) as unknown as Invoice[];
|
||||
|
||||
return NextResponse.json({
|
||||
invoices,
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching invoices:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch invoices" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { organization_id, amount, currency, items, due_date, notes, status: invoiceStatus } = body;
|
||||
|
||||
if (!organization_id || amount === undefined || !items?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "organization_id, amount, and items are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate invoice number: INV-YYYYMM-XXXX
|
||||
const now = new Date();
|
||||
const prefix = `INV-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
const { count } = await supabase
|
||||
.from("invoices")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.like("invoice_number", `${prefix}%`);
|
||||
const seq = String((count || 0) + 1).padStart(4, "0");
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("invoices")
|
||||
.insert({
|
||||
invoice_number: `${prefix}-${seq}`,
|
||||
organization_id,
|
||||
amount: Math.round(amount),
|
||||
currency: currency || "EUR",
|
||||
status: invoiceStatus || "draft",
|
||||
items,
|
||||
due_date: due_date || null,
|
||||
notes,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select("*, organization:organizations(id, name, billing_email)")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const invoice = data as unknown as Invoice;
|
||||
|
||||
return NextResponse.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error("Error creating invoice:", error);
|
||||
return NextResponse.json({ error: "Failed to create invoice" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, action, ...updates } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Invoice id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Send invoice via email
|
||||
if (action === "send") {
|
||||
const { data } = await supabase
|
||||
.from("invoices")
|
||||
.select("*, organization:organizations(id, name, billing_email)")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
const invoice = data as unknown as Invoice | null;
|
||||
|
||||
if (!invoice) {
|
||||
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const org = invoice.organization;
|
||||
const recipientEmail = org?.billing_email;
|
||||
|
||||
if (!recipientEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Organization has no billing email configured" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||
const template = invoiceEmail({
|
||||
organizationName: org?.name || "Unknown",
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
dueDate: invoice.due_date,
|
||||
items: (invoice.items) || [],
|
||||
dashboardUrl: `${appUrl}/dashboard/settings`,
|
||||
});
|
||||
|
||||
const sent = await sendEmail({
|
||||
to: recipientEmail,
|
||||
subject: template.subject,
|
||||
html: template.html,
|
||||
text: template.text,
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("invoices")
|
||||
.update({ status: "sent", updated_at: new Date().toISOString() })
|
||||
.eq("id", id);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Invoice sent" });
|
||||
}
|
||||
|
||||
// Mark as paid
|
||||
if (action === "mark_paid") {
|
||||
const { data, error } = await supabase
|
||||
.from("invoices")
|
||||
.update({ status: "paid", paid_at: new Date().toISOString(), updated_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const invoice = data as unknown as Invoice;
|
||||
return NextResponse.json({ invoice });
|
||||
}
|
||||
|
||||
// Generic update
|
||||
const allowedFields = ["status", "amount", "items", "due_date", "notes"];
|
||||
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
for (const key of allowedFields) {
|
||||
if (key in updates) safeUpdates[key] = updates[key];
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("invoices")
|
||||
.update(safeUpdates)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const invoice = data as unknown as Invoice;
|
||||
|
||||
return NextResponse.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error("Error updating invoice:", error);
|
||||
return NextResponse.json({ error: "Failed to update invoice" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/organizations
|
||||
*
|
||||
* List all organizations with usage stats.
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
@@ -68,6 +73,9 @@ export async function GET(request: Request) {
|
||||
* Update organization: change tier, deactivate, etc.
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { organizationId, updates } = await request.json();
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
import type { Payment } from "@/types/billing";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const orgId = searchParams.get("orgId");
|
||||
const status = searchParams.get("status");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from("payments")
|
||||
.select(
|
||||
"*, organization:organizations(id, name), creator:users!payments_created_by_fkey(id, email, name)",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (orgId) query = query.eq("organization_id", orgId);
|
||||
if (status) query = query.eq("status", status);
|
||||
|
||||
const { data, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const payments = (data || []) as unknown as Payment[];
|
||||
|
||||
return NextResponse.json({
|
||||
payments,
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching payments:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch payments" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { organization_id, amount, currency, status, method, description, notes } = body;
|
||||
|
||||
if (!organization_id || amount === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "organization_id and amount are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("payments")
|
||||
.insert({
|
||||
organization_id,
|
||||
amount: Math.round(amount),
|
||||
currency: currency || "EUR",
|
||||
status: status || "completed",
|
||||
method: method || "manual",
|
||||
description,
|
||||
notes,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select("*, organization:organizations(id, name)")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const payment = data as unknown as Payment;
|
||||
|
||||
// If payment is completed, optionally add credits
|
||||
if (payment.status === "completed" && body.add_as_credit) {
|
||||
const { data: org } = await supabase
|
||||
.from("organizations")
|
||||
.select("credit_balance")
|
||||
.eq("id", organization_id)
|
||||
.single();
|
||||
|
||||
const currentBalance = typeof org?.credit_balance === "number" ? org.credit_balance : 0;
|
||||
const newBalance = currentBalance + Math.round(amount);
|
||||
|
||||
await supabase
|
||||
.from("credit_transactions")
|
||||
.insert({
|
||||
organization_id,
|
||||
amount: Math.round(amount),
|
||||
balance_after: newBalance,
|
||||
type: "credit",
|
||||
reason: "payment",
|
||||
reference_id: payment.id,
|
||||
created_by: authResult.userId,
|
||||
});
|
||||
|
||||
await supabase
|
||||
.from("organizations")
|
||||
.update({ credit_balance: newBalance })
|
||||
.eq("id", organization_id);
|
||||
}
|
||||
|
||||
return NextResponse.json({ payment });
|
||||
} catch (error) {
|
||||
console.error("Error creating payment:", error);
|
||||
return NextResponse.json({ error: "Failed to create payment" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
*
|
||||
* Returns system-wide statistics for the admin dashboard.
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
*
|
||||
* List all users with their organization memberships and usage stats.
|
||||
* Query params: ?page=1&limit=20&search=keyword
|
||||
* Requires admin or owner role.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
@@ -79,6 +84,9 @@ export async function GET(request: Request) {
|
||||
* Body: { userId, action, value }
|
||||
*/
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { userId, action, value } = await request.json();
|
||||
@@ -152,6 +160,9 @@ export async function PATCH(request: Request) {
|
||||
* Body: { userId }
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const auth = await requireAdmin(request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { userId } = await request.json();
|
||||
@@ -1,16 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { TIER_LIMITS } from "@/services/tierLimits";
|
||||
import { requireOrgMembership } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/billing/usage
|
||||
*
|
||||
* Returns current usage vs tier limits for an organization.
|
||||
* Requires authenticated user who is a member of the organization.
|
||||
* Query params: ?organizationId=xxx
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const supabase = getSupabaseAdmin();
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
|
||||
@@ -18,6 +19,12 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "organizationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify caller belongs to this organization
|
||||
const auth = await requireOrgMembership(organizationId, request);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
// Get organization with tier info
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
@@ -2,8 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import { scanScheduler } from "@/services/scanScheduler";
|
||||
import { lighthouseScanner } from "@/services/lighthouseScanner";
|
||||
import { logError } from "@/utils/errorUtils";
|
||||
import { verifyCronSecret } from "@/lib/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = url.searchParams.get("mode") || "all"; // "scheduled", "change_detection", "all"
|
||||
@@ -1,16 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { performUptimeChecks, evaluateUptimeAlerts } from "@/services/uptimeService";
|
||||
import { verifyCronSecret } from "@/lib/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/cron/uptime
|
||||
*
|
||||
* Performs uptime checks on all active websites and evaluates alert rules.
|
||||
* Designed to be called by a cron job (e.g., GitHub Actions, Vercel Cron, or external scheduler).
|
||||
* Requires CRON_SECRET authorization in production.
|
||||
*
|
||||
* Query params:
|
||||
* - alerts=true (default) — also evaluate alert rules after checks
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||