/**
* @fileoverview Project aggregate root
*
* This module defines the Project aggregate which represents a DeepSource project
* with its repository information and configuration settings.
*/
import { AggregateRoot } from '../../shared/aggregate-root.js';
import { ProjectKey } from '../../../types/branded.js';
import {
ProjectRepository,
ProjectConfiguration,
ProjectStatus,
CreateProjectParams,
UpdateProjectParams,
} from './project.types.js';
/**
* Project aggregate root
*
* Represents a DeepSource project with its repository and configuration.
* Enforces business rules and maintains consistency within the aggregate boundary.
*
* @example
* ```typescript
* const project = Project.create({
* key: asProjectKey('my-project'),
* name: 'My Project',
* repository: {
* url: 'https://github.com/user/repo',
* provider: 'GITHUB',
* login: 'user',
* isPrivate: false
* }
* });
*
* project.activate();
* project.updateConfiguration({ autoFix: true });
* ```
*/
export class Project extends AggregateRoot<ProjectKey> {
private _name: string;
private _repository: ProjectRepository;
private _configuration: ProjectConfiguration;
private _status: ProjectStatus;
private _createdAt: Date;
private _updatedAt: Date;
private constructor(
id: ProjectKey,
name: string,
repository: ProjectRepository,
configuration: ProjectConfiguration,
status: ProjectStatus,
createdAt: Date,
updatedAt: Date
) {
super(id);
this._name = name;
this._repository = repository;
this._configuration = configuration;
this._status = status;
this._createdAt = createdAt;
this._updatedAt = updatedAt;
}
/**
* Creates a new Project aggregate
*
* @param params - Project creation parameters
* @returns A new Project instance
* @throws Error if validation fails
*/
static create(params: CreateProjectParams): Project {
const { key, name, repository, configuration } = params;
// Validate name
if (!name || name.trim().length === 0) {
throw new Error('Project name cannot be empty');
}
// Validate repository URL
if (!repository.url || !Project.isValidUrl(repository.url)) {
throw new Error('Invalid repository URL');
}
// Create default configuration
const defaultConfig: ProjectConfiguration = {
isActivated: false,
autoFix: false,
pullRequestIntegration: true,
issueReporting: true,
};
const now = new Date();
const project = new Project(
key,
name.trim(),
repository,
{ ...defaultConfig, ...configuration },
'INACTIVE',
now,
now
);
// Add creation event
project.addDomainEvent({
aggregateId: key,
eventType: 'ProjectCreated',
occurredAt: now,
payload: {
name,
repositoryUrl: repository.url,
provider: repository.provider,
},
});
return project;
}
/**
* Validates if a string is a valid URL
*/
private static isValidUrl(url: string): boolean {
try {
// URL constructor is available in Node.js
// skipcq: JS-R1002 - URL constructor is used for validation, object is intentionally not used
new globalThis.URL(url);
return true;
} catch {
return false;
}
}
/**
* Gets the project key
*/
get key(): ProjectKey {
return this._id;
}
/**
* Gets the project name
*/
get name(): string {
return this._name;
}
/**
* Gets the repository information
*/
get repository(): Readonly<ProjectRepository> {
return { ...this._repository };
}
/**
* Gets the configuration settings
*/
get configuration(): Readonly<ProjectConfiguration> {
return { ...this._configuration };
}
/**
* Gets the project status
*/
get status(): ProjectStatus {
return this._status;
}
/**
* Gets the creation timestamp
*/
get createdAt(): Date {
return this._createdAt;
}
/**
* Gets the last update timestamp
*/
get updatedAt(): Date {
return this._updatedAt;
}
/**
* Checks if the project is active
*/
get isActive(): boolean {
return this._status === 'ACTIVE' && this._configuration.isActivated;
}
/**
* Activates the project
*
* @throws Error if the project is archived
*/
activate(): void {
if (this._status === 'ARCHIVED') {
throw new Error('Cannot activate an archived project');
}
if (this._status === 'ACTIVE' && this._configuration.isActivated) {
return; // Already active
}
this._status = 'ACTIVE';
this._configuration.isActivated = true;
this._updatedAt = new Date();
this.addDomainEvent({
aggregateId: this._id,
eventType: 'ProjectActivated',
occurredAt: this._updatedAt,
payload: {},
});
this.markAsModified();
}
/**
* Deactivates the project
*/
deactivate(): void {
if (this._status === 'INACTIVE' && !this._configuration.isActivated) {
return; // Already inactive
}
this._status = 'INACTIVE';
this._configuration.isActivated = false;
this._updatedAt = new Date();
this.addDomainEvent({
aggregateId: this._id,
eventType: 'ProjectDeactivated',
occurredAt: this._updatedAt,
payload: {},
});
this.markAsModified();
}
/**
* Archives the project
*
* Archived projects cannot be reactivated and are kept for historical purposes.
*/
archive(): void {
if (this._status === 'ARCHIVED') {
return; // Already archived
}
this._status = 'ARCHIVED';
this._configuration.isActivated = false;
this._updatedAt = new Date();
this.addDomainEvent({
aggregateId: this._id,
eventType: 'ProjectArchived',
occurredAt: this._updatedAt,
payload: {},
});
this.markAsModified();
}
/**
* Updates the project information
*
* @param params - Update parameters
* @throws Error if the project is archived
*/
update(params: UpdateProjectParams): void {
if (this._status === 'ARCHIVED') {
throw new Error('Cannot update an archived project');
}
let hasChanges = false;
// Update name if provided
if (params.name !== undefined) {
const trimmedName = params.name.trim();
if (trimmedName.length === 0) {
throw new Error('Project name cannot be empty');
}
if (trimmedName !== this._name) {
this._name = trimmedName;
hasChanges = true;
}
}
// Update repository if provided
if (params.repository) {
if (params.repository.url !== undefined) {
if (!Project.isValidUrl(params.repository.url)) {
throw new Error('Invalid repository URL');
}
}
this._repository = { ...this._repository, ...params.repository };
hasChanges = true;
}
// Update configuration if provided
if (params.configuration) {
this._configuration = { ...this._configuration, ...params.configuration };
hasChanges = true;
}
if (hasChanges) {
this._updatedAt = new Date();
this.addDomainEvent({
aggregateId: this._id,
eventType: 'ProjectUpdated',
occurredAt: this._updatedAt,
payload: params as Record<string, unknown>,
});
this.markAsModified();
}
}
/**
* Updates the project configuration
*
* @param configuration - Partial configuration to update
*/
updateConfiguration(configuration: Partial<ProjectConfiguration>): void {
this.update({ configuration });
}
/**
* Checks if the project can run analysis
*
* @returns True if analysis can be run, false otherwise
*/
canRunAnalysis(): boolean {
return (
this._status === 'ACTIVE' &&
this._configuration.isActivated &&
this._repository.url.length > 0
);
}
/**
* Reconstructs a Project from persisted data
*
* @param data - Persisted project data
* @returns A reconstructed Project instance
*/
static fromPersistence(data: {
key: ProjectKey;
name: string;
repository: ProjectRepository;
configuration: ProjectConfiguration;
status: ProjectStatus;
createdAt: Date;
updatedAt: Date;
}): Project {
return new Project(
data.key,
data.name,
data.repository,
data.configuration,
data.status,
data.createdAt,
data.updatedAt
);
}
/**
* Converts the project to a persistence-friendly format
*/
toPersistence(): {
key: ProjectKey;
name: string;
repository: ProjectRepository;
configuration: ProjectConfiguration;
status: ProjectStatus;
createdAt: Date;
updatedAt: Date;
} {
return {
key: this._id,
name: this._name,
repository: { ...this._repository },
configuration: { ...this._configuration },
status: this._status,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
};
}
}