mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 07:06:42 +02:00
refactor: restructure
monorepo with apps/ and services/ directories
This commit is contained in:
parent
25824ed0ac
commit
ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions
32
services/mana-core-auth/src/app.module.ts
Normal file
32
services/mana-core-auth/src/app.module.ts
Normal 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 {}
|
||||
53
services/mana-core-auth/src/auth/auth.controller.ts
Normal file
53
services/mana-core-auth/src/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/auth/auth.module.ts
Normal file
10
services/mana-core-auth/src/auth/auth.module.ts
Normal 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 {}
|
||||
291
services/mana-core-auth/src/auth/auth.service.ts
Normal file
291
services/mana-core-auth/src/auth/auth.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
17
services/mana-core-auth/src/auth/dto/login.dto.ts
Normal file
17
services/mana-core-auth/src/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
16
services/mana-core-auth/src/auth/dto/register.dto.ts
Normal file
16
services/mana-core-auth/src/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
48
services/mana-core-auth/src/common/guards/jwt-auth.guard.ts
Normal file
48
services/mana-core-auth/src/common/guards/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
services/mana-core-auth/src/config/configuration.ts
Normal file
44
services/mana-core-auth/src/config/configuration.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
40
services/mana-core-auth/src/credits/credits.controller.ts
Normal file
40
services/mana-core-auth/src/credits/credits.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/credits/credits.module.ts
Normal file
10
services/mana-core-auth/src/credits/credits.module.ts
Normal 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 {}
|
||||
275
services/mana-core-auth/src/credits/credits.service.ts
Normal file
275
services/mana-core-auth/src/credits/credits.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsUUID, IsOptional } from 'class-validator';
|
||||
|
||||
export class PurchaseCreditsDto {
|
||||
@IsUUID()
|
||||
packageId: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
21
services/mana-core-auth/src/credits/dto/use-credits.dto.ts
Normal file
21
services/mana-core-auth/src/credits/dto/use-credits.dto.ts
Normal 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>;
|
||||
}
|
||||
33
services/mana-core-auth/src/db/connection.ts
Normal file
33
services/mana-core-auth/src/db/connection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
services/mana-core-auth/src/db/migrate.ts
Normal file
29
services/mana-core-auth/src/db/migrate.ts
Normal 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();
|
||||
179
services/mana-core-auth/src/db/migrations/0000_lush_ironclad.sql
Normal file
179
services/mana-core-auth/src/db/migrations/0000_lush_ironclad.sql
Normal 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");
|
||||
1268
services/mana-core-auth/src/db/migrations/meta/0000_snapshot.json
Normal file
1268
services/mana-core-auth/src/db/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
services/mana-core-auth/src/db/migrations/meta/_journal.json
Normal file
13
services/mana-core-auth/src/db/migrations/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1764089133415,
|
||||
"tag": "0000_lush_ironclad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
94
services/mana-core-auth/src/db/schema/auth.schema.ts
Normal file
94
services/mana-core-auth/src/db/schema/auth.schema.ts
Normal 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(),
|
||||
});
|
||||
104
services/mana-core-auth/src/db/schema/credits.schema.ts
Normal file
104
services/mana-core-auth/src/db/schema/credits.schema.ts
Normal 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),
|
||||
}));
|
||||
2
services/mana-core-auth/src/db/schema/index.ts
Normal file
2
services/mana-core-auth/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './auth.schema';
|
||||
export * from './credits.schema';
|
||||
48
services/mana-core-auth/src/main.ts
Normal file
48
services/mana-core-auth/src/main.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue