diff --git a/src/admin/src/app/app.routes.ts b/src/admin/src/app/app.routes.ts index 5b7bdb7..8dafa9d 100644 --- a/src/admin/src/app/app.routes.ts +++ b/src/admin/src/app/app.routes.ts @@ -46,6 +46,13 @@ export const routes: Routes = [ (m) => m.OrdersAdminComponent, ), }, + { + path: 'after-sales', + loadComponent: () => + import('./features/after-sales/after-sales-admin.component').then( + (m) => m.AfterSalesAdminComponent, + ), + }, { path: 'inventory', loadComponent: () => diff --git a/src/admin/src/app/core/i18n/translations/cs.ts b/src/admin/src/app/core/i18n/translations/cs.ts index 138b802..484f636 100644 --- a/src/admin/src/app/core/i18n/translations/cs.ts +++ b/src/admin/src/app/core/i18n/translations/cs.ts @@ -8,6 +8,42 @@ export const CS_TRANSLATIONS: TranslationMap = { 'nav.analytics': 'Analytika', 'nav.products': 'Produkty', 'nav.orders': 'Objednávky', + 'nav.afterSales': 'Reklamace', + 'afterSales.title': 'Reklamace a vrácení', + 'afterSales.purpose': + 'Kontrola žádostí. Potvrďte refundaci PayU, výměnu nebo zamítnutí.', + 'afterSales.filterStatus': 'Stav', + 'afterSales.colCreated': 'Vytvořeno', + 'afterSales.colOrder': 'Objednávka', + 'afterSales.colCustomer': 'Zákazník', + 'afterSales.colType': 'Typ', + 'afterSales.open': 'Otevřít', + 'afterSales.empty': 'Žádné žádosti.', + 'afterSales.lines': 'Položky', + 'afterSales.customerNote': 'Poznámka zákazníka', + 'afterSales.photos': 'Fotky', + 'afterSales.resolution': 'Rozhodnutí', + 'afterSales.resolveTitle': 'Rozhodnout', + 'afterSales.adminNote': 'Poznámka admina', + 'afterSales.refundAmount': 'Částka refundace', + 'afterSales.confirmResolve': 'Potvrdit', + 'afterSales.errorLoad': 'Nepodařilo se načíst žádosti.', + 'afterSales.errorResolve': 'Nepodařilo se uzavřít žádost.', + 'afterSales.type.Withdrawal': 'Vrácení / odstoupení', + 'afterSales.type.Defect': 'Vada', + 'afterSales.type.MissingPart': 'Chybějící díl', + 'afterSales.type.WrongItem': 'Špatný produkt', + 'afterSales.type.Other': 'Jiné', + 'afterSales.status.Submitted': 'Podáno', + 'afterSales.status.InReview': 'V řešení', + 'afterSales.status.Approved': 'Schváleno', + 'afterSales.status.Rejected': 'Zamítnuto', + 'afterSales.status.Resolved': 'Vyřízeno', + 'afterSales.resolution.Refund': 'Refundace (PayU)', + 'afterSales.resolution.Replacement': 'Poslat náhradu', + 'afterSales.resolution.SendMissingPart': 'Poslat chybějící díl', + 'afterSales.resolution.Reject': 'Zamítnout', + 'afterSales.resolution.Other': 'Jiné', 'nav.promotions': 'Akce', 'nav.discounts': 'Akce', 'nav.newsletter': 'Newsletter', diff --git a/src/admin/src/app/core/i18n/translations/de.ts b/src/admin/src/app/core/i18n/translations/de.ts index 1be0193..541e0fc 100644 --- a/src/admin/src/app/core/i18n/translations/de.ts +++ b/src/admin/src/app/core/i18n/translations/de.ts @@ -8,6 +8,42 @@ export const DE_TRANSLATIONS: TranslationMap = { 'nav.analytics': 'Analysen', 'nav.products': 'Produkte', 'nav.orders': 'Bestellungen', + 'nav.afterSales': 'After-Sales', + 'afterSales.title': 'After-Sales', + 'afterSales.purpose': + 'Retouren und Reklamationen prüfen. Rückerstattung, Ersatz oder Ablehnung bestätigen.', + 'afterSales.filterStatus': 'Status', + 'afterSales.colCreated': 'Erstellt', + 'afterSales.colOrder': 'Bestellung', + 'afterSales.colCustomer': 'Kunde', + 'afterSales.colType': 'Typ', + 'afterSales.open': 'Öffnen', + 'afterSales.empty': 'Keine Anfragen.', + 'afterSales.lines': 'Positionen', + 'afterSales.customerNote': 'Kundennotiz', + 'afterSales.photos': 'Fotos', + 'afterSales.resolution': 'Entscheidung', + 'afterSales.resolveTitle': 'Entscheiden', + 'afterSales.adminNote': 'Admin-Notiz', + 'afterSales.refundAmount': 'Erstattungsbetrag', + 'afterSales.confirmResolve': 'Bestätigen', + 'afterSales.errorLoad': 'Anfragen konnten nicht geladen werden.', + 'afterSales.errorResolve': 'Anfrage konnte nicht abgeschlossen werden.', + 'afterSales.type.Withdrawal': 'Widerruf / Rückgabe', + 'afterSales.type.Defect': 'Defekt', + 'afterSales.type.MissingPart': 'Fehlendes Teil', + 'afterSales.type.WrongItem': 'Falscher Artikel', + 'afterSales.type.Other': 'Sonstiges', + 'afterSales.status.Submitted': 'Eingereicht', + 'afterSales.status.InReview': 'In Prüfung', + 'afterSales.status.Approved': 'Genehmigt', + 'afterSales.status.Rejected': 'Abgelehnt', + 'afterSales.status.Resolved': 'Erledigt', + 'afterSales.resolution.Refund': 'Erstattung (PayU)', + 'afterSales.resolution.Replacement': 'Ersatz senden', + 'afterSales.resolution.SendMissingPart': 'Fehlendes Teil senden', + 'afterSales.resolution.Reject': 'Ablehnen', + 'afterSales.resolution.Other': 'Sonstiges', 'nav.promotions': 'Aktionen', 'nav.discounts': 'Aktionen', 'nav.newsletter': 'Newsletter', diff --git a/src/admin/src/app/core/i18n/translations/en.ts b/src/admin/src/app/core/i18n/translations/en.ts index 2bc1670..2ecb7a4 100644 --- a/src/admin/src/app/core/i18n/translations/en.ts +++ b/src/admin/src/app/core/i18n/translations/en.ts @@ -8,6 +8,42 @@ export const EN_TRANSLATIONS: TranslationMap = { 'nav.analytics': 'Analytics', 'nav.products': 'Products', 'nav.orders': 'Orders', + 'nav.afterSales': 'After-sales', + 'afterSales.title': 'After-sales', + 'afterSales.purpose': + 'Review return and claim requests. Confirm refunds, replacements, or rejections.', + 'afterSales.filterStatus': 'Status', + 'afterSales.colCreated': 'Created', + 'afterSales.colOrder': 'Order', + 'afterSales.colCustomer': 'Customer', + 'afterSales.colType': 'Type', + 'afterSales.open': 'Open', + 'afterSales.empty': 'No after-sales requests.', + 'afterSales.lines': 'Items', + 'afterSales.customerNote': 'Customer note', + 'afterSales.photos': 'Photos', + 'afterSales.resolution': 'Resolution', + 'afterSales.resolveTitle': 'Decide', + 'afterSales.adminNote': 'Admin note', + 'afterSales.refundAmount': 'Refund amount', + 'afterSales.confirmResolve': 'Confirm', + 'afterSales.errorLoad': 'Could not load after-sales requests.', + 'afterSales.errorResolve': 'Could not resolve request.', + 'afterSales.type.Withdrawal': 'Withdrawal / return', + 'afterSales.type.Defect': 'Defect', + 'afterSales.type.MissingPart': 'Missing part', + 'afterSales.type.WrongItem': 'Wrong item', + 'afterSales.type.Other': 'Other', + 'afterSales.status.Submitted': 'Submitted', + 'afterSales.status.InReview': 'In review', + 'afterSales.status.Approved': 'Approved', + 'afterSales.status.Rejected': 'Rejected', + 'afterSales.status.Resolved': 'Resolved', + 'afterSales.resolution.Refund': 'Refund (PayU)', + 'afterSales.resolution.Replacement': 'Send replacement', + 'afterSales.resolution.SendMissingPart': 'Send missing part', + 'afterSales.resolution.Reject': 'Reject', + 'afterSales.resolution.Other': 'Other', 'nav.promotions': 'Promotions', 'nav.discounts': 'Promotions', 'nav.newsletter': 'Newsletter', diff --git a/src/admin/src/app/core/i18n/translations/pl.ts b/src/admin/src/app/core/i18n/translations/pl.ts index 019d1e0..a244fe7 100644 --- a/src/admin/src/app/core/i18n/translations/pl.ts +++ b/src/admin/src/app/core/i18n/translations/pl.ts @@ -8,6 +8,42 @@ export const PL_TRANSLATIONS: TranslationMap = { 'nav.analytics': 'Analityka', 'nav.products': 'Produkty', 'nav.orders': 'Zamówienia', + 'nav.afterSales': 'Reklamacje', + 'afterSales.title': 'Reklamacje i zwroty', + 'afterSales.purpose': + 'Przeglądaj zgłoszenia. Potwierdź zwrot PayU, wymianę lub odrzucenie.', + 'afterSales.filterStatus': 'Status', + 'afterSales.colCreated': 'Utworzono', + 'afterSales.colOrder': 'Zamówienie', + 'afterSales.colCustomer': 'Klient', + 'afterSales.colType': 'Typ', + 'afterSales.open': 'Otwórz', + 'afterSales.empty': 'Brak zgłoszeń.', + 'afterSales.lines': 'Pozycje', + 'afterSales.customerNote': 'Notatka klienta', + 'afterSales.photos': 'Zdjęcia', + 'afterSales.resolution': 'Rozstrzygnięcie', + 'afterSales.resolveTitle': 'Decyzja', + 'afterSales.adminNote': 'Notatka admina', + 'afterSales.refundAmount': 'Kwota zwrotu', + 'afterSales.confirmResolve': 'Potwierdź', + 'afterSales.errorLoad': 'Nie udało się wczytać zgłoszeń.', + 'afterSales.errorResolve': 'Nie udało się rozstrzygnąć.', + 'afterSales.type.Withdrawal': 'Zwrot / odstąpienie', + 'afterSales.type.Defect': 'Wada', + 'afterSales.type.MissingPart': 'Brakująca część', + 'afterSales.type.WrongItem': 'Zły produkt', + 'afterSales.type.Other': 'Inne', + 'afterSales.status.Submitted': 'Nowe', + 'afterSales.status.InReview': 'W trakcie', + 'afterSales.status.Approved': 'Zatwierdzone', + 'afterSales.status.Rejected': 'Odrzucone', + 'afterSales.status.Resolved': 'Zamknięte', + 'afterSales.resolution.Refund': 'Zwrot (PayU)', + 'afterSales.resolution.Replacement': 'Wymiana', + 'afterSales.resolution.SendMissingPart': 'Wyślij brakującą część', + 'afterSales.resolution.Reject': 'Odrzuć', + 'afterSales.resolution.Other': 'Inne', 'nav.promotions': 'Promocje', 'nav.discounts': 'Promocje', 'nav.newsletter': 'Newsletter', diff --git a/src/admin/src/app/features/after-sales/after-sales-admin.component.html b/src/admin/src/app/features/after-sales/after-sales-admin.component.html new file mode 100644 index 0000000..04e5253 --- /dev/null +++ b/src/admin/src/app/features/after-sales/after-sales-admin.component.html @@ -0,0 +1,285 @@ + + +
+
+
+ + +
+
+
+ +@if (loading()) { + +} @else { +
+
+ + + + + + + + + + + + + @for (item of items(); track item.id) { + + + + + + + + + } @empty { + + + + } + +
{{ 'afterSales.colCreated' | translate }}{{ 'afterSales.colOrder' | translate }}{{ 'afterSales.colCustomer' | translate }}{{ 'afterSales.colType' | translate }}{{ 'orders.status' | translate }}
{{ item.createdAt | date: 'yyyy-MM-dd HH:mm' }}{{ item.orderNumber }} +
{{ item.customerName }}
+
{{ item.customerEmail }}
+
{{ 'afterSales.type.' + enumKey(item.type) | translate }} + + {{ 'afterSales.status.' + enumKey(item.status) | translate }} + + + +
+ {{ 'afterSales.empty' | translate }} +
+
+
+ +
+
+} + +@if (selected(); as d) { + +} diff --git a/src/admin/src/app/features/after-sales/after-sales-admin.component.ts b/src/admin/src/app/features/after-sales/after-sales-admin.component.ts new file mode 100644 index 0000000..a4adc2c --- /dev/null +++ b/src/admin/src/app/features/after-sales/after-sales-admin.component.ts @@ -0,0 +1,177 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, + signal, +} from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe'; +import { TranslatePipe } from '../../core/i18n/translate.pipe'; +import { ApiService } from '../../core/api.service'; +import { resolveProductImageUrl } from '../../core/product-image.util'; +import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component'; +import { AdminPaginationComponent } from '../../shared/admin-pagination.component'; +import { ToastService } from '../../shared/toast.service'; +import { + AFTER_SALES_STATUSES, + AdminAfterSalesList, + AfterSalesRequestDetail, + AfterSalesRequestStatus, + AfterSalesResolution, + afterSalesEnumKey, +} from './after-sales.models'; + +@Component({ + selector: 'app-after-sales-admin', + standalone: true, + imports: [ + FormsModule, + DatePipe, + TranslatePipe, + I18nCurrencyPipe, + AdminSkeletonComponent, + AdminPaginationComponent, + ], + templateUrl: './after-sales-admin.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AfterSalesAdminComponent implements OnInit { + readonly #api = inject(ApiService); + readonly #toast = inject(ToastService); + + readonly loading = signal(true); + readonly resolving = signal(false); + readonly items = signal([]); + readonly totalCount = signal(0); + readonly page = signal(1); + readonly pageSize = 20; + readonly selected = signal(null); + + statusFilter: AfterSalesRequestStatus | '' = ''; + readonly statuses = AFTER_SALES_STATUSES; + + resolution: AfterSalesResolution = 'refund'; + adminNote = ''; + refundAmount: number | null = null; + + readonly enumKey = afterSalesEnumKey; + + imageUrl(url: string): string { + return resolveProductImageUrl(url); + } + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading.set(true); + const params = new URLSearchParams({ + page: String(this.page()), + pageSize: String(this.pageSize), + }); + if (this.statusFilter) { + params.set('status', this.statusFilter); + } + + this.#api + .get(`/after-sales/admin?${params.toString()}`) + .subscribe({ + next: (res) => { + this.items.set(res.items); + this.totalCount.set(res.totalCount); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.#toast.error('afterSales.errorLoad'); + }, + }); + } + + handleFilter(): void { + this.page.set(1); + this.load(); + } + + handlePage(p: number): void { + this.page.set(p); + this.load(); + } + + openDetail(item: AfterSalesRequestDetail): void { + this.selected.set(item); + this.resolution = 'refund'; + this.adminNote = ''; + this.refundAmount = item.lines.reduce( + (sum, l) => sum + l.unitPrice * l.quantity, + 0, + ); + } + + closeDetail(): void { + this.selected.set(null); + } + + statusBadge(status: AfterSalesRequestStatus): string { + switch (status) { + case 'submitted': + case 'inReview': + return 'badge-warning'; + case 'approved': + case 'resolved': + return 'badge-success'; + case 'rejected': + return 'badge-danger'; + default: + return 'badge-neutral'; + } + } + + isOpen(item: AfterSalesRequestDetail): boolean { + return ( + item.status === 'submitted' || + item.status === 'inReview' || + item.status === 'approved' + ); + } + + handleResolve(): void { + const item = this.selected(); + if (!item || this.resolving()) { + return; + } + + this.resolving.set(true); + this.#api + .post( + `/after-sales/admin/${item.id}/resolve`, + { + resolution: this.resolution, + adminNote: this.adminNote || null, + refundAmount: + this.resolution === 'refund' ? this.refundAmount : null, + }, + ) + .subscribe({ + next: (updated) => { + this.resolving.set(false); + this.selected.set(updated); + this.#toast.success('common.saved'); + this.load(); + }, + error: (err) => { + this.resolving.set(false); + const msg = + err?.error?.message || + err?.error?.title || + 'afterSales.errorResolve'; + this.#toast.error( + typeof msg === 'string' ? msg : 'afterSales.errorResolve', + ); + }, + }); + } +} diff --git a/src/admin/src/app/features/after-sales/after-sales.models.ts b/src/admin/src/app/features/after-sales/after-sales.models.ts new file mode 100644 index 0000000..a1c3f0f --- /dev/null +++ b/src/admin/src/app/features/after-sales/after-sales.models.ts @@ -0,0 +1,73 @@ +export type AfterSalesRequestType = + | 'withdrawal' + | 'defect' + | 'missingPart' + | 'wrongItem' + | 'other'; + +export type AfterSalesRequestStatus = + | 'submitted' + | 'inReview' + | 'approved' + | 'rejected' + | 'resolved'; + +export type AfterSalesResolution = + | 'refund' + | 'replacement' + | 'sendMissingPart' + | 'reject' + | 'other'; + +export interface AfterSalesRequestLine { + orderLineId: string; + productName: string; + sku: string; + quantity: number; + unitPrice: number; +} + +export interface AfterSalesAttachment { + id: string; + url: string; + originalFileName: string | null; +} + +export interface AfterSalesRequestDetail { + id: string; + orderId: string; + orderNumber: string; + customerEmail: string; + customerName: string; + type: AfterSalesRequestType; + status: AfterSalesRequestStatus; + customerNote: string; + adminNote: string | null; + resolution: AfterSalesResolution | null; + refundAmount: number | null; + payURefundId: string | null; + createdAt: string; + resolvedAt: string | null; + lines: AfterSalesRequestLine[]; + attachments: AfterSalesAttachment[]; +} + +export interface AdminAfterSalesList { + items: AfterSalesRequestDetail[]; + totalCount: number; + page: number; + pageSize: number; +} + +export const AFTER_SALES_STATUSES: AfterSalesRequestStatus[] = [ + 'submitted', + 'inReview', + 'approved', + 'rejected', + 'resolved', +]; + +/** Map API camelCase enum to i18n PascalCase suffix. */ +export function afterSalesEnumKey(value: string): string { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} diff --git a/src/admin/src/app/layout/admin-layout.component.html b/src/admin/src/app/layout/admin-layout.component.html index 08631e7..bd20be8 100644 --- a/src/admin/src/app/layout/admin-layout.component.html +++ b/src/admin/src/app/layout/admin-layout.component.html @@ -56,6 +56,15 @@ {{ 'nav.orders' | translate }} + + + {{ 'nav.afterSales' | translate }} + GetOrder( + Guid orderId, + [FromQuery] string? token, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new GetAfterSalesOrderQuery(orderId, token), + cancellationToken); + return Ok(result); + } + + [HttpPost("uploads")] + [AllowAnonymous] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task Upload( + [FromForm] Guid orderId, + [FromForm] string? token, + IFormFile file, + CancellationToken cancellationToken) + { + if (file is null || file.Length == 0) + { + return BadRequest(new { message = "File required." }); + } + + await using var stream = file.OpenReadStream(); + var result = await mediator.Send( + new UploadAfterSalesImageCommand( + orderId, + token, + stream, + file.FileName), + cancellationToken); + return Ok(result); + } + + [HttpPost("requests")] + [AllowAnonymous] + public async Task Create( + [FromBody] CreateAfterSalesRequestBody body, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateAfterSalesRequestCommand( + body.OrderId, + body.Token, + body.Type, + body.CustomerNote ?? string.Empty, + body.Lines ?? [], + body.AttachmentUrls ?? []), + cancellationToken); + return Ok(result); + } + + [HttpGet("requests/mine")] + [Authorize] + public async Task GetMine(CancellationToken cancellationToken) + { + var result = await mediator.Send( + new GetMyAfterSalesRequestsQuery(), + cancellationToken); + return Ok(result); + } + + [HttpGet("admin")] + [Authorize(Roles = "Admin")] + public async Task AdminList( + [FromQuery] AfterSalesRequestStatus? status, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 30, + CancellationToken cancellationToken = default) + { + var result = await mediator.Send( + new GetAdminAfterSalesQuery(status, page, pageSize), + cancellationToken); + return Ok(result); + } + + [HttpGet("admin/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task AdminDetail( + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new GetAdminAfterSalesDetailQuery(id), + cancellationToken); + return Ok(result); + } + + [HttpPost("admin/{id:guid}/resolve")] + [Authorize(Roles = "Admin")] + public async Task Resolve( + Guid id, + [FromBody] ResolveAfterSalesBody body, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new ResolveAfterSalesRequestCommand( + id, + body.Resolution, + body.AdminNote, + body.RefundAmount), + cancellationToken); + return Ok(result); + } +} + +public sealed class CreateAfterSalesRequestBody +{ + public Guid OrderId { get; set; } + + public string? Token { get; set; } + + public AfterSalesRequestType Type { get; set; } + + public string? CustomerNote { get; set; } + + public List? Lines { get; set; } + + public List? AttachmentUrls { get; set; } +} + +public sealed class ResolveAfterSalesBody +{ + public AfterSalesResolution Resolution { get; set; } + + public string? AdminNote { get; set; } + + public decimal? RefundAmount { get; set; } +} diff --git a/src/backend/src/iLED.Application/AfterSales/AfterSalesCommands.cs b/src/backend/src/iLED.Application/AfterSales/AfterSalesCommands.cs new file mode 100644 index 0000000..f5f762c --- /dev/null +++ b/src/backend/src/iLED.Application/AfterSales/AfterSalesCommands.cs @@ -0,0 +1,562 @@ +using iLED.Application.Common.Exceptions; +using iLED.Application.Common.Interfaces; +using iLED.Domain.AfterSales; +using iLED.Domain.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace iLED.Application.AfterSales; + +public static class AfterSalesRules +{ + public static readonly TimeSpan WithdrawalWindow = TimeSpan.FromDays(14); + public static readonly TimeSpan ClaimWindow = TimeSpan.FromDays(365); + public const int MaxAttachments = 5; + + public static readonly OrderStatus[] EligibleStatuses = + [ + OrderStatus.Paid, + OrderStatus.Processing, + OrderStatus.Shipped, + OrderStatus.Delivered, + ]; + + public static readonly AfterSalesRequestStatus[] OpenStatuses = + [ + AfterSalesRequestStatus.Submitted, + AfterSalesRequestStatus.InReview, + AfterSalesRequestStatus.Approved, + ]; +} + +public sealed record AfterSalesLineInput(Guid OrderLineId, int Quantity); + +public sealed record CreateAfterSalesRequestCommand( + Guid OrderId, + string? AccessToken, + AfterSalesRequestType Type, + string CustomerNote, + IReadOnlyList Lines, + IReadOnlyList AttachmentUrls) : IRequest; + +public sealed record UploadAfterSalesImageCommand( + Guid OrderId, + string? AccessToken, + Stream ImageStream, + string FileName) : IRequest; + +public sealed record AfterSalesUploadResult(string Url); + +public sealed record GetAfterSalesOrderQuery( + Guid OrderId, + string? AccessToken) : IRequest; + +public sealed record GetMyAfterSalesRequestsQuery + : IRequest>; + +public sealed record GetAdminAfterSalesQuery( + AfterSalesRequestStatus? Status = null, + int Page = 1, + int PageSize = 30) : IRequest; + +public sealed record GetAdminAfterSalesDetailQuery(Guid Id) + : IRequest; + +public sealed record ResolveAfterSalesRequestCommand( + Guid Id, + AfterSalesResolution Resolution, + string? AdminNote, + decimal? RefundAmount) : IRequest; + +public sealed record AfterSalesOrderLineDto( + Guid OrderLineId, + string ProductName, + string VariantColor, + string Sku, + string? ImageUrl, + decimal UnitPrice, + int Quantity, + int RemainingClaimable); + +public sealed record AfterSalesOrderDto( + Guid OrderId, + string OrderNumber, + OrderStatus Status, + DateTime CreatedAt, + IReadOnlyList Lines); + +public sealed record AfterSalesRequestLineDto( + Guid OrderLineId, + string ProductName, + string Sku, + int Quantity, + decimal UnitPrice); + +public sealed record AfterSalesAttachmentDto( + Guid Id, + string Url, + string? OriginalFileName); + +public sealed record AfterSalesRequestDto( + Guid Id, + Guid OrderId, + string OrderNumber, + AfterSalesRequestType Type, + AfterSalesRequestStatus Status, + string CustomerNote, + DateTime CreatedAt); + +public sealed record AfterSalesRequestSummaryDto( + Guid Id, + Guid OrderId, + string OrderNumber, + AfterSalesRequestType Type, + AfterSalesRequestStatus Status, + AfterSalesResolution? Resolution, + DateTime CreatedAt); + +public sealed record AfterSalesRequestDetailDto( + Guid Id, + Guid OrderId, + string OrderNumber, + string CustomerEmail, + string CustomerName, + AfterSalesRequestType Type, + AfterSalesRequestStatus Status, + string CustomerNote, + string? AdminNote, + AfterSalesResolution? Resolution, + decimal? RefundAmount, + string? PayURefundId, + DateTime CreatedAt, + DateTime? ResolvedAt, + IReadOnlyList Lines, + IReadOnlyList Attachments); + +public sealed record AdminAfterSalesListDto( + IReadOnlyList Items, + int TotalCount, + int Page, + int PageSize); + +public sealed class GetAfterSalesOrderQueryHandler( + IAppDbContext db, + IAfterSalesAccessService access) + : IRequestHandler +{ + public async Task Handle( + GetAfterSalesOrderQuery request, + CancellationToken cancellationToken) + { + var order = await db.Orders + .AsNoTracking() + .Include(o => o.Lines) + .Include(o => o.AfterSalesRequests) + .ThenInclude(r => r.Lines) + .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken) + ?? throw new NotFoundException("Order not found."); + + if (!access.CanAccessOrder(order, request.AccessToken)) + { + throw new ValidationException("Access denied."); + } + + var claimed = order.AfterSalesRequests + .Where(r => AfterSalesRules.OpenStatuses.Contains(r.Status) || + r.Status == AfterSalesRequestStatus.Resolved) + .SelectMany(r => r.Lines) + .GroupBy(l => l.OrderLineId) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity)); + + var lines = order.Lines + .OrderBy(l => l.CreatedAt) + .Select(l => + { + claimed.TryGetValue(l.Id, out var used); + return new AfterSalesOrderLineDto( + l.Id, + l.ProductName, + l.VariantColor, + l.Sku, + l.ImageUrl, + l.UnitPrice, + l.Quantity, + Math.Max(0, l.Quantity - used)); + }) + .ToList(); + + return new AfterSalesOrderDto( + order.Id, + order.OrderNumber, + order.Status, + order.CreatedAt, + lines); + } +} + +public sealed class UploadAfterSalesImageCommandHandler( + IAppDbContext db, + IAfterSalesAccessService access, + IAfterSalesImageStorage storage) + : IRequestHandler +{ + public async Task Handle( + UploadAfterSalesImageCommand request, + CancellationToken cancellationToken) + { + var order = await db.Orders + .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken) + ?? throw new NotFoundException("Order not found."); + + if (!access.CanAccessOrder(order, request.AccessToken)) + { + throw new ValidationException("Access denied."); + } + + var url = await storage.SaveAsWebpAsync( + request.ImageStream, + request.FileName, + cancellationToken); + + return new AfterSalesUploadResult(url); + } +} + +public sealed class CreateAfterSalesRequestCommandHandler( + IAppDbContext db, + IAfterSalesAccessService access, + ICurrentUserService currentUser) + : IRequestHandler +{ + public async Task Handle( + CreateAfterSalesRequestCommand request, + CancellationToken cancellationToken) + { + var order = await db.Orders + .Include(o => o.Lines) + .Include(o => o.AfterSalesRequests) + .ThenInclude(r => r.Lines) + .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken) + ?? throw new NotFoundException("Order not found."); + + if (!access.CanAccessOrder(order, request.AccessToken)) + { + throw new ValidationException("Access denied."); + } + + if (!AfterSalesRules.EligibleStatuses.Contains(order.Status)) + { + throw new ValidationException( + "After-sales is available after the order is paid."); + } + + var age = DateTime.UtcNow - order.CreatedAt; + var window = request.Type == AfterSalesRequestType.Withdrawal + ? AfterSalesRules.WithdrawalWindow + : AfterSalesRules.ClaimWindow; + if (age > window) + { + throw new ValidationException( + "The claim window for this request type has expired."); + } + + if (request.Lines is null || request.Lines.Count == 0) + { + throw new ValidationException("Select at least one item."); + } + + var attachments = (request.AttachmentUrls ?? []) + .Where(u => !string.IsNullOrWhiteSpace(u)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(AfterSalesRules.MaxAttachments) + .ToList(); + + var claimed = order.AfterSalesRequests + .Where(r => AfterSalesRules.OpenStatuses.Contains(r.Status) || + r.Status == AfterSalesRequestStatus.Resolved) + .SelectMany(r => r.Lines) + .GroupBy(l => l.OrderLineId) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity)); + + var entity = new AfterSalesRequest + { + OrderId = order.Id, + UserId = currentUser.UserId ?? order.UserId, + Type = request.Type, + Status = AfterSalesRequestStatus.Submitted, + CustomerNote = (request.CustomerNote ?? string.Empty).Trim(), + }; + + foreach (var line in request.Lines) + { + var orderLine = order.Lines.FirstOrDefault(l => l.Id == line.OrderLineId) + ?? throw new ValidationException("Invalid order line."); + + claimed.TryGetValue(orderLine.Id, out var used); + var remaining = orderLine.Quantity - used; + if (line.Quantity < 1 || line.Quantity > remaining) + { + throw new ValidationException( + $"Invalid quantity for {orderLine.ProductName}."); + } + + entity.Lines.Add(new AfterSalesRequestLine + { + OrderLineId = orderLine.Id, + Quantity = line.Quantity, + }); + } + + var sort = 0; + foreach (var url in attachments) + { + if (!url.StartsWith("/uploads/after-sales/", StringComparison.Ordinal)) + { + throw new ValidationException("Invalid attachment URL."); + } + + entity.Attachments.Add(new AfterSalesAttachment + { + Url = url, + SortOrder = sort++, + }); + } + + db.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return new AfterSalesRequestDto( + entity.Id, + order.Id, + order.OrderNumber, + entity.Type, + entity.Status, + entity.CustomerNote, + entity.CreatedAt); + } +} + +public sealed class GetMyAfterSalesRequestsQueryHandler( + IAppDbContext db, + ICurrentUserService currentUser) + : IRequestHandler> +{ + public async Task> Handle( + GetMyAfterSalesRequestsQuery request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(currentUser.UserId)) + { + return []; + } + + return await db.AfterSalesRequests + .AsNoTracking() + .Include(r => r.Order) + .Where(r => r.UserId == currentUser.UserId || + r.Order.UserId == currentUser.UserId) + .OrderByDescending(r => r.CreatedAt) + .Select(r => new AfterSalesRequestSummaryDto( + r.Id, + r.OrderId, + r.Order.OrderNumber, + r.Type, + r.Status, + r.Resolution, + r.CreatedAt)) + .ToListAsync(cancellationToken); + } +} + +public sealed class GetAdminAfterSalesQueryHandler(IAppDbContext db) + : IRequestHandler +{ + public async Task Handle( + GetAdminAfterSalesQuery request, + CancellationToken cancellationToken) + { + var page = Math.Max(1, request.Page); + var pageSize = Math.Clamp(request.PageSize, 1, 100); + + var query = db.AfterSalesRequests + .AsNoTracking() + .Include(r => r.Order) + .Include(r => r.Lines) + .ThenInclude(l => l.OrderLine) + .Include(r => r.Attachments) + .AsQueryable(); + + if (request.Status is { } status) + { + query = query.Where(r => r.Status == status); + } + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(r => r.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new AdminAfterSalesListDto( + items.Select(MapDetail).ToList(), + total, + page, + pageSize); + } + + internal static AfterSalesRequestDetailDto MapDetail(AfterSalesRequest r) => + new( + r.Id, + r.OrderId, + r.Order.OrderNumber, + r.Order.CustomerEmail, + $"{r.Order.CustomerFirstName} {r.Order.CustomerLastName}".Trim(), + r.Type, + r.Status, + r.CustomerNote, + r.AdminNote, + r.Resolution, + r.RefundAmount, + r.PayURefundId, + r.CreatedAt, + r.ResolvedAt, + r.Lines.Select(l => new AfterSalesRequestLineDto( + l.OrderLineId, + l.OrderLine.ProductName, + l.OrderLine.Sku, + l.Quantity, + l.OrderLine.UnitPrice)).ToList(), + r.Attachments + .OrderBy(a => a.SortOrder) + .Select(a => new AfterSalesAttachmentDto( + a.Id, + a.Url, + a.OriginalFileName)) + .ToList()); +} + +public sealed class GetAdminAfterSalesDetailQueryHandler(IAppDbContext db) + : IRequestHandler +{ + public async Task Handle( + GetAdminAfterSalesDetailQuery request, + CancellationToken cancellationToken) + { + var entity = await db.AfterSalesRequests + .AsNoTracking() + .Include(r => r.Order) + .Include(r => r.Lines) + .ThenInclude(l => l.OrderLine) + .Include(r => r.Attachments) + .FirstOrDefaultAsync(r => r.Id == request.Id, cancellationToken) + ?? throw new NotFoundException("After-sales request not found."); + + return GetAdminAfterSalesQueryHandler.MapDetail(entity); + } +} + +public sealed class ResolveAfterSalesRequestCommandHandler( + IAppDbContext db, + IPaymentService payments, + ICurrentUserService currentUser) + : IRequestHandler +{ + public async Task Handle( + ResolveAfterSalesRequestCommand request, + CancellationToken cancellationToken) + { + var entity = await db.AfterSalesRequests + .Include(r => r.Order) + .ThenInclude(o => o.Payment) + .Include(r => r.Lines) + .ThenInclude(l => l.OrderLine) + .Include(r => r.Attachments) + .FirstOrDefaultAsync(r => r.Id == request.Id, cancellationToken) + ?? throw new NotFoundException("After-sales request not found."); + + if (entity.Status is AfterSalesRequestStatus.Resolved + or AfterSalesRequestStatus.Rejected) + { + throw new ValidationException("Request is already closed."); + } + + entity.AdminNote = request.AdminNote?.Trim(); + entity.Resolution = request.Resolution; + entity.ResolvedByAdminId = currentUser.UserId; + entity.ResolvedAt = DateTime.UtcNow; + entity.UpdatedAt = DateTime.UtcNow; + + if (request.Resolution == AfterSalesResolution.Reject) + { + if (string.IsNullOrWhiteSpace(entity.AdminNote)) + { + throw new ValidationException("Admin note is required to reject."); + } + + entity.Status = AfterSalesRequestStatus.Rejected; + await db.SaveChangesAsync(cancellationToken); + return GetAdminAfterSalesQueryHandler.MapDetail(entity); + } + + if (request.Resolution == AfterSalesResolution.Refund) + { + var defaultAmount = entity.Lines.Sum( + l => l.OrderLine.UnitPrice * l.Quantity); + var amount = request.RefundAmount ?? defaultAmount; + if (amount <= 0 || amount > entity.Order.Total) + { + throw new ValidationException("Invalid refund amount."); + } + + var payment = entity.Order.Payment; + if (payment is null || + string.IsNullOrWhiteSpace(payment.ExternalId)) + { + throw new ValidationException( + "No PayU payment found for this order."); + } + + if (entity.Order.PaymentMethod != PaymentMethod.PayU) + { + throw new ValidationException( + "Automated refunds are only available for PayU orders."); + } + + var refund = await payments.RefundPaymentAsync( + new PaymentRefundRequest( + payment.ExternalId, + amount, + $"After-sales {entity.Id} · {entity.Order.OrderNumber}"), + cancellationToken); + + if (!refund.Success) + { + throw new ValidationException( + refund.ErrorMessage ?? "PayU refund failed."); + } + + entity.RefundAmount = amount; + entity.PayURefundId = refund.RefundId; + entity.Status = AfterSalesRequestStatus.Resolved; + + var fullyRefunded = amount >= entity.Order.Total; + payment.Status = fullyRefunded + ? PaymentStatus.Refunded + : payment.Status; + payment.UpdatedAt = DateTime.UtcNow; + if (fullyRefunded) + { + entity.Order.Status = OrderStatus.Refunded; + entity.Order.UpdatedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(cancellationToken); + return GetAdminAfterSalesQueryHandler.MapDetail(entity); + } + + entity.Status = AfterSalesRequestStatus.Resolved; + await db.SaveChangesAsync(cancellationToken); + return GetAdminAfterSalesQueryHandler.MapDetail(entity); + } +} diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesAccessService.cs b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesAccessService.cs new file mode 100644 index 0000000..bf65937 --- /dev/null +++ b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesAccessService.cs @@ -0,0 +1,21 @@ +using iLED.Domain.Orders; + +namespace iLED.Application.Common.Interfaces; + +public interface IAfterSalesAccessService +{ + /// Creates a plain token, stores hash+expiry on the order. + string IssueAccessToken(Order order, TimeSpan? lifetime = null); + + bool ValidateAccessToken(Order order, string? plainToken); + + bool CanAccessOrder(Order order, string? plainToken); +} + +public interface IAfterSalesEmailService +{ + Task SendAccessLinkAsync( + Order order, + string plainToken, + CancellationToken cancellationToken = default); +} diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesImageStorage.cs b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesImageStorage.cs new file mode 100644 index 0000000..96d7412 --- /dev/null +++ b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesImageStorage.cs @@ -0,0 +1,9 @@ +namespace iLED.Application.Common.Interfaces; + +public interface IAfterSalesImageStorage +{ + Task SaveAsWebpAsync( + Stream input, + string originalFileName, + CancellationToken cancellationToken); +} diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs b/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs index e461ffe..1d7bc47 100644 --- a/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs +++ b/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs @@ -28,6 +28,12 @@ public interface IAppDbContext : IApplicationDbContext IQueryable Orders { get; } + IQueryable OrderLines { get; } + + IQueryable AfterSalesRequests { get; } + + IQueryable AfterSalesAttachments { get; } + IQueryable Payments { get; } IQueryable Shipments { get; } diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs b/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs index 6f49800..9f19760 100644 --- a/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs +++ b/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs @@ -17,6 +17,13 @@ public interface IPaymentService Task GetPaymentStatusAsync( string externalId, CancellationToken cancellationToken = default); + + /// + /// Refunds a PayU order (full or partial amount in PLN). + /// + Task RefundPaymentAsync( + PaymentRefundRequest request, + CancellationToken cancellationToken = default); } public sealed record PaymentInitiationRequest( @@ -49,3 +56,13 @@ public sealed record PaymentStatusResult( bool IsCompleted, bool IsCanceled, string? ErrorMessage); + +public sealed record PaymentRefundRequest( + string ExternalId, + decimal Amount, + string Description); + +public sealed record PaymentRefundResult( + bool Success, + string? RefundId, + string? ErrorMessage); diff --git a/src/backend/src/iLED.Domain/AfterSales/AfterSalesAttachment.cs b/src/backend/src/iLED.Domain/AfterSales/AfterSalesAttachment.cs new file mode 100644 index 0000000..8e5db39 --- /dev/null +++ b/src/backend/src/iLED.Domain/AfterSales/AfterSalesAttachment.cs @@ -0,0 +1,16 @@ +using iLED.Domain.Common; + +namespace iLED.Domain.AfterSales; + +public class AfterSalesAttachment : BaseEntity +{ + public Guid AfterSalesRequestId { get; set; } + + public AfterSalesRequest AfterSalesRequest { get; set; } = null!; + + public string Url { get; set; } = string.Empty; + + public string? OriginalFileName { get; set; } + + public int SortOrder { get; set; } +} diff --git a/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequest.cs b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequest.cs new file mode 100644 index 0000000..f380ccd --- /dev/null +++ b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequest.cs @@ -0,0 +1,37 @@ +using iLED.Domain.Common; +using iLED.Domain.Enums; +using iLED.Domain.Orders; + +namespace iLED.Domain.AfterSales; + +public class AfterSalesRequest : BaseEntity +{ + public Guid OrderId { get; set; } + + public Order Order { get; set; } = null!; + + public string? UserId { get; set; } + + public AfterSalesRequestType Type { get; set; } + + public AfterSalesRequestStatus Status { get; set; } = + AfterSalesRequestStatus.Submitted; + + public string CustomerNote { get; set; } = string.Empty; + + public string? AdminNote { get; set; } + + public AfterSalesResolution? Resolution { get; set; } + + public decimal? RefundAmount { get; set; } + + public string? PayURefundId { get; set; } + + public DateTime? ResolvedAt { get; set; } + + public string? ResolvedByAdminId { get; set; } + + public ICollection Lines { get; set; } = []; + + public ICollection Attachments { get; set; } = []; +} diff --git a/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequestLine.cs b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequestLine.cs new file mode 100644 index 0000000..6c1864a --- /dev/null +++ b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequestLine.cs @@ -0,0 +1,17 @@ +using iLED.Domain.Common; +using iLED.Domain.Orders; + +namespace iLED.Domain.AfterSales; + +public class AfterSalesRequestLine : BaseEntity +{ + public Guid AfterSalesRequestId { get; set; } + + public AfterSalesRequest AfterSalesRequest { get; set; } = null!; + + public Guid OrderLineId { get; set; } + + public OrderLine OrderLine { get; set; } = null!; + + public int Quantity { get; set; } +} diff --git a/src/backend/src/iLED.Domain/Enums/AfterSalesEnums.cs b/src/backend/src/iLED.Domain/Enums/AfterSalesEnums.cs new file mode 100644 index 0000000..9b0ed8e --- /dev/null +++ b/src/backend/src/iLED.Domain/Enums/AfterSalesEnums.cs @@ -0,0 +1,28 @@ +namespace iLED.Domain.Enums; + +public enum AfterSalesRequestType +{ + Withdrawal = 0, + Defect = 1, + MissingPart = 2, + WrongItem = 3, + Other = 4, +} + +public enum AfterSalesRequestStatus +{ + Submitted = 0, + InReview = 1, + Approved = 2, + Rejected = 3, + Resolved = 4, +} + +public enum AfterSalesResolution +{ + Refund = 0, + Replacement = 1, + SendMissingPart = 2, + Reject = 3, + Other = 4, +} diff --git a/src/backend/src/iLED.Domain/Orders/Order.cs b/src/backend/src/iLED.Domain/Orders/Order.cs index c741e3d..fd18dbf 100644 --- a/src/backend/src/iLED.Domain/Orders/Order.cs +++ b/src/backend/src/iLED.Domain/Orders/Order.cs @@ -74,8 +74,18 @@ public class Order : AuditableEntity public Marketing.DiscountCode? DiscountCode { get; set; } + /// + /// SHA-256 hash of guest after-sales access token (plain token emailed). + /// + public string? AfterSalesAccessTokenHash { get; set; } + + public DateTime? AfterSalesAccessTokenExpiresAt { get; set; } + public ICollection Lines { get; set; } = []; + public ICollection AfterSalesRequests { get; set; } = + []; + public Payments.Payment? Payment { get; set; } public Shipping.Shipment? Shipment { get; set; } diff --git a/src/backend/src/iLED.Infrastructure/DependencyInjection.cs b/src/backend/src/iLED.Infrastructure/DependencyInjection.cs index 10cfaa8..a2f92a6 100644 --- a/src/backend/src/iLED.Infrastructure/DependencyInjection.cs +++ b/src/backend/src/iLED.Infrastructure/DependencyInjection.cs @@ -69,6 +69,11 @@ public static class DependencyInjection services.Configure( configuration.GetSection(ProductImageStorageOptions.SectionName)); services.AddScoped(); + services.Configure( + configuration.GetSection(AfterSalesImageStorageOptions.SectionName)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddStackExchangeRedisCache(options => { diff --git a/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index b0074b6..0725194 100644 --- a/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -357,6 +357,124 @@ namespace iLED.Infrastructure.Migrations b.ToTable("StoreSettings"); }); + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterSalesRequestId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OriginalFileName") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AfterSalesRequestId"); + + b.ToTable("AfterSalesAttachments"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminNote") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PayURefundId") + .HasColumnType("text"); + + b.Property("RefundAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Resolution") + .HasColumnType("integer"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedByAdminId") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Status"); + + b.ToTable("AfterSalesRequests"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterSalesRequestId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderLineId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AfterSalesRequestId"); + + b.HasIndex("OrderLineId"); + + b.ToTable("AfterSalesRequestLines"); + }); + modelBuilder.Entity("iLED.Domain.Cart.Cart", b => { b.Property("Id") @@ -1306,6 +1424,12 @@ namespace iLED.Infrastructure.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AfterSalesAccessTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AfterSalesAccessTokenHash") + .HasColumnType("text"); + b.Property("BillingCity") .HasColumnType("text"); @@ -1647,6 +1771,47 @@ namespace iLED.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b => + { + b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest") + .WithMany("Attachments") + .HasForeignKey("AfterSalesRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AfterSalesRequest"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b => + { + b.HasOne("iLED.Domain.Orders.Order", "Order") + .WithMany("AfterSalesRequests") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b => + { + b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest") + .WithMany("Lines") + .HasForeignKey("AfterSalesRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("iLED.Domain.Orders.OrderLine", "OrderLine") + .WithMany() + .HasForeignKey("OrderLineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AfterSalesRequest"); + + b.Navigation("OrderLine"); + }); + modelBuilder.Entity("iLED.Domain.Cart.CartItem", b => { b.HasOne("iLED.Domain.Cart.Cart", "Cart") @@ -2147,6 +2312,13 @@ namespace iLED.Infrastructure.Migrations b.Navigation("Order"); }); + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b => + { + b.Navigation("Attachments"); + + b.Navigation("Lines"); + }); + modelBuilder.Entity("iLED.Domain.Cart.Cart", b => { b.Navigation("Items"); @@ -2214,6 +2386,8 @@ namespace iLED.Infrastructure.Migrations modelBuilder.Entity("iLED.Domain.Orders.Order", b => { + b.Navigation("AfterSalesRequests"); + b.Navigation("Lines"); b.Navigation("Payment"); diff --git a/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs b/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs index 7812d1d..83fae63 100644 --- a/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs @@ -1,4 +1,5 @@ using iLED.Domain.Admin; +using iLED.Domain.AfterSales; using iLED.Domain.Cart; using iLED.Domain.Catalog; using iLED.Domain.Identity; @@ -48,6 +49,14 @@ public class ApplicationDbContext(DbContextOptions options public DbSet OrderLines => Set(); + public DbSet AfterSalesRequests => Set(); + + public DbSet AfterSalesRequestLines => + Set(); + + public DbSet AfterSalesAttachments => + Set(); + public DbSet Payments => Set(); public DbSet Shipments => Set(); @@ -117,6 +126,17 @@ public class ApplicationDbContext(DbContextOptions options IQueryable Application.Common.Interfaces.IAppDbContext.Orders => Orders; + IQueryable Application.Common.Interfaces.IAppDbContext.OrderLines => + OrderLines; + + IQueryable + Application.Common.Interfaces.IAppDbContext.AfterSalesRequests => + AfterSalesRequests; + + IQueryable + Application.Common.Interfaces.IAppDbContext.AfterSalesAttachments => + AfterSalesAttachments; + IQueryable Application.Common.Interfaces.IAppDbContext.Payments => Payments; @@ -287,5 +307,39 @@ public class ApplicationDbContext(DbContextOptions options .WithOne(o => o.Shipment) .HasForeignKey(s => s.OrderId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasOne(r => r.Order) + .WithMany(o => o.AfterSalesRequests) + .HasForeignKey(r => r.OrderId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasIndex(r => r.Status); + + builder.Entity() + .HasIndex(r => r.OrderId); + + builder.Entity() + .Property(r => r.RefundAmount) + .HasPrecision(18, 2); + + builder.Entity() + .HasOne(l => l.AfterSalesRequest) + .WithMany(r => r.Lines) + .HasForeignKey(l => l.AfterSalesRequestId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasOne(l => l.OrderLine) + .WithMany() + .HasForeignKey(l => l.OrderLineId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne(a => a.AfterSalesRequest) + .WithMany(r => r.Attachments) + .HasForeignKey(a => a.AfterSalesRequestId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.Designer.cs b/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.Designer.cs new file mode 100644 index 0000000..9580fa6 --- /dev/null +++ b/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.Designer.cs @@ -0,0 +1,2403 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using iLED.Infrastructure.Persistence; + +#nullable disable + +namespace iLED.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20261019182006_AfterSalesRequests")] + partial class AfterSalesRequests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("iLED.Domain.Admin.StoreSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllegroClientId") + .HasColumnType("text"); + + b.Property("AllegroClientSecret") + .HasColumnType("text"); + + b.Property("AllegroEnabled") + .HasColumnType("boolean"); + + b.Property("AllegroLastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AllegroSellerId") + .HasColumnType("text"); + + b.Property("AmazonAccessKey") + .HasColumnType("text"); + + b.Property("AmazonEnabled") + .HasColumnType("boolean"); + + b.Property("AmazonLastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AmazonMarketplaceId") + .HasColumnType("text"); + + b.Property("AmazonSecretKey") + .HasColumnType("text"); + + b.Property("AmazonSellerId") + .HasColumnType("text"); + + b.Property("CarrierDhl") + .HasColumnType("boolean"); + + b.Property("CarrierDpd") + .HasColumnType("boolean"); + + b.Property("CarrierInPost") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("DhlApiBaseUrl") + .HasColumnType("text"); + + b.Property("DhlApiKey") + .HasColumnType("text"); + + b.Property("DhlApiSecret") + .HasColumnType("text"); + + b.Property("DhlWidgetUrl") + .HasColumnType("text"); + + b.Property("DpdApiBaseUrl") + .HasColumnType("text"); + + b.Property("DpdFid") + .HasColumnType("text"); + + b.Property("DpdLogin") + .HasColumnType("text"); + + b.Property("DpdPassword") + .HasColumnType("text"); + + b.Property("DpdWidgetUrl") + .HasColumnType("text"); + + b.Property("EmailFromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("EmailFromName") + .IsRequired() + .HasColumnType("text"); + + b.Property("EmailReplyTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("IfirmaApiKey") + .HasColumnType("text"); + + b.Property("IfirmaAutoInvoice") + .HasColumnType("boolean"); + + b.Property("IfirmaEnabled") + .HasColumnType("boolean"); + + b.Property("IfirmaInvoiceSeries") + .HasColumnType("text"); + + b.Property("IfirmaLogin") + .HasColumnType("text"); + + b.Property("InPostGeowidgetConfig") + .HasColumnType("text"); + + b.Property("InPostGeowidgetPublicToken") + .HasColumnType("text"); + + b.Property("InPostGeowidgetSandboxToken") + .HasColumnType("text"); + + b.Property("InPostGeowidgetSdkBaseUrl") + .HasColumnType("text"); + + b.Property("InPostOrganizationId") + .HasColumnType("text"); + + b.Property("InPostShipXBaseUrl") + .HasColumnType("text"); + + b.Property("InPostShipXToken") + .HasColumnType("text"); + + b.Property("LegalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LocaleCs") + .HasColumnType("boolean"); + + b.Property("LocaleDe") + .HasColumnType("boolean"); + + b.Property("LocaleEn") + .HasColumnType("boolean"); + + b.Property("LocalePl") + .HasColumnType("boolean"); + + b.Property("LowStockThreshold") + .HasColumnType("integer"); + + b.Property("PayUClientId") + .HasColumnType("text"); + + b.Property("PayUClientSecret") + .HasColumnType("text"); + + b.Property("PayUContinueUrl") + .HasColumnType("text"); + + b.Property("PayUNotifyUrl") + .HasColumnType("text"); + + b.Property("PayUOauthUrl") + .HasColumnType("text"); + + b.Property("PayUOrdersUrl") + .HasColumnType("text"); + + b.Property("PayUPosId") + .HasColumnType("text"); + + b.Property("PayUSecondKey") + .HasColumnType("text"); + + b.Property("PayUUseSandbox") + .HasColumnType("boolean"); + + b.Property("ShippingUseSandbox") + .HasColumnType("boolean"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("text"); + + b.Property("StorefrontUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupportEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("StoreSettings"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterSalesRequestId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OriginalFileName") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AfterSalesRequestId"); + + b.ToTable("AfterSalesAttachments"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminNote") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PayURefundId") + .HasColumnType("text"); + + b.Property("RefundAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Resolution") + .HasColumnType("integer"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedByAdminId") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("Status"); + + b.ToTable("AfterSalesRequests"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterSalesRequestId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderLineId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AfterSalesRequestId"); + + b.HasIndex("OrderLineId"); + + b.ToTable("AfterSalesRequestLines"); + }); + + modelBuilder.Entity("iLED.Domain.Cart.Cart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Carts"); + }); + + modelBuilder.Entity("iLED.Domain.Cart.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CartId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CartId"); + + b.ToTable("CartItems"); + }); + + modelBuilder.Entity("iLED.Domain.Cart.FavoriteItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("FavoriteItems"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentCategoryId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentCategoryId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BasePrice") + .HasColumnType("numeric"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DeliveryHours") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ean") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBestseller") + .HasColumnType("boolean"); + + b.Property("IsNewArrival") + .HasColumnType("boolean"); + + b.Property("LowestPrice30Days") + .HasColumnType("numeric"); + + b.Property("Manufacturer") + .IsRequired() + .HasColumnType("text"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProductCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProductGroupId") + .HasColumnType("uuid"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ProductGroupId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilterGroup") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductAttributes"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductChannelPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Channel") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExternalOfferId") + .HasColumnType("text"); + + b.Property("ExternalSku") + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId", "Channel") + .IsUnique(); + + b.ToTable("ProductChannelPrices"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ProductGroups"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AltText") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductImages"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ColorHex") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("PriceAdjustment") + .HasColumnType("numeric"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.ToTable("ProductVariants"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("PublicReply") + .HasColumnType("text"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("RepliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Stock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LowStockThreshold") + .HasColumnType("integer"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProductVariantId") + .IsUnique(); + + b.ToTable("Stocks"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Warehouses"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.WarehouseStock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WarehouseId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ProductVariantId", "WarehouseId") + .IsUnique(); + + b.ToTable("WarehouseStocks"); + }); + + modelBuilder.Entity("iLED.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("iLED.Domain.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.CampaignSend", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDelivered") + .HasColumnType("boolean"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("CampaignSends"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.DiscountCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("DiscountRuleId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IssuedForOrderId") + .HasColumnType("uuid"); + + b.Property("IssuedToEmail") + .HasColumnType("text"); + + b.Property("MaxUsages") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UsageCount") + .HasColumnType("integer"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("DiscountRuleId"); + + b.ToTable("DiscountCodes"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.DiscountRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("DiscountAmount") + .HasColumnType("numeric"); + + b.Property("DiscountCodeId") + .HasColumnType("uuid"); + + b.Property("DiscountRuleId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DiscountCodeId"); + + b.HasIndex("DiscountRuleId", "CustomerEmail"); + + b.ToTable("DiscountRedemptions"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.DiscountRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BannerHighlightedCode") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IssueRewardOnPaidOrder") + .HasColumnType("boolean"); + + b.Property("MaxRedemptionsPerCustomer") + .HasColumnType("integer"); + + b.Property("MaxTotalRedemptions") + .HasColumnType("integer"); + + b.Property("MinimumOrderAmount") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RewardCodePrefix") + .HasColumnType("text"); + + b.Property("RewardMinPaidOrderTotal") + .HasColumnType("numeric"); + + b.Property("RewardValidDays") + .HasColumnType("integer"); + + b.Property("ShowInBanner") + .HasColumnType("boolean"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidTo") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.ToTable("DiscountRules"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.NewsletterCampaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHtml") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("NewsletterCampaigns"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.NewsletterSubscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UnsubscribedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("NewsletterSubscribers"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.PromoBanner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("HighlightedCode") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PromoBanners"); + }); + + modelBuilder.Entity("iLED.Domain.Orders.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AfterSalesAccessTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AfterSalesAccessTokenHash") + .HasColumnType("text"); + + b.Property("BillingCity") + .HasColumnType("text"); + + b.Property("BillingCountry") + .HasColumnType("text"); + + b.Property("BillingPostalCode") + .HasColumnType("text"); + + b.Property("BillingStreet") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CustomerEmail") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerFirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerLastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CustomerNotes") + .HasColumnType("text"); + + b.Property("CustomerPhone") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeliveryCost") + .HasColumnType("numeric"); + + b.Property("DiscountAmount") + .HasColumnType("numeric"); + + b.Property("DiscountCodeId") + .HasColumnType("uuid"); + + b.Property("ExternalOrderId") + .HasColumnType("text"); + + b.Property("Locale") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParcelPointAddress") + .HasColumnType("text"); + + b.Property("ParcelPointId") + .HasColumnType("text"); + + b.Property("ParcelPointName") + .HasColumnType("text"); + + b.Property("PaymentMethod") + .HasColumnType("integer"); + + b.Property("ShippingCarrier") + .HasColumnType("integer"); + + b.Property("ShippingCity") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShippingCountry") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShippingPostalCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShippingServiceType") + .HasColumnType("integer"); + + b.Property("ShippingStreet") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Subtotal") + .HasColumnType("numeric"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("VatAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("DiscountCodeId"); + + b.HasIndex("OrderNumber") + .IsUnique(); + + b.HasIndex("Source", "ExternalOrderId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("iLED.Domain.Orders.OrderLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CollectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PickedFromWarehouseId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProductVariantId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("text"); + + b.Property("UnitPrice") + .HasColumnType("numeric"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VariantColor") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("PickedFromWarehouseId"); + + b.ToTable("OrderLines"); + }); + + modelBuilder.Entity("iLED.Domain.Payments.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("RawResponse") + .HasColumnType("text"); + + b.Property("RedirectUrl") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("iLED.Domain.Shipping.Shipment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Carrier") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("ExternalShipmentId") + .HasColumnType("text"); + + b.Property("LabelRelativePath") + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("ParcelPointAddress") + .HasColumnType("text"); + + b.Property("ParcelPointId") + .HasColumnType("text"); + + b.Property("ParcelPointName") + .HasColumnType("text"); + + b.Property("ServiceType") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TrackingNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique(); + + b.ToTable("Shipments"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("iLED.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("iLED.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("iLED.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("iLED.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b => + { + b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest") + .WithMany("Attachments") + .HasForeignKey("AfterSalesRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AfterSalesRequest"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b => + { + b.HasOne("iLED.Domain.Orders.Order", "Order") + .WithMany("AfterSalesRequests") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b => + { + b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest") + .WithMany("Lines") + .HasForeignKey("AfterSalesRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("iLED.Domain.Orders.OrderLine", "OrderLine") + .WithMany() + .HasForeignKey("OrderLineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AfterSalesRequest"); + + b.Navigation("OrderLine"); + }); + + modelBuilder.Entity("iLED.Domain.Cart.CartItem", b => + { + b.HasOne("iLED.Domain.Cart.Cart", "Cart") + .WithMany("Items") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cart"); + }); + + modelBuilder.Entity("iLED.Domain.Cart.FavoriteItem", b => + { + b.HasOne("iLED.Domain.Catalog.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Category", b => + { + b.HasOne("iLED.Domain.Catalog.Category", "ParentCategory") + .WithMany("SubCategories") + .HasForeignKey("ParentCategoryId"); + + b.Navigation("ParentCategory"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Product", b => + { + b.HasOne("iLED.Domain.Catalog.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("iLED.Domain.Catalog.ProductGroup", "ProductGroup") + .WithMany("Products") + .HasForeignKey("ProductGroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "Descriptions", b1 => + { + b1.Property("ProductId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1 + .ToJson("Descriptions") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "Names", b1 => + { + b1.Property("ProductId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1 + .ToJson("Names") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "ShortDescriptions", b1 => + { + b1.Property("ProductId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("ProductId"); + + b1.ToTable("Products"); + + b1 + .ToJson("ShortDescriptions") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Category"); + + b.Navigation("Descriptions") + .IsRequired(); + + b.Navigation("Names") + .IsRequired(); + + b.Navigation("ProductGroup"); + + b.Navigation("ShortDescriptions") + .IsRequired(); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductAttribute", b => + { + b.HasOne("iLED.Domain.Catalog.Product", "Product") + .WithMany("Attributes") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "Names", b1 => + { + b1.Property("ProductAttributeId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("ProductAttributeId"); + + b1.ToTable("ProductAttributes"); + + b1 + .ToJson("Names") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductAttributeId"); + }); + + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "Values", b1 => + { + b1.Property("ProductAttributeId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("ProductAttributeId"); + + b1.ToTable("ProductAttributes"); + + b1 + .ToJson("Values") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductAttributeId"); + }); + + b.Navigation("Names") + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("Values") + .IsRequired(); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductChannelPrice", b => + { + b.HasOne("iLED.Domain.Catalog.Product", "Product") + .WithMany("ChannelPrices") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductGroup", b => + { + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "Names", b1 => + { + b1.Property("ProductGroupId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("ProductGroupId"); + + b1.ToTable("ProductGroups"); + + b1 + .ToJson("Names") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("ProductGroupId"); + }); + + b.Navigation("Names") + .IsRequired(); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductImage", b => + { + b.HasOne("iLED.Domain.Catalog.Product", "Product") + .WithMany("Images") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductVariant", b => + { + b.HasOne("iLED.Domain.Catalog.Product", "Product") + .WithMany("Variants") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Review", b => + { + b.HasOne("iLED.Domain.Catalog.Product", "Product") + .WithMany("Reviews") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Stock", b => + { + b.HasOne("iLED.Domain.Catalog.ProductVariant", "ProductVariant") + .WithOne("Stock") + .HasForeignKey("iLED.Domain.Catalog.Stock", "ProductVariantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProductVariant"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.WarehouseStock", b => + { + b.HasOne("iLED.Domain.Catalog.ProductVariant", "ProductVariant") + .WithMany("WarehouseStocks") + .HasForeignKey("ProductVariantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("iLED.Domain.Catalog.Warehouse", "Warehouse") + .WithMany("Stocks") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProductVariant"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("iLED.Domain.Identity.RefreshToken", b => + { + b.HasOne("iLED.Domain.Identity.ApplicationUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.CampaignSend", b => + { + b.HasOne("iLED.Domain.Marketing.NewsletterCampaign", "Campaign") + .WithMany("Sends") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("iLED.Domain.Marketing.NewsletterSubscriber", "Subscriber") + .WithMany("CampaignSends") + .HasForeignKey("SubscriberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Subscriber"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.DiscountCode", b => + { + b.HasOne("iLED.Domain.Marketing.DiscountRule", "DiscountRule") + .WithMany("Codes") + .HasForeignKey("DiscountRuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DiscountRule"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.DiscountRedemption", b => + { + b.HasOne("iLED.Domain.Marketing.DiscountCode", "DiscountCode") + .WithMany() + .HasForeignKey("DiscountCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("iLED.Domain.Marketing.DiscountRule", "DiscountRule") + .WithMany("Redemptions") + .HasForeignKey("DiscountRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DiscountCode"); + + b.Navigation("DiscountRule"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.DiscountRule", b => + { + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "BannerTexts", b1 => + { + b1.Property("DiscountRuleId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("DiscountRuleId"); + + b1.ToTable("DiscountRules"); + + b1 + .ToJson("BannerTexts") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("DiscountRuleId"); + }); + + b.Navigation("BannerTexts") + .IsRequired(); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.PromoBanner", b => + { + b.OwnsOne("iLED.Domain.Common.LocalizedStrings", "Texts", b1 => + { + b1.Property("PromoBannerId"); + + b1.Property("Cs") + .IsRequired(); + + b1.Property("De") + .IsRequired(); + + b1.Property("En") + .IsRequired(); + + b1.Property("Pl") + .IsRequired(); + + b1.HasKey("PromoBannerId"); + + b1.ToTable("PromoBanners"); + + b1 + .ToJson("Texts") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("PromoBannerId"); + }); + + b.Navigation("Texts") + .IsRequired(); + }); + + modelBuilder.Entity("iLED.Domain.Orders.Order", b => + { + b.HasOne("iLED.Domain.Marketing.DiscountCode", "DiscountCode") + .WithMany() + .HasForeignKey("DiscountCodeId"); + + b.Navigation("DiscountCode"); + }); + + modelBuilder.Entity("iLED.Domain.Orders.OrderLine", b => + { + b.HasOne("iLED.Domain.Orders.Order", "Order") + .WithMany("Lines") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("iLED.Domain.Catalog.Warehouse", "PickedFromWarehouse") + .WithMany() + .HasForeignKey("PickedFromWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Order"); + + b.Navigation("PickedFromWarehouse"); + }); + + modelBuilder.Entity("iLED.Domain.Payments.Payment", b => + { + b.HasOne("iLED.Domain.Orders.Order", "Order") + .WithOne("Payment") + .HasForeignKey("iLED.Domain.Payments.Payment", "OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("iLED.Domain.Shipping.Shipment", b => + { + b.HasOne("iLED.Domain.Orders.Order", "Order") + .WithOne("Shipment") + .HasForeignKey("iLED.Domain.Shipping.Shipment", "OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b => + { + b.Navigation("Attachments"); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("iLED.Domain.Cart.Cart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Category", b => + { + b.Navigation("Products"); + + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Product", b => + { + b.Navigation("Attributes"); + + b.Navigation("ChannelPrices"); + + b.Navigation("Images"); + + b.Navigation("Reviews"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductGroup", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.ProductVariant", b => + { + b.Navigation("Stock") + .IsRequired(); + + b.Navigation("WarehouseStocks"); + }); + + modelBuilder.Entity("iLED.Domain.Catalog.Warehouse", b => + { + b.Navigation("Stocks"); + }); + + modelBuilder.Entity("iLED.Domain.Identity.ApplicationUser", b => + { + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.DiscountRule", b => + { + b.Navigation("Codes"); + + b.Navigation("Redemptions"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.NewsletterCampaign", b => + { + b.Navigation("Sends"); + }); + + modelBuilder.Entity("iLED.Domain.Marketing.NewsletterSubscriber", b => + { + b.Navigation("CampaignSends"); + }); + + modelBuilder.Entity("iLED.Domain.Orders.Order", b => + { + b.Navigation("AfterSalesRequests"); + + b.Navigation("Lines"); + + b.Navigation("Payment"); + + b.Navigation("Shipment"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.cs b/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.cs new file mode 100644 index 0000000..4a5b2bf --- /dev/null +++ b/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.cs @@ -0,0 +1,154 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace iLED.Infrastructure.Persistence.Migrations +{ + /// + public partial class AfterSalesRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AfterSalesAccessTokenExpiresAt", + table: "Orders", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "AfterSalesAccessTokenHash", + table: "Orders", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "AfterSalesRequests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "text", nullable: true), + Type = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CustomerNote = table.Column(type: "text", nullable: false), + AdminNote = table.Column(type: "text", nullable: true), + Resolution = table.Column(type: "integer", nullable: true), + RefundAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true), + PayURefundId = table.Column(type: "text", nullable: true), + ResolvedAt = table.Column(type: "timestamp with time zone", nullable: true), + ResolvedByAdminId = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AfterSalesRequests", x => x.Id); + table.ForeignKey( + name: "FK_AfterSalesRequests_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AfterSalesAttachments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AfterSalesRequestId = table.Column(type: "uuid", nullable: false), + Url = table.Column(type: "text", nullable: false), + OriginalFileName = table.Column(type: "text", nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AfterSalesAttachments", x => x.Id); + table.ForeignKey( + name: "FK_AfterSalesAttachments_AfterSalesRequests_AfterSalesRequestId", + column: x => x.AfterSalesRequestId, + principalTable: "AfterSalesRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AfterSalesRequestLines", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AfterSalesRequestId = table.Column(type: "uuid", nullable: false), + OrderLineId = table.Column(type: "uuid", nullable: false), + Quantity = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AfterSalesRequestLines", x => x.Id); + table.ForeignKey( + name: "FK_AfterSalesRequestLines_AfterSalesRequests_AfterSalesRequest~", + column: x => x.AfterSalesRequestId, + principalTable: "AfterSalesRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AfterSalesRequestLines_OrderLines_OrderLineId", + column: x => x.OrderLineId, + principalTable: "OrderLines", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AfterSalesAttachments_AfterSalesRequestId", + table: "AfterSalesAttachments", + column: "AfterSalesRequestId"); + + migrationBuilder.CreateIndex( + name: "IX_AfterSalesRequestLines_AfterSalesRequestId", + table: "AfterSalesRequestLines", + column: "AfterSalesRequestId"); + + migrationBuilder.CreateIndex( + name: "IX_AfterSalesRequestLines_OrderLineId", + table: "AfterSalesRequestLines", + column: "OrderLineId"); + + migrationBuilder.CreateIndex( + name: "IX_AfterSalesRequests_OrderId", + table: "AfterSalesRequests", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_AfterSalesRequests_Status", + table: "AfterSalesRequests", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AfterSalesAttachments"); + + migrationBuilder.DropTable( + name: "AfterSalesRequestLines"); + + migrationBuilder.DropTable( + name: "AfterSalesRequests"); + + migrationBuilder.DropColumn( + name: "AfterSalesAccessTokenExpiresAt", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "AfterSalesAccessTokenHash", + table: "Orders"); + } + } +} diff --git a/src/backend/src/iLED.Infrastructure/Services/AfterSalesAccessService.cs b/src/backend/src/iLED.Infrastructure/Services/AfterSalesAccessService.cs new file mode 100644 index 0000000..9226f73 --- /dev/null +++ b/src/backend/src/iLED.Infrastructure/Services/AfterSalesAccessService.cs @@ -0,0 +1,97 @@ +using System.Security.Cryptography; +using System.Text; +using iLED.Application.Common.Interfaces; +using iLED.Domain.Orders; +using Microsoft.Extensions.Configuration; + +namespace iLED.Infrastructure.Services; + +public sealed class AfterSalesAccessService(ICurrentUserService currentUser) + : IAfterSalesAccessService +{ + public static readonly TimeSpan DefaultLifetime = TimeSpan.FromDays(30); + + public string IssueAccessToken(Order order, TimeSpan? lifetime = null) + { + var plain = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)) + .ToLowerInvariant(); + order.AfterSalesAccessTokenHash = Hash(plain); + order.AfterSalesAccessTokenExpiresAt = + DateTime.UtcNow.Add(lifetime ?? DefaultLifetime); + return plain; + } + + public bool ValidateAccessToken(Order order, string? plainToken) + { + if (string.IsNullOrWhiteSpace(plainToken) || + string.IsNullOrWhiteSpace(order.AfterSalesAccessTokenHash) || + order.AfterSalesAccessTokenExpiresAt is null || + order.AfterSalesAccessTokenExpiresAt < DateTime.UtcNow) + { + return false; + } + + var hash = Hash(plainToken.Trim()); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(hash), + Encoding.UTF8.GetBytes(order.AfterSalesAccessTokenHash)); + } + + public bool CanAccessOrder(Order order, string? plainToken) + { + if (currentUser.IsAdmin) + { + return true; + } + + if (currentUser.IsAuthenticated && + !string.IsNullOrWhiteSpace(currentUser.UserId) && + string.Equals( + order.UserId, + currentUser.UserId, + StringComparison.Ordinal)) + { + return true; + } + + return ValidateAccessToken(order, plainToken); + } + + public static string Hash(string plainToken) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(plainToken)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} + +public sealed class AfterSalesEmailService( + IEmailService email, + IConfiguration configuration) : IAfterSalesEmailService +{ + public async Task SendAccessLinkAsync( + Order order, + string plainToken, + CancellationToken cancellationToken = default) + { + var shopBase = configuration["Seo:ShopBaseUrl"] ?? "http://localhost:4200"; + var link = + $"{shopBase.TrimEnd('/')}/after-sales" + + $"?orderId={order.Id}" + + $"&token={Uri.EscapeDataString(plainToken)}"; + + var subject = $"iLED — return / report issue · {order.OrderNumber}"; + var body = $""" +

Hello {System.Net.WebUtility.HtmlEncode(order.CustomerFirstName)},

+

Use the link below to request a return or report a problem with order + {System.Net.WebUtility.HtmlEncode(order.OrderNumber)}.

+

Open after-sales form

+

This link expires in 30 days. If you did not place this order, ignore this email.

+ """; + + await email.SendAsync( + order.CustomerEmail, + subject, + body, + cancellationToken); + } +} diff --git a/src/backend/src/iLED.Infrastructure/Services/AfterSalesImageStorage.cs b/src/backend/src/iLED.Infrastructure/Services/AfterSalesImageStorage.cs new file mode 100644 index 0000000..1cda17c --- /dev/null +++ b/src/backend/src/iLED.Infrastructure/Services/AfterSalesImageStorage.cs @@ -0,0 +1,81 @@ +using iLED.Application.Common.Interfaces; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Options; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.Processing; + +namespace iLED.Infrastructure.Services; + +public sealed class AfterSalesImageStorageOptions +{ + public const string SectionName = "AfterSalesImages"; + + public string PublicPathPrefix { get; set; } = "/uploads/after-sales"; + + public int MaxWidth { get; set; } = 1600; + + public int WebpQuality { get; set; } = 82; + + public long MaxFileSizeBytes { get; set; } = 8 * 1024 * 1024; +} + +public sealed class AfterSalesImageStorage( + IWebHostEnvironment environment, + IOptions options) + : IAfterSalesImageStorage +{ + private static readonly HashSet AllowedExtensions = + [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".bmp", + ]; + + private readonly AfterSalesImageStorageOptions _options = options.Value; + + public async Task SaveAsWebpAsync( + Stream input, + string originalFileName, + CancellationToken cancellationToken) + { + var extension = Path.GetExtension(originalFileName).ToLowerInvariant(); + if (!AllowedExtensions.Contains(extension)) + { + throw new Application.Common.Exceptions.ValidationException( + "Unsupported file format. Allowed: JPG, PNG, GIF, WebP, BMP."); + } + + var webRoot = environment.WebRootPath + ?? Path.Combine(environment.ContentRootPath, "wwwroot"); + var uploadDir = Path.Combine(webRoot, "uploads", "after-sales"); + Directory.CreateDirectory(uploadDir); + + var fileName = $"{Guid.NewGuid():N}.webp"; + var fullPath = Path.Combine(uploadDir, fileName); + + using var image = await Image.LoadAsync(input, cancellationToken); + image.Mutate(x => x.AutoOrient()); + + if (image.Width > _options.MaxWidth) + { + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(_options.MaxWidth, 0), + Mode = ResizeMode.Max, + })); + } + + var encoder = new WebpEncoder + { + Quality = _options.WebpQuality, + }; + + await image.SaveAsync(fullPath, encoder, cancellationToken); + + return $"{_options.PublicPathPrefix}/{fileName}"; + } +} diff --git a/src/backend/src/iLED.Infrastructure/Services/ExternalServices.cs b/src/backend/src/iLED.Infrastructure/Services/ExternalServices.cs index 1266e7c..59b530d 100644 --- a/src/backend/src/iLED.Infrastructure/Services/ExternalServices.cs +++ b/src/backend/src/iLED.Infrastructure/Services/ExternalServices.cs @@ -320,6 +320,140 @@ public sealed class PayUPaymentService( } } + public async Task RefundPaymentAsync( + PaymentRefundRequest request, + CancellationToken cancellationToken = default) + { + var settings = await integrationSettings.GetAsync(cancellationToken); + var clientId = settings.PayUClientId; + var clientSecret = settings.PayUClientSecret; + var oauthUrl = settings.PayUOauthUrl; + var ordersUrl = settings.PayUOrdersUrl; + var useSandbox = settings.PayUUseSandbox; + var hasCredentials = + !string.IsNullOrWhiteSpace(clientId) && + !string.IsNullOrWhiteSpace(clientSecret) && + !string.IsNullOrWhiteSpace(oauthUrl) && + !string.IsNullOrWhiteSpace(ordersUrl); + + if (!hasCredentials) + { + if (!useSandbox) + { + return new PaymentRefundResult( + false, + null, + "Configure PayU credentials in Settings → Integrations."); + } + + var mockId = $"REFUND-{Guid.NewGuid():N}"; + logger.LogWarning( + "PayU credentials missing — mocking refund {RefundId} for {ExternalId}", + mockId, + request.ExternalId); + return new PaymentRefundResult(true, mockId, null); + } + + try + { + var token = await GetAccessTokenAsync( + oauthUrl!, + clientId!, + clientSecret!, + cancellationToken); + + var amountMinor = ((int)Math.Round( + request.Amount * 100m, + MidpointRounding.AwayFromZero)) + .ToString(CultureInfo.InvariantCulture); + + var refundUrl = + $"{ordersUrl!.TrimEnd('/')}/{Uri.EscapeDataString(request.ExternalId)}/refunds"; + + var body = new + { + refund = new + { + description = string.IsNullOrWhiteSpace(request.Description) + ? "Customer refund" + : request.Description, + amount = amountMinor, + }, + }; + + var client = httpClientFactory.CreateClient(nameof(PayUPaymentService)); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, refundUrl); + httpRequest.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + httpRequest.Content = new StringContent( + JsonSerializer.Serialize(body), + Encoding.UTF8, + "application/json"); + + using var response = await client.SendAsync( + httpRequest, + cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync( + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + logger.LogError( + "PayU refund failed: {Status} {Body}", + (int)response.StatusCode, + responseBody); + return new PaymentRefundResult( + false, + null, + "PayU refund failed."); + } + + string? refundId = null; + if (!string.IsNullOrWhiteSpace(responseBody)) + { + try + { + using var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.TryGetProperty("refund", out var refundEl) && + refundEl.TryGetProperty("refundId", out var idEl)) + { + refundId = idEl.GetString(); + } + else if (doc.RootElement.TryGetProperty("orderId", out var oid)) + { + refundId = oid.GetString(); + } + } + catch (JsonException) + { + // ignore parse errors; HTTP success is enough + } + } + + logger.LogInformation( + "PayU refund created for {ExternalId}: {RefundId} amount={Amount}", + request.ExternalId, + refundId, + request.Amount); + + return new PaymentRefundResult( + true, + refundId ?? $"REFUND-{Guid.NewGuid():N}", + null); + } + catch (Exception ex) + { + logger.LogError( + ex, + "PayU refund error for {ExternalId}", + request.ExternalId); + return new PaymentRefundResult( + false, + null, + "PayU refund failed."); + } + } + public async Task HandleWebhookAsync( string payload, string signature, diff --git a/src/backend/src/iLED.Infrastructure/Services/OrderService.cs b/src/backend/src/iLED.Infrastructure/Services/OrderService.cs index 4c72306..efebc87 100644 --- a/src/backend/src/iLED.Infrastructure/Services/OrderService.cs +++ b/src/backend/src/iLED.Infrastructure/Services/OrderService.cs @@ -15,7 +15,9 @@ public sealed class OrderService( Persistence.ApplicationDbContext db, ICartService cartService, ICurrentUserService currentUser, - IPromotionRewardService promotionRewards) : IOrderService + IPromotionRewardService promotionRewards, + IAfterSalesAccessService afterSalesAccess, + IAfterSalesEmailService afterSalesEmail) : IOrderService { private const decimal VatRate = 0.23m; private const decimal CourierDeliveryCost = 15.99m; @@ -142,6 +144,7 @@ public sealed class OrderService( }).ToList(), }; + var afterSalesToken = afterSalesAccess.IssueAccessToken(order); db.Orders.Add(order); if (appliedCode is not null) @@ -167,6 +170,19 @@ public sealed class OrderService( } await db.SaveChangesAsync(cancellationToken); + + try + { + await afterSalesEmail.SendAccessLinkAsync( + order, + afterSalesToken, + cancellationToken); + } + catch + { + // Order already persisted; email failures must not roll back checkout. + } + return MapOrder(order); } diff --git a/src/shop/src/app/app.routes.ts b/src/shop/src/app/app.routes.ts index 6509229..1417423 100644 --- a/src/shop/src/app/app.routes.ts +++ b/src/shop/src/app/app.routes.ts @@ -71,6 +71,17 @@ export const routes: Routes = [ descriptionKey: 'meta.order.description', }, }, + { + path: 'after-sales', + loadComponent: () => + import('./features/after-sales/after-sales.component').then( + (m) => m.AfterSalesComponent, + ), + data: { + titleKey: 'meta.afterSales.title', + descriptionKey: 'meta.afterSales.description', + }, + }, { path: 'login', loadComponent: () => @@ -211,6 +222,7 @@ export const routes: Routes = [ { path: 'kontakt', redirectTo: 'contact', pathMatch: 'full' }, { path: 'dostawa', redirectTo: 'shipping', pathMatch: 'full' }, { path: 'zwroty', redirectTo: 'returns', pathMatch: 'full' }, + { path: 'reklamacje', redirectTo: 'after-sales', pathMatch: 'full' }, { path: '**', loadComponent: () => diff --git a/src/shop/src/app/core/api.service.ts b/src/shop/src/app/core/api.service.ts index 7200105..8a3bdad 100644 --- a/src/shop/src/app/core/api.service.ts +++ b/src/shop/src/app/core/api.service.ts @@ -57,6 +57,13 @@ export class ApiService { headers: this.#getHeaders(), }); } + + /** Multipart upload; auth/locale headers only (no JSON content-type). */ + uploadForm(path: string, form: FormData): Observable { + return this.#http.post(`${API_URL}${path}`, form, { + headers: this.#getHeaders(), + }); + } } @Injectable({ providedIn: 'root' }) diff --git a/src/shop/src/app/core/i18n/translations/cs.ts b/src/shop/src/app/core/i18n/translations/cs.ts index 361e8f9..b400373 100644 --- a/src/shop/src/app/core/i18n/translations/cs.ts +++ b/src/shop/src/app/core/i18n/translations/cs.ts @@ -15,6 +15,9 @@ export const CS_TRANSLATIONS: TranslationMap = { 'meta.checkout.description': 'Dokončete objednávku.', 'meta.order.title': 'Potvrzení objednávky — iLED', 'meta.order.description': 'Děkujeme za objednávku.', + 'meta.afterSales.title': 'Vrácení / reklamace — iLED', + 'meta.afterSales.description': + 'Požádejte o vrácení nebo nahlaste problém s objednávkou.', 'meta.login.title': 'Přihlášení — iLED', 'meta.login.description': 'Přihlaste se nebo vytvořte účet.', 'meta.favorites.title': 'Oblíbené — iLED', @@ -360,6 +363,37 @@ export const CS_TRANSLATIONS: TranslationMap = { 'account.logout': 'Odhlásit se', 'account.myOrders': 'Moje objednávky', 'account.details': 'Detail', + 'account.afterSales': 'Vrácení / reklamace', + 'checkout.afterSales': 'Vrácení / reklamace', + 'afterSales.title': 'Vrácení nebo nahlášení problému', + 'afterSales.subtitle': + 'Vyberte položky z objednávky, popište problém a volitelně přidejte fotky.', + 'afterSales.selectItems': 'Vyberte položky', + 'afterSales.remaining': 'Ještě lze reklamovat', + 'afterSales.qty': 'Kusů', + 'afterSales.requestType': 'O co jde?', + 'afterSales.type.Withdrawal': 'Vrácení / odstoupení', + 'afterSales.type.Defect': 'Vada / poškození', + 'afterSales.type.MissingPart': 'Chybějící díl', + 'afterSales.type.WrongItem': 'Špatný produkt', + 'afterSales.type.Other': 'Jiné', + 'afterSales.note': 'Popis', + 'afterSales.notePlaceholder': 'Co je špatně…', + 'afterSales.photos': 'Fotky', + 'afterSales.photosHint': + 'Až 5 fotek. Na telefonu můžete použít fotoaparát.', + 'afterSales.uploading': 'Nahrávání…', + 'afterSales.maxPhotos': 'Maximálně 5 fotek.', + 'afterSales.uploadError': 'Fotku se nepodařilo nahrát.', + 'afterSales.submit': 'Odeslat žádost', + 'afterSales.submitting': 'Odesílání…', + 'afterSales.submitError': 'Žádost se nepodařilo odeslat.', + 'afterSales.successTitle': 'Žádost přijata', + 'afterSales.successBody': + 'Prohlédneme ji a ozveme se e-mailem.', + 'afterSales.missingOrder': + 'Otevřete stránku z e-mailu objednávky nebo účtu.', + 'afterSales.loadError': 'Objednávku se nepodařilo načíst.', 'account.noOrders': 'Žádné objednávky.', 'account.loginPrompt': ' pro zobrazení účtu.', 'favorites.title': 'Oblíbené produkty', diff --git a/src/shop/src/app/core/i18n/translations/de.ts b/src/shop/src/app/core/i18n/translations/de.ts index 6260096..e377a84 100644 --- a/src/shop/src/app/core/i18n/translations/de.ts +++ b/src/shop/src/app/core/i18n/translations/de.ts @@ -15,6 +15,9 @@ export const DE_TRANSLATIONS: TranslationMap = { 'meta.checkout.description': 'Bestellung abschließen.', 'meta.order.title': 'Bestellbestätigung — iLED', 'meta.order.description': 'Vielen Dank für Ihre Bestellung.', + 'meta.afterSales.title': 'Rückgabe / Reklamation — iLED', + 'meta.afterSales.description': + 'Rückgabe oder Problem zu Ihrer Bestellung melden.', 'meta.login.title': 'Anmelden — iLED', 'meta.login.description': 'Anmelden oder Konto erstellen.', 'meta.favorites.title': 'Favoriten — iLED', @@ -361,6 +364,37 @@ export const DE_TRANSLATIONS: TranslationMap = { 'account.logout': 'Abmelden', 'account.myOrders': 'Meine Bestellungen', 'account.details': 'Details', + 'account.afterSales': 'Rückgabe / melden', + 'checkout.afterSales': 'Rückgabe / Problem melden', + 'afterSales.title': 'Rückgabe oder Problem melden', + 'afterSales.subtitle': + 'Wählen Sie Artikel, beschreiben Sie das Problem und fügen Sie optional Fotos hinzu.', + 'afterSales.selectItems': 'Artikel wählen', + 'afterSales.remaining': 'Noch meldebar', + 'afterSales.qty': 'Menge', + 'afterSales.requestType': 'Worum geht es?', + 'afterSales.type.Withdrawal': 'Rückgabe / Widerruf', + 'afterSales.type.Defect': 'Defekt / Beschädigung', + 'afterSales.type.MissingPart': 'Fehlendes Teil', + 'afterSales.type.WrongItem': 'Falscher Artikel', + 'afterSales.type.Other': 'Sonstiges', + 'afterSales.note': 'Beschreibung', + 'afterSales.notePlaceholder': 'Was ist falsch…', + 'afterSales.photos': 'Fotos', + 'afterSales.photosHint': + 'Bis zu 5 Fotos. Am Handy können Sie die Kamera nutzen.', + 'afterSales.uploading': 'Hochladen…', + 'afterSales.maxPhotos': 'Maximal 5 Fotos.', + 'afterSales.uploadError': 'Foto konnte nicht hochgeladen werden.', + 'afterSales.submit': 'Anfrage senden', + 'afterSales.submitting': 'Senden…', + 'afterSales.submitError': 'Anfrage konnte nicht gesendet werden.', + 'afterSales.successTitle': 'Anfrage erhalten', + 'afterSales.successBody': + 'Wir prüfen sie und melden uns per E-Mail.', + 'afterSales.missingOrder': + 'Öffnen Sie diese Seite aus der Bestell-E-Mail oder dem Konto.', + 'afterSales.loadError': 'Bestellung konnte nicht geladen werden.', 'account.noOrders': 'Keine Bestellungen.', 'account.loginPrompt': ', um Ihr Konto zu sehen.', 'favorites.title': 'Lieblingsprodukte', diff --git a/src/shop/src/app/core/i18n/translations/en.ts b/src/shop/src/app/core/i18n/translations/en.ts index 2c7abb7..330f8c6 100644 --- a/src/shop/src/app/core/i18n/translations/en.ts +++ b/src/shop/src/app/core/i18n/translations/en.ts @@ -15,6 +15,9 @@ export const EN_TRANSLATIONS: TranslationMap = { 'meta.checkout.description': 'Complete your order.', 'meta.order.title': 'Order confirmation — iLED', 'meta.order.description': 'Thank you for your order.', + 'meta.afterSales.title': 'Return / report issue — iLED', + 'meta.afterSales.description': + 'Request a return or report a problem with your order.', 'meta.login.title': 'Sign in — iLED', 'meta.login.description': 'Sign in or create an account.', 'meta.favorites.title': 'Favorites — iLED', @@ -356,7 +359,37 @@ export const EN_TRANSLATIONS: TranslationMap = { 'account.logout': 'Sign out', 'account.myOrders': 'My orders', 'account.details': 'Details', + 'account.afterSales': 'Return / report', 'account.noOrders': 'No orders yet.', + 'checkout.afterSales': 'Return / report issue', + 'afterSales.title': 'Return or report an issue', + 'afterSales.subtitle': + 'Select items from your order, describe the problem, and optionally add photos.', + 'afterSales.selectItems': 'Select items', + 'afterSales.remaining': 'Still claimable', + 'afterSales.qty': 'Qty', + 'afterSales.requestType': 'What is this about?', + 'afterSales.type.Withdrawal': 'Return / withdrawal', + 'afterSales.type.Defect': 'Defect / damage', + 'afterSales.type.MissingPart': 'Missing part', + 'afterSales.type.WrongItem': 'Wrong item received', + 'afterSales.type.Other': 'Other', + 'afterSales.note': 'Description', + 'afterSales.notePlaceholder': 'Tell us what is wrong…', + 'afterSales.photos': 'Photos', + 'afterSales.photosHint': + 'Up to 5 photos. On a phone you can take a picture with the camera.', + 'afterSales.uploading': 'Uploading…', + 'afterSales.maxPhotos': 'Maximum 5 photos.', + 'afterSales.uploadError': 'Could not upload photo.', + 'afterSales.submit': 'Submit request', + 'afterSales.submitting': 'Sending…', + 'afterSales.submitError': 'Could not submit request.', + 'afterSales.successTitle': 'Request received', + 'afterSales.successBody': + 'We will review it and get back to you by email.', + 'afterSales.missingOrder': 'Open this page from your order email or account.', + 'afterSales.loadError': 'Could not load this order for after-sales.', 'account.loginPrompt': ' to view your account.', 'favorites.title': 'Favorite products', 'favorites.empty': 'No favorite products yet.', diff --git a/src/shop/src/app/core/i18n/translations/pl.ts b/src/shop/src/app/core/i18n/translations/pl.ts index d1f093a..020ef27 100644 --- a/src/shop/src/app/core/i18n/translations/pl.ts +++ b/src/shop/src/app/core/i18n/translations/pl.ts @@ -15,6 +15,9 @@ export const PL_TRANSLATIONS: TranslationMap = { 'meta.checkout.description': 'Dokończ zamówienie.', 'meta.order.title': 'Potwierdzenie zamówienia — iLED', 'meta.order.description': 'Dziękujemy za zamówienie.', + 'meta.afterSales.title': 'Zwrot / reklamacja — iLED', + 'meta.afterSales.description': + 'Zgłoś zwrot lub problem z zamówieniem.', 'meta.login.title': 'Logowanie — iLED', 'meta.login.description': 'Zaloguj się lub załóż konto.', 'meta.favorites.title': 'Ulubione — iLED', @@ -356,6 +359,37 @@ export const PL_TRANSLATIONS: TranslationMap = { 'account.logout': 'Wyloguj', 'account.myOrders': 'Moje zamówienia', 'account.details': 'Szczegóły', + 'account.afterSales': 'Zwrot / reklamacja', + 'checkout.afterSales': 'Zwrot / reklamacja', + 'afterSales.title': 'Zwrot lub zgłoszenie problemu', + 'afterSales.subtitle': + 'Wybierz produkty z zamówienia, opisz problem i opcjonalnie dodaj zdjęcia.', + 'afterSales.selectItems': 'Wybierz produkty', + 'afterSales.remaining': 'Pozostało do zgłoszenia', + 'afterSales.qty': 'Ilość', + 'afterSales.requestType': 'Czego dotyczy zgłoszenie?', + 'afterSales.type.Withdrawal': 'Zwrot / odstąpienie', + 'afterSales.type.Defect': 'Wada / uszkodzenie', + 'afterSales.type.MissingPart': 'Brakująca część', + 'afterSales.type.WrongItem': 'Otrzymano zły produkt', + 'afterSales.type.Other': 'Inne', + 'afterSales.note': 'Opis', + 'afterSales.notePlaceholder': 'Opisz, co jest nie tak…', + 'afterSales.photos': 'Zdjęcia', + 'afterSales.photosHint': + 'Do 5 zdjęć. Na telefonie możesz zrobić zdjęcie aparatem.', + 'afterSales.uploading': 'Przesyłanie…', + 'afterSales.maxPhotos': 'Maksymalnie 5 zdjęć.', + 'afterSales.uploadError': 'Nie udało się przesłać zdjęcia.', + 'afterSales.submit': 'Wyślij zgłoszenie', + 'afterSales.submitting': 'Wysyłanie…', + 'afterSales.submitError': 'Nie udało się wysłać zgłoszenia.', + 'afterSales.successTitle': 'Zgłoszenie przyjęte', + 'afterSales.successBody': + 'Przejrzymy je i odezwiemy się e-mailem.', + 'afterSales.missingOrder': + 'Otwórz tę stronę z e-maila zamówienia lub konta.', + 'afterSales.loadError': 'Nie udało się wczytać zamówienia.', 'account.noOrders': 'Brak zamówień.', 'account.loginPrompt': ', aby zobaczyć konto.', 'favorites.title': 'Ulubione produkty', diff --git a/src/shop/src/app/core/shop.services.ts b/src/shop/src/app/core/shop.services.ts index 47521e8..18172e1 100644 --- a/src/shop/src/app/core/shop.services.ts +++ b/src/shop/src/app/core/shop.services.ts @@ -176,6 +176,72 @@ export class OrderService { } } +export type AfterSalesRequestType = + | 'withdrawal' + | 'defect' + | 'missingPart' + | 'wrongItem' + | 'other'; + +export interface AfterSalesOrderLine { + orderLineId: string; + productName: string; + variantColor: string; + sku: string; + imageUrl: string | null; + unitPrice: number; + quantity: number; + remainingClaimable: number; +} + +export interface AfterSalesOrder { + orderId: string; + orderNumber: string; + status: string; + createdAt: string; + lines: AfterSalesOrderLine[]; +} + +@Injectable({ providedIn: 'root' }) +export class AfterSalesService { + readonly #api = inject(ApiService); + + getOrder(orderId: string, token?: string | null) { + const params: Record = {}; + if (token) { + params['token'] = token; + } + return this.#api.get( + `/after-sales/orders/${orderId}`, + params, + ); + } + + upload(orderId: string, file: File, token?: string | null) { + const form = new FormData(); + form.append('orderId', orderId); + if (token) { + form.append('token', token); + } + form.append('file', file); + return this.#api.uploadForm<{ url: string }>( + '/after-sales/uploads', + form, + ); + } + + create(body: { + orderId: string; + token?: string | null; + type: AfterSalesRequestType; + customerNote: string; + lines: { orderLineId: string; quantity: number }[]; + attachmentUrls: string[]; + }) { + return this.#api.post<{ id: string }>('/after-sales/requests', body); + } +} + @Injectable({ providedIn: 'root' }) export class FavoritesService { readonly #api = inject(ApiService); diff --git a/src/shop/src/app/features/account/account.component.html b/src/shop/src/app/features/account/account.component.html index b86f67a..7545043 100644 --- a/src/shop/src/app/features/account/account.component.html +++ b/src/shop/src/app/features/account/account.component.html @@ -56,11 +56,18 @@ {{ order.total | i18nCurrency }} - - - {{ 'account.details' | translate }} - - + diff --git a/src/shop/src/app/features/after-sales/after-sales.component.html b/src/shop/src/app/features/after-sales/after-sales.component.html new file mode 100644 index 0000000..65c6304 --- /dev/null +++ b/src/shop/src/app/features/after-sales/after-sales.component.html @@ -0,0 +1,170 @@ + +
+

+ {{ 'afterSales.title' | translate }} +

+

+ {{ 'afterSales.subtitle' | translate }} +

+ + @if (loading()) { +

{{ 'common.loading' | translate }}

+ } @else if (success()) { +
+

+ {{ 'afterSales.successTitle' | translate }} +

+

+ {{ 'afterSales.successBody' | translate }} +

+ + {{ 'checkout.backToShop' | translate }} + +
+ } @else if (order(); as o) { +
+
+

+ {{ 'checkout.orderNumber' | translate }} + {{ o.orderNumber }} +

+
+ +
+ + {{ 'afterSales.selectItems' | translate }} + +
+ @for (line of o.lines; track line.orderLineId) { +
+
+
{{ line.productName }}
+
+ {{ line.sku }} + @if (line.variantColor) { + · {{ line.variantColor }} + } + · {{ line.unitPrice | i18nCurrency }} +
+
+ {{ 'afterSales.remaining' | translate }}: + {{ line.remainingClaimable }} / {{ line.quantity }} +
+
+ +
+ } +
+
+ +
+ + {{ 'afterSales.requestType' | translate }} + +
+ @for (t of types; track t) { + + } +
+
+ +
+ + +
+ +
+ +

+ {{ 'afterSales.photosHint' | translate }} +

+ + @if (uploading()) { +

+ {{ 'afterSales.uploading' | translate }} +

+ } +
+ @for (url of attachmentUrls(); track url) { +
+ + +
+ } +
+
+ + @if (error()) { +

{{ error() }}

+ } + + +
+ } @else if (error()) { +

{{ error() }}

+ + {{ 'checkout.backToShop' | translate }} + + } +
+
diff --git a/src/shop/src/app/features/after-sales/after-sales.component.ts b/src/shop/src/app/features/after-sales/after-sales.component.ts new file mode 100644 index 0000000..77126a1 --- /dev/null +++ b/src/shop/src/app/features/after-sales/after-sales.component.ts @@ -0,0 +1,204 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, + signal, +} from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe'; +import { TranslatePipe } from '../../core/i18n/translate.pipe'; +import { I18nService } from '../../core/i18n/i18n.service'; +import { ShellComponent } from '../../shared/layout/layout.components'; +import { API_BASE_URL } from '../../core/constants'; +import { + AfterSalesOrder, + AfterSalesOrderLine, + AfterSalesRequestType, + AfterSalesService, +} from '../../core/shop.services'; + +@Component({ + selector: 'app-after-sales', + standalone: true, + imports: [ + ShellComponent, + FormsModule, + TranslatePipe, + I18nCurrencyPipe, + RouterLink, + ], + templateUrl: './after-sales.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AfterSalesComponent implements OnInit { + readonly #api = inject(AfterSalesService); + readonly #route = inject(ActivatedRoute); + readonly #i18n = inject(I18nService); + + readonly loading = signal(true); + readonly submitting = signal(false); + readonly uploading = signal(false); + readonly error = signal(''); + readonly success = signal(false); + readonly order = signal(null); + + orderId = ''; + token = ''; + type: AfterSalesRequestType = 'withdrawal'; + note = ''; + readonly selectedQty = new Map(); + readonly attachmentUrls = signal([]); + + readonly types: AfterSalesRequestType[] = [ + 'withdrawal', + 'defect', + 'missingPart', + 'wrongItem', + 'other', + ]; + + ngOnInit(): void { + this.#route.queryParamMap.subscribe((params) => { + this.orderId = params.get('orderId') ?? ''; + this.token = params.get('token') ?? ''; + if (this.orderId) { + this.#load(); + } else { + this.loading.set(false); + this.error.set(this.#i18n.t('afterSales.missingOrder')); + } + }); + } + + #load(): void { + this.loading.set(true); + this.error.set(''); + this.#api.getOrder(this.orderId, this.token || null).subscribe({ + next: (o) => { + this.order.set(o); + this.selectedQty.clear(); + for (const line of o.lines) { + if (line.remainingClaimable > 0) { + this.selectedQty.set(line.orderLineId, 0); + } + } + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.error.set(this.#i18n.t('afterSales.loadError')); + }, + }); + } + + qty(line: AfterSalesOrderLine): number { + return this.selectedQty.get(line.orderLineId) ?? 0; + } + + setQty(line: AfterSalesOrderLine, raw: string | number): void { + const n = Math.max( + 0, + Math.min(line.remainingClaimable, Math.floor(Number(raw) || 0)), + ); + this.selectedQty.set(line.orderLineId, n); + } + + typeLabel(type: AfterSalesRequestType): string { + const key = type.charAt(0).toUpperCase() + type.slice(1); + return `afterSales.type.${key}`; + } + + photoSrc(url: string): string { + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + return `${API_BASE_URL}${url}`; + } + + handleFiles(event: Event): void { + const input = event.target as HTMLInputElement; + const files = input.files; + input.value = ''; + if (!files?.length || this.uploading()) { + return; + } + + const remaining = 5 - this.attachmentUrls().length; + if (remaining <= 0) { + this.error.set(this.#i18n.t('afterSales.maxPhotos')); + return; + } + + const batch = Array.from(files).slice(0, remaining); + this.uploading.set(true); + this.error.set(''); + let pending = batch.length; + + for (const file of batch) { + this.#api.upload(this.orderId, file, this.token || null).subscribe({ + next: (res) => { + this.attachmentUrls.update((urls) => [...urls, res.url]); + pending -= 1; + if (pending <= 0) { + this.uploading.set(false); + } + }, + error: () => { + pending -= 1; + this.error.set(this.#i18n.t('afterSales.uploadError')); + if (pending <= 0) { + this.uploading.set(false); + } + }, + }); + } + } + + removePhoto(url: string): void { + this.attachmentUrls.update((urls) => urls.filter((u) => u !== url)); + } + + handleSubmit(event: Event): void { + event.preventDefault(); + if (this.submitting() || !this.order()) { + return; + } + + const lines = [...this.selectedQty.entries()] + .filter(([, q]) => q > 0) + .map(([orderLineId, quantity]) => ({ orderLineId, quantity })); + + if (lines.length === 0) { + this.error.set(this.#i18n.t('afterSales.selectItems')); + return; + } + + this.submitting.set(true); + this.error.set(''); + this.#api + .create({ + orderId: this.orderId, + token: this.token || null, + type: this.type, + customerNote: this.note.trim(), + lines, + attachmentUrls: this.attachmentUrls(), + }) + .subscribe({ + next: () => { + this.submitting.set(false); + this.success.set(true); + }, + error: (err) => { + this.submitting.set(false); + const msg = + err?.error?.message || + err?.error?.title || + this.#i18n.t('afterSales.submitError'); + this.error.set(typeof msg === 'string' ? msg : this.#i18n.t('afterSales.submitError')); + }, + }); + } +} diff --git a/src/shop/src/app/features/checkout/checkout.component.ts b/src/shop/src/app/features/checkout/checkout.component.ts index 53b720a..2404a50 100644 --- a/src/shop/src/app/features/checkout/checkout.component.ts +++ b/src/shop/src/app/features/checkout/checkout.component.ts @@ -353,6 +353,7 @@ export class OrderConfirmationComponent implements OnInit { readonly #route = inject(ActivatedRoute); readonly #router = inject(Router); readonly #i18n = inject(I18nService); + readonly #auth = inject(AuthService); readonly order = signal(null); readonly syncing = signal(false); @@ -369,9 +370,23 @@ export class OrderConfirmationComponent implements OnInit { } needsPayment(order: OrderDto): boolean { + const method = enumKey(order.paymentMethod); + const status = enumKey(order.status); + return method === 'PayU' && status === 'PendingPayment'; + } + + canAfterSales(order: OrderDto): boolean { + if (!this.#auth.isLoggedIn()) { + // Guests use the emailed magic link (token required). + return false; + } + + const status = enumKey(order.status); return ( - order.paymentMethod === 'PayU' && - order.status === 'PendingPayment' + status === 'Paid' || + status === 'Processing' || + status === 'Shipped' || + status === 'Delivered' ); } @@ -417,7 +432,10 @@ export class OrderConfirmationComponent implements OnInit { const order = await firstValueFrom(this.#orders.getOrder(orderId)); this.order.set(order); - if (order.status === 'Paid' || order.paymentMethod !== 'PayU') { + if ( + enumKey(order.status) === 'Paid' || + enumKey(order.paymentMethod) !== 'PayU' + ) { break; } diff --git a/src/shop/src/app/features/checkout/order-confirmation.component.html b/src/shop/src/app/features/checkout/order-confirmation.component.html index 68d74bc..beb1890 100644 --- a/src/shop/src/app/features/checkout/order-confirmation.component.html +++ b/src/shop/src/app/features/checkout/order-confirmation.component.html @@ -64,6 +64,14 @@ {{ 'checkout.payAgain' | translate }} } + } @else if (canAfterSales(o)) { + + {{ 'checkout.afterSales' | translate }} + }