feat: ability to edit suggestions when accepting

This commit is contained in:
2026-02-04 21:37:57 -08:00
parent 729f410443
commit 800b9f6c2d
5 changed files with 383 additions and 11 deletions
+34
View File
@@ -137,6 +137,40 @@ export default async function (app: FastifyInstance): Promise<void> {
} }
); );
// 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) // Decline a suggestion (admin only)
app.put<{ Params: { id: string }; Body: DeclineSuggestionDto }>( app.put<{ Params: { id: string }; Body: DeclineSuggestionDto }>(
"/:id/decline", "/:id/decline",
@@ -340,6 +340,95 @@ export const SuggestionService = {
return mapSuggestion(updatedSuggestion); return mapSuggestion(updatedSuggestion);
}, },
async acceptSuggestionWithEdits(id: string, editedData: any): Promise<Suggestion> {
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<Suggestion> { async declineSuggestion(id: string, reason?: string): Promise<Suggestion> {
const suggestion = await prisma.suggestion.findUnique({ const suggestion = await prisma.suggestion.findUnique({
where: { id }, where: { id },
@@ -227,6 +227,166 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</div> </div>
</div> </div>
} }
@if (showEditModal()) {
<div class="modal-overlay" (click)="closeEditModal()">
<div class="modal edit-modal" (click)="$event.stopPropagation()">
<h3>Review & Edit Before Accepting</h3>
<p>Review and edit the details before adding to your collection.</p>
@if (editingSuggestion()) {
<form (ngSubmit)="confirmAcceptWithEdits()">
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editedData.title"
name="title"
required
>
</div>
@switch (editingSuggestion()!.entityType) {
@case ('BOOK') {
<div class="form-group">
<label for="edit-author">Author</label>
<input
type="text"
id="edit-author"
[(ngModel)]="editedData.author"
name="author"
required
>
</div>
<div class="form-group">
<label for="edit-isbn">ISBN</label>
<input
type="text"
id="edit-isbn"
[(ngModel)]="editedData.isbn"
name="isbn"
>
</div>
}
@case ('GAME') {
<div class="form-group">
<label for="edit-platform">Platform</label>
<input
type="text"
id="edit-platform"
[(ngModel)]="editedData.platform"
name="platform"
>
</div>
}
@case ('MUSIC') {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editedData.artist"
name="artist"
required
>
</div>
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
<option value="ALBUM">Album</option>
<option value="SINGLE">Single</option>
<option value="EP">EP</option>
</select>
</div>
}
@case ('ART') {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
type="text"
id="edit-artist"
[(ngModel)]="editedData.artist"
name="artist"
required
>
</div>
<div class="form-group">
<label for="edit-description">Description</label>
<textarea
id="edit-description"
[(ngModel)]="editedData.description"
name="description"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="edit-imageUrl">Image URL</label>
<input
type="text"
id="edit-imageUrl"
[(ngModel)]="editedData.imageUrl"
name="imageUrl"
required
>
</div>
}
@case ('SHOW') {
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
<option value="TV_SERIES">TV Series</option>
<option value="ANIME">Anime</option>
<option value="FILM">Film</option>
<option value="DOCUMENTARY">Documentary</option>
</select>
</div>
}
@case ('MANGA') {
<div class="form-group">
<label for="edit-author">Author</label>
<input
type="text"
id="edit-author"
[(ngModel)]="editedData.author"
name="author"
required
>
</div>
}
}
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editedData.notes"
name="notes"
rows="3"
></textarea>
</div>
@if (editingSuggestion()!.entityType !== 'ART') {
<div class="form-group">
<label for="edit-coverImage">Cover Image URL</label>
<input
type="text"
id="edit-coverImage"
[(ngModel)]="editedData.coverImage"
name="coverImage"
>
</div>
}
<div class="modal-actions">
<button type="submit" class="btn btn-accept">Accept with Edits</button>
<button type="button" (click)="closeEditModal()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
</div>
</div>
}
</div> </div>
`, `,
styles: [` styles: [`
@@ -503,6 +663,14 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
overflow-y: auto; overflow-y: auto;
} }
.edit-modal {
max-width: 650px;
}
.edit-modal form {
margin-top: 1rem;
}
.modal h3 { .modal h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
} }
@@ -549,6 +717,11 @@ export class AdminSuggestionsComponent implements OnInit {
decliningsuggestion = signal<Suggestion | null>(null); decliningsuggestion = signal<Suggestion | null>(null);
declineReason = ''; declineReason = '';
// Edit modal state
showEditModal = signal(false);
editingSuggestion = signal<Suggestion | null>(null);
editedData: any = {};
// Pagination state // Pagination state
currentPage = signal(1); currentPage = signal(1);
pageSize = signal(25); pageSize = signal(25);
@@ -637,15 +810,59 @@ export class AdminSuggestionsComponent implements OnInit {
} }
async acceptSuggestion(suggestion: Suggestion) { async acceptSuggestion(suggestion: Suggestion) {
if (!confirm(`Accept "${suggestion.title}"? This will add it to your collection.`)) return; this.editingSuggestion.set(suggestion);
try { // Pre-populate the edit form with suggestion data
await this.suggestionService.acceptSuggestion(suggestion.id); this.editedData = {
alert(`"${suggestion.title}" has been added to your collection!`); title: suggestion.title,
this.loadSuggestions(); notes: '',
} catch { coverImage: '',
alert('Failed to accept suggestion. Please try again.'); 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) { openDeclineModal(suggestion: Suggestion) {
@@ -660,6 +877,26 @@ export class AdminSuggestionsComponent implements OnInit {
this.declineReason = ''; 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() { async confirmDecline() {
const suggestion = this.decliningsuggestion(); const suggestion = this.decliningsuggestion();
if (!suggestion) return; if (!suggestion) return;
+12 -4
View File
@@ -75,10 +75,18 @@ export class ApiService {
delete<T>(endpoint: string): Observable<T> { delete<T>(endpoint: string): Observable<T> {
return this.ensureCsrfToken().pipe( return this.ensureCsrfToken().pipe(
switchMap(() => this.http.delete<T>(`${this.apiUrl}${endpoint}`, { switchMap(() => {
headers: this.getHeaders(), // Don't send Content-Type for DELETE requests as they have no body
withCredentials: true const headers: Record<string, string> = {};
})) const token = this.csrfToken();
if (token) {
headers['X-CSRF-Token'] = token;
}
return this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
headers: new HttpHeaders(headers),
withCredentials: true
});
})
); );
} }
@@ -49,6 +49,10 @@ export class SuggestionService {
return firstValueFrom(this.api.put<Suggestion>(`/suggestions/${id}/accept`, {})); return firstValueFrom(this.api.put<Suggestion>(`/suggestions/${id}/accept`, {}));
} }
async acceptSuggestionWithEdits(id: string, data: any): Promise<Suggestion> {
return firstValueFrom(this.api.put<Suggestion>(`/suggestions/${id}/accept-with-edits`, data));
}
async declineSuggestion(id: string, data: DeclineSuggestionDto): Promise<Suggestion> { async declineSuggestion(id: string, data: DeclineSuggestionDto): Promise<Suggestion> {
return firstValueFrom(this.api.put<Suggestion>(`/suggestions/${id}/decline`, data)); return firstValueFrom(this.api.put<Suggestion>(`/suggestions/${id}/decline`, data));
} }