/** * DiscoveredPage Domain Entity * * Represents a single crawled page in the knowledge base. * Encapsulates page discovery, crawling status, and analysis tracking. * * Domain invariants enforced: * - URL must be valid and normalized * - Depth must be non-negative * - Status transitions follow business rules * * @layer Domain */ export { DiscoverySource } from '@archer/domain'; export { PageStatus } from '@archer/domain'; import { DiscoverySource } from '@archer/domain'; import { PageStatus } from '@archer/domain'; /** * Analysis Status Enum * * Status of page element analysis */ export enum AnalysisStatus { NONE = 'NONE', // Not yet analyzed PENDING = 'PENDING', // Queued for analysis IN_PROGRESS = 'IN_PROGRESS', // Currently analyzing ANALYZED = 'ANALYZED', // Successfully analyzed OUTDATED = 'OUTDATED', // Analysis is outdated FAILED = 'FAILED', // Analysis failed } /** * Element Type Enum * * Types of interactive elements discovered on pages */ export enum ElementType { BUTTON = 'BUTTON', INPUT = 'INPUT', LINK = 'LINK', SELECT = 'SELECT', TEXTAREA = 'TEXTAREA', CHECKBOX = 'CHECKBOX', RADIO = 'RADIO', FORM = 'FORM', IMAGE = 'IMAGE', VIDEO = 'VIDEO', CUSTOM = 'CUSTOM', } /** * Selector Type Enum * * Types of selectors used for element identification */ export enum SelectorType { ARIA = 'ARIA', // Accessibility selector (highest priority) TEXT = 'TEXT', // Text content selector CSS = 'CSS', // CSS selector (class/id) XPATH = 'XPATH', // XPath selector POSITION = 'POSITION', // Position-based selector (lowest priority) } interface DiscoveredPageProps { id: string; tenantId: string; url: string; normalizedUrl: string; title?: string; discoverySource: DiscoverySource; discoveredAt: Date; depth: number; status: PageStatus; analysisStatus: AnalysisStatus; pageType?: string; semanticLabel?: string; parentPageId?: string; httpStatus?: number; lastAnalyzedAt?: Date; elementCount?: number; addedToTraining?: boolean; } /** * DiscoveredPage Entity * * Encapsulates discovered page business logic and crawl status. * Use factory methods (create, fromPersistence) to construct instances. */ export class DiscoveredPage { private constructor(private readonly props: DiscoveredPageProps) { this.validate(); } /** * Creates a new DiscoveredPage instance * * @param data - Discovered page creation data * @returns DiscoveredPage domain entity * @throws Error if validation fails */ static create(data: { id: string; tenantId: string; url: string; normalizedUrl: string; title?: string; discoverySource: DiscoverySource; depth: number; status?: PageStatus; pageType?: string; semanticLabel?: string; parentPageId?: string; addedToTraining?: boolean; }): DiscoveredPage { const now = new Date(); return new DiscoveredPage({ id: data.id, tenantId: data.tenantId, url: data.url, normalizedUrl: data.normalizedUrl, title: data.title, discoverySource: data.discoverySource, discoveredAt: now, depth: data.depth, status: data.status ?? PageStatus.ACTIVE, analysisStatus: AnalysisStatus.NONE, pageType: data.pageType, semanticLabel: data.semanticLabel, parentPageId: data.parentPageId, elementCount: 0, addedToTraining: data.addedToTraining ?? false, }); } /** * Reconstructs DiscoveredPage from persistence layer * * @param data - Persisted discovered page data * @returns DiscoveredPage domain entity */ static fromPersistence(data: { id: string; tenantId: string; url: string; normalizedUrl: string; title?: string; discoverySource: DiscoverySource; discoveredAt: string | Date; depth: number; status: PageStatus; analysisStatus: AnalysisStatus; pageType?: string; semanticLabel?: string; parentPageId?: string; httpStatus?: number; lastAnalyzedAt?: string | Date; elementCount?: number; addedToTraining?: boolean; }): DiscoveredPage { return new DiscoveredPage({ id: data.id, tenantId: data.tenantId, url: data.url, normalizedUrl: data.normalizedUrl, title: data.title, discoverySource: data.discoverySource, discoveredAt: typeof data.discoveredAt === 'string' ? new Date(data.discoveredAt) : data.discoveredAt, depth: data.depth, status: data.status, analysisStatus: data.analysisStatus, pageType: data.pageType, semanticLabel: data.semanticLabel, parentPageId: data.parentPageId, httpStatus: data.httpStatus, lastAnalyzedAt: data.lastAnalyzedAt ? typeof data.lastAnalyzedAt === 'string' ? new Date(data.lastAnalyzedAt) : data.lastAnalyzedAt : undefined, elementCount: data.elementCount ?? 0, addedToTraining: data.addedToTraining ?? false, }); } /** * Validates domain invariants * * @throws Error if validation fails */ private validate(): void { if (!this.props.tenantId) { throw new Error('Discovered page must belong to a tenant'); } if (!this.props.url || this.props.url.trim().length === 0) { throw new Error('Page URL cannot be empty'); } if (!this.props.normalizedUrl || this.props.normalizedUrl.trim().length === 0) { throw new Error('Normalized URL cannot be empty'); } if (this.props.depth < 0) { throw new Error('Page depth cannot be negative'); } } // Getters get id(): string { return this.props.id; } get tenantId(): string { return this.props.tenantId; } get url(): string { return this.props.url; } get normalizedUrl(): string { return this.props.normalizedUrl; } get title(): string | undefined { return this.props.title; } get discoverySource(): DiscoverySource { return this.props.discoverySource; } get discoveredAt(): Date { return this.props.discoveredAt; } get depth(): number { return this.props.depth; } get status(): PageStatus { return this.props.status; } get analysisStatus(): AnalysisStatus { return this.props.analysisStatus; } get pageType(): string | undefined { return this.props.pageType; } get semanticLabel(): string | undefined { return this.props.semanticLabel; } get parentPageId(): string | undefined { return this.props.parentPageId; } get httpStatus(): number | undefined { return this.props.httpStatus; } get lastAnalyzedAt(): Date | undefined { return this.props.lastAnalyzedAt; } get elementCount(): number { return this.props.elementCount ?? 0; } get addedToTraining(): boolean { return this.props.addedToTraining ?? false; } /** * Checks if page is active * * @returns true if page status is ACTIVE */ isActive(): boolean { return this.props.status === PageStatus.ACTIVE; } /** * Checks if page is broken * * @returns true if page status is BROKEN */ isBroken(): boolean { return this.props.status === PageStatus.BROKEN; } /** * Checks if page is excluded from crawling * * @returns true if page status is EXCLUDED */ isExcluded(): boolean { return this.props.status === PageStatus.EXCLUDED; } /** * Checks if page is analyzed * * @returns true if analysis status is ANALYZED */ isAnalyzed(): boolean { return this.props.analysisStatus === AnalysisStatus.ANALYZED; } /** * Checks if page analysis is pending * * @returns true if analysis status is PENDING or IN_PROGRESS */ isAnalysisPending(): boolean { return ( this.props.analysisStatus === AnalysisStatus.PENDING || this.props.analysisStatus === AnalysisStatus.IN_PROGRESS ); } /** * Checks if page analysis is outdated * * @returns true if analysis status is OUTDATED */ isAnalysisOutdated(): boolean { return this.props.analysisStatus === AnalysisStatus.OUTDATED; } /** * Checks if page needs analysis * * @returns true if page has not been analyzed or analysis is outdated */ needsAnalysis(): boolean { return ( this.props.analysisStatus === AnalysisStatus.NONE || this.props.analysisStatus === AnalysisStatus.OUTDATED || this.props.analysisStatus === AnalysisStatus.FAILED ); } /** * Gets display title for UI * * @returns Page title or URL if title is not available */ getDisplayTitle(): string { return this.props.title || this.props.url; } /** * Marks page as analyzed * * @param elementCount - Number of elements discovered * @returns New DiscoveredPage instance with ANALYZED status */ markAsAnalyzed(elementCount: number): DiscoveredPage { return new DiscoveredPage({ ...this.props, analysisStatus: AnalysisStatus.ANALYZED, lastAnalyzedAt: new Date(), elementCount, }); } /** * Marks page analysis as failed * * @returns New DiscoveredPage instance with FAILED analysis status */ markAnalysisFailed(): DiscoveredPage { return new DiscoveredPage({ ...this.props, analysisStatus: AnalysisStatus.FAILED, lastAnalyzedAt: new Date(), }); } /** * Marks page analysis as outdated * * @returns New DiscoveredPage instance with OUTDATED analysis status */ markAnalysisOutdated(): DiscoveredPage { return new DiscoveredPage({ ...this.props, analysisStatus: AnalysisStatus.OUTDATED, }); } /** * Updates page properties * * @param updates - Properties to update * @returns New DiscoveredPage instance with updates */ update(updates: { title?: string; status?: PageStatus; analysisStatus?: AnalysisStatus; pageType?: string; semanticLabel?: string; httpStatus?: number; elementCount?: number; }): DiscoveredPage { return new DiscoveredPage({ ...this.props, ...updates, }); } /** * Converts entity to persistence format * * @returns Plain object for storage */ toPersistence(): { id: string; tenantId: string; url: string; normalizedUrl: string; title?: string; discoverySource: DiscoverySource; discoveredAt: string; depth: number; status: PageStatus; analysisStatus: AnalysisStatus; pageType?: string; semanticLabel?: string; parentPageId?: string; httpStatus?: number; lastAnalyzedAt?: string; elementCount?: number; addedToTraining?: boolean; } { return { id: this.props.id, tenantId: this.props.tenantId, url: this.props.url, normalizedUrl: this.props.normalizedUrl, title: this.props.title, discoverySource: this.props.discoverySource, discoveredAt: this.props.discoveredAt.toISOString(), depth: this.props.depth, status: this.props.status, analysisStatus: this.props.analysisStatus, pageType: this.props.pageType, semanticLabel: this.props.semanticLabel, parentPageId: this.props.parentPageId, httpStatus: this.props.httpStatus, lastAnalyzedAt: this.props.lastAnalyzedAt?.toISOString(), elementCount: this.props.elementCount, addedToTraining: this.props.addedToTraining, }; } }