Refactor mutation GQL methods to service class and display organization name (#53)

* Refactor mutation methods to service class

* Refactor database methods to service class

* Display organization name in sidebar

* Handle review comments

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-02-05 16:21:55 +05:30 committed by GitHub
parent ac7064afa5
commit da92ddfba3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 320 additions and 218 deletions

View File

@ -1,24 +1,20 @@
import { DataSource, DeepPartial } from 'typeorm';
import { DataSource, DeepPartial, FindManyOptions, FindOneOptions } from 'typeorm';
import path from 'path';
import debug from 'debug';
import assert from 'assert';
import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary';
import { DatabaseConfig } from './config';
import { User } from './entity/User';
import { Organization } from './entity/Organization';
import { Project } from './entity/Project';
import { Deployment, Environment } from './entity/Deployment';
import { Permission, ProjectMember } from './entity/ProjectMember';
import { Deployment } from './entity/Deployment';
import { ProjectMember } from './entity/ProjectMember';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Domain } from './entity/Domain';
import { PROJECT_DOMAIN } from './constants';
const log = debug('snowball:database');
const nanoid = customAlphabet(lowercase + numbers, 8);
// TODO: Fix order of methods
export class Database {
private dataSource: DataSource;
@ -38,11 +34,16 @@ export class Database {
log('database initialized');
}
async getUser (userId: number): Promise<User | null> {
async getUser (options: FindOneOptions<User>): Promise<User | null> {
const userRepository = this.dataSource.getRepository(User);
const user = await userRepository.findOneBy({
id: userId
});
const user = await userRepository.findOne(options);
return user;
}
async createUser (data: DeepPartial<User>): Promise<User> {
const userRepository = this.dataSource.getRepository(User);
const user = await userRepository.save(data);
return user;
}
@ -146,6 +147,27 @@ export class Database {
return deployments;
}
async getDeployment (options: FindOneOptions<Deployment>): Promise<Deployment | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOne(options);
return deployment;
}
async getDomains (options: FindManyOptions<Domain>): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain);
const domains = await domainRepository.find(options);
return domains;
}
async createDeployement (data: DeepPartial<Deployment>): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.save(data);
return deployment;
}
async getProjectMembersByProjectId (projectId: string): Promise<ProjectMember[]> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember);
@ -201,60 +223,18 @@ export class Database {
}
}
async addProjectMember (projectId: string, data: {
email: string,
permissions: Permission[]
}): Promise<boolean> {
async addProjectMember (data: DeepPartial<ProjectMember>): Promise<ProjectMember> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember);
const userRepository = this.dataSource.getRepository(User);
const newProjectMember = await projectMemberRepository.save(data);
let user = await userRepository.findOneBy({
email: data.email
});
if (!user) {
user = await userRepository.save({
email: data.email,
isVerified: false
});
}
const newProjectMember = await projectMemberRepository.save({
project: {
id: projectId
},
permissions: data.permissions,
isPending: true,
member: {
id: user.id
}
});
return Boolean(newProjectMember);
return newProjectMember;
}
async addEnvironmentVariablesByProjectId (projectId: string, environmentVariables: {
environments: string[];
key: string;
value: string;
}[]): Promise<boolean> {
async addEnvironmentVariables (data: DeepPartial<EnvironmentVariable>[]): Promise<EnvironmentVariable[]> {
const environmentVariableRepository = this.dataSource.getRepository(EnvironmentVariable);
const savedEnvironmentVariables = await environmentVariableRepository.save(data);
const formattedEnvironmentVariables = environmentVariables.map((environmentVariable) => {
return environmentVariable.environments.map((environment) => {
return ({
key: environmentVariable.key,
value: environmentVariable.value,
environment: environment as Environment,
project: Object.assign(new Project(), {
id: projectId
})
});
});
}).flat();
const savedEnvironmentVariables = await environmentVariableRepository.save(formattedEnvironmentVariables);
return savedEnvironmentVariables.length > 0;
return savedEnvironmentVariables;
}
async updateEnvironmentVariable (environmentVariableId: string, update: DeepPartial<EnvironmentVariable>): Promise<boolean> {
@ -363,40 +343,6 @@ export class Database {
}
}
async redeployToProdById (userId: string, deploymentId: string): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOne({
relations: {
project: true,
domain: 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)
});
}
await deploymentRepository.update({ id: deploymentId }, { domain: null, isCurrent: false });
updatedDeployment.id = nanoid();
updatedDeployment.url = `${updatedDeployment.id}-${updatedDeployment.project.subDomain}`;
return deploymentRepository.save(updatedDeployment);
}
async deleteProjectById (projectId: string): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project);
const project = await projectRepository.findOneOrFail({
@ -416,16 +362,6 @@ export class Database {
async deleteDomainById (domainId: string): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain);
const domainsRedirectedFrom = await domainRepository.find({
where: {
redirectToId: Number(domainId)
}
});
if (domainsRedirectedFrom.length > 0) {
throw new Error('Cannot delete domain since it has redirects from other domains');
}
const deleteResult = await domainRepository.softDelete({ id: Number(domainId) });
if (deleteResult.affected) {

View File

@ -7,10 +7,10 @@ import { OAuthApp } from '@octokit/oauth-app';
import { Service } from './service';
import { Database } from './database';
import { isUserOwner } from './utils';
import { Environment } from './entity/Deployment';
import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain';
import { Project } from './entity/Project';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
const log = debug('snowball:database');
@ -86,12 +86,7 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
permissions: Permission[]
}
}) => {
try {
return await db.updateProjectMemberById(projectMemberId, data);
} catch (err) {
log(err);
return false;
}
return service.updateProjectMember(projectMemberId, data);
},
addProjectMember: async (_: any, { projectId, data }: {
@ -101,100 +96,43 @@ export const createResolvers = async (db: Database, app: OAuthApp, service: Serv
permissions: Permission[]
}
}) => {
try {
// TODO: Send invitation
return await db.addProjectMember(projectId, data);
} catch (err) {
log(err);
return false;
}
return service.addProjectMember(projectId, data);
},
addEnvironmentVariables: async (_: any, { projectId, environmentVariables }: { projectId: string, environmentVariables: { environments: string[], key: string, value: string}[] }) => {
try {
return await db.addEnvironmentVariablesByProjectId(projectId, environmentVariables);
} catch (err) {
log(err);
return false;
}
addEnvironmentVariables: async (_: any, { projectId, data }: { projectId: string, data: { environments: string[], key: string, value: string}[] }) => {
return service.addEnvironmentVariables(projectId, data);
},
updateEnvironmentVariable: async (_: any, { environmentVariableId, environmentVariable }: { environmentVariableId: string, environmentVariable : {
key: string
value: string
}}) => {
try {
return await db.updateEnvironmentVariable(environmentVariableId, environmentVariable);
} catch (err) {
log(err);
return false;
}
updateEnvironmentVariable: async (_: any, { environmentVariableId, data }: { environmentVariableId: string, data : DeepPartial<EnvironmentVariable>}) => {
return service.updateEnvironmentVariable(environmentVariableId, data);
},
removeEnvironmentVariable: async (_: any, { environmentVariableId }: { environmentVariableId: string}) => {
try {
return await db.deleteEnvironmentVariable(environmentVariableId);
} catch (err) {
log(err);
return false;
}
return service.removeEnvironmentVariable(environmentVariableId);
},
updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }) => {
try {
return await db.updateDeploymentById(deploymentId, {
environment: Environment.Production
});
} catch (err) {
log(err);
return false;
}
return service.updateDeploymentToProd(deploymentId);
},
addProject: async (_: any, { projectDetails }: { projectDetails: DeepPartial<Project> }, context: any) => {
try {
await db.addProject(context.userId, projectDetails);
return true;
} catch (err) {
log(err);
return false;
}
addProject: async (_: any, { data }: { data: DeepPartial<Project> }, context: any) => {
return service.addProject(context.userId, data);
},
updateProject: async (_: any, { projectId, projectDetails }: { projectId: string, projectDetails: DeepPartial<Project> }) => {
try {
return await db.updateProjectById(projectId, projectDetails);
} catch (err) {
log(err);
return false;
}
return service.updateProject(projectId, projectDetails);
},
redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
try {
return await db.redeployToProdById(context.userId, deploymentId);
} catch (err) {
log(err);
return false;
}
return service.redeployToProd(context.userId, deploymentId);
},
deleteProject: async (_: any, { projectId }: { projectId: string }) => {
try {
return await db.deleteProjectById(projectId);
} catch (err) {
log(err);
return false;
}
return service.deleteProject(projectId);
},
deleteDomain: async (_: any, { domainId }: { domainId: string }) => {
try {
return await db.deleteDomainById(domainId);
} catch (err) {
log(err);
return false;
}
return service.deleteDomain(domainId);
},
rollbackDeployment: async (_: any, { projectId, deploymentId }: {deploymentId: string, projectId: string }) => {

View File

@ -184,8 +184,8 @@ type Mutation {
addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean!
updateProjectMember(projectMemberId: String!, data: UpdateProjectMemberInput): Boolean!
removeProjectMember(projectMemberId: String!): Boolean!
addEnvironmentVariables(projectId: String!, environmentVariables: [AddEnvironmentVariableInput!]): Boolean!
updateEnvironmentVariable(environmentVariableId: String!, environmentVariable: UpdateEnvironmentVariableInput!): Boolean!
addEnvironmentVariables(projectId: String!, data: [AddEnvironmentVariableInput!]): Boolean!
updateEnvironmentVariable(environmentVariableId: String!, data: UpdateEnvironmentVariableInput!): Boolean!
removeEnvironmentVariable(environmentVariableId: String!): Boolean!
updateDeploymentToProd(deploymentId: String!): Boolean!
addProject(projectDetails: AddProjectInput): Boolean!

View File

@ -1,11 +1,20 @@
import debug from 'debug';
import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary';
import { Database } from './database';
import { Deployment } from './entity/Deployment';
import { Deployment, Environment } from './entity/Deployment';
import { Domain } from './entity/Domain';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Organization } from './entity/Organization';
import { Project } from './entity/Project';
import { ProjectMember } from './entity/ProjectMember';
import { Permission, ProjectMember } from './entity/ProjectMember';
import { User } from './entity/User';
import { DeepPartial } from 'typeorm';
const log = debug('snowball:service');
const nanoid = customAlphabet(lowercase + numbers, 8);
export class Service {
private db: Database;
@ -15,7 +24,11 @@ export class Service {
}
async getUser (userId: number): Promise<User | null> {
return this.db.getUser(userId);
return this.db.getUser({
where: {
id: userId
}
});
}
async getOrganizationsByUserId (userId: number): Promise<Organization[]> {
@ -57,4 +70,189 @@ export class Service {
const dbDomains = await this.db.getDomainsByProjectId(projectId);
return dbDomains;
}
async updateProjectMember (projectMemberId: string, data: {permissions: Permission[]}): Promise<boolean> {
try {
return await this.db.updateProjectMemberById(projectMemberId, data);
} catch (err) {
log(err);
return false;
}
}
async addProjectMember (projectId: string,
data: {
email: string,
permissions: Permission[]
}): Promise<boolean> {
try {
// TODO: Send invitation
let user = await this.db.getUser({
where: {
email: data.email
}
});
if (!user) {
user = await this.db.createUser({
email: data.email
});
}
const newProjectMember = await this.db.addProjectMember({
project: {
id: projectId
},
permissions: data.permissions,
isPending: true,
member: {
id: user.id
}
});
return Boolean(newProjectMember);
} catch (err) {
log(err);
return false;
}
}
async addEnvironmentVariables (projectId: string, data: { environments: string[], key: string, value: string}[]): Promise<boolean> {
try {
const formattedEnvironmentVariables = data.map((environmentVariable) => {
return environmentVariable.environments.map((environment) => {
return ({
key: environmentVariable.key,
value: environmentVariable.value,
environment: environment as Environment,
project: Object.assign(new Project(), {
id: projectId
})
});
});
}).flat();
const savedEnvironmentVariables = await this.db.addEnvironmentVariables(formattedEnvironmentVariables);
return savedEnvironmentVariables.length > 0;
} catch (err) {
log(err);
return false;
}
}
async updateEnvironmentVariable (environmentVariableId: string, data : DeepPartial<EnvironmentVariable>): Promise<boolean> {
try {
return await this.db.updateEnvironmentVariable(environmentVariableId, data);
} catch (err) {
log(err);
return false;
}
}
async removeEnvironmentVariable (environmentVariableId: string): Promise<boolean> {
try {
return await this.db.deleteEnvironmentVariable(environmentVariableId);
} catch (err) {
log(err);
return false;
}
}
async updateDeploymentToProd (deploymentId: string): Promise<boolean> {
try {
return await this.db.updateDeploymentById(deploymentId, {
environment: Environment.Production
});
} catch (err) {
log(err);
return false;
}
}
async addProject (userId: string, data: DeepPartial<Project>): Promise<boolean> {
try {
await this.db.addProject(userId, data);
return true;
} catch (err) {
log(err);
return false;
}
}
async updateProject (projectId: string, data: DeepPartial<Project>): Promise<boolean> {
try {
return await this.db.updateProjectById(projectId, data);
} catch (err) {
log(err);
return false;
}
}
async deleteProject (projectId: string): Promise<boolean> {
try {
return await this.db.deleteProjectById(projectId);
} catch (err) {
log(err);
return false;
}
}
async deleteDomain (domainId: string): Promise<boolean> {
try {
const domainsRedirectedFrom = await this.db.getDomains({
where: {
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);
} catch (err) {
log(err);
return false;
}
}
async redeployToProd (userId: string, deploymentId: string): Promise<Deployment| boolean> {
try {
const deployment = await this.db.getDeployment({
relations: {
project: true,
domain: 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.createDeployement(updatedDeployment);
return oldDeployment && Boolean(newDeployement);
} catch (err) {
log(err);
return false;
}
}
}

View File

@ -1,9 +1,10 @@
import React from 'react';
import { Link, NavLink } from 'react-router-dom';
import { Organization } from 'gql-client';
import { Card, CardBody, Typography } from '@material-tailwind/react';
const Sidebar = () => {
const Sidebar = ({ organization }: { organization: Organization }) => {
return (
<div className="flex flex-col h-full p-4">
<div className="grow">
@ -13,8 +14,12 @@ const Sidebar = () => {
</Link>
</div>
<Card className="-ml-1 my-2">
<CardBody className="p-1 py-2">
<Typography>Organization</Typography>
<CardBody className="p-1 py-2 flex gap-2">
<div>^</div>
<div>
<Typography>{organization.name}</Typography>
<Typography>Organization</Typography>
</div>
</CardBody>
</Card>
<div>

View File

@ -1,19 +1,42 @@
import React from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Organization } from 'gql-client';
import { Outlet } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { useGQLClient } from '../context/GQLClientContext';
// TODO: Implement organization switcher
// TODO: Projects get organization details through routes instead of context
const USER_ORGANIZATION_INDEX = 0;
const Dashboard = () => {
const client = useGQLClient();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const fetchUserOrganizations = useCallback(async () => {
const { organizations } = await client.getOrganizations();
setOrganizations(organizations);
}, []);
useEffect(() => {
fetchUserOrganizations();
}, []);
return (
<div className="grid grid-cols-5 h-screen bg-light-blue-50">
<div className="h-full">
<Sidebar />
</div>
<div className="col-span-4 h-full p-3 overflow-y-hidden">
<div className="bg-white rounded-3xl h-full overflow-y-auto">
<Outlet />
</div>
</div>
{organizations.length > 0 && (
<>
<div className="h-full">
<Sidebar organization={organizations[USER_ORGANIZATION_INDEX]} />
</div>
<div className="col-span-4 h-full p-3 overflow-y-hidden">
<div className="bg-white rounded-3xl h-full overflow-y-auto">
<Outlet context={organizations[USER_ORGANIZATION_INDEX]} />
</div>
</div>
</>
)}
</div>
);
};

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet, useNavigate, useOutletContext } from 'react-router-dom';
import { Organization } from 'gql-client';
import { IconButton, Typography } from '@material-tailwind/react';
@ -8,7 +9,7 @@ import ProjectSearchBar from '../components/projects/ProjectSearchBar';
const ProjectSearch = () => {
const navigate = useNavigate();
const organization = useOutletContext<Organization>();
return (
<div>
<div className="sticky top-0 bg-white z-30">
@ -33,7 +34,7 @@ const ProjectSearch = () => {
<HorizontalLine />
</div>
<div className="z-0">
<Outlet />
<Outlet context={organization} />
</div>
</div>
);

View File

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useOutletContext } from 'react-router-dom';
import { Organization } from 'gql-client';
import { Button, Typography, Chip } from '@material-tailwind/react';
@ -8,16 +9,16 @@ import { useGQLClient } from '../context/GQLClientContext';
import { ProjectDetails } from '../types/project';
import { COMMIT_DETAILS } from '../constants';
// TODO: Implement organization switcher
const USER_ORGANIZATION_ID = '1';
const Projects = () => {
const client = useGQLClient();
const organization = useOutletContext<Organization>();
const [projects, setProjects] = useState<ProjectDetails[]>([]);
const fetchProjects = useCallback(async () => {
const { projectsInOrganization } =
await client.getProjectsInOrganization(USER_ORGANIZATION_ID);
const { projectsInOrganization } = await client.getProjectsInOrganization(
organization.id,
);
const updatedProjects = projectsInOrganization.map((project) => {
return {

View File

@ -148,28 +148,28 @@ export class GQLClient {
return data;
}
async addEnvironmentVariables (projectId: string, environmentVariables: AddEnvironmentVariableInput[]): Promise<AddEnvironmentVariablesResponse> {
const { data } = await this.client.mutate({
async addEnvironmentVariables (projectId: string, data: AddEnvironmentVariableInput[]): Promise<AddEnvironmentVariablesResponse> {
const result = await this.client.mutate({
mutation: addEnvironmentVariables,
variables: {
projectId,
environmentVariables
data
}
});
return data;
return result.data;
}
async updateEnvironmentVariable (environmentVariableId: string, environmentVariable: UpdateEnvironmentVariableInput): Promise<UpdateEnvironmentVariableResponse> {
const { data } = await this.client.mutate({
async updateEnvironmentVariable (environmentVariableId: string, data: UpdateEnvironmentVariableInput): Promise<UpdateEnvironmentVariableResponse> {
const result = await this.client.mutate({
mutation: updateEnvironmentVariable,
variables: {
environmentVariableId,
environmentVariable
data
}
});
return data;
return result.data;
}
async removeEnvironmentVariable (environmentVariableId: string): Promise<RemoveEnvironmentVariableResponse> {

View File

@ -19,14 +19,14 @@ mutation ($projectId: String!, $data: AddProjectMemberInput) {
`;
export const addEnvironmentVariables = gql`
mutation ($projectId: String!, $environmentVariables: [AddEnvironmentVariableInput!]) {
addEnvironmentVariables(projectId: $projectId, environmentVariables: $environmentVariables)
mutation ($projectId: String!, $data: [AddEnvironmentVariableInput!]) {
addEnvironmentVariables(projectId: $projectId, data: $data)
}
`;
export const updateEnvironmentVariable = gql`
mutation ($environmentVariableId: String!, $environmentVariable: UpdateEnvironmentVariableInput!) {
updateEnvironmentVariable(environmentVariableId: $environmentVariableId, environmentVariable: $environmentVariable)
mutation ($environmentVariableId: String!, $data: UpdateEnvironmentVariableInput!) {
updateEnvironmentVariable(environmentVariableId: $environmentVariableId, data: $data)
}
`;