Skip to content

Commit

Permalink
feat(backend): project relationship (#118)
Browse files Browse the repository at this point in the history
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced project forms with stricter validations, ensuring required
fields are provided.
- Improved data management that automatically cleans up related chats
when projects or accounts are removed.
  
- **Refactor**
- Streamlined the main interface: the chat and code editing panels have
been removed, so the home view now focuses solely on settings.
- Removed the unsaved changes notification to simplify the code editor
experience.
  
- **Chores**
  - Updated dependency management for improved consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Nahuel Chen <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: NarwhalChen <[email protected]>
  • Loading branch information
4 people authored Feb 5, 2025
1 parent 00f2df6 commit 4c5dfc0
Show file tree
Hide file tree
Showing 11 changed files with 116 additions and 44 deletions.
7 changes: 3 additions & 4 deletions backend/src/build-system/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,11 @@ export class BuilderContext {
this.globalContext.set('databaseType', sequence.databaseType || 'SQLite');

const projectUUIDPath =
new Date().toISOString().slice(0, 18).replaceAll(/:/g, '-') +
'-' +
uuidv4();
new Date().toISOString().slice(0, 18).replaceAll(/:/g, '-') +
'-' +
uuidv4();
this.globalContext.set('projectUUID', projectUUIDPath);


if (process.env.DEBUG) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
this.logFolder = path.join(
Expand Down
27 changes: 25 additions & 2 deletions backend/src/chat/chat.model.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { Field, ObjectType, ID, registerEnumType } from '@nestjs/graphql';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Message } from 'src/chat/message.model';
import { SystemBaseModel } from 'src/system-base-model/system-base.model';
import { User } from 'src/user/user.model';
import { Project } from 'src/project/project.model';

export enum StreamStatus {
STREAMING = 'streaming',
Expand Down Expand Up @@ -41,7 +48,23 @@ export class Chat extends SystemBaseModel {
})
messages: Message[];

@ManyToOne(() => User, (user) => user.chats)
@Field(() => ID)
@Column()
projectId: string;

@ManyToOne(() => Project, (project) => project.chats, {
onDelete: 'CASCADE',
nullable: false,
})
@JoinColumn({ name: 'project_id' })
@Field(() => Project)
project: Project;

@ManyToOne(() => User, (user) => user.chats, {
onDelete: 'CASCADE',
nullable: false,
})
@JoinColumn({ name: 'user_id' })
@Field(() => User)
user: User;
}
Expand Down
14 changes: 12 additions & 2 deletions backend/src/project/dto/project.input.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
// DTOs for Project APIs
import { InputType, Field, ID } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUUID, IsOptional } from 'class-validator';

/**
* @deprecated We don't need project upsert
*/
@InputType()
export class UpsertProjectInput {
@Field()
@IsNotEmpty()
@IsString()
projectName: string;

@Field()
@IsNotEmpty()
@IsString()
path: string;

@Field(() => ID, { nullable: true })
projectId: string;
@IsOptional()
@IsUUID()
projectId?: string;

@Field(() => [String], { nullable: true })
projectPackages: string[];
@IsOptional()
@IsString({ each: true })
projectPackages?: string[];
}

@InputType()
Expand Down
18 changes: 15 additions & 3 deletions backend/src/project/project.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import {
} from 'typeorm';
import { User } from 'src/user/user.model';
import { ProjectPackages } from './project-packages.model';
import { Chat } from 'src/chat/chat.model';

@Entity()
@ObjectType()
export class Project extends SystemBaseModel {
@Field(() => ID)
@PrimaryGeneratedColumn()
@PrimaryGeneratedColumn('uuid')
id: string;

@Field()
Expand All @@ -29,10 +30,14 @@ export class Project extends SystemBaseModel {

@Field(() => ID)
@Column()
userId: number;
userId: string;

@ManyToOne(() => User)
@ManyToOne(() => User, (user) => user.projects, {
onDelete: 'CASCADE',
nullable: false,
})
@JoinColumn({ name: 'user_id' })
@Field(() => User)
user: User;

@Field(() => [ProjectPackages], { nullable: true })
Expand All @@ -52,4 +57,11 @@ export class Project extends SystemBaseModel {
},
})
projectPackages: ProjectPackages[];

