import { Injectable, inject, signal } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { EMPTY, Observable, Subject, from, of, throwError } from 'rxjs'; import { catchError, concatMap, debounceTime, expand, groupBy, map, mergeMap, reduce, retry, toArray, } from 'rxjs/operators'; import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models'; import { ItemName } from '../models/item.model'; import { MockDataService } from './mock-data.service'; import { ToastService } from './toast.service'; import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class ApiService { private http = inject(HttpClient); private mockService = inject(MockDataService); private toast = inject(ToastService); private readonly API_BASE = environment.apiUrl; /** Whether a debounced save is in-flight */ saving = signal(false); // Debounced save queue private saveQueue$ = new Subject(); constructor() { // Debounce per unique type+id+field so independent fields don't clobber each other this.saveQueue$ .pipe( groupBy(op => `${op.type}:${op.id}:${op.field}`), mergeMap(group$ => group$.pipe(debounceTime(500))) ) .subscribe(operation => { this.executeSave(operation); }); } // ─── Normalizers ────────────────────────────────────────── /** Get text from an ItemName entry, handling the backend 'valuue' typo */ private nameValue(entry: ItemName): string { return entry.value || entry.valuue || ''; } /** Normalize an item from the backend — extracts itemDetails, names, photos */ private normalizeItem(raw: any): Item { const item: Item = { ...raw }; // Extract price/currency/remaining from itemDetails[0] if present const details = raw.itemDetails || raw.itemdetails; if (details && Array.isArray(details) && details.length > 0) { const d = details[0]; item.itemDetails = details; if (item.price == null || item.price === 0) item.price = d.price ?? 0; if (!item.currency) item.currency = d.currency || 'RUB'; if (!item.colour) item.colour = d.colour || d.color || ''; if (!item.size) item.size = d.size || ''; if (item.quantity == null && d.remaining != null) item.quantity = d.remaining; } // Build translations from names[]/descriptions[] if translations map is empty if (raw.names && Array.isArray(raw.names)) { item.translations = item.translations || {}; for (const n of raw.names) { const lang = n.language?.toLowerCase(); const val = this.nameValue(n); if (lang && val) { item.translations[lang] = item.translations[lang] || {}; item.translations[lang].name = val; } } } if (raw.descriptions && Array.isArray(raw.descriptions)) { item.translations = item.translations || {}; for (const d of raw.descriptions) { const lang = d.language?.toLowerCase(); const val = d.value || d.valuue || ''; if (lang && val) { item.translations[lang] = item.translations[lang] || {}; item.translations[lang].simpleDescription = val; } } } // Build imgs[] from photos[] if imgs is empty if ((!item.imgs || item.imgs.length === 0) && raw.photos && Array.isArray(raw.photos)) { item.imgs = raw.photos.map((p: any) => p.url); } // Map callbacks → comments if comments is missing if ((!item.comments || item.comments.length === 0) && raw.callbacks && Array.isArray(raw.callbacks)) { item.comments = raw.callbacks.map((c: any) => ({ id: c.userID || '', text: c.content || '', author: c.userID || '', stars: c.rating, createdAt: c.timestamp, })); } // Defaults item.name = item.name || ''; item.price = item.price ?? 0; item.discount = item.discount ?? 0; item.quantity = item.quantity ?? 0; item.currency = item.currency || 'RUB'; item.imgs = item.imgs || []; item.tags = item.tags || []; item.description = item.description || []; item.simpleDescription = item.simpleDescription || ''; return item; } /** Normalize a category — merges names[] into translations, maps Go struct fields */ private normalizeCategory(raw: any): Category { const cat: Category = { ...raw }; if (raw.names && Array.isArray(raw.names)) { cat.translations = cat.translations || {}; for (const n of raw.names) { const lang = n.language?.toLowerCase(); const val = this.nameValue(n); if (lang && val) { cat.translations[lang] = cat.translations[lang] || {}; cat.translations[lang].name = val; } } } // Map Go struct fields if (raw.icon && !cat.img) cat.img = raw.icon; if (raw.wideicon) cat.wideBanner = raw.wideicon; if (raw.ItemsCount != null) cat.itemCount = raw.ItemsCount; if (raw.CategoriesCount != null) cat.categoriesCount = raw.CategoriesCount; return cat; } // Projects getProjects(): Observable { if (environment.useMockData) return this.mockService.getProjects(); return this.http.get(`${this.API_BASE}/projects`).pipe( retry(2), catchError(this.handleError) ); } // Categories getCategories(projectId: string): Observable { if (environment.useMockData) return this.mockService.getCategories(projectId); return this.http.get(`${this.API_BASE}/projects/${projectId}/categories`).pipe( retry(2), map(cats => cats.map(c => this.normalizeCategory(c))), catchError(this.handleError) ); } getCategory(categoryId: string): Observable { if (environment.useMockData) return this.mockService.getCategory(categoryId); return this.http.get(`${this.API_BASE}/categories/${categoryId}`).pipe( retry(2), map(c => this.normalizeCategory(c)), catchError(this.handleError) ); } updateCategory(categoryId: string, data: Partial): Observable { if (environment.useMockData) return this.mockService.updateCategory(categoryId, data); return this.http.patch(`${this.API_BASE}/categories/${categoryId}`, data).pipe( retry(1), catchError(this.handleError) ); } createCategory(projectId: string, data: Partial): Observable { if (environment.useMockData) return this.mockService.createCategory(projectId, data); return this.http.post(`${this.API_BASE}/projects/${projectId}/categories`, data).pipe( catchError(this.handleError) ); } deleteCategory(categoryId: string): Observable { if (environment.useMockData) return this.mockService.deleteCategory(categoryId); return this.http.delete(`${this.API_BASE}/categories/${categoryId}`).pipe( catchError(this.handleError) ); } // Subcategories getSubcategories(categoryId: string): Observable { if (environment.useMockData) { return this.mockService.getCategory(categoryId).pipe( map(cat => cat.subcategories || []) ); } return this.http.get(`${this.API_BASE}/categories/${categoryId}/subcategories`).pipe( retry(2), catchError(this.handleError) ); } getSubcategory(subcategoryId: string): Observable { if (environment.useMockData) return this.mockService.getSubcategory(subcategoryId); return this.http.get(`${this.API_BASE}/subcategories/${subcategoryId}`).pipe( retry(2), catchError(this.handleError) ); } updateSubcategory(subcategoryId: string, data: Partial): Observable { if (environment.useMockData) return this.mockService.updateSubcategory(subcategoryId, data); return this.http.patch(`${this.API_BASE}/subcategories/${subcategoryId}`, data).pipe( retry(1), catchError(this.handleError) ); } createSubcategory(parentId: string, parentType: 'category' | 'subcategory', data: Partial): Observable { if (environment.useMockData) return this.mockService.createSubcategory(parentId, data); const endpoint = parentType === 'category' ? `${this.API_BASE}/categories/${parentId}/subcategories` : `${this.API_BASE}/subcategories/${parentId}/subcategories`; return this.http.post(endpoint, data).pipe( catchError(this.handleError) ); } deleteSubcategory(subcategoryId: string): Observable { if (environment.useMockData) return this.mockService.deleteSubcategory(subcategoryId); return this.http.delete(`${this.API_BASE}/subcategories/${subcategoryId}`).pipe( catchError(this.handleError) ); } // Items getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: ItemFilters): Observable { if (environment.useMockData) return this.mockService.getItems(subcategoryId, page, limit, search, filters); let params = new HttpParams() .set('page', page.toString()) .set('limit', limit.toString()); if (search) { params = params.set('search', search); } if (filters?.visible !== undefined) { params = params.set('visible', filters.visible.toString()); } if (filters?.tags?.length) { params = params.set('tags', filters.tags.join(',')); } return this.http.get(`${this.API_BASE}/subcategories/${subcategoryId}/items`, { params }).pipe( retry(2), map(resp => ({ ...resp, items: (resp.items || []).map((i: any) => this.normalizeItem(i)) })), catchError(this.handleError) ); } getItem(itemId: string): Observable { if (environment.useMockData) return this.mockService.getItem(itemId); return this.http.get(`${this.API_BASE}/items/${itemId}`).pipe( retry(2), map(raw => this.normalizeItem(raw)), catchError(this.handleError) ); } updateItem(itemId: string, data: Partial): Observable { if (environment.useMockData) return this.mockService.updateItem(itemId, data); return this.http.patch(`${this.API_BASE}/items/${itemId}`, data).pipe( retry(1), catchError(this.handleError) ); } createItem(subcategoryId: string, data: Partial): Observable { if (environment.useMockData) return this.mockService.createItem(subcategoryId, data); return this.http.post(`${this.API_BASE}/subcategories/${subcategoryId}/items`, data).pipe( catchError(this.handleError) ); } deleteItem(itemId: string): Observable { if (environment.useMockData) return this.mockService.deleteItem(itemId); return this.http.delete(`${this.API_BASE}/items/${itemId}`).pipe( catchError(this.handleError) ); } bulkUpdateItems(itemIds: string[], data: Partial): Observable { if (environment.useMockData) return this.mockService.bulkUpdateItems(itemIds, data); return this.http.patch(`${this.API_BASE}/items/bulk`, { itemIds, data }).pipe( retry(1), catchError(this.handleError) ); } setProjectVisibility(categories: Category[], visible: boolean): Observable { const { categoryIds, subcategoryIds } = this.collectVisibilityTargets(categories); const requests = [ ...categoryIds.map(id => () => this.updateCategory(id, { visible })), ...subcategoryIds.map(id => () => this.updateSubcategory(id, { visible })), ...subcategoryIds.map(id => () => this.updateSubcategoryItemsVisibility(id, visible)), ]; if (!requests.length) { return of(void 0); } return from(requests).pipe( concatMap(request => request()), toArray(), map(() => void 0) ); } // Image upload uploadImage(file: File): Observable<{ url: string }> { if (environment.useMockData) return this.mockService.uploadImage(file); const formData = new FormData(); formData.append('image', file); return this.http.post<{ url: string }>(`${this.API_BASE}/upload`, formData).pipe( retry(1), catchError(this.handleError) ); } // Debounced auto-save queueSave(type: 'category' | 'subcategory' | 'item', id: string, field: string, value: unknown) { this.saving.set(true); this.saveQueue$.next({ type, id, field, value }); } private executeSave(operation: SaveOperation) { const data = { [operation.field]: operation.value }; let request: Observable; switch (operation.type) { case 'category': request = this.updateCategory(operation.id, data); break; case 'subcategory': request = this.updateSubcategory(operation.id, data); break; case 'item': request = this.updateItem(operation.id, data); break; } request.subscribe({ next: () => { this.saving.set(false); this.toast.success('Saved'); }, error: (err) => { this.saving.set(false); this.toast.error(err.message || 'Failed to save'); } }); } private updateSubcategoryItemsVisibility(subcategoryId: string, visible: boolean): Observable { return this.getAllSubcategoryItemIds(subcategoryId).pipe( concatMap(itemIds => itemIds.length ? this.bulkUpdateItems(itemIds, { visible }) : of(void 0)) ); } private getAllSubcategoryItemIds(subcategoryId: string): Observable { const pageSize = 100; return this.getItems(subcategoryId, 1, pageSize).pipe( expand(response => response.hasMore ? this.getItems(subcategoryId, response.page + 1, pageSize) : EMPTY ), reduce((itemIds, response) => { itemIds.push(...response.items.map(item => item.id)); return itemIds; }, [] as string[]) ); } private collectVisibilityTargets(categories: Category[]) { const categoryIds = categories.map(category => category.id); const subcategoryIds: string[] = []; const visitSubcategories = (subcategories: Subcategory[]) => { for (const subcategory of subcategories) { subcategoryIds.push(subcategory.id); if (subcategory.subcategories?.length) { visitSubcategories(subcategory.subcategories); } } }; for (const category of categories) { visitSubcategories(category.subcategories || []); } return { categoryIds, subcategoryIds }; } private handleError = (error: any): Observable => { let errorMessage = 'An unexpected error occurred'; if (error.error instanceof ErrorEvent) { // Client-side or network error errorMessage = `Network error: ${error.error.message}`; } else if (error.status) { // Backend returned an unsuccessful response code switch (error.status) { case 400: errorMessage = error.error?.message || 'Invalid request'; break; case 401: errorMessage = 'Unauthorized. Please log in again.'; break; case 403: errorMessage = 'You do not have permission to perform this action'; break; case 404: errorMessage = 'Resource not found'; break; case 409: errorMessage = error.error?.message || 'Conflict: Resource already exists or has conflicts'; break; case 422: errorMessage = error.error?.message || 'Validation failed'; break; case 500: errorMessage = 'Server error. Please try again later.'; break; case 503: errorMessage = 'Service unavailable. Please try again later.'; break; default: errorMessage = error.error?.message || `Error: ${error.status} - ${error.statusText}`; } } console.error('API Error:', { message: errorMessage, status: error.status, error: error.error, url: error.url }); return throwError(() => ({ message: errorMessage, status: error.status, originalError: error })); }; } interface SaveOperation { type: 'category' | 'subcategory' | 'item'; id: string; field: string; value: unknown; } interface ItemFilters { visible?: boolean; tags?: string[]; }