From df83eb20918d0651dba3294c057e9cfc7043529c Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 08:43:59 +0200 Subject: [PATCH] feat(projects): auto-create NAS folder for order-spawned projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual project creation already creates the 02_PROJEKTY/_ folder (projects.service.createProject). Projects created from an order — both from an accepted offer (createOrderFromQuotation) and a manual offer-less order (createOrder) — did not, so they had no NAS folder until a user opened the project and clicked "create folder" or uploaded a file. - orders.service: best-effort, non-fatal createProjectFolder after each project-creating transaction commits (mirrors createProject; gated on isConfigured(), logs on failure, never rolls back the order). - projects.service: log when the existing auto-create returns false (was silently discarded — CLAUDE.md known-issue #4). - nas-file-manager: optional constructor basePath seam for tests. - tests: NasFileManager.createProjectFolder coverage — naming, idempotency, Czech diacritics, empty name, not-mounted (152 -> 158 tests). Bump version to 2.0.3. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/__tests__/nas-project-folder.test.ts | 70 ++++++++++++++++++++++++ src/services/nas-file-manager.ts | 8 ++- src/services/orders.service.ts | 54 +++++++++++++++++- src/services/projects.service.ts | 8 ++- 5 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/nas-project-folder.test.ts diff --git a/package.json b/package.json index 86cb56e..8eb40b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "2.0.2", + "version": "2.0.3", "description": "", "main": "dist/server.js", "scripts": { diff --git a/src/__tests__/nas-project-folder.test.ts b/src/__tests__/nas-project-folder.test.ts new file mode 100644 index 0000000..192c363 --- /dev/null +++ b/src/__tests__/nas-project-folder.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { NasFileManager } from "../services/nas-file-manager"; + +/** + * Folder auto-creation primitive used by both the manual project-create path + * (projects.service createProject) and the order→project paths (orders.service + * createOrder / createOrderFromQuotation). Uses an isolated temp dir via the + * constructor seam so it is deterministic and passes even where the real NAS + * (Z:) is not mounted. + */ +describe("NasFileManager.createProjectFolder", () => { + let tmpBase: string; + let nas: NasFileManager; + + beforeEach(() => { + tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-proj-")); + nas = new NasFileManager(tmpBase); + }); + + afterEach(() => { + fs.rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("creates a _ folder for a new project", () => { + const ok = nas.createProjectFolder("26710001", "Rekonstrukce haly"); + expect(ok).toBe(true); + expect( + fs.existsSync(path.join(tmpBase, "26710001_Rekonstrukce_haly")), + ).toBe(true); + }); + + it("is idempotent — a second call does not create a duplicate", () => { + expect(nas.createProjectFolder("26710002", "Hala")).toBe(true); + expect(nas.createProjectFolder("26710002", "Hala")).toBe(true); + const matches = fs + .readdirSync(tmpBase) + .filter((e) => e.startsWith("26710002_")); + expect(matches).toEqual(["26710002_Hala"]); + }); + + it("preserves Czech diacritics (no transliteration)", () => { + expect(nas.createProjectFolder("26710003", "Měření haly")).toBe(true); + expect(fs.existsSync(path.join(tmpBase, "26710003_Měření_haly"))).toBe( + true, + ); + }); + + it("strips illegal characters and collapses spaces", () => { + expect(nas.createProjectFolder("26710004", "A / B: C")).toBe(true); + expect(fs.existsSync(path.join(tmpBase, "26710004_A_B_C"))).toBe(true); + }); + + it("handles an empty name without crashing (trailing underscore)", () => { + expect(nas.createProjectFolder("26710005", "")).toBe(true); + expect(fs.existsSync(path.join(tmpBase, "26710005_"))).toBe(true); + }); + + it("returns false and creates nothing when the NAS base is not mounted", () => { + const missing = new NasFileManager( + path.join(tmpBase, "does-not-exist-subdir"), + ); + expect(missing.createProjectFolder("26710006", "X")).toBe(false); + expect(fs.existsSync(path.join(tmpBase, "does-not-exist-subdir"))).toBe( + false, + ); + }); +}); diff --git a/src/services/nas-file-manager.ts b/src/services/nas-file-manager.ts index 1bcf69a..14e2b69 100644 --- a/src/services/nas-file-manager.ts +++ b/src/services/nas-file-manager.ts @@ -105,8 +105,12 @@ interface DownloadResult { export class NasFileManager { private readonly basePath: string; - constructor() { - this.basePath = path.resolve(config.nas.path).replace(/\\/g, "/"); + constructor(basePath?: string) { + // basePath is an injection seam for tests; production callers pass nothing + // and get the configured NAS_PATH. + this.basePath = path + .resolve(basePath ?? config.nas.path) + .replace(/\\/g, "/"); } public isConfigured(): boolean { diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index d5ab664..2afeb74 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -5,6 +5,11 @@ import { releaseSharedNumber, isOrderNumberTaken, } from "./numbering.service"; +import { NasFileManager } from "./nas-file-manager"; + +// Best-effort NAS folder creation for order-spawned projects, mirroring +// projects.service. Stateless singleton; reads NAS_PATH at construction. +const nasFileManager = new NasFileManager(); interface OrderItemInput { description?: string | null; @@ -262,6 +267,23 @@ export async function createOrderFromQuotation( return { order, project, orderNumber }; }); + // Best-effort NAS project folder, outside the transaction (mirrors + // createProject). A project created from an accepted offer must get the same + // 02_PROJEKTY/_ folder as a manually-created one. Non-fatal: a + // NAS outage must never roll back the order. + if (result.project.project_number && nasFileManager.isConfigured()) { + const created = nasFileManager.createProjectFolder( + result.project.project_number, + result.project.name || "", + ); + if (!created) { + console.error( + "[orders.service] NAS folder not created for project", + result.project.project_number, + ); + } + } + return { data: { order_id: result.order.id, @@ -297,7 +319,7 @@ export async function createOrder( attachmentName?: string | null, ) { try { - return await prisma.$transaction(async (tx) => { + const result = await prisma.$transaction(async (tx) => { const orderNumber = body.order_number !== undefined && body.order_number !== null ? String(body.order_number) @@ -362,6 +384,8 @@ export async function createOrder( } let projectId: number | undefined; + let createdProject: { project_number: string; name: string } | null = + null; // Only auto-create a project for genuine offer-less orders; share the // order's number (same shared pool) exactly like the from-quotation flow. if (body.create_project !== false && body.quotation_id == null) { @@ -375,14 +399,42 @@ export async function createOrder( }, }); projectId = project.id; + if (project.project_number) { + createdProject = { + project_number: project.project_number, + name: project.name || "", + }; + } } return { id: order.id, order_number: order.order_number, project_id: projectId, + createdProject, }; }); + + // Best-effort NAS project folder, outside the transaction (mirrors + // createProject). Non-fatal so a NAS outage can't roll back the order. + if (result.createdProject && nasFileManager.isConfigured()) { + const created = nasFileManager.createProjectFolder( + result.createdProject.project_number, + result.createdProject.name, + ); + if (!created) { + console.error( + "[orders.service] NAS folder not created for project", + result.createdProject.project_number, + ); + } + } + + return { + id: result.id, + order_number: result.order_number, + project_id: result.project_id, + }; } catch (err) { if (err instanceof Error && "status" in err) { return { diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 33fbcf1..73e1c62 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -189,10 +189,16 @@ export async function createProject(data: CreateProjectInput) { }); if (project.project_number && nasFileManager.isConfigured()) { - nasFileManager.createProjectFolder( + const created = nasFileManager.createProjectFolder( project.project_number, project.name || "", ); + if (!created) { + console.error( + "[projects.service] NAS folder not created for project", + project.project_number, + ); + } } return project;