import prisma from "../config/database"; import { generateProjectNumber, previewProjectNumber, releaseProjectNumber, } from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; import type { CreateProjectInput } from "../schemas/projects.schema"; const nasFileManager = new NasFileManager(); const ALLOWED_SORT_FIELDS = [ "id", "project_number", "name", "status", "created_at", ]; interface ListProjectsParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; search: string; status?: string; customer_id?: number; } export async function listProjects(params: ListProjectsParams) { const { page, limit, skip, sort, order, search, status, customer_id } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const where: Record = {}; if (status) where.status = status; if (customer_id) where.customer_id = customer_id; if (search) where.OR = [ { name: { contains: search } }, { project_number: { contains: search } }, ]; const [projects, total] = await Promise.all([ prisma.projects.findMany({ where, skip, take: limit, orderBy: { [sortField]: order }, include: { customers: { select: { id: true, name: true } }, users: { select: { id: true, first_name: true, last_name: true } }, orders: { select: { order_number: true } }, }, }), prisma.projects.count({ where }), ]); const enriched = projects.map(({ customers, users, orders, ...p }) => ({ ...p, customer_name: customers?.name || null, responsible_user_name: users ? `${users.first_name} ${users.last_name}`.trim() : null, order_number: orders?.order_number || null, })); return { data: enriched, total, page, limit }; } export async function getProject(id: number) { const project = await prisma.projects.findUnique({ where: { id }, include: { customers: { select: { id: true, name: true } }, users: { select: { id: true, first_name: true, last_name: true } }, quotations: { select: { id: true, quotation_number: true } }, orders: { select: { id: true, order_number: true, status: true } }, project_notes: { orderBy: { created_at: "desc" } }, }, }); if (!project) return null; const { orders, quotations, customers, users, ...rest } = project; return { ...rest, customer_name: customers?.name ?? null, responsible_user_name: users ? `${users.first_name} ${users.last_name}` : null, order_number: orders?.order_number ?? null, order_status: orders?.status ?? null, quotation_number: quotations?.quotation_number ?? null, has_nas_folder: project.project_number ? nasFileManager.projectFolderExists(project.project_number) : false, }; } export async function updateProject(id: number, body: Record) { const existing = await prisma.projects.findUnique({ where: { id } }); if (!existing) return null; if ( body.project_number !== undefined && String(body.project_number) !== existing.project_number ) { return { error: "Číslo projektu nelze změnit", status: 400 }; } // FK pre-validation (mirrors createProject): a dangling id would raise // P2003 at Prisma and surface as a generic 500 — return the same Czech // 400s as the create path. null still clears the FK. if (body.customer_id != null) { const customer = await prisma.customers.findUnique({ where: { id: Number(body.customer_id) }, select: { id: true }, }); if (!customer) return { error: "Zákazník nenalezen", status: 400 }; } if (body.responsible_user_id != null) { const user = await prisma.users.findUnique({ where: { id: Number(body.responsible_user_id) }, select: { id: true }, }); if (!user) return { error: "Zodpovědná osoba nenalezena", status: 400 }; } if (body.quotation_id != null) { const quotation = await prisma.quotations.findUnique({ where: { id: Number(body.quotation_id) }, select: { id: true }, }); if (!quotation) return { error: "Nabídka nenalezena", status: 400 }; } if (body.order_id != null) { const order = await prisma.orders.findUnique({ where: { id: Number(body.order_id) }, select: { id: true }, }); if (!order) return { error: "Zakázka nenalezena", status: 400 }; } const data: Record = { modified_at: new Date() }; const strFields = ["name", "status", "notes"]; for (const f of strFields) if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; if (body.customer_id !== undefined) data.customer_id = body.customer_id != null ? Number(body.customer_id) : null; if (body.responsible_user_id !== undefined) data.responsible_user_id = body.responsible_user_id != null ? Number(body.responsible_user_id) : null; if (body.quotation_id !== undefined) data.quotation_id = body.quotation_id != null ? Number(body.quotation_id) : null; if (body.order_id !== undefined) data.order_id = body.order_id != null ? Number(body.order_id) : null; if (body.start_date !== undefined) data.start_date = body.start_date ? new Date(String(body.start_date)) : null; if (body.end_date !== undefined) data.end_date = body.end_date ? new Date(String(body.end_date)) : null; const updated = await prisma.projects.update({ where: { id }, data }); if ( body.name !== undefined && existing.name !== body.name && existing.project_number && nasFileManager.isConfigured() ) { const renamed = nasFileManager.renameProjectFolder( existing.project_number, String(body.name || ""), ); if (!renamed) { console.error( "[projects.service] NAS folder not renamed for project", existing.project_number, ); } } return updated; } /** * Create a standalone (manual) project. Takes the next SHARED number — the * same pool orders/projects draw from — generated atomically inside the * transaction (SELECT … FOR UPDATE via getNextSequence). order_id stays null, * which is what marks it "samostatný" in the UI. NAS folder creation is * best-effort (the manager self-guards isConfigured() and swallows fs errors). */ export async function createProject(data: CreateProjectInput) { if (data.customer_id != null) { const customer = await prisma.customers.findUnique({ where: { id: data.customer_id }, }); if (!customer) return { error: "Zákazník nenalezen", status: 400 }; } if (data.responsible_user_id != null) { const user = await prisma.users.findUnique({ where: { id: data.responsible_user_id }, }); if (!user) return { error: "Zodpovědná osoba nenalezena", status: 400 }; } const project = await prisma.$transaction(async (tx) => { const project_number = await generateProjectNumber(tx); return tx.projects.create({ data: { project_number, name: data.name, customer_id: data.customer_id ?? null, responsible_user_id: data.responsible_user_id ?? null, status: data.status || "aktivni", start_date: data.start_date ? new Date(data.start_date) : null, end_date: data.end_date ? new Date(data.end_date) : null, notes: data.notes ?? null, order_id: null, quotation_id: null, }, }); }); if (project.project_number && nasFileManager.isConfigured()) { 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; } /** Preview the next project number for the create form (does NOT consume it). */ export async function getNextProjectNumber(): Promise { return previewProjectNumber(); } export async function deleteProject(id: number, deleteFiles: boolean = false) { const existing = await prisma.projects.findUnique({ where: { id } }); if (!existing) return { error: "not_found" as const }; if (existing.order_id) return { error: "has_order" as const }; // Delete the DB row FIRST, then the NAS folder (mirrors orders.service // ordering). If the DB delete fails the folder is left untouched, so a DB // failure can't orphan the folder; conversely the NAS cleanup is best-effort // and non-fatal — a NAS error must not undo the committed DB delete. await prisma.projects.delete({ where: { id } }); if (deleteFiles && existing.project_number && nasFileManager.isConfigured()) { const ok = await nasFileManager.deleteProjectFolder( existing.project_number, ); if (!ok) { console.error( "[projects.service] NAS folder not deleted for project", existing.project_number, ); } } const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); await releaseProjectNumber(year, existing.project_number ?? undefined); return existing; } export async function createProjectNote( projectId: number, data: { userId: number; firstName: string; lastName: string; content?: string; }, ) { const project = await prisma.projects.findUnique({ where: { id: projectId }, select: { id: true }, }); if (!project) { return { error: "Projekt nenalezen" as const, status: 404 }; } const note = await prisma.project_notes.create({ data: { project_id: projectId, user_id: data.userId, user_name: `${data.firstName} ${data.lastName}`, content: data.content ? String(data.content) : null, }, }); return note; } export async function deleteProjectNote(projectId: number, noteId: number) { const note = await prisma.project_notes.findFirst({ where: { id: noteId, project_id: projectId }, }); if (!note) return null; await prisma.project_notes.delete({ where: { id: noteId } }); return note; } /** * Author-only note edit: a note belongs to whoever wrote it (even admins * edit only their own — editing someone else's words would falsify * authorship; admins delete instead). Stamps edited_at for the UI marker. */ export async function updateProjectNote( projectId: number, noteId: number, userId: number, content: string, ): Promise< | null | { error: string; status: number; note?: never } | { error?: never; status?: never; note: Awaited>; previousContent: string | null; } > { const note = await prisma.project_notes.findFirst({ where: { id: noteId, project_id: projectId }, }); if (!note) return null; if (note.user_id !== userId) { return { error: "Upravit lze pouze vlastní poznámku", status: 403 }; } const updated = await prisma.project_notes.update({ where: { id: noteId }, data: { content, edited_at: new Date() }, }); return { note: updated, previousContent: note.content }; }