Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ import { EarningsModule } from './earnings/earnings.module';
import { FavoritesModule } from './favorites/favorites.module';
import { FeedModule } from './feed/feed.module';
import { CommentsModule } from './comments/comments.module';
import { ContentModule } from './content/content.module';
import { ConversationsModule } from './conversations/conversations.module';
import { GamesModule } from './games/games.module';
import { LikesModule } from './likes/likes.module';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
import { CorrelationExceptionFilter } from './common/filters/correlation-exception.filter';
import { RequestContextService } from './common/services/request-context.service';
import { ContentModule } from './content/content.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NetworkConfigModule } from './config/network-config.module';
import { PostsModule } from './posts/posts.module';
import { WebhookModule } from './webhook/webhook.module';
Expand All @@ -57,6 +61,20 @@ const IDEMPOTENCY_ROUTES = [

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST', 'localhost'),
port: Number(config.get('DB_PORT', 5432)),
username: config.get('DB_USER', 'myfans'),
password: config.get('DB_PASSWORD', ''),
database: config.get('DB_NAME', 'myfans'),
autoLoadEntities: true,
synchronize: false,
}),
}),
ThrottlerModule.forRoot([
{ name: 'auth', ttl: 60000, limit: 5 },
{ name: 'short', ttl: 60000, limit: 10 },
Expand All @@ -82,8 +100,10 @@ const IDEMPOTENCY_ROUTES = [
FavoritesModule,
FeedModule,
CommentsModule,
LikesModule,
ContentModule,
ConversationsModule,
GamesModule,
LikesModule,
NetworkConfigModule,
PostsModule,
WebhookModule,
Expand Down
54 changes: 48 additions & 6 deletions backend/src/contract-health/contract-health.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ContractCheckResult {
@Injectable()
export class ContractHealthService {
private readonly logger = new Logger(ContractHealthService.name);
private up = 0;
private readonly rpcUrl =
process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org';

Expand All @@ -21,7 +22,14 @@ export class ContractHealthService {
params: unknown[] = [],
): Promise<ContractCheckResult> {
if (!contractId) {
return { contract: name, contractId, ok: false, error: 'Contract ID is empty', durationMs: 0 };
this.up = 0;
return {
contract: name,
contractId,
ok: false,
error: 'Contract ID is empty',
durationMs: 0,
};
}

const start = Date.now();
Expand All @@ -48,25 +56,59 @@ export class ContractHealthService {
const durationMs = Date.now() - start;

if (!res.ok) {
return { contract: name, contractId, ok: false, error: `HTTP ${res.status}`, durationMs };
this.up = 0;
return {
contract: name,
contractId,
ok: false,
error: `HTTP ${res.status}`,
durationMs,
};
}

const json = (await res.json()) as { error?: { message: string }; result?: unknown };
const json = (await res.json()) as {
error?: { message: string };
result?: unknown;
};

if (json.error) {
return { contract: name, contractId, ok: false, error: json.error.message, durationMs };
this.up = 0;
return {
contract: name,
contractId,
ok: false,
error: json.error.message,
durationMs,
};
}

this.logger.log(`Contract check passed: ${name} (${durationMs}ms)`);
this.up = 1;
return { contract: name, contractId, ok: true, durationMs };
} catch (err) {
this.up = 0;
const durationMs = Date.now() - start;
return { contract: name, contractId, ok: false, error: err.message, durationMs };
return {
contract: name,
contractId,
ok: false,
error: (err as Error).message,
durationMs,
};
}
}

getUpGauge(): number {
return this.up;
}

// Minimal XDR stub — in real usage replace with @stellar/stellar-sdk TransactionBuilder
private buildInvokeXdr(contractId: string, method: string, _params: unknown[]): string {
private buildInvokeXdr(
contractId: string,
method: string,
_params: unknown[],
): string {
void _params;
// Returns a placeholder; real XDR built by stellar-sdk in production
return `invoke:${contractId}:${method}`;
}
Expand Down
55 changes: 43 additions & 12 deletions backend/src/conversations/conversations.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { plainToInstance } from 'class-transformer';
import { Conversation } from './entities/conversation.entity';
import { Message } from './entities/message.entity';
import { ConversationDto, MessageDto, CreateConversationDto, SendMessageDto } from './dto';
import {
ConversationDto,
MessageDto,
CreateConversationDto,
SendMessageDto,
} from './dto';
import { PaginationDto, PaginatedResponseDto } from '../common/dto';

@Injectable()
Expand All @@ -17,14 +26,21 @@ export class ConversationsService {
) {}

private toConversationDto(conversation: Conversation): ConversationDto {
return plainToInstance(ConversationDto, conversation, { excludeExtraneousValues: true });
return plainToInstance(ConversationDto, conversation, {
excludeExtraneousValues: true,
});
}

private toMessageDto(message: Message): MessageDto {
return plainToInstance(MessageDto, message, { excludeExtraneousValues: true });
return plainToInstance(MessageDto, message, {
excludeExtraneousValues: true,
});
}

async create(userId: string, dto: CreateConversationDto): Promise<ConversationDto> {
async create(
userId: string,
dto: CreateConversationDto,
): Promise<ConversationDto> {
const conversation = this.conversationsRepository.create({
participant1Id: userId,
participant2Id: dto.participant2Id,
Expand All @@ -33,12 +49,18 @@ export class ConversationsService {
return this.toConversationDto(saved);
}

async findAll(userId: string, pagination: PaginationDto): Promise<PaginatedResponseDto<ConversationDto>> {
async findAll(
userId: string,
pagination: PaginationDto,
): Promise<PaginatedResponseDto<ConversationDto>> {
const { cursor, limit = 20 } = pagination;

const qb = this.conversationsRepository
.createQueryBuilder('conversation')
.where('conversation.participant1Id = :userId OR conversation.participant2Id = :userId', { userId })
.where(
'conversation.participant1Id = :userId OR conversation.participant2Id = :userId',
{ userId },
)
.orderBy('conversation.id', 'ASC')
.take(limit + 1);

Expand Down Expand Up @@ -70,14 +92,19 @@ export class ConversationsService {

async findOne(userId: string, id: string): Promise<ConversationDto> {
const conversation = await this.conversationsRepository.findOne({
where: [
{ id, participant1Id: userId },
{ id, participant2Id: userId },
],
where: { id },
});
if (!conversation) {
throw new NotFoundException(`Conversation with id "${id}" not found`);
}
if (
conversation.participant1Id !== userId &&
conversation.participant2Id !== userId
) {
throw new ForbiddenException(
'You are not a participant in this conversation',
);
}
return this.toConversationDto(conversation);
}

Expand Down Expand Up @@ -122,7 +149,11 @@ export class ConversationsService {
);
}

async sendMessage(userId: string, conversationId: string, dto: SendMessageDto): Promise<MessageDto> {
async sendMessage(
userId: string,
conversationId: string,
dto: SendMessageDto,
): Promise<MessageDto> {
// Verify user has access to conversation
await this.findOne(userId, conversationId);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateContentConversationsGames1751000000000 implements MigrationInterface {
name = 'CreateContentConversationsGames1751000000000';

async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DO $$ BEGIN CREATE TYPE "content_metadata_content_type_enum" AS ENUM ('image','video','audio','document'); EXCEPTION WHEN duplicate_object THEN null; END $$;`,
);
await queryRunner.query(
`DO $$ BEGIN CREATE TYPE "games_status_enum" AS ENUM ('PENDING','IN_PROGRESS','COMPLETED'); EXCEPTION WHEN duplicate_object THEN null; END $$;`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "content_metadata" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "creator_id" varchar NOT NULL, "title" varchar NOT NULL, "description" text, "ipfs_cid" varchar NOT NULL, "ipfs_url" varchar, "content_type" "content_metadata_content_type_enum" NOT NULL DEFAULT 'image', "subscription_tier" varchar, "is_published" boolean NOT NULL DEFAULT false, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now())`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_content_creator" ON "content_metadata" ("creator_id")`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "conversations" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "participant1Id" varchar NOT NULL, "participant2Id" varchar NOT NULL, "lastMessageId" varchar, "createdAt" timestamptz NOT NULL DEFAULT now(), "updatedAt" timestamptz NOT NULL DEFAULT now())`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "messages" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "conversationId" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE, "senderId" varchar NOT NULL, "content" text NOT NULL, "isRead" boolean NOT NULL DEFAULT false, "createdAt" timestamptz NOT NULL DEFAULT now())`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_messages_conversation" ON "messages" ("conversationId")`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "games" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "status" "games_status_enum" NOT NULL DEFAULT 'PENDING', "number_of_players" integer NOT NULL, "game_settings" jsonb NOT NULL, "host_user_id" varchar, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now())`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "players" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "game_id" uuid NOT NULL REFERENCES "games"("id") ON DELETE CASCADE, "user_id" varchar NOT NULL, "balance" numeric(10,2) NOT NULL, "turn_order" integer, "symbol" varchar, "created_at" timestamptz NOT NULL DEFAULT now(), CONSTRAINT "UQ_player_game_user" UNIQUE ("game_id", "user_id"))`,
);
}

async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "players", "games", "messages", "conversations", "content_metadata" CASCADE`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS "games_status_enum", "content_metadata_content_type_enum"`,
);
}
}
23 changes: 23 additions & 0 deletions backend/src/games/dto/create-game.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
IsBoolean,
IsInt,
IsNumber,
IsOptional,
Max,
Min,
} from 'class-validator';

export class CreateGameDto {
@IsInt()
@Min(2)
@Max(20)
numberOfPlayers: number;

@IsNumber()
@Min(1)
startingCash: number;

@IsOptional()
@IsBoolean()
randomizeTurnOrder = false;
}
19 changes: 18 additions & 1 deletion backend/src/games/games.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { GamesService } from './games.service';
import { ListGamesDto } from './dto/list-games.dto';
import { SubmitScoreDto } from './dto/submit-score.dto';
import { CreateGameDto } from './dto/create-game.dto';
import { JwtAuthGuard } from '../auth-module/guards/jwt-auth.guard';
import { CurrentUser } from '../auth-module/decorators/current-user.decorator';

Expand All @@ -28,6 +29,14 @@ import { CurrentUser } from '../auth-module/decorators/current-user.decorator';
export class GamesController {
constructor(private readonly gamesService: GamesService) {}

@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a game as the authenticated host' })
create(@Body() dto: CreateGameDto, @CurrentUser() user: { userId: string }) {
return this.gamesService.create(dto, user.userId);
}

@Get()
@ApiOperation({ summary: 'List games with pagination' })
@ApiQuery({
Expand All @@ -50,6 +59,12 @@ export class GamesController {
return await this.gamesService.findAll(listGamesDto);
}

@Get(':id')
@ApiOperation({ summary: 'Get a game by ID' })
findOne(@Param('id') id: string) {
return this.gamesService.findOne(id);
}

/**
* The joining player is always the authenticated JWT subject, never a
* caller-supplied body field — otherwise any caller could join a game as
Expand Down Expand Up @@ -112,7 +127,9 @@ export class GamesController {
@Post(':id/score')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Submit the authenticated player's current/final score" })
@ApiOperation({
summary: "Submit the authenticated player's current/final score",
})
@ApiParam({ name: 'id', description: 'Game ID' })
@ApiResponse({ status: 201, description: 'Score recorded' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
Expand Down
Loading
Loading