generated from nhcarrigan/template
feat: ability to edit suggestions when accepting
This commit is contained in:
@@ -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)
|
||||
app.put<{ Params: { id: string }; Body: DeclineSuggestionDto }>(
|
||||
"/:id/decline",
|
||||
|
||||
@@ -340,6 +340,95 @@ export const SuggestionService = {
|
||||
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> {
|
||||
const suggestion = await prisma.suggestion.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -227,6 +227,166 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
|
||||
</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>
|
||||
`,
|
||||
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<Suggestion | null>(null);
|
||||
declineReason = '';
|
||||
|
||||
// Edit modal state
|
||||
showEditModal = signal(false);
|
||||
editingSuggestion = signal<Suggestion | null>(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;
|
||||
|
||||
@@ -75,10 +75,18 @@ export class ApiService {
|
||||
|
||||
delete<T>(endpoint: string): Observable<T> {
|
||||
return this.ensureCsrfToken().pipe(
|
||||
switchMap(() => this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
switchMap(() => {
|
||||
// Don't send Content-Type for DELETE requests as they have no body
|
||||
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`, {}));
|
||||
}
|
||||
|
||||
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> {
|
||||
return firstValueFrom(this.api.put<Suggestion>(`/suggestions/${id}/decline`, data));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user