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.
2026-07-13 17:19:42 -04:00
|
|
|
// 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,
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
ProposalVersionRequest,
|
|
|
|
|
ConcurrencyConflict,
|
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.
2026-07-13 17:19:42 -04:00
|
|
|
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(),
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
rowVersion: z.string(),
|
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.
2026-07-13 17:19:42 -04:00
|
|
|
}) 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(),
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
rowVersion: z.string(),
|
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.
2026-07-13 17:19:42 -04:00
|
|
|
}) 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(),
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
proposalVersion: z.string().optional(),
|
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.
2026-07-13 17:19:42 -04:00
|
|
|
}) satisfies z.ZodType<UpdateProposalRequest>;
|
|
|
|
|
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
export const proposalVersionRequestSchema = z.object({
|
|
|
|
|
proposalVersion: z.string().optional(),
|
|
|
|
|
}) satisfies z.ZodType<ProposalVersionRequest>;
|
|
|
|
|
|
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.
2026-07-13 17:19:42 -04:00
|
|
|
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(),
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
rowVersion: z.string(),
|
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.
2026-07-13 17:19:42 -04:00
|
|
|
}) 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),
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
proposalVersion: z.string().optional(),
|
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.
2026-07-13 17:19:42 -04:00
|
|
|
}) 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>;
|
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer
Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency
wire contract shipped in 6a.
shared/api-contracts (additive):
- rowVersion: string on ProposalListItem, ProposalDetail, and LineItem
- proposalVersion?: string on UpdateProposalRequest and
BulkUpdateLineItemsRequest; new ProposalVersionRequest
- ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004
409 envelope (guarded path; the unguarded fallback carries no state)
- matching zod schemas, all kept under the satisfies z.ZodType<T> coupling
web:
- lib/api/errors.ts: ConflictError carrying the server's reloaded
currentState; client.ts interceptor throws it on 409 (WEB-M2 401
handling untouched)
- guarded mutations read the token from the cached proposal detail at
mutate time; the save flow chains rotated tokens (PUT response token
into the bulk replace) and ends with a detail refetch so
approve-after-save never sends a stale version
- 409 recovery in the admin use-cases: write currentState into the
detail cache, invalidateProposalViews() (stale-queue invariant holds
on the failure path too), and toast the conflict instead of the
generic failure message
- e2e smoke mock payloads carry rowVersion; vitest coverage for the
interceptor ConflictError paths, token threading/rotation, and 409
cache recovery
Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright
smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
|
|
|
|
|
|
|
|
// 409 concurrency-conflict envelope. The proposal aggregate is the only
|
|
|
|
|
// guarded resource today, so the concrete schema is coupled to ProposalDetail;
|
|
|
|
|
// build others via the same pattern when new aggregates gain guards.
|
|
|
|
|
export const proposalConcurrencyConflictSchema = z.object({
|
|
|
|
|
message: z.string(),
|
|
|
|
|
currentState: proposalDetailSchema.nullable(),
|
|
|
|
|
}) satisfies z.ZodType<ConcurrencyConflict<ProposalDetail>>;
|