mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -65,6 +65,7 @@ mana-core-auth/
|
|||
### 2. Database Schema ✅
|
||||
|
||||
**Auth Schema:**
|
||||
|
||||
- `auth.users` - User accounts with soft delete support
|
||||
- `auth.sessions` - Active sessions with device tracking
|
||||
- `auth.passwords` - Separate password storage (bcrypt hashed)
|
||||
|
|
@ -74,6 +75,7 @@ mana-core-auth/
|
|||
- `auth.security_events` - Security audit log
|
||||
|
||||
**Credits Schema:**
|
||||
|
||||
- `credits.balances` - User credit balances with optimistic locking
|
||||
- `credits.transactions` - Double-entry transaction ledger
|
||||
- `credits.packages` - Credit pricing packages
|
||||
|
|
@ -81,6 +83,7 @@ mana-core-auth/
|
|||
- `credits.usage_stats` - Usage analytics per app
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Row-Level Security (RLS) policies on all tables
|
||||
- Optimistic locking for balance updates (prevents race conditions)
|
||||
- Idempotency keys for transactions
|
||||
|
|
@ -89,6 +92,7 @@ mana-core-auth/
|
|||
### 3. Authentication System ✅
|
||||
|
||||
**Endpoints Implemented:**
|
||||
|
||||
- `POST /api/v1/auth/register` - User registration
|
||||
- `POST /api/v1/auth/login` - Login with credentials
|
||||
- `POST /api/v1/auth/refresh` - Refresh access token
|
||||
|
|
@ -96,6 +100,7 @@ mana-core-auth/
|
|||
- `POST /api/v1/auth/validate` - Validate JWT token
|
||||
|
||||
**Security Features:**
|
||||
|
||||
- RS256 JWT algorithm (asymmetric keys)
|
||||
- Access tokens: 15 minutes expiry
|
||||
- Refresh tokens: 7 days expiry with rotation
|
||||
|
|
@ -107,6 +112,7 @@ mana-core-auth/
|
|||
### 4. Credit System ✅
|
||||
|
||||
**Endpoints Implemented:**
|
||||
|
||||
- `GET /api/v1/credits/balance` - Get current balance
|
||||
- `POST /api/v1/credits/use` - Deduct credits
|
||||
- `GET /api/v1/credits/transactions` - Transaction history
|
||||
|
|
@ -114,6 +120,7 @@ mana-core-auth/
|
|||
- `GET /api/v1/credits/packages` - Available packages
|
||||
|
||||
**Features:**
|
||||
|
||||
- Signup bonus: 150 free credits
|
||||
- Daily free credits: 5 credits every 24 hours
|
||||
- Automatic daily reset with transaction logging
|
||||
|
|
@ -123,12 +130,14 @@ mana-core-auth/
|
|||
- Complete audit trail via double-entry ledger
|
||||
|
||||
**Credit Pricing:**
|
||||
|
||||
- 100 mana = €1.00 (configurable)
|
||||
- Stored as integer (euro cents) for precision
|
||||
|
||||
### 5. Docker Infrastructure ✅
|
||||
|
||||
**Services Configured:**
|
||||
|
||||
- **Traefik** - Reverse proxy with automatic SSL (Let's Encrypt)
|
||||
- **PostgreSQL 16** - Database with SCRAM-SHA-256 auth
|
||||
- **PgBouncer** - Connection pooling (transaction mode)
|
||||
|
|
@ -138,6 +147,7 @@ mana-core-auth/
|
|||
- **Grafana** - Monitoring dashboards
|
||||
|
||||
**Docker Features:**
|
||||
|
||||
- Multi-stage Dockerfile (optimized build)
|
||||
- Health checks for all services
|
||||
- Volume persistence for data
|
||||
|
|
@ -148,6 +158,7 @@ mana-core-auth/
|
|||
### 6. Configuration & Environment ✅
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
- Database connection (PostgreSQL)
|
||||
- Redis configuration
|
||||
- JWT keys (RS256 public/private)
|
||||
|
|
@ -157,6 +168,7 @@ mana-core-auth/
|
|||
- Rate limiting configuration
|
||||
|
||||
**Configuration Files:**
|
||||
|
||||
- `.env.example` - Template with all variables
|
||||
- `configuration.ts` - Type-safe config loading
|
||||
- `docker-compose.yml` - Full stack orchestration
|
||||
|
|
@ -164,6 +176,7 @@ mana-core-auth/
|
|||
### 7. Security Features ✅
|
||||
|
||||
**Application Level:**
|
||||
|
||||
- Helmet.js security headers
|
||||
- CORS protection
|
||||
- Rate limiting (100 req/min per IP)
|
||||
|
|
@ -172,6 +185,7 @@ mana-core-auth/
|
|||
- Refresh token rotation
|
||||
|
||||
**Database Level:**
|
||||
|
||||
- Row-Level Security (RLS) policies
|
||||
- Helper functions: `auth.uid()`, `auth.role()`
|
||||
- Separate password table
|
||||
|
|
@ -179,6 +193,7 @@ mana-core-auth/
|
|||
- Security events logging
|
||||
|
||||
**Infrastructure Level:**
|
||||
|
||||
- Traefik rate limiting
|
||||
- PostgreSQL SCRAM-SHA-256
|
||||
- Redis password protection
|
||||
|
|
@ -188,11 +203,13 @@ mana-core-auth/
|
|||
### 8. Additional Features ✅
|
||||
|
||||
**Scripts:**
|
||||
|
||||
- `generate-keys.sh` - Generate RS256 key pair
|
||||
- Migration management via Drizzle Kit
|
||||
- Docker health checks
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- README.md - Complete setup guide
|
||||
- API endpoint documentation
|
||||
- Architecture overview
|
||||
|
|
@ -286,6 +303,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
### 5. Future Implementation Tasks
|
||||
|
||||
**Phase 1: Stripe Integration**
|
||||
|
||||
- [ ] Implement Stripe payment intent creation
|
||||
- [ ] Add webhook handler for payment events
|
||||
- [ ] Create credit packages in database
|
||||
|
|
@ -293,12 +311,14 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
- [ ] Test payment flow end-to-end
|
||||
|
||||
**Phase 2: OAuth Providers**
|
||||
|
||||
- [ ] Configure OAuth providers (Google, GitHub, Apple)
|
||||
- [ ] Add OAuth login endpoints
|
||||
- [ ] Handle account linking
|
||||
- [ ] Test social login flow
|
||||
|
||||
**Phase 3: Advanced Features**
|
||||
|
||||
- [ ] Implement 2FA setup and verification
|
||||
- [ ] Add email verification system
|
||||
- [ ] Create password reset flow
|
||||
|
|
@ -306,6 +326,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
- [ ] Admin dashboard
|
||||
|
||||
**Phase 4: Shared Package**
|
||||
|
||||
- [ ] Create `@manacore/shared-auth` package
|
||||
- [ ] Platform-agnostic auth service
|
||||
- [ ] Auto-refresh logic
|
||||
|
|
@ -313,6 +334,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
- [ ] App-token generation
|
||||
|
||||
**Phase 5: Production Deployment**
|
||||
|
||||
- [ ] Set up VPS (Hetzner CPX31)
|
||||
- [ ] Configure DNS records
|
||||
- [ ] Deploy with docker-compose
|
||||
|
|
@ -324,38 +346,38 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
|
||||
### Authentication
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/api/v1/auth/register` | POST | None | Register new user |
|
||||
| `/api/v1/auth/login` | POST | None | Login with credentials |
|
||||
| `/api/v1/auth/refresh` | POST | None | Refresh access token |
|
||||
| `/api/v1/auth/logout` | POST | Bearer | Logout and revoke session |
|
||||
| `/api/v1/auth/validate` | POST | None | Validate JWT token |
|
||||
| Endpoint | Method | Auth | Description |
|
||||
| ----------------------- | ------ | ------ | ------------------------- |
|
||||
| `/api/v1/auth/register` | POST | None | Register new user |
|
||||
| `/api/v1/auth/login` | POST | None | Login with credentials |
|
||||
| `/api/v1/auth/refresh` | POST | None | Refresh access token |
|
||||
| `/api/v1/auth/logout` | POST | Bearer | Logout and revoke session |
|
||||
| `/api/v1/auth/validate` | POST | None | Validate JWT token |
|
||||
|
||||
### Credits
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/api/v1/credits/balance` | GET | Bearer | Get current balance |
|
||||
| `/api/v1/credits/use` | POST | Bearer | Deduct credits |
|
||||
| `/api/v1/credits/transactions` | GET | Bearer | Transaction history |
|
||||
| `/api/v1/credits/purchases` | GET | Bearer | Purchase history |
|
||||
| `/api/v1/credits/packages` | GET | Bearer | Available packages |
|
||||
| Endpoint | Method | Auth | Description |
|
||||
| ------------------------------ | ------ | ------ | ------------------- |
|
||||
| `/api/v1/credits/balance` | GET | Bearer | Get current balance |
|
||||
| `/api/v1/credits/use` | POST | Bearer | Deduct credits |
|
||||
| `/api/v1/credits/transactions` | GET | Bearer | Transaction history |
|
||||
| `/api/v1/credits/purchases` | GET | Bearer | Purchase history |
|
||||
| `/api/v1/credits/packages` | GET | Bearer | Available packages |
|
||||
|
||||
## Technical Stack Summary
|
||||
|
||||
| Component | Technology | Version |
|
||||
|-----------|-----------|---------|
|
||||
| Framework | NestJS | 10.4.x |
|
||||
| Runtime | Node.js | 20+ |
|
||||
| Package Manager | pnpm | 9.15.0 |
|
||||
| Database | PostgreSQL | 16 |
|
||||
| ORM | Drizzle | 0.38.x |
|
||||
| Cache | Redis | 7 |
|
||||
| Payment | Stripe | 17.x |
|
||||
| Reverse Proxy | Traefik | 3.0 |
|
||||
| Connection Pool | PgBouncer | Latest |
|
||||
| Monitoring | Prometheus + Grafana | Latest |
|
||||
| Component | Technology | Version |
|
||||
| --------------- | -------------------- | ------- |
|
||||
| Framework | NestJS | 10.4.x |
|
||||
| Runtime | Node.js | 20+ |
|
||||
| Package Manager | pnpm | 9.15.0 |
|
||||
| Database | PostgreSQL | 16 |
|
||||
| ORM | Drizzle | 0.38.x |
|
||||
| Cache | Redis | 7 |
|
||||
| Payment | Stripe | 17.x |
|
||||
| Reverse Proxy | Traefik | 3.0 |
|
||||
| Connection Pool | PgBouncer | Latest |
|
||||
| Monitoring | Prometheus + Grafana | Latest |
|
||||
|
||||
## File Locations
|
||||
|
||||
|
|
@ -370,6 +392,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
## Success Metrics
|
||||
|
||||
✅ **Core Implementation Complete**
|
||||
|
||||
- 12 database tables with RLS policies
|
||||
- 10 API endpoints (5 auth + 5 credits)
|
||||
- Docker deployment infrastructure
|
||||
|
|
@ -380,6 +403,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
## Estimated Time to Production
|
||||
|
||||
Based on remaining tasks:
|
||||
|
||||
- JWT key generation: 5 minutes
|
||||
- Environment configuration: 15 minutes
|
||||
- Local testing: 30 minutes
|
||||
|
|
@ -392,6 +416,7 @@ Based on remaining tasks:
|
|||
## Support
|
||||
|
||||
For questions or issues:
|
||||
|
||||
1. Check README.md in the package
|
||||
2. Review master plan in .hive-mind/
|
||||
3. Contact the development team
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ The Mana Core Auth system is a **central authentication service** that serves th
|
|||
- `chat/`
|
||||
|
||||
This matches the monorepo structure where:
|
||||
|
||||
- **Root-level projects** = Complete applications/services
|
||||
- **`packages/` directory** = Shared libraries and utilities (e.g., `@manacore/shared-auth`, `@manacore/shared-types`)
|
||||
|
||||
|
|
@ -39,29 +40,36 @@ manacore-monorepo/
|
|||
## Files Updated
|
||||
|
||||
### 1. docker-compose.yml
|
||||
|
||||
- Changed postgres init volume: `./mana-core-auth/postgres/init`
|
||||
- Changed Dockerfile path: `./mana-core-auth/Dockerfile`
|
||||
|
||||
### 2. mana-core-auth/Dockerfile
|
||||
|
||||
- Updated all `packages/mana-core-auth/` references to `mana-core-auth/`
|
||||
|
||||
### 3. mana-core-auth/package.json
|
||||
|
||||
- Changed name from `@manacore/auth` to `mana-core-auth`
|
||||
- Reflects that it's a standalone service, not a shared package
|
||||
|
||||
### 4. Documentation Files
|
||||
|
||||
- All `.md` files updated to reference correct path
|
||||
- `QUICKSTART.md`, `README.md`, `IMPLEMENTATION_SUMMARY.md` all updated
|
||||
|
||||
## Impact
|
||||
|
||||
### No Breaking Changes ✅
|
||||
|
||||
- The service is standalone and doesn't affect other projects
|
||||
- Docker configuration updated to match new location
|
||||
- All internal references corrected
|
||||
|
||||
### Workspace Configuration
|
||||
|
||||
The service is still part of the pnpm workspace (via `pnpm-workspace.yaml`), so you can still run:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter mana-core-auth start:dev
|
||||
|
|
@ -93,11 +101,13 @@ pnpm start:dev
|
|||
## Integration with Other Projects
|
||||
|
||||
When you create the `@manacore/shared-auth` package for mobile/web apps, it will:
|
||||
|
||||
- Live in `packages/shared-auth/` (shared library)
|
||||
- Connect to the `mana-core-auth` service (central service)
|
||||
- Be imported as `import { AuthService } from '@manacore/shared-auth'`
|
||||
|
||||
Clear separation:
|
||||
|
||||
- **`mana-core-auth/`** = The backend service (NestJS, PostgreSQL)
|
||||
- **`packages/shared-auth/`** = Client library for apps (React Native, SvelteKit)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ When you run `docker-compose up`, migrations are **automatically applied** befor
|
|||
For local development, you have two options:
|
||||
|
||||
#### Option 1: Automatic Schema Sync (Recommended)
|
||||
|
||||
```bash
|
||||
# Sync schema to database (creates/updates tables)
|
||||
pnpm db:push
|
||||
|
|
@ -29,6 +30,7 @@ pnpm db:push
|
|||
This is the **fastest** way during development. It pushes your schema changes directly to the database without generating migration files.
|
||||
|
||||
#### Option 2: Generated Migrations (Production-style)
|
||||
|
||||
```bash
|
||||
# 1. Generate migration files from schema changes
|
||||
pnpm migration:generate
|
||||
|
|
@ -41,17 +43,19 @@ Use this approach when you want explicit migration files for version control.
|
|||
|
||||
## Commands Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm db:push` | Sync schema to database (no migration files) |
|
||||
| `pnpm db:studio` | Open Drizzle Studio to view/edit data |
|
||||
| `pnpm migration:generate` | Generate migration files from schema |
|
||||
| `pnpm migration:run` | Apply pending migrations |
|
||||
| Command | Description |
|
||||
| ------------------------- | -------------------------------------------- |
|
||||
| `pnpm db:push` | Sync schema to database (no migration files) |
|
||||
| `pnpm db:studio` | Open Drizzle Studio to view/edit data |
|
||||
| `pnpm migration:generate` | Generate migration files from schema |
|
||||
| `pnpm migration:run` | Apply pending migrations |
|
||||
|
||||
## How It Works
|
||||
|
||||
### Schema Location
|
||||
|
||||
All database tables are defined in TypeScript:
|
||||
|
||||
```
|
||||
src/db/schema/
|
||||
├── auth.schema.ts # Users, sessions, passwords, etc.
|
||||
|
|
@ -92,11 +96,13 @@ exec node dist/main.js
|
|||
When starting fresh:
|
||||
|
||||
1. **Start PostgreSQL**:
|
||||
|
||||
```bash
|
||||
docker compose up postgres -d
|
||||
```
|
||||
|
||||
2. **Apply Schema**:
|
||||
|
||||
```bash
|
||||
pnpm db:push
|
||||
```
|
||||
|
|
@ -116,6 +122,7 @@ docker compose up -d mana-core-auth
|
|||
```
|
||||
|
||||
The service will:
|
||||
|
||||
1. Wait for PostgreSQL to be healthy (`depends_on`)
|
||||
2. Run migrations via entrypoint script
|
||||
3. Start the NestJS application
|
||||
|
|
@ -123,17 +130,21 @@ The service will:
|
|||
## Troubleshooting
|
||||
|
||||
### "relation does not exist"
|
||||
|
||||
**Problem**: Schema not synced to database
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
### "schema already exists"
|
||||
|
||||
**Problem**: Partial migration state
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Option 1: Force push
|
||||
pnpm db:push --force
|
||||
|
|
@ -145,26 +156,31 @@ pnpm db:push
|
|||
```
|
||||
|
||||
### Migration fails in Docker
|
||||
|
||||
**Problem**: Database credentials or connection
|
||||
|
||||
**Solution**:
|
||||
Check `docker-compose.yml` environment variables:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `POSTGRES_PASSWORD`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Development
|
||||
|
||||
- ✅ Use `pnpm db:push` for fast iteration
|
||||
- ✅ Use Drizzle Studio to inspect data: `pnpm db:studio`
|
||||
- ❌ Don't commit generated migration files during active development
|
||||
|
||||
### Production
|
||||
|
||||
- ✅ Let Docker handle migrations automatically
|
||||
- ✅ Monitor container logs for migration success
|
||||
- ✅ Ensure DATABASE_URL is correct in environment
|
||||
|
||||
### Schema Changes
|
||||
|
||||
- ✅ Make schema changes in `src/db/schema/*.ts`
|
||||
- ✅ Test locally with `pnpm db:push`
|
||||
- ✅ Commit schema changes to git
|
||||
|
|
@ -174,9 +190,9 @@ Check `docker-compose.yml` environment variables:
|
|||
|
||||
This project uses **"push-based migrations"** rather than explicit migration files:
|
||||
|
||||
| Approach | When to Use |
|
||||
|----------|-------------|
|
||||
| **Push (`db:push`)** | Development, Docker, quick iteration |
|
||||
| Approach | When to Use |
|
||||
| ------------------------ | --------------------------------------------- |
|
||||
| **Push (`db:push`)** | Development, Docker, quick iteration |
|
||||
| **Generated Migrations** | When you need explicit SQL files, audit trail |
|
||||
|
||||
The push-based approach is **simpler** and **faster** for most use cases, which is why it's used in the Docker entrypoint.
|
||||
|
|
@ -190,6 +206,7 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname
|
|||
```
|
||||
|
||||
In Docker Compose, this is auto-configured:
|
||||
|
||||
```yaml
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@pgbouncer:6432/${POSTGRES_DB}
|
||||
```
|
||||
|
|
@ -197,16 +214,19 @@ DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@pgbouncer:6432/
|
|||
## Health Checks
|
||||
|
||||
The service won't start until:
|
||||
|
||||
1. ✅ PostgreSQL is healthy
|
||||
2. ✅ Migrations complete successfully
|
||||
3. ✅ Application boots without errors
|
||||
|
||||
Check container logs:
|
||||
|
||||
```bash
|
||||
docker logs manacore-auth
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
```
|
||||
🔄 Running database migrations...
|
||||
✅ Migrations complete
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ cp .env.example .env
|
|||
```
|
||||
|
||||
**Minimum required changes in .env:**
|
||||
|
||||
```env
|
||||
POSTGRES_PASSWORD=your-secure-password-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
|
|
@ -57,6 +58,7 @@ pnpm migration:run
|
|||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
Running migrations...
|
||||
Migrations completed successfully
|
||||
|
|
@ -69,6 +71,7 @@ pnpm start:dev
|
|||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
🚀 Mana Core Auth running on: http://localhost:3001
|
||||
📚 Environment: development
|
||||
|
|
@ -89,12 +92,13 @@ curl -X POST http://localhost:3001/api/v1/auth/register \
|
|||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"createdAt": "2025-11-25T..."
|
||||
"id": "uuid-here",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"createdAt": "2025-11-25T..."
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -110,18 +114,19 @@ curl -X POST http://localhost:3001/api/v1/auth/login \
|
|||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "uuid-here",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"role": "user"
|
||||
},
|
||||
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
|
||||
"refreshToken": "long-random-string",
|
||||
"expiresIn": 900,
|
||||
"tokenType": "Bearer"
|
||||
"user": {
|
||||
"id": "uuid-here",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"role": "user"
|
||||
},
|
||||
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
|
||||
"refreshToken": "long-random-string",
|
||||
"expiresIn": 900,
|
||||
"tokenType": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -134,13 +139,14 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
|
|||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 150,
|
||||
"totalEarned": 0,
|
||||
"totalSpent": 0,
|
||||
"dailyFreeCredits": 5
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 150,
|
||||
"totalEarned": 0,
|
||||
"totalSpent": 0,
|
||||
"dailyFreeCredits": 5
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -159,25 +165,26 @@ curl -X POST http://localhost:3001/api/v1/credits/use \
|
|||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"transaction": {
|
||||
"id": "uuid-here",
|
||||
"userId": "uuid-here",
|
||||
"type": "usage",
|
||||
"status": "completed",
|
||||
"amount": -10,
|
||||
"balanceBefore": 150,
|
||||
"balanceAfter": 140,
|
||||
"appId": "test",
|
||||
"description": "Test credit usage"
|
||||
},
|
||||
"newBalance": {
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 140,
|
||||
"totalSpent": 10
|
||||
}
|
||||
"success": true,
|
||||
"transaction": {
|
||||
"id": "uuid-here",
|
||||
"userId": "uuid-here",
|
||||
"type": "usage",
|
||||
"status": "completed",
|
||||
"amount": -10,
|
||||
"balanceBefore": 150,
|
||||
"balanceAfter": 140,
|
||||
"appId": "test",
|
||||
"description": "Test credit usage"
|
||||
},
|
||||
"newBalance": {
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 140,
|
||||
"totalSpent": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -211,6 +218,7 @@ The authentication system is now running and ready to use.
|
|||
**Problem:** Database not ready yet
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
docker-compose ps # Check if postgres is healthy
|
||||
docker-compose logs postgres # Check logs
|
||||
|
|
@ -221,6 +229,7 @@ docker-compose logs postgres # Check logs
|
|||
**Problem:** JWT keys not set in .env
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Run the key generator again
|
||||
./scripts/generate-keys.sh
|
||||
|
|
@ -234,6 +243,7 @@ docker-compose logs postgres # Check logs
|
|||
**Problem:** Database schema issues
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Drop and recreate database
|
||||
docker-compose down -v
|
||||
|
|
@ -247,6 +257,7 @@ pnpm migration:run
|
|||
**Problem:** Another service is using the port
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Change PORT in .env
|
||||
echo "PORT=3002" >> .env
|
||||
|
|
@ -315,11 +326,13 @@ pnpm db:studio
|
|||
## Environment Variables Reference
|
||||
|
||||
### Required
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)
|
||||
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
|
||||
|
||||
### Optional (have defaults)
|
||||
|
||||
- `PORT` - Server port (default: 3001)
|
||||
- `NODE_ENV` - Environment (default: development)
|
||||
- `REDIS_HOST` - Redis host (default: localhost)
|
||||
|
|
@ -328,6 +341,7 @@ pnpm db:studio
|
|||
- `CREDITS_DAILY_FREE` - Daily free credits (default: 5)
|
||||
|
||||
### For Production
|
||||
|
||||
- `STRIPE_SECRET_KEY` - Stripe secret key
|
||||
- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret
|
||||
- `ACME_EMAIL` - Email for Let's Encrypt SSL
|
||||
|
|
@ -343,6 +357,7 @@ pnpm db:studio
|
|||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check this guide first
|
||||
2. Review the logs
|
||||
3. Consult the master plan
|
||||
|
|
|
|||
|
|
@ -37,34 +37,40 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
### Development Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Generate JWT keys**
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
./scripts/generate-keys.sh
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and add your JWT keys and other configuration
|
||||
```
|
||||
|
||||
4. **Start PostgreSQL and Redis** (using Docker)
|
||||
|
||||
```bash
|
||||
docker-compose up postgres redis -d
|
||||
```
|
||||
|
||||
5. **Run migrations**
|
||||
|
||||
```bash
|
||||
pnpm migration:generate
|
||||
pnpm migration:run
|
||||
```
|
||||
|
||||
6. **Start development server**
|
||||
|
||||
```bash
|
||||
pnpm start:dev
|
||||
```
|
||||
|
|
@ -74,18 +80,21 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
### Production Deployment (Docker)
|
||||
|
||||
1. **Set up environment variables**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with production values
|
||||
```
|
||||
|
||||
2. **Generate JWT keys**
|
||||
|
||||
```bash
|
||||
./mana-core-auth/scripts/generate-keys.sh
|
||||
# Add the generated keys to .env
|
||||
```
|
||||
|
||||
3. **Start all services**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
|
@ -101,26 +110,31 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
### Authentication
|
||||
|
||||
**POST** `/api/v1/auth/register`
|
||||
|
||||
- Register a new user
|
||||
- Body: `{ email, password, name? }`
|
||||
- Returns: User object
|
||||
|
||||
**POST** `/api/v1/auth/login`
|
||||
|
||||
- Login with email and password
|
||||
- Body: `{ email, password, deviceId?, deviceName? }`
|
||||
- Returns: `{ user, accessToken, refreshToken, expiresIn, tokenType }`
|
||||
|
||||
**POST** `/api/v1/auth/refresh`
|
||||
|
||||
- Refresh access token
|
||||
- Body: `{ refreshToken }`
|
||||
- Returns: New token pair
|
||||
|
||||
**POST** `/api/v1/auth/logout`
|
||||
|
||||
- Logout and revoke session
|
||||
- Requires: Bearer token
|
||||
- Returns: Success message
|
||||
|
||||
**POST** `/api/v1/auth/validate`
|
||||
|
||||
- Validate a JWT token
|
||||
- Body: `{ token }`
|
||||
- Returns: `{ valid, payload }`
|
||||
|
|
@ -128,27 +142,32 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
### Credits
|
||||
|
||||
**GET** `/api/v1/credits/balance`
|
||||
|
||||
- Get current credit balance
|
||||
- Requires: Bearer token
|
||||
- Returns: `{ balance, freeCreditsRemaining, totalEarned, totalSpent }`
|
||||
|
||||
**POST** `/api/v1/credits/use`
|
||||
|
||||
- Deduct credits from balance
|
||||
- Requires: Bearer token
|
||||
- Body: `{ amount, appId, description, idempotencyKey?, metadata? }`
|
||||
- Returns: Transaction details
|
||||
|
||||
**GET** `/api/v1/credits/transactions?limit=50&offset=0`
|
||||
|
||||
- Get transaction history
|
||||
- Requires: Bearer token
|
||||
- Returns: Array of transactions
|
||||
|
||||
**GET** `/api/v1/credits/purchases`
|
||||
|
||||
- Get purchase history
|
||||
- Requires: Bearer token
|
||||
- Returns: Array of purchases
|
||||
|
||||
**GET** `/api/v1/credits/packages`
|
||||
|
||||
- Get available credit packages
|
||||
- Requires: Bearer token
|
||||
- Returns: Array of packages
|
||||
|
|
@ -156,6 +175,7 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
## Database Schema
|
||||
|
||||
### Auth Schema
|
||||
|
||||
- `auth.users` - User accounts
|
||||
- `auth.sessions` - Active sessions
|
||||
- `auth.passwords` - Hashed passwords
|
||||
|
|
@ -165,6 +185,7 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
- `auth.security_events` - Security audit log
|
||||
|
||||
### Credits Schema
|
||||
|
||||
- `credits.balances` - User credit balances
|
||||
- `credits.transactions` - Transaction ledger
|
||||
- `credits.packages` - Available credit packages
|
||||
|
|
@ -176,6 +197,7 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
See `.env.example` for all available configuration options.
|
||||
|
||||
Key variables:
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
|
||||
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false,
|
||||
"tsConfigPath": "tsconfig.json"
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false,
|
||||
"tsConfigPath": "tsconfig.json"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,78 @@
|
|||
{
|
||||
"name": "mana-core-auth",
|
||||
"version": "0.1.0",
|
||||
"description": "Mana Core Authentication and Credit System",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-auth": "^1.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nanoid": "^5.0.9",
|
||||
"postgres": "^3.4.5",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"winston": "^3.17.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
"name": "mana-core-auth",
|
||||
"version": "0.1.0",
|
||||
"description": "Mana Core Authentication and Credit System",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-auth": "^1.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nanoid": "^5.0.9",
|
||||
"postgres": "^3.4.5",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"winston": "^3.17.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,25 +8,25 @@ import { CreditsModule } from './credits/credits.module';
|
|||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000, // 60 seconds
|
||||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
AuthModule,
|
||||
CreditsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000, // 60 seconds
|
||||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
AuthModule,
|
||||
CreditsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -9,45 +9,45 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
|
|||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(
|
||||
@Body() registerDto: RegisterDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string,
|
||||
) {
|
||||
return this.authService.register(registerDto, ipAddress, userAgent);
|
||||
}
|
||||
@Post('register')
|
||||
async register(
|
||||
@Body() registerDto: RegisterDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string
|
||||
) {
|
||||
return this.authService.register(registerDto, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() loginDto: LoginDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string,
|
||||
) {
|
||||
return this.authService.login(loginDto, ipAddress, userAgent);
|
||||
}
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() loginDto: LoginDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string
|
||||
) {
|
||||
return this.authService.login(loginDto, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
async refresh(
|
||||
@Body() refreshTokenDto: RefreshTokenDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string,
|
||||
) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken, ipAddress, userAgent);
|
||||
}
|
||||
@Post('refresh')
|
||||
async refresh(
|
||||
@Body() refreshTokenDto: RefreshTokenDto,
|
||||
@Ip() ipAddress: string,
|
||||
@Headers('user-agent') userAgent: string
|
||||
) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logout(@Req() req: Request & { user: CurrentUserData }) {
|
||||
// Extract sessionId from JWT (would need to be added to the CurrentUserData interface)
|
||||
// For now, we'll use a placeholder
|
||||
return this.authService.logout('session-id');
|
||||
}
|
||||
@Post('logout')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logout(@Req() req: Request & { user: CurrentUserData }) {
|
||||
// Extract sessionId from JWT (would need to be added to the CurrentUserData interface)
|
||||
// For now, we'll use a placeholder
|
||||
return this.authService.logout('session-id');
|
||||
}
|
||||
|
||||
@Post('validate')
|
||||
async validate(@Body() body: { token: string }) {
|
||||
return this.authService.validateToken(body.token);
|
||||
}
|
||||
@Post('validate')
|
||||
async validate(@Body() body: { token: string }) {
|
||||
return this.authService.validateToken(body.token);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { AuthController } from './auth.controller';
|
|||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
|
@ -11,281 +16,275 @@ import { RegisterDto } from './dto/register.dto';
|
|||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId: string;
|
||||
deviceId?: string;
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, registerDto.email.toLowerCase()))
|
||||
.limit(1);
|
||||
// Check if user already exists
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, registerDto.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
throw new ConflictException('User with this email already exists');
|
||||
}
|
||||
if (existingUser.length > 0) {
|
||||
throw new ConflictException('User with this email already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
||||
|
||||
// Create user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: registerDto.email.toLowerCase(),
|
||||
name: registerDto.name,
|
||||
role: 'user',
|
||||
})
|
||||
.returning();
|
||||
// Create user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: registerDto.email.toLowerCase(),
|
||||
name: registerDto.name,
|
||||
role: 'user',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Store password
|
||||
await db.insert(passwords).values({
|
||||
userId: newUser.id,
|
||||
hashedPassword,
|
||||
});
|
||||
// Store password
|
||||
await db.insert(passwords).values({
|
||||
userId: newUser.id,
|
||||
hashedPassword,
|
||||
});
|
||||
|
||||
// Initialize credit balance (done via trigger or separate service call)
|
||||
// This will be handled by the credits service
|
||||
// Initialize credit balance (done via trigger or separate service call)
|
||||
// This will be handled by the credits service
|
||||
|
||||
return {
|
||||
id: newUser.id,
|
||||
email: newUser.email,
|
||||
name: newUser.name,
|
||||
createdAt: newUser.createdAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: newUser.id,
|
||||
email: newUser.email,
|
||||
name: newUser.name,
|
||||
createdAt: newUser.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
async login(loginDto: LoginDto, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Find user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, loginDto.email.toLowerCase()))
|
||||
.limit(1);
|
||||
// Find user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, loginDto.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Check if user is soft-deleted
|
||||
if (user.deletedAt) {
|
||||
throw new UnauthorizedException('Account has been deleted');
|
||||
}
|
||||
// Check if user is soft-deleted
|
||||
if (user.deletedAt) {
|
||||
throw new UnauthorizedException('Account has been deleted');
|
||||
}
|
||||
|
||||
// Get password
|
||||
const [passwordRecord] = await db
|
||||
.select()
|
||||
.from(passwords)
|
||||
.where(eq(passwords.userId, user.id))
|
||||
.limit(1);
|
||||
// Get password
|
||||
const [passwordRecord] = await db
|
||||
.select()
|
||||
.from(passwords)
|
||||
.where(eq(passwords.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!passwordRecord) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
if (!passwordRecord) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(loginDto.password, passwordRecord.hashedPassword);
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(loginDto.password, passwordRecord.hashedPassword);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokenData = await this.generateTokens(
|
||||
user.id,
|
||||
user.email,
|
||||
user.role,
|
||||
loginDto.deviceId,
|
||||
loginDto.deviceName,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
// Generate tokens
|
||||
const tokenData = await this.generateTokens(
|
||||
user.id,
|
||||
user.email,
|
||||
user.role,
|
||||
loginDto.deviceId,
|
||||
loginDto.deviceName,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
...tokenData,
|
||||
};
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
...tokenData,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
async refreshToken(refreshToken: string, ipAddress?: string, userAgent?: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Find session by refresh token
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt)))
|
||||
.limit(1);
|
||||
// Find session by refresh token
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
// Check if refresh token is expired
|
||||
if (new Date() > session.refreshTokenExpiresAt) {
|
||||
throw new UnauthorizedException('Refresh token expired');
|
||||
}
|
||||
// Check if refresh token is expired
|
||||
if (new Date() > session.refreshTokenExpiresAt) {
|
||||
throw new UnauthorizedException('Refresh token expired');
|
||||
}
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
if (!user || user.deletedAt) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Revoke old session (refresh token rotation)
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(sessions.id, session.id));
|
||||
// Revoke old session (refresh token rotation)
|
||||
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id));
|
||||
|
||||
// Generate new tokens
|
||||
const tokenData = await this.generateTokens(
|
||||
user.id,
|
||||
user.email,
|
||||
user.role,
|
||||
session.deviceId ?? undefined,
|
||||
session.deviceName ?? undefined,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
// Generate new tokens
|
||||
const tokenData = await this.generateTokens(
|
||||
user.id,
|
||||
user.email,
|
||||
user.role,
|
||||
session.deviceId ?? undefined,
|
||||
session.deviceName ?? undefined,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
...tokenData,
|
||||
};
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
...tokenData,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(sessionId: string) {
|
||||
const db = this.getDb();
|
||||
async logout(sessionId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(sessions.id, sessionId));
|
||||
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, sessionId));
|
||||
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
private async generateTokens(
|
||||
userId: string,
|
||||
email: string,
|
||||
role: string,
|
||||
deviceId?: string,
|
||||
deviceName?: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const db = this.getDb();
|
||||
private async generateTokens(
|
||||
userId: string,
|
||||
email: string,
|
||||
role: string,
|
||||
deviceId?: string,
|
||||
deviceName?: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
) {
|
||||
const db = this.getDb();
|
||||
|
||||
const privateKeyRaw = this.configService.get<string>('jwt.privateKey');
|
||||
if (!privateKeyRaw) {
|
||||
throw new Error('JWT private key not configured');
|
||||
}
|
||||
const privateKey: string = privateKeyRaw;
|
||||
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
|
||||
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshTokenExpiry') || '7d';
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const privateKeyRaw = this.configService.get<string>('jwt.privateKey');
|
||||
if (!privateKeyRaw) {
|
||||
throw new Error('JWT private key not configured');
|
||||
}
|
||||
const privateKey: string = privateKeyRaw;
|
||||
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
|
||||
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshTokenExpiry') || '7d';
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
|
||||
// Generate session ID (must be UUID for database)
|
||||
const sessionId = randomUUID();
|
||||
// Generate session ID (must be UUID for database)
|
||||
const sessionId = randomUUID();
|
||||
|
||||
// Create session record
|
||||
const refreshTokenString = nanoid(64);
|
||||
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
// Create session record
|
||||
const refreshTokenString = nanoid(64);
|
||||
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
|
||||
await db.insert(sessions).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
token: sessionId,
|
||||
refreshToken: refreshTokenString,
|
||||
refreshTokenExpiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceId,
|
||||
deviceName,
|
||||
expiresAt: accessTokenExpiresAt,
|
||||
});
|
||||
await db.insert(sessions).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
token: sessionId,
|
||||
refreshToken: refreshTokenString,
|
||||
refreshTokenExpiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceId,
|
||||
deviceName,
|
||||
expiresAt: accessTokenExpiresAt,
|
||||
});
|
||||
|
||||
// Generate JWT payload
|
||||
const tokenPayload: Record<string, unknown> = {
|
||||
sub: userId,
|
||||
email,
|
||||
role,
|
||||
sessionId,
|
||||
...(deviceId && { deviceId }),
|
||||
};
|
||||
// Generate JWT payload
|
||||
const tokenPayload: Record<string, unknown> = {
|
||||
sub: userId,
|
||||
email,
|
||||
role,
|
||||
sessionId,
|
||||
...(deviceId && { deviceId }),
|
||||
};
|
||||
|
||||
// Sign access token
|
||||
const accessToken = jwt.sign(tokenPayload, privateKey, {
|
||||
algorithm: 'RS256' as const,
|
||||
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
|
||||
...(issuer && { issuer }),
|
||||
...(audience && { audience }),
|
||||
});
|
||||
// Sign access token
|
||||
const accessToken = jwt.sign(tokenPayload, privateKey, {
|
||||
algorithm: 'RS256' as const,
|
||||
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
|
||||
...(issuer && { issuer }),
|
||||
...(audience && { audience }),
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenString,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenString,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
}
|
||||
|
||||
async validateToken(token: string) {
|
||||
try {
|
||||
const publicKey = this.configService.get<string>('jwt.publicKey');
|
||||
if (!publicKey) {
|
||||
throw new Error('JWT public key not configured');
|
||||
}
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
async validateToken(token: string) {
|
||||
try {
|
||||
const publicKey = this.configService.get<string>('jwt.publicKey');
|
||||
if (!publicKey) {
|
||||
throw new Error('JWT public key not configured');
|
||||
}
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
|
||||
const payload = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience,
|
||||
issuer,
|
||||
}) as TokenPayload;
|
||||
const payload = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience,
|
||||
issuer,
|
||||
}) as TokenPayload;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { IsEmail, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deviceId?: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deviceId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deviceName?: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deviceName?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password: string;
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,37 +3,37 @@ import { Response } from 'express';
|
|||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest();
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let errors: any = undefined;
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let errors: any = undefined;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
message = (exceptionResponse as any).message || message;
|
||||
errors = (exceptionResponse as any).errors;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
message = (exceptionResponse as any).message || message;
|
||||
errors = (exceptionResponse as any).errors;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
const errorResponse = {
|
||||
statusCode: status,
|
||||
message,
|
||||
...(errors && { errors }),
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
const errorResponse = {
|
||||
statusCode: status,
|
||||
message,
|
||||
...(errors && { errors }),
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,45 +4,45 @@ import * as jwt from 'jsonwebtoken';
|
|||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const publicKey = this.configService.get<string>('jwt.publicKey');
|
||||
if (!publicKey) {
|
||||
throw new UnauthorizedException('JWT configuration error');
|
||||
}
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
try {
|
||||
const publicKey = this.configService.get<string>('jwt.publicKey');
|
||||
if (!publicKey) {
|
||||
throw new UnauthorizedException('JWT configuration error');
|
||||
}
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
|
||||
const payload = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience,
|
||||
issuer,
|
||||
}) as jwt.JwtPayload;
|
||||
const payload = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience,
|
||||
issuer,
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,47 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
|
||||
},
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
|
||||
},
|
||||
|
||||
jwt: {
|
||||
publicKey: process.env.JWT_PUBLIC_KEY || '',
|
||||
privateKey: process.env.JWT_PRIVATE_KEY || '',
|
||||
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
|
||||
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
},
|
||||
jwt: {
|
||||
publicKey: process.env.JWT_PUBLIC_KEY || '',
|
||||
privateKey: process.env.JWT_PRIVATE_KEY || '',
|
||||
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
|
||||
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
},
|
||||
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
},
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000', 'http://localhost:8081'],
|
||||
credentials: true,
|
||||
},
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:8081',
|
||||
],
|
||||
credentials: true,
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10),
|
||||
limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||
},
|
||||
rateLimit: {
|
||||
ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10),
|
||||
limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||
},
|
||||
|
||||
credits: {
|
||||
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
|
||||
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
|
||||
},
|
||||
credits: {
|
||||
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
|
||||
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,34 +7,34 @@ import { UseCreditsDto } from './dto/use-credits.dto';
|
|||
@Controller('credits')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CreditsController {
|
||||
constructor(private readonly creditsService: CreditsService) {}
|
||||
constructor(private readonly creditsService: CreditsService) {}
|
||||
|
||||
@Get('balance')
|
||||
async getBalance(@CurrentUser() user: CurrentUserData) {
|
||||
return this.creditsService.getBalance(user.userId);
|
||||
}
|
||||
@Get('balance')
|
||||
async getBalance(@CurrentUser() user: CurrentUserData) {
|
||||
return this.creditsService.getBalance(user.userId);
|
||||
}
|
||||
|
||||
@Post('use')
|
||||
async useCredits(@CurrentUser() user: CurrentUserData, @Body() useCreditsDto: UseCreditsDto) {
|
||||
return this.creditsService.useCredits(user.userId, useCreditsDto);
|
||||
}
|
||||
@Post('use')
|
||||
async useCredits(@CurrentUser() user: CurrentUserData, @Body() useCreditsDto: UseCreditsDto) {
|
||||
return this.creditsService.useCredits(user.userId, useCreditsDto);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
async getTransactionHistory(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
|
||||
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number,
|
||||
) {
|
||||
return this.creditsService.getTransactionHistory(user.userId, limit, offset);
|
||||
}
|
||||
@Get('transactions')
|
||||
async getTransactionHistory(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
|
||||
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number
|
||||
) {
|
||||
return this.creditsService.getTransactionHistory(user.userId, limit, offset);
|
||||
}
|
||||
|
||||
@Get('purchases')
|
||||
async getPurchaseHistory(@CurrentUser() user: CurrentUserData) {
|
||||
return this.creditsService.getPurchaseHistory(user.userId);
|
||||
}
|
||||
@Get('purchases')
|
||||
async getPurchaseHistory(@CurrentUser() user: CurrentUserData) {
|
||||
return this.creditsService.getPurchaseHistory(user.userId);
|
||||
}
|
||||
|
||||
@Get('packages')
|
||||
async getPackages() {
|
||||
return this.creditsService.getPackages();
|
||||
}
|
||||
@Get('packages')
|
||||
async getPackages() {
|
||||
return this.creditsService.getPackages();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { CreditsController } from './credits.controller';
|
|||
import { CreditsService } from './credits.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CreditsController],
|
||||
providers: [CreditsService],
|
||||
exports: [CreditsService],
|
||||
controllers: [CreditsController],
|
||||
providers: [CreditsService],
|
||||
exports: [CreditsService],
|
||||
})
|
||||
export class CreditsModule {}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, sql, desc } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
|
|
@ -7,269 +12,261 @@ import { UseCreditsDto } from './dto/use-credits.dto';
|
|||
|
||||
@Injectable()
|
||||
export class CreditsService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
async initializeUserBalance(userId: string) {
|
||||
const db = this.getDb();
|
||||
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
|
||||
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
|
||||
async initializeUserBalance(userId: string) {
|
||||
const db = this.getDb();
|
||||
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
|
||||
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
|
||||
|
||||
// Check if balance already exists
|
||||
const [existingBalance] = await db
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
// Check if balance already exists
|
||||
const [existingBalance] = await db
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existingBalance) {
|
||||
return existingBalance;
|
||||
}
|
||||
if (existingBalance) {
|
||||
return existingBalance;
|
||||
}
|
||||
|
||||
// Create initial balance with signup bonus
|
||||
const [balance] = await db
|
||||
.insert(balances)
|
||||
.values({
|
||||
userId,
|
||||
balance: 0,
|
||||
freeCreditsRemaining: signupBonus,
|
||||
dailyFreeCredits,
|
||||
lastDailyResetAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
// Create initial balance with signup bonus
|
||||
const [balance] = await db
|
||||
.insert(balances)
|
||||
.values({
|
||||
userId,
|
||||
balance: 0,
|
||||
freeCreditsRemaining: signupBonus,
|
||||
dailyFreeCredits,
|
||||
lastDailyResetAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create transaction record for signup bonus
|
||||
await db.insert(transactions).values({
|
||||
userId,
|
||||
type: 'bonus',
|
||||
status: 'completed',
|
||||
amount: signupBonus,
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 0,
|
||||
appId: 'system',
|
||||
description: 'Signup bonus',
|
||||
completedAt: new Date(),
|
||||
});
|
||||
// Create transaction record for signup bonus
|
||||
await db.insert(transactions).values({
|
||||
userId,
|
||||
type: 'bonus',
|
||||
status: 'completed',
|
||||
amount: signupBonus,
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 0,
|
||||
appId: 'system',
|
||||
description: 'Signup bonus',
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
return balance;
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
async getBalance(userId: string) {
|
||||
const db = this.getDb();
|
||||
async getBalance(userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check and apply daily free credits reset
|
||||
await this.checkDailyReset(userId);
|
||||
// Check and apply daily free credits reset
|
||||
await this.checkDailyReset(userId);
|
||||
|
||||
const [balance] = await db
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
|
||||
|
||||
if (!balance) {
|
||||
// Initialize balance if it doesn't exist
|
||||
return this.initializeUserBalance(userId);
|
||||
}
|
||||
if (!balance) {
|
||||
// Initialize balance if it doesn't exist
|
||||
return this.initializeUserBalance(userId);
|
||||
}
|
||||
|
||||
return {
|
||||
balance: balance.balance,
|
||||
freeCreditsRemaining: balance.freeCreditsRemaining,
|
||||
totalEarned: balance.totalEarned,
|
||||
totalSpent: balance.totalSpent,
|
||||
dailyFreeCredits: balance.dailyFreeCredits,
|
||||
};
|
||||
}
|
||||
return {
|
||||
balance: balance.balance,
|
||||
freeCreditsRemaining: balance.freeCreditsRemaining,
|
||||
totalEarned: balance.totalEarned,
|
||||
totalSpent: balance.totalSpent,
|
||||
dailyFreeCredits: balance.dailyFreeCredits,
|
||||
};
|
||||
}
|
||||
|
||||
async useCredits(userId: string, useCreditsDto: UseCreditsDto) {
|
||||
const db = this.getDb();
|
||||
async useCredits(userId: string, useCreditsDto: UseCreditsDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Check for idempotency
|
||||
if (useCreditsDto.idempotencyKey) {
|
||||
const [existingTransaction] = await db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.idempotencyKey, useCreditsDto.idempotencyKey))
|
||||
.limit(1);
|
||||
// Check for idempotency
|
||||
if (useCreditsDto.idempotencyKey) {
|
||||
const [existingTransaction] = await db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.idempotencyKey, useCreditsDto.idempotencyKey))
|
||||
.limit(1);
|
||||
|
||||
if (existingTransaction) {
|
||||
return {
|
||||
success: true,
|
||||
transaction: existingTransaction,
|
||||
message: 'Transaction already processed',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (existingTransaction) {
|
||||
return {
|
||||
success: true,
|
||||
transaction: existingTransaction,
|
||||
message: 'Transaction already processed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use a transaction for atomicity
|
||||
return await db.transaction(async (tx) => {
|
||||
// Get current balance with row lock (SELECT FOR UPDATE)
|
||||
const [currentBalance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
// Use a transaction for atomicity
|
||||
return await db.transaction(async (tx) => {
|
||||
// Get current balance with row lock (SELECT FOR UPDATE)
|
||||
const [currentBalance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!currentBalance) {
|
||||
throw new NotFoundException('User balance not found');
|
||||
}
|
||||
if (!currentBalance) {
|
||||
throw new NotFoundException('User balance not found');
|
||||
}
|
||||
|
||||
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
|
||||
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
|
||||
|
||||
if (totalAvailable < useCreditsDto.amount) {
|
||||
throw new BadRequestException('Insufficient credits');
|
||||
}
|
||||
if (totalAvailable < useCreditsDto.amount) {
|
||||
throw new BadRequestException('Insufficient credits');
|
||||
}
|
||||
|
||||
// Calculate deduction from free and paid credits
|
||||
let freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
|
||||
let paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
|
||||
// Calculate deduction from free and paid credits
|
||||
let freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
|
||||
let paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
|
||||
|
||||
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
|
||||
const newBalance = currentBalance.balance - paidCreditsUsed;
|
||||
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
|
||||
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
|
||||
const newBalance = currentBalance.balance - paidCreditsUsed;
|
||||
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
|
||||
|
||||
// Update balance with optimistic locking
|
||||
const updateResult = await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
freeCreditsRemaining: newFreeCredits,
|
||||
totalSpent: newTotalSpent,
|
||||
version: currentBalance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, userId), eq(balances.version, currentBalance.version)))
|
||||
.returning();
|
||||
// Update balance with optimistic locking
|
||||
const updateResult = await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
freeCreditsRemaining: newFreeCredits,
|
||||
totalSpent: newTotalSpent,
|
||||
version: currentBalance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, userId), eq(balances.version, currentBalance.version)))
|
||||
.returning();
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
throw new ConflictException('Balance was modified by another transaction. Please retry.');
|
||||
}
|
||||
if (updateResult.length === 0) {
|
||||
throw new ConflictException('Balance was modified by another transaction. Please retry.');
|
||||
}
|
||||
|
||||
// Create transaction record
|
||||
const [transaction] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId,
|
||||
type: 'usage',
|
||||
status: 'completed',
|
||||
amount: -useCreditsDto.amount,
|
||||
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
|
||||
balanceAfter: newBalance + newFreeCredits,
|
||||
appId: useCreditsDto.appId,
|
||||
description: useCreditsDto.description,
|
||||
metadata: useCreditsDto.metadata,
|
||||
idempotencyKey: useCreditsDto.idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
// Create transaction record
|
||||
const [transaction] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId,
|
||||
type: 'usage',
|
||||
status: 'completed',
|
||||
amount: -useCreditsDto.amount,
|
||||
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
|
||||
balanceAfter: newBalance + newFreeCredits,
|
||||
appId: useCreditsDto.appId,
|
||||
description: useCreditsDto.description,
|
||||
metadata: useCreditsDto.metadata,
|
||||
idempotencyKey: useCreditsDto.idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Track usage stats (for analytics)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
// Track usage stats (for analytics)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
await tx.insert(usageStats).values({
|
||||
userId,
|
||||
appId: useCreditsDto.appId,
|
||||
creditsUsed: useCreditsDto.amount,
|
||||
date: today,
|
||||
metadata: useCreditsDto.metadata,
|
||||
});
|
||||
await tx.insert(usageStats).values({
|
||||
userId,
|
||||
appId: useCreditsDto.appId,
|
||||
creditsUsed: useCreditsDto.amount,
|
||||
date: today,
|
||||
metadata: useCreditsDto.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction,
|
||||
newBalance: {
|
||||
balance: newBalance,
|
||||
freeCreditsRemaining: newFreeCredits,
|
||||
totalSpent: newTotalSpent,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
transaction,
|
||||
newBalance: {
|
||||
balance: newBalance,
|
||||
freeCreditsRemaining: newFreeCredits,
|
||||
totalSpent: newTotalSpent,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getTransactionHistory(userId: string, limit: number = 50, offset: number = 0) {
|
||||
const db = this.getDb();
|
||||
async getTransactionHistory(userId: string, limit: number = 50, offset: number = 0) {
|
||||
const db = this.getDb();
|
||||
|
||||
const transactionList = await db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.userId, userId))
|
||||
.orderBy(desc(transactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const transactionList = await db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.userId, userId))
|
||||
.orderBy(desc(transactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return transactionList;
|
||||
}
|
||||
return transactionList;
|
||||
}
|
||||
|
||||
async getPurchaseHistory(userId: string) {
|
||||
const db = this.getDb();
|
||||
async getPurchaseHistory(userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(purchases)
|
||||
.where(eq(purchases.userId, userId))
|
||||
.orderBy(desc(purchases.createdAt));
|
||||
}
|
||||
return await db
|
||||
.select()
|
||||
.from(purchases)
|
||||
.where(eq(purchases.userId, userId))
|
||||
.orderBy(desc(purchases.createdAt));
|
||||
}
|
||||
|
||||
async getPackages() {
|
||||
const db = this.getDb();
|
||||
async getPackages() {
|
||||
const db = this.getDb();
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(packages)
|
||||
.where(eq(packages.active, true))
|
||||
.orderBy(packages.sortOrder);
|
||||
}
|
||||
return await db
|
||||
.select()
|
||||
.from(packages)
|
||||
.where(eq(packages.active, true))
|
||||
.orderBy(packages.sortOrder);
|
||||
}
|
||||
|
||||
private async checkDailyReset(userId: string) {
|
||||
const db = this.getDb();
|
||||
private async checkDailyReset(userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
const [balance] = await db
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
|
||||
|
||||
if (!balance) {
|
||||
return;
|
||||
}
|
||||
if (!balance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastReset = balance.lastDailyResetAt;
|
||||
const now = new Date();
|
||||
const lastReset = balance.lastDailyResetAt;
|
||||
|
||||
// Check if last reset was on a different day
|
||||
if (
|
||||
!lastReset ||
|
||||
lastReset.getDate() !== now.getDate() ||
|
||||
lastReset.getMonth() !== now.getMonth() ||
|
||||
lastReset.getFullYear() !== now.getFullYear()
|
||||
) {
|
||||
// Reset daily free credits
|
||||
await db
|
||||
.update(balances)
|
||||
.set({
|
||||
freeCreditsRemaining: balance.freeCreditsRemaining + balance.dailyFreeCredits,
|
||||
lastDailyResetAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(balances.userId, userId));
|
||||
// Check if last reset was on a different day
|
||||
if (
|
||||
!lastReset ||
|
||||
lastReset.getDate() !== now.getDate() ||
|
||||
lastReset.getMonth() !== now.getMonth() ||
|
||||
lastReset.getFullYear() !== now.getFullYear()
|
||||
) {
|
||||
// Reset daily free credits
|
||||
await db
|
||||
.update(balances)
|
||||
.set({
|
||||
freeCreditsRemaining: balance.freeCreditsRemaining + balance.dailyFreeCredits,
|
||||
lastDailyResetAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(balances.userId, userId));
|
||||
|
||||
// Create transaction record for daily bonus
|
||||
await db.insert(transactions).values({
|
||||
userId,
|
||||
type: 'bonus',
|
||||
status: 'completed',
|
||||
amount: balance.dailyFreeCredits,
|
||||
balanceBefore: balance.balance + balance.freeCreditsRemaining,
|
||||
balanceAfter: balance.balance + balance.freeCreditsRemaining + balance.dailyFreeCredits,
|
||||
appId: 'system',
|
||||
description: 'Daily free credits',
|
||||
completedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Create transaction record for daily bonus
|
||||
await db.insert(transactions).values({
|
||||
userId,
|
||||
type: 'bonus',
|
||||
status: 'completed',
|
||||
amount: balance.dailyFreeCredits,
|
||||
balanceBefore: balance.balance + balance.freeCreditsRemaining,
|
||||
balanceAfter: balance.balance + balance.freeCreditsRemaining + balance.dailyFreeCredits,
|
||||
appId: 'system',
|
||||
description: 'Daily free credits',
|
||||
completedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { IsUUID, IsOptional } from 'class-validator';
|
||||
|
||||
export class PurchaseCreditsDto {
|
||||
@IsUUID()
|
||||
packageId: string;
|
||||
@IsUUID()
|
||||
packageId: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { IsString, IsInt, IsPositive, IsOptional, IsObject } from 'class-validator';
|
||||
|
||||
export class UseCreditsDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
amount: number;
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
appId: string;
|
||||
@IsString()
|
||||
appId: string;
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
idempotencyKey?: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
idempotencyKey?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,28 +6,28 @@ let connection: ReturnType<typeof postgres> | null = null;
|
|||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,24 @@ import { getDb, closeConnection } from './connection';
|
|||
config();
|
||||
|
||||
async function runMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
console.log('Running migrations...');
|
||||
console.log('Running migrations...');
|
||||
|
||||
try {
|
||||
const db = getDb(databaseUrl);
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
console.log('Migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await closeConnection();
|
||||
}
|
||||
try {
|
||||
const db = getDb(databaseUrl);
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
console.log('Migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1764089133415,
|
||||
"tag": "0000_lush_ironclad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1764089133415,
|
||||
"tag": "0000_lush_ironclad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,87 +8,97 @@ export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']);
|
|||
|
||||
// Users table
|
||||
export const users = authSchema.table('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
email: text('email').unique().notNull(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
role: userRoleEnum('role').default('user').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
email: text('email').unique().notNull(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
role: userRoleEnum('role').default('user').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Sessions table
|
||||
export const sessions = authSchema.table('sessions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
refreshToken: text('refresh_token').unique().notNull(),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
deviceId: text('device_id'),
|
||||
deviceName: text('device_name'),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
refreshToken: text('refresh_token').unique().notNull(),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
deviceId: text('device_id'),
|
||||
deviceName: text('device_name'),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Accounts table (for OAuth providers)
|
||||
export const accounts = authSchema.table('accounts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
|
||||
providerAccountId: text('provider_account_id').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
tokenType: text('token_type'),
|
||||
scope: text('scope'),
|
||||
idToken: text('id_token'),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
|
||||
providerAccountId: text('provider_account_id').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
tokenType: text('token_type'),
|
||||
scope: text('scope'),
|
||||
idToken: text('id_token'),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Verification tokens (for email verification, password reset)
|
||||
export const verificationTokens = authSchema.table('verification_tokens', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
type: text('type').notNull(), // 'email_verification', 'password_reset'
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
token: text('token').unique().notNull(),
|
||||
type: text('type').notNull(), // 'email_verification', 'password_reset'
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Password table (separate for security)
|
||||
export const passwords = authSchema.table('passwords', {
|
||||
userId: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
hashedPassword: text('hashed_password').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
hashedPassword: text('hashed_password').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Two-factor authentication
|
||||
export const twoFactorAuth = authSchema.table('two_factor_auth', {
|
||||
userId: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
secret: text('secret').notNull(),
|
||||
enabled: boolean('enabled').default(false).notNull(),
|
||||
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
enabledAt: timestamp('enabled_at', { withTimezone: true }),
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
secret: text('secret').notNull(),
|
||||
enabled: boolean('enabled').default(false).notNull(),
|
||||
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
enabledAt: timestamp('enabled_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Security events log
|
||||
export const securityEvents = authSchema.table('security_events', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,104 +1,136 @@
|
|||
import { pgSchema, uuid, integer, text, timestamp, jsonb, index, pgEnum, boolean } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
pgSchema,
|
||||
uuid,
|
||||
integer,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
pgEnum,
|
||||
boolean,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth.schema';
|
||||
|
||||
export const creditsSchema = pgSchema('credits');
|
||||
|
||||
// Transaction types enum
|
||||
export const transactionTypeEnum = pgEnum('transaction_type', [
|
||||
'purchase',
|
||||
'usage',
|
||||
'refund',
|
||||
'bonus',
|
||||
'expiry',
|
||||
'adjustment',
|
||||
'purchase',
|
||||
'usage',
|
||||
'refund',
|
||||
'bonus',
|
||||
'expiry',
|
||||
'adjustment',
|
||||
]);
|
||||
|
||||
// Transaction status enum
|
||||
export const transactionStatusEnum = pgEnum('transaction_status', [
|
||||
'pending',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
// Credit balances (one per user)
|
||||
export const balances = creditsSchema.table('balances', {
|
||||
userId: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
freeCreditsRemaining: integer('free_credits_remaining').default(150).notNull(),
|
||||
dailyFreeCredits: integer('daily_free_credits').default(5).notNull(),
|
||||
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
|
||||
totalEarned: integer('total_earned').default(0).notNull(),
|
||||
totalSpent: integer('total_spent').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(), // For optimistic locking
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
freeCreditsRemaining: integer('free_credits_remaining').default(150).notNull(),
|
||||
dailyFreeCredits: integer('daily_free_credits').default(5).notNull(),
|
||||
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
|
||||
totalEarned: integer('total_earned').default(0).notNull(),
|
||||
totalSpent: integer('total_spent').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(), // For optimistic locking
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Transaction ledger
|
||||
export const transactions = creditsSchema.table('transactions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
type: transactionTypeEnum('type').notNull(),
|
||||
status: transactionStatusEnum('status').default('pending').notNull(),
|
||||
amount: integer('amount').notNull(),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
|
||||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'), // Additional context
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('transactions_user_id_idx').on(table.userId),
|
||||
appIdIdx: index('transactions_app_id_idx').on(table.appId),
|
||||
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
}));
|
||||
export const transactions = creditsSchema.table(
|
||||
'transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
type: transactionTypeEnum('type').notNull(),
|
||||
status: transactionStatusEnum('status').default('pending').notNull(),
|
||||
amount: integer('amount').notNull(),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
|
||||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'), // Additional context
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('transactions_user_id_idx').on(table.userId),
|
||||
appIdIdx: index('transactions_app_id_idx').on(table.appId),
|
||||
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
})
|
||||
);
|
||||
|
||||
// Credit packages (pricing tiers)
|
||||
export const packages = creditsSchema.table('packages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
credits: integer('credits').notNull(), // Number of credits
|
||||
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
|
||||
stripePriceId: text('stripe_price_id').unique(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
credits: integer('credits').notNull(), // Number of credits
|
||||
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
|
||||
stripePriceId: text('stripe_price_id').unique(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Purchase history
|
||||
export const purchases = creditsSchema.table('purchases', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
packageId: uuid('package_id').references(() => packages.id),
|
||||
credits: integer('credits').notNull(),
|
||||
priceEuroCents: integer('price_euro_cents').notNull(),
|
||||
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
status: transactionStatusEnum('status').default('pending').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('purchases_user_id_idx').on(table.userId),
|
||||
stripePaymentIntentIdIdx: index('purchases_stripe_payment_intent_id_idx').on(table.stripePaymentIntentId),
|
||||
}));
|
||||
export const purchases = creditsSchema.table(
|
||||
'purchases',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
packageId: uuid('package_id').references(() => packages.id),
|
||||
credits: integer('credits').notNull(),
|
||||
priceEuroCents: integer('price_euro_cents').notNull(),
|
||||
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
status: transactionStatusEnum('status').default('pending').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('purchases_user_id_idx').on(table.userId),
|
||||
stripePaymentIntentIdIdx: index('purchases_stripe_payment_intent_id_idx').on(
|
||||
table.stripePaymentIntentId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
// Usage tracking (for analytics)
|
||||
export const usageStats = creditsSchema.table('usage_stats', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
appId: text('app_id').notNull(),
|
||||
creditsUsed: integer('credits_used').notNull(),
|
||||
date: timestamp('date', { withTimezone: true }).notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
}, (table) => ({
|
||||
userIdDateIdx: index('usage_stats_user_id_date_idx').on(table.userId, table.date),
|
||||
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
|
||||
}));
|
||||
export const usageStats = creditsSchema.table(
|
||||
'usage_stats',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
appId: text('app_id').notNull(),
|
||||
creditsUsed: integer('credits_used').notNull(),
|
||||
date: timestamp('date', { withTimezone: true }).notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
},
|
||||
(table) => ({
|
||||
userIdDateIdx: index('usage_stats_user_id_date_idx').on(table.userId, table.date),
|
||||
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,43 +6,43 @@ import cookieParser from 'cookie-parser';
|
|||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cookieParser());
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS configuration
|
||||
const corsOrigins = configService.get<string[]>('cors.origin') || [];
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
});
|
||||
// CORS configuration
|
||||
const corsOrigins = configService.get<string[]>('cors.origin') || [];
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api/v1');
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = configService.get<number>('port') || 3001;
|
||||
await app.listen(port);
|
||||
const port = configService.get<number>('port') || 3001;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
|
||||
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
|
||||
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
|
||||
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "../",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["mana-core-auth/src/*"],
|
||||
"@manacore/*": ["packages/*/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "../",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["mana-core-auth/src/*"],
|
||||
"@manacore/*": ["packages/*/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue