From 800b9f6c2d3afb99303279f607d40420c8df9bdf Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 4 Feb 2026 21:37:57 -0800 Subject: [PATCH] feat: ability to edit suggestions when accepting --- api/src/app/routes/suggestions/index.ts | 34 +++ api/src/app/services/suggestion.service.ts | 89 +++++++ .../admin/admin-suggestions.component.ts | 251 +++++++++++++++++- apps/frontend/src/app/services/api.service.ts | 16 +- .../src/app/services/suggestion.service.ts | 4 + 5 files changed, 383 insertions(+), 11 deletions(-) diff --git a/api/src/app/routes/suggestions/index.ts b/api/src/app/routes/suggestions/index.ts index 7d191dd..c94631d 100644 --- a/api/src/app/routes/suggestions/index.ts +++ b/api/src/app/routes/suggestions/index.ts @@ -137,6 +137,40 @@ export default async function (app: FastifyInstance): Promise { } ); + // Accept a suggestion with edits (admin only) + app.put<{ Params: { id: string }; Body: any }>( + "/:id/accept-with-edits", + { + preHandler: [app.authenticate, adminGuard, app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + const editedData = request.body; + + try { + const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData); + + await AuditService.log( + { + action: AuditAction.ENTRY_UPDATE, + category: AuditCategory.ADMIN, + resourceType: "Suggestion", + resourceId: suggestion.id, + details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`, + success: true, + }, + request + ); + + reply.send(suggestion); + } catch (error) { + return reply.badRequest( + error instanceof Error ? error.message : "Failed to accept suggestion with edits" + ); + } + } + ); + // Decline a suggestion (admin only) app.put<{ Params: { id: string }; Body: DeclineSuggestionDto }>( "/:id/decline", diff --git a/api/src/app/services/suggestion.service.ts b/api/src/app/services/suggestion.service.ts index b62866c..da4d677 100644 --- a/api/src/app/services/suggestion.service.ts +++ b/api/src/app/services/suggestion.service.ts @@ -340,6 +340,95 @@ export const SuggestionService = { return mapSuggestion(updatedSuggestion); }, + async acceptSuggestionWithEdits(id: string, editedData: any): Promise { + const suggestion = await prisma.suggestion.findUnique({ + where: { id }, + include: { user: true }, + }); + + if (!suggestion) { + throw new Error("Suggestion not found"); + } + + if (suggestion.status !== "UNREVIEWED") { + throw new Error("Suggestion has already been reviewed"); + } + + // Create the entity based on type with edited data + switch (suggestion.entityType) { + case "GAME": { + await gameService.createGame({ + title: editedData.title || suggestion.title, + platform: editedData.platform, + status: GameStatus.backlog, + notes: editedData.notes, + coverImage: editedData.coverImage, + }); + break; + } + case "BOOK": { + await bookService.createBook({ + title: editedData.title || suggestion.title, + author: editedData.author || "Unknown", + isbn: editedData.isbn, + status: BookStatus.toRead, + notes: editedData.notes, + coverImage: editedData.coverImage, + }); + break; + } + case "MUSIC": { + await musicService.createMusic({ + title: editedData.title || suggestion.title, + artist: editedData.artist || "Unknown", + type: getMusicType(editedData.type), + status: MusicStatus.wantToListen, + notes: editedData.notes, + coverArt: editedData.coverArt || editedData.coverImage, + }); + break; + } + case "ART": { + await artService.createArt({ + title: editedData.title || suggestion.title, + artist: editedData.artist || "Unknown", + description: editedData.description, + imageUrl: editedData.imageUrl || "", + }); + break; + } + case "SHOW": { + await showService.createShow({ + title: editedData.title || suggestion.title, + type: getShowType(editedData.type), + status: ShowStatus.wantToWatch, + notes: editedData.notes, + coverImage: editedData.coverImage, + }); + break; + } + case "MANGA": { + await mangaService.createManga({ + title: editedData.title || suggestion.title, + author: editedData.author || "Unknown", + status: MangaStatus.wantToRead, + notes: editedData.notes, + coverImage: editedData.coverImage, + }); + break; + } + } + + // Update suggestion status + const updatedSuggestion = await prisma.suggestion.update({ + where: { id }, + data: { status: "ACCEPTED" }, + include: { user: true }, + }); + + return mapSuggestion(updatedSuggestion); + }, + async declineSuggestion(id: string, reason?: string): Promise { const suggestion = await prisma.suggestion.findUnique({ where: { id }, diff --git a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts index 317aaa4..2a7b175 100644 --- a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts +++ b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts @@ -227,6 +227,166 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared- } + + @if (showEditModal()) { + + } `, styles: [` @@ -503,6 +663,14 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared- overflow-y: auto; } + .edit-modal { + max-width: 650px; + } + + .edit-modal form { + margin-top: 1rem; + } + .modal h3 { margin: 0 0 1rem 0; } @@ -549,6 +717,11 @@ export class AdminSuggestionsComponent implements OnInit { decliningsuggestion = signal(null); declineReason = ''; + // Edit modal state + showEditModal = signal(false); + editingSuggestion = signal(null); + editedData: any = {}; + // Pagination state currentPage = signal(1); pageSize = signal(25); @@ -637,15 +810,59 @@ export class AdminSuggestionsComponent implements OnInit { } async acceptSuggestion(suggestion: Suggestion) { - if (!confirm(`Accept "${suggestion.title}"? This will add it to your collection.`)) return; + this.editingSuggestion.set(suggestion); - try { - await this.suggestionService.acceptSuggestion(suggestion.id); - alert(`"${suggestion.title}" has been added to your collection!`); - this.loadSuggestions(); - } catch { - alert('Failed to accept suggestion. Please try again.'); + // Pre-populate the edit form with suggestion data + this.editedData = { + title: suggestion.title, + notes: '', + coverImage: '', + coverArt: '' + }; + + // Add entity-specific data + switch (suggestion.entityType) { + case 'BOOK': + const bookData = suggestion.bookData as any; + this.editedData.author = bookData?.author || ''; + this.editedData.isbn = bookData?.isbn || ''; + this.editedData.notes = bookData?.notes || ''; + this.editedData.coverImage = bookData?.coverImage || ''; + break; + case 'GAME': + const gameData = suggestion.gameData as any; + this.editedData.platform = gameData?.platform || ''; + this.editedData.notes = gameData?.notes || ''; + this.editedData.coverImage = gameData?.coverImage || ''; + break; + case 'MUSIC': + const musicData = suggestion.musicData as any; + this.editedData.artist = musicData?.artist || ''; + this.editedData.type = musicData?.type || 'ALBUM'; + this.editedData.notes = musicData?.notes || ''; + this.editedData.coverArt = musicData?.coverArt || ''; + break; + case 'ART': + const artData = suggestion.artData as any; + this.editedData.artist = artData?.artist || ''; + this.editedData.description = artData?.description || ''; + this.editedData.imageUrl = artData?.imageUrl || ''; + break; + case 'SHOW': + const showData = suggestion.showData as any; + this.editedData.type = showData?.type || 'TV_SERIES'; + this.editedData.notes = showData?.notes || ''; + this.editedData.coverImage = showData?.coverImage || ''; + break; + case 'MANGA': + const mangaData = suggestion.mangaData as any; + this.editedData.author = mangaData?.author || ''; + this.editedData.notes = mangaData?.notes || ''; + this.editedData.coverImage = mangaData?.coverImage || ''; + break; } + + this.showEditModal.set(true); } openDeclineModal(suggestion: Suggestion) { @@ -660,6 +877,26 @@ export class AdminSuggestionsComponent implements OnInit { this.declineReason = ''; } + closeEditModal() { + this.showEditModal.set(false); + this.editingSuggestion.set(null); + this.editedData = {}; + } + + async confirmAcceptWithEdits() { + const suggestion = this.editingSuggestion(); + if (!suggestion) return; + + try { + await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, this.editedData); + alert(`"${this.editedData.title}" has been added to your collection with your edits!`); + this.closeEditModal(); + this.loadSuggestions(); + } catch (error) { + alert('Failed to accept suggestion. Please try again.'); + } + } + async confirmDecline() { const suggestion = this.decliningsuggestion(); if (!suggestion) return; diff --git a/apps/frontend/src/app/services/api.service.ts b/apps/frontend/src/app/services/api.service.ts index ecb4f3f..3cdac3c 100644 --- a/apps/frontend/src/app/services/api.service.ts +++ b/apps/frontend/src/app/services/api.service.ts @@ -75,10 +75,18 @@ export class ApiService { delete(endpoint: string): Observable { return this.ensureCsrfToken().pipe( - switchMap(() => this.http.delete(`${this.apiUrl}${endpoint}`, { - headers: this.getHeaders(), - withCredentials: true - })) + switchMap(() => { + // Don't send Content-Type for DELETE requests as they have no body + const headers: Record = {}; + const token = this.csrfToken(); + if (token) { + headers['X-CSRF-Token'] = token; + } + return this.http.delete(`${this.apiUrl}${endpoint}`, { + headers: new HttpHeaders(headers), + withCredentials: true + }); + }) ); } diff --git a/apps/frontend/src/app/services/suggestion.service.ts b/apps/frontend/src/app/services/suggestion.service.ts index 3427f37..d59455a 100644 --- a/apps/frontend/src/app/services/suggestion.service.ts +++ b/apps/frontend/src/app/services/suggestion.service.ts @@ -49,6 +49,10 @@ export class SuggestionService { return firstValueFrom(this.api.put(`/suggestions/${id}/accept`, {})); } + async acceptSuggestionWithEdits(id: string, data: any): Promise { + return firstValueFrom(this.api.put(`/suggestions/${id}/accept-with-edits`, data)); + } + async declineSuggestion(id: string, data: DeclineSuggestionDto): Promise { return firstValueFrom(this.api.put(`/suggestions/${id}/decline`, data)); }