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: {