AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)

This commit was merged in pull request #40.
This commit is contained in:
2026-07-22 20:26:30 +00:00
parent 1a40e5708a
commit 8dcbd48045
41 changed files with 4548 additions and 145 deletions
@@ -9,6 +9,15 @@ import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
/**
* Multipart upload endpoints for site logos and category icons.
*
* Challenge attachments use the staged lifecycle exposed by
* `AdminChallengesController` (`POST /api/v1/admin/challenges/files/stage`),
* which guarantees the file only becomes "real" once the admin saves the
* challenge. The previous direct-to-final challenge-file route was removed
* because it bypassed staging and could overwrite files by name.
*/
@ApiTags('uploads')
@ApiBearerAuth()
@Controller('api/v1/uploads')
@@ -32,15 +41,6 @@ export class UploadsController {
return this.handleCategoryIcon(req);
}
@Post('challenge-file')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Upload a challenge attachment (admin only)' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
async uploadChallengeFile(@Req() req: any) {
return this.handleGenericUpload(req, 'challenges');
}
@Post('logo')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Upload the site logo (admin only)' })
@@ -78,9 +78,6 @@ export class UploadsController {
const finalDir = path.join(this.uploadDir, 'icons');
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
// When categoryId is provided we normalize to 128x128 and store at a
// deterministic URL keyed by id. When it is omitted (legacy / pre-existing
// tests) we fall back to a safe version of the original filename.
const hasId = !!categoryId;
const fileName = hasId ? `${categoryId}.png` : safeFilename(file.originalname || 'file.png');
const finalPath = path.join(finalDir, fileName);
@@ -110,28 +107,6 @@ export class UploadsController {
};
}
private handleGenericUpload(req: any, subdir: string) {
const file = req.file as any;
if (!file) throw new BadRequestException('No file uploaded under field "file"');
if (file.size > this.globalLimit) {
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
}
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9._-]/g, '_');
const finalDir = path.join(this.uploadDir, subdir);
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
const finalPath = path.join(finalDir, safeName);
fs.writeFileSync(finalPath, file.buffer);
const publicUrl = `/uploads/${subdir}/${safeName}`;
return {
id: safeName,
originalFilename: file.originalname,
storedPath: finalPath,
publicUrl,
size: file.size,
mimeType: file.mimetype,
};
}
private async handleLogoUpload(req: any): Promise<{ publicUrl: string; originalFilename: string }> {
const file = req.file as any;
if (!file) throw new BadRequestException('No file uploaded under field "file"');
@@ -149,7 +124,6 @@ export class UploadsController {
throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE);
}
const safeName = safeFilename(file.originalname || 'logo');
// Files live at the top level of UPLOAD_DIR (served directly at /uploads/...).
const finalPath = path.join(this.uploadDir, safeName);
fs.writeFileSync(finalPath, file.buffer);
return {
@@ -157,4 +131,4 @@ export class UploadsController {
originalFilename: file.originalname || safeName,
};
}
}
}