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
@@ -63,6 +63,110 @@ export interface LogoUploadResponse {
originalFilename: string;
}
export interface AdminChallengeFile {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface AdminChallengeListItem {
id: string;
name: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
protocol: 'NC' | 'WEB';
enabled: boolean;
createdAt: string;
updatedAt: string;
fileCount: number;
solveCount: number;
}
export interface AdminChallengeDetail {
id: string;
name: string;
descriptionMd: string;
categoryId: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: 'NC' | 'WEB';
port: number | null;
ipAddress: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
files: AdminChallengeFile[];
}
export interface ChallengeFileManifestPayload {
id?: string;
stagedToken?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
remove?: boolean;
}
export interface CreateChallengeBody {
name: string;
descriptionMd: string;
categoryId: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: 'NC' | 'WEB';
port: number | null;
ipAddress: string;
enabled: boolean;
files?: ChallengeFileManifestPayload[];
}
export interface UpdateChallengeBody {
name?: string;
descriptionMd?: string;
categoryId?: string;
difficulty?: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints?: number;
minimumPoints?: number;
decaySolves?: number;
flag?: string;
protocol?: 'NC' | 'WEB';
port?: number | null;
ipAddress?: string;
enabled?: boolean;
files?: ChallengeFileManifestPayload[];
}
export interface ImportPreviewResponse {
total: number;
conflicts: string[];
unknownCategories: string[];
warnings: string[];
}
export interface ImportResultResponse {
imported: number;
skipped: { name: string; reason: string }[];
warnings: string[];
}
export interface StageChallengeFileResponse {
token: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}
@Injectable({ providedIn: 'root' })
export class AdminService {
private readonly http = inject(HttpClient);
@@ -139,4 +243,99 @@ export class AdminService {
this.http.post<LogoUploadResponse>('/api/v1/uploads/logo', fd, { withCredentials: true }),
);
}
async listChallenges(query: { q?: string; categoryId?: string } = {}): Promise<AdminChallengeListItem[]> {
const params: Record<string, string> = {};
if (query.q && query.q.trim().length > 0) params['q'] = query.q.trim();
if (query.categoryId) params['categoryId'] = query.categoryId;
return firstValueFrom(
this.http.get<AdminChallengeListItem[]>('/api/v1/admin/challenges', {
params,
withCredentials: true,
}),
);
}
async getChallengeDetail(id: string): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.get<AdminChallengeDetail>(
`/api/v1/admin/challenges/${encodeURIComponent(id)}`,
{ withCredentials: true },
),
);
}
async createChallenge(body: CreateChallengeBody): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.post<AdminChallengeDetail>('/api/v1/admin/challenges', body, {
withCredentials: true,
}),
);
}
async updateChallenge(id: string, body: UpdateChallengeBody): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.put<AdminChallengeDetail>(
`/api/v1/admin/challenges/${encodeURIComponent(id)}`,
body,
{ withCredentials: true },
),
);
}
async deleteChallenge(id: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(`/api/v1/admin/challenges/${encodeURIComponent(id)}`, {
withCredentials: true,
}),
);
}
async exportChallenges(): Promise<Blob> {
return firstValueFrom(
this.http.get('/api/v1/admin/challenges/export', {
withCredentials: true,
responseType: 'blob',
}),
);
}
async previewChallengeImport(doc: unknown): Promise<ImportPreviewResponse> {
return firstValueFrom(
this.http.post<ImportPreviewResponse>('/api/v1/admin/challenges/import', doc, {
withCredentials: true,
}),
);
}
async confirmChallengeImport(doc: unknown): Promise<ImportResultResponse> {
return firstValueFrom(
this.http.post<ImportResultResponse>(
'/api/v1/admin/challenges/import?confirm=overwrite',
doc,
{ withCredentials: true },
),
);
}
async stageChallengeFile(file: File): Promise<StageChallengeFileResponse> {
const fd = new FormData();
fd.append('file', file);
return firstValueFrom(
this.http.post<StageChallengeFileResponse>(
'/api/v1/admin/challenges/files/stage',
fd,
{ withCredentials: true },
),
);
}
async discardChallengeFile(token: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(
`/api/v1/admin/challenges/files/stage/${encodeURIComponent(token)}`,
{ withCredentials: true },
),
);
}
}