You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
296 lines
8.7 KiB
TypeScript
296 lines
8.7 KiB
TypeScript
|
4 hours ago
|
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<Release, 'branch' | 'version' | 'build' | 'date'> & { 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<Release>,
|
||
|
|
private readonly projectsService: ProjectsService,
|
||
|
|
private readonly emailService: EmailService,
|
||
|
|
private readonly usersService: UsersService,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
async getReleasesForUser(userId: string): Promise<ReleasesResponse> {
|
||
|
|
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<ReleasesResponse>((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<ReleasesResponse> {
|
||
|
|
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<ReleasesResponse> {
|
||
|
|
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<string, unknown> {
|
||
|
|
const metadata: Record<string, unknown> = {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|