refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View file

@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { APP_FILTER } from '@nestjs/core';
import configuration from './config/configuration';
import { AuthModule } from './auth/auth.module';
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,
},
],
})
export class AppModule {}

View file

@ -0,0 +1,53 @@
import { Controller, Post, Body, UseGuards, Req, Ip, Headers } from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
@Controller('auth')
export class AuthController {
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('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('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);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View file

@ -0,0 +1,291 @@
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';
import * as jwt from 'jsonwebtoken';
import { nanoid } from 'nanoid';
import { randomUUID } from 'crypto';
import { getDb } from '../db/connection';
import { users, passwords, sessions } from '../db/schema';
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;
}
@Injectable()
export class AuthService {
constructor(private configService: ConfigService) {}
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();
// 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');
}
// 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();
// 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
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();
// 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');
}
// 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);
if (!passwordRecord) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
const isPasswordValid = await bcrypt.compare(loginDto.password, passwordRecord.hashedPassword);
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,
);
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();
// 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');
}
// 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);
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));
// 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,
};
}
async logout(sessionId: string) {
const db = this.getDb();
await db
.update(sessions)
.set({ revokedAt: new Date() })
.where(eq(sessions.id, sessionId));
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();
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();
// 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,
});
// 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 }),
});
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');
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
issuer,
}) as TokenPayload;
return {
valid: true,
payload,
};
} catch (error) {
return {
valid: false,
error: error.message,
};
}
}
}

View file

@ -0,0 +1,17 @@
import { IsEmail, IsString, IsOptional } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
@IsString()
@IsOptional()
deviceId?: string;
@IsString()
@IsOptional()
deviceName?: string;
}

View file

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View file

@ -0,0 +1,16 @@
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password: string;
@IsString()
@IsOptional()
@MaxLength(255)
name?: string;
}

View file

@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View file

@ -0,0 +1,39 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
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();
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 (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,
};
response.status(status).json(errorResponse);
}
}

View file

@ -0,0 +1,48 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
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');
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,
};
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;
}
}

View file

@ -0,0 +1,44 @@
export default () => ({
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',
},
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,
},
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,
},
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),
},
});

View file

@ -0,0 +1,40 @@
import { Controller, Get, Post, Body, UseGuards, Query, ParseIntPipe } from '@nestjs/common';
import { CreditsService } from './credits.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { UseCreditsDto } from './dto/use-credits.dto';
@Controller('credits')
@UseGuards(JwtAuthGuard)
export class CreditsController {
constructor(private readonly creditsService: CreditsService) {}
@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);
}
@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('packages')
async getPackages() {
return this.creditsService.getPackages();
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CreditsController } from './credits.controller';
import { CreditsService } from './credits.service';
@Module({
controllers: [CreditsController],
providers: [CreditsService],
exports: [CreditsService],
})
export class CreditsModule {}

View file

@ -0,0 +1,275 @@
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';
import { balances, transactions, purchases, packages, usageStats } from '../db/schema';
import { UseCreditsDto } from './dto/use-credits.dto';
@Injectable()
export class CreditsService {
constructor(private configService: ConfigService) {}
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;
// Check if balance already exists
const [existingBalance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
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 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;
}
async getBalance(userId: string) {
const db = this.getDb();
// 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);
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,
};
}
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);
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);
if (!currentBalance) {
throw new NotFoundException('User balance not found');
}
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
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;
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();
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();
// 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,
});
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();
const transactionList = await db
.select()
.from(transactions)
.where(eq(transactions.userId, userId))
.orderBy(desc(transactions.createdAt))
.limit(limit)
.offset(offset);
return transactionList;
}
async getPurchaseHistory(userId: string) {
const db = this.getDb();
return await db
.select()
.from(purchases)
.where(eq(purchases.userId, userId))
.orderBy(desc(purchases.createdAt));
}
async getPackages() {
const db = this.getDb();
return await db
.select()
.from(packages)
.where(eq(packages.active, true))
.orderBy(packages.sortOrder);
}
private async checkDailyReset(userId: string) {
const db = this.getDb();
const [balance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
if (!balance) {
return;
}
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));
// 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,
});
}
}
}

View file

@ -0,0 +1,9 @@
import { IsUUID, IsOptional } from 'class-validator';
export class PurchaseCreditsDto {
@IsUUID()
packageId: string;
@IsOptional()
metadata?: Record<string, any>;
}

View file

@ -0,0 +1,21 @@
import { IsString, IsInt, IsPositive, IsOptional, IsObject } from 'class-validator';
export class UseCreditsDto {
@IsInt()
@IsPositive()
amount: number;
@IsString()
appId: string;
@IsString()
description: string;
@IsString()
@IsOptional()
idempotencyKey?: string;
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
}

View file

@ -0,0 +1,33 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
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;
}
export function getDb(databaseUrl: string) {
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;
}
}

View file

@ -0,0 +1,29 @@
import { config } from 'dotenv';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { getDb, closeConnection } from './connection';
// Load environment variables
config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
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();
}
}
runMigrations();

