Show domains for change to production and redeploy in deployments page (#56)

* Display URL for change to production dialog box

* Refactor database method for domains to service class

* Handle error in resolver instead of service class

* Return entity from service class for add operation

* Do not fetch branches if repo not available

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-02-06 14:18:06 +05:30 committed by GitHub
parent 7e522aa45d
commit afd522654c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 382 additions and 335 deletions

View File

@ -1,4 +1,4 @@
import { DataSource, DeepPartial, FindManyOptions, FindOneOptions } from 'typeorm'; import { DataSource, DeepPartial, FindManyOptions, FindOneOptions, FindOptionsWhere } from 'typeorm';
import path from 'path'; import path from 'path';
import debug from 'debug'; import debug from 'debug';
import assert from 'assert'; import assert from 'assert';
@ -41,7 +41,7 @@ export class Database {
return user; return user;
} }
async createUser (data: DeepPartial<User>): Promise<User> { async addUser (data: DeepPartial<User>): Promise<User> {
const userRepository = this.dataSource.getRepository(User); const userRepository = this.dataSource.getRepository(User);
const user = await userRepository.save(data); const user = await userRepository.save(data);
@ -161,7 +161,7 @@ export class Database {
return domains; return domains;
} }
async createDeployement (data: DeepPartial<Deployment>): Promise<Deployment> { async addDeployement (data: DeepPartial<Deployment>): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment); const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.save(data); const deployment = await deploymentRepository.save(data);
@ -216,11 +216,7 @@ export class Database {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember); const projectMemberRepository = this.dataSource.getRepository(ProjectMember);
const updateResult = await projectMemberRepository.update({ id: Number(projectMemberId) }, data); const updateResult = await projectMemberRepository.update({ id: Number(projectMemberId) }, data);
if (updateResult.affected) { return Boolean(updateResult.affected);
return updateResult.affected > 0;
} else {
return false;
}
} }
async addProjectMember (data: DeepPartial<ProjectMember>): Promise<ProjectMember> { async addProjectMember (data: DeepPartial<ProjectMember>): Promise<ProjectMember> {
@ -241,11 +237,7 @@ export class Database {
const environmentVariableRepository = this.dataSource.getRepository(EnvironmentVariable); const environmentVariableRepository = this.dataSource.getRepository(EnvironmentVariable);
const updateResult = await environmentVariableRepository.update({ id: Number(environmentVariableId) }, update); const updateResult = await environmentVariableRepository.update({ id: Number(environmentVariableId) }, update);
if (updateResult.affected) { return Boolean(updateResult.affected);
return updateResult.affected > 0;
} else {
return false;
}
} }
async deleteEnvironmentVariable (environmentVariableId: string): Promise<boolean> { async deleteEnvironmentVariable (environmentVariableId: string): Promise<boolean> {
@ -302,11 +294,7 @@ export class Database {
const deploymentRepository = this.dataSource.getRepository(Deployment); const deploymentRepository = this.dataSource.getRepository(Deployment);
const updateResult = await deploymentRepository.update({ id: deploymentId }, updates); const updateResult = await deploymentRepository.update({ id: deploymentId }, updates);
if (updateResult.affected) { return Boolean(updateResult.affected);
return updateResult.affected > 0;
} else {
return false;
}
} }
async addProject (userId: string, projectDetails: DeepPartial<Project>): Promise<Project> { async addProject (userId: string, projectDetails: DeepPartial<Project>): Promise<Project> {
@ -336,11 +324,7 @@ export class Database {
const projectRepository = this.dataSource.getRepository(Project); const projectRepository = this.dataSource.getRepository(Project);
const updateResult = await projectRepository.update({ id: projectId }, updates); const updateResult = await projectRepository.update({ id: projectId }, updates);
if (updateResult.affected) { return Boolean(updateResult.affected);
return updateResult.affected > 0;
} else {
return false;
}
} }
async deleteProjectById (projectId: string): Promise<boolean> { async deleteProjectById (projectId: string): Promise<boolean> {
@ -371,70 +355,28 @@ export class Database {
} }
} }
async rollbackDeploymentById (projectId: string, deploymentId: string): Promise<boolean> { async addDomain (data: DeepPartial<Domain>): Promise<Domain> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
// TODO: Implement transactions
const oldCurrentDeployment = await deploymentRepository.findOne({
relations: {
domain: true
},
where: {
project: {
id: projectId
},
isCurrent: true
}
});
const oldCurrentDeploymentUpdate = await deploymentRepository.update({ project: { id: projectId }, isCurrent: true }, { isCurrent: false, domain: null });
const newCurrentDeploymentUpdate = await deploymentRepository.update({ id: deploymentId }, { isCurrent: true, domain: oldCurrentDeployment?.domain });
if (oldCurrentDeploymentUpdate.affected && newCurrentDeploymentUpdate.affected) {
return oldCurrentDeploymentUpdate.affected > 0 && newCurrentDeploymentUpdate.affected > 0;
} else {
return false;
}
}
async addDomainByProjectId (projectId: string, domainDetails: { name: string }): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const projectRepository = this.dataSource.getRepository(Project); const newDomain = await domainRepository.save(data);
const currentProject = await projectRepository.findOneBy({ return newDomain;
id: projectId
});
if (currentProject === null) {
throw new Error(`Project with ${projectId} not found`);
}
const primaryDomainDetails = {
...domainDetails,
branch: currentProject.prodBranch,
project: currentProject
};
const primaryDomain = domainRepository.create(primaryDomainDetails as DeepPartial<Domain>);
const savedPrimaryDomain = await domainRepository.save(primaryDomain);
const domainArr = domainDetails.name.split('www.');
const redirectedDomainDetails = {
name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`,
branch: currentProject.prodBranch,
project: currentProject,
redirectTo: savedPrimaryDomain
};
const redirectedDomain = domainRepository.create(redirectedDomainDetails as DeepPartial<Domain>);
const savedRedirectedDomain = await domainRepository.save(redirectedDomain);
return [savedPrimaryDomain, savedRedirectedDomain];
} }
async getDomainsByProjectId (projectId: string): Promise<Domain[]> { async getDomain (options: FindOneOptions<Domain>): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain);
const domain = await domainRepository.findOne(options);
return domain;
}
async updateDomainById (domainId: string, updates: DeepPartial<Domain>): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain);
const updateResult = await domainRepository.update({ id: Number(domainId) }, updates);
return Boolean(updateResult.affected);
}
async getDomainsByProjectId (projectId: string, filter?: FindOptionsWhere<Domain>): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const domains = await domainRepository.find({ const domains = await domainRepository.find({
@ -444,68 +386,11 @@ export class Database {
where: { where: {
project: { project: {
id: projectId id: projectId
} },
...filter
} }
}); });
return domains; return domains;
} }
async updateDomainById (domainId: string, data: DeepPartial<Domain>): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain);
const domain = await domainRepository.findOne({
where: {
id: Number(domainId)
}
});
const newDomain: DeepPartial<Domain> = {
...data
};
if (domain === null) {
throw new Error(`Error finding domain with id ${domainId}`);
}
const domainsRedirectedFrom = await domainRepository.find({
where: {
project: {
id: domain.projectId
},
redirectToId: domain.id
}
});
// If there are domains redirecting to current domain, only branch of current domain can be updated
if (domainsRedirectedFrom.length > 0 && data.branch === domain.branch) {
throw new Error('Remove all redirects to this domain before updating');
}
if (data.redirectToId) {
const redirectedDomain = await domainRepository.findOne({
where: {
id: Number(data.redirectToId)
}
});
if (redirectedDomain === null) {
throw new Error('Could not find Domain to redirect to');
}
if (redirectedDomain.redirectToId) {
throw new Error('Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.');
}
newDomain.redirectTo = redirectedDomain;
}
const updateResult = await domainRepository.update({ id: Number(domainId) }, newDomain);
if (updateResult.affected) {
return updateResult.affected > 0;
} else {
return false;
}
}
} }

View File

@ -55,5 +55,5 @@ export class Domain {
updatedAt!: Date; updatedAt!: Date;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt?: Date; deletedAt!: Date | null;
} }

View File

@ -67,7 +67,7 @@ export class Project {
updatedAt!: Date; updatedAt!: Date;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt?: Date; deletedAt!: Date | null;
@OneToMany(() => Deployment, (deployment) => deployment.project) @OneToMany(() => Deployment, (deployment) => deployment.project)
deployments!: Deployment[]; deployments!: Deployment[];

View File

@ -47,5 +47,5 @@ export class ProjectMember {
updatedAt!: Date; updatedAt!: Date;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt?: Date; deletedAt!: Date | null;
} }

View File

@ -43,5 +43,5 @@ export class UserOrganization {
updatedAt!: Date; updatedAt!: Date;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt?: Date; deletedAt!: Date | null;
} }

View File

@ -1,12 +1,10 @@
import debug from 'debug'; import debug from 'debug';
import assert from 'assert'; import { DeepPartial, FindOptionsWhere } from 'typeorm';
import { DeepPartial } from 'typeorm';
import { OAuthApp } from '@octokit/oauth-app'; import { OAuthApp } from '@octokit/oauth-app';
import { Service } from './service'; import { Service } from './service';
import { Database } from './database'; import { Database } from './database';
import { isUserOwner } from './utils';
import { Permission } from './entity/ProjectMember'; import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain'; import { Domain } from './entity/Domain';
import { Project } from './entity/Project'; import { Project } from './entity/Project';
@ -51,8 +49,8 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
return service.searchProjects(context.userId, searchText); return service.searchProjects(context.userId, searchText);
}, },
domains: async (_:any, { projectId }: { projectId: string }) => { domains: async (_:any, { projectId, filter }: { projectId: string, filter?: FindOptionsWhere<Domain> }) => {
return service.getDomainsByProjectId(projectId); return service.getDomainsByProjectId(projectId, filter);
} }
}, },
@ -60,20 +58,7 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
Mutation: { Mutation: {
removeProjectMember: async (_: any, { projectMemberId }: { projectMemberId: string }, context: any) => { removeProjectMember: async (_: any, { projectMemberId }: { projectMemberId: string }, context: any) => {
try { try {
const member = await db.getProjectMemberById(projectMemberId); return await service.removeProjectMember(context.userId, projectMemberId);
if (member.member.id === context.userId) {
throw new Error('Invalid operation: cannot remove self');
}
const memberProject = member.project;
assert(memberProject);
if (isUserOwner(String(context.userId), String(memberProject.owner.id))) {
return await db.removeProjectMemberById(projectMemberId);
} else {
throw new Error('Invalid operation: not authorized');
}
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@ -86,7 +71,12 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
permissions: Permission[] permissions: Permission[]
} }
}) => { }) => {
return service.updateProjectMember(projectMemberId, data); try {
return await service.updateProjectMember(projectMemberId, data);
} catch (err) {
log(err);
return false;
}
}, },
addProjectMember: async (_: any, { projectId, data }: { addProjectMember: async (_: any, { projectId, data }: {
@ -96,48 +86,97 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
permissions: Permission[] permissions: Permission[]
} }
}) => { }) => {
return service.addProjectMember(projectId, data); try {
return Boolean(await service.addProjectMember(projectId, data));
} catch (err) {
log(err);
return false;
}
}, },
addEnvironmentVariables: async (_: any, { projectId, data }: { projectId: string, data: { environments: string[], key: string, value: string}[] }) => { addEnvironmentVariables: async (_: any, { projectId, data }: { projectId: string, data: { environments: string[], key: string, value: string}[] }) => {
return service.addEnvironmentVariables(projectId, data); try {
return Boolean(await service.addEnvironmentVariables(projectId, data));
} catch (err) {
log(err);
return false;
}
}, },
updateEnvironmentVariable: async (_: any, { environmentVariableId, data }: { environmentVariableId: string, data : DeepPartial<EnvironmentVariable>}) => { updateEnvironmentVariable: async (_: any, { environmentVariableId, data }: { environmentVariableId: string, data : DeepPartial<EnvironmentVariable>}) => {
return service.updateEnvironmentVariable(environmentVariableId, data); try {
return await service.updateEnvironmentVariable(environmentVariableId, data);
} catch (err) {
log(err);
return false;
}
}, },
removeEnvironmentVariable: async (_: any, { environmentVariableId }: { environmentVariableId: string}) => { removeEnvironmentVariable: async (_: any, { environmentVariableId }: { environmentVariableId: string}) => {
return service.removeEnvironmentVariable(environmentVariableId); try {
return await service.removeEnvironmentVariable(environmentVariableId);
} catch (err) {
log(err);
return false;
}
}, },
updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }) => { updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }) => {
return service.updateDeploymentToProd(deploymentId); try {
return await service.updateDeploymentToProd(deploymentId);
} catch (err) {
log(err);
return false;
}
}, },
addProject: async (_: any, { data }: { data: DeepPartial<Project> }, context: any) => { addProject: async (_: any, { data }: { data: DeepPartial<Project> }, context: any) => {
return service.addProject(context.userId, data); try {
return Boolean(await service.addProject(context.userId, data));
} catch (err) {
log(err);
return false;
}
}, },
updateProject: async (_: any, { projectId, projectDetails }: { projectId: string, projectDetails: DeepPartial<Project> }) => { updateProject: async (_: any, { projectId, projectDetails }: { projectId: string, projectDetails: DeepPartial<Project> }) => {
return service.updateProject(projectId, projectDetails); try {
return await service.updateProject(projectId, projectDetails);
} catch (err) {
log(err);
return false;
}
}, },
redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => { redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
return service.redeployToProd(context.userId, deploymentId); try {
return await service.redeployToProd(context.userId, deploymentId);
} catch (err) {
log(err);
return false;
}
}, },
deleteProject: async (_: any, { projectId }: { projectId: string }) => { deleteProject: async (_: any, { projectId }: { projectId: string }) => {
return service.deleteProject(projectId); try {
return await service.deleteProject(projectId);
} catch (err) {
log(err); return false;
}
}, },
deleteDomain: async (_: any, { domainId }: { domainId: string }) => { deleteDomain: async (_: any, { domainId }: { domainId: string }) => {
return service.deleteDomain(domainId); try {
return await service.deleteDomain(domainId);
} catch (err) {
log(err);
return false;
}
}, },
rollbackDeployment: async (_: any, { projectId, deploymentId }: {deploymentId: string, projectId: string }) => { rollbackDeployment: async (_: any, { projectId, deploymentId }: {deploymentId: string, projectId: string }) => {
try { try {
return await db.rollbackDeploymentById(projectId, deploymentId); return await service.rollbackDeployment(projectId, deploymentId);
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@ -146,7 +185,7 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
addDomain: async (_: any, { projectId, domainDetails }: { projectId: string, domainDetails: { name: string } }) => { addDomain: async (_: any, { projectId, domainDetails }: { projectId: string, domainDetails: { name: string } }) => {
try { try {
return await db.addDomainByProjectId(projectId, domainDetails); return Boolean(await service.addDomain(projectId, domainDetails));
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@ -155,7 +194,7 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
updateDomain: async (_: any, { domainId, domainDetails }: { domainId: string, domainDetails: DeepPartial<Domain>}) => { updateDomain: async (_: any, { domainId, domainDetails }: { domainId: string, domainDetails: DeepPartial<Domain>}) => {
try { try {
return await db.updateDomainById(domainId, domainDetails); return await service.updateDomain(domainId, domainDetails);
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;

View File

@ -167,6 +167,10 @@ input UpdateProjectMemberInput {
permissions: [Permission] permissions: [Permission]
} }
input FilterDomainsInput {
branch: String
}
type Query { type Query {
user: User! user: User!
organizations: [Organization!] organizations: [Organization!]
@ -177,7 +181,7 @@ type Query {
environmentVariables(projectId: String!): [EnvironmentVariable!] environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!] projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!] searchProjects(searchText: String!): [Project!]
domains(projectId: String!): [Domain!] domains(projectId: String!, filter: FilterDomainsInput): [Domain]
} }
type Mutation { type Mutation {

View File

@ -1,6 +1,7 @@
import debug from 'debug'; import assert from 'assert';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary'; import { lowercase, numbers } from 'nanoid-dictionary';
import { DeepPartial, FindOptionsWhere } from 'typeorm';
import { Database } from './database'; import { Database } from './database';
import { Deployment, Environment } from './entity/Deployment'; import { Deployment, Environment } from './entity/Deployment';
@ -10,9 +11,7 @@ import { Organization } from './entity/Organization';
import { Project } from './entity/Project'; import { Project } from './entity/Project';
import { Permission, ProjectMember } from './entity/ProjectMember'; import { Permission, ProjectMember } from './entity/ProjectMember';
import { User } from './entity/User'; import { User } from './entity/User';
import { DeepPartial } from 'typeorm'; import { isUserOwner } from './utils';
const log = debug('snowball:service');
const nanoid = customAlphabet(lowercase + numbers, 8); const nanoid = customAlphabet(lowercase + numbers, 8);
@ -66,193 +65,290 @@ export class Service {
return dbProjects; return dbProjects;
} }
async getDomainsByProjectId (projectId: string): Promise<Domain[]> { async getDomainsByProjectId (projectId: string, filter?: FindOptionsWhere<Domain>): Promise<Domain[]> {
const dbDomains = await this.db.getDomainsByProjectId(projectId); const dbDomains = await this.db.getDomainsByProjectId(projectId, filter);
return dbDomains; return dbDomains;
} }
async updateProjectMember (projectMemberId: string, data: {permissions: Permission[]}): Promise<boolean> { async updateProjectMember (projectMemberId: string, data: {permissions: Permission[]}): Promise<boolean> {
try { return this.db.updateProjectMemberById(projectMemberId, data);
return await this.db.updateProjectMemberById(projectMemberId, data);
} catch (err) {
log(err);
return false;
}
} }
async addProjectMember (projectId: string, async addProjectMember (projectId: string,
data: { data: {
email: string, email: string,
permissions: Permission[] permissions: Permission[]
}): Promise<boolean> { }): Promise<ProjectMember> {
try { // TODO: Send invitation
// TODO: Send invitation let user = await this.db.getUser({
let user = await this.db.getUser({ where: {
where: { email: data.email
email: data.email
}
});
if (!user) {
user = await this.db.createUser({
email: data.email
});
} }
});
const newProjectMember = await this.db.addProjectMember({ if (!user) {
project: { user = await this.db.addUser({
id: projectId email: data.email
},
permissions: data.permissions,
isPending: true,
member: {
id: user.id
}
}); });
}
return Boolean(newProjectMember); const newProjectMember = await this.db.addProjectMember({
} catch (err) { project: {
log(err); id: projectId
return false; },
permissions: data.permissions,
isPending: true,
member: {
id: user.id
}
});
return newProjectMember;
}
async removeProjectMember (userId: string, projectMemberId: string): Promise<boolean> {
const member = await this.db.getProjectMemberById(projectMemberId);
if (String(member.member.id) === userId) {
throw new Error('Invalid operation: cannot remove self');
}
const memberProject = member.project;
assert(memberProject);
if (isUserOwner(String(userId), String(memberProject.owner.id))) {
return this.db.removeProjectMemberById(projectMemberId);
} else {
throw new Error('Invalid operation: not authorized');
} }
} }
async addEnvironmentVariables (projectId: string, data: { environments: string[], key: string, value: string}[]): Promise<boolean> { async addEnvironmentVariables (projectId: string, data: { environments: string[], key: string, value: string}[]): Promise<EnvironmentVariable[]> {
try { const formattedEnvironmentVariables = data.map((environmentVariable) => {
const formattedEnvironmentVariables = data.map((environmentVariable) => { return environmentVariable.environments.map((environment) => {
return environmentVariable.environments.map((environment) => { return ({
return ({ key: environmentVariable.key,
key: environmentVariable.key, value: environmentVariable.value,
value: environmentVariable.value, environment: environment as Environment,
environment: environment as Environment, project: Object.assign(new Project(), {
project: Object.assign(new Project(), { id: projectId
id: projectId })
})
});
}); });
}).flat(); });
}).flat();
const savedEnvironmentVariables = await this.db.addEnvironmentVariables(formattedEnvironmentVariables); const savedEnvironmentVariables = await this.db.addEnvironmentVariables(formattedEnvironmentVariables);
return savedEnvironmentVariables.length > 0; return savedEnvironmentVariables;
} catch (err) {
log(err);
return false;
}
} }
async updateEnvironmentVariable (environmentVariableId: string, data : DeepPartial<EnvironmentVariable>): Promise<boolean> { async updateEnvironmentVariable (environmentVariableId: string, data : DeepPartial<EnvironmentVariable>): Promise<boolean> {
try { return this.db.updateEnvironmentVariable(environmentVariableId, data);
return await this.db.updateEnvironmentVariable(environmentVariableId, data);
} catch (err) {
log(err);
return false;
}
} }
async removeEnvironmentVariable (environmentVariableId: string): Promise<boolean> { async removeEnvironmentVariable (environmentVariableId: string): Promise<boolean> {
try { return this.db.deleteEnvironmentVariable(environmentVariableId);
return await this.db.deleteEnvironmentVariable(environmentVariableId);
} catch (err) {
log(err);
return false;
}
} }
async updateDeploymentToProd (deploymentId: string): Promise<boolean> { async updateDeploymentToProd (deploymentId: string): Promise<boolean> {
try { const deployment = await this.db.getDeployment({ where: { id: deploymentId }, relations: { project: true } });
return await this.db.updateDeploymentById(deploymentId, {
environment: Environment.Production if (!deployment) {
}); throw new Error('Deployment does not exist');
} catch (err) {
log(err);
return false;
} }
const prodBranchDomains = await this.db.getDomainsByProjectId(deployment.project.id, { branch: deployment.project.prodBranch });
const oldDeployment = await this.db.getDeployment({
where: {
domain: {
id: prodBranchDomains[0].id
}
}
});
if (oldDeployment) {
await this.db.updateDeploymentById(oldDeployment.id, {
domain: null,
isCurrent: false
});
}
const updateResult = await this.db.updateDeploymentById(deploymentId, {
environment: Environment.Production,
domain: prodBranchDomains[0],
isCurrent: true
});
return updateResult;
} }
async addProject (userId: string, data: DeepPartial<Project>): Promise<boolean> { async addProject (userId: string, data: DeepPartial<Project>): Promise<Project> {
try { return this.db.addProject(userId, data);
await this.db.addProject(userId, data);
return true;
} catch (err) {
log(err);
return false;
}
} }
async updateProject (projectId: string, data: DeepPartial<Project>): Promise<boolean> { async updateProject (projectId: string, data: DeepPartial<Project>): Promise<boolean> {
try { return this.db.updateProjectById(projectId, data);
return await this.db.updateProjectById(projectId, data);
} catch (err) {
log(err);
return false;
}
} }
async deleteProject (projectId: string): Promise<boolean> { async deleteProject (projectId: string): Promise<boolean> {
try { return this.db.deleteProjectById(projectId);
return await this.db.deleteProjectById(projectId);
} catch (err) {
log(err);
return false;
}
} }
async deleteDomain (domainId: string): Promise<boolean> { async deleteDomain (domainId: string): Promise<boolean> {
try { const domainsRedirectedFrom = await this.db.getDomains({
const domainsRedirectedFrom = await this.db.getDomains({ where: {
where: { redirectToId: Number(domainId)
redirectToId: Number(domainId)
}
});
if (domainsRedirectedFrom.length > 0) {
throw new Error('Cannot delete domain since it has redirects from other domains');
} }
});
return await this.db.deleteDomainById(domainId); if (domainsRedirectedFrom.length > 0) {
} catch (err) { throw new Error('Cannot delete domain since it has redirects from other domains');
log(err);
return false;
} }
return this.db.deleteDomainById(domainId);
} }
async redeployToProd (userId: string, deploymentId: string): Promise<Deployment| boolean> { async redeployToProd (userId: string, deploymentId: string): Promise<Deployment| boolean> {
try { const deployment = await this.db.getDeployment({
const deployment = await this.db.getDeployment({ relations: {
relations: { project: true,
project: true, domain: true,
domain: true, createdBy: true
createdBy: true },
where: {
id: deploymentId
}
});
if (deployment === null) {
throw new Error('Deployment not found');
}
const { createdAt, updatedAt, ...updatedDeployment } = deployment;
if (updatedDeployment.environment === Environment.Production) {
// TODO: Put isCurrent field in project
updatedDeployment.isCurrent = true;
updatedDeployment.createdBy = Object.assign(new User(), {
id: Number(userId)
});
}
updatedDeployment.id = nanoid();
updatedDeployment.url = `${updatedDeployment.id}-${updatedDeployment.project.subDomain}`;
const oldDeployment = await this.db.updateDeploymentById(deploymentId, { domain: null, isCurrent: false });
const newDeployement = await this.db.addDeployement(updatedDeployment);
return oldDeployment && Boolean(newDeployement);
}
async rollbackDeployment (projectId: string, deploymentId: string): Promise<boolean> {
// TODO: Implement transactions
const oldCurrentDeployment = await this.db.getDeployment({
relations: {
domain: true
},
where: {
project: {
id: projectId
}, },
isCurrent: true
}
});
if (!oldCurrentDeployment) {
throw new Error('Current deployement doesnot exist');
}
const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById(oldCurrentDeployment.id, { isCurrent: false, domain: null });
const newCurrentDeploymentUpdate = await this.db.updateDeploymentById(deploymentId, { isCurrent: true, domain: oldCurrentDeployment?.domain });
return newCurrentDeploymentUpdate && oldCurrentDeploymentUpdate;
}
async addDomain (projectId: string, domainDetails: { name: string }): Promise<{
primaryDomain: Domain,
redirectedDomain: Domain
}> {
const currentProject = await this.db.getProjectById(projectId);
if (currentProject === null) {
throw new Error(`Project with ${projectId} not found`);
}
const primaryDomainDetails = {
...domainDetails,
branch: currentProject.prodBranch,
project: currentProject
};
const savedPrimaryDomain = await this.db.addDomain(primaryDomainDetails);
const domainArr = domainDetails.name.split('www.');
const redirectedDomainDetails = {
name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`,
branch: currentProject.prodBranch,
project: currentProject,
redirectTo: savedPrimaryDomain
};
const savedRedirectedDomain = await this.db.addDomain(redirectedDomainDetails);
return { primaryDomain: savedPrimaryDomain, redirectedDomain: savedRedirectedDomain };
}
async updateDomain (domainId: string, domainDetails: DeepPartial<Domain>): Promise<boolean> {
const domain = await this.db.getDomain({
where: {
id: Number(domainId)
}
});
if (domain === null) {
throw new Error(`Error finding domain with id ${domainId}`);
}
const newDomain = {
...domainDetails
};
const domainsRedirectedFrom = await this.db.getDomains({
where: {
project: {
id: domain.projectId
},
redirectToId: domain.id
}
});
// If there are domains redirecting to current domain, only branch of current domain can be updated
if (domainsRedirectedFrom.length > 0 && domainDetails.branch === domain.branch) {
throw new Error('Remove all redirects to this domain before updating');
}
if (domainDetails.redirectToId) {
const redirectedDomain = await this.db.getDomain({
where: { where: {
id: deploymentId id: Number(domainDetails.redirectToId)
} }
}); });
if (deployment === null) { if (redirectedDomain === null) {
throw new Error('Deployment not found'); throw new Error('Could not find Domain to redirect to');
} }
const { createdAt, updatedAt, ...updatedDeployment } = deployment; if (redirectedDomain.redirectToId) {
throw new Error('Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.');
if (updatedDeployment.environment === Environment.Production) {
// TODO: Put isCurrent field in project
updatedDeployment.isCurrent = true;
updatedDeployment.createdBy = Object.assign(new User(), {
id: Number(userId)
});
} }
updatedDeployment.id = nanoid(); newDomain.redirectTo = redirectedDomain;
updatedDeployment.url = `${updatedDeployment.id}-${updatedDeployment.project.subDomain}`;
const oldDeployment = await this.db.updateDeploymentById(deploymentId, { domain: null, isCurrent: false });
const newDeployement = await this.db.createDeployement(updatedDeployment);
return oldDeployment && Boolean(newDeployement);
} catch (err) {
log(err);
return false;
} }
const updateResult = await this.db.updateDomainById(domainId, newDomain);
return updateResult;
} }
} }

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Project } from 'gql-client'; import { Project, Domain } from 'gql-client';
import { Button, Typography } from '@material-tailwind/react'; import { Button, Typography } from '@material-tailwind/react';
@ -22,6 +22,7 @@ const DeploymentsTabPanel = ({ project }: { project: Project }) => {
const [filterValue, setFilterValue] = useState(DEFAULT_FILTER_VALUE); const [filterValue, setFilterValue] = useState(DEFAULT_FILTER_VALUE);
const [deployments, setDeployments] = useState<DeploymentDetails[]>([]); const [deployments, setDeployments] = useState<DeploymentDetails[]>([]);
const [prodBranchDomains, setProdBranchDomains] = useState<Domain[]>([]);
const fetchDeployments = async () => { const fetchDeployments = async () => {
const { deployments } = await client.getDeployments(project.id); const { deployments } = await client.getDeployments(project.id);
@ -35,8 +36,16 @@ const DeploymentsTabPanel = ({ project }: { project: Project }) => {
setDeployments(updatedDeployments); setDeployments(updatedDeployments);
}; };
const fetchProductionBranchDomains = useCallback(async () => {
const { domains } = await client.getDomains(project.id, {
branch: project.prodBranch,
});
setProdBranchDomains(domains);
}, []);
useEffect(() => { useEffect(() => {
fetchDeployments(); fetchDeployments();
fetchProductionBranchDomains();
}, []); }, []);
const currentDeployment = useMemo(() => { const currentDeployment = useMemo(() => {
@ -93,6 +102,7 @@ const DeploymentsTabPanel = ({ project }: { project: Project }) => {
currentDeployment={currentDeployment!} currentDeployment={currentDeployment!}
onUpdate={onUpdateDeploymenToProd} onUpdate={onUpdateDeploymenToProd}
project={project} project={project}
prodBranchDomains={prodBranchDomains}
/> />
); );
}) })

View File

@ -31,6 +31,11 @@ const OverviewTabPanel = ({ project }: OverviewProps) => {
const fetchRepoActivity = async () => { const fetchRepoActivity = async () => {
const [owner, repo] = project.repository.split('/'); const [owner, repo] = project.repository.split('/');
if (!repo) {
// Do not fetch branches if repo not available
return;
}
// Get all branches in project repo // Get all branches in project repo
const result = await octokit.rest.repos.listBranches({ const result = await octokit.rest.repos.listBranches({
owner, owner,

View File

@ -10,7 +10,7 @@ import {
ChipProps, ChipProps,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Environment, Project } from 'gql-client'; import { Environment, Project, Domain } from 'gql-client';
import { relativeTimeMs } from '../../../../utils/time'; import { relativeTimeMs } from '../../../../utils/time';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
@ -24,6 +24,7 @@ interface DeployDetailsCardProps {
currentDeployment: DeploymentDetails; currentDeployment: DeploymentDetails;
onUpdate: () => Promise<void>; onUpdate: () => Promise<void>;
project: Project; project: Project;
prodBranchDomains: Domain[];
} }
const STATUS_COLORS: { [key in Status]: ChipProps['color'] } = { const STATUS_COLORS: { [key in Status]: ChipProps['color'] } = {
@ -37,6 +38,7 @@ const DeploymentDetailsCard = ({
currentDeployment, currentDeployment,
onUpdate, onUpdate,
project, project,
prodBranchDomains,
}: DeployDetailsCardProps) => { }: DeployDetailsCardProps) => {
const client = useGQLClient(); const client = useGQLClient();
@ -167,12 +169,14 @@ const DeploymentDetailsCard = ({
<Typography variant="small"> <Typography variant="small">
The new deployment will be associated with these domains: The new deployment will be associated with these domains:
</Typography> </Typography>
<Typography variant="small" color="blue"> {prodBranchDomains.length > 0 &&
^ saugatt.com prodBranchDomains.map((value) => {
</Typography> return (
<Typography variant="small" color="blue"> <Typography variant="small" color="blue" key={value.id}>
^ www.saugatt.com ^ {value.name}
</Typography> </Typography>
);
})}
</div> </div>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog <ConfirmDialog
@ -195,12 +199,11 @@ const DeploymentDetailsCard = ({
<Typography variant="small"> <Typography variant="small">
These domains will point to your new deployment: These domains will point to your new deployment:
</Typography> </Typography>
<Typography variant="small" color="blue"> {deployment.domain?.name && (
^ saugatt.com <Typography variant="small" color="blue">
</Typography> {deployment.domain?.name}
<Typography variant="small" color="blue"> </Typography>
^ www.saugatt.com )}
</Typography>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog <ConfirmDialog

View File

@ -1,7 +1,7 @@
import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables, getProject, getDomains, getProjectsInOrganization } from './queries'; import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables, getProject, getDomains, getProjectsInOrganization } from './queries';
import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, UpdateDeploymentToProdResponse, GetProjectResponse, UpdateProjectResponse, UpdateProjectInput, RedeployToProdResponse, DeleteProjectResponse, GetProjectsInOrganizationResponse, RollbackDeploymentResponse, AddDomainInput, AddDomainResponse, GetDomainsResponse, UpdateDomainInput, UpdateDomainResponse, AuthenticateGitHubResponse, UnauthenticateGitHubResponse, UpdateEnvironmentVariableResponse, UpdateEnvironmentVariableInput, RemoveEnvironmentVariableResponse, UpdateProjectMemberInput, RemoveProjectMemberResponse, UpdateProjectMemberResponse, DeleteDomainResponse, AddProjectMemberInput, AddProjectMemberResponse, AddProjectInput, AddProjectResponse } from './types'; import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, UpdateDeploymentToProdResponse, GetProjectResponse, UpdateProjectResponse, UpdateProjectInput, RedeployToProdResponse, DeleteProjectResponse, GetProjectsInOrganizationResponse, RollbackDeploymentResponse, AddDomainInput, AddDomainResponse, GetDomainsResponse, UpdateDomainInput, UpdateDomainResponse, AuthenticateGitHubResponse, UnauthenticateGitHubResponse, UpdateEnvironmentVariableResponse, UpdateEnvironmentVariableInput, RemoveEnvironmentVariableResponse, UpdateProjectMemberInput, RemoveProjectMemberResponse, UpdateProjectMemberResponse, DeleteDomainResponse, AddProjectMemberInput, AddProjectMemberResponse, AddProjectInput, AddProjectResponse, FilterDomainInput } from './types';
import { removeProjectMember, addEnvironmentVariables, updateDeploymentToProd, updateProjectMutation, redeployToProd, deleteProject, addDomain, rollbackDeployment, updateDomainMutation, authenticateGitHub, unauthenticateGitHub, updateEnvironmentVariable, removeEnvironmentVariable, updateProjectMember, deleteDomain, addProjectMember, addProject } from './mutations'; import { removeProjectMember, addEnvironmentVariables, updateDeploymentToProd, updateProjectMutation, redeployToProd, deleteProject, addDomain, rollbackDeployment, updateDomainMutation, authenticateGitHub, unauthenticateGitHub, updateEnvironmentVariable, removeEnvironmentVariable, updateProjectMember, deleteDomain, addProjectMember, addProject } from './mutations';
export interface GraphQLConfig { export interface GraphQLConfig {
@ -286,11 +286,12 @@ export class GQLClient {
return data; return data;
} }
async getDomains (projectId: string): Promise<GetDomainsResponse> { async getDomains (projectId: string, filter?: FilterDomainInput): Promise<GetDomainsResponse> {
const { data } = await this.client.query({ const { data } = await this.client.query({
query: getDomains, query: getDomains,
variables: { variables: {
projectId projectId,
filter
} }
}); });

View File

@ -199,8 +199,8 @@ query ($searchText: String!) {
`; `;
export const getDomains = gql` export const getDomains = gql`
query ($projectId: String!) { query ($projectId: String!, $filter: FilterDomainsInput) {
domains(projectId: $projectId) { domains(projectId: $projectId, filter: $filter) {
branch branch
createdAt createdAt
redirectTo { redirectTo {

View File

@ -272,6 +272,10 @@ export type AddDomainInput = {
name: string name: string
} }
export type FilterDomainInput = {
branch: string
}
export type AddDomainResponse = { export type AddDomainResponse = {
addDomain: true addDomain: true
} }