import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { normalizeKey } from '../shared/normalize-key'; import { Release } from './release.entity'; import { UpsertReleaseDto } from './dto/upsert-release.dto'; import { ReleasePayload, ReleasesResponse } from './releases.types'; import { ProjectsService } from '../projects/projects.service'; import { EmailService } from '../email/email.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/user.entity'; import { formatReleaseDate } from 'src/utils/date.utils'; const RELEASE_CHANGE_FIELDS = ['branch', 'version', 'build', 'date', 'commitMessage'] as const; type ReleaseChangeField = (typeof RELEASE_CHANGE_FIELDS)[number]; type ReleaseSnapshot = Pick & { commitMessage: string | null }; type ReleaseChangeValue = ReleaseSnapshot[ReleaseChangeField]; interface ReleaseChange { field: ReleaseChangeField; previous: ReleaseChangeValue; current: ReleaseChangeValue; } @Injectable() export class ReleasesService { constructor( @InjectRepository(Release) private readonly releasesRepository: Repository, private readonly projectsService: ProjectsService, private readonly emailService: EmailService, private readonly usersService: UsersService, ) {} async getReleasesForUser(userId: string): Promise { const projectIds = await this.projectsService.getAccessibleProjectIds(userId); if (projectIds.length === 0) { return {}; } const releases = await this.releasesRepository.find({ where: { projectId: In(projectIds) }, order: { client: 'ASC', environment: 'ASC', }, }); return releases.reduce((acc, release) => { if (!acc[release.client]) { acc[release.client] = {}; } acc[release.client][release.environment] = this.buildPayload(release); return acc; }, {}); } async upsertRelease(userId: string, dto: UpsertReleaseDto): Promise { const client = normalizeKey(dto.client); const environment = normalizeKey(dto.environment); const project = await this.projectsService.ensureProjectForUser(userId, dto.client); const normalizedCommitMessage = this.normalizeCommitMessage(dto.commitMessage); const commitMessageProvided = dto.commitMessage !== undefined; let release = await this.releasesRepository.findOne({ where: { projectId: project.id, environment }, }); const previousSnapshot = release ? this.captureReleaseSnapshot(release) : null; const isNewRelease = !release; if (!release) { release = this.releasesRepository.create({ projectId: project.id, client, environment, branch: dto.branch, version: dto.version, build: dto.build, date: dto.date, commitMessage: commitMessageProvided ? normalizedCommitMessage : null, }); } else { release.branch = dto.branch; release.version = dto.version; release.build = dto.build; release.date = dto.date; release.client = client; if (commitMessageProvided) { release.commitMessage = normalizedCommitMessage; } } await this.releasesRepository.save(release); const currentSnapshot = this.captureReleaseSnapshot(release); const changes = this.buildReleaseChanges(previousSnapshot, currentSnapshot); const metadata = this.buildReleaseActivityMetadata({ environment, release, changes, isNewRelease, }); await this.projectsService.recordActivity(project.id, userId, 'release_upserted', metadata); await this.notifyCollaboratorsOfReleaseUpdate({ project, release, actorId: userId, isNewRelease, }); return this.getReleasesForUser(userId); } async deleteRelease( userId: string, clientParam: string, envParam: string, ): Promise { const client = normalizeKey(clientParam); const environment = normalizeKey(envParam); const project = await this.projectsService.findAccessibleProjectBySlug(userId, client); if (!project) { throw new ForbiddenException('You do not have access to this project'); } const release = await this.releasesRepository.findOne({ where: { projectId: project.id, environment }, }); if (!release) { throw new NotFoundException('Release not found'); } const metadata = { environment, releaseId: release.id, branch: release.branch, version: release.version, build: release.build, date: release.date, commitMessage: release.commitMessage, }; await this.releasesRepository.remove(release); await this.projectsService.recordActivity(project.id, userId, 'release_deleted', metadata); return this.getReleasesForUser(userId); } private buildPayload(release: Release): ReleasePayload { return { branch: release.branch, version: release.version, build: release.build, date: release.date, commitMessage: release.commitMessage ?? null, }; } private normalizeCommitMessage(value?: string | null): string | null { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } private async notifyCollaboratorsOfReleaseUpdate(options: { project: { id: string; name: string; slug: string }; release: Release; actorId: string; isNewRelease: boolean; }) { const collaborators = await this.projectsService.getProjectCollaborators(options.project.id); if (!collaborators.length) { return; } const recipients = collaborators.filter((user) => user.id !== options.actorId); if (!recipients.length) { return; } const actor = collaborators.find((user) => user.id === options.actorId) ?? (await this.usersService.findById(options.actorId)); const actorName = this.buildUserDisplayName(actor); const actorEmail = actor?.email ?? 'unknown'; const releaseDate = formatReleaseDate(options.release.date) ?? ''; await Promise.all( recipients.map((recipient) => this.emailService.sendReleaseUpdateNotification(recipient.email, { projectName: options.project.name, projectSlug: options.project.slug, environment: options.release.environment, version: options.release.version, branch: options.release.branch, build: options.release.build, date: releaseDate, commitMessage: options.release.commitMessage ?? null, actorName, actorEmail, isNewRelease: options.isNewRelease, }), ), ); } private buildUserDisplayName(user: User | null | undefined) { if (!user) { return 'A collaborator'; } if (user.displayName) { return user.displayName; } const nameParts = [user.firstName, user.lastName].filter((part): part is string => Boolean(part && part.trim()), ); if (nameParts.length) { return nameParts.join(' '); } return user.email; } private captureReleaseSnapshot(release: Release): ReleaseSnapshot { return { branch: release.branch, version: release.version, build: release.build, date: release.date, commitMessage: release.commitMessage ?? null, }; } private buildReleaseChanges(previous: ReleaseSnapshot | null, current: ReleaseSnapshot): ReleaseChange[] { if (!previous) { return []; } return RELEASE_CHANGE_FIELDS.map((field) => { const before = previous[field]; const after = current[field]; if (before === after) { return null; } return { field, previous: before ?? null, current: after ?? null, }; }).filter((change): change is ReleaseChange => Boolean(change)); } private buildReleaseActivityMetadata(options: { environment: string; release: Release; changes: ReleaseChange[]; isNewRelease: boolean; }): Record { const metadata: Record = { client: options.release.client, environment: options.environment, releaseId: options.release.id, branch: options.release.branch, version: options.release.version, build: options.release.build, date: options.release.date, commitMessage: options.release.commitMessage ?? null, isNewRelease: options.isNewRelease, }; if (options.changes.length > 0) { metadata.changes = options.changes; } return metadata; } }