chore: Install project dependencies.
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
This commit is contained in:
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
backend/src/app.controller.ts
Normal file
12
backend/src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
26
backend/src/app.module.ts
Normal file
26
backend/src/app.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TenantsModule } from './tenants/tenants.module';
|
||||
import { ProjectsModule } from './projects/projects.module';
|
||||
import { Tenant } from './tenants/tenant.entity';
|
||||
import { Project } from './projects/project.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
username: process.env.DB_USERNAME || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_DATABASE || 'evrak',
|
||||
entities: [Tenant, Project],
|
||||
synchronize: true, // Disable in production!
|
||||
}),
|
||||
TenantsModule,
|
||||
ProjectsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
118
backend/src/database/schema.sql
Normal file
118
backend/src/database/schema.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- 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 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 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 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
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- GIST index for fast ltree operations (ancestor/descendant checks)
|
||||
CREATE INDEX projects_path_gist_idx ON projects USING GIST (path);
|
||||
|
||||
-- B-Tree index for path to speed up exact matches and sorting
|
||||
CREATE INDEX projects_path_btree_idx ON projects USING btree (path);
|
||||
|
||||
-- GIN index for JSONB searching
|
||||
CREATE INDEX projects_attributes_gin_idx ON projects USING GIN (attributes);
|
||||
|
||||
-- Tenant ID indexes for RLS performance
|
||||
CREATE INDEX users_tenant_id_idx ON users (tenant_id);
|
||||
CREATE INDEX projects_tenant_id_idx ON projects (tenant_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security (RLS)
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy: Tenants can only see themselves (this might need adjustment based on auth logic,
|
||||
-- usually a superadmin sees all, but for strict isolation:)
|
||||
CREATE POLICY tenant_isolation_policy ON tenants
|
||||
USING (id = current_setting('app.current_tenant')::uuid);
|
||||
|
||||
-- Policy: Users can only access data within their tenant
|
||||
CREATE POLICY user_tenant_isolation_policy ON users
|
||||
USING (tenant_id = current_setting('app.current_tenant')::uuid);
|
||||
|
||||
-- Policy: Projects can only be accessed within the tenant
|
||||
CREATE POLICY project_tenant_isolation_policy ON projects
|
||||
USING (tenant_id = current_setting('app.current_tenant')::uuid);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Helper Function for updating paths (Move Subtree)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- This function updates the path of a project and all its descendants when it is moved.
|
||||
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;
|
||||
8
backend/src/main.ts
Normal file
8
backend/src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
36
backend/src/projects/project.entity.ts
Normal file
36
backend/src/projects/project.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Tenant } from '../tenants/tenant.entity';
|
||||
|
||||
@Entity('projects')
|
||||
export class Project {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'ltree' })
|
||||
path: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
parent_id: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
attributes: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
28
backend/src/projects/projects.controller.ts
Normal file
28
backend/src/projects/projects.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Post, Body, Param, Put } from '@nestjs/common';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { Project } from './project.entity';
|
||||
|
||||
@Controller('projects')
|
||||
export class ProjectsController {
|
||||
constructor(private readonly projectsService: ProjectsService) { }
|
||||
|
||||
@Post()
|
||||
create(@Body() data: Partial<Project>): Promise<Project> {
|
||||
return this.projectsService.create(data);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(): Promise<Project[]> {
|
||||
return this.projectsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string): Promise<Project | null> {
|
||||
return this.projectsService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id/move')
|
||||
move(@Param('id') id: string, @Body('newParentId') newParentId: string | null): Promise<void> {
|
||||
return this.projectsService.moveSubtree(id, newParentId);
|
||||
}
|
||||
}
|
||||
13
backend/src/projects/projects.module.ts
Normal file
13
backend/src/projects/projects.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Project } from './project.entity';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Project])],
|
||||
providers: [ProjectsService],
|
||||
controllers: [ProjectsController],
|
||||
exports: [ProjectsService],
|
||||
})
|
||||
export class ProjectsModule { }
|
||||
42
backend/src/projects/projects.service.ts
Normal file
42
backend/src/projects/projects.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Project } from './project.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private projectsRepository: Repository<Project>,
|
||||
private dataSource: DataSource,
|
||||
) { }
|
||||
|
||||
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:
|
||||
|
||||
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);
|
||||
return this.projectsRepository.save(project);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Project[]> {
|
||||
return this.projectsRepository.find();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Project | null> {
|
||||
return this.projectsRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async moveSubtree(projectId: string, newParentId: string | null): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
'SELECT move_project_subtree($1, $2)',
|
||||
[projectId, newParentId]
|
||||
);
|
||||
}
|
||||
}
|
||||
16
backend/src/tenants/tenant.entity.ts
Normal file
16
backend/src/tenants/tenant.entity.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('tenants')
|
||||
export class Tenant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
23
backend/src/tenants/tenants.controller.ts
Normal file
23
backend/src/tenants/tenants.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { Tenant } from './tenant.entity';
|
||||
|
||||
@Controller('tenants')
|
||||
export class TenantsController {
|
||||
constructor(private readonly tenantsService: TenantsService) { }
|
||||
|
||||
@Post()
|
||||
create(@Body('name') name: string): Promise<Tenant> {
|
||||
return this.tenantsService.create(name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(): Promise<Tenant[]> {
|
||||
return this.tenantsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string): Promise<Tenant | null> {
|
||||
return this.tenantsService.findOne(id);
|
||||
}
|
||||
}
|
||||
13
backend/src/tenants/tenants.module.ts
Normal file
13
backend/src/tenants/tenants.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Tenant } from './tenant.entity';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Tenant])],
|
||||
providers: [TenantsService],
|
||||
controllers: [TenantsController],
|
||||
exports: [TenantsService],
|
||||
})
|
||||
export class TenantsModule { }
|
||||
25
backend/src/tenants/tenants.service.ts
Normal file
25
backend/src/tenants/tenants.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Tenant } from './tenant.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
constructor(
|
||||
@InjectRepository(Tenant)
|
||||
private tenantsRepository: Repository<Tenant>,
|
||||
) { }
|
||||
|
||||
async create(name: string): Promise<Tenant> {
|
||||
const tenant = this.tenantsRepository.create({ name });
|
||||
return this.tenantsRepository.save(tenant);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Tenant[]> {
|
||||
return this.tenantsRepository.find();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Tenant | null> {
|
||||
return this.tenantsRepository.findOneBy({ id });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user