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
9 changes: 9 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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 },
Expand All @@ -74,6 +93,9 @@ const IDEMPOTENCY_ROUTES = [
FavoritesModule,
FeedModule,
CommentsModule,
ContentModule,
ConversationsModule,
GamesModule,
],
controllers: [AppController, OpenAPIController],
providers: [
Expand Down
12 changes: 10 additions & 2 deletions backend/src/content/content-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContentMetadata> & {
export type GatedContentView = Omit<
Partial<ContentMetadata>,
'ipfs_cid' | 'ipfs_url'
> & {
ipfs_cid?: string | null;
ipfs_url?: string | null;
locked: boolean;
preview_message?: string;
};
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 9 additions & 3 deletions backend/src/content/content.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
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;
}
Loading