diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 903b242d..7c07059b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,13 +145,13 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - targets: wasm32-unknown-unknown + targets: wasm32v1-none components: rustfmt # See onchain/Cargo.lock for pinned dependency resolutions. - name: Build contracts (release wasm) working-directory: onchain - run: cargo build --workspace --target wasm32-unknown-unknown --release --locked + run: cargo build --workspace --target wasm32v1-none --release --locked - name: Format check working-directory: onchain diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4483fce..c28a80e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - targets: wasm32-unknown-unknown + targets: wasm32v1-none - name: Setup Node.js uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 @@ -69,7 +69,7 @@ jobs: - name: Build onchain contracts working-directory: onchain - run: cargo build --workspace --target wasm32-unknown-unknown --release --locked + run: cargo build --workspace --target wasm32v1-none --release --locked - name: Run onchain tests working-directory: onchain @@ -79,7 +79,7 @@ jobs: working-directory: onchain run: | for f in stellar_hunts stellar_hunts_nft stellar_hunts_receiver; do - test -s "target/wasm32-unknown-unknown/release/${f}.wasm" \ + test -s "target/wasm32v1-none/release/${f}.wasm" \ || { echo "::error::missing or empty artifact: ${f}.wasm"; exit 1; } done @@ -147,7 +147,7 @@ jobs: prerelease: false generate_release_notes: false files: | - onchain/target/wasm32-unknown-unknown/release/stellar_hunts.wasm - onchain/target/wasm32-unknown-unknown/release/stellar_hunts_nft.wasm - onchain/target/wasm32-unknown-unknown/release/stellar_hunts_receiver.wasm + onchain/target/wasm32v1-none/release/stellar_hunts.wasm + onchain/target/wasm32v1-none/release/stellar_hunts_nft.wasm + onchain/target/wasm32v1-none/release/stellar_hunts_receiver.wasm fail_on_unmatched_files: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4616ceba..0a69ec84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,15 @@ docs(api): document rewards claim endpoint - **Formatting**: Run `npm run format` (Prettier) before committing - **Modules**: Follow NestJS modular architecture — each feature gets its own module with `controller`, `service`, and `entity` files - **DTOs**: Validate all inputs using `class-validator` decorators + - Every DTO bound with `@Body()` (a request DTO) must decorate its required + fields (`@IsString`, `@IsInt`, `@IsUUID`, …) so the global `ValidationPipe` + (`whitelist: true`, `forbidNonWhitelisted: true`) can enforce types and + reject unknown properties instead of silently stripping them (issue #529). + - Response-only DTOs (shapes returned to clients, never bound as a request + body) must start with the marker comment `// Response-only DTO` and carry + no validation decorators by design. + - `update-*` DTOs should extend their decorated `create-*` base via + `PartialType` so the whitelisted properties are inherited. - **API docs**: Use Swagger decorators (`@ApiTags`, `@ApiOperation`, `@ApiResponse`) for all endpoints ### Onchain (Soroban / Rust) diff --git a/backend/package.json b/backend/package.json index f6c27599..b915c872 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,10 +37,9 @@ "@nestjs/jwt": "^12.0.1", "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", - "@nestjs/platform-express": "^12.0.1", - "@nestjs/platform-socket.io": "^12.0.4", + "@nestjs/platform-express": "^11.2.6", + "@nestjs/platform-socket.io": "^11.2.6", "@nestjs/schedule": "^12.0.1", - "@nestjs/serve-static": "^12.0.0", "@nestjs/swagger": "^11.4.7", "@nestjs/terminus": "^11.1.1", "@nestjs/typeorm": "^12.0.1", @@ -129,4 +128,4 @@ } } } -} \ No newline at end of file +} diff --git a/backend/src/achievement/dto/player-achievement.dto.ts b/backend/src/achievement/dto/player-achievement.dto.ts index af5bf06e..14391456 100644 --- a/backend/src/achievement/dto/player-achievement.dto.ts +++ b/backend/src/achievement/dto/player-achievement.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; export class PlayerAchievementDto { diff --git a/backend/src/analytic/analytic.service.ts b/backend/src/analytic/analytic.service.ts index 2adebb60..3f579f77 100644 --- a/backend/src/analytic/analytic.service.ts +++ b/backend/src/analytic/analytic.service.ts @@ -90,7 +90,7 @@ export class AnalyticService { const sql = effectiveLimit !== undefined ? `SELECT puzzle_id, solve_count FROM puzzle_stats_mv ORDER BY solve_count DESC LIMIT $1 OFFSET $2` - : hasOffset + : offset !== undefined ? `SELECT puzzle_id, solve_count FROM puzzle_stats_mv ORDER BY solve_count DESC OFFSET $1` : `SELECT puzzle_id, solve_count FROM puzzle_stats_mv @@ -190,31 +190,6 @@ export class AnalyticService { return result; } - private aggregateFallback(): Array<{ puzzle_id: string; solve_count: number }> { - const counts = new Map(); - for (const row of this.fallbackRows) counts.set(row.puzzle_id, (counts.get(row.puzzle_id) ?? 0) + 1); - return [...counts].map(([puzzle_id, solve_count]) => ({ puzzle_id, solve_count })); - } - - private aggregateFallbackAverage(puzzleId: string): { solve_count: number; total_solve_time: number } { - const rows = this.fallbackRows.filter((row) => row.puzzle_id === puzzleId); - return { solve_count: rows.length, total_solve_time: rows.reduce((sum, row) => sum + row.solve_time, 0) }; - } - - private aggregateFallbackForUser(userId: string) { - const grouped = new Map(); - for (const row of this.fallbackRows.filter((item) => item.user_id === userId)) { - grouped.set(row.puzzle_id, [...(grouped.get(row.puzzle_id) ?? []), row]); - } - return [...grouped].map(([puzzle_id, rows]) => ({ - puzzle_id, - solve_count: rows.length, - attempts: rows.length, - total_solve_time: rows.reduce((sum, row) => sum + row.solve_time, 0), - last_solved: rows.reduce((latest, row) => row.solved_at > latest ? row.solved_at : latest, rows[0].solved_at), - })); - } - /** * Paginated user history, most recently solved first. Pagination is * done in SQL (LIMIT/OFFSET) rather than in memory, so it stays cheap diff --git a/backend/src/analytic/dto/create-analytic.dto.ts b/backend/src/analytic/dto/create-analytic.dto.ts deleted file mode 100644 index b976607c..00000000 --- a/backend/src/analytic/dto/create-analytic.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreateAnalyticsDto {} diff --git a/backend/src/analytic/dto/update-analytic.dto.ts b/backend/src/analytic/dto/update-analytic.dto.ts deleted file mode 100644 index cbccaf3f..00000000 --- a/backend/src/analytic/dto/update-analytic.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateAnalyticsDto } from './create-analytic.dto'; - -export class UpdateAnalyticsDto extends PartialType(CreateAnalyticsDto) {} diff --git a/backend/src/api-key/dto/create-api-key.dto.ts b/backend/src/api-key/dto/create-api-key.dto.ts deleted file mode 100644 index f6212a7d..00000000 --- a/backend/src/api-key/dto/create-api-key.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreateApiKeyDto {} diff --git a/backend/src/api-key/dto/update-api-key.dto.ts b/backend/src/api-key/dto/update-api-key.dto.ts deleted file mode 100644 index b54bc2c9..00000000 --- a/backend/src/api-key/dto/update-api-key.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateApiKeyDto } from './create-api-key.dto'; - -export class UpdateApiKeyDto extends PartialType(CreateApiKeyDto) {} diff --git a/backend/src/auth/controllers/auth.controller.ts b/backend/src/auth/controllers/auth.controller.ts index 7551efe5..31fd73c0 100644 --- a/backend/src/auth/controllers/auth.controller.ts +++ b/backend/src/auth/controllers/auth.controller.ts @@ -18,6 +18,7 @@ import { AuthService } from '../services/auth.service'; import { Auth } from '../decorators/auth-decorator'; import { AuthType } from '../enums/auth-type.enum'; import { AuthResponseDto } from '../dto/auth-response.dto'; +import { GenericAuthMessageDto } from '../dto/generic-auth-message.dto'; import { RegisterDto } from '../dto/register.dto'; import { LoginDto } from '../dto/login.dto'; import { RefreshTokenDto } from '../dto/refresh-token.dto'; diff --git a/backend/src/auth/dto/auth-response.dto.ts b/backend/src/auth/dto/auth-response.dto.ts index 94e8cd2c..40004462 100644 --- a/backend/src/auth/dto/auth-response.dto.ts +++ b/backend/src/auth/dto/auth-response.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; // Step 1: Create a nested DTO for the user diff --git a/backend/src/auth/dto/generic-auth-message.dto.ts b/backend/src/auth/dto/generic-auth-message.dto.ts index dd2e4af5..59b6e0dc 100644 --- a/backend/src/auth/dto/generic-auth-message.dto.ts +++ b/backend/src/auth/dto/generic-auth-message.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from "@nestjs/swagger" // Anti-enumeration response body returned by public auth endpoints when the diff --git a/backend/src/auth/services/auth.service.ts b/backend/src/auth/services/auth.service.ts index 191b291e..06818a95 100644 --- a/backend/src/auth/services/auth.service.ts +++ b/backend/src/auth/services/auth.service.ts @@ -12,6 +12,7 @@ import * as crypto from 'crypto'; import { User } from '../entities/user.entity'; import { RegisterDto } from '../dto/register.dto'; import { AuthResponseDto } from '../dto/auth-response.dto'; +import { GenericAuthMessageDto } from '../dto/generic-auth-message.dto'; import { LoginDto } from '../dto/login.dto'; import { InjectRepository } from '@nestjs/typeorm'; import { UserTokenHistoryService } from '../../user-token-history/services/user-token-history.service'; @@ -90,6 +91,18 @@ export class AuthService { } } + + /** + * Anti-enumeration: the neutral, account-existence-neutral success body + * returned for duplicate registrations (see GenericAuthMessageDto). + */ + private genericRegistrationMessage(): GenericAuthMessageDto { + return { + message: + 'Registration successful. If an account already exists, please log in.', + }; + } + /** * Registers a new user. * diff --git a/backend/src/badge/dto/assign-badge.dto.ts b/backend/src/badge/dto/assign-badge.dto.ts index bc6c00be..d0516c95 100644 --- a/backend/src/badge/dto/assign-badge.dto.ts +++ b/backend/src/badge/dto/assign-badge.dto.ts @@ -1,4 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsPositive } from 'class-validator'; + +// Request body for POST /badges/assign. Both fields are required: without +// decorators the global ValidationPipe (whitelist: true) would strip the +// entire payload and the handler would receive an empty object (issue #529). export class AssignBadgeDto { + @ApiProperty({ description: 'ID of the user to assign the badge to', example: 1 }) + @IsInt() + @IsPositive() userId: number; + + @ApiProperty({ description: 'ID of the badge to assign', example: 1 }) + @IsInt() + @IsPositive() badgeId: number; } diff --git a/backend/src/config/dto-whitelist.spec.ts b/backend/src/config/dto-whitelist.spec.ts new file mode 100644 index 00000000..6dd346fe --- /dev/null +++ b/backend/src/config/dto-whitelist.spec.ts @@ -0,0 +1,48 @@ +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { AssignBadgeDto } from '../badge/dto/assign-badge.dto'; +import { CreateReportCardBodyDto } from '../user-report-card/dto/report-card.dto'; + +// Replicates the global ValidationPipe options configured in main.ts so the +// assertions below exercise exactly what production requests go through +// (issue #529). +const globalPipe = new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, +}); + +const context = { type: 'body' as const }; + +describe('Request DTO whitelisting (issue #529)', () => { + it('rejects an empty body when the request DTO has required fields', async () => { + // Before #529 AssignBadgeDto had no decorators: the pipe silently + // stripped the whole body and the handler saw {}. + await expect( + globalPipe.transform({}, { ...context, metatype: AssignBadgeDto }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects a wrongly-typed field on a required request DTO', async () => { + await expect( + globalPipe.transform( + { userId: 'not-a-number', badgeId: 1 }, + { ...context, metatype: AssignBadgeDto }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('accepts an empty body for an all-optional request DTO but rejects unknown fields', async () => { + // The report-card create endpoint takes its user id from the route + // param; the body only carries optional overrides. + await expect( + globalPipe.transform({}, { ...context, metatype: CreateReportCardBodyDto }), + ).resolves.toBeInstanceOf(CreateReportCardBodyDto); + + await expect( + globalPipe.transform( + { userId: 42 }, + { ...context, metatype: CreateReportCardBodyDto }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/backend/src/hint/create-hint.dto.ts b/backend/src/hint/create-hint.dto.ts deleted file mode 100644 index d6cf0d14..00000000 --- a/backend/src/hint/create-hint.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class CreateHintDto { - puzzleId: string; - content: string; - unlockTimeInMinutes: number; -} diff --git a/backend/src/in-app-notifications/dto/notification-response.dto.ts b/backend/src/in-app-notifications/dto/notification-response.dto.ts index b28df2f6..0ba0c194 100644 --- a/backend/src/in-app-notifications/dto/notification-response.dto.ts +++ b/backend/src/in-app-notifications/dto/notification-response.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; import { InAppNotificationType } from '../entities/in-app-notification.entity'; diff --git a/backend/src/main.ts b/backend/src/main.ts index 04d99c67..a1476355 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,9 +4,8 @@ import { NestFactory } from '@nestjs/core'; import helmet from 'helmet'; import { AppModule } from './app.module'; import { DOCS_ROUTE_EXCLUSIONS, buildApiPrefix } from './api-prefix'; -import { API_DOC_PATH, buildSwaggerConfig } from './swagger'; -import { securityHeadersConfig } from './security-headers'; import { setupSwagger } from './swagger'; +import { securityHeadersConfig } from './security-headers'; /** * Hard limit (ms) we allow the graceful shutdown sequence to take before @@ -31,7 +30,16 @@ async function bootstrap(): Promise { credentials: configService.get('appConfig.cors.credentials') ?? true, }); app.use(helmet()); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + // forbidNonWhitelisted is the settled posture (issue #529): unknown + // properties must be rejected with 400, not silently stripped. The pipe + // spec in src/config/global-validation-pipe.spec.ts asserts this mode. + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); // Respect `appConfig.swagger.enabled` (see backend/config/app.config.ts): // the UI is mounted in development but stays off by default in diff --git a/backend/src/maintenance-mode/dto/maintenance-status.dto.ts b/backend/src/maintenance-mode/dto/maintenance-status.dto.ts index 265998cb..c32c1d8e 100644 --- a/backend/src/maintenance-mode/dto/maintenance-status.dto.ts +++ b/backend/src/maintenance-mode/dto/maintenance-status.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; export class MaintenanceStatusDto { diff --git a/backend/src/maintenance-mode/maintenance-mode.service.ts b/backend/src/maintenance-mode/maintenance-mode.service.ts index 7f2dba37..ed34c4c3 100644 --- a/backend/src/maintenance-mode/maintenance-mode.service.ts +++ b/backend/src/maintenance-mode/maintenance-mode.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; import type { Repository } from 'typeorm'; import { MaintenanceConfig } from './entities/maintenance-config.entity'; import type { UpdateMaintenanceConfigDto } from './dto/maintenance-config.dto'; diff --git a/backend/src/milestone/dto/milestone-achievement.dto.ts b/backend/src/milestone/dto/milestone-achievement.dto.ts index 844e8acf..d8fe090b 100644 --- a/backend/src/milestone/dto/milestone-achievement.dto.ts +++ b/backend/src/milestone/dto/milestone-achievement.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import type { MilestoneCategory, MilestoneType, diff --git a/backend/src/multiplayer-queue/dto/match-result.dto.ts b/backend/src/multiplayer-queue/dto/match-result.dto.ts index 2ed61900..ed344e69 100644 --- a/backend/src/multiplayer-queue/dto/match-result.dto.ts +++ b/backend/src/multiplayer-queue/dto/match-result.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; import { MatchStatus } from '../entities/match.entity'; diff --git a/backend/src/multiplayer-queue/dto/queue-stats.dto.ts b/backend/src/multiplayer-queue/dto/queue-stats.dto.ts index be1a195e..13c5bf99 100644 --- a/backend/src/multiplayer-queue/dto/queue-stats.dto.ts +++ b/backend/src/multiplayer-queue/dto/queue-stats.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; export class QueueStatsDto { diff --git a/backend/src/multiplayer-queue/dto/queue-status.dto.ts b/backend/src/multiplayer-queue/dto/queue-status.dto.ts index 80aca1a0..188d484e 100644 --- a/backend/src/multiplayer-queue/dto/queue-status.dto.ts +++ b/backend/src/multiplayer-queue/dto/queue-status.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; import { QueueStatus, SkillLevel } from '../entities/queue.entity'; diff --git a/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts b/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts index b46b205f..50cc6aa5 100644 --- a/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts +++ b/backend/src/multiplayer-queue/multiplayer-queue.gateway.ts @@ -12,6 +12,7 @@ import { Server, Socket } from 'socket.io' import type { MultiplayerQueueService } from './multiplayer-queue.service' import type { JoinQueueDto } from './dto/join-queue.dto' import { QueueStatusDto } from './dto/queue-status.dto' +import { MatchResultDto } from './dto/match-result.dto'; import { WsRateLimitGuard } from '../common/guards/ws-rate-limit.guard' import { WsRateLimit } from '../common/decorators/ws-rate-limit.decorator' @@ -41,6 +42,20 @@ export class MultiplayerGateway this.logger.log(`Client disconnected from multiplayer: ${client.id}`) } + /** + * Broadcast a newly created match to all connected multiplayer clients so + * the matched players leave the queue immediately without polling. Used by + * MultiplayerQueueService during matchmaking (see issue #529 workspace + * compile fixes; the service has always called this method). + */ + notifyMatchCreated(match: MatchResultDto): void { + if (!this.server) { + this.logger.warn('Socket server not ready; skipping matchCreated broadcast'); + return; + } + this.server.emit('matchCreated', match); + } + /** * Handle a player joining the matchmaking queue. */ diff --git a/backend/src/multiplayer-queue/multiplayer-queue.service.ts b/backend/src/multiplayer-queue/multiplayer-queue.service.ts index 4369156d..be0e77b7 100644 --- a/backend/src/multiplayer-queue/multiplayer-queue.service.ts +++ b/backend/src/multiplayer-queue/multiplayer-queue.service.ts @@ -13,7 +13,7 @@ import type { JoinQueueDto } from './dto/join-queue.dto'; import type { QueueStatusDto } from './dto/queue-status.dto'; import type { MatchResultDto } from './dto/match-result.dto'; import type { QueueStatsDto } from './dto/queue-stats.dto'; -import { MultiplayerQueueGateway } from './multiplayer-queue.gateway'; +import { MultiplayerGateway } from './multiplayer-queue.gateway'; @Injectable() export class MultiplayerQueueService { @@ -25,7 +25,7 @@ export class MultiplayerQueueService { @InjectRepository(Match) private readonly matchRepository: Repository, private readonly dataSource: DataSource, - private readonly gateway: MultiplayerQueueGateway, + private readonly gateway: MultiplayerGateway, ) {} /** diff --git a/backend/src/nft-claim/providers/stellar-handler.service.ts b/backend/src/nft-claim/providers/stellar-handler.service.ts index 40a809a0..86e96e24 100644 --- a/backend/src/nft-claim/providers/stellar-handler.service.ts +++ b/backend/src/nft-claim/providers/stellar-handler.service.ts @@ -205,23 +205,156 @@ export class StellarHandlerService { }; } - private async realClaimNFT(claimNFTDto: ClaimNFTDto): Promise { - const rpcUrl = this.validateRpcUrl(); - this.logger.log(`Processing live Stellar NFT claim using RPC at ${rpcUrl}`); - // TODO: Wire up `@stellar/stellar-sdk` here. Sketch: - // const server = new StellarSdk.SorobanRpc.Server(rpcUrl); - // const contract = new StellarSdk.Contract(process.env.SOROBAN_NFT_CONTRACT_ID); - // const tx = new StellarSdk.TransactionBuilder(...) - // .addOperation(contract.call('mint_level_badge', ...)) - // .setTimeout(30).build(); - // const result = await server.sendTransaction(await tx.sign(...)); - // return { status: 'success', transactionId: result.hash, ...claimNFTDto }; - // - // For now, simulate random failures so integration tests cover the error paths. - const randomError = Math.random(); - if (randomError < 0.3) { - throw new BadRequestException('Invalid NFT claim parameters'); - } else if (randomError < 0.6) { + /** + * Parse the on-chain level from the request's `nftId`. Accepts the bare + * level (`easy`), prefixed snake/camel forms (`level_easy`, `nft-easy`, + * `LevelEasy`), or ids that end in the level (`badge-easy`). Anything + * else is a permanent input error. + */ + private parseLevel(rawNftId: string): LevelName { + const normalized = (rawNftId || '').trim().toLowerCase().replace(/[\s_-]+/g, ''); + for (const level of Object.keys(LEVEL_CODES)) { + if (normalized.endsWith(level)) { + return level as LevelName; + } + } + throw new ClaimRejectedError( + `nftId "${rawNftId}" does not encode a known level (expected one of: ${Object.keys(LEVEL_CODES).join(', ')})`, + 'invalid_claim', + ); + } + + /** + * Resolve the on-chain recipient. In live mode the mint needs a real + * Stellar account id; the caller passes it as `userId` (a `G...` + * Ed25519 account id). Any other shape is a permanent input error — + * guessing a recipient for an on-chain mint would mint to the wrong + * account. + */ + private resolveRecipient(userId: string): string { + const candidate = (userId || '').trim(); + try { + Keypair.fromPublicKey(candidate); + return candidate; + } catch { + throw new ClaimRejectedError( + 'userId must be the recipient\'s Stellar account id (G...) in live mode', + 'invalid_claim', + ); + } + } + + /** + * Read and validate the live-mode configuration. A missing or malformed + * value is a server-side misconfiguration (retryable 500), while a + * structurally invalid claim is a 400 — the two must not be confused. + */ + private loadLiveConfig(): { + server: rpc.Server; + custodian: Keypair; + nftContractId: string; + networkPassphrase: string; + feeStroops: string; + } { + const rpcUrl = process.env.SOROBAN_RPC_URL; + if (!rpcUrl) { + throw new InternalServerErrorException( + 'SOROBAN_RPC_URL is not configured; cannot submit claims in live mode', + ); + } + this.validateRpcUrl(); + + const custodianSecret = + process.env.STELLAR_CUSTODIAN_SECRET_KEY || + process.env.STELLAR_SECRET_KEY; + if (!custodianSecret) { + throw new InternalServerErrorException( + 'STELLAR_CUSTODIAN_SECRET_KEY is not configured; the backend cannot sign mint transactions', + ); + } + let custodian: Keypair; + try { + custodian = Keypair.fromSecret(custodianSecret); + } catch { + throw new InternalServerErrorException( + 'STELLAR_CUSTODIAN_SECRET_KEY is not a valid Stellar secret key', + ); + } + + const nftContractId = process.env.SOROBAN_NFT_CONTRACT_ID; + if (!nftContractId) { + throw new InternalServerErrorException( + 'SOROBAN_NFT_CONTRACT_ID is not configured; cannot target the NFT contract', + ); + } + + const networkPassphrase = + process.env.STELLAR_NETWORK_PASSPHRASE || Networks.TESTNET; + + const feeStroops = ( + Number(process.env.SOROBAN_TX_FEE_STROOPS) || DEFAULT_TX_FEE_STROOPS + ).toString(); + + const server = new rpc.Server(rpcUrl, { allowHttp: DEV_RPC_HOSTS.some((h) => rpcUrl.includes(h)) }); + + return { server, custodian, nftContractId, networkPassphrase, feeStroops }; + } + + /** + * Build, sign, submit and confirm a real `mint_level_badge` invocation. + * + * Failure classification (deterministic, issue #486): + * - input/config problems before submission: invalid claim -> `BadRequest` + * (permanent), server misconfiguration -> `InternalServerError`. + * - submission transport errors (network down, RPC 5xx): + * `InternalServerError` — transient, safe to retry because a duplicate + * resubmission is detected by the RPC (same source account + sequence) + * and resolves to the original transaction. + * - `sendTransaction` returning `ERROR`, or a confirmed `FAILED` + * transaction: `ClaimRejectedError` (a `BadRequestException`) — the + * contract or host deterministically rejected this invocation. + * - no confirmation before the deadline: a `pending` result, not an + * error — the transaction is still in flight and must not be retried + * into a double mint. + */ + private async realClaimNFT(claimNFTDto: ClaimNFTDto): Promise { + this.logger.log('Processing live Stellar NFT claim'); + + const level = this.parseLevel(claimNFTDto.nftId); + const recipient = this.resolveRecipient(claimNFTDto.userId); + const { server, custodian, nftContractId, networkPassphrase, feeStroops } = + this.loadLiveConfig(); + + const base: NftClaimResult = { + status: 'pending', + transactionId: '', + userId: claimNFTDto.userId, + nftId: claimNFTDto.nftId, + contractId: nftContractId, + level, + recipient, + }; + + // 1. Build and sign the mint invocation. + let signedTx: ReturnType; + try { + const sourceAccount = await server.getAccount(custodian.publicKey()); + const operation = new Contract(nftContractId).call( + 'mint_level_badge', + nativeToScVal(custodian.publicKey(), { type: 'address' }), + nativeToScVal(recipient, { type: 'address' }), + nativeToScVal(LEVEL_CODES[level], { type: 'u32' }), + ); + signedTx = new TransactionBuilder(sourceAccount, { + fee: feeStroops, + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(TX_TIMEOUT_SECONDS) + .build(); + signedTx.sign(custodian); + } catch (error) { + if (error instanceof ClaimRejectedError) throw error; throw new InternalServerErrorException( `Failed to build or sign the mint transaction: ${(error as Error).message}`, ); diff --git a/backend/src/progress/dto/create-progress.dto.ts b/backend/src/progress/dto/create-progress.dto.ts deleted file mode 100644 index 53914d48..00000000 --- a/backend/src/progress/dto/create-progress.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreateProgressDto {} diff --git a/backend/src/progress/dto/progress-response.dto.ts b/backend/src/progress/dto/progress-response.dto.ts index def3a32c..0eb88d3d 100644 --- a/backend/src/progress/dto/progress-response.dto.ts +++ b/backend/src/progress/dto/progress-response.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; export class ProgressResponseDto { diff --git a/backend/src/progress/dto/update-progress.dto.ts b/backend/src/progress/dto/update-progress.dto.ts deleted file mode 100644 index 88ee4236..00000000 --- a/backend/src/progress/dto/update-progress.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateProgressDto } from './create-progress.dto'; - -export class UpdateProgressDto extends PartialType(CreateProgressDto) {} diff --git a/backend/src/puzzle-comment/dto/create-puzzle-comment.dto.ts b/backend/src/puzzle-comment/dto/create-puzzle-comment.dto.ts deleted file mode 100644 index 784c9747..00000000 --- a/backend/src/puzzle-comment/dto/create-puzzle-comment.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreatePuzzleCommentDto {} diff --git a/backend/src/puzzle-comment/dto/update-puzzle-comment.dto.ts b/backend/src/puzzle-comment/dto/update-puzzle-comment.dto.ts deleted file mode 100644 index 9185c97b..00000000 --- a/backend/src/puzzle-comment/dto/update-puzzle-comment.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreatePuzzleCommentDto } from './create-puzzle-comment.dto'; - -export class UpdatePuzzleCommentDto extends PartialType( - CreatePuzzleCommentDto, -) {} diff --git a/backend/src/puzzle-draft/draft-puzzle.controller.ts b/backend/src/puzzle-draft/draft-puzzle.controller.ts index cd8f1f8e..5e201fc6 100644 --- a/backend/src/puzzle-draft/draft-puzzle.controller.ts +++ b/backend/src/puzzle-draft/draft-puzzle.controller.ts @@ -16,10 +16,6 @@ import { Roles } from '../admin/roles.decorator'; import { DraftPuzzleService } from './draft-puzzle.service'; import { CreateDraftDto } from './dto/create-draft.dto'; import { UpdateDraftDto } from './dto/update-draft.dto'; -// Assume AuthGuard is set up to handle roles like admin/contributor -import { JwtAuthGuard as AuthGuard } from '../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../common/gaurds/roles.gaurds'; -import { Roles } from '../common/decorators/roles.decorator'; // The draft workflow is an admin-only concern: creating, curating and // publishing puzzle drafts requires an authenticated admin account. diff --git a/backend/src/puzzle-draft/dto/update-draft.dto.ts b/backend/src/puzzle-draft/dto/update-draft.dto.ts index 47fe4cb3..1c46f87d 100644 --- a/backend/src/puzzle-draft/dto/update-draft.dto.ts +++ b/backend/src/puzzle-draft/dto/update-draft.dto.ts @@ -1,4 +1,14 @@ import { PartialType } from '@nestjs/mapped-types'; +import { IsIn, IsOptional } from 'class-validator'; import { CreateDraftDto } from './create-draft.dto'; -export class UpdateDraftDto extends PartialType(CreateDraftDto) {} +/** + * Request body for PATCH /drafts/:id (issue #529: request DTOs must carry + * class-validator decorators). Status changes are constrained to the draft + * workflow; the service enforces the allowed transition matrix on top. + */ +export class UpdateDraftDto extends PartialType(CreateDraftDto) { + @IsOptional() + @IsIn(['draft', 'review', 'approved', 'published']) + status?: string; +} diff --git a/backend/src/puzzle-submission/puzzle-submission.module.ts b/backend/src/puzzle-submission/puzzle-submission.module.ts index faff03ae..bc70c74e 100644 --- a/backend/src/puzzle-submission/puzzle-submission.module.ts +++ b/backend/src/puzzle-submission/puzzle-submission.module.ts @@ -10,6 +10,5 @@ import { Puzzle } from '../puzzle/puzzle.entity'; imports: [TypeOrmModule.forFeature([PuzzleSubmission]), RateLimiterModule], providers: [PuzzleSubmissionService], controllers: [PuzzleSubmissionController], - providers: [PuzzleSubmissionService], }) export class PuzzleSubmissionModule {} diff --git a/backend/src/quiz/dto/quiz-result.dto.ts b/backend/src/quiz/dto/quiz-result.dto.ts index 8c97ef6e..1ceae586 100644 --- a/backend/src/quiz/dto/quiz-result.dto.ts +++ b/backend/src/quiz/dto/quiz-result.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. export class QuestionResultDto { questionId: string; question: string; diff --git a/backend/src/referral/dto/referral-stats.dto.ts b/backend/src/referral/dto/referral-stats.dto.ts index 0bb12fb5..f5442a54 100644 --- a/backend/src/referral/dto/referral-stats.dto.ts +++ b/backend/src/referral/dto/referral-stats.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. export class ReferralStatsDto { totalInvites: number; successfulInvites: number; diff --git a/backend/src/report/report.controller.ts b/backend/src/report/report.controller.ts index fd11b892..f3ff8d49 100644 --- a/backend/src/report/report.controller.ts +++ b/backend/src/report/report.controller.ts @@ -14,8 +14,10 @@ import { import { ReportService } from './report.service'; import { CreateReportDto } from './dto/create-report.dto'; import { UpdateReportDto } from './dto/update-report.dto'; -import { Roles } from '../common/decorators/roles.decorator'; import { RolesGuard } from '../common/gaurds/roles.gaurds'; +import { JwtAuthGuard } from '../admin/guards/jwt-auth.guard'; +import { AdminRole } from '../admin/admin-role.enum'; +import { Roles } from '../admin/roles.decorator'; @Controller('report') // Stricter than the global policy (issue #340): report payloads are small, diff --git a/backend/src/reward-shop/dto/create-reward-shop.dto.ts b/backend/src/reward-shop/dto/create-reward-shop.dto.ts deleted file mode 100644 index a6f9af32..00000000 --- a/backend/src/reward-shop/dto/create-reward-shop.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreateRewardShopDto {} diff --git a/backend/src/reward-shop/dto/update-reward-shop.dto.ts b/backend/src/reward-shop/dto/update-reward-shop.dto.ts deleted file mode 100644 index fda9ad10..00000000 --- a/backend/src/reward-shop/dto/update-reward-shop.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateRewardShopDto } from './create-reward-shop.dto'; - -export class UpdateRewardShopDto extends PartialType(CreateRewardShopDto) {} diff --git a/backend/src/reward-shop/reward-shop.controller.ts b/backend/src/reward-shop/reward-shop.controller.ts index bd6090ea..69419bac 100644 --- a/backend/src/reward-shop/reward-shop.controller.ts +++ b/backend/src/reward-shop/reward-shop.controller.ts @@ -9,6 +9,7 @@ import { HttpStatus, Logger, BadRequestException, + UseGuards, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { OwnershipGuard } from '../common/guards/ownership.guard'; diff --git a/backend/src/streak/controllers/streak.controller.ts b/backend/src/streak/controllers/streak.controller.ts index 4d3d253b..569d91f8 100644 --- a/backend/src/streak/controllers/streak.controller.ts +++ b/backend/src/streak/controllers/streak.controller.ts @@ -3,7 +3,6 @@ import { Controller, Get, Post, - Body, Param, Query, HttpStatus, diff --git a/backend/src/streak/dto/streak-stats.dto.ts b/backend/src/streak/dto/streak-stats.dto.ts index 43bd169d..d0200e27 100644 --- a/backend/src/streak/dto/streak-stats.dto.ts +++ b/backend/src/streak/dto/streak-stats.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import type { ActivityType } from '../entities/streak-activity.entity'; export class StreakStatsDto { diff --git a/backend/src/streak/services/streak.service.ts b/backend/src/streak/services/streak.service.ts index 614f587b..e50f401a 100644 --- a/backend/src/streak/services/streak.service.ts +++ b/backend/src/streak/services/streak.service.ts @@ -4,7 +4,7 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import type { Repository } from 'typeorm'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; import { Streak } from '../entities/streak.entity'; import { StreakActivity } from '../entities/streak-activity.entity'; import type { ActivityType } from '../entities/streak-activity.entity'; @@ -53,6 +53,7 @@ export class StreakService { userId: string, recordDto: RecordActivityDto, config: Partial = {}, + manager?: EntityManager, ): Promise { // The server clock is the source of truth for "today". Client-supplied // dates are only accepted for backfilling past activity — future dates diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index 6a2cdd2b..783bdf3e 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -17,6 +17,18 @@ export function buildSwaggerConfig(configService: ConfigService) { .build(); } +/** + * Builds the full OpenAPI document from the application's route/controller + * graph without initializing it (no database connection is opened). Used by + * `scripts/generate-openapi.ts` to emit `docs/openapi.json` in CI (issue #555). + */ +export function buildOpenApiDocument( + app: INestApplication, + configService: ConfigService, +): ReturnType { + return SwaggerModule.createDocument(app, buildSwaggerConfig(configService)); +} + /** * Registers the Swagger UI (and its `/docs-json` document endpoint) only * when `appConfig.swagger.enabled` is true. diff --git a/backend/src/user-inventory/dto/create-user-inventory.dto.ts b/backend/src/user-inventory/dto/create-user-inventory.dto.ts deleted file mode 100644 index b9026525..00000000 --- a/backend/src/user-inventory/dto/create-user-inventory.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreateUserInventoryDto {} diff --git a/backend/src/user-inventory/dto/update-user-inventory.dto.ts b/backend/src/user-inventory/dto/update-user-inventory.dto.ts deleted file mode 100644 index ba7e8816..00000000 --- a/backend/src/user-inventory/dto/update-user-inventory.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateUserInventoryDto } from './create-user-inventory.dto'; - -export class UpdateUserInventoryDto extends PartialType( - CreateUserInventoryDto, -) {} diff --git a/backend/src/user-ranking/dto/create-user-ranking.dto.ts b/backend/src/user-ranking/dto/create-user-ranking.dto.ts index 9252bd4b..85d8d97d 100644 --- a/backend/src/user-ranking/dto/create-user-ranking.dto.ts +++ b/backend/src/user-ranking/dto/create-user-ranking.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; export class CreateUserRankingDto { diff --git a/backend/src/user-ranking/dto/update-user-ranking.dto.ts b/backend/src/user-ranking/dto/update-user-ranking.dto.ts deleted file mode 100644 index 093c063a..00000000 --- a/backend/src/user-ranking/dto/update-user-ranking.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { UserRankDto } from './create-user-ranking.dto'; - -export class UpdateUserRankingDto extends PartialType(UserRankDto) {} diff --git a/backend/src/user-reaction/dto/reaction-aggregation.dto.ts b/backend/src/user-reaction/dto/reaction-aggregation.dto.ts index c00eff20..348e5b80 100644 --- a/backend/src/user-reaction/dto/reaction-aggregation.dto.ts +++ b/backend/src/user-reaction/dto/reaction-aggregation.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; export class ReactionAggregationDto { diff --git a/backend/src/user-report-card/dto/create-user-report-card.dto.ts b/backend/src/user-report-card/dto/create-user-report-card.dto.ts deleted file mode 100644 index eaf3dc2d..00000000 --- a/backend/src/user-report-card/dto/create-user-report-card.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreateUserReportCardDto {} diff --git a/backend/src/user-report-card/dto/report-card.dto.ts b/backend/src/user-report-card/dto/report-card.dto.ts index 62505bbe..66a2b822 100644 --- a/backend/src/user-report-card/dto/report-card.dto.ts +++ b/backend/src/user-report-card/dto/report-card.dto.ts @@ -63,6 +63,21 @@ export class ReportCardDto { updatedAt: Date; } +// Request-body DTO for POST :id/report-card. The user id comes from the +// route param and is set by the controller, so the body itself is entirely +// optional overrides (issue #529). +export class CreateReportCardBodyDto { + @ApiProperty({ description: 'Number of completed puzzles', required: false }) + @IsOptional() + @IsNumber() + completedPuzzles?: number; + + @ApiProperty({ description: 'Number of rewards earned', required: false }) + @IsOptional() + @IsNumber() + rewardsEarned?: number; +} + export class CreateReportCardDto { @ApiProperty({ description: 'User ID for the report card' }) @IsString() diff --git a/backend/src/user-report-card/dto/update-user-report-card.dto.ts b/backend/src/user-report-card/dto/update-user-report-card.dto.ts deleted file mode 100644 index 3bb21044..00000000 --- a/backend/src/user-report-card/dto/update-user-report-card.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateUserReportCardDto } from './create-user-report-card.dto'; - -export class UpdateUserReportCardDto extends PartialType( - CreateUserReportCardDto, -) {} diff --git a/backend/src/user-report-card/user-report-card.controller.ts b/backend/src/user-report-card/user-report-card.controller.ts index ecae55f7..dfb80c67 100644 --- a/backend/src/user-report-card/user-report-card.controller.ts +++ b/backend/src/user-report-card/user-report-card.controller.ts @@ -21,7 +21,7 @@ import { ApiQuery, } from '@nestjs/swagger'; import { UserReportCardService } from './user-report-card.service'; -import { ReportCardDto, CreateReportCardDto } from './dto/report-card.dto'; +import { ReportCardDto, CreateReportCardBodyDto } from './dto/report-card.dto'; @ApiTags('User Report Cards') @Controller('users') @@ -78,9 +78,9 @@ export class UserReportCardController { }) async createUserReportCard( @Param('id') userId: string, - @Body() createDto: Partial = {}, + @Body() createDto: CreateReportCardBodyDto = new CreateReportCardBodyDto(), ): Promise { - const reportCardData: CreateReportCardDto = { + const reportCardData = { userId, ...createDto, }; diff --git a/backend/src/user-settings/dto/settings-categories.dto.ts b/backend/src/user-settings/dto/settings-categories.dto.ts index 23bbe9eb..84401a31 100644 --- a/backend/src/user-settings/dto/settings-categories.dto.ts +++ b/backend/src/user-settings/dto/settings-categories.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; export class SettingsCategoriesDto { diff --git a/backend/src/user-settings/dto/user-settings-response.dto.ts b/backend/src/user-settings/dto/user-settings-response.dto.ts index cb1bdd41..4d585b6b 100644 --- a/backend/src/user-settings/dto/user-settings-response.dto.ts +++ b/backend/src/user-settings/dto/user-settings-response.dto.ts @@ -1,3 +1,5 @@ +// Response-only DTO (issue #529): this shape is never bound as a @Body() +// request type, so it carries no class-validator decorators by design. import { ApiProperty } from '@nestjs/swagger'; import { Language, diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 4845a3d5..c6ff4198 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -8,6 +8,8 @@ import { Param, Request, UseGuards, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { ApiTags, diff --git a/frontend/app/admin/puzzle-submission/page.jsx b/frontend/app/admin/puzzle-submission/page.jsx index 2af36dbf..a4b1dffc 100644 --- a/frontend/app/admin/puzzle-submission/page.jsx +++ b/frontend/app/admin/puzzle-submission/page.jsx @@ -1,7 +1,14 @@ "use client"; import React, { useState } from "react"; -import { apiClient } from "../../../lib/api"; +import { useApiMutation } from "../../../hooks/useApiMutation"; +import { + buildDraftPayload, + createDraft, + extractFieldErrors, + publishDraft, + updateDraft, +} from "../../../services/puzzleDraftService"; const difficulties = ["Easy", "Medium", "Hard", "Expert"]; @@ -13,55 +20,139 @@ export default function AdminPuzzleSubmission() { difficulty: difficulties[0], nftMetadata: "", }); + // `null` when no draft exists yet; a string draft id once saved. Its + // presence switches the form between "create" and "update" mode. + const [draftId, setDraftId] = useState(null); const [status, setStatus] = useState(null); - const [submitting, setSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + // Local busy flag rather than the mutation's `isLoading`: it covers both + // mutations (including the save-then-publish fallback) and stays + // independent of the react-query result naming across versions. + const [busy, setBusy] = useState(false); const handleChange = (e) => { const { name, value } = e.target; setForm((prev) => ({ ...prev, [name]: value })); }; - const handleSubmit = async (e) => { + const resetAfterPublish = () => { + setForm({ + title: "", + description: "", + answer: "", + difficulty: difficulties[0], + nftMetadata: "", + }); + setDraftId(null); + }; + + // Shared mutation hook: consistent retry/error surface, no query cache to + // invalidate because draft state is local to this form. + const saveMutation = useApiMutation({ + fn: async ({ payload }) => { + if (draftId) { + return updateDraft(draftId, payload); + } + const created = await createDraft(payload); + setDraftId(created.id); + return created; + }, + onError: (error) => { + setFieldErrors(extractFieldErrors(error)); + setStatus("Failed to save draft."); + }, + }); + + const publishMutation = useApiMutation({ + fn: async () => { + if (!draftId) { + // The publish endpoint needs a persisted draft; save first. + const { payload, metadataError } = buildDraftPayload(form); + if (metadataError) { + const error = new Error(metadataError); + error.data = { message: [metadataError] }; + throw error; + } + const created = await createDraft(payload); + setDraftId(created.id); + return publishDraft(created.id); + } + return publishDraft(draftId); + }, + onError: (error) => { + setFieldErrors(extractFieldErrors(error)); + setStatus("Failed to publish draft."); + }, + }); + + const handleSave = async (e) => { e.preventDefault(); - setSubmitting(true); setStatus(null); - + setFieldErrors({}); + const { payload, metadataError } = buildDraftPayload(form); + if (metadataError) { + setFieldErrors({ form: metadataError }); + return; + } + setBusy(true); try { - await apiClient.post("/admin/puzzles", { - title: form.title.trim(), - description: form.description.trim(), - difficulty: form.difficulty.toLowerCase(), - hint: form.nftMetadata.trim() || undefined, - solution: form.answer.trim(), - isActive: true, - }); + await saveMutation.mutateAsync({ payload }); + } catch { + // The error surface (status + field errors) is set in onError. + } finally { + setBusy(false); + } + }; - setForm({ - title: "", - description: "", - answer: "", - difficulty: difficulties[0], - nftMetadata: "", - }); - setStatus("Puzzle submitted successfully."); - } catch (error) { - setStatus(error.message || "Failed to submit puzzle."); + const handlePublish = async () => { + setStatus(null); + setFieldErrors({}); + const { payload, metadataError } = buildDraftPayload(form); + if (metadataError) { + setFieldErrors({ form: metadataError }); + return; + } + setBusy(true); + try { + const published = await publishMutation.mutateAsync(); + if (published) { + setStatus("Draft published successfully."); + resetAfterPublish(); + } + } catch { + // The error surface (status + field errors) is set in onError. } finally { - setSubmitting(false); + setBusy(false); } }; + const submitting = busy || saveMutation.isLoading || publishMutation.isLoading; + // The entered content is never cleared on failure: the form state is only + // reset in `resetAfterPublish`, which runs after a successful publish. + const showError = (field) => + fieldErrors[field] ? ( +

{fieldErrors[field]}

+ ) : null; + return (
-

+

Submit New Puzzle

-
+ {draftId && ( +

+ Draft saved — you can update it or publish it. +

+ )} +
- + + {showError("title")}
-