mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 01:36:42 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
20
games/figgos/apps/backend/src/app.module.ts
Normal file
20
games/figgos/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
38
games/figgos/apps/backend/src/db/connection.ts
Normal file
38
games/figgos/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
28
games/figgos/apps/backend/src/db/database.module.ts
Normal file
28
games/figgos/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
27
games/figgos/apps/backend/src/db/schema/figures.schema.ts
Normal file
27
games/figgos/apps/backend/src/db/schema/figures.schema.ts
Normal 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;
|
||||
2
games/figgos/apps/backend/src/db/schema/index.ts
Normal file
2
games/figgos/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './figures.schema';
|
||||
export * from './figure-likes.schema';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
124
games/figgos/apps/backend/src/figure/figure.controller.ts
Normal file
124
games/figgos/apps/backend/src/figure/figure.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
games/figgos/apps/backend/src/figure/figure.module.ts
Normal file
10
games/figgos/apps/backend/src/figure/figure.module.ts
Normal 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 {}
|
||||
193
games/figgos/apps/backend/src/figure/figure.service.ts
Normal file
193
games/figgos/apps/backend/src/figure/figure.service.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
163
games/figgos/apps/backend/src/generation/generation.service.ts
Normal file
163
games/figgos/apps/backend/src/generation/generation.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
games/figgos/apps/backend/src/health/health.controller.ts
Normal file
13
games/figgos/apps/backend/src/health/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
games/figgos/apps/backend/src/health/health.module.ts
Normal file
7
games/figgos/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
36
games/figgos/apps/backend/src/main.ts
Normal file
36
games/figgos/apps/backend/src/main.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue