feat: Add full CRUD functionality, project detail panel, and improved UI
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
118
backend/src/database/schema-local.sql
Normal file
118
backend/src/database/schema-local.sql
Normal 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user