From ae81b11a5a775e586c4cd79496981889f64ad72e Mon Sep 17 00:00:00 2001 From: Abdelrahman <70455708+AbdelrahmanEssam1007@users.noreply.github.com> Date: Mon, 29 Dec 2025 05:17:15 +0200 Subject: [PATCH] feat(orders): implement timezone-aware timestamp handling - Add migration 004_DateToTimestamp.sql to change order_date from DATE to TIMESTAMPTZ - Configure Npgsql to handle timestamps as UTC (disable legacy behavior) - Update ReplenishmentOrder model and DTO from DateOnly to DateTime - Modify repositories to explicitly mark DateTime values as UTC (DateTimeKind.Utc) - Update frontend date formatters to display timestamps in user's local timezone - Add timezone abbreviation display (e.g., GMT+2, EST, PST) for clarity - Configure ASP.NET Core JSON serializer for proper UTC datetime serialization Fixes timezone offset issues where timestamps stored in UTC were incorrectly displayed in the frontend. PostgreSQL TIMESTAMPTZ now stores values in UTC internally, and JavaScript Date objects correctly interpret and convert to the user's local timezone. Backend: backend/Migrations/, backend/Models/, backend/DTOs/, backend/Repositories/, backend/Program.cs Frontend: frontend/app/user/orders/, frontend/app/admin/home/, frontend/app/admin/publisher-orders/ --- .../ReplenishmentOrderDTO.cs | 2 +- backend/Migrations/004_DateToTimestamp.sql | 19 +++++++++++++++++++ backend/Models/ReplenishmentOrder.cs | 2 +- backend/Program.cs | 5 +++++ .../CustomerOrder/CustomerOrderRepository.cs | 10 ++++++++-- .../ReplenishmentOrderRepository.cs | 15 +++++++++++++-- frontend/app/admin/home/page.tsx | 6 +++++- frontend/app/admin/publisher-orders/page.tsx | 6 +++++- frontend/app/user/orders/page.tsx | 8 ++++++-- 9 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 backend/Migrations/004_DateToTimestamp.sql diff --git a/backend/DTOs/ReplenishmentOrder/ReplenishmentOrderDTO.cs b/backend/DTOs/ReplenishmentOrder/ReplenishmentOrderDTO.cs index 6c43d29..851d244 100644 --- a/backend/DTOs/ReplenishmentOrder/ReplenishmentOrderDTO.cs +++ b/backend/DTOs/ReplenishmentOrder/ReplenishmentOrderDTO.cs @@ -4,7 +4,7 @@ public class ReplenishmentOrderResponseDto { public Guid OrderId { get; set; } public string Isbn { get; set; } - public DateOnly OrderDate { get; set; } + public DateTime OrderDate { get; set; } public int Quantity { get; set; } public string Status { get; set; } } diff --git a/backend/Migrations/004_DateToTimestamp.sql b/backend/Migrations/004_DateToTimestamp.sql new file mode 100644 index 0000000..a48b967 --- /dev/null +++ b/backend/Migrations/004_DateToTimestamp.sql @@ -0,0 +1,19 @@ +-- Migration to change DATE columns to TIMESTAMPTZ for timezone support +-- This ensures proper handling of timezone offsets in frontend/backend +-- TIMESTAMPTZ stores timestamps in UTC internally and converts based on timezone + +-- Alter customer_order table +ALTER TABLE customer_order +ALTER COLUMN order_date TYPE TIMESTAMPTZ +USING order_date::TIMESTAMPTZ; + +ALTER TABLE customer_order +ALTER COLUMN order_date SET DEFAULT CURRENT_TIMESTAMP; + +-- Alter replenishment_order table +ALTER TABLE replenishment_order +ALTER COLUMN order_date TYPE TIMESTAMPTZ +USING order_date::TIMESTAMPTZ; + +ALTER TABLE replenishment_order +ALTER COLUMN order_date SET DEFAULT CURRENT_TIMESTAMP; diff --git a/backend/Models/ReplenishmentOrder.cs b/backend/Models/ReplenishmentOrder.cs index 91831be..f79d6b5 100644 --- a/backend/Models/ReplenishmentOrder.cs +++ b/backend/Models/ReplenishmentOrder.cs @@ -4,7 +4,7 @@ public class ReplenishmentOrder { public Guid OrderId { get; set; } public string Isbn { get; set; } - public DateOnly OrderDate { get; set; } + public DateTime OrderDate { get; set; } public int Quantity { get; set; } public string Status { get; set; } } diff --git a/backend/Program.cs b/backend/Program.cs index 074d3f5..bb3b858 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -17,6 +17,9 @@ var builder = WebApplication.CreateBuilder(args); Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; +// Configure Npgsql to read timestamps as UTC +AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", false); + // Load DB Connection var connectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING"); if (string.IsNullOrEmpty(connectionString)) @@ -41,6 +44,8 @@ .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + // Ensure DateTime values are serialized as UTC with 'Z' suffix + options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); }); diff --git a/backend/Repositories/CustomerOrder/CustomerOrderRepository.cs b/backend/Repositories/CustomerOrder/CustomerOrderRepository.cs index b5980ca..d593db8 100644 --- a/backend/Repositories/CustomerOrder/CustomerOrderRepository.cs +++ b/backend/Repositories/CustomerOrder/CustomerOrderRepository.cs @@ -28,12 +28,18 @@ INSERT INTO customer_order_item(order_id, isbn, quantity, price) public async Task> GetOrdersAsync(Guid userId) { const string sql = @" - SELECT order_id as OrderId, u_id as UserId, order_date::timestamp as OrderDate, total_price as TotalPrice + SELECT order_id as OrderId, u_id as UserId, order_date AT TIME ZONE 'UTC' as OrderDate, total_price as TotalPrice FROM customer_order WHERE u_id = @UserId ORDER BY order_date DESC; "; - return (await _db.QueryAsync(sql, new { UserId = userId })).ToList(); + var orders = await _db.QueryAsync(sql, new { UserId = userId }); + // Ensure DateTime is marked as UTC + foreach (var order in orders) + { + order.OrderDate = DateTime.SpecifyKind(order.OrderDate, DateTimeKind.Utc); + } + return orders.ToList(); } public async Task> GetOrderItemsAsync(Guid orderId) diff --git a/backend/Repositories/ReplenishmentOrder/ReplenishmentOrderRepository.cs b/backend/Repositories/ReplenishmentOrder/ReplenishmentOrderRepository.cs index f3e2165..71e2a89 100644 --- a/backend/Repositories/ReplenishmentOrder/ReplenishmentOrderRepository.cs +++ b/backend/Repositories/ReplenishmentOrder/ReplenishmentOrderRepository.cs @@ -15,17 +15,28 @@ public ReplenishmentOrderRepository(IDbConnection db) public async Task> GetAllAsync() { - return await _db.QueryAsync( + var orders = await _db.QueryAsync( "SELECT * FROM replenishment_order ORDER BY order_date DESC;" ); + // Ensure DateTime is marked as UTC + foreach (var order in orders) + { + order.OrderDate = DateTime.SpecifyKind(order.OrderDate, DateTimeKind.Utc); + } + return orders; } public async Task GetByIdAsync(Guid orderId) { - return await _db.QuerySingleOrDefaultAsync( + var order = await _db.QuerySingleOrDefaultAsync( "SELECT * FROM replenishment_order WHERE order_id = @OrderId;", new { OrderId = orderId } ); + if (order != null) + { + order.OrderDate = DateTime.SpecifyKind(order.OrderDate, DateTimeKind.Utc); + } + return order; } public async Task ConfirmAsync(Guid orderId) diff --git a/frontend/app/admin/home/page.tsx b/frontend/app/admin/home/page.tsx index 55a2a50..5c8e827 100644 --- a/frontend/app/admin/home/page.tsx +++ b/frontend/app/admin/home/page.tsx @@ -125,10 +125,14 @@ export default function AdminDashboard() { const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleDateString("en-US", { + return date.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); }; diff --git a/frontend/app/admin/publisher-orders/page.tsx b/frontend/app/admin/publisher-orders/page.tsx index 66cf672..c9a58c6 100644 --- a/frontend/app/admin/publisher-orders/page.tsx +++ b/frontend/app/admin/publisher-orders/page.tsx @@ -91,10 +91,14 @@ export default function PublisherOrdersPage() { const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleDateString("en-US", { + return date.toLocaleString(undefined, { year: "numeric", month: "long", day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); }; diff --git a/frontend/app/user/orders/page.tsx b/frontend/app/user/orders/page.tsx index fa1385e..bce9882 100644 --- a/frontend/app/user/orders/page.tsx +++ b/frontend/app/user/orders/page.tsx @@ -64,21 +64,25 @@ export default function OrdersPage() { const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleDateString("en-US", { + return date.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); }; const formatDateTime = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleString("en-US", { + return date.toLocaleString(undefined, { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); };