@Field(() => [Chat], { nullable: true })
@OneToMany(() => Chat, (chat) => chat.project, {
cascade: true,
eager: false,
})
chats: Chat[];
}
32 changes: 24 additions & 8 deletions backend/src/project/project.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
// GraphQL Resolvers for Project APIs
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
Args,
Mutation,
Query,
Resolver,
ResolveField,
Parent,
} from '@nestjs/graphql';
import { ProjectService } from './project.service';
import { Project } from './project.model';
import { CreateProjectInput, IsValidProjectInput } from './dto/project.input';
import { UseGuards } from '@nestjs/common';
import { ProjectGuard } from '../guard/project.guard';
import { GetUserIdFromToken } from '../decorator/get-auth-token.decorator';
import { User } from '../user/user.model';
import { Chat } from '../chat/chat.model';

@Resolver(() => Project)
export class ProjectsResolver {
constructor(private readonly projectsService: ProjectService) {}

@Query(() => [Project])
async getUserProjects(
@GetUserIdFromToken() userId: number,
): Promise<Project[]> {
async getProjects(@GetUserIdFromToken() userId: string): Promise<Project[]> {
return this.projectsService.getProjectsByUser(userId);
}

// @GetAuthToken() token: string
@Query(() => Project)
@UseGuards(ProjectGuard)
async getProjectDetails(
@Args('projectId') projectId: string,
): Promise<Project> {
async getProject(@Args('projectId') projectId: string): Promise<Project> {
return this.projectsService.getProjectById(projectId);
}

Expand All @@ -48,4 +52,16 @@ export class ProjectsResolver {
): Promise<boolean> {
return this.projectsService.isValidProject(userId, input);
}

@ResolveField('user', () => User)
async getUser(@Parent() project: Project): Promise<User> {
const { user } = await this.projectsService.getProjectById(project.id);
return user;
}

@ResolveField('chats', () => [Chat])
async getChats(@Parent() project: Project): Promise<Chat[]> {
const { chats } = await this.projectsService.getProjectById(project.id);
return chats?.filter((chat) => !chat.isDeleted) || [];
}
}
36 changes: 23 additions & 13 deletions backend/src/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,42 +34,51 @@ export class ProjectService {
private projectPackagesRepository: Repository<ProjectPackages>,
) {}

async getProjectsByUser(userId: number): Promise<Project[]> {
async getProjectsByUser(userId: string): Promise<Project[]> {
const projects = await this.projectsRepository.find({
where: { userId: userId, isDeleted: false },
relations: ['projectPackages'],
where: { userId, isDeleted: false },
relations: ['projectPackages', 'chats'],
});

if (projects && projects.length > 0) {
projects.forEach((project) => {
// Filter deleted packages
project.projectPackages = project.projectPackages.filter(
(pkg) => !pkg.isDeleted,
);
// Filter deleted chats
if (project.chats) {
project.chats = project.chats.filter((chat) => !chat.isDeleted);
}
});
}

if (!projects || projects.length === 0) {
throw new NotFoundException(`User with ID ${userId} have no project.`);
throw new NotFoundException(`User with ID ${userId} has no projects.`);
}
return projects;
}

async getProjectById(projectId: string): Promise<Project> {
const project = await this.projectsRepository.findOne({
where: { id: projectId, isDeleted: false },
relations: ['projectPackages'],
relations: ['projectPackages', 'chats', 'user'],
});

if (project) {
project.projectPackages = project.projectPackages.filter(
(pkg) => !pkg.isDeleted,
);
if (project.chats) {
project.chats = project.chats.filter((chat) => !chat.isDeleted);
}
}

if (!project) {
throw new NotFoundException(`Project with ID ${projectId} not found.`);
}
return project;
}

// staring build the project
async createProject(
input: CreateProjectInput,
Expand Down Expand Up @@ -98,7 +107,6 @@ export class ProjectService {
input.projectName = response;
this.logger.debug(`Generated project name: ${input.projectName}`);
}

// Build project sequence and get project path
const sequence = buildProjectSequenceByProject(input);
const context = new BuilderContext(sequence, sequence.id);
Expand All @@ -123,7 +131,6 @@ export class ProjectService {
throw new InternalServerErrorException('Error creating the project.');
}
}

private async transformInputToProjectPackages(
inputPackages: ProjectPackage[],
): Promise<ProjectPackages[]> {
Expand Down Expand Up @@ -175,27 +182,30 @@ export class ProjectService {
async deleteProject(projectId: string): Promise<boolean> {
const project = await this.projectsRepository.findOne({
where: { id: projectId },
relations: ['projectPackages', 'chats'],
});

if (!project) {
throw new NotFoundException(`Project with ID ${projectId} not found.`);
}

try {
// Perform a soft delete by updating is_active and is_deleted fields
// Soft delete the project
project.isActive = false;
project.isDeleted = true;
await this.projectsRepository.save(project);

// Perform a soft delete for related project packages
const projectPackages = project.projectPackages;
if (projectPackages && projectPackages.length > 0) {
for (const pkg of projectPackages) {
// Soft delete related project packages
if (project.projectPackages?.length > 0) {
for (const pkg of project.projectPackages) {
pkg.isActive = false;
pkg.isDeleted = true;
await this.projectPackagesRepository.save(pkg);
}
}

// Note: Related chats will be automatically handled by the CASCADE setting

return true;
} catch (error) {
throw new InternalServerErrorException('Error deleting the project.');
Expand Down
17 changes: 13 additions & 4 deletions backend/src/user/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IsEmail } from 'class-validator';
import { Role } from 'src/auth/role/role.model';
import { SystemBaseModel } from 'src/system-base-model/system-base.model';
import { Chat } from 'src/chat/chat.model';
import { Project } from 'src/project/project.model';
import {
Entity,
PrimaryGeneratedColumn,
Expand All @@ -15,7 +16,7 @@ import {
@Entity()
@ObjectType()
export class User extends SystemBaseModel {
@PrimaryGeneratedColumn()
@PrimaryGeneratedColumn('uuid')
id: string;

@Field()
Expand All @@ -32,12 +33,20 @@ export class User extends SystemBaseModel {

@Field(() => [Chat])
@OneToMany(() => Chat, (chat) => chat.user, {
cascade: true, // Automatically save related chats
lazy: true, // Load chats only when accessed
onDelete: 'CASCADE', // Delete chats when user is deleted
cascade: true,
lazy: true,
onDelete: 'CASCADE',
})
chats: Chat[];

@Field(() => [Project])
@OneToMany(() => Project, (project) => project.user, {
cascade: true,
lazy: true,
onDelete: 'CASCADE',
})
projects: Project[];

@ManyToMany(() => Role)
@JoinTable({
name: 'user_roles',
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"generate:watch": "graphql-codegen --watch"
},
"dependencies": {
"codefox-common": "workspace:*",
"@apollo/client": "^3.11.8",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
Expand Down
1 change: 0 additions & 1 deletion frontend/src/app/(main)/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ export default function Home() {
</div>
);
}

// Render the main layout
return (
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/app/api/project/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export async function GET(req: Request) {
async function fetchFileStructure(projectId) {
const reader = FileReader.getInstance();
const res = await reader.getAllPaths(projectId);

if (!res || res.length === 0) {
return {
root: {
Expand All @@ -42,7 +41,6 @@ async function fetchFileStructure(projectId) {
const cleanedPaths = res.map((path) => path.replace(projectPrefix, ''));

const fileRegex = /\.[a-z0-9]+$/i;

function buildTree(paths) {
const tree = {};

Expand All @@ -68,7 +66,6 @@ async function fetchFileStructure(projectId) {

return tree;
}

function convertTreeToComplexTree(tree, parentId = 'root') {
const items = {};

Expand Down
Loading

0 comments on commit 4c5dfc0

Please sign in to comment.