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)
|
// 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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
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
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user