View file

@ -0,0 +1,179 @@
CREATE SCHEMA "auth";
--> statement-breakpoint
CREATE SCHEMA "credits";
--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('user', 'admin', 'service');--> statement-breakpoint
CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'failed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."transaction_type" AS ENUM('purchase', 'usage', 'refund', 'bonus', 'expiry', 'adjustment');--> statement-breakpoint
CREATE TABLE "auth"."accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"provider" text NOT NULL,
"provider_account_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"token_type" text,
"scope" text,
"id_token" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."passwords" (
"user_id" uuid PRIMARY KEY NOT NULL,
"hashed_password" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."security_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid,
"event_type" text NOT NULL,
"ip_address" text,
"user_agent" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"refresh_token" text NOT NULL,
"refresh_token_expires_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"device_id" text,
"device_name" text,
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
CONSTRAINT "sessions_token_unique" UNIQUE("token"),
CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token")
);
--> statement-breakpoint
CREATE TABLE "auth"."two_factor_auth" (
"user_id" uuid PRIMARY KEY NOT NULL,
"secret" text NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"backup_codes" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"enabled_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "auth"."users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"name" text,
"avatar_url" text,
"role" "user_role" DEFAULT 'user' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "auth"."verification_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"type" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"used_at" timestamp with time zone,
CONSTRAINT "verification_tokens_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "credits"."balances" (
"user_id" uuid PRIMARY KEY NOT NULL,
"balance" integer DEFAULT 0 NOT NULL,
"free_credits_remaining" integer DEFAULT 150 NOT NULL,
"daily_free_credits" integer DEFAULT 5 NOT NULL,
"last_daily_reset_at" timestamp with time zone DEFAULT now(),
"total_earned" integer DEFAULT 0 NOT NULL,
"total_spent" integer DEFAULT 0 NOT NULL,
"version" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "credits"."packages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"credits" integer NOT NULL,
"price_euro_cents" integer NOT NULL,
"stripe_price_id" text,
"active" boolean DEFAULT true NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "packages_stripe_price_id_unique" UNIQUE("stripe_price_id")
);
--> statement-breakpoint
CREATE TABLE "credits"."purchases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"package_id" uuid,
"credits" integer NOT NULL,
"price_euro_cents" integer NOT NULL,
"stripe_payment_intent_id" text,
"stripe_customer_id" text,
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "purchases_stripe_payment_intent_id_unique" UNIQUE("stripe_payment_intent_id")
);
--> statement-breakpoint
CREATE TABLE "credits"."transactions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" "transaction_type" NOT NULL,
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
"amount" integer NOT NULL,
"balance_before" integer NOT NULL,
"balance_after" integer NOT NULL,
"app_id" text NOT NULL,
"description" text NOT NULL,
"metadata" jsonb,
"idempotency_key" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "transactions_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "credits"."usage_stats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"app_id" text NOT NULL,
"credits_used" integer NOT NULL,
"date" timestamp with time zone NOT NULL,
"metadata" jsonb
);
--> statement-breakpoint
ALTER TABLE "auth"."accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."passwords" ADD CONSTRAINT "passwords_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."security_events" ADD CONSTRAINT "security_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."two_factor_auth" ADD CONSTRAINT "two_factor_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."verification_tokens" ADD CONSTRAINT "verification_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."balances" ADD CONSTRAINT "balances_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "credits"."packages"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."usage_stats" ADD CONSTRAINT "usage_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "purchases_user_id_idx" ON "credits"."purchases" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "purchases_stripe_payment_intent_id_idx" ON "credits"."purchases" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
CREATE INDEX "transactions_user_id_idx" ON "credits"."transactions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transactions_app_id_idx" ON "credits"."transactions" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "transactions_created_at_idx" ON "credits"."transactions" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "transactions_idempotency_key_idx" ON "credits"."transactions" USING btree ("idempotency_key");--> statement-breakpoint
CREATE INDEX "usage_stats_user_id_date_idx" ON "credits"."usage_stats" USING btree ("user_id","date");--> statement-breakpoint
CREATE INDEX "usage_stats_app_id_date_idx" ON "credits"."usage_stats" USING btree ("app_id","date");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,94 @@
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const authSchema = pgSchema('auth');
// Enum for user roles
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 }),
});
// 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 }),
});
// 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(),
});
// 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 }),
});
// 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(),
});
// 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 }),
});
// 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(),
});

View file

@ -0,0 +1,104 @@
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',
]);
// Transaction status enum
export const transactionStatusEnum = pgEnum('transaction_status', [
'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(),
});
// 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),
}));
// 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(),
});
// 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),
}));
// 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),
}));

View file

@ -0,0 +1,2 @@
export * from './auth.schema';
export * from './credits.schema';

View file

@ -0,0 +1,48 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// 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'],
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Global prefix
app.setGlobalPrefix('api/v1');
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')}`);
}
bootstrap();