Shared Package
The packages/shared directory contains shared code used across all ForgeX microservices (bids, projects, field). This ensures consistency in types, constants, and utility functions while avoiding code duplication.
The shared package is referenced directly via relative paths. No build step or npm publish required — changes are immediately available to all services.
Package Structure
packages/shared/
├── README.md
├── package.json
├── types/
│ └── index.ts # TypeScript type definitions & enums
├── constants/
│ ├── bidStatuses.js # Bid status definitions & transitions
│ ├── defaults.js # Default values (rates, prices, settings)
│ ├── exportOptions.js # PDF export configuration
│ ├── features.js # Feature flags & API endpoints
│ └── validation.js # Validation rules & constraints
└── utils/
├── calculations.js # Cost/quantity calculation utilities
├── formatters.js # Formatting utilities (JS version)
├── formatters.ts # Formatting utilities (TS version)
└── normalize.js # Data normalization utilities
Types (types/index.ts)
Central TypeScript type definitions and enums used across all services.
Enums
- UserRole
- BidStatus
- ModuleType
- ProjectStatus
- PurchaseOrderStatus
- TimesheetStatus
Defines user roles and permissions across the platform:
export enum UserRole {
ADMIN = 'ADMIN', // Phase 1 - Full access
ESTIMATOR = 'ESTIMATOR', // Phase 1 - Bid creation/editing
PM = 'PM', // Phase 1 - Read-only bids, Phase 2 - Project management
OPS = 'OPS', // Phase 2 - Purchase orders
ACCOUNTING = 'ACCOUNTING', // Phase 2 - Cost reconciliation
FOREMAN = 'FOREMAN' // Phase 3 - Field operations
}
Usage:
import { UserRole } from '../../../../packages/shared/types';
if (user.role === UserRole.ADMIN) {
// Allow deletion
}
Bid lifecycle statuses:
export enum BidStatus {
DRAFT = 'DRAFT', // Initial state, fully editable
SUBMITTED = 'SUBMITTED', // Sent to client
NEGOTIATION = 'NEGOTIATION', // Under discussion
AWARDED = 'AWARDED', // Won the bid
COMPLETED = 'COMPLETED', // Project completed
LOST = 'LOST' // Did not win
}
Valid Transitions:
- DRAFT → SUBMITTED
- SUBMITTED → NEGOTIATION, AWARDED, LOST
- NEGOTIATION → AWARDED, LOST, SUBMITTED
- AWARDED → COMPLETED
Estimation module types:
export enum ModuleType {
CONCRETE = 'CONCRETE',
LABOR = 'LABOR',
EQUIPMENT = 'EQUIPMENT',
MATERIALS = 'MATERIALS',
SUBLET = 'SUBLET',
MISC = 'MISC'
}
Used for cost rollup and categorization.
Project lifecycle statuses (Phase 2):
export enum ProjectStatus {
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
ON_HOLD = 'ON_HOLD',
ARCHIVED = 'ARCHIVED'
}
PO workflow statuses (Phase 2):
export enum PurchaseOrderStatus {
DRAFT = 'DRAFT',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
POSTED = 'POSTED'
}
Timesheet statuses (Phase 3):
export enum TimesheetStatus {
OPEN = 'OPEN',
SUBMITTED = 'SUBMITTED',
ALLOCATED = 'ALLOCATED'
}
Interfaces
User
export interface User {
id: string;
email: string;
googleId?: string;
name?: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
Client
export interface Client {
id: string;
name: string;
email: string;
createdBy: string;
createdAt: Date;
}
Bid
export interface Bid {
id: string;
clientId: string;
jobName: string;
location: string;
status: BidStatus;
createdBy: string;
createdAt: Date;
lastUpdated: Date;
lastUpdatedBy?: string;
// Cost rollup fields
concreteCost: number;
laborCost: number;
equipmentCost: number;
materialCost: number;
subletCost: number;
miscCost: number;
subtotalCost: number;
overheadAmount: number;
profitAmount: number;
totalCost: number;
}
Scope
export interface Scope {
id: string;
bidId: string;
name: string;
shape: string;
length?: number;
width?: number;
height?: number;
diameter?: number;
cubicYards?: number;
rebarWasteOverride?: number;
createdBy: string;
createdAt: Date;
// Cost rollup fields
concreteCost: number;
laborCost: number;
equipmentCost: number;
materialCost: number;
subletCost: number;
miscCost: number;
totalCost: number;
}
BidWithDetails (Extended)
Extended Bid interface with project details:
export interface BidWithDetails extends Bid {
taxExempt?: boolean;
perDiemEnabled?: boolean;
notes?: string | null;
insuranceType?: InsuranceType;
certifiedPayroll?: boolean;
c3Required?: boolean;
drugTestingRequired?: boolean;
attachments?: BidAttachment[];
exclusions?: BidExclusion[];
}
InsuranceType:
export type InsuranceType = 'CCIP' | 'OCIP' | 'RCIP' | 'NONE' | null;
Constants
features.js - Feature Flags
Controls module and role visibility across all services:
module.exports = {
// Module availability flags
ENABLED_MODULES: {
BID_MANAGER: true, // Phase 1 - LIVE
PROJECT_MANAGEMENT: false, // Phase 2 - Coming soon
FIELD_OPERATIONS: false, // Phase 3 - Coming soon
},
// Role visibility flags
ENABLED_ROLES: {
ADMIN: true, // Phase 1
ESTIMATOR: true, // Phase 1
PM: true, // Phase 1 (read-only)
OPS: false, // Phase 2
ACCOUNTING: false,// Phase 2
FOREMAN: false, // Phase 3
},
// Service API endpoints
API_ENDPOINTS: {
BIDS: process.env.BIDS_API_URL || 'http://localhost:5001',
PROJECTS: process.env.PROJECTS_API_URL || 'http://localhost:5002',
FIELD: process.env.FIELD_API_URL || 'http://localhost:5003',
},
// Pub/Sub topic names
PUBSUB_TOPICS: {
BID_AWARDED: 'bid-awarded',
PROJECT_UPDATED: 'project-updated',
TIMESHEET_SUBMITTED: 'timesheet-submitted',
}
};
Usage:
const { ENABLED_MODULES } = require('../../../../packages/shared/constants/features');
if (ENABLED_MODULES.PROJECT_MANAGEMENT) {
// Show Projects tile
}
See Feature Flags for details.
defaults.js - Default Values
Centralized default values for rates, prices, and settings:
module.exports = {
REBAR: {
BASE_PRICE: 0.85,
TAX_RATE: 0.0825,
WASTE_PERCENT: 0,
STANDARD_WEIGHTS: {
'#2': 0.167,
'#3': 0.376,
'#4': 0.668,
'#5': 1.043,
'#6': 1.502,
'#7': 2.044,
'#8': 2.670,
'#9': 3.400,
'#10': 4.303,
'#11': 5.310
}
},
LABOR: {
PER_DIEM_RATE: 138,
DEFAULT_HOURS_PER_DAY: 8,
DEFAULT_DAYS: 1
},
EQUIPMENT: {
DEFAULT_DELIVERY_FEE: 300,
DEFAULT_FUEL_CHARGE_PER_DAY: 50,
DEFAULT_RENTAL_TAX_RATE: 0.1225,
FORMWORK_TAX_RATE: 0,
DEFAULT_DURATION_VALUE: 1,
DEFAULT_DURATION_UNIT: 'day'
},
MATERIALS: {
DEFAULT_WASTE_PERCENT: 0,
DEFAULT_TAX_RATE: 0.0825
},
SUBCONTRACTOR: {
DEFAULT_WASTE_PERCENT: 0,
DEFAULT_UNIT: 'LS'
},
BID: {
DEFAULT_LOCATION: 'Texas',
DEFAULT_STATUS: 'DRAFT',
DEFAULT_TAX_EXEMPT: false,
DEFAULT_PER_DIEM_ENABLED: false
}
};
Usage:
const { EQUIPMENT } = require('../../../../packages/shared/constants/defaults');
const deliveryFee = item.deliveryFee ?? EQUIPMENT.DEFAULT_DELIVERY_FEE;
bidStatuses.js - Status Definitions
Bid status transitions and validation:
module.exports = {
STATUSES: {
DRAFT: 'DRAFT',
SUBMITTED: 'SUBMITTED',
NEGOTIATION: 'NEGOTIATION',
AWARDED: 'AWARDED',
COMPLETED: 'COMPLETED',
LOST: 'LOST'
},
VALID_TRANSITIONS: {
DRAFT: ['SUBMITTED'],
SUBMITTED: ['NEGOTIATION', 'AWARDED', 'LOST'],
NEGOTIATION: ['AWARDED', 'LOST', 'SUBMITTED'],
AWARDED: ['COMPLETED'],
COMPLETED: [],
LOST: []
},
// Check if transition is valid
canTransition(from, to) {
return this.VALID_TRANSITIONS[from]?.includes(to) ?? false;
}
};
Usage:
const { canTransition } = require('../../../../packages/shared/constants/bidStatuses');
if (!canTransition(bid.status, newStatus)) {
throw new Error('Invalid status transition');
}
validation.js - Validation Rules
Validation constraints for inputs:
module.exports = {
BID: {
JOB_NAME_MAX_LENGTH: 255,
LOCATION_MAX_LENGTH: 255,
NOTES_MAX_LENGTH: 5000
},
SCOPE: {
NAME_MAX_LENGTH: 255,
MAX_MULTIPLIER: 1000,
MIN_MULTIPLIER: 1
},
ITEM: {
DESCRIPTION_MAX_LENGTH: 500,
MAX_QUANTITY: 1000000,
MIN_QUANTITY: 0
},
CLIENT: {
NAME_MAX_LENGTH: 255,
EMAIL_MAX_LENGTH: 255
}
};
exportOptions.js - Export Configuration
PDF export field definitions and options:
module.exports = {
FIELD_GROUPS: {
BASIC: ['jobName', 'clientName', 'location'],
COMPLIANCE: ['insuranceType', 'certifiedPayroll', 'c3Required'],
COSTS: ['concreteCost', 'laborCost', 'equipmentCost']
},
EXCLUDED_FIELDS: ['createdAt', 'updatedAt', 'id'],
DATE_FORMAT: 'YYYY-MM-DD',
CURRENCY_FORMAT: { style: 'currency', currency: 'USD' }
};
Utilities
formatters.ts / formatters.js - Formatting
Consistent formatting utilities for currency, percentages, and quantities:
- formatCurrency
- formatPercent
- formatQuantity
export const formatCurrency = (
value: number | string | null | undefined,
options?: Intl.NumberFormatOptions
): string => {
const numeric = Number(value || 0);
if (!Number.isFinite(numeric)) {
return '$0.00';
}
return numeric.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
...options
});
};
Usage:
import { formatCurrency } from '../../../../packages/shared/utils/formatters';
formatCurrency(1234.56) // "$1,234.56"
formatCurrency(null) // "$0.00"
export const formatPercent = (
value: number | string | null | undefined,
fractionDigits: number = 1
): string => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '0%';
// Normalize: 0-1 = decimal, else percentage
const normalized = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return `${normalized.toFixed(fractionDigits)}%`;
};
Usage:
formatPercent(0.1) // "10.0%"
formatPercent(10) // "10.0%"
formatPercent(10.5) // "10.5%"
export const formatQuantity = (
value: number | string | null | undefined,
fractionDigits: number = 2
): string => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '0.00';
return numeric.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
});
};
Usage:
formatQuantity(1234.567) // "1,234.57"
formatQuantity(1234.567, 0) // "1,235"
calculations.js - Calculation Utilities
Common calculation functions:
module.exports = {
/**
* Calculate rebar pounds from cubic yards
*/
calculateRebarPounds(cubicYards, poundsPerCY) {
return cubicYards * poundsPerCY;
},
/**
* Calculate cubic yards from dimensions
*/
calculateCubicYards(length, width, depth) {
return (length * width * depth) / 27; // 27 cubic feet per cubic yard
},
/**
* Apply waste percentage
*/
applyWaste(quantity, wastePercent) {
return quantity * (1 + wastePercent / 100);
},
/**
* Calculate tax amount
*/
calculateTax(amount, taxRate) {
return amount * taxRate;
}
};
normalize.js - Normalization Utilities
Data normalization and validation:
module.exports = {
/**
* Normalize boolean from various formats
*/
normalizeBoolean(value) {
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1' || value === 1) return true;
if (value === 'false' || value === '0' || value === 0) return false;
return null;
},
/**
* Normalize numeric value
*/
normalizeNumber(value, defaultValue = 0) {
const num = Number(value);
return Number.isFinite(num) ? num : defaultValue;
},
/**
* Normalize email (lowercase, trim)
*/
normalizeEmail(email) {
if (typeof email !== 'string') return null;
return email.toLowerCase().trim();
}
};
How Services Use Shared
Backend (Node.js)
Import via relative paths:
// Types (via JSDoc)
/**
* @typedef {import('../../../../packages/shared/types').Bid} Bid
* @typedef {import('../../../../packages/shared/types').UserRole} UserRole
*/
// Constants
const { ENABLED_MODULES } = require('../../../../packages/shared/constants/features');
const { REBAR } = require('../../../../packages/shared/constants/defaults');
// Utilities
const { formatCurrency } = require('../../../../packages/shared/utils/formatters');
const { normalizeBoolean } = require('../../../../packages/shared/utils/normalize');
Frontend (React + TypeScript)
Import via relative paths:
// Types & Enums
import { UserRole, BidStatus, ModuleType } from '../../../../packages/shared/types';
// Constants
import { ENABLED_MODULES } from '../../../../packages/shared/constants/features';
// Utilities
import { formatCurrency, formatPercent } from '../../../../packages/shared/utils/formatters';
// Usage
const formattedTotal = formatCurrency(bid.totalCost);
if (user.role === UserRole.ADMIN) {
// Show admin features
}
Adding New Shared Code
Decide where the new code belongs:
- Types: New interface or enum
- Constants: Configuration or default value
- Utils: Reusable function
Edit the relevant file in packages/shared/:
types/index.tsfor TypeScript typesconstants/[category].jsfor constantsutils/[utility].jsor.tsfor utilities
Ensure the new code is exported:
// types/index.ts
export interface NewInterface { ... }
// constants/features.js
module.exports = { NEW_FEATURE_FLAG: true };
// utils/calculations.js
module.exports = { newCalculation: () => { ... } };
Document the new addition in packages/shared/README.md.
Import and use in any service:
import { NewInterface } from '../../../../packages/shared/types';
Changes are immediately available (no build step).
Best Practices
Type Everything
Use TypeScript types for all shared interfaces. Enforce type safety across services.
Single Source of Truth
Define constants once in shared package. Avoid duplicating values across services.
Keep It Pure
Shared utilities should be pure functions with no side effects or dependencies.
Document Changes
Update README.md when adding new shared code. Include usage examples.
Versioning & Breaking Changes
Shared package changes affect ALL services. Breaking changes require coordinated updates across services.
Safe Changes
✅ Adding new types, constants, or utilities ✅ Adding optional fields to interfaces ✅ Adding new enum values (append only) ✅ Deprecating (but not removing) old utilities
Breaking Changes
❌ Removing types, constants, or utilities ❌ Changing function signatures ❌ Removing or renaming interface fields ❌ Removing enum values
For breaking changes:
- Deprecate old version (add comment)
- Add new version alongside old
- Update all services to use new version
- Remove old version in next major release
Testing Shared Code
Shared code should be tested within each service that uses it:
// Example: Test formatCurrency in bids backend
describe('formatCurrency', () => {
it('formats valid numbers', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
});
it('handles null gracefully', () => {
expect(formatCurrency(null)).toBe('$0.00');
});
});