diff --git a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs index 08ee88b..af6206f 100644 --- a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs +++ b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.Json; using FluentValidation; using Microsoft.AspNetCore.Mvc; +using ProposalSystem.Application.Common; namespace ProposalSystem.Api.Middleware; @@ -26,54 +27,52 @@ public class GlobalExceptionHandler : IMiddleware } } + private static ProblemDetails MakeProblem(int status, string title, string detail, string code) + { + var problem = new ProblemDetails + { + Status = status, + Title = title, + Detail = detail, + }; + problem.Extensions["code"] = code; // serialized top-level via [JsonExtensionData] + return problem; + } + private async Task HandleExceptionAsync(HttpContext context, Exception exception) { + // Every response carries a machine-readable "code" extension (SHOC + // error-code vocabulary convention) so clients branch on codes, not + // on human-readable text. var (statusCode, problemDetails) = exception switch { ValidationException validationEx => ( HttpStatusCode.BadRequest, - new ProblemDetails - { - Status = 400, - Title = "Validation Error", - Detail = string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)), - } + MakeProblem(400, "Validation Error", + string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)), + "ValidationFailed") + ), + BusinessRuleException businessEx => ( + HttpStatusCode.UnprocessableEntity, + MakeProblem(422, "Business Rule Violation", businessEx.Message, businessEx.Code) ), KeyNotFoundException => ( HttpStatusCode.NotFound, - new ProblemDetails - { - Status = 404, - Title = "Not Found", - Detail = "The requested resource was not found", - } + MakeProblem(404, "Not Found", "The requested resource was not found", "NotFound") ), UnauthorizedAccessException => ( HttpStatusCode.Unauthorized, - new ProblemDetails - { - Status = 401, - Title = "Unauthorized", - Detail = "Authentication required", - } + MakeProblem(401, "Unauthorized", "Authentication required", "Unauthorized") ), InvalidOperationException => ( HttpStatusCode.BadRequest, - new ProblemDetails - { - Status = 400, - Title = "Invalid Operation", - Detail = "The requested operation is not valid for the current state", - } + MakeProblem(400, "Invalid Operation", + "The requested operation is not valid for the current state", + "InvalidStateTransition") ), _ => ( HttpStatusCode.InternalServerError, - new ProblemDetails - { - Status = 500, - Title = "Internal Server Error", - Detail = "An unexpected error occurred", - } + MakeProblem(500, "Internal Server Error", "An unexpected error occurred", "InternalError") ), }; diff --git a/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs b/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs new file mode 100644 index 0000000..af1853a --- /dev/null +++ b/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs @@ -0,0 +1,17 @@ +namespace ProposalSystem.Application.Common; + +/// +/// Business-rule violation carrying a machine-readable code, surfaced as +/// 422 ProblemDetails with a top-level "code" extension (SHOC error-code +/// vocabulary convention — e.g. "CancelNotAllowed"). Clients branch on the +/// code, never on the human-readable message. +/// +public class BusinessRuleException : Exception +{ + public string Code { get; } + + public BusinessRuleException(string code, string message) : base(message) + { + Code = code; + } +} diff --git a/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..78c5c04 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using NSubstitute; +using ProposalSystem.Api.Middleware; +using ProposalSystem.Application.Common; +using Xunit; + +namespace ProposalSystem.Tests.Middleware; + +/// +/// GlobalExceptionHandler tests — verifies the RFC 7807 ProblemDetails +/// mapping and the machine-readable "code" extension (SHOC error-code +/// vocabulary convention). Clients branch on code, so the code values are +/// wire contract: changing one is a breaking API change. +/// +public class GlobalExceptionHandlerTests +{ + private static async Task<(int Status, JsonElement Body)> InvokeWith(Exception exception) + { + var logger = Substitute.For>(); + var handler = new GlobalExceptionHandler(logger); + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await handler.InvokeAsync(context, _ => throw exception); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(context.Response.Body); + var body = JsonDocument.Parse(await reader.ReadToEndAsync()).RootElement.Clone(); + return (context.Response.StatusCode, body); + } + + [Fact(DisplayName = "BusinessRuleException maps to 422 with its business code")] + public async Task BusinessRuleException_Maps422WithCode() + { + var (status, body) = await InvokeWith( + new BusinessRuleException("CancelNotAllowed", "Sent proposals cannot be canceled")); + + status.Should().Be(422); + body.GetProperty("code").GetString().Should().Be("CancelNotAllowed"); + body.GetProperty("title").GetString().Should().Be("Business Rule Violation"); + body.GetProperty("detail").GetString().Should().Be("Sent proposals cannot be canceled"); + } + + [Fact(DisplayName = "InvalidOperationException maps to 400 InvalidStateTransition")] + public async Task InvalidOperationException_Maps400InvalidStateTransition() + { + var (status, body) = await InvokeWith(new InvalidOperationException("bad transition")); + + status.Should().Be(400); + body.GetProperty("code").GetString().Should().Be("InvalidStateTransition"); + // Detail must stay generic — no internal exception text on the wire. + body.GetProperty("detail").GetString().Should().NotContain("bad transition"); + } + + [Fact(DisplayName = "ValidationException maps to 400 ValidationFailed")] + public async Task ValidationException_Maps400ValidationFailed() + { + var failures = new[] { new ValidationFailure("Name", "Name is required") }; + var (status, body) = await InvokeWith(new ValidationException(failures)); + + status.Should().Be(400); + body.GetProperty("code").GetString().Should().Be("ValidationFailed"); + body.GetProperty("detail").GetString().Should().Contain("Name is required"); + } + + [Theory(DisplayName = "Standard exceptions map to their status and code")] + [InlineData(typeof(KeyNotFoundException), 404, "NotFound")] + [InlineData(typeof(UnauthorizedAccessException), 401, "Unauthorized")] + [InlineData(typeof(ApplicationException), 500, "InternalError")] + public async Task StandardExceptions_MapToStatusAndCode(Type exceptionType, int expectedStatus, string expectedCode) + { + var exception = (Exception)Activator.CreateInstance(exceptionType)!; + var (status, body) = await InvokeWith(exception); + + status.Should().Be(expectedStatus); + body.GetProperty("code").GetString().Should().Be(expectedCode); + } + + [Fact(DisplayName = "Responses use application/problem+json")] + public async Task Responses_UseProblemJsonContentType() + { + var logger = Substitute.For>(); + var handler = new GlobalExceptionHandler(logger); + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await handler.InvokeAsync(context, _ => throw new KeyNotFoundException()); + + context.Response.ContentType.Should().Be("application/problem+json"); + } +} diff --git a/shared/api-contracts/package-lock.json b/shared/api-contracts/package-lock.json index 21217e1..3468ed6 100644 --- a/shared/api-contracts/package-lock.json +++ b/shared/api-contracts/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "@proposal-system/api-contracts", "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "typescript": "~7.0.2" } @@ -385,6 +388,15 @@ "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/shared/api-contracts/package.json b/shared/api-contracts/package.json index 85c2a65..59f22a7 100644 --- a/shared/api-contracts/package.json +++ b/shared/api-contracts/package.json @@ -9,5 +9,8 @@ }, "devDependencies": { "typescript": "~7.0.2" + }, + "dependencies": { + "zod": "^4.4.3" } } diff --git a/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts index 6a14010..f189eb2 100644 --- a/shared/api-contracts/src/index.ts +++ b/shared/api-contracts/src/index.ts @@ -1,15 +1,46 @@ +// @proposal-system/api-contracts — single source of truth for API wire types. +// Fix: WEB-M5 — web and mobile both import these; shapes mirror the .NET DTOs +// in api/src/ProposalSystem.Application/DTOs/ (JsonStringEnumConverter → string +// enums, Guid → string, DateTime → ISO string, decimal → number). +// Runtime validation schemas live in ./schemas (separate entrypoint so +// consumers that only need types never pull zod). + +// ── Enums (string over the wire) ────────────────────────────────────────── export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised'; -// Fix: WEB-M4 — align with API enum (ProposalSystem.Domain.Entities.ServiceCategory includes 'Other') export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other'; export type Priority = 'Standard' | 'Urgent' | 'Emergency'; export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; export type UserRole = 'Dispatcher' | 'Admin' | 'SysAdmin'; -export interface Proposal { +// ── Shared envelopes ────────────────────────────────────────────────────── +export interface PagedResponse { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +// ── Proposals (ProposalDtos.cs) ─────────────────────────────────────────── +export interface ProposalListItem { + id: string; + proposalNumber: string; + customerName: string; + workOrderNumber: string; + serviceCategory: ServiceCategory; + priority: Priority; + status: ProposalStatus; + totalBidAmount: number; + submittedAt: string; + submittedByName: string | null; + assignedAdminName: string | null; +} + +export interface ProposalDetail { id: string; proposalNumber: string; workOrderNumber: string; + poNumber: string | null; customerName: string; customerAddress: string; scopeOfWork: string; @@ -21,6 +52,7 @@ export interface Proposal { vendorTotalCost: number | null; notes: string; submittedById: string; + submittedByName: string | null; submittedAt: string; assignedAdminId: string | null; approvedById: string | null; @@ -32,6 +64,45 @@ export interface Proposal { updatedAt: string; } +export interface CreateProposalRequest { + workOrderNumber: string; + poNumber?: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + serviceCategory: ServiceCategory; + priority: Priority; + notes?: string; +} + +export interface UpdateProposalRequest { + refinedScope?: string; + notes?: string; + poNumber?: string; + workOrderNumber?: string; + assignedAdminId?: string; +} + +export interface ProposalFilters { + search?: string; + page?: number; + pageSize?: number; + status?: string; + serviceCategory?: string; + priority?: string; + fromDate?: string; + toDate?: string; + mine?: boolean; +} + +export interface ProposalStats { + totalCount: number; + inReviewCount: number; + approvedCount: number; + sentCount: number; +} + +// ── Line items (LineItemDtos.cs) ────────────────────────────────────────── export interface LineItem { id: string; proposalId: string; @@ -47,20 +118,34 @@ export interface LineItem { updatedAt: string; } -export interface CreateProposalRequest { - workOrderNumber: string; - customerName: string; - customerAddress: string; - scopeOfWork: string; - serviceCategory: ServiceCategory; - priority: Priority; - notes?: string; +export interface CreateLineItemRequest { + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; } -export interface UpdateLineItemsRequest { - lineItems: Omit[]; +export interface UpdateLineItemEntry { + id: string | null; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; } +export interface BulkUpdateLineItemsRequest { + lineItems: UpdateLineItemEntry[]; +} + +// ── Customers (CustomerDtos.cs) ─────────────────────────────────────────── export interface Customer { id: string; name: string; @@ -69,6 +154,12 @@ export interface Customer { createdAt: string; } +export interface CustomerListParams { + page?: number; + pageSize?: number; + search?: string; +} + export interface CreateCustomerRequest { name: string; addresses?: string[]; @@ -81,14 +172,7 @@ export interface UpdateCustomerRequest { contactEmail?: string | null; } -export interface PagedResponse { - items: T[]; - totalCount: number; - page: number; - pageSize: number; -} - -// PR5: Pricing Library types +// ── Pricing library (PricingLibraryDtos.cs) ─────────────────────────────── export interface PricingLibraryItem { id: string; title: string; @@ -102,12 +186,17 @@ export interface PricingLibraryItem { updatedAt: string; } +export interface PricingLibraryListParams { + page?: number; + pageSize?: number; +} + export interface CreatePricingLibraryItemRequest { title: string; description?: string; serviceCategory: ServiceCategory; unit?: string; - unitPrice?: number; + unitPrice?: number | null; keywords?: string; source?: string; } @@ -117,6 +206,82 @@ export interface UpdatePricingLibraryItemRequest { description?: string; serviceCategory?: ServiceCategory; unit?: string; - unitPrice?: number; + unitPrice?: number | null; keywords?: string; } + +// ── Admin (DashboardDtos.cs, AuditDtos.cs) ──────────────────────────────── +export interface DashboardStats { + pendingCount: number; + approvedThisWeek: number; + avgTurnaroundHours: number; + totalProposals: number; +} + +export interface AuditEntry { + id: string; + proposalId: string | null; + userId: string; + userName: string | null; + action: string; + details: string | null; + timestamp: string; + ipAddress: string | null; +} + +// ── Sites (SiteDtos.cs) ─────────────────────────────────────────────────── +export interface Site { + siteCode: string; + fullAddress: string | null; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; +} + +// ── Auth / users (UserDtos.cs, AuthController) ──────────────────────────── +export interface AuthUser { + id: string; + email: string; + displayName: string; + role: UserRole; + token: string; +} + +export interface UserProfile { + id: string; + email: string; + displayName: string; + role: UserRole; +} + +// ── Files / PDFs (FileDtos.cs) ──────────────────────────────────────────── +export interface PdfVersion { + revision: number; + generatedAt: string; +} + +export interface PresignedUpload { + uploadUrl: string; + s3Key: string; + expiresAt: string; + vendorProposalId: string; +} + +// ── Errors (GlobalExceptionHandler.cs) ──────────────────────────────────── +// RFC 7807 ProblemDetails + machine-readable business code (SHOC error-code +// vocabulary convention). Branch on `code`, never on title/detail text. +export type ApiProblemCode = + | 'ValidationFailed' + | 'InvalidStateTransition' + | 'NotFound' + | 'Unauthorized' + | 'InternalError' + | (string & {}); // business codes are open-ended (e.g. future CancelNotAllowed) + +export interface ApiProblem { + status: number; + title: string; + detail: string; + code: ApiProblemCode; +} diff --git a/shared/api-contracts/src/schemas.ts b/shared/api-contracts/src/schemas.ts new file mode 100644 index 0000000..6929c86 --- /dev/null +++ b/shared/api-contracts/src/schemas.ts @@ -0,0 +1,276 @@ +// Runtime validation schemas coupled to the wire types in ./index. +// Separate entrypoint by design: consumers that only need types (mobile) +// never pull zod. Each schema is compile-time-checked against its type via +// `satisfies z.ZodType` — if a DTO and its schema drift, tsc fails here. +import { z } from 'zod'; +import type { + ProposalStatus, + ServiceCategory, + Priority, + LineItemSource, + PricingMode, + UserRole, + ProposalListItem, + ProposalDetail, + CreateProposalRequest, + UpdateProposalRequest, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, + BulkUpdateLineItemsRequest, + Customer, + CreateCustomerRequest, + UpdateCustomerRequest, + PricingLibraryItem, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, + DashboardStats, + AuditEntry, + Site, + AuthUser, + UserProfile, + ProposalStats, + PdfVersion, + PresignedUpload, + ApiProblem, +} from './index'; + +// ── Enums ───────────────────────────────────────────────────────────────── +export const proposalStatusSchema = z.enum(['Draft', 'InReview', 'Approved', 'Sent', 'Revised']) satisfies z.ZodType; +export const serviceCategorySchema = z.enum(['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other']) satisfies z.ZodType; +export const prioritySchema = z.enum(['Standard', 'Urgent', 'Emergency']) satisfies z.ZodType; +export const lineItemSourceSchema = z.enum(['AI', 'Vendor', 'Manual', 'Historical']) satisfies z.ZodType; +export const pricingModeSchema = z.enum(['UnitPrice', 'TotalPrice', 'Both']) satisfies z.ZodType; +export const userRoleSchema = z.enum(['Dispatcher', 'Admin', 'SysAdmin']) satisfies z.ZodType; + +// ── Proposals ───────────────────────────────────────────────────────────── +export const proposalListItemSchema = z.object({ + id: z.string(), + proposalNumber: z.string(), + customerName: z.string(), + workOrderNumber: z.string(), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + status: proposalStatusSchema, + totalBidAmount: z.number(), + submittedAt: z.string(), + submittedByName: z.string().nullable(), + assignedAdminName: z.string().nullable(), +}) satisfies z.ZodType; + +export const proposalDetailSchema = z.object({ + id: z.string(), + proposalNumber: z.string(), + workOrderNumber: z.string(), + poNumber: z.string().nullable(), + customerName: z.string(), + customerAddress: z.string(), + scopeOfWork: z.string(), + refinedScope: z.string().nullable(), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + status: proposalStatusSchema, + totalBidAmount: z.number(), + vendorTotalCost: z.number().nullable(), + notes: z.string(), + submittedById: z.string(), + submittedByName: z.string().nullable(), + submittedAt: z.string(), + assignedAdminId: z.string().nullable(), + approvedById: z.string().nullable(), + approvedAt: z.string().nullable(), + sentAt: z.string().nullable(), + currentRevision: z.number(), + parentProposalId: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createProposalRequestSchema = z.object({ + workOrderNumber: z.string().min(1), + poNumber: z.string().optional(), + customerName: z.string().min(1), + customerAddress: z.string().min(1), + scopeOfWork: z.string().min(1), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + notes: z.string().optional(), +}) satisfies z.ZodType; + +export const updateProposalRequestSchema = z.object({ + refinedScope: z.string().optional(), + notes: z.string().optional(), + poNumber: z.string().optional(), + workOrderNumber: z.string().optional(), + assignedAdminId: z.string().optional(), +}) satisfies z.ZodType; + +export const proposalStatsSchema = z.object({ + totalCount: z.number(), + inReviewCount: z.number(), + approvedCount: z.number(), + sentCount: z.number(), +}) satisfies z.ZodType; + +// ── Line items ──────────────────────────────────────────────────────────── +export const lineItemSchema = z.object({ + id: z.string(), + proposalId: z.string(), + description: z.string(), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createLineItemRequestSchema = z.object({ + description: z.string().min(1), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, +}) satisfies z.ZodType; + +export const updateLineItemEntrySchema = z.object({ + id: z.string().nullable(), + description: z.string().min(1), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, +}) satisfies z.ZodType; + +export const bulkUpdateLineItemsRequestSchema = z.object({ + lineItems: z.array(updateLineItemEntrySchema), +}) satisfies z.ZodType; + +// ── Customers ───────────────────────────────────────────────────────────── +export const customerSchema = z.object({ + id: z.string(), + name: z.string(), + addresses: z.array(z.string()), + contactEmail: z.string().nullable(), + createdAt: z.string(), +}) satisfies z.ZodType; + +export const createCustomerRequestSchema = z.object({ + name: z.string().min(1), + addresses: z.array(z.string()).optional(), + contactEmail: z.string().optional(), +}) satisfies z.ZodType; + +export const updateCustomerRequestSchema = z.object({ + name: z.string().optional(), + addresses: z.array(z.string()).optional(), + contactEmail: z.string().nullable().optional(), +}) satisfies z.ZodType; + +// ── Pricing library ─────────────────────────────────────────────────────── +export const pricingLibraryItemSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + serviceCategory: serviceCategorySchema, + unit: z.string().nullable(), + unitPrice: z.number().nullable(), + keywords: z.string().nullable(), + source: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createPricingLibraryItemRequestSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + serviceCategory: serviceCategorySchema, + unit: z.string().optional(), + unitPrice: z.number().nullable().optional(), + keywords: z.string().optional(), + source: z.string().optional(), +}) satisfies z.ZodType; + +export const updatePricingLibraryItemRequestSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + serviceCategory: serviceCategorySchema.optional(), + unit: z.string().optional(), + unitPrice: z.number().nullable().optional(), + keywords: z.string().optional(), +}) satisfies z.ZodType; + +// ── Admin ───────────────────────────────────────────────────────────────── +export const dashboardStatsSchema = z.object({ + pendingCount: z.number(), + approvedThisWeek: z.number(), + avgTurnaroundHours: z.number(), + totalProposals: z.number(), +}) satisfies z.ZodType; + +export const auditEntrySchema = z.object({ + id: z.string(), + proposalId: z.string().nullable(), + userId: z.string(), + userName: z.string().nullable(), + action: z.string(), + details: z.string().nullable(), + timestamp: z.string(), + ipAddress: z.string().nullable(), +}) satisfies z.ZodType; + +// ── Sites ───────────────────────────────────────────────────────────────── +export const siteSchema = z.object({ + siteCode: z.string(), + fullAddress: z.string().nullable(), + address: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + zip: z.string().nullable(), +}) satisfies z.ZodType; + +// ── Auth / users ────────────────────────────────────────────────────────── +export const authUserSchema = z.object({ + id: z.string(), + email: z.string(), + displayName: z.string(), + role: userRoleSchema, + token: z.string(), +}) satisfies z.ZodType; + +export const userProfileSchema = z.object({ + id: z.string(), + email: z.string(), + displayName: z.string(), + role: userRoleSchema, +}) satisfies z.ZodType; + +// ── Files / PDFs ────────────────────────────────────────────────────────── +export const pdfVersionSchema = z.object({ + revision: z.number(), + generatedAt: z.string(), +}) satisfies z.ZodType; + +export const presignedUploadSchema = z.object({ + uploadUrl: z.string(), + s3Key: z.string(), + expiresAt: z.string(), + vendorProposalId: z.string(), +}) satisfies z.ZodType; + +// ── Errors ──────────────────────────────────────────────────────────────── +export const apiProblemSchema = z.object({ + status: z.number(), + title: z.string(), + detail: z.string(), + code: z.string(), +}) satisfies z.ZodType; diff --git a/web/package-lock.json b/web/package-lock.json index 60df75c..5d716c3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", + "@proposal-system/api-contracts": "file:../shared/api-contracts", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", @@ -23,7 +24,8 @@ "react-dom": "^19.2.7", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -38,6 +40,16 @@ "vitest": "^4.1.10" } }, + "../shared/api-contracts": { + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "~7.0.2" + } + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -920,6 +932,10 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@proposal-system/api-contracts": { + "resolved": "../shared/api-contracts", + "link": true + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -4002,6 +4018,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/web/package.json b/web/package.json index 42707cc..c9b4793 100644 --- a/web/package.json +++ b/web/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "postinstall": "npm ci --prefix ../shared/api-contracts", "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", @@ -18,6 +19,7 @@ "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", + "@proposal-system/api-contracts": "file:../shared/api-contracts", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", @@ -26,7 +28,8 @@ "react-dom": "^19.2.7", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 47df6b8..918524c 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -1,31 +1,8 @@ import apiClient from './client'; -import { type ProposalDetail } from './proposals'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts'; -export interface DashboardStats { - pendingCount: number; - approvedThisWeek: number; - avgTurnaroundHours: number; - totalProposals: number; -} - -export interface UpdateProposalRequest { - refinedScope?: string; - notes?: string; - poNumber?: string; - workOrderNumber?: string; - assignedAdminId?: string; -} - -export interface AuditEntry { - id: string; - proposalId: string | null; - userId: string; - userName: string; - action: string; - details: string | null; - timestamp: string; - ipAddress: string | null; -} +export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; export const adminApi = { getDashboard: async (): Promise => { diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts index b964dc2..673cc77 100644 --- a/web/src/lib/api/auth.ts +++ b/web/src/lib/api/auth.ts @@ -1,19 +1,8 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { AuthUser, UserProfile } from '@proposal-system/api-contracts'; -export interface AuthUser { - id: string; - email: string; - displayName: string; - role: 'Dispatcher' | 'Admin' | 'SysAdmin'; - token: string; -} - -export interface UserProfile { - id: string; - email: string; - displayName: string; - role: 'Dispatcher' | 'Admin' | 'SysAdmin'; -} +export type { AuthUser, UserProfile } from '@proposal-system/api-contracts'; export const authApi = { exchangeCode: async (code: string, redirectUri: string): Promise => { diff --git a/web/src/lib/api/customers.ts b/web/src/lib/api/customers.ts index ddef320..61ee7ee 100644 --- a/web/src/lib/api/customers.ts +++ b/web/src/lib/api/customers.ts @@ -1,38 +1,23 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes +// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest +// sent a singular `address` field the API silently dropped. +import type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, + PagedResponse, +} from '@proposal-system/api-contracts'; -export interface Customer { - id: string; - name: string; - addresses: string[]; - /** Contact email for proposal delivery. Null when not set. */ - contactEmail?: string | null; - createdAt: string; -} +export type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, +} from '@proposal-system/api-contracts'; -export interface CustomerListParams { - page?: number; - pageSize?: number; - search?: string; -} - -export interface PagedCustomerResponse { - items: Customer[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface CreateCustomerRequest { - name: string; - address: string; - contactEmail?: string; -} - -export interface UpdateCustomerRequest { - name: string; - address: string; - contactEmail?: string; -} +export type PagedCustomerResponse = PagedResponse; export const customersApi = { /** GET /api/customers?query= — search autocomplete (unchanged). */ diff --git a/web/src/lib/api/lineItems.ts b/web/src/lib/api/lineItems.ts index a3f3fc4..95f4eb8 100644 --- a/web/src/lib/api/lineItems.ts +++ b/web/src/lib/api/lineItems.ts @@ -1,45 +1,14 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts'; -export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; -export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; - -export interface LineItem { - id: string; - proposalId: string; - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; - createdAt: string; - updatedAt: string; -} - -export interface CreateLineItemRequest { - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; -} - -export interface UpdateLineItemEntry { - id: string | null; - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; -} +export type { + PricingMode, + LineItemSource, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, +} from '@proposal-system/api-contracts'; export const lineItemsApi = { getAll: async (proposalId: string): Promise => { diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/lib/api/pricingLibrary.ts index d477779..a0b4bb0 100644 --- a/web/src/lib/api/pricingLibrary.ts +++ b/web/src/lib/api/pricingLibrary.ts @@ -1,47 +1,21 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, + PagedResponse, +} from '@proposal-system/api-contracts'; -export interface PricingLibraryItem { - id: string; - title: string; - description?: string | null; - serviceCategory: string; - unit?: string | null; - unitPrice?: number | null; - keywords?: string | null; - source: string; - createdAt: string; - updatedAt: string; -} +export type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, +} from '@proposal-system/api-contracts'; -export interface PricingLibraryListParams { - page?: number; - pageSize?: number; -} - -export interface PagedPricingLibraryResponse { - items: PricingLibraryItem[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface CreatePricingLibraryItemRequest { - title: string; - serviceCategory: string; - description?: string; - unit?: string; - unitPrice?: number | null; - keywords?: string; -} - -export interface UpdatePricingLibraryItemRequest { - title: string; - serviceCategory: string; - description?: string; - unit?: string; - unitPrice?: number | null; - keywords?: string; -} +export type PagedPricingLibraryResponse = PagedResponse; export const pricingLibraryApi = { /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts index cac276c..024d1d8 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/lib/api/proposals.ts @@ -1,80 +1,29 @@ import apiClient from './client'; +// Fix: WEB-M5 — types now come from the shared contracts package (single source +// of truth aligned with the .NET DTOs); re-exported so page imports stay stable. +import type { + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; -// Fix: WEB-M5 — align with shared contract types (shared/api-contracts/src/index.ts) -export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other'; -export type Priority = 'Standard' | 'Urgent' | 'Emergency'; - -export interface CreateProposalRequest { - workOrderNumber: string; - poNumber?: string; - customerName: string; - customerAddress: string; - scopeOfWork: string; - serviceCategory: ServiceCategory; - priority: Priority; - notes?: string; -} - -export interface ProposalListItem { - id: string; - proposalNumber: string; - customerName: string; - workOrderNumber: string; - serviceCategory: string; - priority: string; - status: string; - totalBidAmount: number; - submittedAt: string; - submittedByName: string | null; - assignedAdminName: string | null; -} - -export interface ProposalDetail { - id: string; - proposalNumber: string; - workOrderNumber: string; - poNumber: string | null; - customerName: string; - customerAddress: string; - scopeOfWork: string; - refinedScope: string | null; - serviceCategory: string; - priority: string; - status: string; - totalBidAmount: number; - vendorTotalCost: number | null; - notes: string; - submittedById: string; - submittedByName: string | null; - submittedAt: string; - assignedAdminId: string | null; - approvedById: string | null; - approvedAt: string | null; - sentAt: string | null; - currentRevision: number; - parentProposalId: string | null; - createdAt: string; - updatedAt: string; -} - -export interface PagedResponse { - items: T[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface ProposalFilters { - search?: string; - page?: number; - pageSize?: number; - status?: string; - serviceCategory?: string; - priority?: string; - fromDate?: string; - toDate?: string; - mine?: boolean; -} +export type { + ServiceCategory, + Priority, + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; export const proposalsApi = { create: async (data: CreateProposalRequest): Promise => { @@ -103,7 +52,7 @@ export const proposalsApi = { return res.data; }, - uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<{ uploadUrl: string; s3Key: string; vendorProposalId: string }> => { + uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise => { const params = new URLSearchParams({ fileName }); if (vendorName) params.append('vendorName', vendorName); const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`); @@ -146,15 +95,3 @@ export const proposalsApi = { return { downloadUrl }; }, }; - -export interface ProposalStats { - totalCount: number; - inReviewCount: number; - approvedCount: number; - sentCount: number; -} - -export interface PdfVersion { - revision: number; - generatedAt: string; -} diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts index e65bb19..eb0b8a8 100644 --- a/web/src/lib/api/sites.ts +++ b/web/src/lib/api/sites.ts @@ -1,13 +1,8 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { Site } from '@proposal-system/api-contracts'; -export interface Site { - siteCode: string; - fullAddress: string | null; - address: string | null; - city: string | null; - state: string | null; - zip: string | null; -} +export type { Site } from '@proposal-system/api-contracts'; export const sitesApi = { search: async (query: string): Promise => { diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index f5ec909..bd6a515 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -176,9 +176,12 @@ export default function CustomerManagementPage() { const handleSubmit = () => { if (!validateForm()) return; + // Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs); + // the old singular `address` field was silently dropped by model binding. + // The form edits the primary address; any additional addresses are preserved. const payload = { name: form.name.trim(), - address: form.address.trim(), + addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])], ...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}), }; diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx index 099c2a7..ea200c6 100644 --- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -37,6 +37,7 @@ import { type CreatePricingLibraryItemRequest, type UpdatePricingLibraryItemRequest, } from '../../../lib/api/pricingLibrary'; +import type { ServiceCategory } from '@proposal-system/api-contracts'; import { queryClient } from '../../../lib/queryClient'; import { QUERY_KEYS } from '../../../constants/queryKeys'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; @@ -56,7 +57,7 @@ const CATEGORY_CHIP_STYLES: Record { if (!validateForm()) return; + if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union const payload = { title: form.title.trim(), diff --git a/web/tsconfig.json b/web/tsconfig.json index 1bc6e12..0651de3 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,7 +15,11 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "paths": { + "@proposal-system/api-contracts": ["../shared/api-contracts/src"], + "@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"] + } }, - "include": ["src"] + "include": ["src", "../shared/api-contracts/src"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 4953e6a..782b661 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + resolve: { + // @proposal-system/api-contracts is a file: symlink; preserve it so its + // 'zod' import resolves from web/node_modules in CI (shared has no + // installed node_modules there). + preserveSymlinks: true, + }, server: { port: 5173, proxy: {