From 5493d1ec81c3684a1a392baebbec8e311740acaa Mon Sep 17 00:00:00 2001 From: llins Date: Wed, 26 Aug 2026 10:34:09 +0100 Subject: [PATCH] fix: mount content conversations games and contract readiness --- DEPLOYMENT.md | 9 ++ backend/src/app.module.ts | 22 +++ backend/src/content/content-access.service.ts | 12 +- backend/src/content/content.controller.ts | 12 +- .../contract-health.service.ts | 54 +++++++- .../conversations/conversations.service.ts | 55 ++++++-- ...0000000-CreateContentConversationsGames.ts | 44 ++++++ backend/src/games/dto/create-game.dto.ts | 23 +++ backend/src/games/games.controller.ts | 19 ++- backend/src/games/games.service.ts | 45 +++++- backend/src/health/health.module.ts | 10 +- backend/src/health/health.service.ts | 131 ++++++++++++++---- backend/src/metrics/metrics.controller.ts | 90 +++++++++--- backend/src/metrics/metrics.module.ts | 3 +- backend/src/migration.datasource.ts | 2 + frontend/src/app/games/page.tsx | 6 +- frontend/src/app/messages/[id]/page.tsx | 6 +- frontend/src/app/messages/page.tsx | 14 +- frontend/src/lib/api/messages.ts | 3 + 19 files changed, 470 insertions(+), 90 deletions(-) create mode 100644 backend/src/database/1751000000000-CreateContentConversationsGames.ts create mode 100644 backend/src/games/dto/create-game.dto.ts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e49d224e..fa08ae2b 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -80,6 +80,15 @@ Contract IDs written by `contract/scripts/deploy.sh` (`.env.deployed`) use canon ### Frontend (.env.local) - `NEXT_PUBLIC_SUBSCRIPTION_CONTRACT_ID` (or `NEXT_PUBLIC_SUBSCRIPTIONS_CONTRACT_ID`): same contract as backend subscription id + +### Readiness probe + +Configure orchestrators to call `GET /v1/health/ready`. In production this +returns HTTP 503 unless the database is reachable and both +`CONTRACT_ID_SUBSCRIPTION` and `CONTRACT_ID_MYFANS_TOKEN` are configured and +respond to Soroban simulation. Test environments report missing contracts as +`not_configured` without failing readiness. The latest probe is exported as +the `contract_health_up` gauge from `GET /v1/metrics/prometheus`. - `NEXT_PUBLIC_STELLAR_NETWORK`: testnet or public - `NEXT_PUBLIC_API_URL`: Backend API URL diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ce003dc9..2b7598a0 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -32,9 +32,14 @@ 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 { CsrfMiddleware } from './common/middleware/csrf.middleware'; import { CorrelationExceptionFilter } from './common/filters/correlation-exception.filter'; import { RequestContextService } from './common/services/request-context.service'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; /** Routes where idempotency protection is enforced. */ const IDEMPOTENCY_ROUTES = [ @@ -50,6 +55,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 }, @@ -74,6 +93,9 @@ const IDEMPOTENCY_ROUTES = [ FavoritesModule, FeedModule, CommentsModule, + ContentModule, + ConversationsModule, + GamesModule, ], controllers: [AppController, OpenAPIController], providers: [ diff --git a/backend/src/content/content-access.service.ts b/backend/src/content/content-access.service.ts index f9bcace0..5b727085 100644 --- a/backend/src/content/content-access.service.ts +++ b/backend/src/content/content-access.service.ts @@ -3,7 +3,12 @@ import { ContentService } from './content.service'; import { ContentMetadata } from './entities/content.entity'; import { SubscriptionsService } from '../subscriptions/subscriptions.service'; -export type GatedContentView = Partial & { +export type GatedContentView = Omit< + Partial, + 'ipfs_cid' | 'ipfs_url' +> & { + ipfs_cid?: string | null; + ipfs_url?: string | null; locked: boolean; preview_message?: string; }; @@ -46,7 +51,10 @@ export class ContentAccessService { } const isSubscriber = requesterId - ? await this.subscriptionsService.isSubscriber(requesterId, content.creator_id) + ? await this.subscriptionsService.isSubscriber( + requesterId, + content.creator_id, + ) : false; if (isSubscriber) { diff --git a/backend/src/content/content.controller.ts b/backend/src/content/content.controller.ts index f888c5d2..dc54c3af 100644 --- a/backend/src/content/content.controller.ts +++ b/backend/src/content/content.controller.ts @@ -18,13 +18,19 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { CurrentUser, JwtUserPayload } from '../auth-module/decorators/current-user.decorator'; +import { CurrentUser } from '../auth-module/decorators/current-user.decorator'; +import type { JwtUserPayload } from '../auth-module/decorators/current-user.decorator'; import { JwtAuthGuard } from '../auth-module/guards/jwt-auth.guard'; import { OptionalJwtAuthGuard } from '../auth-module/guards/optional-jwt-auth.guard'; import { PaginatedResponseDto, PaginationDto } from '../common/dto'; -import { ContentAccessService, GatedContentView } from './content-access.service'; +import { ContentAccessService } from './content-access.service'; +import type { GatedContentView } from './content-access.service'; import { ContentService } from './content.service'; -import { ContentResponseDto, CreateContentDto, UpdateContentDto } from './dto/content.dto'; +import { + ContentResponseDto, + CreateContentDto, + UpdateContentDto, +} from './dto/content.dto'; import { ContentMetadata } from './entities/content.entity'; @ApiTags('content') diff --git a/backend/src/contract-health/contract-health.service.ts b/backend/src/contract-health/contract-health.service.ts index 87411635..ada303ec 100644 --- a/backend/src/contract-health/contract-health.service.ts +++ b/backend/src/contract-health/contract-health.service.ts @@ -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'; @@ -21,7 +22,14 @@ export class ContractHealthService { params: unknown[] = [], ): Promise { 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(); @@ -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}`; } diff --git a/backend/src/conversations/conversations.service.ts b/backend/src/conversations/conversations.service.ts index 0f751cf9..0508b697 100644 --- a/backend/src/conversations/conversations.service.ts +++ b/backend/src/conversations/conversations.service.ts @@ -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() @@ -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 { + async create( + userId: string, + dto: CreateConversationDto, + ): Promise { const conversation = this.conversationsRepository.create({ participant1Id: userId, participant2Id: dto.participant2Id, @@ -33,12 +49,18 @@ export class ConversationsService { return this.toConversationDto(saved); } - async findAll(userId: string, pagination: PaginationDto): Promise> { + async findAll( + userId: string, + pagination: PaginationDto, + ): Promise> { 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); @@ -70,14 +92,19 @@ export class ConversationsService { async findOne(userId: string, id: string): Promise { 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); } @@ -122,7 +149,11 @@ export class ConversationsService { ); } - async sendMessage(userId: string, conversationId: string, dto: SendMessageDto): Promise { + async sendMessage( + userId: string, + conversationId: string, + dto: SendMessageDto, + ): Promise { // Verify user has access to conversation await this.findOne(userId, conversationId); diff --git a/backend/src/database/1751000000000-CreateContentConversationsGames.ts b/backend/src/database/1751000000000-CreateContentConversationsGames.ts new file mode 100644 index 00000000..13762cf4 --- /dev/null +++ b/backend/src/database/1751000000000-CreateContentConversationsGames.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateContentConversationsGames1751000000000 implements MigrationInterface { + name = 'CreateContentConversationsGames1751000000000'; + + async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`, + ); + } +} diff --git a/backend/src/games/dto/create-game.dto.ts b/backend/src/games/dto/create-game.dto.ts new file mode 100644 index 00000000..f1c267b8 --- /dev/null +++ b/backend/src/games/dto/create-game.dto.ts @@ -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; +} diff --git a/backend/src/games/games.controller.ts b/backend/src/games/games.controller.ts index 79734cf8..542b8b0d 100644 --- a/backend/src/games/games.controller.ts +++ b/backend/src/games/games.controller.ts @@ -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'; @@ -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({ @@ -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 @@ -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' }) diff --git a/backend/src/games/games.service.ts b/backend/src/games/games.service.ts index 5918c241..d0bb19ba 100644 --- a/backend/src/games/games.service.ts +++ b/backend/src/games/games.service.ts @@ -10,6 +10,7 @@ import { Game, GameStatus } from './entities/game.entity'; import { Player } from './entities/player.entity'; import { ListGamesDto } from './dto/list-games.dto'; import { SubmitScoreDto } from './dto/submit-score.dto'; +import { CreateGameDto } from './dto/create-game.dto'; import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; @Injectable() @@ -22,7 +23,43 @@ export class GamesService { private dataSource: DataSource, ) {} - async findAll(listGamesDto: ListGamesDto): Promise> { + async create(dto: CreateGameDto, userId: string): Promise { + return this.dataSource.transaction(async (manager) => { + const game = manager.create(Game, { + status: GameStatus.PENDING, + number_of_players: dto.numberOfPlayers, + game_settings: { + starting_cash: dto.startingCash, + randomize_turn_order: dto.randomizeTurnOrder, + }, + host_user_id: userId, + }); + const saved = await manager.save(Game, game); + await manager.save( + Player, + manager.create(Player, { + game_id: saved.id, + user_id: userId, + balance: dto.startingCash, + turn_order: 1, + }), + ); + return saved; + }); + } + + async findOne(id: string): Promise { + const game = await this.gameRepository.findOne({ + where: { id }, + relations: ['players'], + }); + if (!game) throw new NotFoundException('Game not found'); + return game; + } + + async findAll( + listGamesDto: ListGamesDto, + ): Promise> { const { page = 1, limit = 20, status } = listGamesDto; const queryBuilder = this.gameRepository @@ -124,8 +161,10 @@ export class GamesService { ); } - game.status = GameStatus.IN_PROGRESS; - return await manager.save(Game, game); + return await manager.save(Game, { + ...game, + status: GameStatus.IN_PROGRESS, + }); }); } diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts index 884b2ae9..04cc707c 100644 --- a/backend/src/health/health.module.ts +++ b/backend/src/health/health.module.ts @@ -6,11 +6,17 @@ import { StartupProbeService } from './startup-probe.service'; import { SorobanRpcService } from '../common/services/soroban-rpc.service'; import { QueueMetricsService } from '../common/services/queue-metrics.service'; import { MetricsModule } from '../metrics/metrics.module'; +import { ContractHealthModule } from '../contract-health/contract-health.module'; @Module({ - imports: [TypeOrmModule.forFeature([]), MetricsModule], + imports: [TypeOrmModule.forFeature([]), MetricsModule, ContractHealthModule], controllers: [HealthController], - providers: [HealthService, StartupProbeService, SorobanRpcService, QueueMetricsService], + providers: [ + HealthService, + StartupProbeService, + SorobanRpcService, + QueueMetricsService, + ], exports: [HealthService, StartupProbeService], }) export class HealthModule {} diff --git a/backend/src/health/health.service.ts b/backend/src/health/health.service.ts index 1f2fb762..cb0d78f0 100644 --- a/backend/src/health/health.service.ts +++ b/backend/src/health/health.service.ts @@ -1,13 +1,24 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import * as net from 'net'; import * as tls from 'tls'; import { DataSource } from 'typeorm'; -import { SorobanRpcService, SorobanHealthStatus } from '../common/services/soroban-rpc.service'; -import { QueueMetricsService, QueueSnapshot } from '../common/services/queue-metrics.service'; +import { + SorobanRpcService, + SorobanHealthStatus, +} from '../common/services/soroban-rpc.service'; +import { + QueueMetricsService, + QueueSnapshot, +} from '../common/services/queue-metrics.service'; +import { + ContractHealthService, + ContractCheckResult, +} from '../contract-health/contract-health.service'; +import { loadContractIds } from '../contract-health/contract-ids.loader'; export interface HealthCheckResult { - status: 'up' | 'down' | 'degraded'; - timestamp: string; + status: 'up' | 'down' | 'degraded'; + timestamp: string; } /** @@ -18,17 +29,17 @@ export interface HealthCheckResult { * does not drag the aggregate health to `degraded`. */ export interface RedisHealthStatus { - status: 'up' | 'down' | 'not_configured'; - latencyMs?: number; - error?: string; + status: 'up' | 'down' | 'not_configured'; + latencyMs?: number; + error?: string; } export interface DetailedHealthCheckResult extends HealthCheckResult { - checks?: { - database?: { status: 'up' | 'down' | 'degraded'; error?: string }; - sorobanRpc?: SorobanHealthStatus; - sorobanContract?: SorobanHealthStatus; - }; + checks?: { + database?: { status: 'up' | 'down' | 'degraded'; error?: string }; + sorobanRpc?: SorobanHealthStatus; + sorobanContract?: SorobanHealthStatus; + }; } /** @@ -42,8 +53,17 @@ export interface ReadinessResult { status: 'up' | 'down'; timestamp: string; checks: { - database: { status: 'up' | 'down' | 'degraded'; latencyMs?: number; error?: string }; + database: { + status: 'up' | 'down' | 'degraded'; + latencyMs?: number; + error?: string; + }; sorobanRpc: SorobanHealthStatus; + contracts: { + status: 'up' | 'down' | 'not_configured'; + results: ContractCheckResult[]; + error?: string; + }; }; } @@ -53,7 +73,11 @@ export interface AggregatedHealthResult { uptime: number; version: string; subsystems: { - database: { status: 'up' | 'down' | 'degraded'; latencyMs?: number; error?: string }; + database: { + status: 'up' | 'down' | 'degraded'; + latencyMs?: number; + error?: string; + }; // Omitted entirely when Redis is not configured (optional subsystem). redis?: RedisHealthStatus; sorobanRpc: SorobanHealthStatus; @@ -78,6 +102,7 @@ export class HealthService { private dataSource: DataSource, private sorobanRpcService: SorobanRpcService, private queueMetrics: QueueMetricsService, + @Optional() private contractHealth?: ContractHealthService, ) {} getHealth(): DetailedHealthCheckResult { @@ -99,7 +124,10 @@ export class HealthService { if (dbHealth.status === 'down') { overallStatus = 'down'; - } else if (rpcHealth.status === 'down' || contractHealth.status === 'down') { + } else if ( + rpcHealth.status === 'down' || + contractHealth.status === 'down' + ) { overallStatus = 'down'; } else if ( rpcHealth.status === 'degraded' || @@ -131,12 +159,13 @@ export class HealthService { * overall 'down' → 503 */ async getAggregatedHealth(): Promise { - const [dbHealth, redisHealth, rpcHealth, contractHealth] = await Promise.all([ - this.checkDatabaseWithLatency(), - this.checkRedis(), - this.checkSorobanRpc(), - this.checkSorobanContract(), - ]); + const [dbHealth, redisHealth, rpcHealth, contractHealth] = + await Promise.all([ + this.checkDatabaseWithLatency(), + this.checkRedis(), + this.checkSorobanRpc(), + this.checkSorobanContract(), + ]); // An unconfigured Redis is skipped so it neither counts toward the summary // nor degrades the aggregate status. @@ -179,7 +208,10 @@ export class HealthService { }; } - async checkDatabase(): Promise<{ status: 'up' | 'down' | 'degraded'; error?: string }> { + async checkDatabase(): Promise<{ + status: 'up' | 'down' | 'degraded'; + error?: string; + }> { try { await this.dataSource.query('SELECT 1'); return { status: 'up' }; @@ -270,8 +302,12 @@ export class HealthService { const useTls = parsed.protocol === 'rediss:'; const host = parsed.hostname; const port = parsed.port ? Number(parsed.port) : 6379; - const password = parsed.password ? decodeURIComponent(parsed.password) : undefined; - const username = parsed.username ? decodeURIComponent(parsed.username) : undefined; + const password = parsed.password + ? decodeURIComponent(parsed.password) + : undefined; + const username = parsed.username + ? decodeURIComponent(parsed.username) + : undefined; return new Promise((resolve, reject) => { const socket = useTls @@ -345,21 +381,62 @@ export class HealthService { * readiness on its own. */ async getReadiness(): Promise { - const [dbHealth, rpcHealth] = await Promise.all([ + const [dbHealth, rpcHealth, contracts] = await Promise.all([ this.checkDatabaseWithLatency(), this.checkSorobanRpc(), + this.checkConfiguredContracts(), ]); return { - status: dbHealth.status === 'down' ? 'down' : 'up', + status: + dbHealth.status === 'down' || + (process.env.NODE_ENV === 'production' && contracts.status !== 'up') + ? 'down' + : 'up', timestamp: new Date().toISOString(), checks: { database: dbHealth, sorobanRpc: rpcHealth, + contracts, }, }; } + private async checkConfiguredContracts(): Promise< + ReadinessResult['checks']['contracts'] + > { + if (!this.contractHealth) return { status: 'not_configured', results: [] }; + try { + const ids = loadContractIds(); + const configured = [ + ['subscriptions', ids.subscriptions, 'plan_count'], + ['token', ids.myfansToken, 'balance'], + ] as const; + if (configured.some(([, id]) => !id)) { + return { + status: 'not_configured', + results: [], + error: 'Required contract ID is missing', + }; + } + const results = await Promise.all( + configured.map(([name, id, method]) => + this.contractHealth!.checkContract(name, id, method), + ), + ); + return { + status: results.every((result) => result.ok) ? 'up' : 'down', + results, + }; + } catch (error) { + return { + status: 'not_configured', + results: [], + error: (error as Error).message, + }; + } + } + async checkSorobanRpc(): Promise { return this.sorobanRpcService.checkConnectivity(); } diff --git a/backend/src/metrics/metrics.controller.ts b/backend/src/metrics/metrics.controller.ts index c81cdc7c..015c8f80 100644 --- a/backend/src/metrics/metrics.controller.ts +++ b/backend/src/metrics/metrics.controller.ts @@ -1,11 +1,25 @@ -import { Controller, Get, Header, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Header, + Optional, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import type { MetricsSnapshot } from '../common/services/http-metrics.service'; import { HttpMetricsService } from '../common/services/http-metrics.service'; -import { RpcMetricsService, RpcMetricsSnapshot } from '../common/services/rpc-metrics.service'; -import { ModerationSlaService, ModerationSlaSnapshot } from '../moderation/moderation-sla.service'; +import { + RpcMetricsService, + RpcMetricsSnapshot, +} from '../common/services/rpc-metrics.service'; +import { + ModerationSlaService, + ModerationSlaSnapshot, +} from '../moderation/moderation-sla.service'; import { Public } from '../common/decorators/public.decorator'; import { MetricsGuard } from './metrics.guard'; +import { ContractHealthService } from '../contract-health/contract-health.service'; export type MetricSeverity = 'warning' | 'critical'; @@ -40,6 +54,7 @@ export class MetricsController { private readonly httpMetrics: HttpMetricsService, private readonly rpcMetrics: RpcMetricsService, private readonly moderationSla: ModerationSlaService, + @Optional() private readonly contractHealth?: ContractHealthService, ) {} /** @@ -51,11 +66,20 @@ export class MetricsController { @Get() @Public() @UseGuards(MetricsGuard) - @ApiOperation({ summary: 'Per-endpoint HTTP latency, error rate metrics, Soroban RPC metrics, and moderation queue SLA' }) - @ApiQuery({ name: 'route', required: false, description: 'Filter HTTP endpoints by route prefix, e.g. /v1/auth' }) + @ApiOperation({ + summary: + 'Per-endpoint HTTP latency, error rate metrics, Soroban RPC metrics, and moderation queue SLA', + }) + @ApiQuery({ + name: 'route', + required: false, + description: 'Filter HTTP endpoints by route prefix, e.g. /v1/auth', + }) @ApiResponse({ status: 200, description: 'Metrics snapshot' }) @ApiResponse({ status: 401, description: 'Missing or invalid scrape token' }) - async getMetrics(@Query('route') routeFilter?: string): Promise { + async getMetrics( + @Query('route') routeFilter?: string, + ): Promise { const [httpSnap, rpcSnap, slaSnap] = await Promise.all([ Promise.resolve(this.httpMetrics.snapshot()), Promise.resolve(this.rpcMetrics.snapshot()), @@ -76,11 +100,19 @@ export class MetricsController { @Public() @UseGuards(MetricsGuard) @Header('Content-Type', 'text/plain; version=0.0.4') - @ApiOperation({ summary: 'Prometheus scrape endpoint for HTTP and Soroban RPC metrics' }) - @ApiQuery({ name: 'route', required: false, description: 'Filter HTTP endpoints by route prefix, e.g. /v1/auth' }) + @ApiOperation({ + summary: 'Prometheus scrape endpoint for HTTP and Soroban RPC metrics', + }) + @ApiQuery({ + name: 'route', + required: false, + description: 'Filter HTTP endpoints by route prefix, e.g. /v1/auth', + }) @ApiResponse({ status: 200, description: 'Prometheus metrics text format' }) @ApiResponse({ status: 401, description: 'Missing or invalid scrape token' }) - async getPrometheusMetrics(@Query('route') routeFilter?: string): Promise { + async getPrometheusMetrics( + @Query('route') routeFilter?: string, + ): Promise { const [httpSnap, rpcSnap] = await Promise.all([ Promise.resolve(this.httpMetrics.snapshot()), Promise.resolve(this.rpcMetrics.snapshot()), @@ -181,22 +213,41 @@ export class MetricsController { rpcSnap: RpcMetricsSnapshot, ): string { const lines: string[] = []; - lines.push('# HELP backend_http_requests_total Total HTTP requests received'); + lines.push( + '# HELP backend_http_requests_total Total HTTP requests received', + ); lines.push('# TYPE backend_http_requests_total counter'); - lines.push('# HELP backend_http_request_errors_total Total HTTP errors by class'); + lines.push( + '# HELP backend_http_request_errors_total Total HTTP errors by class', + ); lines.push('# TYPE backend_http_request_errors_total counter'); - lines.push('# HELP backend_http_request_duration_seconds Histogram of HTTP request durations in seconds'); + lines.push( + '# HELP backend_http_request_duration_seconds Histogram of HTTP request durations in seconds', + ); lines.push('# TYPE backend_http_request_duration_seconds histogram'); - lines.push('# HELP backend_soroban_rpc_calls_total Total Soroban RPC calls by method and outcome'); + lines.push( + '# HELP backend_soroban_rpc_calls_total Total Soroban RPC calls by method and outcome', + ); lines.push('# TYPE backend_soroban_rpc_calls_total counter'); - lines.push('# HELP backend_soroban_rpc_duration_seconds_total Total Soroban RPC duration in seconds'); + lines.push( + '# HELP backend_soroban_rpc_duration_seconds_total Total Soroban RPC duration in seconds', + ); lines.push('# TYPE backend_soroban_rpc_duration_seconds_total counter'); - lines.push('# HELP backend_soroban_rpc_duration_seconds_count Total Soroban RPC duration count'); + lines.push( + '# HELP backend_soroban_rpc_duration_seconds_count Total Soroban RPC duration count', + ); lines.push('# TYPE backend_soroban_rpc_duration_seconds_count counter'); + lines.push( + '# HELP contract_health_up Whether the latest configured contract probe succeeded', + ); + lines.push('# TYPE contract_health_up gauge'); + lines.push(`contract_health_up ${this.contractHealth?.getUpGauge() ?? 0}`); for (const endpoint of httpSnap.endpoints) { const baseLabels = { method: endpoint.method, route: endpoint.route }; - lines.push(`backend_http_requests_total${this.prometheusLabels(baseLabels)} ${endpoint.requests}`); + lines.push( + `backend_http_requests_total${this.prometheusLabels(baseLabels)} ${endpoint.requests}`, + ); lines.push( `backend_http_request_errors_total${this.prometheusLabels({ ...baseLabels, code: '4xx' })} ${endpoint.errors4xx}`, ); @@ -206,7 +257,8 @@ export class MetricsController { const histogramBuckets = this.cumulativeHistogram(endpoint.histogram); for (const [bound, count] of Object.entries(histogramBuckets)) { - const le = bound === 'Infinity' ? '+Inf' : (Number(bound) / 1000).toFixed(3); + const le = + bound === 'Infinity' ? '+Inf' : (Number(bound) / 1000).toFixed(3); lines.push( `backend_http_request_duration_seconds_bucket${this.prometheusLabels({ ...baseLabels, le })} ${count}`, ); @@ -245,7 +297,9 @@ export class MetricsController { return parts.length ? `{${parts.join(',')}}` : ''; } - private cumulativeHistogram(histogram: Record): Record { + private cumulativeHistogram( + histogram: Record, + ): Record { const ordered = Object.keys(histogram) .map((key) => (key === 'Infinity' ? Infinity : Number(key))) .sort((a, b) => a - b); diff --git a/backend/src/metrics/metrics.module.ts b/backend/src/metrics/metrics.module.ts index 0820340f..2e3c356a 100644 --- a/backend/src/metrics/metrics.module.ts +++ b/backend/src/metrics/metrics.module.ts @@ -5,9 +5,10 @@ import { MetricsGuard } from './metrics.guard'; import { HttpMetricsService } from '../common/services/http-metrics.service'; import { RpcMetricsService } from '../common/services/rpc-metrics.service'; import { ModerationModule } from '../moderation/moderation.module'; +import { ContractHealthModule } from '../contract-health/contract-health.module'; @Module({ - imports: [ConfigModule, ModerationModule], + imports: [ConfigModule, ModerationModule, ContractHealthModule], controllers: [MetricsController], providers: [HttpMetricsService, RpcMetricsService, MetricsGuard], exports: [HttpMetricsService, RpcMetricsService], diff --git a/backend/src/migration.datasource.ts b/backend/src/migration.datasource.ts index 329d75ff..c2b29b2b 100644 --- a/backend/src/migration.datasource.ts +++ b/backend/src/migration.datasource.ts @@ -14,6 +14,7 @@ import { AddRoleToUsers1747000000000 } from './users/1747000000000-AddRoleToUser import { CreateSocialLinksTable1748000000000 } from './social-link/1748000000000-CreateSocialLinksTable'; import { CreateCreatorOnchainMappings1749000000000 } from './creators/1749000000000-CreateCreatorOnchainMappings'; import { CreateNotificationDurableState1750000000000 } from './notifications/1750000000000-CreateNotificationDurableState'; +import { CreateContentConversationsGames1751000000000 } from './database/1751000000000-CreateContentConversationsGames'; export const migrationDataSource = new DataSource({ type: 'postgres', @@ -37,5 +38,6 @@ export const migrationDataSource = new DataSource({ CreateSocialLinksTable1748000000000, CreateCreatorOnchainMappings1749000000000, CreateNotificationDurableState1750000000000, + CreateContentConversationsGames1751000000000, ], }); diff --git a/frontend/src/app/games/page.tsx b/frontend/src/app/games/page.tsx index 65779aa4..c8b2154e 100644 --- a/frontend/src/app/games/page.tsx +++ b/frontend/src/app/games/page.tsx @@ -1,9 +1,9 @@ import Link from 'next/link'; -import { listGames } from '@/lib/api/games'; +import { Game, listGames } from '@/lib/api/games'; export default async function GamesPage() { - let games = []; - let error = null; + let games: Game[] = []; + let error: string | null = null; try { const result = await listGames({ limit: 20 }); diff --git a/frontend/src/app/messages/[id]/page.tsx b/frontend/src/app/messages/[id]/page.tsx index 6faa9c8b..02612b88 100644 --- a/frontend/src/app/messages/[id]/page.tsx +++ b/frontend/src/app/messages/[id]/page.tsx @@ -1,6 +1,6 @@ import { notFound } from 'next/navigation'; import Link from 'next/link'; -import { getConversationById, listMessages } from '@/lib/api/messages'; +import { getConversationById, listMessages, Message } from '@/lib/api/messages'; import { MessageThread } from './message-thread'; interface PageProps { @@ -15,8 +15,8 @@ export default async function MessageThreadPage({ params }: PageProps) { notFound(); } - let messages = []; - let messagesError = null; + let messages: Message[] = []; + let messagesError: string | null = null; try { const result = await listMessages(id, { limit: 30 }); diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index d806929b..da446900 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -1,9 +1,9 @@ import Link from 'next/link'; -import { listConversations } from '@/lib/api/messages'; +import { Conversation, listConversations } from '@/lib/api/messages'; export default async function MessagesPage() { - let conversations = []; - let error = null; + let conversations: Conversation[] = []; + let error: string | null = null; try { const result = await listConversations({ limit: 20 }); @@ -12,15 +12,11 @@ export default async function MessagesPage() { error = err instanceof Error ? err.message : 'Failed to load conversations'; } - const getOtherParticipant = (conv: any) => { - return conv.participant2?.username || conv.participant1?.username || 'Unknown'; - }; - - const getOtherParticipantName = (conv: any) => { + const getOtherParticipantName = (conv: Conversation) => { return conv.participant2?.displayName || conv.participant2?.username || 'Unknown'; }; - const getLastMessagePreview = (conv: any) => { + const getLastMessagePreview = (conv: Conversation) => { if (!conv.lastMessage) return 'No messages yet'; const content = conv.lastMessage.content; return content.length > 50 ? `${content.slice(0, 50)}...` : content; diff --git a/frontend/src/lib/api/messages.ts b/frontend/src/lib/api/messages.ts index 0b2af9b3..cfe33052 100644 --- a/frontend/src/lib/api/messages.ts +++ b/frontend/src/lib/api/messages.ts @@ -49,6 +49,7 @@ export interface MessagesPage { } const API_BASE = `${getApiBaseUrl()}/api/v1`; +const idempotencyKey = () => globalThis.crypto.randomUUID(); /** * List user conversations with cursor-based pagination. @@ -100,6 +101,7 @@ export async function createConversation(params: { credentials: 'include', headers: { 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey(), }, body: JSON.stringify(params), }); @@ -151,6 +153,7 @@ export async function sendMessage( credentials: 'include', headers: { 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey(), }, body: JSON.stringify(params), });