feat(contracts): shared api-contracts adoption, zod schemas, ProblemDetails codes (#222)

* feat(web): adopt SHOC design system and shell layout (ADR 0003)

Port shoc-frontend-new dev's design system with its CSS-variable
single-token-source mechanism:

- src/styles/theme.css: SHOC token file ported verbatim (Montserrat/
  DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/
  shadow/sidebar/header token layers); fonts self-hosted via @fontsource
- src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme
  adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows
  tuple, component overrides; MUI v9 slot renames expressed as class
  selectors); theme.ts is now a re-export
- Shell: SHOC composition (sidebar column + sticky gradient topbar +
  scrolling main); sidebar 244px/76px collapse with brand header row,
  grouped nav, SHOC active treatment (white card + 3px accent bar);
  topbar 100-degree gradient, surface hamburger, gradient avatar pill
- Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as
  sx; wordmark subtitle localized to PROPOSAL SYSTEM)
- Login: SHOC auth-card treatment (centered 384px card on #f9fafb)
- Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed;
  remaining hardcoded hexes replaced with tokens; lucide-react for
  shell/nav icons per SHOC convention

Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots
pixel-sampled against the extracted SHOC spec (all hard values exact,
no blocking deviations).

* feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes

Closes WEB-M5 (web hand-duplicated wire types, standing drift risk):

- shared/api-contracts: rewritten as the authoritative superset of the
  .NET DTOs (ProposalListItem/ProposalDetail with poNumber and
  submittedByName, line item requests, customers, pricing library,
  dashboard, audit, sites, auth, presigned upload, ApiProblem); stale
  Proposal/UpdateLineItemsRequest shapes removed
- shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to
  every wire type via `satisfies z.ZodType<T>` (schema/type drift is now
  a compile error); separate entrypoint so type-only consumers (mobile)
  never pull zod
- web: imports @proposal-system/api-contracts (file: dep + tsconfig
  paths + vite preserveSymlinks); all 7 lib/api modules re-export shared
  types so page imports stay stable; enum unions tightened
  (PricingLibraryPage form state now ServiceCategory-typed)
- fix(web): customer create/update sent a singular `address` field the
  API silently dropped (contract is addresses: string[], CustomerDtos.cs)
  - addresses now round-trip, extra addresses preserved on edit
- api: ProblemDetails responses carry a machine-readable top-level
  `code` (SHOC error-code vocabulary): ValidationFailed,
  InvalidStateTransition, NotFound, Unauthorized, InternalError; new
  BusinessRuleException(code, message) maps to 422 with its code;
  GlobalExceptionHandlerTests cover the full mapping (wire contract)

Cross-checked .NET DTOs vs TS types vs zod schemas with the
orchestrator scanner (Gemini): core domains consistent; internal-only
DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos
admin surface) intentionally uncovered.

Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc,
shared tsc all green.

* fix(web): install shared api-contracts deps via postinstall

Web Frontend Check failed on PR #222: tsc compiles
shared/api-contracts/src/schemas.ts through the tsconfig path alias,
and module resolution for its zod import walks up from shared/, never
reaching web/node_modules. CI only ran npm ci in web/, so the shared
package's deps were absent. A postinstall hook installs them wherever
web's deps are installed (CI typecheck, web-test, deploy bundling).

Passed locally only because a stray repo-root node_modules/zod
satisfied the lookup.
This commit is contained in:
Adam Moussa 2026-07-13 19:28:33 -04:00 • committed by GitHub
parent 8ce2a5cd8f
commit dfaee830f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 744 additions and 307 deletions

View file

@ -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")
),
};

View file

@ -0,0 +1,17 @@
namespace ProposalSystem.Application.Common;
/// <summary>
/// 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.
/// </summary>
public class BusinessRuleException : Exception
{
public string Code { get; }
public BusinessRuleException(string code, string message) : base(message)
{
Code = code;
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public class GlobalExceptionHandlerTests
{
private static async Task<(int Status, JsonElement Body)> InvokeWith(Exception exception)
{
var logger = Substitute.For<ILogger<GlobalExceptionHandler>>();
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<ILogger<GlobalExceptionHandler>>();
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");
}
}

View file

@ -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"
}
}
}
}

View file

@ -9,5 +9,8 @@
},
"devDependencies": {
"typescript": "~7.0.2"
},
"dependencies": {
"zod": "^4.4.3"
}
}

View file

