first commit
This commit is contained in:
commit
146ef59847
|
|
@ -0,0 +1,61 @@
|
|||
# Luxe Platform Master Environment Variables
|
||||
|
||||
# Node & General App Config
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
GATEWAY_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL=postgresql://luxe_admin:luxe_secure_password_2026@localhost:5432/luxe_db?schema=public&connection_limit=20&pool_timeout=10
|
||||
|
||||
# Caching & Queue (Redis)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Security & Secrets
|
||||
JWT_SECRET=super_secret_luxe_jwt_signing_key_2026_production_grade
|
||||
JWT_ACCESS_EXPIRATION=15m
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
ARGON_MEMORY_COST=65536
|
||||
ARGON_TIME_COST=3
|
||||
|
||||
# Object Storage (MinIO / AWS S3 / Cloudflare R2)
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=luxe-media
|
||||
S3_USE_SSL=false
|
||||
|
||||
# Search Service (Meilisearch / OpenSearch)
|
||||
MEILI_HOST=http://localhost:7700
|
||||
MEILI_MASTER_KEY=luxe_meili_master_key_2026
|
||||
|
||||
# Email / Notifications (SMTP / Nodemailer)
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
EMAIL_FROM="Luxe Marketplace <no-reply@luxe.com>"
|
||||
|
||||
# SMS & WhatsApp (Twilio)
|
||||
TWILIO_ACCOUNT_SID=AC_dummy_account_sid
|
||||
TWILIO_AUTH_TOKEN=dummy_auth_token
|
||||
TWILIO_PHONE_NUMBER=+15005550006
|
||||
|
||||
# Telegram Bot Integration
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyZ
|
||||
|
||||
# Payment Gateways
|
||||
STRIPE_SECRET_KEY=sk_test_51LuxeMarketplaceDummyStripeKey
|
||||
STRIPE_WEBHOOK_SECRET=whsec_dummy_webhook_secret
|
||||
RAZORPAY_KEY_ID=rzp_test_luxe_dummy_key
|
||||
RAZORPAY_KEY_SECRET=dummy_razorpay_secret
|
||||
|
||||
# Observability & Metrics
|
||||
OTEL_SERVICE_NAME=luxe-gateway
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
PROMETHEUS_METRICS_ENABLED=true
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Node dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.staging
|
||||
|
||||
# Prisma
|
||||
/packages/database/src/generated/
|
||||
|
||||
# Logs & Coverage
|
||||
logs/
|
||||
*.log
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# IDE & System files
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
# 📖 Luxe Platform - Backend API Specification & Technical Documentation
|
||||
|
||||
This document provides a comprehensive technical specification for building the backend RESTful API services and database architecture for **Luxe**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary & System Architecture
|
||||
|
||||
### 1.1 Overview
|
||||
Luxe is a multi-tier platform connecting **Users** (members/visitors) with **Clients** (service providers/listed profiles) and managed by **Admins**.
|
||||
|
||||
### 1.2 User Roles & Access Rights Matrix
|
||||
| Role | Code Identifier | Permissions & Capabilities |
|
||||
| :--- | :--- | :--- |
|
||||
| **User** | `user` | Browse profiles, search with filters, save favorite listings, write reviews, interact with blog/forum, send messages. |
|
||||
| **Client** | `advertiser` / `client` | All `user` capabilities + create and manage listed profile(s), upload gallery media, set availability & pricing, request verification, view analytics. |
|
||||
| **Admin** | `admin` / `super_admin` | System-wide management, verify client profiles, moderate user content, full CRUD for Blogs & Forum, manage Cities & Categories, view dashboard analytics & audit logs. |
|
||||
|
||||
---
|
||||
|
||||
## 2. API Design Conventions & Protocols
|
||||
|
||||
* **Base URL**: `https://api.luxe.com/api/v1`
|
||||
* **Transport**: HTTPS with TLS 1.3
|
||||
* **Authentication**: JSON Web Tokens (JWT)
|
||||
* Header: `Authorization: Bearer <access_token>`
|
||||
* Access Token expiry: 15–60 minutes
|
||||
* Refresh Token expiry: 7–30 days (stored in HttpOnly, Secure Cookie)
|
||||
|
||||
### Standard Response Schemas
|
||||
|
||||
#### Success (`200 OK`, `201 Created`)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation executed successfully",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Paginated List Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [ ... ],
|
||||
"pagination": {
|
||||
"total": 245,
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Response (`400 Bad Request`, `401 Unauthorized`, `403 Forbidden`, `404 Not Found`, `422 Unprocessable Entity`, `500 Internal Error`)
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Validation Error",
|
||||
"errors": [
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Email address is already in use"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Complete API Endpoint Catalog
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Module 1: Authentication & Identity (`/auth`)
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/auth/register` | Public | Register new user as `USER` or `CLIENT`. Returns access token & user object. |
|
||||
| `POST` | `/auth/login` | Public | Authenticate user credentials. Sets HttpOnly refresh token cookie. |
|
||||
| `POST` | `/auth/logout` | Authenticated | Revoke refresh token & invalidate current session. |
|
||||
| `POST` | `/auth/refresh-token` | Public | Exchange refresh token cookie for a new access token. |
|
||||
| `POST` | `/auth/forgot-password` | Public | Send password reset link or OTP to user email. |
|
||||
| `/auth/reset-password` | `POST` | Public | Reset password using reset token/OTP. |
|
||||
| `/auth/verify-otp` | `POST` | Authenticated | Verify email or phone number OTP code. |
|
||||
| `/auth/me` | `GET` | Authenticated | Get current authenticated user profile & permissions. |
|
||||
|
||||
---
|
||||
|
||||
### 👤 Module 2: User Account & Favorites (`/users`)
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/users/profile` | Authenticated | Fetch logged-in user profile metadata. |
|
||||
| `PUT` | `/users/profile` | Authenticated | Update user display name, avatar, bio, and preferences. |
|
||||
| `GET` | `/users/favorites` | User | Get paginated list of user's saved/bookmarked client profiles. |
|
||||
| `POST` | `/users/favorites/:profileId` | User | Add client profile to user favorites. |
|
||||
| `DELETE` | `/users/favorites/:profileId` | User | Remove client profile from user favorites. |
|
||||
| `PUT` | `/users/change-password` | Authenticated | Update user password (requires old password). |
|
||||
| `GET` | `/users/notifications` | Authenticated | Fetch notifications for current user. |
|
||||
|
||||
---
|
||||
|
||||
### 💋 Module 3: Client & Listed Profile Management (`/clients`)
|
||||
*Dedicated to `CLIENT` accounts to manage their public listing.*
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/clients/my-profile` | Client | Get client's listing details, completion status, and verification state. |
|
||||
| `POST` | `/clients/my-profile` | Client | Create listed profile draft. |
|
||||
| `PUT` | `/clients/my-profile` | Client | Full update of listed profile (name, age, city, area, priceFrom, tagline, categories, languages, height). |
|
||||
| `PATCH` | `/clients/my-profile/status` | Client | Toggle online availability (`isOnline: true/false`). |
|
||||
| `POST` | `/clients/my-profile/gallery` | Client | Upload & append image/video to profile gallery. |
|
||||
| `DELETE` | `/clients/my-profile/gallery/:mediaId` | Client | Delete image/video from gallery. |
|
||||
| `POST` | `/clients/my-profile/verification` | Client | Submit verification photos/documents for Admin approval. |
|
||||
| `GET` | `/clients/analytics` | Client | Fetch client analytics (profile views, unique visitors, phone clicks, favorite counts). |
|
||||
|
||||
---
|
||||
|
||||
### 🔍 Module 4: Public Directory & Search (`/profiles`, `/search`, `/cities`, `/categories`)
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/search/profiles` | Public | Multi-filter profile search (Query params: `query`, `city`, `area`, `categories[]`, `ageMin`, `ageMax`, `priceMin`, `priceMax`, `verified`, `online`, `premium`, `rating`, `sortBy`, `page`, `pageSize`). |
|
||||
| `GET` | `/profiles/featured` | Public | Fetch featured & high-ranking client profiles. |
|
||||
| `/profiles/trending` | `GET` | Public | Fetch trending/popular client profiles. |
|
||||
| `GET` | `/profiles/:slug` | Public | Get full public profile information by unique slug. |
|
||||
| `GET` | `/profiles/:slug/reviews` | Public | Get reviews and aggregate ratings for a client profile. |
|
||||
| `POST` | `/profiles/:slug/reviews` | User | Submit a review & rating (1 to 5 stars) for a client profile. |
|
||||
| `GET` | `/cities` | Public | Fetch active cities with listing counts and cover images. |
|
||||
| `GET` | `/categories` | Public | Fetch all available profile service categories and icons. |
|
||||
|
||||
---
|
||||
|
||||
### 📰 Module 5: Blog & CMS (`/blogs`)
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/blogs` | Public | Fetch paginated published blog posts (filters: `category`, `tag`, `search`). |
|
||||
| `GET` | `/blogs/:slug` | Public | Get single blog post by slug with author info and reading time. |
|
||||
| `POST` | `/blogs/:id/like` | Authenticated | Toggle like on a blog post. |
|
||||
| `GET` | `/blogs/:id/comments` | Public | Fetch comments on a blog post. |
|
||||
| `POST` | `/blogs/:id/comments` | Authenticated | Post a comment on a blog post. |
|
||||
| `GET` | `/blogs/categories` | Public | Fetch all blog categories. |
|
||||
|
||||
---
|
||||
|
||||
### 💬 Module 6: Forum & Community (`/forum`)
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/forum/topics` | Public | Get forum discussion topics (supports sorting by `newest`, `popular`, `pinned`). |
|
||||
| `GET` | `/forum/topics/:slug` | Public | View forum topic detail with replies thread. |
|
||||
| `POST` | `/forum/topics` | Authenticated | Create a new discussion thread. |
|
||||
| `POST` | `/forum/topics/:id/reply` | Authenticated | Add a reply to a discussion thread. |
|
||||
| `POST` | `/forum/topics/:id/like` | Authenticated | Upvote/like a topic or reply. |
|
||||
|
||||
---
|
||||
|
||||
### 💬 Module 7: Messaging & Notifications (`/messages`, `/notifications`)
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/messages/conversations` | Authenticated | Get list of direct message conversations. |
|
||||
| `GET` | `/messages/conversations/:id` | Authenticated | Get message history with another user/client. |
|
||||
| `POST` | `/messages/send` | Authenticated | Send a direct message. (Emits WebSockets event `new_message`). |
|
||||
| `GET` | `/notifications` | Authenticated | Fetch notifications (unread & read). |
|
||||
| `PATCH` | `/notifications/:id/read` | Authenticated | Mark notification as read. |
|
||||
| `PATCH` | `/notifications/read-all` | Authenticated | Mark all notifications as read. |
|
||||
|
||||
---
|
||||
|
||||
### 📁 Module 8: Media & Cloud File Upload (`/media`)
|
||||
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/media/presigned-url` | Authenticated | Generate AWS S3 / Cloudinary presigned upload URL for client-side upload. |
|
||||
| `POST` | `/media/upload` | Authenticated | Direct multipart form file upload endpoint (fallback). |
|
||||
|
||||
---
|
||||
|
||||
### 🛡️ Module 9: Admin Panel API Suite (`/admin`)
|
||||
|
||||
#### 9.1 Dashboard & Analytics
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/admin/stats/overview` | Admin | Overall system counters (Total Users, Total Clients, Active Listings, Verification Queue, Revenue). |
|
||||
| `GET` | `/admin/stats/growth` | Admin | Time-series data for registrations, pageviews, active subscriptions. |
|
||||
|
||||
#### 9.2 User & Client Moderation
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/admin/users` | Admin | Get paginated list of all users with search, role filter (`USER`, `CLIENT`, `ADMIN`), status filter. |
|
||||
| `GET` | `/admin/users/:id` | Admin | Get detailed user record, IPs, login logs, and associated profiles. |
|
||||
| `PATCH` | `/admin/users/:id/status` | Admin | Update user status (`active`, `suspended`, `banned`). |
|
||||
| `PATCH` | `/admin/users/:id/role` | Admin | Modify user role (e.g. promote to `ADMIN` or `CLIENT`). |
|
||||
|
||||
#### 9.3 Client Verification Queue & Profile Controls
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/admin/verifications` | Admin | Get pending client verification requests with uploaded ID proofs. |
|
||||
| `POST` | `/admin/verifications/:id/approve` | Admin | Approve verification (`isVerified = true`), notify client. |
|
||||
| `POST` | `/admin/verifications/:id/reject` | Admin | Reject verification with reason note. |
|
||||
| `PATCH` | `/admin/profiles/:id/premium` | Admin | Toggle client profile badge (`isPremium = true/false`). |
|
||||
| `DELETE` | `/admin/profiles/:id` | Admin | Moderation action: Soft-delete or remove violating listing. |
|
||||
|
||||
#### 9.4 Blog CMS Management
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/admin/blogs` | Admin | List all blog posts including drafts and scheduled posts. |
|
||||
| `POST` | `/admin/blogs` | Admin | Create a new blog post (`title`, `slug`, `excerpt`, `content`, `coverImage`, `category`, `tags`). |
|
||||
| `PUT` | `/admin/blogs/:id` | Admin | Edit existing blog post. |
|
||||
| `DELETE` | `/admin/blogs/:id` | Admin | Delete blog post. |
|
||||
| `POST` | `/admin/blogs/categories` | Admin | Create blog category. |
|
||||
|
||||
#### 9.5 Platform Meta Data Management (Cities & Categories)
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/admin/cities` | Admin | Add new city to directory. |
|
||||
| `PUT` | `/admin/cities/:id` | Admin | Edit city details, image, or slug. |
|
||||
| `DELETE` | `/admin/cities/:id` | Admin | Delete city. |
|
||||
| `POST` | `/admin/categories` | Admin | Add new profile category/service. |
|
||||
| `PUT` | `/admin/categories/:id` | Admin | Edit category details. |
|
||||
| `DELETE` | `/admin/categories/:id` | Admin | Delete category. |
|
||||
|
||||
#### 9.6 Moderation, Reports & Audit Logs
|
||||
| Method | Endpoint | Access Level | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/admin/reviews` | Admin | Moderate user-submitted profile reviews. |
|
||||
| `POST` | `/admin/reviews/:id/approve` | Admin | Approve published review. |
|
||||
| `DELETE` | `/admin/reviews/:id` | Admin | Delete fake/inappropriate review. |
|
||||
| `GET` | `/admin/audit-logs` | Admin | View system audit log of admin actions. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Database Entity Schema Specification (PostgreSQL / Prisma / MongoDB)
|
||||
|
||||
### 4.1 `User`
|
||||
* `id`: String (UUID / Primary Key)
|
||||
* `email`: String (Unique, Indexed)
|
||||
* `username`: String (Unique)
|
||||
* `passwordHash`: String
|
||||
* `role`: Enum (`user`, `client`, `admin`, `super_admin`)
|
||||
* `isVerified`: Boolean (default: false)
|
||||
* `isPremium`: Boolean (default: false)
|
||||
* `createdAt`: Timestamp
|
||||
* `updatedAt`: Timestamp
|
||||
|
||||
### 4.2 `Profile` (Client Listings)
|
||||
* `id`: String (UUID)
|
||||
* `userId`: String (Foreign Key -> User.id)
|
||||
* `slug`: String (Unique, Indexed)
|
||||
* `name`: String
|
||||
* `age`: Integer
|
||||
* `cityId`: String (Foreign Key -> City.id)
|
||||
* `area`: String
|
||||
* `avatar`: String (URL)
|
||||
* `coverImage`: String (URL)
|
||||
* `rating`: Float (default: 0.0)
|
||||
* `reviewCount`: Integer (default: 0)
|
||||
* `isVerified`: Boolean
|
||||
* `isPremium`: Boolean
|
||||
* `isOnline`: Boolean
|
||||
* `priceFrom`: Float
|
||||
* `currency`: String (default: 'USD')
|
||||
* `height`: String
|
||||
* `tagline`: String
|
||||
* `categories`: String[] (Array of Category slugs)
|
||||
* `languages`: String[]
|
||||
* `createdAt`: Timestamp
|
||||
|
||||
### 4.3 `BlogPost`
|
||||
* `id`: String (UUID)
|
||||
* `slug`: String (Unique)
|
||||
* `title`: String
|
||||
* `excerpt`: Text
|
||||
* `content`: Text
|
||||
* `coverImage`: String (URL)
|
||||
* `authorName`: String
|
||||
* `authorAvatar`: String
|
||||
* `category`: String
|
||||
* `tags`: String[]
|
||||
* `readingTime`: Integer (minutes)
|
||||
* `likes`: Integer (default: 0)
|
||||
* `publishedAt`: Timestamp
|
||||
|
||||
### 4.4 `City`
|
||||
* `id`: String (UUID)
|
||||
* `slug`: String (Unique)
|
||||
* `name`: String
|
||||
* `country`: String
|
||||
* `profileCount`: Integer
|
||||
* `image`: String (URL)
|
||||
|
||||
### 4.5 `Category`
|
||||
* `id`: String (UUID)
|
||||
* `slug`: String (Unique)
|
||||
* `name`: String
|
||||
* `icon`: String
|
||||
* `profileCount`: Integer
|
||||
|
||||
---
|
||||
|
||||
## 5. Security & Implementation Best Practices
|
||||
|
||||
1. **Password Hashing**: Use **Argon2id** or **Bcrypt** with salt rounds >= 12.
|
||||
2. **CORS Configuration**: Restrict origins to trusted domains (`https://luxe.com`).
|
||||
3. **Rate Limiting**: Enforce 100 requests per 15-minute window for standard endpoints; 5 attempts per 15 minutes for `/auth/login`.
|
||||
4. **Input Sanitization**: Validate all requests using `Zod` or `Joi` schemas to prevent SQL Injection and XSS.
|
||||
5. **Real-time Engine**: Use **Socket.io** or WebSockets for messaging and online status events.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Multi-stage production Dockerfile for Luxe API Gateway
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy monorepo configuration & package files
|
||||
COPY package.json tsconfig.json pnpm-workspace.yaml ./
|
||||
COPY packages/database/package.json ./packages/database/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY services/gateway/package.json ./services/gateway/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --ignore-scripts
|
||||
|
||||
# Copy source code
|
||||
COPY packages/database ./packages/database
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY services/gateway ./services/gateway
|
||||
|
||||
# Build Prisma & TypeScript packages
|
||||
RUN npm run db:generate
|
||||
RUN npm run build
|
||||
|
||||
# Production Runner stage
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
COPY --from=builder /app ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start", "--workspace=@luxe/gateway"]
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: luxe_postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: luxe_admin
|
||||
POSTGRES_PASSWORD: luxe_secure_password_2026
|
||||
POSTGRES_DB: luxe_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U luxe_admin -d luxe_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: luxe_redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2024-01-18T22-51-28Z
|
||||
container_name: luxe_minio
|
||||
restart: always
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.15
|
||||
container_name: luxe_mailpit
|
||||
restart: always
|
||||
ports:
|
||||
- "1025:1025" # SMTP port
|
||||
- "8025:8025" # Web UI
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.50.1
|
||||
container_name: luxe_prometheus
|
||||
restart: always
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.3.3
|
||||
container_name: luxe_grafana
|
||||
restart: always
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "luxe-gateway"
|
||||
static_configs:
|
||||
- targets: ["host.docker.internal:3000"]
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# 🏛️ Luxe Platform - Backend Architecture Blueprint
|
||||
|
||||
## System Overview
|
||||
**Luxe Platform** is an enterprise classified marketplace designed for high availability, zero-downtime deployment, horizontal scale, and millions of concurrent users & listings.
|
||||
|
||||
The backend is built following **Clean Architecture** as a **Microservice-Ready Monorepo**.
|
||||
|
||||
```
|
||||
+------------------------+
|
||||
| Cloudflare WAF / CDN |
|
||||
+-----------+------------+
|
||||
|
|
||||
v
|
||||
+------------------------+
|
||||
| Fastify API Gateway |
|
||||
+-----------+------------+
|
||||
|
|
||||
+-------------------------------+-------------------------------+
|
||||
| | |
|
||||
v v v
|
||||
+--------------+ +---------------+ +---------------+
|
||||
| Identity & | | Listing & | | Wallet & |
|
||||
| Auth Service | | Search Service| | Ledger Service|
|
||||
+--------------+ +---------------+ +---------------+
|
||||
| | |
|
||||
+-------------------------------+-------------------------------+
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| PostgreSQL 16 (Primary) |
|
||||
| Redis 7 (Cache & BullMQ) |
|
||||
| MinIO / Cloudflare R2 |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workspace Structure
|
||||
- **`packages/database`**: Central Prisma ORM data layer with PostgreSQL schemas, soft deletes, GIN/B-tree indexing, and seeder scripts.
|
||||
- **`packages/shared`**: Enterprise core utilities, standard HTTP response wrappers, Argon2 password hashing, JWT token rotation, Zod request schemas, and dynamic RBAC policies.
|
||||
- **`services/gateway`**: High-performance Fastify API Gateway handling TLS termination, rate limiting, request validation, authentication hooks, Swagger UI docs, and Socket.IO real-time websockets.
|
||||
- **`services/notification-service`**: Async background worker powered by BullMQ processing multi-channel notifications (Email, SMS, WhatsApp, Telegram).
|
||||
|
||||
---
|
||||
|
||||
## Clean Architecture Layers
|
||||
1. **Domain Layer**: Entity models defined in Prisma & TypeScript schemas.
|
||||
2. **Use-Case / Service Layer**: Business logic for verification queues, immutable wallet ledgers, coupon processing, and search filters.
|
||||
3. **Interface Adapters**: Fastify routes, controllers, and socket handlers.
|
||||
4. **Infrastructure Layer**: PostgreSQL, Redis, MinIO S3, Prometheus, Grafana, and Docker/Kubernetes orchestrators.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# 🚀 Luxe Platform - Production Deployment Guide
|
||||
|
||||
This guide covers deployment procedures for local Docker Compose development, production Kubernetes environments, and database migrations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Quick Local Setup with Docker Compose
|
||||
|
||||
### Prerequisites
|
||||
- Node.js >= 22.0.0 LTS
|
||||
- Docker & Docker Compose
|
||||
|
||||
### Step 1: Clone & Configure Environment
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Step 2: Spin Up Infrastructure Containers
|
||||
```bash
|
||||
npm run docker:up
|
||||
```
|
||||
This launches:
|
||||
- **PostgreSQL 16**: `localhost:5432`
|
||||
- **Redis 7**: `localhost:6379`
|
||||
- **MinIO S3**: `http://localhost:9000` (Console: `http://localhost:9001`)
|
||||
- **Mailpit SMTP**: `http://localhost:8025`
|
||||
- **Prometheus**: `http://localhost:9090`
|
||||
- **Grafana**: `http://localhost:3001`
|
||||
|
||||
### Step 3: Run Database Migrations & Seeding
|
||||
```bash
|
||||
npm run db:push
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### Step 4: Launch Gateway & Workers in Dev Mode
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Open [http://localhost:3000/documentation](http://localhost:3000/documentation) to view the live OpenAPI Swagger UI.
|
||||
|
||||
---
|
||||
|
||||
## 2. Production Deployment on Kubernetes
|
||||
|
||||
### Step 1: Apply Kubernetes Manifests
|
||||
```bash
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
```
|
||||
|
||||
### Step 2: Verify Autoscaling (HPA)
|
||||
```bash
|
||||
kubectl get hpa luxe-gateway-hpa
|
||||
```
|
||||
The Gateway will automatically scale up to **20 replicas** when CPU utilization exceeds 75%.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# 🛡️ Luxe Platform - Enterprise Security Model & Audit Checklist
|
||||
|
||||
## Security Mitigations & Standards
|
||||
|
||||
### 1. OWASP Top 10 Protections
|
||||
- **SQL Injection**: Handled natively by Prisma ORM prepared statements and parameterized queries.
|
||||
- **Cross-Site Scripting (XSS)**: Handled by `@fastify/helmet` setting strict HTTP security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options).
|
||||
- **Cross-Site Request Forgery (CSRF)**: Refresh tokens are stored in `HttpOnly`, `SameSite=Strict`, `Secure` cookies.
|
||||
- **Brute Force Protection**: Rate limiting enforced at 100 requests / 15 minutes per IP via `@fastify/rate-limit`, plus progressive account login lockouts.
|
||||
|
||||
### 2. Password & Encryption Standards
|
||||
- Password Hashing: **Argon2id** with 64MB memory cost, 3 iterations, and 4 degree parallelism.
|
||||
- Tokens: **JWT (ES2022 / RS256 or HS256)** short-lived access tokens (15m) with refresh token rotation (7d).
|
||||
|
||||
### 3. Dynamic Policy RBAC
|
||||
- Role-based and Permission-based dynamic policy evaluation on every sensitive endpoint.
|
||||
- Database-driven permissions preventing hardcoded authorization checks.
|
||||
|
||||
### 4. Audit Logging
|
||||
- Every sensitive action (Status updates, User suspensions, Verification approvals, Balance adjustments) records an immutable `AuditLog` entry tagged with IP, User Agent, Timestamp, and Admin ID.
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: luxe-gateway
|
||||
namespace: default
|
||||
labels:
|
||||
app: luxe-gateway
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: luxe-gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: luxe-gateway
|
||||
spec:
|
||||
containers:
|
||||
- name: gateway
|
||||
image: luxe/gateway:1.0.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: luxe-config
|
||||
- secretRef:
|
||||
name: luxe-secrets
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: luxe-gateway-service
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: luxe-gateway
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: luxe-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- api.luxe.com
|
||||
secretName: luxe-tls-secret
|
||||
rules:
|
||||
- host: api.luxe.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: luxe-gateway-service
|
||||
port:
|
||||
number: 80
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: luxe-gateway-hpa
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: luxe-gateway
|
||||
minReplicas: 3
|
||||
maxReplicas: 20
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 75
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "luxe-backend-monorepo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Production-ready enterprise-grade microservice backend for Luxe classified marketplace platform",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"services/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"lint": "npm run lint --workspaces --if-present",
|
||||
"format": "prettier --write \"**/*.{ts,json,md}\"",
|
||||
"db:generate": "npm run generate --workspace=@luxe/database",
|
||||
"db:push": "npm run push --workspace=@luxe/database",
|
||||
"db:seed": "npm run seed --workspace=@luxe/database",
|
||||
"docker:up": "docker-compose -f docker/docker-compose.yml up -d",
|
||||
"docker:down": "docker-compose -f docker/docker-compose.yml down"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@luxe/database",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"generate": "prisma generate",
|
||||
"push": "prisma db push",
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.0.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,685 @@
|
|||
// Luxe Platform Production Database Schema
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ENUMS
|
||||
// ==========================================
|
||||
|
||||
enum RoleType {
|
||||
GUEST
|
||||
USER
|
||||
ADVERTISER
|
||||
AGENCY
|
||||
MODERATOR
|
||||
SUPPORT
|
||||
CONTENT_MODERATOR
|
||||
FINANCE
|
||||
ADMIN
|
||||
SUPER_ADMIN
|
||||
}
|
||||
|
||||
enum VerificationStatus {
|
||||
UNVERIFIED
|
||||
PENDING
|
||||
VERIFIED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ListingStatus {
|
||||
DRAFT
|
||||
PENDING_APPROVAL
|
||||
ACTIVE
|
||||
REJECTED
|
||||
EXPIRED
|
||||
ARCHIVED
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
enum TransactionType {
|
||||
RECHARGE
|
||||
DEBIT
|
||||
CREDIT
|
||||
WITHDRAWAL
|
||||
REFUND
|
||||
BONUS
|
||||
}
|
||||
|
||||
enum TransactionStatus {
|
||||
PENDING
|
||||
SUCCESS
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum PaymentProvider {
|
||||
STRIPE
|
||||
RAZORPAY
|
||||
PAYPAL
|
||||
WALLET
|
||||
}
|
||||
|
||||
enum AdType {
|
||||
BANNER
|
||||
SPONSORED_LISTING
|
||||
BOOST_LISTING
|
||||
}
|
||||
|
||||
enum AdTargetType {
|
||||
CITY
|
||||
CATEGORY
|
||||
GLOBAL
|
||||
}
|
||||
|
||||
enum NotificationChannel {
|
||||
EMAIL
|
||||
SMS
|
||||
PUSH
|
||||
WHATSAPP
|
||||
TELEGRAM
|
||||
IN_APP
|
||||
}
|
||||
|
||||
enum ReportStatus {
|
||||
OPEN
|
||||
UNDER_REVIEW
|
||||
RESOLVED
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
enum TicketPriority {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
URGENT
|
||||
}
|
||||
|
||||
enum TicketStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
WAITING_ON_USER
|
||||
RESOLVED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUTH & USER IDENTITY
|
||||
// ==========================================
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
email String @unique
|
||||
phone String? @unique
|
||||
username String @unique
|
||||
passwordHash String
|
||||
role RoleType @default(USER)
|
||||
status String @default("ACTIVE") // ACTIVE, SUSPENDED, BANNED
|
||||
emailVerifiedAt DateTime?
|
||||
phoneVerifiedAt DateTime?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecret String?
|
||||
avatar String?
|
||||
bio String?
|
||||
language String @default("en")
|
||||
currency String @default("USD")
|
||||
lastLoginAt DateTime?
|
||||
lastLoginIp String?
|
||||
failedLoginCount Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
// Relations
|
||||
sessions Session[]
|
||||
devices Device[]
|
||||
userRoles UserRole[]
|
||||
profile AdvertiserProfile?
|
||||
wallet Wallet?
|
||||
reviews Review[]
|
||||
favorites Favorite[]
|
||||
savedSearches SavedSearch[]
|
||||
notifications Notification[]
|
||||
sentMessages Message[] @relation("SentMessages")
|
||||
receivedMessages Message[] @relation("ReceivedMessages")
|
||||
blogComments BlogComment[]
|
||||
forumThreads ForumThread[]
|
||||
forumReplies ForumReply[]
|
||||
reportsSubmitted Report[] @relation("ReporterUser")
|
||||
supportTickets SupportTicket[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@index([email])
|
||||
@@index([phone])
|
||||
@@index([username])
|
||||
@@index([role, status])
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
code RoleType @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
rolePermissions RolePermission[]
|
||||
userRoles UserRole[]
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
resource String // e.g. listing, user, invoice
|
||||
action String // e.g. create, read, update, delete, approve
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
rolePermissions RolePermission[]
|
||||
|
||||
@@unique([resource, action])
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String @db.Uuid
|
||||
permissionId String @db.Uuid
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String @db.Uuid
|
||||
roleId String @db.Uuid
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, roleId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
refreshToken String @unique
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
isRevoked Boolean @default(false)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([refreshToken])
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
deviceToken String @unique
|
||||
platform String // ios, android, web
|
||||
ipAddress String?
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ADVERTISER & LISTING DOMAIN
|
||||
// ==========================================
|
||||
|
||||
model AdvertiserProfile {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @unique @db.Uuid
|
||||
type String @default("INDIVIDUAL") // INDIVIDUAL, AGENCY
|
||||
agencyName String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
whatsapp String?
|
||||
telegram String?
|
||||
website String?
|
||||
verificationStatus VerificationStatus @default(UNVERIFIED)
|
||||
rating Float @default(0.0)
|
||||
reviewCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
listings Listing[]
|
||||
verifications VerificationRequest[]
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
slug String @unique
|
||||
icon String?
|
||||
description String?
|
||||
parentId String? @db.Uuid
|
||||
isFeatured Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryHierarchy")
|
||||
listings Listing[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model City {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
slug String @unique
|
||||
state String?
|
||||
country String @default("US")
|
||||
image String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
areas Area[]
|
||||
listings Listing[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model Area {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
cityId String @db.Uuid
|
||||
name String
|
||||
slug String
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
city City @relation(fields: [cityId], references: [id], onDelete: Cascade)
|
||||
listings Listing[]
|
||||
|
||||
@@unique([cityId, slug])
|
||||
}
|
||||
|
||||
model Listing {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
profileId String @db.Uuid
|
||||
categoryId String @db.Uuid
|
||||
cityId String @db.Uuid
|
||||
areaId String? @db.Uuid
|
||||
title String
|
||||
slug String @unique
|
||||
description String
|
||||
price Float
|
||||
currency String @default("USD")
|
||||
age Int?
|
||||
height String?
|
||||
tagline String?
|
||||
isOnline Boolean @default(true)
|
||||
isFeatured Boolean @default(false)
|
||||
isPremium Boolean @default(false)
|
||||
isVerified Boolean @default(false)
|
||||
status ListingStatus @default(PENDING_APPROVAL)
|
||||
viewsCount Int @default(0)
|
||||
callsCount Int @default(0)
|
||||
languages String[] @default([])
|
||||
tags String[] @default([])
|
||||
attributes Json? @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
advertiser AdvertiserProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
city City @relation(fields: [cityId], references: [id])
|
||||
area Area? @relation(fields: [areaId], references: [id])
|
||||
gallery ListingGallery[]
|
||||
reviews Review[]
|
||||
favorites Favorite[]
|
||||
ads Advertisement[]
|
||||
|
||||
@@index([slug])
|
||||
@@index([cityId, categoryId, status])
|
||||
@@index([isFeatured, isOnline])
|
||||
@@index([price])
|
||||
}
|
||||
|
||||
model ListingGallery {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
listingId String @db.Uuid
|
||||
url String
|
||||
mediaType String @default("IMAGE") // IMAGE, VIDEO
|
||||
thumbnail String?
|
||||
isPrimary Boolean @default(false)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([listingId])
|
||||
}
|
||||
|
||||
model Review {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
listingId String @db.Uuid
|
||||
userId String @db.Uuid
|
||||
rating Int // 1 to 5
|
||||
comment String
|
||||
isApproved Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([listingId])
|
||||
}
|
||||
|
||||
model Favorite {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
listingId String @db.Uuid
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, listingId])
|
||||
}
|
||||
|
||||
model SavedSearch {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
name String
|
||||
filters Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// WALLET, FINANCIAL LEDGER & PAYMENTS
|
||||
// ==========================================
|
||||
|
||||
model Wallet {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @unique @db.Uuid
|
||||
balance Float @default(0.0)
|
||||
credits Float @default(0.0)
|
||||
currency String @default("USD")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
ledgers WalletLedger[]
|
||||
invoices Invoice[]
|
||||
}
|
||||
|
||||
model WalletLedger {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
walletId String @db.Uuid
|
||||
type TransactionType
|
||||
amount Float
|
||||
balanceBefore Float
|
||||
balanceAfter Float
|
||||
referenceId String? // Order or Transaction ID
|
||||
description String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([walletId])
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
provider PaymentProvider
|
||||
type TransactionType
|
||||
status TransactionStatus @default(PENDING)
|
||||
amount Float
|
||||
currency String @default("USD")
|
||||
gatewayTxnId String? @unique
|
||||
metadata Json? @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([gatewayTxnId])
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
walletId String @db.Uuid
|
||||
invoiceNumber String @unique
|
||||
amount Float
|
||||
taxAmount Float @default(0.0)
|
||||
totalAmount Float
|
||||
currency String @default("USD")
|
||||
pdfUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Coupon {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
code String @unique
|
||||
discountPct Float?
|
||||
fixedAmount Float?
|
||||
minPurchase Float @default(0.0)
|
||||
maxUses Int @default(100)
|
||||
usedCount Int @default(0)
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
planName String // BASIC, VIP, AGENCY_PRO
|
||||
price Float
|
||||
status String @default("ACTIVE")
|
||||
startsAt DateTime @default(now())
|
||||
endsAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Advertisement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
listingId String? @db.Uuid
|
||||
type AdType
|
||||
target AdTargetType @default(GLOBAL)
|
||||
targetId String? // City ID or Category ID
|
||||
imageUrl String?
|
||||
linkUrl String?
|
||||
impressions Int @default(0)
|
||||
clicks Int @default(0)
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
status String @default("ACTIVE")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
listing Listing? @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MESSAGING & NOTIFICATIONS
|
||||
// ==========================================
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
senderId String @db.Uuid
|
||||
receiverId String @db.Uuid
|
||||
content String
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
sender User @relation("SentMessages", fields: [senderId], references: [id], onDelete: Cascade)
|
||||
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([senderId, receiverId])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
channel NotificationChannel @default(IN_APP)
|
||||
title String
|
||||
body String
|
||||
data Json?
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, isRead])
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BLOG, FORUM & CONTENT
|
||||
// ==========================================
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String
|
||||
slug String @unique
|
||||
excerpt String
|
||||
content String
|
||||
coverImage String?
|
||||
authorName String @default("Luxe Editorial")
|
||||
category String
|
||||
tags String[] @default([])
|
||||
readingTime Int @default(3)
|
||||
likesCount Int @default(0)
|
||||
isPublished Boolean @default(true)
|
||||
publishedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
comments BlogComment[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model BlogComment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
postId String @db.Uuid
|
||||
userId String @db.Uuid
|
||||
comment String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
post BlogPost @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ForumThread {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
title String
|
||||
slug String @unique
|
||||
content String
|
||||
category String
|
||||
isPinned Boolean @default(false)
|
||||
viewsCount Int @default(0)
|
||||
likesCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
replies ForumReply[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model ForumReply {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
threadId String @db.Uuid
|
||||
userId String @db.Uuid
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
thread ForumThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MODERATION, VERIFICATION & AUDIT LOGS
|
||||
// ==========================================
|
||||
|
||||
model VerificationRequest {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
profileId String @db.Uuid
|
||||
idProofUrl String
|
||||
selfieUrl String
|
||||
notes String?
|
||||
status VerificationStatus @default(PENDING)
|
||||
rejectionReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
profile AdvertiserProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Report {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reporterId String @db.Uuid
|
||||
targetType String // LISTING, USER, REVIEW, FORUM
|
||||
targetId String
|
||||
reason String
|
||||
description String?
|
||||
status ReportStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
reporter User @relation("ReporterUser", fields: [reporterId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model SupportTicket {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
ticketNum String @unique
|
||||
subject String
|
||||
description String
|
||||
priority TicketPriority @default(MEDIUM)
|
||||
status TicketStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String? @db.Uuid
|
||||
action String // e.g. USER_BAN, PROFILE_VERIFY, ROLE_UPDATE
|
||||
resource String // e.g. User, Listing
|
||||
resourceId String?
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
metadata Json? @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([action])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model FeatureFlag {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
key String @unique
|
||||
isEnabled Boolean @default(false)
|
||||
description String?
|
||||
metadata Json? @default("{}")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export {};
|
||||
//# sourceMappingURL=seed.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["seed.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const client_1 = require("@prisma/client");
|
||||
const argon2 = __importStar(require("argon2"));
|
||||
const prisma = new client_1.PrismaClient();
|
||||
async function main() {
|
||||
console.log('🌱 Starting Luxe Platform Database Seeding...');
|
||||
// 1. Seed Roles
|
||||
const rolesData = [
|
||||
{ name: 'Super Admin', code: client_1.RoleType.SUPER_ADMIN, description: 'Full system root access' },
|
||||
{ name: 'Admin', code: client_1.RoleType.ADMIN, description: 'Platform administration and management' },
|
||||
{ name: 'Moderator', code: client_1.RoleType.MODERATOR, description: 'Content and report moderation' },
|
||||
{ name: 'Content Moderator', code: client_1.RoleType.CONTENT_MODERATOR, description: 'Blog and forum moderation' },
|
||||
{ name: 'Finance Manager', code: client_1.RoleType.FINANCE, description: 'Wallet, ledger, transaction and invoice access' },
|
||||
{ name: 'Support Agent', code: client_1.RoleType.SUPPORT, description: 'User support and verification processing' },
|
||||
{ name: 'Agency Advertiser', code: client_1.RoleType.AGENCY, description: 'Agency advertiser account managing multiple staff' },
|
||||
{ name: 'Advertiser', code: client_1.RoleType.ADVERTISER, description: 'Individual advertiser listing creator' },
|
||||
{ name: 'Registered User', code: client_1.RoleType.USER, description: 'Standard platform member' },
|
||||
{ name: 'Guest', code: client_1.RoleType.GUEST, description: 'Unauthenticated visitor' },
|
||||
];
|
||||
for (const role of rolesData) {
|
||||
await prisma.role.upsert({
|
||||
where: { code: role.code },
|
||||
update: { name: role.name, description: role.description },
|
||||
create: role,
|
||||
});
|
||||
}
|
||||
// 2. Seed Permissions
|
||||
const permissionsData = [
|
||||
{ resource: 'users', action: 'read', description: 'View user profiles' },
|
||||
{ resource: 'users', action: 'write', description: 'Modify users' },
|
||||
{ resource: 'users', action: 'ban', description: 'Ban user accounts' },
|
||||
{ resource: 'listings', action: 'create', description: 'Create marketplace listings' },
|
||||
{ resource: 'listings', action: 'approve', description: 'Approve pending listings' },
|
||||
{ resource: 'listings', action: 'delete', description: 'Delete marketplace listings' },
|
||||
{ resource: 'verifications', action: 'approve', description: 'Approve ID verifications' },
|
||||
{ resource: 'wallet', action: 'credit', description: 'Add bonus credits to user wallet' },
|
||||
{ resource: 'admin', action: 'view_dashboard', description: 'Access admin analytics overview' },
|
||||
];
|
||||
for (const perm of permissionsData) {
|
||||
await prisma.permission.upsert({
|
||||
where: { resource_action: { resource: perm.resource, action: perm.action } },
|
||||
update: { description: perm.description },
|
||||
create: perm,
|
||||
});
|
||||
}
|
||||
// 3. Seed Super Admin User
|
||||
const superAdminPasswordHash = await argon2.hash('LuxeSuperAdmin2026Secure!');
|
||||
const superAdminUser = await prisma.user.upsert({
|
||||
where: { email: 'admin@luxe.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@luxe.com',
|
||||
username: 'superadmin',
|
||||
passwordHash: superAdminPasswordHash,
|
||||
role: client_1.RoleType.SUPER_ADMIN,
|
||||
status: 'ACTIVE',
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Seed Wallet for Super Admin
|
||||
await prisma.wallet.upsert({
|
||||
where: { userId: superAdminUser.id },
|
||||
update: {},
|
||||
create: {
|
||||
userId: superAdminUser.id,
|
||||
balance: 100000.0,
|
||||
credits: 5000.0,
|
||||
currency: 'USD',
|
||||
},
|
||||
});
|
||||
// 4. Seed Cities
|
||||
const cities = [
|
||||
{ name: 'New York', slug: 'new-york', state: 'NY', country: 'US' },
|
||||
{ name: 'Los Angeles', slug: 'los-angeles', state: 'CA', country: 'US' },
|
||||
{ name: 'Miami', slug: 'miami', state: 'FL', country: 'US' },
|
||||
{ name: 'London', slug: 'london', state: 'Greater London', country: 'UK' },
|
||||
{ name: 'Dubai', slug: 'dubai', state: 'Dubai', country: 'AE' },
|
||||
];
|
||||
for (const city of cities) {
|
||||
await prisma.city.upsert({
|
||||
where: { slug: city.slug },
|
||||
update: {},
|
||||
create: city,
|
||||
});
|
||||
}
|
||||
// 5. Seed Categories
|
||||
const categories = [
|
||||
{ name: 'VIP Escorts', slug: 'vip-escorts', icon: 'crown', description: 'Premium verified VIP companionship' },
|
||||
{ name: 'Massage & Spa', slug: 'massage-spa', icon: 'sparkles', description: 'Relaxing holistic & therapeutic wellness' },
|
||||
{ name: 'Model & Hostess', slug: 'model-hostess', icon: 'camera', description: 'High fashion models for events & dates' },
|
||||
{ name: 'Nightlife Companions', slug: 'nightlife-companions', icon: 'glass-cheers', description: 'Party & event escorts' },
|
||||
];
|
||||
for (const cat of categories) {
|
||||
await prisma.category.upsert({
|
||||
where: { slug: cat.slug },
|
||||
update: {},
|
||||
create: cat,
|
||||
});
|
||||
}
|
||||
// 6. Seed Feature Flags
|
||||
const featureFlags = [
|
||||
{ key: 'ENABLE_AI_IMAGE_MODERATION', isEnabled: true, description: 'Automatic NSFW and duplicate detection' },
|
||||
{ key: 'ENABLE_CRYPTO_PAYMENTS', isEnabled: false, description: 'Accept Web3 crypto payments for wallet recharge' },
|
||||
{ key: 'ENABLE_WHATSAPP_OTP', isEnabled: true, description: 'WhatsApp Business API for phone verification' },
|
||||
];
|
||||
for (const flag of featureFlags) {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { key: flag.key },
|
||||
update: {},
|
||||
create: flag,
|
||||
});
|
||||
}
|
||||
console.log('✅ Database Seeding Completed Successfully!');
|
||||
}
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seeding failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
//# sourceMappingURL=seed.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,137 @@
|
|||
import { PrismaClient, RoleType } from '@prisma/client';
|
||||
import * as argon2 from 'argon2';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting Luxe Platform Database Seeding...');
|
||||
|
||||
// 1. Seed Roles
|
||||
const rolesData: { name: string; code: RoleType; description: string }[] = [
|
||||
{ name: 'Super Admin', code: RoleType.SUPER_ADMIN, description: 'Full system root access' },
|
||||
{ name: 'Admin', code: RoleType.ADMIN, description: 'Platform administration and management' },
|
||||
{ name: 'Moderator', code: RoleType.MODERATOR, description: 'Content and report moderation' },
|
||||
{ name: 'Content Moderator', code: RoleType.CONTENT_MODERATOR, description: 'Blog and forum moderation' },
|
||||
{ name: 'Finance Manager', code: RoleType.FINANCE, description: 'Wallet, ledger, transaction and invoice access' },
|
||||
{ name: 'Support Agent', code: RoleType.SUPPORT, description: 'User support and verification processing' },
|
||||
{ name: 'Agency Advertiser', code: RoleType.AGENCY, description: 'Agency advertiser account managing multiple staff' },
|
||||
{ name: 'Advertiser', code: RoleType.ADVERTISER, description: 'Individual advertiser listing creator' },
|
||||
{ name: 'Registered User', code: RoleType.USER, description: 'Standard platform member' },
|
||||
{ name: 'Guest', code: RoleType.GUEST, description: 'Unauthenticated visitor' },
|
||||
];
|
||||
|
||||
for (const role of rolesData) {
|
||||
await prisma.role.upsert({
|
||||
where: { code: role.code },
|
||||
update: { name: role.name, description: role.description },
|
||||
create: role,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Seed Permissions
|
||||
const permissionsData = [
|
||||
{ resource: 'users', action: 'read', description: 'View user profiles' },
|
||||
{ resource: 'users', action: 'write', description: 'Modify users' },
|
||||
{ resource: 'users', action: 'ban', description: 'Ban user accounts' },
|
||||
{ resource: 'listings', action: 'create', description: 'Create marketplace listings' },
|
||||
{ resource: 'listings', action: 'approve', description: 'Approve pending listings' },
|
||||
{ resource: 'listings', action: 'delete', description: 'Delete marketplace listings' },
|
||||
{ resource: 'verifications', action: 'approve', description: 'Approve ID verifications' },
|
||||
{ resource: 'wallet', action: 'credit', description: 'Add bonus credits to user wallet' },
|
||||
{ resource: 'admin', action: 'view_dashboard', description: 'Access admin analytics overview' },
|
||||
];
|
||||
|
||||
for (const perm of permissionsData) {
|
||||
await prisma.permission.upsert({
|
||||
where: { resource_action: { resource: perm.resource, action: perm.action } },
|
||||
update: { description: perm.description },
|
||||
create: perm,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Seed Super Admin User
|
||||
const superAdminPasswordHash = await argon2.hash('LuxeSuperAdmin2026Secure!');
|
||||
const superAdminUser = await prisma.user.upsert({
|
||||
where: { email: 'admin@luxe.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@luxe.com',
|
||||
username: 'superadmin',
|
||||
passwordHash: superAdminPasswordHash,
|
||||
role: RoleType.SUPER_ADMIN,
|
||||
status: 'ACTIVE',
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Seed Wallet for Super Admin
|
||||
await prisma.wallet.upsert({
|
||||
where: { userId: superAdminUser.id },
|
||||
update: {},
|
||||
create: {
|
||||
userId: superAdminUser.id,
|
||||
balance: 100000.0,
|
||||
credits: 5000.0,
|
||||
currency: 'USD',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Seed Cities
|
||||
const cities = [
|
||||
{ name: 'New York', slug: 'new-york', state: 'NY', country: 'US' },
|
||||
{ name: 'Los Angeles', slug: 'los-angeles', state: 'CA', country: 'US' },
|
||||
{ name: 'Miami', slug: 'miami', state: 'FL', country: 'US' },
|
||||
{ name: 'London', slug: 'london', state: 'Greater London', country: 'UK' },
|
||||
{ name: 'Dubai', slug: 'dubai', state: 'Dubai', country: 'AE' },
|
||||
];
|
||||
|
||||
for (const city of cities) {
|
||||
await prisma.city.upsert({
|
||||
where: { slug: city.slug },
|
||||
update: {},
|
||||
create: city,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Seed Categories
|
||||
const categories = [
|
||||
{ name: 'VIP Escorts', slug: 'vip-escorts', icon: 'crown', description: 'Premium verified VIP companionship' },
|
||||
{ name: 'Massage & Spa', slug: 'massage-spa', icon: 'sparkles', description: 'Relaxing holistic & therapeutic wellness' },
|
||||
{ name: 'Model & Hostess', slug: 'model-hostess', icon: 'camera', description: 'High fashion models for events & dates' },
|
||||
{ name: 'Nightlife Companions', slug: 'nightlife-companions', icon: 'glass-cheers', description: 'Party & event escorts' },
|
||||
];
|
||||
|
||||
for (const cat of categories) {
|
||||
await prisma.category.upsert({
|
||||
where: { slug: cat.slug },
|
||||
update: {},
|
||||
create: cat,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Seed Feature Flags
|
||||
const featureFlags = [
|
||||
{ key: 'ENABLE_AI_IMAGE_MODERATION', isEnabled: true, description: 'Automatic NSFW and duplicate detection' },
|
||||
{ key: 'ENABLE_CRYPTO_PAYMENTS', isEnabled: false, description: 'Accept Web3 crypto payments for wallet recharge' },
|
||||
{ key: 'ENABLE_WHATSAPP_OTP', isEnabled: true, description: 'WhatsApp Business API for phone verification' },
|
||||
];
|
||||
|
||||
for (const flag of featureFlags) {
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { key: flag.key },
|
||||
update: {},
|
||||
create: flag,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Database Seeding Completed Successfully!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seeding failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
declare global {
|
||||
var prismaGlobal: PrismaClient | undefined;
|
||||
}
|
||||
export declare const prisma: PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
||||
export * from '@prisma/client';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,OAAO,CAAC,MAAM,CAAC;IAGb,IAAI,YAAY,EAAE,YAAY,GAAG,SAAS,CAAC;CAC5C;AAED,eAAO,MAAM,MAAM,gIAIf,CAAC;AAML,cAAc,gBAAgB,CAAC"}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.prisma = void 0;
|
||||
const client_1 = require("@prisma/client");
|
||||
exports.prisma = globalThis.prismaGlobal ??
|
||||
new client_1.PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prismaGlobal = exports.prisma;
|
||||
}
|
||||
__exportStar(require("@prisma/client"), exports);
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,2CAA8C;AAQjC,QAAA,MAAM,GACjB,UAAU,CAAC,YAAY;IACvB,IAAI,qBAAY,CAAC;QACf,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,aAAa,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;KACrF,CAAC,CAAC;AAEL,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IAC1C,UAAU,CAAC,YAAY,GAAG,cAAM,CAAC;AACnC,CAAC;AAED,iDAA+B"}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
// Allow global prisma in dev to prevent multiple instances
|
||||
// eslint-disable-next-line no-var
|
||||
var prismaGlobal: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalThis.prismaGlobal ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prismaGlobal = prisma;
|
||||
}
|
||||
|
||||
export * from '@prisma/client';
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@luxe/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.41.1",
|
||||
"fastify": "^5.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pino": "^9.6.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export * from './utils/response.js';
|
||||
export * from './utils/argon2.js';
|
||||
export * from './utils/jwt.js';
|
||||
export * from './logger/index.js';
|
||||
export * from './middleware/rbac.js';
|
||||
export * from './validators/index.js';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC"}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("./utils/response.js"), exports);
|
||||
__exportStar(require("./utils/argon2.js"), exports);
|
||||
__exportStar(require("./utils/jwt.js"), exports);
|
||||
__exportStar(require("./logger/index.js"), exports);
|
||||
__exportStar(require("./middleware/rbac.js"), exports);
|
||||
__exportStar(require("./validators/index.js"), exports);
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,sDAAoC;AACpC,oDAAkC;AAClC,iDAA+B;AAC/B,oDAAkC;AAClC,uDAAqC;AACrC,wDAAsC"}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './utils/response.js';
|
||||
export * from './utils/argon2.js';
|
||||
export * from './utils/jwt.js';
|
||||
export * from './logger/index.js';
|
||||
export * from './middleware/rbac.js';
|
||||
export * from './validators/index.js';
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import pino from 'pino';
|
||||
export declare const logger: pino.Logger<never, boolean>;
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AAExB,eAAO,MAAM,MAAM,6BAOjB,CAAC"}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.logger = void 0;
|
||||
const pino_1 = __importDefault(require("pino"));
|
||||
exports.logger = (0, pino_1.default)({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
formatters: {
|
||||
level: (label) => ({ level: label }),
|
||||
},
|
||||
timestamp: pino_1.default.stdTimeFunctions.isoTime,
|
||||
redact: ['req.headers.authorization', 'password', 'token', 'creditCard'],
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAwB;AAEX,QAAA,MAAM,GAAG,IAAA,cAAI,EAAC;IACzB,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,MAAM;IACtC,UAAU,EAAE;QACV,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;KACrC;IACD,SAAS,EAAE,cAAI,CAAC,gBAAgB,CAAC,OAAO;IACxC,MAAM,EAAE,CAAC,2BAA2B,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,CAAC;CACzE,CAAC,CAAC"}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
formatters: {
|
||||
level: (label) => ({ level: label }),
|
||||
},
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
redact: ['req.headers.authorization', 'password', 'token', 'creditCard'],
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
export declare function authorize(allowedRoles: string[]): (request: FastifyRequest, reply: FastifyReply) => Promise<undefined>;
|
||||
//# sourceMappingURL=rbac.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"rbac.d.ts","sourceRoot":"","sources":["rbac.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAGvD,wBAAgB,SAAS,CAAC,YAAY,EAAE,MAAM,EAAE,IAChC,SAAS,cAAc,EAAE,OAAO,YAAY,wBAU3D"}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.authorize = authorize;
|
||||
const response_js_1 = require("../utils/response.js");
|
||||
function authorize(allowedRoles) {
|
||||
return async (request, reply) => {
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
return reply.status(401).send((0, response_js_1.errorResponse)('Unauthorized: Authentication required'));
|
||||
}
|
||||
if (!allowedRoles.includes(user.role) && user.role !== 'SUPER_ADMIN') {
|
||||
return reply.status(403).send((0, response_js_1.errorResponse)('Forbidden: Insufficient privileges'));
|
||||
}
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=rbac.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"rbac.js","sourceRoot":"","sources":["rbac.ts"],"names":[],"mappings":";;AAGA,8BAWC;AAbD,sDAAqD;AAErD,SAAgB,SAAS,CAAC,YAAsB;IAC9C,OAAO,KAAK,EAAE,OAAuB,EAAE,KAAmB,EAAE,EAAE;QAC5D,MAAM,IAAI,GAAI,OAAe,CAAC,IAAI,CAAC;QACnC,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAA,2BAAa,EAAC,uCAAuC,CAAC,CAAC,CAAC;QACxF,CAAC;QAED,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,KAAK,aAAa,EAAE,CAAC;YACrE,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAA,2BAAa,EAAC,oCAAoC,CAAC,CAAC,CAAC;QACrF,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { errorResponse } from '../utils/response.js';
|
||||
|
||||
export function authorize(allowedRoles: string[]) {
|
||||
return async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = (request as any).user;
|
||||
if (!user) {
|
||||
return reply.status(401).send(errorResponse('Unauthorized: Authentication required'));
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(user.role) && user.role !== 'SUPER_ADMIN') {
|
||||
return reply.status(403).send(errorResponse('Forbidden: Insufficient privileges'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export declare function hashPassword(password: string): Promise<string>;
|
||||
export declare function verifyPassword(hash: string, plainText: string): Promise<boolean>;
|
||||
//# sourceMappingURL=argon2.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"argon2.d.ts","sourceRoot":"","sources":["argon2.ts"],"names":[],"mappings":"AAEA,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOpE;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMtF"}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.hashPassword = hashPassword;
|
||||
exports.verifyPassword = verifyPassword;
|
||||
const argon2 = __importStar(require("argon2"));
|
||||
async function hashPassword(password) {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: Number(process.env.ARGON_MEMORY_COST) || 65536,
|
||||
timeCost: Number(process.env.ARGON_TIME_COST) || 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
async function verifyPassword(hash, plainText) {
|
||||
try {
|
||||
return await argon2.verify(hash, plainText);
|
||||
}
|
||||
catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=argon2.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"argon2.js","sourceRoot":"","sources":["argon2.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEA,oCAOC;AAED,wCAMC;AAjBD,+CAAiC;AAE1B,KAAK,UAAU,YAAY,CAAC,QAAgB;IACjD,OAAO,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE;QAC3B,IAAI,EAAE,MAAM,CAAC,QAAQ;QACrB,UAAU,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,IAAI,KAAK;QAC1D,QAAQ,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC;QAClD,WAAW,EAAE,CAAC;KACf,CAAC,CAAC;AACL,CAAC;AAEM,KAAK,UAAU,cAAc,CAAC,IAAY,EAAE,SAAiB;IAClE,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IAC9C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import * as argon2 from 'argon2';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: Number(process.env.ARGON_MEMORY_COST) || 65536,
|
||||
timeCost: Number(process.env.ARGON_TIME_COST) || 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(hash: string, plainText: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, plainText);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export interface JwtPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
export declare function generateAccessToken(payload: JwtPayload): string;
|
||||
export declare function generateRefreshToken(payload: JwtPayload): string;
|
||||
export declare function verifyToken(token: string): JwtPayload;
|
||||
//# sourceMappingURL=jwt.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"jwt.d.ts","sourceRoot":"","sources":["jwt.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,CAK/D;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,UAAU,GAAG,MAAM,CAKhE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAErD"}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateAccessToken = generateAccessToken;
|
||||
exports.generateRefreshToken = generateRefreshToken;
|
||||
exports.verifyToken = verifyToken;
|
||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_luxe_jwt_signing_key_2026_production_grade';
|
||||
const ACCESS_EXPIRATION = process.env.JWT_ACCESS_EXPIRATION || '15m';
|
||||
const REFRESH_EXPIRATION = process.env.JWT_REFRESH_EXPIRATION || '7d';
|
||||
function generateAccessToken(payload) {
|
||||
return jsonwebtoken_1.default.sign(payload, JWT_SECRET, {
|
||||
expiresIn: ACCESS_EXPIRATION,
|
||||
issuer: 'luxe-api',
|
||||
});
|
||||
}
|
||||
function generateRefreshToken(payload) {
|
||||
return jsonwebtoken_1.default.sign({ ...payload, type: 'refresh' }, JWT_SECRET, {
|
||||
expiresIn: REFRESH_EXPIRATION,
|
||||
issuer: 'luxe-api',
|
||||
});
|
||||
}
|
||||
function verifyToken(token) {
|
||||
return jsonwebtoken_1.default.verify(token, JWT_SECRET, { issuer: 'luxe-api' });
|
||||
}
|
||||
//# sourceMappingURL=jwt.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"jwt.js","sourceRoot":"","sources":["jwt.ts"],"names":[],"mappings":";;;;;AAaA,kDAKC;AAED,oDAKC;AAED,kCAEC;AA7BD,gEAA+B;AAS/B,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,yDAAyD,CAAC;AACvG,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,KAAK,CAAC;AACrE,MAAM,kBAAkB,GAAG,OAAO,CAAC,GAAG,CAAC,sBAAsB,IAAI,IAAI,CAAC;AAEtE,SAAgB,mBAAmB,CAAC,OAAmB;IACrD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE;QACnC,SAAS,EAAE,iBAAwB;QACnC,MAAM,EAAE,UAAU;KACnB,CAAC,CAAC;AACL,CAAC;AAED,SAAgB,oBAAoB,CAAC,OAAmB;IACtD,OAAO,sBAAG,CAAC,IAAI,CAAC,EAAE,GAAG,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,UAAU,EAAE;QAC3D,SAAS,EAAE,kBAAyB;QACpC,MAAM,EAAE,UAAU;KACnB,CAAC,CAAC;AACL,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,CAAe,CAAC;AAC7E,CAAC"}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_luxe_jwt_signing_key_2026_production_grade';
|
||||
const ACCESS_EXPIRATION = process.env.JWT_ACCESS_EXPIRATION || '15m';
|
||||
const REFRESH_EXPIRATION = process.env.JWT_REFRESH_EXPIRATION || '7d';
|
||||
|
||||
export function generateAccessToken(payload: JwtPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, {
|
||||
expiresIn: ACCESS_EXPIRATION as any,
|
||||
issuer: 'luxe-api',
|
||||
});
|
||||
}
|
||||
|
||||
export function generateRefreshToken(payload: JwtPayload): string {
|
||||
return jwt.sign({ ...payload, type: 'refresh' }, JWT_SECRET, {
|
||||
expiresIn: REFRESH_EXPIRATION as any,
|
||||
issuer: 'luxe-api',
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload {
|
||||
return jwt.verify(token, JWT_SECRET, { issuer: 'luxe-api' }) as JwtPayload;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
errors?: Array<{
|
||||
field?: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
export declare function successResponse<T>(data: T, message?: string): ApiResponse<T>;
|
||||
export declare function paginatedResponse<T>(data: T[], total: number, page: number, pageSize: number, message?: string): ApiResponse<T[]>;
|
||||
export declare function errorResponse(message?: string, errors?: Array<{
|
||||
field?: string;
|
||||
message: string;
|
||||
}>): ApiResponse;
|
||||
//# sourceMappingURL=response.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"response.d.ts","sourceRoot":"","sources":["response.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW,CAAC,CAAC,GAAG,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,CAAC,CAAC;IACT,UAAU,CAAC,EAAE;QACX,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC;IACF,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,SAAoC,GAAG,WAAW,CAAC,CAAC,CAAC,CAMvG;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,IAAI,EAAE,CAAC,EAAE,EACT,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,OAAO,SAAoC,GAC1C,WAAW,CAAC,CAAC,EAAE,CAAC,CAYlB;AAED,wBAAgB,aAAa,CAC3B,OAAO,SAAsB,EAC7B,MAAM,GAAE,KAAK,CAAC;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAM,GACtD,WAAW,CAMb"}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.successResponse = successResponse;
|
||||
exports.paginatedResponse = paginatedResponse;
|
||||
exports.errorResponse = errorResponse;
|
||||
function successResponse(data, message = 'Operation executed successfully') {
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
}
|
||||
function paginatedResponse(data, total, page, pageSize, message = 'Operation executed successfully') {
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: page * pageSize < total,
|
||||
},
|
||||
};
|
||||
}
|
||||
function errorResponse(message = 'An error occurred', errors = []) {
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=response.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"response.js","sourceRoot":"","sources":["response.ts"],"names":[],"mappings":";;AAaA,0CAMC;AAED,8CAkBC;AAED,sCASC;AArCD,SAAgB,eAAe,CAAI,IAAO,EAAE,OAAO,GAAG,iCAAiC;IACrF,OAAO;QACL,OAAO,EAAE,IAAI;QACb,OAAO;QACP,IAAI;KACL,CAAC;AACJ,CAAC;AAED,SAAgB,iBAAiB,CAC/B,IAAS,EACT,KAAa,EACb,IAAY,EACZ,QAAgB,EAChB,OAAO,GAAG,iCAAiC;IAE3C,OAAO;QACL,OAAO,EAAE,IAAI;QACb,OAAO;QACP,IAAI;QACJ,UAAU,EAAE;YACV,KAAK;YACL,IAAI;YACJ,QAAQ;YACR,OAAO,EAAE,IAAI,GAAG,QAAQ,GAAG,KAAK;SACjC;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,aAAa,CAC3B,OAAO,GAAG,mBAAmB,EAC7B,SAAqD,EAAE;IAEvD,OAAO;QACL,OAAO,EAAE,KAAK;QACd,OAAO;QACP,MAAM;KACP,CAAC;AACJ,CAAC"}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
errors?: Array<{ field?: string; message: string }>;
|
||||
}
|
||||
|
||||
export function successResponse<T>(data: T, message = 'Operation executed successfully'): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function paginatedResponse<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
message = 'Operation executed successfully'
|
||||
): ApiResponse<T[]> {
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: page * pageSize < total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function errorResponse(
|
||||
message = 'An error occurred',
|
||||
errors: Array<{ field?: string; message: string }> = []
|
||||
): ApiResponse {
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { z } from 'zod';
|
||||
export declare const registerSchema: z.ZodObject<{
|
||||
email: z.ZodString;
|
||||
password: z.ZodString;
|
||||
username: z.ZodString;
|
||||
role: z.ZodDefault<z.ZodEnum<["USER", "ADVERTISER", "AGENCY"]>>;
|
||||
phone: z.ZodOptional<z.ZodString>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
role: "USER" | "ADVERTISER" | "AGENCY";
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
phone?: string | undefined;
|
||||
}, {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role?: "USER" | "ADVERTISER" | "AGENCY" | undefined;
|
||||
phone?: string | undefined;
|
||||
}>;
|
||||
export declare const loginSchema: z.ZodObject<{
|
||||
email: z.ZodString;
|
||||
password: z.ZodString;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
email: string;
|
||||
password: string;
|
||||
}, {
|
||||
email: string;
|
||||
password: string;
|
||||
}>;
|
||||
export declare const forgotPasswordSchema: z.ZodObject<{
|
||||
email: z.ZodString;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
email: string;
|
||||
}, {
|
||||
email: string;
|
||||
}>;
|
||||
export declare const resetPasswordSchema: z.ZodObject<{
|
||||
email: z.ZodString;
|
||||
otp: z.ZodString;
|
||||
newPassword: z.ZodString;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
email: string;
|
||||
otp: string;
|
||||
newPassword: string;
|
||||
}, {
|
||||
email: string;
|
||||
otp: string;
|
||||
newPassword: string;
|
||||
}>;
|
||||
export declare const createListingSchema: z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
description: z.ZodString;
|
||||
categoryId: z.ZodString;
|
||||
cityId: z.ZodString;
|
||||
areaId: z.ZodOptional<z.ZodString>;
|
||||
price: z.ZodNumber;
|
||||
currency: z.ZodDefault<z.ZodString>;
|
||||
age: z.ZodOptional<z.ZodNumber>;
|
||||
height: z.ZodOptional<z.ZodString>;
|
||||
tagline: z.ZodOptional<z.ZodString>;
|
||||
languages: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
||||
tags: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
description: string;
|
||||
currency: string;
|
||||
title: string;
|
||||
categoryId: string;
|
||||
cityId: string;
|
||||
price: number;
|
||||
languages: string[];
|
||||
tags: string[];
|
||||
areaId?: string | undefined;
|
||||
age?: number | undefined;
|
||||
height?: string | undefined;
|
||||
tagline?: string | undefined;
|
||||
}, {
|
||||
description: string;
|
||||
title: string;
|
||||
categoryId: string;
|
||||
cityId: string;
|
||||
price: number;
|
||||
currency?: string | undefined;
|
||||
areaId?: string | undefined;
|
||||
age?: number | undefined;
|
||||
height?: string | undefined;
|
||||
tagline?: string | undefined;
|
||||
languages?: string[] | undefined;
|
||||
tags?: string[] | undefined;
|
||||
}>;
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;EAMzB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;EAGtB,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;EAE/B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;EAI9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAa9B,CAAC"}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createListingSchema = exports.resetPasswordSchema = exports.forgotPasswordSchema = exports.loginSchema = exports.registerSchema = void 0;
|
||||
const zod_1 = require("zod");
|
||||
exports.registerSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
password: zod_1.z.string().min(8, 'Password must be at least 8 characters'),
|
||||
username: zod_1.z.string().min(3).max(30),
|
||||
role: zod_1.z.enum(['USER', 'ADVERTISER', 'AGENCY']).default('USER'),
|
||||
phone: zod_1.z.string().optional(),
|
||||
});
|
||||
exports.loginSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
password: zod_1.z.string().min(1, 'Password is required'),
|
||||
});
|
||||
exports.forgotPasswordSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
});
|
||||
exports.resetPasswordSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
otp: zod_1.z.string().length(6),
|
||||
newPassword: zod_1.z.string().min(8),
|
||||
});
|
||||
exports.createListingSchema = zod_1.z.object({
|
||||
title: zod_1.z.string().min(5).max(100),
|
||||
description: zod_1.z.string().min(20),
|
||||
categoryId: zod_1.z.string().uuid(),
|
||||
cityId: zod_1.z.string().uuid(),
|
||||
areaId: zod_1.z.string().uuid().optional(),
|
||||
price: zod_1.z.number().positive(),
|
||||
currency: zod_1.z.string().default('USD'),
|
||||
age: zod_1.z.number().int().min(18).max(99).optional(),
|
||||
height: zod_1.z.string().optional(),
|
||||
tagline: zod_1.z.string().max(150).optional(),
|
||||
languages: zod_1.z.array(zod_1.z.string()).default([]),
|
||||
tags: zod_1.z.array(zod_1.z.string()).default([]),
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,6BAAwB;AAEX,QAAA,cAAc,GAAG,OAAC,CAAC,MAAM,CAAC;IACrC,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;IACzB,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,wCAAwC,CAAC;IACrE,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;IACnC,IAAI,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,YAAY,EAAE,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;IAC9D,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC7B,CAAC,CAAC;AAEU,QAAA,WAAW,GAAG,OAAC,CAAC,MAAM,CAAC;IAClC,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;IACzB,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,sBAAsB,CAAC;CACpD,CAAC,CAAC;AAEU,QAAA,oBAAoB,GAAG,OAAC,CAAC,MAAM,CAAC;IAC3C,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;CAC1B,CAAC,CAAC;AAEU,QAAA,mBAAmB,GAAG,OAAC,CAAC,MAAM,CAAC;IAC1C,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;IACzB,GAAG,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IACzB,WAAW,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CAC/B,CAAC,CAAC;AAEU,QAAA,mBAAmB,GAAG,OAAC,CAAC,MAAM,CAAC;IAC1C,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;IACjC,WAAW,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAC/B,UAAU,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE;IAC7B,MAAM,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE;IACzB,MAAM,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE;IACpC,KAAK,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC5B,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC;IACnC,GAAG,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE;IAChD,MAAM,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7B,OAAO,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE;IACvC,SAAS,EAAE,OAAC,CAAC,KAAK,CAAC,OAAC,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;IAC1C,IAAI,EAAE,OAAC,CAAC,KAAK,CAAC,OAAC,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;CACtC,CAAC,CAAC"}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
username: z.string().min(3).max(30),
|
||||
role: z.enum(['USER', 'ADVERTISER', 'AGENCY']).default('USER'),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
email: z.string().email(),
|
||||
otp: z.string().length(6),
|
||||
newPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
export const createListingSchema = z.object({
|
||||
title: z.string().min(5).max(100),
|
||||
description: z.string().min(20),
|
||||
categoryId: z.string().uuid(),
|
||||
cityId: z.string().uuid(),
|
||||
areaId: z.string().uuid().optional(),
|
||||
price: z.number().positive(),
|
||||
currency: z.string().default('USD'),
|
||||
age: z.number().int().min(18).max(99).optional(),
|
||||
height: z.string().optional(),
|
||||
tagline: z.string().max(150).optional(),
|
||||
languages: z.array(z.string()).default([]),
|
||||
tags: z.array(z.string()).default([]),
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
console.log('🚀 Starting Luxe Monorepo Development Environment...');
|
||||
|
||||
const services = [
|
||||
{ name: 'Gateway', command: 'npx', args: ['tsx', 'watch', 'services/gateway/src/index.ts'] },
|
||||
{ name: 'Notifications', command: 'npx', args: ['tsx', 'watch', 'services/notification-service/src/index.ts'] },
|
||||
];
|
||||
|
||||
services.forEach((service) => {
|
||||
const proc = spawn(service.command, service.args, {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
console.log(`[${service.name}] process exited with code ${code}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@luxe/gateway",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/formbody": "^8.0.0",
|
||||
"@fastify/helmet": "^12.0.1",
|
||||
"@fastify/multipart": "^9.0.2",
|
||||
"@fastify/rate-limit": "^10.2.1",
|
||||
"@fastify/swagger": "^9.4.0",
|
||||
"@fastify/swagger-ui": "^5.2.0",
|
||||
"@luxe/database": "*",
|
||||
"@luxe/shared": "*",
|
||||
"bullmq": "^5.34.8",
|
||||
"fastify": "^5.2.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export {};
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":""}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import cookie from '@fastify/cookie';
|
||||
import swagger from '@fastify/swagger';
|
||||
import swaggerUi from '@fastify/swagger-ui';
|
||||
import { Server } from 'socket.io';
|
||||
import { createServer } from 'node:http';
|
||||
import { authRoutes } from './routes/auth.routes.js';
|
||||
import { userRoutes } from './routes/user.routes.js';
|
||||
import { clientRoutes } from './routes/client.routes.js';
|
||||
import { listingRoutes } from './routes/listing.routes.js';
|
||||
import { walletRoutes } from './routes/wallet.routes.js';
|
||||
import { blogRoutes } from './routes/blog.routes.js';
|
||||
import { adminRoutes } from './routes/admin.routes.js';
|
||||
import { mediaRoutes } from './routes/media.routes.js';
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
async function bootstrap() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
},
|
||||
});
|
||||
// 1. Security & Core Middleware
|
||||
await app.register(helmet, { contentSecurityPolicy: false });
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
await app.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '15 minutes',
|
||||
});
|
||||
await app.register(cookie, {
|
||||
secret: process.env.JWT_SECRET || 'luxe_cookie_secret_key_2026',
|
||||
});
|
||||
// 2. Swagger Documentation Setup
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Luxe Platform REST API Specification',
|
||||
description: 'Production-ready microservices API specification for Luxe classified marketplace.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [{ url: `http://localhost:${PORT}` }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: '/documentation',
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: false,
|
||||
},
|
||||
});
|
||||
// 3. Health Check Endpoints
|
||||
app.get('/health', async () => ({ status: 'UP', timestamp: new Date().toISOString() }));
|
||||
app.get('/ready', async () => ({ status: 'READY', service: 'luxe-gateway' }));
|
||||
// 4. API Route Modules Registration
|
||||
await app.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
await app.register(userRoutes, { prefix: '/api/v1/users' });
|
||||
await app.register(clientRoutes, { prefix: '/api/v1/clients' });
|
||||
await app.register(listingRoutes, { prefix: '/api/v1' });
|
||||
await app.register(walletRoutes, { prefix: '/api/v1/wallet' });
|
||||
await app.register(blogRoutes, { prefix: '/api/v1' });
|
||||
await app.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||
await app.register(mediaRoutes, { prefix: '/api/v1/media' });
|
||||
// 5. Initialize Server & WebSockets (Socket.IO)
|
||||
const httpServer = createServer(app.server);
|
||||
const io = new Server(httpServer, {
|
||||
cors: { origin: '*' },
|
||||
});
|
||||
io.on('connection', (socket) => {
|
||||
app.log.info(`Socket connected: ${socket.id}`);
|
||||
socket.on('join_room', (room) => {
|
||||
socket.join(room);
|
||||
});
|
||||
socket.on('send_message', (data) => {
|
||||
io.to(data.roomId).emit('new_message', data);
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
app.log.info(`Socket disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
try {
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
app.log.info(`🚀 Luxe API Gateway server running on http://${HOST}:${PORT}`);
|
||||
app.log.info(`📖 Swagger OpenAPI documentation available at http://localhost:${PORT}/documentation`);
|
||||
}
|
||||
catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
bootstrap();
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import cookie from '@fastify/cookie';
|
||||
import swagger from '@fastify/swagger';
|
||||
import swaggerUi from '@fastify/swagger-ui';
|
||||
import { Server } from 'socket.io';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { authRoutes } from './routes/auth.routes.js';
|
||||
import { userRoutes } from './routes/user.routes.js';
|
||||
import { clientRoutes } from './routes/client.routes.js';
|
||||
import { listingRoutes } from './routes/listing.routes.js';
|
||||
import { walletRoutes } from './routes/wallet.routes.js';
|
||||
import { blogRoutes } from './routes/blog.routes.js';
|
||||
import { adminRoutes } from './routes/admin.routes.js';
|
||||
import { mediaRoutes } from './routes/media.routes.js';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
},
|
||||
});
|
||||
|
||||
// 1. Security & Core Middleware
|
||||
await app.register(helmet, { contentSecurityPolicy: false });
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
await app.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '15 minutes',
|
||||
});
|
||||
await app.register(cookie, {
|
||||
secret: process.env.JWT_SECRET || 'luxe_cookie_secret_key_2026',
|
||||
});
|
||||
|
||||
// 2. Swagger Documentation Setup
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Luxe Platform REST API Specification',
|
||||
description: 'Production-ready microservices API specification for Luxe classified marketplace.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [{ url: `http://localhost:${PORT}` }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: '/documentation',
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Health Check Endpoints
|
||||
app.get('/health', async () => ({ status: 'UP', timestamp: new Date().toISOString() }));
|
||||
app.get('/ready', async () => ({ status: 'READY', service: 'luxe-gateway' }));
|
||||
|
||||
// 4. API Route Modules Registration
|
||||
await app.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
await app.register(userRoutes, { prefix: '/api/v1/users' });
|
||||
await app.register(clientRoutes, { prefix: '/api/v1/clients' });
|
||||
await app.register(listingRoutes, { prefix: '/api/v1' });
|
||||
await app.register(walletRoutes, { prefix: '/api/v1/wallet' });
|
||||
await app.register(blogRoutes, { prefix: '/api/v1' });
|
||||
await app.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||
await app.register(mediaRoutes, { prefix: '/api/v1/media' });
|
||||
|
||||
// 5. Initialize Server & WebSockets (Socket.IO)
|
||||
const httpServer = createServer(app.server);
|
||||
const io = new Server(httpServer, {
|
||||
cors: { origin: '*' },
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
app.log.info(`Socket connected: ${socket.id}`);
|
||||
|
||||
socket.on('join_room', (room) => {
|
||||
socket.join(room);
|
||||
});
|
||||
|
||||
socket.on('send_message', (data) => {
|
||||
io.to(data.roomId).emit('new_message', data);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
app.log.info(`Socket disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
app.log.info(`🚀 Luxe API Gateway server running on http://${HOST}:${PORT}`);
|
||||
app.log.info(`📖 Swagger OpenAPI documentation available at http://localhost:${PORT}/documentation`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
export declare function adminRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=admin.routes.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"admin.routes.d.ts","sourceRoot":"","sources":["admin.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAyBxE,wBAAsB,WAAW,CAAC,OAAO,EAAE,eAAe,iBA6JzD"}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { prisma } from '@luxe/database';
|
||||
import { verifyToken, successResponse, paginatedResponse, errorResponse } from '@luxe/shared';
|
||||
async function authenticateAdmin(request, reply) {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
reply.status(401).send(errorResponse('Admin Bearer token required'));
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyToken(token);
|
||||
if (!['ADMIN', 'SUPER_ADMIN', 'MODERATOR', 'FINANCE', 'SUPPORT'].includes(decoded.role)) {
|
||||
reply.status(403).send(errorResponse('Forbidden: Admin privilege required'));
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
catch (err) {
|
||||
reply.status(401).send(errorResponse('Invalid or expired token'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function adminRoutes(fastify) {
|
||||
// GET /api/v1/admin/stats/overview
|
||||
fastify.get('/stats/overview', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin)
|
||||
return;
|
||||
const [totalUsers, activeListings, pendingVerifications, totalRevenue] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.listing.count({ where: { status: 'ACTIVE' } }),
|
||||
prisma.verificationRequest.count({ where: { status: 'PENDING' } }),
|
||||
prisma.transaction.aggregate({
|
||||
where: { status: 'SUCCESS' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
]);
|
||||
return reply.send(successResponse({
|
||||
totalUsers,
|
||||
activeListings,
|
||||
pendingVerifications,
|
||||
totalRevenue: totalRevenue._sum.amount || 0.0,
|
||||
}));
|
||||
});
|
||||
// GET /api/v1/admin/users
|
||||
fastify.get('/users', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin)
|
||||
return;
|
||||
const { page = '1', pageSize = '20', role, status, search } = request.query;
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
const where = {};
|
||||
if (role)
|
||||
where.role = role;
|
||||
if (status)
|
||||
where.status = status;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ username: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
return reply.send(paginatedResponse(users, total, p, ps));
|
||||
});
|
||||
// PATCH /api/v1/admin/users/:id/status
|
||||
fastify.patch('/users/:id/status', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin)
|
||||
return;
|
||||
const { id } = request.params;
|
||||
const { status } = request.body; // ACTIVE, SUSPENDED, BANNED
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
// Create Audit Log entry
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: admin.userId,
|
||||
action: 'USER_STATUS_CHANGE',
|
||||
resource: 'User',
|
||||
resourceId: id,
|
||||
metadata: { newStatus: status },
|
||||
},
|
||||
});
|
||||
return reply.send(successResponse(updatedUser, `User status updated to ${status}`));
|
||||
});
|
||||
// GET /api/v1/admin/verifications
|
||||
fastify.get('/verifications', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin)
|
||||
return;
|
||||
const verifications = await prisma.verificationRequest.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
include: {
|
||||
profile: {
|
||||
include: { user: { select: { id: true, email: true, username: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return reply.send(successResponse(verifications));
|
||||
});
|
||||
// POST /api/v1/admin/verifications/:id/approve
|
||||
fastify.post('/verifications/:id/approve', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin)
|
||||
return;
|
||||
const { id } = request.params;
|
||||
const verification = await prisma.verificationRequest.findUnique({ where: { id } });
|
||||
if (!verification)
|
||||
return reply.status(404).send(errorResponse('Verification request not found'));
|
||||
await prisma.$transaction([
|
||||
prisma.verificationRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'VERIFIED' },
|
||||
}),
|
||||
prisma.advertiserProfile.update({
|
||||
where: { id: verification.profileId },
|
||||
data: { verificationStatus: 'VERIFIED' },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
userId: admin.userId,
|
||||
action: 'VERIFICATION_APPROVED',
|
||||
resource: 'VerificationRequest',
|
||||
resourceId: id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return reply.send(successResponse(null, 'Verification request approved'));
|
||||
});
|
||||
// GET /api/v1/admin/audit-logs
|
||||
fastify.get('/audit-logs', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin)
|
||||
return;
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
take: 50,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { username: true, role: true } } },
|
||||
});
|
||||
return reply.send(successResponse(logs));
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=admin.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,183 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@luxe/database';
|
||||
import { verifyToken, successResponse, paginatedResponse, errorResponse, authorize } from '@luxe/shared';
|
||||
|
||||
async function authenticateAdmin(request: FastifyRequest, reply: FastifyReply) {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
reply.status(401).send(errorResponse('Admin Bearer token required'));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyToken(token);
|
||||
if (!['ADMIN', 'SUPER_ADMIN', 'MODERATOR', 'FINANCE', 'SUPPORT'].includes(decoded.role)) {
|
||||
reply.status(403).send(errorResponse('Forbidden: Admin privilege required'));
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch (err) {
|
||||
reply.status(401).send(errorResponse('Invalid or expired token'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/v1/admin/stats/overview
|
||||
fastify.get('/stats/overview', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const [totalUsers, activeListings, pendingVerifications, totalRevenue] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.listing.count({ where: { status: 'ACTIVE' } }),
|
||||
prisma.verificationRequest.count({ where: { status: 'PENDING' } }),
|
||||
prisma.transaction.aggregate({
|
||||
where: { status: 'SUCCESS' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return reply.send(
|
||||
successResponse({
|
||||
totalUsers,
|
||||
activeListings,
|
||||
pendingVerifications,
|
||||
totalRevenue: totalRevenue._sum.amount || 0.0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// GET /api/v1/admin/users
|
||||
fastify.get('/users', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const { page = '1', pageSize = '20', role, status, search } = request.query as any;
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
|
||||
const where: any = {};
|
||||
if (role) where.role = role;
|
||||
if (status) where.status = status;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ username: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return reply.send(paginatedResponse(users, total, p, ps));
|
||||
});
|
||||
|
||||
// PATCH /api/v1/admin/users/:id/status
|
||||
fastify.patch('/users/:id/status', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const { id } = request.params as any;
|
||||
const { status } = request.body as any; // ACTIVE, SUSPENDED, BANNED
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
|
||||
// Create Audit Log entry
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: admin.userId,
|
||||
action: 'USER_STATUS_CHANGE',
|
||||
resource: 'User',
|
||||
resourceId: id,
|
||||
metadata: { newStatus: status },
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send(successResponse(updatedUser, `User status updated to ${status}`));
|
||||
});
|
||||
|
||||
// GET /api/v1/admin/verifications
|
||||
fastify.get('/verifications', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const verifications = await prisma.verificationRequest.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
include: {
|
||||
profile: {
|
||||
include: { user: { select: { id: true, email: true, username: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return reply.send(successResponse(verifications));
|
||||
});
|
||||
|
||||
// POST /api/v1/admin/verifications/:id/approve
|
||||
fastify.post('/verifications/:id/approve', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const { id } = request.params as any;
|
||||
|
||||
const verification = await prisma.verificationRequest.findUnique({ where: { id } });
|
||||
if (!verification) return reply.status(404).send(errorResponse('Verification request not found'));
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.verificationRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'VERIFIED' },
|
||||
}),
|
||||
prisma.advertiserProfile.update({
|
||||
where: { id: verification.profileId },
|
||||
data: { verificationStatus: 'VERIFIED' },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
userId: admin.userId,
|
||||
action: 'VERIFICATION_APPROVED',
|
||||
resource: 'VerificationRequest',
|
||||
resourceId: id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return reply.send(successResponse(null, 'Verification request approved'));
|
||||
});
|
||||
|
||||
// GET /api/v1/admin/audit-logs
|
||||
fastify.get('/audit-logs', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await authenticateAdmin(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
take: 50,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { username: true, role: true } } },
|
||||
});
|
||||
|
||||
return reply.send(successResponse(logs));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
export declare function authRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=auth.routes.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"auth.routes.d.ts","sourceRoot":"","sources":["auth.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAgBxE,wBAAsB,UAAU,CAAC,OAAO,EAAE,eAAe,iBAuOxD"}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { prisma } from '@luxe/database';
|
||||
import { hashPassword, verifyPassword, generateAccessToken, generateRefreshToken, verifyToken, successResponse, errorResponse, registerSchema, loginSchema, } from '@luxe/shared';
|
||||
export async function authRoutes(fastify) {
|
||||
// 1. POST /api/v1/auth/register
|
||||
fastify.post('/register', async (request, reply) => {
|
||||
const parseResult = registerSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.status(400).send(errorResponse('Validation error', parseResult.error.errors.map(e => ({ field: e.path.join('.'), message: e.message }))));
|
||||
}
|
||||
const { email, password, username, role, phone } = parseResult.data;
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: [{ email }, { username }] },
|
||||
});
|
||||
if (existingUser) {
|
||||
return reply.status(400).send(errorResponse('User with email or username already exists'));
|
||||
}
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
role: role,
|
||||
phone,
|
||||
},
|
||||
});
|
||||
// Create Wallet for new user
|
||||
await prisma.wallet.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
balance: 0.0,
|
||||
credits: 10.0, // Complimentary bonus credits
|
||||
},
|
||||
});
|
||||
// If Advertiser role, create AdvertiserProfile shell
|
||||
if (role === 'ADVERTISER' || role === 'AGENCY') {
|
||||
await prisma.advertiserProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: role,
|
||||
},
|
||||
});
|
||||
}
|
||||
const tokenPayload = { userId: user.id, email: user.email, role: user.role };
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
// Save Session in DB
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1/auth',
|
||||
});
|
||||
return reply.status(201).send(successResponse({
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
},
|
||||
}, 'User registered successfully'));
|
||||
});
|
||||
// 2. POST /api/v1/auth/login
|
||||
fastify.post('/login', async (request, reply) => {
|
||||
const parseResult = loginSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.status(400).send(errorResponse('Invalid payload'));
|
||||
}
|
||||
const { email, password } = parseResult.data;
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return reply.status(401).send(errorResponse('Invalid email or password'));
|
||||
}
|
||||
if (user.status === 'BANNED' || user.status === 'SUSPENDED') {
|
||||
return reply.status(403).send(errorResponse(`Account is ${user.status.toLowerCase()}`));
|
||||
}
|
||||
const isValid = await verifyPassword(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLoginCount: { increment: 1 } },
|
||||
});
|
||||
return reply.status(401).send(errorResponse('Invalid email or password'));
|
||||
}
|
||||
// Reset failed attempts & update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLoginCount: 0, lastLoginAt: new Date() },
|
||||
});
|
||||
const tokenPayload = { userId: user.id, email: user.email, role: user.role };
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1/auth',
|
||||
});
|
||||
return reply.send(successResponse({
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
},
|
||||
}, 'Logged in successfully'));
|
||||
});
|
||||
// 3. POST /api/v1/auth/refresh-token
|
||||
fastify.post('/refresh-token', async (request, reply) => {
|
||||
const refreshToken = request.cookies.refreshToken;
|
||||
if (!refreshToken) {
|
||||
return reply.status(401).send(errorResponse('Refresh token required'));
|
||||
}
|
||||
try {
|
||||
const decoded = verifyToken(refreshToken);
|
||||
const session = await prisma.session.findUnique({ where: { refreshToken } });
|
||||
if (!session || session.isRevoked || session.expiresAt < new Date()) {
|
||||
return reply.status(401).send(errorResponse('Invalid or expired session'));
|
||||
}
|
||||
const newAccessToken = generateAccessToken({
|
||||
userId: decoded.userId,
|
||||
email: decoded.email,
|
||||
role: decoded.role,
|
||||
});
|
||||
return reply.send(successResponse({ accessToken: newAccessToken }));
|
||||
}
|
||||
catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid refresh token'));
|
||||
}
|
||||
});
|
||||
// 4. POST /api/v1/auth/logout
|
||||
fastify.post('/logout', async (request, reply) => {
|
||||
const refreshToken = request.cookies.refreshToken;
|
||||
if (refreshToken) {
|
||||
await prisma.session.updateMany({
|
||||
where: { refreshToken },
|
||||
data: { isRevoked: true },
|
||||
});
|
||||
}
|
||||
reply.clearCookie('refreshToken', { path: '/api/v1/auth' });
|
||||
return reply.send(successResponse(null, 'Logged out successfully'));
|
||||
});
|
||||
// 5. GET /api/v1/auth/me
|
||||
fastify.get('/me', async (request, reply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send(errorResponse('Bearer token required'));
|
||||
}
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
language: true,
|
||||
currency: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
wallet: { select: { balance: true, credits: true } },
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
return reply.status(404).send(errorResponse('User not found'));
|
||||
}
|
||||
return reply.send(successResponse(user));
|
||||
}
|
||||
catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid or expired token'));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=auth.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,248 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma, RoleType } from '@luxe/database';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyToken,
|
||||
successResponse,
|
||||
errorResponse,
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
forgotPasswordSchema,
|
||||
resetPasswordSchema,
|
||||
} from '@luxe/shared';
|
||||
|
||||
export async function authRoutes(fastify: FastifyInstance) {
|
||||
// 1. POST /api/v1/auth/register
|
||||
fastify.post('/register', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parseResult = registerSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.status(400).send(errorResponse('Validation error', parseResult.error.errors.map(e => ({ field: e.path.join('.'), message: e.message }))));
|
||||
}
|
||||
|
||||
const { email, password, username, role, phone } = parseResult.data;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: [{ email }, { username }] },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return reply.status(400).send(errorResponse('User with email or username already exists'));
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
role: role as RoleType,
|
||||
phone,
|
||||
},
|
||||
});
|
||||
|
||||
// Create Wallet for new user
|
||||
await prisma.wallet.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
balance: 0.0,
|
||||
credits: 10.0, // Complimentary bonus credits
|
||||
},
|
||||
});
|
||||
|
||||
// If Advertiser role, create AdvertiserProfile shell
|
||||
if (role === 'ADVERTISER' || role === 'AGENCY') {
|
||||
await prisma.advertiserProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const tokenPayload = { userId: user.id, email: user.email, role: user.role };
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
// Save Session in DB
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1/auth',
|
||||
});
|
||||
|
||||
return reply.status(201).send(
|
||||
successResponse(
|
||||
{
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
'User registered successfully'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// 2. POST /api/v1/auth/login
|
||||
fastify.post('/login', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parseResult = loginSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.status(400).send(errorResponse('Invalid payload'));
|
||||
}
|
||||
|
||||
const { email, password } = parseResult.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return reply.status(401).send(errorResponse('Invalid email or password'));
|
||||
}
|
||||
|
||||
if (user.status === 'BANNED' || user.status === 'SUSPENDED') {
|
||||
return reply.status(403).send(errorResponse(`Account is ${user.status.toLowerCase()}`));
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLoginCount: { increment: 1 } },
|
||||
});
|
||||
return reply.status(401).send(errorResponse('Invalid email or password'));
|
||||
}
|
||||
|
||||
// Reset failed attempts & update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedLoginCount: 0, lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const tokenPayload = { userId: user.id, email: user.email, role: user.role };
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
reply.setCookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1/auth',
|
||||
});
|
||||
|
||||
return reply.send(
|
||||
successResponse(
|
||||
{
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
},
|
||||
},
|
||||
'Logged in successfully'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// 3. POST /api/v1/auth/refresh-token
|
||||
fastify.post('/refresh-token', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const refreshToken = request.cookies.refreshToken;
|
||||
if (!refreshToken) {
|
||||
return reply.status(401).send(errorResponse('Refresh token required'));
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(refreshToken);
|
||||
const session = await prisma.session.findUnique({ where: { refreshToken } });
|
||||
|
||||
if (!session || session.isRevoked || session.expiresAt < new Date()) {
|
||||
return reply.status(401).send(errorResponse('Invalid or expired session'));
|
||||
}
|
||||
|
||||
const newAccessToken = generateAccessToken({
|
||||
userId: decoded.userId,
|
||||
email: decoded.email,
|
||||
role: decoded.role,
|
||||
});
|
||||
|
||||
return reply.send(successResponse({ accessToken: newAccessToken }));
|
||||
} catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid refresh token'));
|
||||
}
|
||||
});
|
||||
|
||||
// 4. POST /api/v1/auth/logout
|
||||
fastify.post('/logout', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const refreshToken = request.cookies.refreshToken;
|
||||
if (refreshToken) {
|
||||
await prisma.session.updateMany({
|
||||
where: { refreshToken },
|
||||
data: { isRevoked: true },
|
||||
});
|
||||
}
|
||||
|
||||
reply.clearCookie('refreshToken', { path: '/api/v1/auth' });
|
||||
return reply.send(successResponse(null, 'Logged out successfully'));
|
||||
});
|
||||
|
||||
// 5. GET /api/v1/auth/me
|
||||
fastify.get('/me', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send(errorResponse('Bearer token required'));
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
language: true,
|
||||
currency: true,
|
||||
emailVerifiedAt: true,
|
||||
phoneVerifiedAt: true,
|
||||
wallet: { select: { balance: true, credits: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send(errorResponse('User not found'));
|
||||
}
|
||||
|
||||
return reply.send(successResponse(user));
|
||||
} catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid or expired token'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
export declare function blogRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=blog.routes.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"blog.routes.d.ts","sourceRoot":"","sources":["blog.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAIxE,wBAAsB,UAAU,CAAC,OAAO,EAAE,eAAe,iBA2FxD"}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { prisma } from '@luxe/database';
|
||||
import { verifyToken, successResponse, paginatedResponse, errorResponse } from '@luxe/shared';
|
||||
export async function blogRoutes(fastify) {
|
||||
// GET /api/v1/blogs
|
||||
fastify.get('/blogs', async (request, reply) => {
|
||||
const { page = '1', pageSize = '10', category } = request.query;
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
const where = { isPublished: true };
|
||||
if (category)
|
||||
where.category = category;
|
||||
const [posts, total] = await Promise.all([
|
||||
prisma.blogPost.findMany({
|
||||
where,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.blogPost.count({ where }),
|
||||
]);
|
||||
return reply.send(paginatedResponse(posts, total, p, ps));
|
||||
});
|
||||
// GET /api/v1/blogs/:slug
|
||||
fastify.get('/blogs/:slug', async (request, reply) => {
|
||||
const { slug } = request.params;
|
||||
const post = await prisma.blogPost.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
comments: {
|
||||
include: { user: { select: { username: true, avatar: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!post)
|
||||
return reply.status(404).send(errorResponse('Blog post not found'));
|
||||
return reply.send(successResponse(post));
|
||||
});
|
||||
// GET /api/v1/forum/topics
|
||||
fastify.get('/forum/topics', async (request, reply) => {
|
||||
const { page = '1', pageSize = '15' } = request.query;
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
const [threads, total] = await Promise.all([
|
||||
prisma.forumThread.findMany({
|
||||
include: {
|
||||
user: { select: { username: true, avatar: true } },
|
||||
_count: { select: { replies: true } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.forumThread.count(),
|
||||
]);
|
||||
return reply.send(paginatedResponse(threads, total, p, ps));
|
||||
});
|
||||
// POST /api/v1/forum/topics
|
||||
fastify.post('/forum/topics', async (request, reply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send(errorResponse('Authentication required'));
|
||||
}
|
||||
try {
|
||||
const userPayload = verifyToken(authHeader.split(' ')[1]);
|
||||
const { title, content, category } = request.body;
|
||||
const slug = `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36)}`;
|
||||
const thread = await prisma.forumThread.create({
|
||||
data: {
|
||||
userId: userPayload.userId,
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
category: category || 'General',
|
||||
},
|
||||
});
|
||||
return reply.status(201).send(successResponse(thread, 'Forum topic created'));
|
||||
}
|
||||
catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid token'));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=blog.routes.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"blog.routes.js","sourceRoot":"","sources":["blog.routes.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@luxe/database';
|
||||
import { verifyToken, successResponse, paginatedResponse, errorResponse } from '@luxe/shared';
|
||||
|
||||
export async function blogRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/v1/blogs
|
||||
fastify.get('/blogs', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { page = '1', pageSize = '10', category } = request.query as any;
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
|
||||
const where: any = { isPublished: true };
|
||||
if (category) where.category = category;
|
||||
|
||||
const [posts, total] = await Promise.all([
|
||||
prisma.blogPost.findMany({
|
||||
where,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.blogPost.count({ where }),
|
||||
]);
|
||||
|
||||
return reply.send(paginatedResponse(posts, total, p, ps));
|
||||
});
|
||||
|
||||
// GET /api/v1/blogs/:slug
|
||||
fastify.get('/blogs/:slug', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { slug } = request.params as any;
|
||||
|
||||
const post = await prisma.blogPost.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
comments: {
|
||||
include: { user: { select: { username: true, avatar: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) return reply.status(404).send(errorResponse('Blog post not found'));
|
||||
|
||||
return reply.send(successResponse(post));
|
||||
});
|
||||
|
||||
// GET /api/v1/forum/topics
|
||||
fastify.get('/forum/topics', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { page = '1', pageSize = '15' } = request.query as any;
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
|
||||
const [threads, total] = await Promise.all([
|
||||
prisma.forumThread.findMany({
|
||||
include: {
|
||||
user: { select: { username: true, avatar: true } },
|
||||
_count: { select: { replies: true } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.forumThread.count(),
|
||||
]);
|
||||
|
||||
return reply.send(paginatedResponse(threads, total, p, ps));
|
||||
});
|
||||
|
||||
// POST /api/v1/forum/topics
|
||||
fastify.post('/forum/topics', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send(errorResponse('Authentication required'));
|
||||
}
|
||||
|
||||
try {
|
||||
const userPayload = verifyToken(authHeader.split(' ')[1]);
|
||||
const { title, content, category } = request.body as any;
|
||||
const slug = `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36)}`;
|
||||
|
||||
const thread = await prisma.forumThread.create({
|
||||
data: {
|
||||
userId: userPayload.userId,
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
category: category || 'General',
|
||||
},
|
||||
});
|
||||
|
||||
return reply.status(201).send(successResponse(thread, 'Forum topic created'));
|
||||
} catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid token'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
export declare function clientRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=client.routes.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"client.routes.d.ts","sourceRoot":"","sources":["client.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAoBxE,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,iBA4I1D"}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { prisma } from '@luxe/database';
|
||||
import { verifyToken, successResponse, errorResponse, createListingSchema } from '@luxe/shared';
|
||||
async function authenticateClient(request, reply) {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
reply.status(401).send(errorResponse('Authorization header missing'));
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyToken(token);
|
||||
return decoded;
|
||||
}
|
||||
catch (err) {
|
||||
reply.status(401).send(errorResponse('Invalid or expired token'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function clientRoutes(fastify) {
|
||||
// GET /api/v1/clients/my-profile
|
||||
fastify.get('/my-profile', async (request, reply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload)
|
||||
return;
|
||||
const advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
include: {
|
||||
listings: { include: { category: true, city: true, gallery: true } },
|
||||
verifications: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
if (!advertiser) {
|
||||
return reply.status(404).send(errorResponse('Advertiser profile not found'));
|
||||
}
|
||||
return reply.send(successResponse(advertiser));
|
||||
});
|
||||
// POST /api/v1/clients/listings
|
||||
fastify.post('/listings', async (request, reply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload)
|
||||
return;
|
||||
const parseResult = createListingSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.status(400).send(errorResponse('Validation error', parseResult.error.errors.map(e => ({ field: e.path.join('.'), message: e.message }))));
|
||||
}
|
||||
let advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
});
|
||||
if (!advertiser) {
|
||||
advertiser = await prisma.advertiserProfile.create({
|
||||
data: { userId: userPayload.userId, type: 'INDIVIDUAL' },
|
||||
});
|
||||
}
|
||||
const { title, description, categoryId, cityId, areaId, price, currency, age, height, tagline, languages, tags } = parseResult.data;
|
||||
const slug = `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36)}`;
|
||||
const listing = await prisma.listing.create({
|
||||
data: {
|
||||
profileId: advertiser.id,
|
||||
title,
|
||||
slug,
|
||||
description,
|
||||
categoryId,
|
||||
cityId,
|
||||
areaId,
|
||||
price,
|
||||
currency,
|
||||
age,
|
||||
height,
|
||||
tagline,
|
||||
languages,
|
||||
tags,
|
||||
status: 'ACTIVE', // Or PENDING_APPROVAL based on system settings
|
||||
},
|
||||
});
|
||||
return reply.status(201).send(successResponse(listing, 'Listing created successfully'));
|
||||
});
|
||||
// POST /api/v1/clients/my-profile/gallery
|
||||
fastify.post('/my-profile/gallery', async (request, reply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload)
|
||||
return;
|
||||
const { listingId, url, mediaType = 'IMAGE', isPrimary = false } = request.body;
|
||||
const galleryItem = await prisma.listingGallery.create({
|
||||
data: {
|
||||
listingId,
|
||||
url,
|
||||
mediaType,
|
||||
isPrimary,
|
||||
},
|
||||
});
|
||||
return reply.status(201).send(successResponse(galleryItem, 'Media added to gallery'));
|
||||
});
|
||||
// POST /api/v1/clients/my-profile/verification
|
||||
fastify.post('/my-profile/verification', async (request, reply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload)
|
||||
return;
|
||||
const { idProofUrl, selfieUrl, notes } = request.body;
|
||||
const advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
});
|
||||
if (!advertiser)
|
||||
return reply.status(404).send(errorResponse('Advertiser profile not found'));
|
||||
const verification = await prisma.verificationRequest.create({
|
||||
data: {
|
||||
profileId: advertiser.id,
|
||||
idProofUrl,
|
||||
selfieUrl,
|
||||
notes,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
await prisma.advertiserProfile.update({
|
||||
where: { id: advertiser.id },
|
||||
data: { verificationStatus: 'PENDING' },
|
||||
});
|
||||
return reply.status(201).send(successResponse(verification, 'Verification submitted successfully'));
|
||||
});
|
||||
// GET /api/v1/clients/analytics
|
||||
fastify.get('/analytics', async (request, reply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload)
|
||||
return;
|
||||
const advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
include: { listings: { select: { id: true, viewsCount: true, callsCount: true } } },
|
||||
});
|
||||
if (!advertiser)
|
||||
return reply.status(404).send(errorResponse('Advertiser profile not found'));
|
||||
const totalViews = advertiser.listings.reduce((sum, l) => sum + l.viewsCount, 0);
|
||||
const totalCalls = advertiser.listings.reduce((sum, l) => sum + l.callsCount, 0);
|
||||
return reply.send(successResponse({
|
||||
totalListings: advertiser.listings.length,
|
||||
totalViews,
|
||||
totalCalls,
|
||||
}));
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=client.routes.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,161 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@luxe/database';
|
||||
import { verifyToken, successResponse, errorResponse, createListingSchema } from '@luxe/shared';
|
||||
|
||||
async function authenticateClient(request: FastifyRequest, reply: FastifyReply) {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
reply.status(401).send(errorResponse('Authorization header missing'));
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = verifyToken(token);
|
||||
return decoded;
|
||||
} catch (err) {
|
||||
reply.status(401).send(errorResponse('Invalid or expired token'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clientRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/v1/clients/my-profile
|
||||
fastify.get('/my-profile', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload) return;
|
||||
|
||||
const advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
include: {
|
||||
listings: { include: { category: true, city: true, gallery: true } },
|
||||
verifications: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
if (!advertiser) {
|
||||
return reply.status(404).send(errorResponse('Advertiser profile not found'));
|
||||
}
|
||||
|
||||
return reply.send(successResponse(advertiser));
|
||||
});
|
||||
|
||||
// POST /api/v1/clients/listings
|
||||
fastify.post('/listings', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload) return;
|
||||
|
||||
const parseResult = createListingSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.status(400).send(errorResponse('Validation error', parseResult.error.errors.map(e => ({ field: e.path.join('.'), message: e.message }))));
|
||||
}
|
||||
|
||||
let advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
});
|
||||
|
||||
if (!advertiser) {
|
||||
advertiser = await prisma.advertiserProfile.create({
|
||||
data: { userId: userPayload.userId, type: 'INDIVIDUAL' },
|
||||
});
|
||||
}
|
||||
|
||||
const { title, description, categoryId, cityId, areaId, price, currency, age, height, tagline, languages, tags } = parseResult.data;
|
||||
const slug = `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36)}`;
|
||||
|
||||
const listing = await prisma.listing.create({
|
||||
data: {
|
||||
profileId: advertiser.id,
|
||||
title,
|
||||
slug,
|
||||
description,
|
||||
categoryId,
|
||||
cityId,
|
||||
areaId,
|
||||
price,
|
||||
currency,
|
||||
age,
|
||||
height,
|
||||
tagline,
|
||||
languages,
|
||||
tags,
|
||||
status: 'ACTIVE', // Or PENDING_APPROVAL based on system settings
|
||||
},
|
||||
});
|
||||
|
||||
return reply.status(201).send(successResponse(listing, 'Listing created successfully'));
|
||||
});
|
||||
|
||||
// POST /api/v1/clients/my-profile/gallery
|
||||
fastify.post('/my-profile/gallery', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload) return;
|
||||
|
||||
const { listingId, url, mediaType = 'IMAGE', isPrimary = false } = request.body as any;
|
||||
|
||||
const galleryItem = await prisma.listingGallery.create({
|
||||
data: {
|
||||
listingId,
|
||||
url,
|
||||
mediaType,
|
||||
isPrimary,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.status(201).send(successResponse(galleryItem, 'Media added to gallery'));
|
||||
});
|
||||
|
||||
// POST /api/v1/clients/my-profile/verification
|
||||
fastify.post('/my-profile/verification', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload) return;
|
||||
|
||||
const { idProofUrl, selfieUrl, notes } = request.body as any;
|
||||
|
||||
const advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
});
|
||||
|
||||
if (!advertiser) return reply.status(404).send(errorResponse('Advertiser profile not found'));
|
||||
|
||||
const verification = await prisma.verificationRequest.create({
|
||||
data: {
|
||||
profileId: advertiser.id,
|
||||
idProofUrl,
|
||||
selfieUrl,
|
||||
notes,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.advertiserProfile.update({
|
||||
where: { id: advertiser.id },
|
||||
data: { verificationStatus: 'PENDING' },
|
||||
});
|
||||
|
||||
return reply.status(201).send(successResponse(verification, 'Verification submitted successfully'));
|
||||
});
|
||||
|
||||
// GET /api/v1/clients/analytics
|
||||
fastify.get('/analytics', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const userPayload = await authenticateClient(request, reply);
|
||||
if (!userPayload) return;
|
||||
|
||||
const advertiser = await prisma.advertiserProfile.findUnique({
|
||||
where: { userId: userPayload.userId },
|
||||
include: { listings: { select: { id: true, viewsCount: true, callsCount: true } } },
|
||||
});
|
||||
|
||||
if (!advertiser) return reply.status(404).send(errorResponse('Advertiser profile not found'));
|
||||
|
||||
const totalViews = advertiser.listings.reduce((sum: number, l: { viewsCount: number }) => sum + l.viewsCount, 0);
|
||||
const totalCalls = advertiser.listings.reduce((sum: number, l: { callsCount: number }) => sum + l.callsCount, 0);
|
||||
|
||||
return reply.send(
|
||||
successResponse({
|
||||
totalListings: advertiser.listings.length,
|
||||
totalViews,
|
||||
totalCalls,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
export declare function listingRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=listing.routes.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"listing.routes.d.ts","sourceRoot":"","sources":["listing.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAIxE,wBAAsB,aAAa,CAAC,OAAO,EAAE,eAAe,iBA0I3D"}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { prisma } from '@luxe/database';
|
||||
import { successResponse, paginatedResponse, errorResponse } from '@luxe/shared';
|
||||
export async function listingRoutes(fastify) {
|
||||
// GET /api/v1/search/profiles
|
||||
fastify.get('/search/profiles', async (request, reply) => {
|
||||
const { query, city, category, ageMin, ageMax, priceMin, priceMax, verified, featured, page = '1', pageSize = '20', sortBy = 'createdAt', } = request.query;
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
const whereClause = {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
};
|
||||
if (query) {
|
||||
whereClause.OR = [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ description: { contains: query, mode: 'insensitive' } },
|
||||
{ tagline: { contains: query, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (city) {
|
||||
whereClause.city = { slug: city };
|
||||
}
|
||||
if (category) {
|
||||
whereClause.category = { slug: category };
|
||||
}
|
||||
if (ageMin || ageMax) {
|
||||
whereClause.age = {};
|
||||
if (ageMin)
|
||||
whereClause.age.gte = parseInt(ageMin);
|
||||
if (ageMax)
|
||||
whereClause.age.lte = parseInt(ageMax);
|
||||
}
|
||||
if (priceMin || priceMax) {
|
||||
whereClause.price = {};
|
||||
if (priceMin)
|
||||
whereClause.price.gte = parseFloat(priceMin);
|
||||
if (priceMax)
|
||||
whereClause.price.lte = parseFloat(priceMax);
|
||||
}
|
||||
if (verified === 'true')
|
||||
whereClause.isVerified = true;
|
||||
if (featured === 'true')
|
||||
whereClause.isFeatured = true;
|
||||
const [listings, total] = await Promise.all([
|
||||
prisma.listing.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
city: { select: { name: true, slug: true } },
|
||||
area: { select: { name: true, slug: true } },
|
||||
gallery: { where: { isPrimary: true }, take: 1 },
|
||||
advertiser: { select: { rating: true, reviewCount: true, verificationStatus: true } },
|
||||
},
|
||||
orderBy: { [sortBy]: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.listing.count({ where: whereClause }),
|
||||
]);
|
||||
return reply.send(paginatedResponse(listings, total, p, ps));
|
||||
});
|
||||
// GET /api/v1/profiles/featured
|
||||
fastify.get('/profiles/featured', async (_request, reply) => {
|
||||
const featured = await prisma.listing.findMany({
|
||||
where: { isFeatured: true, status: 'ACTIVE', deletedAt: null },
|
||||
include: {
|
||||
category: true,
|
||||
city: true,
|
||||
gallery: true,
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
return reply.send(successResponse(featured));
|
||||
});
|
||||
// GET /api/v1/profiles/:slug
|
||||
fastify.get('/profiles/:slug', async (request, reply) => {
|
||||
const { slug } = request.params;
|
||||
const listing = await prisma.listing.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
category: true,
|
||||
city: true,
|
||||
area: true,
|
||||
gallery: true,
|
||||
advertiser: true,
|
||||
reviews: { take: 5, orderBy: { createdAt: 'desc' }, include: { user: { select: { username: true, avatar: true } } } },
|
||||
},
|
||||
});
|
||||
if (!listing)
|
||||
return reply.status(404).send(errorResponse('Listing profile not found'));
|
||||
// Increment view count asynchronously
|
||||
prisma.listing.update({
|
||||
where: { id: listing.id },
|
||||
data: { viewsCount: { increment: 1 } },
|
||||
}).catch(() => { });
|
||||
return reply.send(successResponse(listing));
|
||||
});
|
||||
// GET /api/v1/cities
|
||||
fastify.get('/cities', async (_request, reply) => {
|
||||
const cities = await prisma.city.findMany({
|
||||
include: { _count: { select: { listings: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return reply.send(successResponse(cities));
|
||||
});
|
||||
// GET /api/v1/categories
|
||||
fastify.get('/categories', async (_request, reply) => {
|
||||
const categories = await prisma.category.findMany({
|
||||
include: { _count: { select: { listings: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return reply.send(successResponse(categories));
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=listing.routes.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"listing.routes.js","sourceRoot":"","sources":["listing.routes.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@luxe/database';
|
||||
import { successResponse, paginatedResponse, errorResponse } from '@luxe/shared';
|
||||
|
||||
export async function listingRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/v1/search/profiles
|
||||
fastify.get('/search/profiles', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const {
|
||||
query,
|
||||
city,
|
||||
category,
|
||||
ageMin,
|
||||
ageMax,
|
||||
priceMin,
|
||||
priceMax,
|
||||
verified,
|
||||
featured,
|
||||
page = '1',
|
||||
pageSize = '20',
|
||||
sortBy = 'createdAt',
|
||||
} = request.query as any;
|
||||
|
||||
const p = parseInt(page);
|
||||
const ps = parseInt(pageSize);
|
||||
|
||||
const whereClause: any = {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
whereClause.OR = [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ description: { contains: query, mode: 'insensitive' } },
|
||||
{ tagline: { contains: query, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (city) {
|
||||
whereClause.city = { slug: city };
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClause.category = { slug: category };
|
||||
}
|
||||
|
||||
if (ageMin || ageMax) {
|
||||
whereClause.age = {};
|
||||
if (ageMin) whereClause.age.gte = parseInt(ageMin);
|
||||
if (ageMax) whereClause.age.lte = parseInt(ageMax);
|
||||
}
|
||||
|
||||
if (priceMin || priceMax) {
|
||||
whereClause.price = {};
|
||||
if (priceMin) whereClause.price.gte = parseFloat(priceMin);
|
||||
if (priceMax) whereClause.price.lte = parseFloat(priceMax);
|
||||
}
|
||||
|
||||
if (verified === 'true') whereClause.isVerified = true;
|
||||
if (featured === 'true') whereClause.isFeatured = true;
|
||||
|
||||
const [listings, total] = await Promise.all([
|
||||
prisma.listing.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
city: { select: { name: true, slug: true } },
|
||||
area: { select: { name: true, slug: true } },
|
||||
gallery: { where: { isPrimary: true }, take: 1 },
|
||||
advertiser: { select: { rating: true, reviewCount: true, verificationStatus: true } },
|
||||
},
|
||||
orderBy: { [sortBy]: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
prisma.listing.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return reply.send(paginatedResponse(listings, total, p, ps));
|
||||
});
|
||||
|
||||
// GET /api/v1/profiles/featured
|
||||
fastify.get('/profiles/featured', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const featured = await prisma.listing.findMany({
|
||||
where: { isFeatured: true, status: 'ACTIVE', deletedAt: null },
|
||||
include: {
|
||||
category: true,
|
||||
city: true,
|
||||
gallery: true,
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
return reply.send(successResponse(featured));
|
||||
});
|
||||
|
||||
// GET /api/v1/profiles/:slug
|
||||
fastify.get('/profiles/:slug', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { slug } = request.params as any;
|
||||
|
||||
const listing = await prisma.listing.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
category: true,
|
||||
city: true,
|
||||
area: true,
|
||||
gallery: true,
|
||||
advertiser: true,
|
||||
reviews: { take: 5, orderBy: { createdAt: 'desc' }, include: { user: { select: { username: true, avatar: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!listing) return reply.status(404).send(errorResponse('Listing profile not found'));
|
||||
|
||||
// Increment view count asynchronously
|
||||
prisma.listing.update({
|
||||
where: { id: listing.id },
|
||||
data: { viewsCount: { increment: 1 } },
|
||||
}).catch(() => {});
|
||||
|
||||
return reply.send(successResponse(listing));
|
||||
});
|
||||
|
||||
// GET /api/v1/cities
|
||||
fastify.get('/cities', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const cities = await prisma.city.findMany({
|
||||
include: { _count: { select: { listings: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return reply.send(successResponse(cities));
|
||||
});
|
||||
|
||||
// GET /api/v1/categories
|
||||
fastify.get('/categories', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const categories = await prisma.category.findMany({
|
||||
include: { _count: { select: { listings: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return reply.send(successResponse(categories));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
export declare function mediaRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=media.routes.d.ts.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"media.routes.d.ts","sourceRoot":"","sources":["media.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAGxE,wBAAsB,WAAW,CAAC,OAAO,EAAE,eAAe,iBA8BzD"}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { verifyToken, successResponse, errorResponse } from '@luxe/shared';
|
||||
export async function mediaRoutes(fastify) {
|
||||
// POST /api/v1/media/presigned-url
|
||||
fastify.post('/presigned-url', async (request, reply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send(errorResponse('Bearer token required'));
|
||||
}
|
||||
try {
|
||||
const userPayload = verifyToken(authHeader.split(' ')[1]);
|
||||
const { fileName, fileType, folder = 'listings' } = request.body;
|
||||
if (!fileName || !fileType) {
|
||||
return reply.status(400).send(errorResponse('fileName and fileType are required'));
|
||||
}
|
||||
const key = `${folder}/${userPayload.userId}/${Date.now()}-${fileName}`;
|
||||
const uploadUrl = `${process.env.S3_ENDPOINT || 'http://localhost:9000'}/${process.env.S3_BUCKET || 'luxe-media'}/${key}`;
|
||||
return reply.send(successResponse({
|
||||
uploadUrl,
|
||||
key,
|
||||
publicUrl: uploadUrl,
|
||||
}, 'Presigned URL generated'));
|
||||
}
|
||||
catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid token'));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=media.routes.js.map
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"media.routes.js","sourceRoot":"","sources":["media.routes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE3E,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,OAAwB;IACxD,mCAAmC;IACnC,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,KAAK,EAAE,OAAuB,EAAE,KAAmB,EAAE,EAAE;QACpF,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC;QACjD,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YACrD,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,uBAAuB,CAAC,CAAC,CAAC;QACxE,CAAC;QAED,IAAI,CAAC;YACH,MAAM,WAAW,GAAG,WAAW,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC1D,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,EAAE,GAAG,OAAO,CAAC,IAAW,CAAC;YAExE,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ,EAAE,CAAC;gBAC3B,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,oCAAoC,CAAC,CAAC,CAAC;YACrF,CAAC;YAED,MAAM,GAAG,GAAG,GAAG,MAAM,IAAI,WAAW,CAAC,MAAM,IAAI,IAAI,CAAC,GAAG,EAAE,IAAI,QAAQ,EAAE,CAAC;YACxE,MAAM,SAAS,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,uBAAuB,IAAI,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,YAAY,IAAI,GAAG,EAAE,CAAC;YAE1H,OAAO,KAAK,CAAC,IAAI,CACf,eAAe,CAAC;gBACd,SAAS;gBACT,GAAG;gBACH,SAAS,EAAE,SAAS;aACrB,EAAE,yBAAyB,CAAC,CAC9B,CAAC;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC,CAAC;QAChE,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC"}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { verifyToken, successResponse, errorResponse } from '@luxe/shared';
|
||||
|
||||
export async function mediaRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/v1/media/presigned-url
|
||||
fastify.post('/presigned-url', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send(errorResponse('Bearer token required'));
|
||||
}
|
||||
|
||||
try {
|
||||
const userPayload = verifyToken(authHeader.split(' ')[1]);
|
||||
const { fileName, fileType, folder = 'listings' } = request.body as any;
|
||||
|
||||
if (!fileName || !fileType) {
|
||||
return reply.status(400).send(errorResponse('fileName and fileType are required'));
|
||||
}
|
||||
|
||||
const key = `${folder}/${userPayload.userId}/${Date.now()}-${fileName}`;
|
||||
const uploadUrl = `${process.env.S3_ENDPOINT || 'http://localhost:9000'}/${process.env.S3_BUCKET || 'luxe-media'}/${key}`;
|
||||
|
||||
return reply.send(
|
||||
successResponse({
|
||||
uploadUrl,
|
||||
key,
|
||||
publicUrl: uploadUrl,
|
||||
}, 'Presigned URL generated')
|
||||
);
|
||||
} catch (err) {
|
||||
return reply.status(401).send(errorResponse('Invalid token'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
export declare function userRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=user.routes.d.ts.map
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue