feat: Add full CRUD functionality, project detail panel, and improved UI
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

- Add UPDATE and DELETE endpoints to backend
- Implement project detail panel with comprehensive editing
- Add drag-and-drop functionality for projects in mind map
- Show all projects in map (not just selected + children)
- Fix infinite render loop in MindMap component
- Improve UI spacing and button layouts
- Add local development database schema with RLS disabled
- Update docker-compose for regular docker-compose (not Swarm)
- Add CORS support and nginx API proxying
- Improve button spacing and modern design principles
This commit is contained in:
gitmuhammedalbayrak
2025-11-27 03:18:48 +03:00
parent 066c16221d
commit b9148cfa4b
15 changed files with 1306 additions and 161 deletions

View File

@@ -17,7 +17,7 @@ import { Project } from './projects/project.entity';
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE || 'evrak',
entities: [Tenant, Project],
synchronize: true, // Disable in production!
synchronize: false, // Schema is managed by SQL migration files
}),
TenantsModule,
ProjectsModule,

View File

@@ -0,0 +1,118 @@
-- Local Development Schema (RLS Disabled for easier testing)
-- This is a simplified version for local development
-- Production should use schema.sql with RLS enabled
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "ltree";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- -----------------------------------------------------------------------------
-- Tenants Table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- -----------------------------------------------------------------------------
-- Users Table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (tenant_id, email)
);
-- -----------------------------------------------------------------------------
-- Projects Table (Hierarchical)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Hierarchy using ltree
path ltree NOT NULL,
parent_id UUID REFERENCES projects(id) ON DELETE CASCADE,
-- Flexible metadata
attributes JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- -----------------------------------------------------------------------------
-- Indexes (Create only if they don't exist)
-- -----------------------------------------------------------------------------
-- GIST index for fast ltree operations (ancestor/descendant checks)
CREATE INDEX IF NOT EXISTS projects_path_gist_idx ON projects USING GIST (path);
-- B-Tree index for path to speed up exact matches and sorting
CREATE INDEX IF NOT EXISTS projects_path_btree_idx ON projects USING btree (path);
-- GIN index for JSONB searching
CREATE INDEX IF NOT EXISTS projects_attributes_gin_idx ON projects USING GIN (attributes);
-- Tenant ID indexes for performance
CREATE INDEX IF NOT EXISTS users_tenant_id_idx ON users (tenant_id);
CREATE INDEX IF NOT EXISTS projects_tenant_id_idx ON projects (tenant_id);
-- -----------------------------------------------------------------------------
-- Row Level Security (DISABLED for local development)
-- -----------------------------------------------------------------------------
-- RLS is disabled for local development to simplify testing
-- Production deployments should use schema.sql which enables RLS
ALTER TABLE tenants DISABLE ROW LEVEL SECURITY;
ALTER TABLE users DISABLE ROW LEVEL SECURITY;
ALTER TABLE projects DISABLE ROW LEVEL SECURITY;
-- -----------------------------------------------------------------------------
-- Helper Function for updating paths (Move Subtree)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION move_project_subtree(
p_project_id UUID,
p_new_parent_id UUID
) RETURNS VOID AS $$
DECLARE
v_old_path ltree;
v_new_path ltree;
v_new_parent_path ltree;
BEGIN
-- Get the old path
SELECT path INTO v_old_path FROM projects WHERE id = p_project_id;
-- Calculate new path
IF p_new_parent_id IS NULL THEN
v_new_path := text2ltree(replace(p_project_id::text, '-', '_')); -- Root node
ELSE
SELECT path INTO v_new_parent_path FROM projects WHERE id = p_new_parent_id;
v_new_path := v_new_parent_path || text2ltree(replace(p_project_id::text, '-', '_'));
END IF;
-- Update the project and all descendants
UPDATE projects
SET path = v_new_path || subpath(path, nlevel(v_old_path))
WHERE path <@ v_old_path;
-- Update parent_id for the moved node specifically
UPDATE projects SET parent_id = p_new_parent_id WHERE id = p_project_id;
END;
$$ LANGUAGE plpgsql;
-- -----------------------------------------------------------------------------
-- Create a default tenant for local development
-- -----------------------------------------------------------------------------
INSERT INTO tenants (id, name)
VALUES ('00000000-0000-0000-0000-000000000001', 'Default Local Tenant')
ON CONFLICT (id) DO NOTHING;

View File

@@ -3,6 +3,13 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for frontend
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:80',
credentials: true,
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Param, Put } from '@nestjs/common';
import { Controller, Get, Post, Body, Param, Put, Delete, Patch } from '@nestjs/common';
import { ProjectsService } from './projects.service';
import { Project } from './project.entity';
@@ -21,6 +21,16 @@ export class ProjectsController {
return this.projectsService.findOne(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() data: Partial<Project>): Promise<Project> {
return this.projectsService.update(id, data);
}
@Delete(':id')
remove(@Param('id') id: string): Promise<void> {
return this.projectsService.remove(id);
}
@Put(':id/move')
move(@Param('id') id: string, @Body('newParentId') newParentId: string | null): Promise<void> {
return this.projectsService.moveSubtree(id, newParentId);

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { randomUUID } from 'crypto';
import { Project } from './project.entity';
@Injectable()
@@ -12,16 +13,35 @@ export class ProjectsService {
) { }
async create(data: Partial<Project>): Promise<Project> {
// Logic to calculate path based on parent_id would go here
// For now, assuming path is provided or calculated by caller/DB trigger if we had one
// But typically we calculate it in service:
// Generate UUID if not provided
const projectId = data.id || randomUUID();
// Calculate ltree path based on parent_id
let path: string;
if (!data.parent_id) {
// Root node: path is just the project ID (with dashes replaced by underscores for ltree)
path = projectId.replace(/-/g, '_');
} else {
// Get parent's path and append this project's ID
const parent = await this.projectsRepository.findOne({
where: { id: data.parent_id },
select: ['path'],
});
if (!parent) {
throw new Error(`Parent project with id ${data.parent_id} not found`);
}
// Append this project's ID to parent path
path = `${parent.path}.${projectId.replace(/-/g, '_')}`;
}
let path = data.id ? data.id.replace(/-/g, '_') : 'temp'; // ID is not generated yet...
// Actually, we need to save first to get ID, or generate UUID manually.
// Better to generate UUID in code if we need it for path.
// Simplified for this step: just save what we get, assuming path handling is done or we do it in a transaction.
const project = this.projectsRepository.create(data);
const project = this.projectsRepository.create({
...data,
id: projectId,
path: path as any, // TypeORM will handle ltree conversion
});
return this.projectsRepository.save(project);
}
@@ -33,6 +53,45 @@ export class ProjectsService {
return this.projectsRepository.findOneBy({ id });
}
async update(id: string, data: Partial<Project>): Promise<Project> {
const project = await this.projectsRepository.findOneBy({ id });
if (!project) {
throw new Error(`Project with id ${id} not found`);
}
// If parent_id is being updated, recalculate path
if (data.parent_id !== undefined && data.parent_id !== project.parent_id) {
// Use the move function to handle path recalculation
await this.moveSubtree(id, data.parent_id);
// Remove parent_id from data since moveSubtree handles it
const { parent_id, ...updateData } = data;
Object.assign(project, updateData);
} else {
Object.assign(project, data);
}
return this.projectsRepository.save(project);
}
async remove(id: string): Promise<void> {
const project = await this.projectsRepository.findOneBy({ id });
if (!project) {
throw new Error(`Project with id ${id} not found`);
}
// Check if project has children
const children = await this.projectsRepository
.createQueryBuilder('project')
.where('project.parent_id = :id', { id })
.getCount();
if (children > 0) {
throw new Error('Cannot delete project with children. Please delete or move children first.');
}
await this.projectsRepository.remove(project);
}
async moveSubtree(projectId: string, newParentId: string | null): Promise<void> {
await this.dataSource.query(
'SELECT move_project_subtree($1, $2)',