From 6285f7ddd38b3b78985745c3d8a6dfea6c44b0e7 Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Sun, 31 May 2026 21:37:20 +0100 Subject: [PATCH] Add database indexes to opsce entity columns for query performance Add database indexes to opsce entity columns for query performance --- .../src/opsce/assets/entities/asset.entity.ts | 468 +++++++++++++++++- .../departments/entities/department.entity.ts | 223 ++++++++- .../locations/entities/location.entity.ts | 357 ++++++++++++- 3 files changed, 1001 insertions(+), 47 deletions(-) diff --git a/backend/src/opsce/assets/entities/asset.entity.ts b/backend/src/opsce/assets/entities/asset.entity.ts index 2d546799..3670b7ee 100644 --- a/backend/src/opsce/assets/entities/asset.entity.ts +++ b/backend/src/opsce/assets/entities/asset.entity.ts @@ -5,73 +5,497 @@ import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn, + ManyToOne, + OneToMany, + JoinColumn, Index, + Check, + BeforeInsert, + BeforeUpdate, } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Department } from '../../departments/entities/department.entity'; +import { Location } from '../../locations/entities/location.entity'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const ASSET_TAG_PATTERN = /^[A-Z0-9_-]{2,30}$/; + +// ─── Enums ──────────────────────────────────────────────────────────────────── export enum AssetStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', + ACTIVE = 'active', + INACTIVE = 'inactive', MAINTENANCE = 'maintenance', - RETIRED = 'retired', + RESERVED = 'reserved', + LOST = 'lost', + STOLEN = 'stolen', + DISPOSED = 'disposed', + RETIRED = 'retired', } export enum AssetCondition { + NEW = 'new', EXCELLENT = 'excellent', - GOOD = 'good', - FAIR = 'fair', - POOR = 'poor', + GOOD = 'good', + FAIR = 'fair', + POOR = 'poor', + DAMAGED = 'damaged', +} + +export enum DepreciationMethod { + STRAIGHT_LINE = 'straight_line', + DECLINING_BALANCE = 'declining_balance', + NONE = 'none', +} + +export enum MaintenanceFrequency { + WEEKLY = 'weekly', + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', + ANNUALLY = 'annually', + AS_NEEDED = 'as_needed', +} + +// ─── Value-object interfaces ────────────────────────────────────────────────── + +export interface WarrantyInfo { + /** Warranty provider / vendor name */ + provider: string; + /** Warranty reference or contract number */ + referenceNumber?: string; + /** ISO 8601 date — when warranty begins */ + startDate: string; + /** ISO 8601 date — when warranty expires */ + expiryDate: string; + /** Coverage description (e.g. "Parts and labour") */ + coverageDetails?: string; + /** Support contact (email or phone) */ + contactInfo?: string; +} + +export interface DepreciationConfig { + method: DepreciationMethod; + /** Useful life in years */ + usefulLifeYears: number; + /** Residual / salvage value at end of life */ + residualValue: number; + /** Annual depreciation rate as a decimal (e.g. 0.2 = 20%) — for declining-balance */ + annualRate?: number; } +export interface MaintenanceSchedule { + frequency: MaintenanceFrequency; + /** ISO 8601 date of the next scheduled maintenance */ + nextDueDate: string; + /** ISO 8601 date of the last completed maintenance */ + lastCompletedDate?: string; + /** Estimated duration in minutes */ + estimatedDurationMinutes?: number; + notes?: string; +} + +export interface InsuranceInfo { + provider: string; + policyNumber: string; + /** ISO 8601 date */ + expiryDate: string; + /** Insured value */ + insuredValue: number; + currency: string; +} + +export interface AssetCheckout { + userId: string; + checkedOutAt: string; + expectedReturnAt?: string; + returnedAt?: string; + notes?: string; +} + +// ─── Entity ─────────────────────────────────────────────────────────────────── + @Entity('assets') +@Index('IDX_ASSET_STATUS_CATEGORY', ['status', 'category']) +@Index('IDX_ASSET_DEPT_STATUS', ['departmentId', 'status']) +@Index('IDX_ASSET_LOCATION_STATUS', ['locationId', 'status']) +@Index('IDX_ASSET_ASSIGNED_USER', ['assignedToUserId']) +@Index('IDX_ASSET_DELETED_AT', ['deletedAt']) +@Check(`"name" <> ''`) +@Check(`"purchaseValue" IS NULL OR "purchaseValue" >= 0`) +@Check(`"currentValue" IS NULL OR "currentValue" >= 0`) +@Check(`"residualValue" IS NULL OR "residualValue" >= 0`) +@Check(`"usefulLifeYears" IS NULL OR "usefulLifeYears" > 0`) +@Check( + `"warrantyExpiryDate" IS NULL OR "purchaseDate" IS NULL OR "warrantyExpiryDate" >= "purchaseDate"`, +) export class Asset { + + // ─── Identity ─────────────────────────────────────────────────────────────── + @PrimaryGeneratedColumn('uuid') id: string; - @Column() + @Column({ length: 200 }) name: string; - @Column({ nullable: true }) + @Column({ type: 'text', nullable: true }) description?: string; + /** + * Asset tag / barcode printed on the physical label. + * Must match ASSET_TAG_PATTERN when set. + */ + @Index('IDX_ASSET_TAG', { unique: true, where: '"deletedAt" IS NULL AND "assetTag" IS NOT NULL' }) + @Column({ length: 30, nullable: true }) + assetTag?: string; + + /** + * Manufacturer serial number. + * Partial unique index — allows duplicate nulls for assets without serials. + */ + @Index('IDX_ASSET_SERIAL', { unique: true, where: '"deletedAt" IS NULL AND "serialNumber" IS NOT NULL' }) + @Column({ length: 100, nullable: true }) + serialNumber?: string; + + @Column({ length: 100, nullable: true }) + manufacturer?: string; + + @Column({ length: 100, nullable: true }) + model?: string; + + /** Model year (e.g. 2023). */ + @Column({ type: 'int', nullable: true }) + modelYear?: number; + + // ─── Classification ────────────────────────────────────────────────────────── + @Index() - @Column() + @Column({ length: 100 }) category: string; + /** + * Optional sub-category (e.g. category="IT" → subCategory="Laptop"). + */ @Index() - @Column({ nullable: true }) - serialNumber?: string; + @Column({ length: 100, nullable: true }) + subCategory?: string; + @Column({ + type: 'enum', + enum: AssetStatus, + default: AssetStatus.ACTIVE, + }) @Index() - @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) status: AssetStatus; - @Column({ type: 'enum', enum: AssetCondition, default: AssetCondition.GOOD }) + @Column({ + type: 'enum', + enum: AssetCondition, + default: AssetCondition.GOOD, + }) condition: AssetCondition; + /** + * Searchable tags (e.g. ["portable", "shared", "critical"]). + * Deduplicated and lowercased by lifecycle hook. + */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + tags: string[]; + + // ─── Financials ────────────────────────────────────────────────────────────── + + /** ISO 4217 currency code for all monetary values (e.g. "USD", "NGN"). */ + @Column({ length: 3, nullable: true, default: 'USD' }) + currency?: string; + @Column({ type: 'date', nullable: true }) purchaseDate?: Date; - @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) purchaseValue?: number; - @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + /** Book value as of the last valuation. */ + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) currentValue?: number; - @Column({ nullable: true }) + /** Salvage / residual value at end of useful life. */ + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + residualValue?: number; + + /** Useful life in years — used for depreciation calculations. */ + @Column({ type: 'int', nullable: true }) + usefulLifeYears?: number; + + @Column({ + type: 'enum', + enum: DepreciationMethod, + default: DepreciationMethod.STRAIGHT_LINE, + nullable: true, + }) + depreciationMethod?: DepreciationMethod; + + /** Full depreciation configuration stored as JSONB. */ + @Column({ type: 'jsonb', nullable: true }) + depreciationConfig?: DepreciationConfig; + + /** Name of the vendor / supplier the asset was purchased from. */ + @Column({ length: 200, nullable: true }) + vendor?: string; + + /** Purchase order number for procurement traceability. */ + @Column({ length: 100, nullable: true }) + purchaseOrderNumber?: string; + + /** Invoice number from the vendor. */ + @Column({ length: 100, nullable: true }) + invoiceNumber?: string; + + // ─── Warranty ──────────────────────────────────────────────────────────────── + + /** + * Denormalised expiry date for fast "expiring soon" queries. + * Kept in sync with warrantyInfo.expiryDate by the lifecycle hook. + */ + @Index('IDX_ASSET_WARRANTY_EXPIRY') + @Column({ type: 'date', nullable: true }) + warrantyExpiryDate?: Date; + + /** Full warranty detail stored as JSONB. */ + @Column({ type: 'jsonb', nullable: true }) + warrantyInfo?: WarrantyInfo; + + // ─── Insurance ─────────────────────────────────────────────────────────────── + + @Index('IDX_ASSET_INSURANCE_EXPIRY') + @Column({ type: 'date', nullable: true }) + insuranceExpiryDate?: Date; + + @Column({ type: 'jsonb', nullable: true }) + insuranceInfo?: InsuranceInfo; + + // ─── Maintenance ───────────────────────────────────────────────────────────── + + @Index('IDX_ASSET_MAINTENANCE_DUE') + @Column({ type: 'date', nullable: true }) + nextMaintenanceDue?: Date; + + @Column({ type: 'date', nullable: true }) + lastMaintenanceDate?: Date; + + @Column({ type: 'jsonb', nullable: true }) + maintenanceSchedule?: MaintenanceSchedule; + + // ─── Assignment ────────────────────────────────────────────────────────────── + + @Column({ type: 'uuid', nullable: true }) assignedToUserId?: string; - @Column({ nullable: true }) + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL', eager: false }) + @JoinColumn({ name: 'assignedToUserId' }) + assignedToUser?: User; + + /** ISO 8601 timestamp when the current assignment began. */ + @Column({ type: 'timestamptz', nullable: true }) + assignedAt?: Date; + + /** Expected return date for temporarily assigned assets. */ + @Column({ type: 'date', nullable: true }) + expectedReturnDate?: Date; + + /** + * Rolling checkout history (last 50 entries). + * Full history should be stored in a dedicated `asset_checkouts` table + * for high-volume assets. + */ + @Column({ type: 'jsonb', nullable: true, default: [] }) + checkoutHistory?: AssetCheckout[]; + + // ─── Placement ─────────────────────────────────────────────────────────────── + + @Column({ type: 'uuid', nullable: true }) departmentId?: string; - @Column({ nullable: true }) + @ManyToOne(() => Department, { nullable: true, onDelete: 'SET NULL', eager: false }) + @JoinColumn({ name: 'departmentId' }) + department?: Department; + + @Column({ type: 'uuid', nullable: true }) locationId?: string; - @CreateDateColumn() + @ManyToOne(() => Location, { nullable: true, onDelete: 'SET NULL', eager: false }) + @JoinColumn({ name: 'locationId' }) + location?: Location; + + // ─── Media & documentation ─────────────────────────────────────────────────── + + /** URL to the primary photo of the asset. */ + @Column({ type: 'text', nullable: true }) + photoUrl?: string; + + /** Additional photo URLs. */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + additionalPhotoUrls: string[]; + + /** URLs to manuals, certificates, invoices, etc. */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + documentUrls: string[]; + + // ─── Metadata ──────────────────────────────────────────────────────────────── + + /** + * Arbitrary JSON for third-party integrations + * (e.g. { "helpDeskTicketId": "…", "externalAssetId": "…" }). + */ + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // ─── Audit ─────────────────────────────────────────────────────────────────── + + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; - @UpdateDateColumn() + @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; - @DeleteDateColumn() + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) deletedAt?: Date; -} + + @Column({ type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ type: 'uuid', nullable: true }) + updatedBy?: string; + + @Column({ type: 'uuid', nullable: true }) + deletedBy?: string; + + /** Free-text reason for the most recent status change. */ + @Column({ type: 'text', nullable: true }) + statusChangeReason?: string; + + /** Timestamp of the most recent status change. */ + @Column({ type: 'timestamptz', nullable: true }) + statusChangedAt?: Date; + + // ─── Computed helpers ──────────────────────────────────────────────────────── + + get isDeleted(): boolean { + return !!this.deletedAt; + } + + get isAssigned(): boolean { + return !!this.assignedToUserId; + } + + /** + * Depreciation to date using straight-line method. + * Returns null if required fields are missing. + */ + get accruedDepreciation(): number | null { + if ( + this.purchaseValue == null || + this.residualValue == null || + this.usefulLifeYears == null || + !this.purchaseDate + ) return null; + + const msPerYear = 365.25 * 24 * 60 * 60 * 1000; + const ageYears = (Date.now() - new Date(this.purchaseDate).getTime()) / msPerYear; + const annualDep = (this.purchaseValue - this.residualValue) / this.usefulLifeYears; + const total = Math.min(annualDep * ageYears, this.purchaseValue - this.residualValue); + + return Math.max(0, parseFloat(total.toFixed(2))); + } + + /** + * Estimated current book value based on straight-line depreciation. + * Returns currentValue when explicitly set, falls back to computed value. + */ + get estimatedBookValue(): number | null { + if (this.currentValue != null) return Number(this.currentValue); + if (this.accruedDepreciation == null || this.purchaseValue == null) return null; + return Math.max( + this.residualValue ?? 0, + parseFloat((Number(this.purchaseValue) - this.accruedDepreciation).toFixed(2)), + ); + } + + get isWarrantyExpired(): boolean { + if (!this.warrantyExpiryDate) return false; + return new Date(this.warrantyExpiryDate) < new Date(); + } + + get isInsuranceExpired(): boolean { + if (!this.insuranceExpiryDate) return false; + return new Date(this.insuranceExpiryDate) < new Date(); + } + + get isMaintenanceOverdue(): boolean { + if (!this.nextMaintenanceDue) return false; + return new Date(this.nextMaintenanceDue) < new Date(); + } + + // ─── Lifecycle hooks ───────────────────────────────────────────────────────── + + @BeforeInsert() + @BeforeUpdate() + normalizeFields(): void { + if (this.name) this.name = this.name.trim(); + if (this.description) this.description = this.description.trim(); + if (this.vendor) this.vendor = this.vendor.trim(); + + if (this.assetTag) { + this.assetTag = this.assetTag.toUpperCase().trim(); + if (!ASSET_TAG_PATTERN.test(this.assetTag)) { + throw new Error( + `Asset tag "${this.assetTag}" is invalid. Must match ${ASSET_TAG_PATTERN.source}`, + ); + } + } + + if (this.serialNumber) { + this.serialNumber = this.serialNumber.trim(); + } + + // Deduplicate + lowercase tags + if (this.tags) { + this.tags = [...new Set(this.tags.map((t) => t.toLowerCase().trim()))]; + } + + // Sync denormalised warranty expiry date from JSONB + if (this.warrantyInfo?.expiryDate) { + this.warrantyExpiryDate = new Date(this.warrantyInfo.expiryDate); + } + + // Sync denormalised insurance expiry date from JSONB + if (this.insuranceInfo?.expiryDate) { + this.insuranceExpiryDate = new Date(this.insuranceInfo.expiryDate); + } + + // Sync denormalised maintenance due date from JSONB + if (this.maintenanceSchedule?.nextDueDate) { + this.nextMaintenanceDue = new Date(this.maintenanceSchedule.nextDueDate); + } + } + + @BeforeInsert() + @BeforeUpdate() + validateFinancials(): void { + if ( + this.purchaseValue != null && + this.residualValue != null && + Number(this.residualValue) > Number(this.purchaseValue) + ) { + throw new Error('residualValue cannot exceed purchaseValue'); + } + + if ( + this.currentValue != null && + this.purchaseValue != null && + Number(this.currentValue) > Number(this.purchaseValue) + ) { + throw new Error('currentValue cannot exceed purchaseValue'); + } + } +} \ No newline at end of file diff --git a/backend/src/opsce/departments/entities/department.entity.ts b/backend/src/opsce/departments/entities/department.entity.ts index 30bc174e..ec75977a 100644 --- a/backend/src/opsce/departments/entities/department.entity.ts +++ b/backend/src/opsce/departments/entities/department.entity.ts @@ -4,41 +4,246 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, OneToMany, + ManyToMany, JoinColumn, + JoinTable, + Index, + Check, + BeforeInsert, + BeforeUpdate, } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Maximum nesting depth enforced at the application layer. */ +export const MAX_DEPARTMENT_DEPTH = 5; + +/** Regex that department codes must satisfy. */ +export const DEPARTMENT_CODE_PATTERN = /^[A-Z0-9_-]{2,20}$/; + +// ─── Entity ─────────────────────────────────────────────────────────────────── @Entity('departments') +@Index('IDX_DEPT_PARENT_ACTIVE', ['parentId', 'isActive']) +@Index('IDX_DEPT_DELETED_AT', ['deletedAt']) +@Check(`"code" IS NULL OR LENGTH(TRIM("code")) > 0`) +@Check(`"name" <> ''`) export class Department { + + // ─── Identity ───────────────────────────────────────────────────────────── + @PrimaryGeneratedColumn('uuid') id: string; - @Column({ unique: true }) + /** + * Human-readable department name — unique across non-deleted departments. + * Uniqueness is enforced via a partial index rather than a column constraint + * so that soft-deleted names can be reused. + */ + @Index('IDX_DEPT_NAME_ACTIVE', { where: '"deletedAt" IS NULL' }) + @Column({ length: 150 }) name: string; - @Column({ nullable: true }) + @Column({ type: 'text', nullable: true }) description?: string; - @Column({ nullable: true }) + /** + * Short uppercase code used in HR systems (e.g. "ENG", "FIN-OPS"). + * Must match DEPARTMENT_CODE_PATTERN when provided. + */ + @Index('IDX_DEPT_CODE_ACTIVE', { where: '"deletedAt" IS NULL AND "code" IS NOT NULL' }) + @Column({ length: 20, nullable: true }) code?: string; @Column({ default: true }) isActive: boolean; - @Column({ nullable: true }) + // ─── Hierarchy ──────────────────────────────────────────────────────────── + + @Column({ type: 'uuid', nullable: true }) parentId?: string; - @ManyToOne(() => Department, (d) => d.children, { nullable: true }) + /** + * Materialized path for efficient ancestor/descendant queries. + * Format: //// + * Maintained automatically by lifecycle hooks. + */ + @Column({ type: 'text', nullable: true }) + path?: string; + + /** + * Nesting depth — 0 for root departments. + * Maintained automatically by lifecycle hooks. + */ + @Column({ type: 'int', default: 0 }) + depth: number; + + @ManyToOne(() => Department, (d) => d.children, { + nullable: true, + onDelete: 'SET NULL', + }) @JoinColumn({ name: 'parentId' }) parent?: Department; - @OneToMany(() => Department, (d) => d.parent) + @OneToMany(() => Department, (d) => d.parent, { cascade: ['soft-remove'] }) children: Department[]; - @CreateDateColumn() + // ─── Staffing ───────────────────────────────────────────────────────────── + + /** + * The designated head of this department. + * Nullable — a department may exist without an assigned head. + */ + @Column({ type: 'uuid', nullable: true }) + headId?: string; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL', eager: false }) + @JoinColumn({ name: 'headId' }) + head?: User; + + /** Members directly assigned to this department. */ + @ManyToMany(() => User, { cascade: false, eager: false }) + @JoinTable({ + name: 'department_members', + joinColumn: { name: 'departmentId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'userId', referencedColumnName: 'id' }, + }) + members: User[]; + + /** + * Cached headcount — updated by application logic or a DB trigger. + * Avoids COUNT(*) joins on hot read paths. + */ + @Column({ type: 'int', default: 0 }) + memberCount: number; + + // ─── Budget ─────────────────────────────────────────────────────────────── + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + budgetAmount?: number; + + /** ISO 4217 currency code, e.g. "USD", "NGN". */ + @Column({ length: 3, nullable: true }) + budgetCurrency?: string; + + /** Fiscal year the budget applies to (e.g. 2025). */ + @Column({ type: 'int', nullable: true }) + budgetYear?: number; + + // ─── Display / UX ───────────────────────────────────────────────────────── + + /** + * Hex colour used in org-chart UIs (e.g. "#3B82F6"). + * Validated in the DTO layer. + */ + @Column({ length: 7, nullable: true }) + color?: string; + + /** URL or icon key for org-chart and navigation usage. */ + @Column({ type: 'text', nullable: true }) + iconUrl?: string; + + /** + * Display order among siblings — lower = shown first. + * Defaults to 0; ties are broken by createdAt. + */ + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + // ─── Metadata ───────────────────────────────────────────────────────────── + + /** + * Arbitrary JSON key–value metadata for integrations + * (e.g. external HR system IDs, Slack channel IDs). + */ + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // ─── Audit ──────────────────────────────────────────────────────────────── + + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; - @UpdateDateColumn() + @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; -} + + /** + * Soft-delete timestamp — null means the record is active. + * TypeORM automatically excludes soft-deleted rows from all queries + * unless `.withDeleted()` is explicitly called. + */ + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) + deletedAt?: Date; + + /** ID of the user who created this department. */ + @Column({ type: 'uuid', nullable: true }) + createdBy?: string; + + /** ID of the user who last modified this department. */ + @Column({ type: 'uuid', nullable: true }) + updatedBy?: string; + + /** ID of the user who deleted this department (when soft-deleted). */ + @Column({ type: 'uuid', nullable: true }) + deletedBy?: string; + + // ─── Computed helpers ───────────────────────────────────────────────────── + + /** + * Returns true when the department has been soft-deleted. + * Keeps controllers and services free of null-check boilerplate. + */ + get isDeleted(): boolean { + return this.deletedAt !== null && this.deletedAt !== undefined; + } + + /** + * Returns true when this department is a root (no parent). + */ + get isRoot(): boolean { + return !this.parentId; + } + + /** + * Parses the materialized path into an ordered array of ancestor IDs, + * from root to immediate parent (excludes the department's own ID). + */ + get ancestorIds(): string[] { + if (!this.path) return []; + return this.path + .split('/') + .filter(Boolean) + .slice(0, -1); // last segment is this department's own id + } + + // ─── Lifecycle hooks ────────────────────────────────────────────────────── + + /** + * Validates the department code format before insert or update. + * Full schema-level validation belongs in a DTO; this is a last-resort guard. + */ + @BeforeInsert() + @BeforeUpdate() + validateCode(): void { + if (this.code && !DEPARTMENT_CODE_PATTERN.test(this.code)) { + throw new Error( + `Department code "${this.code}" is invalid. Must match ${DEPARTMENT_CODE_PATTERN.source}`, + ); + } + } + + /** + * Trims whitespace from name and description before persistence. + */ + @BeforeInsert() + @BeforeUpdate() + normalizeStrings(): void { + if (this.name) this.name = this.name.trim(); + if (this.description) this.description = this.description.trim(); + if (this.code) this.code = this.code.toUpperCase().trim(); + } +} \ No newline at end of file diff --git a/backend/src/opsce/locations/entities/location.entity.ts b/backend/src/opsce/locations/entities/location.entity.ts index 05402a52..81c518b5 100644 --- a/backend/src/opsce/locations/entities/location.entity.ts +++ b/backend/src/opsce/locations/entities/location.entity.ts @@ -4,51 +4,376 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, OneToMany, + ManyToMany, JoinColumn, + JoinTable, + Index, + Check, + BeforeInsert, + BeforeUpdate, } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const MAX_LOCATION_DEPTH = 6; + +export const LOCATION_CODE_PATTERN = /^[A-Z0-9_-]{1,30}$/; + +// ─── Enums ──────────────────────────────────────────────────────────────────── export enum LocationType { + CAMPUS = 'campus', BUILDING = 'building', - FLOOR = 'floor', - ROOM = 'room', - ZONE = 'zone', + FLOOR = 'floor', + WING = 'wing', + ROOM = 'room', + ZONE = 'zone', + DESK = 'desk', + OUTDOOR = 'outdoor', +} + +export enum LocationStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + UNDER_MAINTENANCE = 'under_maintenance', + RESERVED = 'reserved', + DECOMMISSIONED = 'decommissioned', +} + +export enum AccessLevel { + PUBLIC = 'public', + RESTRICTED = 'restricted', + PRIVATE = 'private', + SECURE = 'secure', +} + +// ─── Embedded value objects ─────────────────────────────────────────────────── + +export interface GeoCoordinates { + /** WGS-84 latitude (-90 to 90) */ + latitude: number; + /** WGS-84 longitude (-180 to 180) */ + longitude: number; + /** Altitude in metres above sea level (optional) */ + altitudeM?: number; +} + +export interface IndoorCoordinates { + /** X position in metres from the floor origin */ + x: number; + /** Y position in metres from the floor origin */ + y: number; + /** Floor number (0 = ground) */ + floor?: number; +} + +export interface OperatingHours { + /** ISO 8601 time, e.g. "08:00" */ + open: string; + /** ISO 8601 time, e.g. "18:00" */ + close: string; + /** Days this schedule applies to: 0=Sun … 6=Sat */ + days: number[]; + /** IANA timezone, e.g. "Africa/Lagos" */ + timezone: string; } +export interface LocationDimensions { + /** Width in metres */ + widthM?: number; + /** Length in metres */ + lengthM?: number; + /** Height in metres */ + heightM?: number; + /** Total area in square metres (may be set independently of width/length) */ + areaM2?: number; +} + +// ─── Entity ─────────────────────────────────────────────────────────────────── + @Entity('locations') +@Index('IDX_LOC_PARENT_STATUS', ['parentId', 'status']) +@Index('IDX_LOC_TYPE_ACTIVE', ['type', 'isActive']) +@Index('IDX_LOC_DELETED_AT', ['deletedAt']) +@Check(`"name" <> ''`) +@Check(`"capacity" IS NULL OR "capacity" >= 0`) +@Check(`"currentOccupancy" IS NULL OR "currentOccupancy" >= 0`) +@Check( + `"currentOccupancy" IS NULL OR "capacity" IS NULL OR "currentOccupancy" <= "capacity"`, +) export class Location { + + // ─── Identity ─────────────────────────────────────────────────────────────── + @PrimaryGeneratedColumn('uuid') id: string; - @Column() + /** + * Human-readable name — unique among non-deleted siblings of the same parent. + * Full uniqueness is enforced by a partial index. + */ + @Index('IDX_LOC_NAME_ACTIVE', { where: '"deletedAt" IS NULL' }) + @Column({ length: 200 }) name: string; + /** + * Short, uppercase location code for signage / integrations. + * e.g. "B3-F2-R14". Must match LOCATION_CODE_PATTERN. + */ + @Index('IDX_LOC_CODE_ACTIVE', { where: '"deletedAt" IS NULL AND "code" IS NOT NULL' }) + @Column({ length: 30, nullable: true }) + code?: string; + @Column({ type: 'enum', enum: LocationType }) type: LocationType; - @Column({ nullable: true }) - address?: string; + @Column({ + type: 'enum', + enum: LocationStatus, + default: LocationStatus.ACTIVE, + }) + status: LocationStatus; - @Column({ type: 'jsonb', nullable: true }) - coordinates?: Record; + @Column({ + type: 'enum', + enum: AccessLevel, + default: AccessLevel.PUBLIC, + }) + accessLevel: AccessLevel; - @Column({ default: true }) - isActive: boolean; + @Column({ type: 'text', nullable: true }) + description?: string; - @Column({ nullable: true }) + // ─── Hierarchy / materialized path ───────────────────────────────────────── + + @Column({ type: 'uuid', nullable: true }) parentId?: string; - @ManyToOne(() => Location, (l) => l.children, { nullable: true }) + /** + * Materialized path for O(1) ancestor queries and O(depth) subtree queries. + * Format: //…/// + */ + @Index('IDX_LOC_PATH') + @Column({ type: 'text', nullable: true }) + path?: string; + + /** Nesting depth — 0 for root locations (campus / standalone building). */ + @Column({ type: 'int', default: 0 }) + depth: number; + + @ManyToOne(() => Location, (l) => l.children, { + nullable: true, + onDelete: 'SET NULL', + }) @JoinColumn({ name: 'parentId' }) parent?: Location; - @OneToMany(() => Location, (l) => l.parent) + @OneToMany(() => Location, (l) => l.parent, { cascade: ['soft-remove'] }) children: Location[]; - @CreateDateColumn() + // ─── Physical attributes ──────────────────────────────────────────────────── + + /** Physical mailing / street address (buildings / campus level). */ + @Column({ type: 'text', nullable: true }) + address?: string; + + /** Floor number within a building (relevant for FLOOR / ROOM / ZONE / DESK). */ + @Column({ type: 'int', nullable: true }) + floorNumber?: number; + + /** Room / suite number as a string to accommodate "3A", "B-12", etc. */ + @Column({ length: 20, nullable: true }) + roomNumber?: string; + + /** Maximum number of people allowed in this location simultaneously. */ + @Column({ type: 'int', nullable: true }) + capacity?: number; + + /** Live occupancy count — updated by an IoT or booking service. */ + @Column({ type: 'int', nullable: true, default: 0 }) + currentOccupancy?: number; + + /** Physical dimensions stored as a JSONB object. */ + @Column({ type: 'jsonb', nullable: true }) + dimensions?: LocationDimensions; + + // ─── Coordinates ──────────────────────────────────────────────────────────── + + /** + * WGS-84 geographic coordinates for outdoor / campus-level locations. + * Stored as JSONB; migrate to PostGIS `geography` type for spatial queries. + */ + @Column({ type: 'jsonb', nullable: true }) + geoCoordinates?: GeoCoordinates; + + /** + * Indoor positioning coordinates (e.g. from a BLE / UWB system). + */ + @Column({ type: 'jsonb', nullable: true }) + indoorCoordinates?: IndoorCoordinates; + + // ─── Operations ───────────────────────────────────────────────────────────── + + /** Weekly operating schedule. Multiple entries support split shifts. */ + @Column({ type: 'jsonb', nullable: true }) + operatingHours?: OperatingHours[]; + + /** + * Tags for flexible filtering (e.g. ["wheelchair-accessible", "projector"]). + * Stored as a simple text array. + */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + tags: string[]; + + /** + * Amenities available at this location (e.g. ["wifi", "whiteboard", "parking"]). + */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + amenities: string[]; + + // ─── Managed-by ───────────────────────────────────────────────────────────── + + /** + * Users responsible for managing this location (facility managers, admins). + */ + @ManyToMany(() => User, { cascade: false, eager: false }) + @JoinTable({ + name: 'location_managers', + joinColumn: { name: 'locationId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'userId', referencedColumnName: 'id' }, + }) + managers: User[]; + + // ─── Media ────────────────────────────────────────────────────────────────── + + /** URL to the floor-plan image / SVG for this location. */ + @Column({ type: 'text', nullable: true }) + floorPlanUrl?: string; + + /** URLs to photos of this location. */ + @Column({ type: 'text', array: true, nullable: true, default: [] }) + photoUrls: string[]; + + // ─── Metadata ─────────────────────────────────────────────────────────────── + + /** + * Arbitrary JSON for third-party integrations + * (e.g. { "calendarRoomId": "…", "accessControlId": "…" }). + */ + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // ─── Legacy compatibility ─────────────────────────────────────────────────── + + /** + * Kept for backwards compatibility — prefer `status` for new code. + * Synced with status in the BeforeInsert / BeforeUpdate hook. + */ + @Column({ default: true }) + isActive: boolean; + + // ─── Audit ────────────────────────────────────────────────────────────────── + + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; - @UpdateDateColumn() + @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; -} + + /** + * Soft-delete timestamp. + * TypeORM excludes soft-deleted rows from all queries unless + * `.withDeleted()` is explicitly used. + */ + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) + deletedAt?: Date; + + @Column({ type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ type: 'uuid', nullable: true }) + updatedBy?: string; + + @Column({ type: 'uuid', nullable: true }) + deletedBy?: string; + + // ─── Computed helpers ──────────────────────────────────────────────────────── + + get isDeleted(): boolean { + return !!this.deletedAt; + } + + get isRoot(): boolean { + return !this.parentId; + } + + /** + * Occupancy as a percentage (0–100), or null if capacity is unset. + */ + get occupancyPct(): number | null { + if (this.capacity == null || this.capacity === 0) return null; + return Math.min(100, Math.round(((this.currentOccupancy ?? 0) / this.capacity) * 100)); + } + + /** + * True when currentOccupancy >= capacity (and both are set). + */ + get isAtCapacity(): boolean { + if (this.capacity == null) return false; + return (this.currentOccupancy ?? 0) >= this.capacity; + } + + /** + * Ordered array of ancestor IDs from root to immediate parent. + */ + get ancestorIds(): string[] { + if (!this.path) return []; + return this.path.split('/').filter(Boolean).slice(0, -1); + } + + // ─── Lifecycle hooks ───────────────────────────────────────────────────────── + + @BeforeInsert() + @BeforeUpdate() + normalizeFields(): void { + // Trim strings + if (this.name) this.name = this.name.trim(); + if (this.description) this.description = this.description.trim(); + if (this.address) this.address = this.address.trim(); + + // Uppercase + trim code + if (this.code) { + this.code = this.code.toUpperCase().trim(); + if (!LOCATION_CODE_PATTERN.test(this.code)) { + throw new Error( + `Location code "${this.code}" is invalid. Must match ${LOCATION_CODE_PATTERN.source}`, + ); + } + } + + // Sync legacy isActive with status + this.isActive = this.status === LocationStatus.ACTIVE; + + // Deduplicate tags and amenities + if (this.tags) this.tags = [...new Set(this.tags.map((t) => t.toLowerCase().trim()))]; + if (this.amenities) this.amenities = [...new Set(this.amenities.map((a) => a.toLowerCase().trim()))]; + } + + @BeforeInsert() + @BeforeUpdate() + validateCoordinates(): void { + if (this.geoCoordinates) { + const { latitude: lat, longitude: lng } = this.geoCoordinates; + if (lat < -90 || lat > 90) { + throw new Error(`Invalid latitude ${lat}: must be between -90 and 90`); + } + if (lng < -180 || lng > 180) { + throw new Error(`Invalid longitude ${lng}: must be between -180 and 180`); + } + } + } +} \ No newline at end of file