Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { FigureModule } from './figure/figure.module';
import { GenerationModule } from './generation/generation.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
FigureModule,
GenerationModule,
HealthModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,22 @@
import { createParamDecorator } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
export interface CurrentUserPayload {
userId: string;
email: string;
role: string;
sessionId: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserPayload;
if (data) {
return user?.[data];
}
return user;
}
);

View file

@ -0,0 +1,65 @@
import {
Injectable,
type CanActivate,
type ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@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 {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
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;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,28 @@
import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { figures } from './figures.schema';
export const figureLikes = pgTable(
'figure_likes',
{
id: uuid('id').primaryKey().defaultRandom(),
figureId: uuid('figure_id')
.notNull()
.references(() => figures.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
createdAt: timestamp('created_at').defaultNow(),
},
(table) => ({
uniqueLike: unique().on(table.figureId, table.userId),
})
);
export const figureLikesRelations = relations(figureLikes, ({ one }) => ({
figure: one(figures, {
fields: [figureLikes.figureId],
references: [figures.id],
}),
}));
export type FigureLike = typeof figureLikes.$inferSelect;
export type NewFigureLike = typeof figureLikes.$inferInsert;

View file

@ -0,0 +1,27 @@
import { pgTable, uuid, text, boolean, integer, timestamp, jsonb } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const figures = pgTable('figures', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
subject: text('subject').notNull(),
imageUrl: text('image_url').notNull(),
enhancedPrompt: text('enhanced_prompt'),
rarity: text('rarity').default('common'),
characterInfo: jsonb('character_info'),
isPublic: boolean('is_public').default(true),
isArchived: boolean('is_archived').default(false),
likes: integer('likes').default(0),
userId: uuid('user_id').notNull(),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const figuresRelations = relations(figures, ({ many }) => ({
likes: many(figureLikes),
}));
import { figureLikes } from './figure-likes.schema';
export type Figure = typeof figures.$inferSelect;
export type NewFigure = typeof figures.$inferInsert;

View file

@ -0,0 +1,2 @@
export * from './figures.schema';
export * from './figure-likes.schema';

View file

@ -0,0 +1,28 @@
import { IsString, IsOptional, IsBoolean, IsObject, IsEnum } from 'class-validator';
export class CreateFigureDto {
@IsString()
name: string;
@IsString()
subject: string;
@IsString()
imageUrl: string;
@IsOptional()
@IsString()
enhancedPrompt?: string;
@IsOptional()
@IsEnum(['common', 'rare', 'epic', 'legendary'])
rarity?: string;
@IsOptional()
@IsObject()
characterInfo?: Record<string, any>;
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}

View file

@ -0,0 +1,27 @@
import { IsString, IsOptional, IsBoolean, IsObject, IsEnum } from 'class-validator';
export class UpdateFigureDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
subject?: string;
@IsOptional()
@IsEnum(['common', 'rare', 'epic', 'legendary'])
rarity?: string;
@IsOptional()
@IsObject()
characterInfo?: Record<string, any>;
@IsOptional()
@IsBoolean()
isPublic?: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
}

View file

@ -0,0 +1,124 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Headers,
} from '@nestjs/common';
import { FigureService } from './figure.service';
import { CreateFigureDto } from './dto/create-figure.dto';
import { UpdateFigureDto } from './dto/update-figure.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('figures')
export class FigureController {
constructor(private readonly figureService: FigureService) {}
/**
* Get public figures (no auth required)
* Optionally checks like status if authorization header present
*/
@Get('public')
async getPublicFigures(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Headers('authorization') authHeader?: string
) {
const pageNum = page ? parseInt(page, 10) : 1;
const limitNum = limit ? parseInt(limit, 10) : 20;
// If no auth header, return without like status
if (!authHeader) {
return this.figureService.findPublicFigures(pageNum, limitNum);
}
// Try to extract user ID from token for like status
// This is optional, so we don't throw on failure
try {
const token = authHeader.replace('Bearer ', '');
const authUrl = process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001';
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (response.ok) {
const { valid, payload } = await response.json();
if (valid && payload) {
return this.figureService.getPublicFiguresWithLikeStatus(payload.sub, pageNum, limitNum);
}
}
} catch {
// Ignore auth errors for public endpoint
}
return this.figureService.findPublicFigures(pageNum, limitNum);
}
/**
* Get a single figure by ID (no auth required)
*/
@Get(':id')
async getFigure(@Param('id') id: string) {
return this.figureService.findById(id);
}
/**
* Get current user's figures (auth required)
*/
@Get()
@UseGuards(JwtAuthGuard)
async getUserFigures(
@CurrentUser() user: CurrentUserPayload,
@Query('includeArchived') includeArchived?: string
) {
return this.figureService.findUserFigures(user.userId, includeArchived === 'true');
}
/**
* Create a new figure (auth required)
*/
@Post()
@UseGuards(JwtAuthGuard)
async createFigure(@Body() dto: CreateFigureDto, @CurrentUser() user: CurrentUserPayload) {
return this.figureService.create(dto, user.userId);
}
/**
* Update a figure (auth required, must be owner)
*/
@Put(':id')
@UseGuards(JwtAuthGuard)
async updateFigure(
@Param('id') id: string,
@Body() dto: UpdateFigureDto,
@CurrentUser() user: CurrentUserPayload
) {
return this.figureService.update(id, dto, user.userId);
}
/**
* Delete a figure (auth required, must be owner)
*/
@Delete(':id')
@UseGuards(JwtAuthGuard)
async deleteFigure(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) {
return this.figureService.delete(id, user.userId);
}
/**
* Toggle like on a figure (auth required)
*/
@Post(':id/like')
@UseGuards(JwtAuthGuard)
async toggleLike(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) {
return this.figureService.toggleLike(id, user.userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FigureController } from './figure.controller';
import { FigureService } from './figure.service';
@Module({
controllers: [FigureController],
providers: [FigureService],
exports: [FigureService],
})
export class FigureModule {}

View file

@ -0,0 +1,193 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { figures, figureLikes } from '../db/schema';
import { CreateFigureDto } from './dto/create-figure.dto';
import { UpdateFigureDto } from './dto/update-figure.dto';
@Injectable()
export class FigureService {
constructor(
@Inject(DATABASE_CONNECTION)
private db: Database
) {}
async create(dto: CreateFigureDto, userId: string) {
const [figure] = await this.db
.insert(figures)
.values({
name: dto.name,
subject: dto.subject,
imageUrl: dto.imageUrl,
enhancedPrompt: dto.enhancedPrompt,
rarity: dto.rarity || 'common',
characterInfo: dto.characterInfo,
isPublic: dto.isPublic ?? true,
isArchived: false,
likes: 0,
userId,
})
.returning();
return figure;
}
async findById(id: string) {
const [figure] = await this.db.select().from(figures).where(eq(figures.id, id));
if (!figure) {
throw new NotFoundException('Figure not found');
}
return figure;
}
async findPublicFigures(page = 1, limit = 20) {
const offset = (page - 1) * limit;
const result = await this.db
.select()
.from(figures)
.where(and(eq(figures.isPublic, true), eq(figures.isArchived, false)))
.orderBy(desc(figures.createdAt))
.limit(limit)
.offset(offset);
return result;
}
async findUserFigures(userId: string, includeArchived = false) {
const conditions = [eq(figures.userId, userId)];
if (!includeArchived) {
conditions.push(eq(figures.isArchived, false));
}
const result = await this.db
.select()
.from(figures)
.where(and(...conditions))
.orderBy(desc(figures.createdAt));
return result;
}
async update(id: string, dto: UpdateFigureDto, userId: string) {
// First check if figure exists and belongs to user
const [existing] = await this.db.select().from(figures).where(eq(figures.id, id));
if (!existing) {
throw new NotFoundException('Figure not found');
}
if (existing.userId !== userId) {
throw new ForbiddenException('You do not have permission to update this figure');
}
const [updated] = await this.db
.update(figures)
.set({
...dto,
updatedAt: new Date(),
})
.where(eq(figures.id, id))
.returning();
return updated;
}
async delete(id: string, userId: string) {
// First check if figure exists and belongs to user
const [existing] = await this.db.select().from(figures).where(eq(figures.id, id));
if (!existing) {
throw new NotFoundException('Figure not found');
}
if (existing.userId !== userId) {
throw new ForbiddenException('You do not have permission to delete this figure');
}
await this.db.delete(figures).where(eq(figures.id, id));
return { success: true };
}
async toggleLike(figureId: string, userId: string) {
// Check if figure exists
const [figure] = await this.db.select().from(figures).where(eq(figures.id, figureId));
if (!figure) {
throw new NotFoundException('Figure not found');
}
// Check if user already liked this figure
const [existingLike] = await this.db
.select()
.from(figureLikes)
.where(and(eq(figureLikes.figureId, figureId), eq(figureLikes.userId, userId)));
if (existingLike) {
// Unlike: remove like and decrement count
await this.db
.delete(figureLikes)
.where(and(eq(figureLikes.figureId, figureId), eq(figureLikes.userId, userId)));
await this.db
.update(figures)
.set({
likes: sql`GREATEST(${figures.likes} - 1, 0)`,
})
.where(eq(figures.id, figureId));
return { liked: false, likes: Math.max((figure.likes || 0) - 1, 0) };
} else {
// Like: add like and increment count
await this.db.insert(figureLikes).values({
figureId,
userId,
});
await this.db
.update(figures)
.set({
likes: sql`${figures.likes} + 1`,
})
.where(eq(figures.id, figureId));
return { liked: true, likes: (figure.likes || 0) + 1 };
}
}
async checkUserLiked(figureId: string, userId: string): Promise<boolean> {
const [like] = await this.db
.select()
.from(figureLikes)
.where(and(eq(figureLikes.figureId, figureId), eq(figureLikes.userId, userId)));
return !!like;
}
async getPublicFiguresWithLikeStatus(userId: string | null, page = 1, limit = 20) {
const publicFigures = await this.findPublicFigures(page, limit);
if (!userId) {
return publicFigures.map((f) => ({ ...f, hasLiked: false }));
}
// Get all likes for this user for these figures
const figureIds = publicFigures.map((f) => f.id);
const userLikes = await this.db
.select()
.from(figureLikes)
.where(and(eq(figureLikes.userId, userId), sql`${figureLikes.figureId} = ANY(${figureIds})`));
const likedIds = new Set(userLikes.map((l) => l.figureId));
return publicFigures.map((f) => ({
...f,
hasLiked: likedIds.has(f.id),
}));
}
}

View file

@ -0,0 +1,39 @@
import { IsString, IsOptional, IsEnum, IsArray, ValidateNested, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
class ArtifactDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
}
export class GenerateFigureDto {
@IsString()
name: string;
@IsOptional()
@IsString()
characterDescription?: string;
@IsOptional()
@IsEnum(['common', 'rare', 'epic', 'legendary'])
rarity?: string;
@IsOptional()
@IsString()
characterImage?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ArtifactDto)
artifacts?: ArtifactDto[];
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}

View file

@ -0,0 +1,20 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { GenerationService } from './generation.service';
import { GenerateFigureDto } from './dto/generate-figure.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('generate')
export class GenerationController {
constructor(private readonly generationService: GenerationService) {}
/**
* Generate a new figure using AI (auth required)
* This endpoint uses OpenAI to generate character info and image
*/
@Post('figure')
@UseGuards(JwtAuthGuard)
async generateFigure(@Body() dto: GenerateFigureDto, @CurrentUser() user: CurrentUserPayload) {
return this.generationService.generateFigure(dto, user.userId);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { GenerationController } from './generation.controller';
import { GenerationService } from './generation.service';
import { FigureModule } from '../figure/figure.module';
@Module({
imports: [FigureModule],
controllers: [GenerationController],
providers: [GenerationService],
exports: [GenerationService],
})
export class GenerationModule {}

View file

@ -0,0 +1,163 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import { GenerateFigureDto } from './dto/generate-figure.dto';
import { FigureService } from '../figure/figure.service';
interface CharacterInfo {
character: {
description: string;
imagePrompt: string;
lore: string;
};
items: Array<{
name: string;
description: string;
imagePrompt: string;
lore: string;
}>;
styleDescription?: string;
}
@Injectable()
export class GenerationService {
private openai: OpenAI;
constructor(
private configService: ConfigService,
private figureService: FigureService
) {
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
if (apiKey) {
this.openai = new OpenAI({ apiKey });
}
}
async generateFigure(dto: GenerateFigureDto, userId: string) {
if (!this.openai) {
throw new BadRequestException('OpenAI API key not configured');
}
// Step 1: Generate character info using GPT-4
const characterInfo = await this.generateCharacterInfo(dto);
// Step 2: Generate image using DALL-E
const { imageUrl, enhancedPrompt } = await this.generateImage(dto.name, characterInfo);
// Step 3: Store figure in database
const figure = await this.figureService.create(
{
name: dto.name,
subject: dto.name,
imageUrl,
enhancedPrompt,
rarity: dto.rarity || 'common',
characterInfo,
isPublic: dto.isPublic ?? true,
},
userId
);
return {
...figure,
generatedDescriptions: characterInfo,
};
}
private async generateCharacterInfo(dto: GenerateFigureDto): Promise<CharacterInfo> {
const artifactNames = dto.artifacts?.map((a) => a.name).filter(Boolean) || [];
const artifactDescriptions = dto.artifacts?.map((a) => a.description).filter(Boolean) || [];
const prompt = `You are creating a collectible fantasy figure character. Generate detailed information for the following:
Character Name: ${dto.name}
${dto.characterDescription ? `Character Description: ${dto.characterDescription}` : ''}
${artifactNames.length > 0 ? `Artifacts/Items: ${artifactNames.join(', ')}` : ''}
${artifactDescriptions.length > 0 ? `Item Descriptions: ${artifactDescriptions.join('; ')}` : ''}
Rarity: ${dto.rarity || 'common'}
Please respond in the following JSON format:
{
"character": {
"description": "A detailed description of the character's appearance and personality (2-3 sentences)",
"imagePrompt": "A detailed prompt for generating the character's image (focus on visual elements)",
"lore": "Background story and lore for the character (2-3 sentences)"
},
"items": [
{
"name": "Item name",
"description": "What the item is and does",
"imagePrompt": "Visual description for the item",
"lore": "History or significance of the item"
}
],
"styleDescription": "Overall art style recommendation"
}
Generate 3 items total, using the provided artifact names/descriptions as inspiration if available.
Make the character and items more elaborate for higher rarities (legendary > epic > rare > common).`;
const completion = await this.openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
'You are a creative fantasy character designer. Always respond with valid JSON only, no additional text.',
},
{ role: 'user', content: prompt },
],
temperature: 0.8,
});
const content = completion.choices[0]?.message?.content;
if (!content) {
throw new BadRequestException('Failed to generate character info');
}
try {
// Parse JSON, handling potential markdown code blocks
const jsonStr = content.replace(/```json\n?|\n?```/g, '').trim();
return JSON.parse(jsonStr);
} catch {
throw new BadRequestException('Failed to parse character info response');
}
}
private async generateImage(
name: string,
characterInfo: CharacterInfo
): Promise<{ imageUrl: string; enhancedPrompt: string }> {
const imagePrompt = `Create a collectible figure/toy design in a stylized 3D render style:
Character: ${name}
${characterInfo.character.imagePrompt}
Style: High-quality collectible figure design, similar to Funko Pop or designer toys,
soft lighting, clean background, professional product photography style.
${characterInfo.styleDescription || ''}
The figure should be displayed on a simple pedestal or stand, emphasizing the collectible nature.`;
const response = await this.openai.images.generate({
model: 'dall-e-3',
prompt: imagePrompt,
n: 1,
size: '1024x1024',
quality: 'standard',
});
const imageUrl = response.data[0]?.url;
if (!imageUrl) {
throw new BadRequestException('Failed to generate image');
}
// TODO: Upload image to S3/MinIO storage instead of using OpenAI URL directly
// For now, return the temporary OpenAI URL (expires after ~1 hour)
return {
imageUrl,
enhancedPrompt: response.data[0]?.revised_prompt || imagePrompt,
};
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'figgos-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for web app
app.enableCors({
origin: [
'http://localhost:5181', // figgos web
'http://localhost:5173',
'http://localhost:3000',
'http://localhost:3001', // Mana Core Auth
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3012;
await app.listen(port);
console.log(`Figgos backend running on http://localhost:${port}`);
}
bootstrap();