@ -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<T> {
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<LineItem, 'id' | 'proposalId' | 'createdAt' | 'updatedAt'>[];
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<T> {
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;
}

View file

@ -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<T>` — 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<ProposalStatus>;
export const serviceCategorySchema = z.enum(['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other']) satisfies z.ZodType<ServiceCategory>;
export const prioritySchema = z.enum(['Standard', 'Urgent', 'Emergency']) satisfies z.ZodType<Priority>;
export const lineItemSourceSchema = z.enum(['AI', 'Vendor', 'Manual', 'Historical']) satisfies z.ZodType<LineItemSource>;
export const pricingModeSchema = z.enum(['UnitPrice', 'TotalPrice', 'Both']) satisfies z.ZodType<PricingMode>;
export const userRoleSchema = z.enum(['Dispatcher', 'Admin', 'SysAdmin']) satisfies z.ZodType<UserRole>;
// ── 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<ProposalListItem>;
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<ProposalDetail>;
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<CreateProposalRequest>;
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<UpdateProposalRequest>;
export const proposalStatsSchema = z.object({
totalCount: z.number(),
inReviewCount: z.number(),
approvedCount: z.number(),
sentCount: z.number(),
}) satisfies z.ZodType<ProposalStats>;
// ── 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<LineItem>;
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<CreateLineItemRequest>;
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<UpdateLineItemEntry>;
export const bulkUpdateLineItemsRequestSchema = z.object({
lineItems: z.array(updateLineItemEntrySchema),
}) satisfies z.ZodType<BulkUpdateLineItemsRequest>;
// ── 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<Customer>;
export const createCustomerRequestSchema = z.object({
name: z.string().min(1),
addresses: z.array(z.string()).optional(),
contactEmail: z.string().optional(),
}) satisfies z.ZodType<CreateCustomerRequest>;
export const updateCustomerRequestSchema = z.object({
name: z.string().optional(),
addresses: z.array(z.string()).optional(),
contactEmail: z.string().nullable().optional(),
}) satisfies z.ZodType<UpdateCustomerRequest>;
// ── 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<PricingLibraryItem>;
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<CreatePricingLibraryItemRequest>;
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<UpdatePricingLibraryItemRequest>;
// ── Admin ─────────────────────────────────────────────────────────────────
export const dashboardStatsSchema = z.object({
pendingCount: z.number(),
approvedThisWeek: z.number(),
avgTurnaroundHours: z.number(),
totalProposals: z.number(),
}) satisfies z.ZodType<DashboardStats>;
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<AuditEntry>;
// ── 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<Site>;
// ── Auth / users ──────────────────────────────────────────────────────────
export const authUserSchema = z.object({
id: z.string(),
email: z.string(),
displayName: z.string(),
role: userRoleSchema,
token: z.string(),
}) satisfies z.ZodType<AuthUser>;
export const userProfileSchema = z.object({
id: z.string(),
email: z.string(),
displayName: z.string(),
role: userRoleSchema,
}) satisfies z.ZodType<UserProfile>;
// ── Files / PDFs ──────────────────────────────────────────────────────────
export const pdfVersionSchema = z.object({
revision: z.number(),
generatedAt: z.string(),
}) satisfies z.ZodType<PdfVersion>;
export const presignedUploadSchema = z.object({
uploadUrl: z.string(),
s3Key: z.string(),
expiresAt: z.string(),
vendorProposalId: z.string(),
}) satisfies z.ZodType<PresignedUpload>;
// ── Errors ────────────────────────────────────────────────────────────────
export const apiProblemSchema = z.object({
status: z.number(),
title: z.string(),
detail: z.string(),
code: z.string(),
}) satisfies z.ZodType<ApiProblem>;

27
web/package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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",

View file

@ -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<DashboardStats> => {

View file

@ -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<AuthUser> => {

View file

@ -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<Customer>;
export const customersApi = {
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */

View file

@ -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<LineItem[]> => {

View file

@ -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<PricingLibraryItem>;
export const pricingLibraryApi = {
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */

View file

@ -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<T> {
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<ProposalDetail> => {
@ -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<PresignedUpload> => {
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;
}

View file

@ -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<Site[]> => {

View file

@ -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() } : {}),
};

View file

@ -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<string, { bgcolor: string; color: string; bor
interface ItemFormState {
title: string;
serviceCategory: string;
serviceCategory: ServiceCategory | '';
unit: string;
unitPrice: string;
keywords: string;
@ -210,6 +211,7 @@ export default function PricingLibraryPage() {
const handleSubmit = () => {
if (!validateForm()) return;
if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union
const payload = {
title: form.title.trim(),

View file

@ -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"]
}

View file

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