feat: AI Kurzy module, project/service documents, services SQL import

- Add AI Kurzy module with courses, participants, and registrations management
- Add project documents and service documents features
- Add service folders for document organization
- Add SQL import queries for services from firmy.slovensko.ai
- Update todo notifications and group messaging
- Various API improvements and bug fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-21 11:32:49 +01:00
parent d9f16ad0a6
commit 4089bb4be2
37 changed files with 7514 additions and 35 deletions

View File

@@ -31,6 +31,7 @@ import pushRoutes from './routes/push.routes.js';
import userRoutes from './routes/user.routes.js';
import serviceRoutes from './routes/service.routes.js';
import emailSignatureRoutes from './routes/email-signature.routes.js';
import aiKurzyRoutes from './routes/ai-kurzy.routes.js';
const app = express();
@@ -130,6 +131,7 @@ app.use('/api/push', pushRoutes);
app.use('/api/users', userRoutes);
app.use('/api/services', serviceRoutes);
app.use('/api/email-signature', emailSignatureRoutes);
app.use('/api/ai-kurzy', aiKurzyRoutes);
// Basic route
app.get('/', (req, res) => {

View File

@@ -0,0 +1,243 @@
import * as aiKurzyService from '../services/ai-kurzy.service.js';
// ==================== KURZY ====================
export const getAllKurzy = async (req, res, next) => {
try {
const kurzy = await aiKurzyService.getAllKurzy();
res.json({ data: kurzy });
} catch (error) {
next(error);
}
};
export const getKurzById = async (req, res, next) => {
try {
const { kurzId } = req.params;
const kurz = await aiKurzyService.getKurzById(parseInt(kurzId));
res.json({ data: kurz });
} catch (error) {
next(error);
}
};
export const createKurz = async (req, res, next) => {
try {
const kurz = await aiKurzyService.createKurz(req.body);
res.status(201).json({ data: kurz });
} catch (error) {
next(error);
}
};
export const updateKurz = async (req, res, next) => {
try {
const { kurzId } = req.params;
const kurz = await aiKurzyService.updateKurz(parseInt(kurzId), req.body);
res.json({ data: kurz });
} catch (error) {
next(error);
}
};
export const deleteKurz = async (req, res, next) => {
try {
const { kurzId } = req.params;
const result = await aiKurzyService.deleteKurz(parseInt(kurzId));
res.json({ data: result });
} catch (error) {
next(error);
}
};
// ==================== UCASTNICI ====================
export const getAllUcastnici = async (req, res, next) => {
try {
const ucastnici = await aiKurzyService.getAllUcastnici();
res.json({ data: ucastnici });
} catch (error) {
next(error);
}
};
export const getUcastnikById = async (req, res, next) => {
try {
const { ucastnikId } = req.params;
const ucastnik = await aiKurzyService.getUcastnikById(parseInt(ucastnikId));
res.json({ data: ucastnik });
} catch (error) {
next(error);
}
};
export const createUcastnik = async (req, res, next) => {
try {
const ucastnik = await aiKurzyService.createUcastnik(req.body);
res.status(201).json({ data: ucastnik });
} catch (error) {
next(error);
}
};
export const updateUcastnik = async (req, res, next) => {
try {
const { ucastnikId } = req.params;
const ucastnik = await aiKurzyService.updateUcastnik(parseInt(ucastnikId), req.body);
res.json({ data: ucastnik });
} catch (error) {
next(error);
}
};
export const deleteUcastnik = async (req, res, next) => {
try {
const { ucastnikId } = req.params;
const result = await aiKurzyService.deleteUcastnik(parseInt(ucastnikId));
res.json({ data: result });
} catch (error) {
next(error);
}
};
// ==================== REGISTRACIE ====================
export const getAllRegistracie = async (req, res, next) => {
try {
const { kurzId } = req.query;
const registracie = await aiKurzyService.getAllRegistracie(kurzId ? parseInt(kurzId) : null);
res.json({ data: registracie });
} catch (error) {
next(error);
}
};
export const getRegistraciaById = async (req, res, next) => {
try {
const { registraciaId } = req.params;
const registracia = await aiKurzyService.getRegistraciaById(parseInt(registraciaId));
res.json({ data: registracia });
} catch (error) {
next(error);
}
};
export const createRegistracia = async (req, res, next) => {
try {
const registracia = await aiKurzyService.createRegistracia(req.body);
res.status(201).json({ data: registracia });
} catch (error) {
next(error);
}
};
export const updateRegistracia = async (req, res, next) => {
try {
const { registraciaId } = req.params;
const registracia = await aiKurzyService.updateRegistracia(parseInt(registraciaId), req.body);
res.json({ data: registracia });
} catch (error) {
next(error);
}
};
export const deleteRegistracia = async (req, res, next) => {
try {
const { registraciaId } = req.params;
const result = await aiKurzyService.deleteRegistracia(parseInt(registraciaId));
res.json({ data: result });
} catch (error) {
next(error);
}
};
// ==================== COMBINED TABLE (Excel-style) ====================
export const getCombinedTable = async (req, res, next) => {
try {
const data = await aiKurzyService.getCombinedTableData();
res.json({ data });
} catch (error) {
next(error);
}
};
export const updateField = async (req, res, next) => {
try {
const { registraciaId } = req.params;
const { field, value } = req.body;
const result = await aiKurzyService.updateField(parseInt(registraciaId), field, value);
res.json({ data: result });
} catch (error) {
next(error);
}
};
// ==================== PRILOHY (Documents) ====================
export const getPrilohy = async (req, res, next) => {
try {
const { registraciaId } = req.params;
const prilohy = await aiKurzyService.getPrilohyByRegistracia(parseInt(registraciaId));
res.json({ data: prilohy });
} catch (error) {
next(error);
}
};
export const uploadPriloha = async (req, res, next) => {
try {
const { registraciaId } = req.params;
const file = req.file;
if (!file) {
return res.status(400).json({ message: 'Súbor je povinný' });
}
const priloha = await aiKurzyService.createPriloha({
registraciaId: parseInt(registraciaId),
nazovSuboru: file.originalname,
typPrilohy: req.body.typPrilohy || 'ine',
cestaKSuboru: file.path,
mimeType: file.mimetype,
velkostSuboru: file.size,
popis: req.body.popis || null,
});
res.status(201).json({ data: priloha });
} catch (error) {
next(error);
}
};
export const deletePriloha = async (req, res, next) => {
try {
const { prilohaId } = req.params;
const result = await aiKurzyService.deletePriloha(parseInt(prilohaId));
// Optionally delete the file from disk
if (result.filePath) {
const fs = await import('fs/promises');
try {
await fs.unlink(result.filePath);
} catch {
// File might not exist, ignore
}
}
res.json({ data: { success: true } });
} catch (error) {
next(error);
}
};
// ==================== STATISTICS ====================
export const getStats = async (req, res, next) => {
try {
const stats = await aiKurzyService.getKurzyStats();
res.json({ data: stats });
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,88 @@
import * as projectDocumentService from '../services/project-document.service.js';
/**
* Get all documents for a project
* GET /api/projects/:projectId/documents
*/
export const getDocuments = async (req, res, next) => {
try {
const { projectId } = req.params;
const documents = await projectDocumentService.getDocumentsByProjectId(projectId);
res.status(200).json({
success: true,
count: documents.length,
data: documents,
});
} catch (error) {
next(error);
}
};
/**
* Upload a document for a project
* POST /api/projects/:projectId/documents
*/
export const uploadDocument = async (req, res, next) => {
try {
const { projectId } = req.params;
const userId = req.userId;
const { description } = req.body;
const document = await projectDocumentService.uploadDocument({
projectId,
userId,
file: req.file,
description,
});
res.status(201).json({
success: true,
data: document,
message: 'Dokument bol nahraný',
});
} catch (error) {
next(error);
}
};
/**
* Download a document
* GET /api/projects/:projectId/documents/:docId/download
*/
export const downloadDocument = async (req, res, next) => {
try {
const { projectId, docId } = req.params;
const { filePath, fileName, fileType } = await projectDocumentService.getDocumentForDownload(
projectId,
docId
);
res.setHeader('Content-Type', fileType);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
res.sendFile(filePath);
} catch (error) {
next(error);
}
};
/**
* Delete a document
* DELETE /api/projects/:projectId/documents/:docId
*/
export const deleteDocument = async (req, res, next) => {
try {
const { projectId, docId } = req.params;
const result = await projectDocumentService.deleteDocument(projectId, docId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,54 @@
import * as serviceDocumentService from '../services/service-document.service.js';
export const getDocumentsByFolderId = async (req, res, next) => {
try {
const { folderId } = req.params;
const documents = await serviceDocumentService.getDocumentsByFolderId(folderId);
res.json({ data: documents });
} catch (error) {
next(error);
}
};
export const uploadDocument = async (req, res, next) => {
try {
const { folderId } = req.params;
const userId = req.user.id;
const file = req.file;
const { description } = req.body;
const document = await serviceDocumentService.uploadDocument({
folderId,
userId,
file,
description,
});
res.status(201).json({ data: document });
} catch (error) {
next(error);
}
};
export const downloadDocument = async (req, res, next) => {
try {
const { folderId, documentId } = req.params;
const { filePath, fileName, fileType } = await serviceDocumentService.getDocumentForDownload(folderId, documentId);
res.setHeader('Content-Type', fileType);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
res.sendFile(filePath);
} catch (error) {
next(error);
}
};
export const deleteDocument = async (req, res, next) => {
try {
const { folderId, documentId } = req.params;
const result = await serviceDocumentService.deleteDocument(folderId, documentId);
res.json({ data: result });
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,52 @@
import * as serviceFolderService from '../services/service-folder.service.js';
export const getAllFolders = async (req, res, next) => {
try {
const folders = await serviceFolderService.getAllFolders();
res.json({ data: folders });
} catch (error) {
next(error);
}
};
export const getFolderById = async (req, res, next) => {
try {
const { folderId } = req.params;
const folder = await serviceFolderService.getFolderById(folderId);
res.json({ data: folder });
} catch (error) {
next(error);
}
};
export const createFolder = async (req, res, next) => {
try {
const { name } = req.body;
const userId = req.user.id;
const folder = await serviceFolderService.createFolder({ name, userId });
res.status(201).json({ data: folder });
} catch (error) {
next(error);
}
};
export const updateFolder = async (req, res, next) => {
try {
const { folderId } = req.params;
const { name } = req.body;
const folder = await serviceFolderService.updateFolder(folderId, { name });
res.json({ data: folder });
} catch (error) {
next(error);
}
};
export const deleteFolder = async (req, res, next) => {
try {
const { folderId } = req.params;
const result = await serviceFolderService.deleteFolder(folderId);
res.json({ data: result });
} catch (error) {
next(error);
}
};

View File

@@ -231,3 +231,81 @@ export const toggleTodo = async (req, res, next) => {
next(error);
}
};
/**
* Get overdue todos count
* GET /api/todos/overdue-count
*/
export const getOverdueCount = async (req, res, next) => {
try {
const userId = req.userId;
const userRole = req.user?.role;
const count = await todoService.getOverdueCount(userId, userRole);
res.status(200).json({
success: true,
data: { overdueCount: count },
});
} catch (error) {
next(error);
}
};
/**
* Get completed todos created by current user count
* GET /api/todos/completed-by-me
*/
export const getCompletedByMeCount = async (req, res, next) => {
try {
const userId = req.userId;
const count = await todoService.getCompletedByMeCount(userId);
res.status(200).json({
success: true,
data: { completedByMeCount: count },
});
} catch (error) {
next(error);
}
};
/**
* Get combined todo counts for sidebar badges
* GET /api/todos/counts
*/
export const getTodoCounts = async (req, res, next) => {
try {
const userId = req.userId;
const userRole = req.user?.role;
const counts = await todoService.getTodoCounts(userId, userRole);
res.status(200).json({
success: true,
data: counts,
});
} catch (error) {
next(error);
}
};
/**
* Mark all completed todos created by the user as notified
* POST /api/todos/mark-completed-notified
*/
export const markCompletedAsNotified = async (req, res, next) => {
try {
const userId = req.userId;
await todoService.markCompletedAsNotified(userId);
res.status(200).json({
success: true,
message: 'Completed todos marked as notified',
});
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,193 @@
CREATE TYPE "public"."forma_kurzu_enum" AS ENUM('prezencne', 'online', 'hybridne');--> statement-breakpoint
CREATE TYPE "public"."stav_registracie_enum" AS ENUM('potencialny', 'registrovany', 'potvrdeny', 'absolvoval', 'zruseny');--> statement-breakpoint
CREATE TYPE "public"."typ_prilohy_enum" AS ENUM('certifikat', 'faktura', 'prihlaska', 'doklad_o_platbe', 'ine');--> statement-breakpoint
CREATE TABLE "chat_group_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"joined_at" timestamp DEFAULT now() NOT NULL,
"last_read_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "chat_group_member_unique" UNIQUE("group_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "chat_groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_by_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "company_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"file_name" text NOT NULL,
"original_name" text NOT NULL,
"file_path" text NOT NULL,
"file_type" text NOT NULL,
"file_size" integer NOT NULL,
"description" text,
"uploaded_by" uuid,
"uploaded_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "email_signatures" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"full_name" text,
"position" text,
"phone" text,
"email" text,
"company_name" text,
"website" text,
"is_enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "email_signatures_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "group_messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid NOT NULL,
"sender_id" uuid,
"content" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "kurzy" (
"id" serial PRIMARY KEY NOT NULL,
"nazov" varchar(255) NOT NULL,
"typ_kurzu" varchar(100) NOT NULL,
"popis" text,
"cena" numeric(10, 2) NOT NULL,
"max_kapacita" integer,
"aktivny" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "prilohy" (
"id" serial PRIMARY KEY NOT NULL,
"registracia_id" integer NOT NULL,
"nazov_suboru" varchar(255) NOT NULL,
"typ_prilohy" "typ_prilohy_enum" DEFAULT 'ine' NOT NULL,
"cesta_k_suboru" varchar(500) NOT NULL,
"mime_type" varchar(100),
"velkost_suboru" bigint,
"popis" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "project_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"file_name" text NOT NULL,
"original_name" text NOT NULL,
"file_path" text NOT NULL,
"file_type" text NOT NULL,
"file_size" integer NOT NULL,
"description" text,
"uploaded_by" uuid,
"uploaded_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "push_subscription_endpoint_unique" UNIQUE("user_id","endpoint")
);
--> statement-breakpoint
CREATE TABLE "registracie" (
"id" serial PRIMARY KEY NOT NULL,
"kurz_id" integer NOT NULL,
"ucastnik_id" integer NOT NULL,
"datum_od" date,
"datum_do" date,
"forma_kurzu" "forma_kurzu_enum" DEFAULT 'prezencne' NOT NULL,
"pocet_ucastnikov" integer DEFAULT 1 NOT NULL,
"faktura_cislo" varchar(100),
"faktura_vystavena" boolean DEFAULT false NOT NULL,
"zaplatene" boolean DEFAULT false NOT NULL,
"stav" "stav_registracie_enum" DEFAULT 'registrovany' NOT NULL,
"poznamka" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "service_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"folder_id" uuid NOT NULL,
"file_name" text NOT NULL,
"original_name" text NOT NULL,
"file_path" text NOT NULL,
"file_type" text NOT NULL,
"file_size" integer NOT NULL,
"description" text,
"uploaded_by" uuid,
"uploaded_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "service_folders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_by" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "services" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"price" text NOT NULL,
"description" text,
"created_by" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ucastnici" (
"id" serial PRIMARY KEY NOT NULL,
"titul" varchar(50),
"meno" varchar(100) NOT NULL,
"priezvisko" varchar(100) NOT NULL,
"email" varchar(255) NOT NULL,
"telefon" varchar(50),
"firma" varchar(255),
"mesto" varchar(100),
"ulica" varchar(255),
"psc" varchar(10),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ucastnici_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "personal_contacts" ALTER COLUMN "phone" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "todos" ADD COLUMN "completed_notified_at" timestamp;--> statement-breakpoint
ALTER TABLE "chat_group_members" ADD CONSTRAINT "chat_group_members_group_id_chat_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."chat_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_group_members" ADD CONSTRAINT "chat_group_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_groups" ADD CONSTRAINT "chat_groups_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "company_documents" ADD CONSTRAINT "company_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "company_documents" ADD CONSTRAINT "company_documents_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_signatures" ADD CONSTRAINT "email_signatures_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "group_messages" ADD CONSTRAINT "group_messages_group_id_chat_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."chat_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "group_messages" ADD CONSTRAINT "group_messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "prilohy" ADD CONSTRAINT "prilohy_registracia_id_registracie_id_fk" FOREIGN KEY ("registracia_id") REFERENCES "public"."registracie"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_documents" ADD CONSTRAINT "project_documents_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_documents" ADD CONSTRAINT "project_documents_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "registracie" ADD CONSTRAINT "registracie_kurz_id_kurzy_id_fk" FOREIGN KEY ("kurz_id") REFERENCES "public"."kurzy"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "registracie" ADD CONSTRAINT "registracie_ucastnik_id_ucastnici_id_fk" FOREIGN KEY ("ucastnik_id") REFERENCES "public"."ucastnici"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "service_documents" ADD CONSTRAINT "service_documents_folder_id_service_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."service_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "service_documents" ADD CONSTRAINT "service_documents_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "service_folders" ADD CONSTRAINT "service_folders_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "services" ADD CONSTRAINT "services_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "registracie_kurz_ucastnik_idx" ON "registracie" USING btree ("kurz_id","ucastnik_id");--> statement-breakpoint
CREATE UNIQUE INDEX "ucastnici_email_idx" ON "ucastnici" USING btree ("email");

View File

@@ -0,0 +1,8 @@
-- Add lastReadAt column to chat_group_members for tracking unread group messages
ALTER TABLE chat_group_members
ADD COLUMN IF NOT EXISTS last_read_at TIMESTAMP DEFAULT NOW() NOT NULL;
-- Update existing records to have current timestamp as lastReadAt
UPDATE chat_group_members
SET last_read_at = NOW()
WHERE last_read_at IS NULL;

View File

@@ -0,0 +1,17 @@
-- Create project_documents table
CREATE TABLE IF NOT EXISTS project_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
description TEXT,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
uploaded_at TIMESTAMP DEFAULT NOW() NOT NULL,
created_at TIMESTAMP DEFAULT NOW() NOT NULL
);
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_project_documents_project_id ON project_documents(project_id);

View File

@@ -0,0 +1,26 @@
-- Create service_folders table
CREATE TABLE IF NOT EXISTS service_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
);
-- Create service_documents table
CREATE TABLE IF NOT EXISTS service_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
folder_id UUID NOT NULL REFERENCES service_folders(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
description TEXT,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
uploaded_at TIMESTAMP DEFAULT NOW() NOT NULL,
created_at TIMESTAMP DEFAULT NOW() NOT NULL
);
-- Create indexes for faster lookups
CREATE INDEX IF NOT EXISTS idx_service_documents_folder_id ON service_documents(folder_id);

View File

@@ -0,0 +1,88 @@
-- Create enums for AI Courses
DO $$ BEGIN
CREATE TYPE forma_kurzu_enum AS ENUM ('prezencne', 'online', 'hybridne');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE stav_registracie_enum AS ENUM ('potencialny', 'registrovany', 'potvrdeny', 'absolvoval', 'zruseny');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE typ_prilohy_enum AS ENUM ('certifikat', 'faktura', 'prihlaska', 'doklad_o_platbe', 'ine');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Create kurzy table (AI courses)
CREATE TABLE IF NOT EXISTS kurzy (
id SERIAL PRIMARY KEY,
nazov VARCHAR(255) NOT NULL,
typ_kurzu VARCHAR(100) NOT NULL,
popis TEXT,
cena NUMERIC(10, 2) NOT NULL,
datum_od DATE NOT NULL,
datum_do DATE NOT NULL,
max_kapacita INTEGER,
aktivny BOOLEAN DEFAULT TRUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Create ucastnici table (participants)
CREATE TABLE IF NOT EXISTS ucastnici (
id SERIAL PRIMARY KEY,
titul VARCHAR(50),
meno VARCHAR(100) NOT NULL,
priezvisko VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
telefon VARCHAR(50),
firma VARCHAR(255),
mesto VARCHAR(100),
ulica VARCHAR(255),
psc VARCHAR(10),
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ucastnici_email_idx ON ucastnici(email);
-- Create registracie table (registrations)
CREATE TABLE IF NOT EXISTS registracie (
id SERIAL PRIMARY KEY,
kurz_id INTEGER NOT NULL REFERENCES kurzy(id) ON DELETE CASCADE,
ucastnik_id INTEGER NOT NULL REFERENCES ucastnici(id) ON DELETE CASCADE,
forma_kurzu forma_kurzu_enum DEFAULT 'prezencne' NOT NULL,
pocet_ucastnikov INTEGER DEFAULT 1 NOT NULL,
faktura_cislo VARCHAR(100),
faktura_vystavena BOOLEAN DEFAULT FALSE NOT NULL,
zaplatene BOOLEAN DEFAULT FALSE NOT NULL,
stav stav_registracie_enum DEFAULT 'registrovany' NOT NULL,
poznamka TEXT,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS registracie_kurz_ucastnik_idx ON registracie(kurz_id, ucastnik_id);
-- Create prilohy table (attachments)
CREATE TABLE IF NOT EXISTS prilohy (
id SERIAL PRIMARY KEY,
registracia_id INTEGER NOT NULL REFERENCES registracie(id) ON DELETE CASCADE,
nazov_suboru VARCHAR(255) NOT NULL,
typ_prilohy typ_prilohy_enum DEFAULT 'ine' NOT NULL,
cesta_k_suboru VARCHAR(500) NOT NULL,
mime_type VARCHAR(100),
velkost_suboru BIGINT,
popis TEXT,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Create indexes for faster lookups
CREATE INDEX IF NOT EXISTS idx_registracie_kurz_id ON registracie(kurz_id);
CREATE INDEX IF NOT EXISTS idx_registracie_ucastnik_id ON registracie(ucastnik_id);
CREATE INDEX IF NOT EXISTS idx_prilohy_registracia_id ON prilohy(registracia_id);

View File

@@ -0,0 +1,3 @@
-- Add completed_notified_at column to todos table
-- This tracks when the creator was notified about a todo completion
ALTER TABLE todos ADD COLUMN IF NOT EXISTS completed_notified_at TIMESTAMP;

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1768469306890,
"tag": "0000_fat_unus",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1768990516243,
"tag": "0001_living_natasha_romanoff",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { pgTable, text, timestamp, boolean, uuid, pgEnum, unique, integer } from 'drizzle-orm/pg-core';
import { pgTable, text, timestamp, boolean, uuid, pgEnum, unique, integer, serial, varchar, numeric, date, bigint, uniqueIndex } from 'drizzle-orm/pg-core';
// Enums
export const roleEnum = pgEnum('role', ['admin', 'member']);
@@ -196,6 +196,7 @@ export const todos = pgTable('todos', {
priority: todoPriorityEnum('priority').default('medium').notNull(),
dueDate: timestamp('due_date'),
completedAt: timestamp('completed_at'),
completedNotifiedAt: timestamp('completed_notified_at'), // when creator was notified about completion
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
@@ -299,6 +300,21 @@ export const companyDocuments = pgTable('company_documents', {
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Project Documents table - dokumenty nahrané k projektu
export const projectDocuments = pgTable('project_documents', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(),
fileName: text('file_name').notNull(), // unikátny názov súboru na disku
originalName: text('original_name').notNull(), // pôvodný názov súboru
filePath: text('file_path').notNull(), // cesta k súboru
fileType: text('file_type').notNull(), // MIME typ
fileSize: integer('file_size').notNull(), // veľkosť v bytoch
description: text('description'),
uploadedBy: uuid('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
uploadedAt: timestamp('uploaded_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Services table - služby ponúkané firmou
export const services = pgTable('services', {
id: uuid('id').primaryKey().defaultRandom(),
@@ -353,6 +369,7 @@ export const chatGroupMembers = pgTable('chat_group_members', {
groupId: uuid('group_id').references(() => chatGroups.id, { onDelete: 'cascade' }).notNull(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
joinedAt: timestamp('joined_at').defaultNow().notNull(),
lastReadAt: timestamp('last_read_at').defaultNow().notNull(), // Track when user last read group messages
}, (table) => ({
uniqueMember: unique('chat_group_member_unique').on(table.groupId, table.userId),
}));
@@ -377,3 +394,99 @@ export const pushSubscriptions = pgTable('push_subscriptions', {
}, (table) => ({
uniqueEndpoint: unique('push_subscription_endpoint_unique').on(table.userId, table.endpoint),
}));
// Service Folders table - priečinky pre dokumenty služieb
export const serviceFolders = pgTable('service_folders', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Service Documents table - dokumenty v priečinkoch služieb
export const serviceDocuments = pgTable('service_documents', {
id: uuid('id').primaryKey().defaultRandom(),
folderId: uuid('folder_id').references(() => serviceFolders.id, { onDelete: 'cascade' }).notNull(),
fileName: text('file_name').notNull(),
originalName: text('original_name').notNull(),
filePath: text('file_path').notNull(),
fileType: text('file_type').notNull(),
fileSize: integer('file_size').notNull(),
description: text('description'),
uploadedBy: uuid('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
uploadedAt: timestamp('uploaded_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ==================== AI KURZY ====================
// Enums for AI Courses
export const formaKurzuEnum = pgEnum('forma_kurzu_enum', ['prezencne', 'online', 'hybridne']);
export const stavRegistracieEnum = pgEnum('stav_registracie_enum', ['potencialny', 'registrovany', 'potvrdeny', 'absolvoval', 'zruseny']);
export const typPrilohyEnum = pgEnum('typ_prilohy_enum', ['certifikat', 'faktura', 'prihlaska', 'doklad_o_platbe', 'ine']);
// Kurzy table - AI courses definitions (templates)
export const kurzy = pgTable('kurzy', {
id: serial('id').primaryKey(),
nazov: varchar('nazov', { length: 255 }).notNull(),
typKurzu: varchar('typ_kurzu', { length: 100 }).notNull(),
popis: text('popis'),
cena: numeric('cena', { precision: 10, scale: 2 }).notNull(),
maxKapacita: integer('max_kapacita'),
aktivny: boolean('aktivny').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Ucastnici table - course participants
export const ucastnici = pgTable('ucastnici', {
id: serial('id').primaryKey(),
titul: varchar('titul', { length: 50 }),
meno: varchar('meno', { length: 100 }).notNull(),
priezvisko: varchar('priezvisko', { length: 100 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
telefon: varchar('telefon', { length: 50 }),
firma: varchar('firma', { length: 255 }),
mesto: varchar('mesto', { length: 100 }),
ulica: varchar('ulica', { length: 255 }),
psc: varchar('psc', { length: 10 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
emailIdx: uniqueIndex('ucastnici_email_idx').on(table.email),
}));
// Registracie table - course registrations (many-to-many)
export const registracie = pgTable('registracie', {
id: serial('id').primaryKey(),
kurzId: integer('kurz_id').notNull().references(() => kurzy.id, { onDelete: 'cascade' }),
ucastnikId: integer('ucastnik_id').notNull().references(() => ucastnici.id, { onDelete: 'cascade' }),
datumOd: date('datum_od', { mode: 'date' }), // dátum začiatku pre túto registráciu
datumDo: date('datum_do', { mode: 'date' }), // dátum konca pre túto registráciu
formaKurzu: formaKurzuEnum('forma_kurzu').default('prezencne').notNull(),
pocetUcastnikov: integer('pocet_ucastnikov').default(1).notNull(),
fakturaCislo: varchar('faktura_cislo', { length: 100 }),
fakturaVystavena: boolean('faktura_vystavena').default(false).notNull(),
zaplatene: boolean('zaplatene').default(false).notNull(),
stav: stavRegistracieEnum('stav').default('registrovany').notNull(),
poznamka: text('poznamka'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
uniqRegistracia: uniqueIndex('registracie_kurz_ucastnik_idx').on(table.kurzId, table.ucastnikId),
}));
// Prilohy table - attachments for registrations
export const prilohy = pgTable('prilohy', {
id: serial('id').primaryKey(),
registraciaId: integer('registracia_id').notNull().references(() => registracie.id, { onDelete: 'cascade' }),
nazovSuboru: varchar('nazov_suboru', { length: 255 }).notNull(),
typPrilohy: typPrilohyEnum('typ_prilohy').default('ine').notNull(),
cestaKSuboru: varchar('cesta_k_suboru', { length: 500 }).notNull(),
mimeType: varchar('mime_type', { length: 100 }),
velkostSuboru: bigint('velkost_suboru', { mode: 'number' }),
popis: text('popis'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@@ -0,0 +1,303 @@
import dotenv from 'dotenv';
dotenv.config();
import { eq, sql } from 'drizzle-orm';
const { db } = await import('../../config/database.js');
const { kurzy, ucastnici, registracie } = await import('../schema.js');
// Clear existing data
async function clearData() {
console.log('Clearing existing data...');
await db.delete(registracie);
await db.delete(ucastnici);
await db.delete(kurzy);
// Reset sequences
await db.execute(sql`ALTER SEQUENCE kurzy_id_seq RESTART WITH 1`);
await db.execute(sql`ALTER SEQUENCE ucastnici_id_seq RESTART WITH 1`);
await db.execute(sql`ALTER SEQUENCE registracie_id_seq RESTART WITH 1`);
console.log('Data cleared.');
}
// Course data - now without dates (dates are per-registration)
const coursesData = [
{
nazov: 'AI 1+2 (2 dni) - 290€',
typKurzu: 'AI',
cena: '290',
},
{
nazov: 'AI 1 (1 deň) - 150€',
typKurzu: 'AI',
cena: '150',
},
{
nazov: 'AI 2 (1 deň) - 150€',
typKurzu: 'AI',
cena: '150',
},
{
nazov: 'AI v SEO (1 deň) - 150€',
typKurzu: 'SEO',
cena: '150',
},
{
nazov: 'AI I+II Marec 2026',
typKurzu: 'AI',
cena: '290',
},
{
nazov: 'AI I+II Apríl 2026',
typKurzu: 'AI',
cena: '290',
},
];
// Participants data from CSV - dates are now on registration level
const participantsData = [
// Umelá Inteligencia I+II 2. - 3. Február 2026
{
meno: 'Martin',
priezvisko: 'Sovák',
telefon: '0918986172',
email: 'info@energium.sk',
firma: 'energium sro',
formaKurzu: 'prezencne',
kurz: 'AI 1+2 (2 dni) - 290€',
datumOd: new Date('2026-02-02'),
datumDo: new Date('2026-02-03'),
pocetUcastnikov: 1,
mesto: 'Bratislava',
ulica: 'Topolcianska 5',
psc: '85105',
fakturaVystavena: true,
zaplatene: false,
poznamka: 'FA 2026020',
stav: 'registrovany',
},
{
meno: 'Michal',
priezvisko: 'Farkaš',
telefon: '0911209122',
email: 'michal.farkas83@gmail.com',
firma: 'SLOVWELD',
formaKurzu: 'online',
kurz: 'AI 1 (1 deň) - 150€',
datumOd: new Date('2026-02-02'),
datumDo: new Date('2026-02-02'),
pocetUcastnikov: 1,
mesto: 'Dunajska Lužná',
ulica: 'Mandlova 30',
psc: '90042',
fakturaVystavena: true,
zaplatene: true,
poznamka: 'Fa 2025 338, Súhlasil so zmeneným termínom',
stav: 'registrovany',
},
{
meno: 'Alena',
priezvisko: 'Šranková',
telefon: '0917352580',
email: 'alena.srankova@gmail.com',
formaKurzu: 'online',
kurz: 'AI 1+2 (2 dni) - 290€',
datumOd: new Date('2026-02-02'),
datumDo: new Date('2026-02-03'),
pocetUcastnikov: 1,
mesto: 'Bratislava',
ulica: 'Šándorova 1',
psc: '82103',
fakturaVystavena: true,
zaplatene: true,
stav: 'registrovany',
},
{
meno: 'Katarina',
priezvisko: 'Tomaníková',
telefon: '0948 070 611',
email: 'k.tomanikova@riseday.net',
firma: 'Classica Shipping Limited',
formaKurzu: 'prezencne',
kurz: 'AI 1+2 (2 dni) - 290€',
datumOd: new Date('2026-02-02'),
datumDo: new Date('2026-02-03'),
pocetUcastnikov: 1,
mesto: 'Bratislava',
ulica: 'Keltska 104',
psc: '85110',
fakturaVystavena: true,
zaplatene: true,
poznamka: 'presunuta z oktobra, chce až január',
stav: 'registrovany',
},
{
meno: 'Róbert',
priezvisko: 'Brišák',
telefon: '0910583883',
email: 'robert.brisak@ss-nizna.sk',
firma: 'Spojená škola, Hattalova 471, 02743 Nižná',
formaKurzu: 'prezencne',
kurz: 'AI 1+2 (2 dni) - 290€',
datumOd: new Date('2026-02-02'),
datumDo: new Date('2026-02-03'),
pocetUcastnikov: 1,
mesto: 'Nižná',
ulica: 'Hattalova 471',
psc: '02743',
fakturaVystavena: true,
zaplatene: false,
poznamka: 'FA 2026019',
stav: 'registrovany',
},
{
meno: 'Marián',
priezvisko: 'Bača',
telefon: '0907994126',
email: 'baca.marian@gmail.com',
formaKurzu: 'prezencne',
kurz: 'AI 2 (1 deň) - 150€',
datumOd: new Date('2026-02-03'),
datumDo: new Date('2026-02-03'),
pocetUcastnikov: 1,
mesto: 'Petrovany',
ulica: '8',
psc: '08253',
fakturaVystavena: true,
zaplatene: false,
poznamka: 'Fa Gablasova',
stav: 'registrovany',
},
{
titul: 'Mgr. MBA',
meno: 'Nikola',
priezvisko: 'Horáčková',
telefon: '0918482184',
email: 'nikolahorackova11@gmail.com',
kurz: 'AI 1+2 (2 dni) - 290€',
datumOd: new Date('2026-02-02'),
datumDo: new Date('2026-02-03'),
pocetUcastnikov: 1,
mesto: 'Zákopčie',
ulica: 'Zákopčie stred 12',
psc: '023 11',
fakturaVystavena: false,
zaplatene: false,
poznamka: 'vzdelávací poukaz',
stav: 'potencialny',
},
// AI v SEO 13.2.2026
{
meno: 'Tomáš',
priezvisko: 'Kupec',
telefon: '0911030190',
email: 'kupec.tom@gmail.com',
firma: 'Jamajka',
formaKurzu: 'prezencne',
kurz: 'AI v SEO (1 deň) - 150€',
datumOd: new Date('2026-02-13'),
datumDo: new Date('2026-02-13'),
pocetUcastnikov: 1,
mesto: 'Liptovská Sielnica',
psc: '032 23',
fakturaVystavena: true,
zaplatene: false,
poznamka: 'FA 2026021',
stav: 'registrovany',
},
{
meno: 'Anton',
priezvisko: 'Považský',
email: 'anton.povazsky@example.com', // No email in CSV, using placeholder
formaKurzu: 'prezencne',
kurz: 'AI v SEO (1 deň) - 150€',
datumOd: new Date('2026-02-13'),
datumDo: new Date('2026-02-13'),
pocetUcastnikov: 1,
fakturaVystavena: true,
zaplatene: false,
stav: 'registrovany',
},
];
async function importData() {
console.log('Starting import...');
// Create courses (now without dates)
console.log('\nCreating courses...');
const createdKurzy = {};
for (const course of coursesData) {
const [created] = await db.insert(kurzy).values({
nazov: course.nazov,
typKurzu: course.typKurzu,
cena: course.cena,
aktivny: true,
}).returning();
createdKurzy[course.nazov] = created.id;
console.log(` Created course: ${course.nazov} (ID: ${created.id})`);
}
// Create participants and registrations (with dates)
console.log('\nCreating participants and registrations...');
for (const p of participantsData) {
// Check if participant already exists by email
let [existingUcastnik] = await db.select().from(ucastnici).where(eq(ucastnici.email, p.email)).limit(1);
let ucastnikId;
if (existingUcastnik) {
ucastnikId = existingUcastnik.id;
console.log(` Using existing participant: ${p.email}`);
} else {
const [created] = await db.insert(ucastnici).values({
titul: p.titul || null,
meno: p.meno,
priezvisko: p.priezvisko,
email: p.email,
telefon: p.telefon || null,
firma: p.firma || null,
mesto: p.mesto || null,
ulica: p.ulica || null,
psc: p.psc || null,
}).returning();
ucastnikId = created.id;
console.log(` Created participant: ${p.meno} ${p.priezvisko} (${p.email})`);
}
// Get kurz ID
const kurzId = createdKurzy[p.kurz];
if (!kurzId) {
console.error(` ERROR: Course not found: ${p.kurz}`);
continue;
}
// Create registration with dates
await db.insert(registracie).values({
kurzId: kurzId,
ucastnikId: ucastnikId,
datumOd: p.datumOd || null,
datumDo: p.datumDo || null,
formaKurzu: p.formaKurzu || 'prezencne',
pocetUcastnikov: p.pocetUcastnikov || 1,
fakturaVystavena: p.fakturaVystavena || false,
zaplatene: p.zaplatene || false,
stav: p.stav || 'registrovany',
poznamka: p.poznamka || null,
});
console.log(` Created registration for ${p.email} -> ${p.kurz} (${p.datumOd?.toLocaleDateString('sk-SK')} - ${p.datumDo?.toLocaleDateString('sk-SK')})`);
}
console.log('\n=== Import completed ===');
console.log(`Courses: ${coursesData.length}`);
console.log(`Participants: ${participantsData.length}`);
}
// Run
clearData()
.then(() => importData())
.then(() => {
console.log('Done!');
process.exit(0);
})
.catch((error) => {
console.error('Import failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,292 @@
import dotenv from 'dotenv';
dotenv.config();
import ExcelJS from 'exceljs';
import { eq, and } from 'drizzle-orm';
import path from 'path';
// Dynamic imports to ensure env is loaded first
const { db } = await import('../../config/database.js');
const { kurzy, ucastnici, registracie } = await import('../schema.js');
const EXCEL_FILE = '/home/richardtekula/Downloads/Copy of AI školenie študenti.xlsx';
// Helper to parse dates from various formats
const parseDate = (value) => {
if (!value) return null;
if (value instanceof Date) return value;
if (typeof value === 'number') {
// Excel serial date number
const date = new Date((value - 25569) * 86400 * 1000);
return date;
}
if (typeof value === 'string') {
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
return null;
};
// Helper to clean string values
const cleanString = (value) => {
if (value === null || value === undefined) return null;
const str = String(value).trim();
return str === '' ? null : str;
};
// Helper to parse numeric value
const parseNumber = (value) => {
if (value === null || value === undefined) return null;
const num = parseFloat(value);
return isNaN(num) ? null : num;
};
// Map stav from Excel to our enum values
const mapStav = (value) => {
if (!value) return 'registrovany';
const v = String(value).toLowerCase().trim();
if (v.includes('absolvoval')) return 'absolvoval';
if (v.includes('potvrden')) return 'potvrdeny';
if (v.includes('zrusen')) return 'zruseny';
if (v.includes('potencial')) return 'potencialny';
return 'registrovany';
};
// Map forma kurzu
const mapForma = (value) => {
if (!value) return 'prezencne';
const v = String(value).toLowerCase().trim();
if (v.includes('online')) return 'online';
if (v.includes('hybrid')) return 'hybridne';
return 'prezencne';
};
async function importAiKurzy() {
console.log('Reading Excel file:', EXCEL_FILE);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(EXCEL_FILE);
console.log('Sheets in workbook:', workbook.worksheets.map(ws => ws.name));
// Process each sheet
for (const worksheet of workbook.worksheets) {
console.log(`\n=== Processing sheet: ${worksheet.name} ===`);
console.log(`Rows: ${worksheet.rowCount}, Columns: ${worksheet.columnCount}`);
// Get headers from first row
const headerRow = worksheet.getRow(1);
const headers = [];
headerRow.eachCell((cell, colNum) => {
headers[colNum] = cleanString(cell.value);
});
console.log('Headers:', headers.filter(Boolean));
// Collect data rows
const dataRows = [];
worksheet.eachRow((row, rowNum) => {
if (rowNum === 1) return; // Skip header
const rowData = {};
row.eachCell((cell, colNum) => {
const header = headers[colNum];
if (header) {
rowData[header] = cell.value;
}
});
// Only add if row has some data
if (Object.values(rowData).some(v => v !== null && v !== undefined && v !== '')) {
dataRows.push(rowData);
}
});
console.log(`Found ${dataRows.length} data rows`);
// Log first few rows to understand structure
if (dataRows.length > 0) {
console.log('Sample row:', JSON.stringify(dataRows[0], null, 2));
}
// Try to import data based on headers
await importSheetData(worksheet.name, headers, dataRows);
}
console.log('\n=== Import completed ===');
}
async function importSheetData(sheetName, headers, rows) {
// Detect what kind of data this is based on headers
const headerLower = headers.map(h => h?.toLowerCase() || '');
const hasKurzFields = headerLower.some(h => h.includes('kurz') || h.includes('datum') || h.includes('cena'));
const hasUcastnikFields = headerLower.some(h => h.includes('meno') || h.includes('email') || h.includes('priezvisko'));
if (rows.length === 0) {
console.log('No data to import');
return;
}
// Import participants and registrations
if (hasUcastnikFields) {
await importParticipantsAndRegistrations(sheetName, headers, rows);
}
}
async function importParticipantsAndRegistrations(sheetName, headers, rows) {
console.log(`\nImporting participants from sheet: ${sheetName}`);
// First, ensure we have a course for this sheet
const courseName = sheetName;
let course = await db.select().from(kurzy).where(eq(kurzy.nazov, courseName)).limit(1);
if (course.length === 0) {
// Create course from sheet name
const [newCourse] = await db.insert(kurzy).values({
nazov: courseName,
typKurzu: extractCourseType(sheetName),
cena: '0', // Will need to update manually
datumOd: new Date(),
datumDo: new Date(),
aktivny: true,
}).returning();
course = [newCourse];
console.log(`Created course: ${courseName} (ID: ${newCourse.id})`);
} else {
console.log(`Using existing course: ${courseName} (ID: ${course[0].id})`);
}
const kurzId = course[0].id;
// Map headers to our fields
const headerMap = {};
headers.forEach((header, idx) => {
if (!header) return;
const h = header.toLowerCase();
if (h.includes('titul') || h === 'titul') headerMap.titul = idx;
if (h.includes('meno') && !h.includes('priezvisko')) headerMap.meno = idx;
if (h.includes('priezvisko') || h === 'surname' || h === 'priezvisko') headerMap.priezvisko = idx;
if (h.includes('email') || h.includes('e-mail')) headerMap.email = idx;
if (h.includes('telefon') || h.includes('phone') || h.includes('tel')) headerMap.telefon = idx;
if (h.includes('firma') || h.includes('company') || h.includes('spolocnost')) headerMap.firma = idx;
if (h.includes('mesto') || h.includes('city')) headerMap.mesto = idx;
if (h.includes('ulica') || h.includes('street') || h.includes('adresa')) headerMap.ulica = idx;
if (h.includes('psc') || h.includes('zip') || h.includes('postal')) headerMap.psc = idx;
if (h.includes('stav') || h.includes('status')) headerMap.stav = idx;
if (h.includes('forma') || h.includes('form')) headerMap.forma = idx;
if (h.includes('faktur') && h.includes('cislo')) headerMap.fakturaCislo = idx;
if (h.includes('faktur') && h.includes('vystaven')) headerMap.fakturaVystavena = idx;
if (h.includes('zaplaten') || h.includes('paid')) headerMap.zaplatene = idx;
if (h.includes('poznam') || h.includes('note')) headerMap.poznamka = idx;
if (h.includes('pocet') || h.includes('count')) headerMap.pocetUcastnikov = idx;
});
console.log('Field mapping:', headerMap);
let importedCount = 0;
let skippedCount = 0;
for (const row of rows) {
try {
// Get email - required field
const email = cleanString(row[headers[headerMap.email]] || Object.values(row).find(v => String(v).includes('@')));
if (!email || !email.includes('@')) {
skippedCount++;
continue;
}
// Check if participant exists
let participant = await db.select().from(ucastnici).where(eq(ucastnici.email, email)).limit(1);
if (participant.length === 0) {
// Try to find name fields
let meno = cleanString(row[headers[headerMap.meno]]);
let priezvisko = cleanString(row[headers[headerMap.priezvisko]]);
// If no separate fields, try to split full name
if (!meno && !priezvisko) {
// Look for a name-like field
for (const [key, value] of Object.entries(row)) {
const val = cleanString(value);
if (val && !val.includes('@') && !val.includes('http') && val.length < 50) {
const parts = val.split(/\s+/);
if (parts.length >= 2) {
meno = parts[0];
priezvisko = parts.slice(1).join(' ');
break;
}
}
}
}
// Create participant
const [newParticipant] = await db.insert(ucastnici).values({
titul: cleanString(row[headers[headerMap.titul]]),
meno: meno || 'N/A',
priezvisko: priezvisko || 'N/A',
email: email,
telefon: cleanString(row[headers[headerMap.telefon]]),
firma: cleanString(row[headers[headerMap.firma]]),
mesto: cleanString(row[headers[headerMap.mesto]]),
ulica: cleanString(row[headers[headerMap.ulica]]),
psc: cleanString(row[headers[headerMap.psc]]),
}).returning();
participant = [newParticipant];
console.log(`Created participant: ${email}`);
}
const ucastnikId = participant[0].id;
// Check if registration exists
const existingReg = await db.select()
.from(registracie)
.where(and(eq(registracie.kurzId, kurzId), eq(registracie.ucastnikId, ucastnikId)))
.limit(1);
if (existingReg.length === 0) {
// Create registration
await db.insert(registracie).values({
kurzId: kurzId,
ucastnikId: ucastnikId,
formaKurzu: mapForma(row[headers[headerMap.forma]]),
pocetUcastnikov: parseInt(row[headers[headerMap.pocetUcastnikov]]) || 1,
fakturaCislo: cleanString(row[headers[headerMap.fakturaCislo]]),
fakturaVystavena: Boolean(row[headers[headerMap.fakturaVystavena]]),
zaplatene: Boolean(row[headers[headerMap.zaplatene]]),
stav: mapStav(row[headers[headerMap.stav]]),
poznamka: cleanString(row[headers[headerMap.poznamka]]),
});
importedCount++;
} else {
console.log(`Registration already exists for ${email} in ${sheetName}`);
skippedCount++;
}
} catch (error) {
console.error(`Error processing row:`, error.message);
skippedCount++;
}
}
console.log(`Imported ${importedCount} registrations, skipped ${skippedCount}`);
}
function extractCourseType(sheetName) {
const name = sheetName.toLowerCase();
if (name.includes('ai 1') || name.includes('ai1')) return 'AI 1';
if (name.includes('ai 2') || name.includes('ai2')) return 'AI 2';
if (name.includes('seo')) return 'SEO';
if (name.includes('marketing')) return 'Marketing';
return 'AI';
}
// Run the import
importAiKurzy()
.then(() => {
console.log('Import finished successfully');
process.exit(0);
})
.catch((error) => {
console.error('Import failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,232 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import * as aiKurzyController from '../controllers/ai-kurzy.controller.js';
import { authenticate } from '../middlewares/auth/authMiddleware.js';
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
import { validateBody, validateParams, validateQuery } from '../middlewares/security/validateInput.js';
import { z } from 'zod';
const router = express.Router();
// Configure multer for file uploads
const uploadsDir = path.join(process.cwd(), 'uploads', 'ai-kurzy');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + file.originalname);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
});
// Validation schemas
const kurzIdSchema = z.object({
kurzId: z.string().regex(/^\d+$/),
});
const ucastnikIdSchema = z.object({
ucastnikId: z.string().regex(/^\d+$/),
});
const registraciaIdSchema = z.object({
registraciaId: z.string().regex(/^\d+$/),
});
const createKurzSchema = z.object({
nazov: z.string().min(1).max(255),
typKurzu: z.string().min(1).max(100),
popis: z.string().optional().nullable(),
cena: z.string().or(z.number()),
maxKapacita: z.number().int().positive().optional().nullable(),
aktivny: z.boolean().optional(),
});
const updateKurzSchema = createKurzSchema.partial();
const createUcastnikSchema = z.object({
titul: z.string().max(50).optional().nullable(),
meno: z.string().min(1).max(100),
priezvisko: z.string().min(1).max(100),
email: z.string().email().max(255),
telefon: z.string().max(50).optional().nullable(),
firma: z.string().max(255).optional().nullable(),
mesto: z.string().max(100).optional().nullable(),
ulica: z.string().max(255).optional().nullable(),
psc: z.string().max(10).optional().nullable(),
});
const updateUcastnikSchema = createUcastnikSchema.partial();
const createRegistraciaSchema = z.object({
kurzId: z.number().int().positive(),
ucastnikId: z.number().int().positive(),
datumOd: z.string().optional().nullable(),
datumDo: z.string().optional().nullable(),
formaKurzu: z.enum(['prezencne', 'online', 'hybridne']).optional(),
pocetUcastnikov: z.number().int().positive().optional(),
fakturaCislo: z.string().max(100).optional().nullable(),
fakturaVystavena: z.boolean().optional(),
zaplatene: z.boolean().optional(),
stav: z.enum(['potencialny', 'registrovany', 'potvrdeny', 'absolvoval', 'zruseny']).optional(),
poznamka: z.string().optional().nullable(),
});
const updateRegistraciaSchema = createRegistraciaSchema.partial();
const registracieQuerySchema = z.object({
kurzId: z.string().regex(/^\d+$/).optional(),
});
// All routes require authentication and admin role
router.use(authenticate);
router.use(requireAdmin);
// ==================== STATISTICS ====================
router.get('/stats', aiKurzyController.getStats);
// ==================== KURZY ====================
router.get('/kurzy', aiKurzyController.getAllKurzy);
router.post(
'/kurzy',
validateBody(createKurzSchema),
aiKurzyController.createKurz
);
router.get(
'/kurzy/:kurzId',
validateParams(kurzIdSchema),
aiKurzyController.getKurzById
);
router.put(
'/kurzy/:kurzId',
validateParams(kurzIdSchema),
validateBody(updateKurzSchema),
aiKurzyController.updateKurz
);
router.delete(
'/kurzy/:kurzId',
validateParams(kurzIdSchema),
aiKurzyController.deleteKurz
);
// ==================== UCASTNICI ====================
router.get('/ucastnici', aiKurzyController.getAllUcastnici);
router.post(
'/ucastnici',
validateBody(createUcastnikSchema),
aiKurzyController.createUcastnik
);
router.get(
'/ucastnici/:ucastnikId',
validateParams(ucastnikIdSchema),
aiKurzyController.getUcastnikById
);
router.put(
'/ucastnici/:ucastnikId',
validateParams(ucastnikIdSchema),
validateBody(updateUcastnikSchema),
aiKurzyController.updateUcastnik
);
router.delete(
'/ucastnici/:ucastnikId',
validateParams(ucastnikIdSchema),
aiKurzyController.deleteUcastnik
);
// ==================== REGISTRACIE ====================
router.get(
'/registracie',
validateQuery(registracieQuerySchema),
aiKurzyController.getAllRegistracie
);
router.post(
'/registracie',
validateBody(createRegistraciaSchema),
aiKurzyController.createRegistracia
);
router.get(
'/registracie/:registraciaId',
validateParams(registraciaIdSchema),
aiKurzyController.getRegistraciaById
);
router.put(
'/registracie/:registraciaId',
validateParams(registraciaIdSchema),
validateBody(updateRegistraciaSchema),
aiKurzyController.updateRegistracia
);
router.delete(
'/registracie/:registraciaId',
validateParams(registraciaIdSchema),
aiKurzyController.deleteRegistracia
);
// ==================== COMBINED TABLE (Excel-style) ====================
router.get('/table', aiKurzyController.getCombinedTable);
const updateFieldSchema = z.object({
field: z.string(),
value: z.any(),
});
router.patch(
'/table/:registraciaId/field',
validateParams(registraciaIdSchema),
validateBody(updateFieldSchema),
aiKurzyController.updateField
);
// ==================== PRILOHY (Documents) ====================
const prilohaIdSchema = z.object({
prilohaId: z.string().regex(/^\d+$/),
});
router.get(
'/registracie/:registraciaId/prilohy',
validateParams(registraciaIdSchema),
aiKurzyController.getPrilohy
);
router.post(
'/registracie/:registraciaId/prilohy',
validateParams(registraciaIdSchema),
upload.single('file'),
aiKurzyController.uploadPriloha
);
router.delete(
'/prilohy/:prilohaId',
validateParams(prilohaIdSchema),
aiKurzyController.deletePriloha
);
export default router;

View File

@@ -1,5 +1,7 @@
import express from 'express';
import multer from 'multer';
import * as projectController from '../controllers/project.controller.js';
import * as projectDocumentController from '../controllers/project-document.controller.js';
import { authenticate } from '../middlewares/auth/authMiddleware.js';
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
import { checkProjectAccess } from '../middlewares/auth/resourceAccessMiddleware.js';
@@ -7,6 +9,14 @@ import { validateBody, validateParams } from '../middlewares/security/validateIn
import { createProjectSchema, updateProjectSchema } from '../validators/crm.validators.js';
import { z } from 'zod';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB max
},
});
const router = express.Router();
// All project routes require authentication
@@ -136,4 +146,40 @@ router.delete(
projectController.removeUserFromProject
);
// Project Documents
router.get(
'/:projectId/documents',
validateParams(z.object({ projectId: z.string().uuid() })),
checkProjectAccess,
projectDocumentController.getDocuments
);
router.post(
'/:projectId/documents',
validateParams(z.object({ projectId: z.string().uuid() })),
checkProjectAccess,
upload.single('file'),
projectDocumentController.uploadDocument
);
router.get(
'/:projectId/documents/:docId/download',
validateParams(z.object({
projectId: z.string().uuid(),
docId: z.string().uuid()
})),
checkProjectAccess,
projectDocumentController.downloadDocument
);
router.delete(
'/:projectId/documents/:docId',
requireAdmin,
validateParams(z.object({
projectId: z.string().uuid(),
docId: z.string().uuid()
})),
projectDocumentController.deleteDocument
);
export default router;

View File

@@ -1,5 +1,8 @@
import express from 'express';
import multer from 'multer';
import * as serviceController from '../controllers/service.controller.js';
import * as serviceFolderController from '../controllers/service-folder.controller.js';
import * as serviceDocumentController from '../controllers/service-document.controller.js';
import { authenticate } from '../middlewares/auth/authMiddleware.js';
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
@@ -8,27 +11,129 @@ import { z } from 'zod';
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
});
const serviceIdSchema = z.object({
serviceId: z.string().uuid(),
});
const folderIdSchema = z.object({
folderId: z.string().uuid(),
});
const folderDocumentIdSchema = z.object({
folderId: z.string().uuid(),
documentId: z.string().uuid(),
});
const createFolderSchema = z.object({
name: z.string().min(1).max(255),
});
const updateFolderSchema = z.object({
name: z.string().min(1).max(255),
});
// All service routes require authentication
router.use(authenticate);
// ==================== SERVICE FOLDERS (must be before :serviceId routes) ====================
/**
* GET /api/services/folders - Get all folders (all authenticated users)
*/
router.get('/folders', serviceFolderController.getAllFolders);
/**
* POST /api/services/folders - Create new folder (admin only)
*/
router.post(
'/folders',
requireAdmin,
validateBody(createFolderSchema),
serviceFolderController.createFolder
);
/**
* GET /api/services/folders/:folderId - Get folder by ID (all authenticated users)
*/
router.get(
'/folders/:folderId',
validateParams(folderIdSchema),
serviceFolderController.getFolderById
);
/**
* PUT /api/services/folders/:folderId - Update folder (admin only)
*/
router.put(
'/folders/:folderId',
requireAdmin,
validateParams(folderIdSchema),
validateBody(updateFolderSchema),
serviceFolderController.updateFolder
);
/**
* DELETE /api/services/folders/:folderId - Delete folder (admin only)
*/
router.delete(
'/folders/:folderId',
requireAdmin,
validateParams(folderIdSchema),
serviceFolderController.deleteFolder
);
// ==================== SERVICE DOCUMENTS ====================
/**
* GET /api/services/folders/:folderId/documents - Get all documents in folder
*/
router.get(
'/folders/:folderId/documents',
validateParams(folderIdSchema),
serviceDocumentController.getDocumentsByFolderId
);
/**
* POST /api/services/folders/:folderId/documents - Upload document to folder
*/
router.post(
'/folders/:folderId/documents',
validateParams(folderIdSchema),
upload.single('file'),
serviceDocumentController.uploadDocument
);
/**
* GET /api/services/folders/:folderId/documents/:documentId/download - Download document
*/
router.get(
'/folders/:folderId/documents/:documentId/download',
validateParams(folderDocumentIdSchema),
serviceDocumentController.downloadDocument
);
/**
* DELETE /api/services/folders/:folderId/documents/:documentId - Delete document (admin only)
*/
router.delete(
'/folders/:folderId/documents/:documentId',
requireAdmin,
validateParams(folderDocumentIdSchema),
serviceDocumentController.deleteDocument
);
// ==================== SERVICES ====================
/**
* GET /api/services - Get all services (all authenticated users)
*/
router.get('/', serviceController.getAllServices);
/**
* GET /api/services/:serviceId - Get service by ID (all authenticated users)
*/
router.get(
'/:serviceId',
validateParams(serviceIdSchema),
serviceController.getServiceById
);
/**
* POST /api/services - Create new service (admin only)
*/
@@ -39,6 +144,15 @@ router.post(
serviceController.createService
);
/**
* GET /api/services/:serviceId - Get service by ID (all authenticated users)
*/
router.get(
'/:serviceId',
validateParams(serviceIdSchema),
serviceController.getServiceById
);
/**
* PUT /api/services/:serviceId - Update service (admin only)
*/

View File

@@ -19,6 +19,18 @@ router.use(authenticate);
// Get all todos
router.get('/', todoController.getAllTodos);
// Get combined todo counts (overdue + completed by me) for sidebar badges
router.get('/counts', todoController.getTodoCounts);
// Get overdue todos count
router.get('/overdue-count', todoController.getOverdueCount);
// Get completed todos created by current user
router.get('/completed-by-me', todoController.getCompletedByMeCount);
// Mark completed todos as notified (when user opens Todos page)
router.post('/mark-completed-notified', todoController.markCompletedAsNotified);
// Get todo by ID
router.get(
'/:todoId',

View File

@@ -0,0 +1,414 @@
import { db } from '../config/database.js';
import { kurzy, ucastnici, registracie, prilohy } from '../db/schema.js';
import { and, desc, eq, sql, asc } from 'drizzle-orm';
import { NotFoundError } from '../utils/errors.js';
// ==================== KURZY (Courses) ====================
export const getAllKurzy = async () => {
const result = await db
.select({
id: kurzy.id,
nazov: kurzy.nazov,
typKurzu: kurzy.typKurzu,
popis: kurzy.popis,
cena: kurzy.cena,
maxKapacita: kurzy.maxKapacita,
aktivny: kurzy.aktivny,
createdAt: kurzy.createdAt,
registraciiCount: sql`(SELECT COUNT(*) FROM registracie WHERE kurz_id = ${kurzy.id})::int`,
})
.from(kurzy)
.orderBy(asc(kurzy.nazov));
return result;
};
export const getKurzById = async (id) => {
const [kurz] = await db
.select()
.from(kurzy)
.where(eq(kurzy.id, id))
.limit(1);
if (!kurz) {
throw new NotFoundError('Kurz nenájdený');
}
return kurz;
};
export const createKurz = async (data) => {
const [newKurz] = await db
.insert(kurzy)
.values({
nazov: data.nazov,
typKurzu: data.typKurzu,
popis: data.popis || null,
cena: data.cena,
maxKapacita: data.maxKapacita || null,
aktivny: data.aktivny !== undefined ? data.aktivny : true,
})
.returning();
return newKurz;
};
export const updateKurz = async (id, data) => {
await getKurzById(id);
const updateData = { ...data, updatedAt: new Date() };
const [updated] = await db
.update(kurzy)
.set(updateData)
.where(eq(kurzy.id, id))
.returning();
return updated;
};
export const deleteKurz = async (id) => {
await getKurzById(id);
await db.delete(kurzy).where(eq(kurzy.id, id));
return { success: true, message: 'Kurz bol odstránený' };
};
// ==================== UCASTNICI (Participants) ====================
export const getAllUcastnici = async () => {
const result = await db
.select({
id: ucastnici.id,
titul: ucastnici.titul,
meno: ucastnici.meno,
priezvisko: ucastnici.priezvisko,
email: ucastnici.email,
telefon: ucastnici.telefon,
firma: ucastnici.firma,
mesto: ucastnici.mesto,
ulica: ucastnici.ulica,
psc: ucastnici.psc,
createdAt: ucastnici.createdAt,
registraciiCount: sql`(SELECT COUNT(*) FROM registracie WHERE ucastnik_id = ${ucastnici.id})::int`,
})
.from(ucastnici)
.orderBy(asc(ucastnici.priezvisko), asc(ucastnici.meno));
return result;
};
export const getUcastnikById = async (id) => {
const [ucastnik] = await db
.select()
.from(ucastnici)
.where(eq(ucastnici.id, id))
.limit(1);
if (!ucastnik) {
throw new NotFoundError('Účastník nenájdený');
}
return ucastnik;
};
export const createUcastnik = async (data) => {
const [newUcastnik] = await db
.insert(ucastnici)
.values({
titul: data.titul || null,
meno: data.meno,
priezvisko: data.priezvisko,
email: data.email,
telefon: data.telefon || null,
firma: data.firma || null,
mesto: data.mesto || null,
ulica: data.ulica || null,
psc: data.psc || null,
})
.returning();
return newUcastnik;
};
export const updateUcastnik = async (id, data) => {
await getUcastnikById(id);
const [updated] = await db
.update(ucastnici)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(ucastnici.id, id))
.returning();
return updated;
};
export const deleteUcastnik = async (id) => {
await getUcastnikById(id);
await db.delete(ucastnici).where(eq(ucastnici.id, id));
return { success: true, message: 'Účastník bol odstránený' };
};
// ==================== REGISTRACIE (Registrations) ====================
export const getAllRegistracie = async (kurzId = null) => {
const conditions = kurzId ? [eq(registracie.kurzId, kurzId)] : [];
const result = await db
.select({
id: registracie.id,
kurzId: registracie.kurzId,
ucastnikId: registracie.ucastnikId,
datumOd: registracie.datumOd,
datumDo: registracie.datumDo,
formaKurzu: registracie.formaKurzu,
pocetUcastnikov: registracie.pocetUcastnikov,
fakturaCislo: registracie.fakturaCislo,
fakturaVystavena: registracie.fakturaVystavena,
zaplatene: registracie.zaplatene,
stav: registracie.stav,
poznamka: registracie.poznamka,
createdAt: registracie.createdAt,
// Kurz info
kurzNazov: kurzy.nazov,
kurzTyp: kurzy.typKurzu,
// Ucastnik info
ucastnikMeno: ucastnici.meno,
ucastnikPriezvisko: ucastnici.priezvisko,
ucastnikEmail: ucastnici.email,
ucastnikFirma: ucastnici.firma,
})
.from(registracie)
.leftJoin(kurzy, eq(registracie.kurzId, kurzy.id))
.leftJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(registracie.datumOd), desc(registracie.createdAt));
return result;
};
export const getRegistraciaById = async (id) => {
const [reg] = await db
.select({
id: registracie.id,
kurzId: registracie.kurzId,
ucastnikId: registracie.ucastnikId,
formaKurzu: registracie.formaKurzu,
pocetUcastnikov: registracie.pocetUcastnikov,
fakturaCislo: registracie.fakturaCislo,
fakturaVystavena: registracie.fakturaVystavena,
zaplatene: registracie.zaplatene,
stav: registracie.stav,
poznamka: registracie.poznamka,
createdAt: registracie.createdAt,
kurzNazov: kurzy.nazov,
kurzTyp: kurzy.typKurzu,
ucastnikMeno: ucastnici.meno,
ucastnikPriezvisko: ucastnici.priezvisko,
ucastnikEmail: ucastnici.email,
})
.from(registracie)
.leftJoin(kurzy, eq(registracie.kurzId, kurzy.id))
.leftJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id))
.where(eq(registracie.id, id))
.limit(1);
if (!reg) {
throw new NotFoundError('Registrácia nenájdená');
}
return reg;
};
export const createRegistracia = async (data) => {
const [newReg] = await db
.insert(registracie)
.values({
kurzId: data.kurzId,
ucastnikId: data.ucastnikId,
datumOd: data.datumOd ? new Date(data.datumOd) : null,
datumDo: data.datumDo ? new Date(data.datumDo) : null,
formaKurzu: data.formaKurzu || 'prezencne',
pocetUcastnikov: data.pocetUcastnikov || 1,
fakturaCislo: data.fakturaCislo || null,
fakturaVystavena: data.fakturaVystavena || false,
zaplatene: data.zaplatene || false,
stav: data.stav || 'registrovany',
poznamka: data.poznamka || null,
})
.returning();
return newReg;
};
export const updateRegistracia = async (id, data) => {
await getRegistraciaById(id);
const [updated] = await db
.update(registracie)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(registracie.id, id))
.returning();
return updated;
};
export const deleteRegistracia = async (id) => {
await getRegistraciaById(id);
await db.delete(registracie).where(eq(registracie.id, id));
return { success: true, message: 'Registrácia bola odstránená' };
};
// ==================== COMBINED TABLE VIEW (Excel-style) ====================
export const getCombinedTableData = async () => {
const result = await db
.select({
// Registration ID (main row identifier)
id: registracie.id,
// Ucastnik fields
ucastnikId: ucastnici.id,
titul: ucastnici.titul,
meno: ucastnici.meno,
priezvisko: ucastnici.priezvisko,
email: ucastnici.email,
telefon: ucastnici.telefon,
firma: ucastnici.firma,
mesto: ucastnici.mesto,
ulica: ucastnici.ulica,
psc: ucastnici.psc,
// Kurz fields
kurzId: kurzy.id,
kurzNazov: kurzy.nazov,
kurzTyp: kurzy.typKurzu,
// Registration fields (dates are now here)
datumOd: registracie.datumOd,
datumDo: registracie.datumDo,
formaKurzu: registracie.formaKurzu,
pocetUcastnikov: registracie.pocetUcastnikov,
fakturaCislo: registracie.fakturaCislo,
fakturaVystavena: registracie.fakturaVystavena,
zaplatene: registracie.zaplatene,
stav: registracie.stav,
poznamka: registracie.poznamka,
createdAt: registracie.createdAt,
// Document count
dokumentyCount: sql`(SELECT COUNT(*) FROM prilohy WHERE registracia_id = ${registracie.id})::int`,
})
.from(registracie)
.innerJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id))
.innerJoin(kurzy, eq(registracie.kurzId, kurzy.id))
.orderBy(desc(registracie.datumOd), desc(registracie.createdAt));
return result;
};
// Update a single field (for inline editing)
export const updateField = async (registrationId, field, value) => {
// Determine which table to update based on the field
const ucastnikFields = ['titul', 'meno', 'priezvisko', 'email', 'telefon', 'firma', 'mesto', 'ulica', 'psc'];
const registraciaFields = ['datumOd', 'datumDo', 'formaKurzu', 'pocetUcastnikov', 'fakturaCislo', 'fakturaVystavena', 'zaplatene', 'stav', 'poznamka', 'kurzId'];
const dateFields = ['datumOd', 'datumDo'];
// Get the registration to find ucastnikId
const [reg] = await db
.select({ ucastnikId: registracie.ucastnikId })
.from(registracie)
.where(eq(registracie.id, registrationId))
.limit(1);
if (!reg) {
throw new NotFoundError('Registrácia nenájdená');
}
// Convert date strings to Date objects
let processedValue = value;
if (dateFields.includes(field)) {
processedValue = value ? new Date(value) : null;
}
if (ucastnikFields.includes(field)) {
// Update ucastnik table
await db
.update(ucastnici)
.set({ [field]: processedValue, updatedAt: new Date() })
.where(eq(ucastnici.id, reg.ucastnikId));
} else if (registraciaFields.includes(field)) {
// Update registracie table
await db
.update(registracie)
.set({ [field]: processedValue, updatedAt: new Date() })
.where(eq(registracie.id, registrationId));
} else {
throw new Error(`Unknown field: ${field}`);
}
return { success: true };
};
// ==================== PRILOHY (Documents) ====================
export const getPrilohyByRegistracia = async (registraciaId) => {
const result = await db
.select()
.from(prilohy)
.where(eq(prilohy.registraciaId, registraciaId))
.orderBy(desc(prilohy.createdAt));
return result;
};
export const createPriloha = async (data) => {
const [newPriloha] = await db
.insert(prilohy)
.values({
registraciaId: data.registraciaId,
nazovSuboru: data.nazovSuboru,
typPrilohy: data.typPrilohy || 'ine',
cestaKSuboru: data.cestaKSuboru,
mimeType: data.mimeType || null,
velkostSuboru: data.velkostSuboru || null,
popis: data.popis || null,
})
.returning();
return newPriloha;
};
export const deletePriloha = async (id) => {
const [priloha] = await db
.select()
.from(prilohy)
.where(eq(prilohy.id, id))
.limit(1);
if (!priloha) {
throw new NotFoundError('Príloha nenájdená');
}
await db.delete(prilohy).where(eq(prilohy.id, id));
return { success: true, filePath: priloha.cestaKSuboru };
};
// ==================== STATISTICS ====================
export const getKurzyStats = async () => {
const [stats] = await db
.select({
totalKurzy: sql`(SELECT COUNT(*) FROM kurzy)::int`,
aktivneKurzy: sql`(SELECT COUNT(*) FROM kurzy WHERE aktivny = true)::int`,
totalUcastnici: sql`(SELECT COUNT(*) FROM ucastnici)::int`,
totalRegistracie: sql`(SELECT COUNT(*) FROM registracie)::int`,
zaplateneRegistracie: sql`(SELECT COUNT(*) FROM registracie WHERE zaplatene = true)::int`,
absolvovaneRegistracie: sql`(SELECT COUNT(*) FROM registracie WHERE stav = 'absolvoval')::int`,
})
.from(sql`(SELECT 1) AS dummy`);
return stats;
};

View File

@@ -1,6 +1,6 @@
import { db } from '../config/database.js';
import { chatGroups, chatGroupMembers, groupMessages, users } from '../db/schema.js';
import { eq, and, desc, inArray, sql } from 'drizzle-orm';
import { eq, and, desc, inArray, sql, gt } from 'drizzle-orm';
import { NotFoundError, ForbiddenError } from '../utils/errors.js';
import { sendPushNotificationToUsers } from './push.service.js';
import { logger } from '../utils/logger.js';
@@ -34,15 +34,22 @@ export const createGroup = async (name, creatorId, memberIds) => {
* Get all groups for a user
*/
export const getUserGroups = async (userId) => {
// Get groups where user is a member
// Get groups where user is a member with lastReadAt
const memberOf = await db
.select({ groupId: chatGroupMembers.groupId })
.select({
groupId: chatGroupMembers.groupId,
lastReadAt: chatGroupMembers.lastReadAt,
})
.from(chatGroupMembers)
.where(eq(chatGroupMembers.userId, userId));
if (memberOf.length === 0) return [];
const groupIds = memberOf.map((m) => m.groupId);
const lastReadMap = memberOf.reduce((acc, m) => {
acc[m.groupId] = m.lastReadAt;
return acc;
}, {});
const groups = await db
.select({
@@ -56,27 +63,41 @@ export const getUserGroups = async (userId) => {
.where(inArray(chatGroups.id, groupIds))
.orderBy(desc(chatGroups.updatedAt));
// Get last message and member count for each group
// Get last message, member count, and unread count for each group
const result = await Promise.all(
groups.map(async (group) => {
const lastMessage = await db
.select({
content: groupMessages.content,
createdAt: groupMessages.createdAt,
senderId: groupMessages.senderId,
senderFirstName: users.firstName,
senderUsername: users.username,
})
.from(groupMessages)
.leftJoin(users, eq(groupMessages.senderId, users.id))
.where(eq(groupMessages.groupId, group.id))
.orderBy(desc(groupMessages.createdAt))
.limit(1);
const lastReadAt = lastReadMap[group.id];
const members = await db
.select({ id: chatGroupMembers.id })
.from(chatGroupMembers)
.where(eq(chatGroupMembers.groupId, group.id));
const [lastMessage, members, unreadResult] = await Promise.all([
db
.select({
content: groupMessages.content,
createdAt: groupMessages.createdAt,
senderId: groupMessages.senderId,
senderFirstName: users.firstName,
senderUsername: users.username,
})
.from(groupMessages)
.leftJoin(users, eq(groupMessages.senderId, users.id))
.where(eq(groupMessages.groupId, group.id))
.orderBy(desc(groupMessages.createdAt))
.limit(1),
db
.select({ id: chatGroupMembers.id })
.from(chatGroupMembers)
.where(eq(chatGroupMembers.groupId, group.id)),
// Count unread messages (messages after lastReadAt, not sent by current user)
db
.select({ count: sql`count(*)::int` })
.from(groupMessages)
.where(
and(
eq(groupMessages.groupId, group.id),
gt(groupMessages.createdAt, lastReadAt),
sql`${groupMessages.senderId} != ${userId}`
)
),
]);
return {
...group,
@@ -87,6 +108,7 @@ export const getUserGroups = async (userId) => {
isMine: lastMessage[0].senderId === userId,
} : null,
memberCount: members.length,
unreadCount: unreadResult[0]?.count || 0,
type: 'group',
};
})
@@ -163,6 +185,17 @@ export const getGroupMessages = async (groupId, userId) => {
throw new ForbiddenError('Nie ste členom tejto skupiny');
}
// Update lastReadAt for this user in this group
await db
.update(chatGroupMembers)
.set({ lastReadAt: new Date() })
.where(
and(
eq(chatGroupMembers.groupId, groupId),
eq(chatGroupMembers.userId, userId)
)
);
const messages = await db
.select({
id: groupMessages.id,
@@ -402,3 +435,38 @@ export const deleteGroup = async (groupId, requesterId) => {
return { success: true };
};
/**
* Get total unread group messages count for a user
*/
export const getGroupUnreadCount = async (userId) => {
// Get all groups user is a member of with lastReadAt
const memberOf = await db
.select({
groupId: chatGroupMembers.groupId,
lastReadAt: chatGroupMembers.lastReadAt,
})
.from(chatGroupMembers)
.where(eq(chatGroupMembers.userId, userId));
if (memberOf.length === 0) return 0;
// Count unread messages across all groups
let totalUnread = 0;
for (const membership of memberOf) {
const result = await db
.select({ count: sql`count(*)::int` })
.from(groupMessages)
.where(
and(
eq(groupMessages.groupId, membership.groupId),
gt(groupMessages.createdAt, membership.lastReadAt),
sql`${groupMessages.senderId} != ${userId}`
)
);
totalUnread += result[0]?.count || 0;
}
return totalUnread;
};

View File

@@ -4,6 +4,7 @@ import { eq, and, or, desc, ne, sql } from 'drizzle-orm';
import { NotFoundError } from '../utils/errors.js';
import { sendPushNotification } from './push.service.js';
import { logger } from '../utils/logger.js';
import { getGroupUnreadCount } from './group.service.js';
/**
* Get all conversations for a user
@@ -251,9 +252,9 @@ export const deleteConversation = async (userId, partnerId) => {
};
/**
* Get unread message count for a user
* Get unread message count for a user (direct messages only)
*/
export const getUnreadCount = async (userId) => {
export const getDirectUnreadCount = async (userId) => {
const result = await db
.select({ count: sql`count(*)::int` })
.from(messages)
@@ -268,6 +269,22 @@ export const getUnreadCount = async (userId) => {
return result[0]?.count || 0;
};
/**
* Get combined unread message count for a user (direct + group)
*/
export const getUnreadCount = async (userId) => {
const [directCount, groupCount] = await Promise.all([
getDirectUnreadCount(userId),
getGroupUnreadCount(userId),
]);
return {
directCount,
groupCount,
total: directCount + groupCount,
};
};
/**
* Get all CRM users available for chat (excluding current user)
*/

View File

@@ -0,0 +1,166 @@
import fs from 'fs/promises';
import path from 'path';
import { db } from '../config/database.js';
import { projectDocuments, projects, users } from '../db/schema.js';
import { and, desc, eq } from 'drizzle-orm';
import { BadRequestError, NotFoundError } from '../utils/errors.js';
import { logger } from '../utils/logger.js';
const BASE_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'project-documents');
const buildDestinationPath = (projectId, originalName) => {
const ext = path.extname(originalName);
const name = path.basename(originalName, ext);
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const filename = `${name}-${uniqueSuffix}${ext}`;
const folder = path.join(BASE_UPLOAD_DIR, projectId);
const filePath = path.join(folder, filename);
return { folder, filename, filePath };
};
const safeUnlink = async (filePath) => {
if (!filePath) return;
try {
await fs.unlink(filePath);
} catch (error) {
logger.error('Failed to delete file', error);
}
};
const ensureProjectExists = async (projectId) => {
const [project] = await db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (!project) {
throw new NotFoundError('Projekt nenájdený');
}
return project;
};
/**
* Get all documents for a project
*/
export const getDocumentsByProjectId = async (projectId) => {
await ensureProjectExists(projectId);
return db
.select({
id: projectDocuments.id,
projectId: projectDocuments.projectId,
fileName: projectDocuments.fileName,
originalName: projectDocuments.originalName,
fileType: projectDocuments.fileType,
fileSize: projectDocuments.fileSize,
description: projectDocuments.description,
uploadedAt: projectDocuments.uploadedAt,
uploadedBy: projectDocuments.uploadedBy,
uploaderUsername: users.username,
uploaderFirstName: users.firstName,
uploaderLastName: users.lastName,
})
.from(projectDocuments)
.leftJoin(users, eq(projectDocuments.uploadedBy, users.id))
.where(eq(projectDocuments.projectId, projectId))
.orderBy(desc(projectDocuments.uploadedAt));
};
/**
* Upload a document for a project
*/
export const uploadDocument = async ({ projectId, userId, file, description }) => {
if (!file) {
throw new BadRequestError('Súbor nebol nahraný');
}
await ensureProjectExists(projectId);
const { folder, filename, filePath } = buildDestinationPath(projectId, file.originalname);
await fs.mkdir(folder, { recursive: true });
try {
await fs.writeFile(filePath, file.buffer);
const [newDoc] = await db
.insert(projectDocuments)
.values({
projectId,
fileName: filename,
originalName: file.originalname,
filePath,
fileType: file.mimetype,
fileSize: file.size,
description: description || null,
uploadedBy: userId,
})
.returning();
return newDoc;
} catch (error) {
await safeUnlink(filePath);
throw error;
}
};
/**
* Get document by ID for download
*/
export const getDocumentForDownload = async (projectId, documentId) => {
await ensureProjectExists(projectId);
const [doc] = await db
.select()
.from(projectDocuments)
.where(and(
eq(projectDocuments.id, documentId),
eq(projectDocuments.projectId, projectId)
))
.limit(1);
if (!doc) {
throw new NotFoundError('Dokument nenájdený');
}
try {
await fs.access(doc.filePath);
} catch {
throw new NotFoundError('Súbor nebol nájdený na serveri');
}
return {
document: doc,
filePath: doc.filePath,
fileName: doc.originalName,
fileType: doc.fileType,
};
};
/**
* Delete a document
*/
export const deleteDocument = async (projectId, documentId) => {
await ensureProjectExists(projectId);
const [doc] = await db
.select()
.from(projectDocuments)
.where(and(
eq(projectDocuments.id, documentId),
eq(projectDocuments.projectId, projectId)
))
.limit(1);
if (!doc) {
throw new NotFoundError('Dokument nenájdený');
}
await safeUnlink(doc.filePath);
await db.delete(projectDocuments).where(eq(projectDocuments.id, documentId));
return { success: true, message: 'Dokument bol odstránený' };
};

View File

@@ -0,0 +1,166 @@
import fs from 'fs/promises';
import path from 'path';
import { db } from '../config/database.js';
import { serviceDocuments, serviceFolders, users } from '../db/schema.js';
import { and, desc, eq } from 'drizzle-orm';
import { BadRequestError, NotFoundError } from '../utils/errors.js';
import { logger } from '../utils/logger.js';
const BASE_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'service-documents');
const buildDestinationPath = (folderId, originalName) => {
const ext = path.extname(originalName);
const name = path.basename(originalName, ext);
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const filename = `${name}-${uniqueSuffix}${ext}`;
const folder = path.join(BASE_UPLOAD_DIR, folderId);
const filePath = path.join(folder, filename);
return { folder, filename, filePath };
};
const safeUnlink = async (filePath) => {
if (!filePath) return;
try {
await fs.unlink(filePath);
} catch (error) {
logger.error('Failed to delete file', error);
}
};
const ensureFolderExists = async (folderId) => {
const [folder] = await db
.select()
.from(serviceFolders)
.where(eq(serviceFolders.id, folderId))
.limit(1);
if (!folder) {
throw new NotFoundError('Priečinok nenájdený');
}
return folder;
};
/**
* Get all documents in a folder
*/
export const getDocumentsByFolderId = async (folderId) => {
await ensureFolderExists(folderId);
return db
.select({
id: serviceDocuments.id,
folderId: serviceDocuments.folderId,
fileName: serviceDocuments.fileName,
originalName: serviceDocuments.originalName,
fileType: serviceDocuments.fileType,
fileSize: serviceDocuments.fileSize,
description: serviceDocuments.description,
uploadedAt: serviceDocuments.uploadedAt,
uploadedBy: serviceDocuments.uploadedBy,
uploaderUsername: users.username,
uploaderFirstName: users.firstName,
uploaderLastName: users.lastName,
})
.from(serviceDocuments)
.leftJoin(users, eq(serviceDocuments.uploadedBy, users.id))
.where(eq(serviceDocuments.folderId, folderId))
.orderBy(desc(serviceDocuments.uploadedAt));
};
/**
* Upload a document to a folder
*/
export const uploadDocument = async ({ folderId, userId, file, description }) => {
if (!file) {
throw new BadRequestError('Súbor nebol nahraný');
}
await ensureFolderExists(folderId);
const { folder, filename, filePath } = buildDestinationPath(folderId, file.originalname);
await fs.mkdir(folder, { recursive: true });
try {
await fs.writeFile(filePath, file.buffer);
const [newDoc] = await db
.insert(serviceDocuments)
.values({
folderId,
fileName: filename,
originalName: file.originalname,
filePath,
fileType: file.mimetype,
fileSize: file.size,
description: description || null,
uploadedBy: userId,
})
.returning();
return newDoc;
} catch (error) {
await safeUnlink(filePath);
throw error;
}
};
/**
* Get document by ID for download
*/
export const getDocumentForDownload = async (folderId, documentId) => {
await ensureFolderExists(folderId);
const [doc] = await db
.select()
.from(serviceDocuments)
.where(and(
eq(serviceDocuments.id, documentId),
eq(serviceDocuments.folderId, folderId)
))
.limit(1);
if (!doc) {
throw new NotFoundError('Dokument nenájdený');
}
try {
await fs.access(doc.filePath);
} catch {
throw new NotFoundError('Súbor nebol nájdený na serveri');
}
return {
document: doc,
filePath: doc.filePath,
fileName: doc.originalName,
fileType: doc.fileType,
};
};
/**
* Delete a document
*/
export const deleteDocument = async (folderId, documentId) => {
await ensureFolderExists(folderId);
const [doc] = await db
.select()
.from(serviceDocuments)
.where(and(
eq(serviceDocuments.id, documentId),
eq(serviceDocuments.folderId, folderId)
))
.limit(1);
if (!doc) {
throw new NotFoundError('Dokument nenájdený');
}
await safeUnlink(doc.filePath);
await db.delete(serviceDocuments).where(eq(serviceDocuments.id, documentId));
return { success: true, message: 'Dokument bol odstránený' };
};

View File

@@ -0,0 +1,112 @@
import { db } from '../config/database.js';
import { serviceFolders, serviceDocuments, users } from '../db/schema.js';
import { desc, eq, sql } from 'drizzle-orm';
import { NotFoundError } from '../utils/errors.js';
/**
* Get all service folders with document counts
*/
export const getAllFolders = async () => {
const folders = await db
.select({
id: serviceFolders.id,
name: serviceFolders.name,
createdAt: serviceFolders.createdAt,
updatedAt: serviceFolders.updatedAt,
createdBy: serviceFolders.createdBy,
creatorUsername: users.username,
creatorFirstName: users.firstName,
documentCount: sql`(SELECT COUNT(*) FROM service_documents WHERE folder_id = ${serviceFolders.id})::int`,
})
.from(serviceFolders)
.leftJoin(users, eq(serviceFolders.createdBy, users.id))
.orderBy(desc(serviceFolders.createdAt));
return folders;
};
/**
* Get folder by ID
*/
export const getFolderById = async (folderId) => {
const [folder] = await db
.select({
id: serviceFolders.id,
name: serviceFolders.name,
createdAt: serviceFolders.createdAt,
updatedAt: serviceFolders.updatedAt,
createdBy: serviceFolders.createdBy,
creatorUsername: users.username,
creatorFirstName: users.firstName,
})
.from(serviceFolders)
.leftJoin(users, eq(serviceFolders.createdBy, users.id))
.where(eq(serviceFolders.id, folderId))
.limit(1);
if (!folder) {
throw new NotFoundError('Priečinok nenájdený');
}
return folder;
};
/**
* Create a new folder
*/
export const createFolder = async ({ name, userId }) => {
const [newFolder] = await db
.insert(serviceFolders)
.values({
name,
createdBy: userId,
})
.returning();
return newFolder;
};
/**
* Update folder name
*/
export const updateFolder = async (folderId, { name }) => {
const [existing] = await db
.select()
.from(serviceFolders)
.where(eq(serviceFolders.id, folderId))
.limit(1);
if (!existing) {
throw new NotFoundError('Priečinok nenájdený');
}
const [updated] = await db
.update(serviceFolders)
.set({
name,
updatedAt: new Date(),
})
.where(eq(serviceFolders.id, folderId))
.returning();
return updated;
};
/**
* Delete a folder (cascade deletes documents)
*/
export const deleteFolder = async (folderId) => {
const [existing] = await db
.select()
.from(serviceFolders)
.where(eq(serviceFolders.id, folderId))
.limit(1);
if (!existing) {
throw new NotFoundError('Priečinok nenájdený');
}
await db.delete(serviceFolders).where(eq(serviceFolders.id, folderId));
return { success: true, message: 'Priečinok bol odstránený' };
};

View File

@@ -1,6 +1,6 @@
import { db } from '../config/database.js';
import { todos, todoUsers, notes, projects, companies, users } from '../db/schema.js';
import { eq, desc, ilike, or, and, inArray } from 'drizzle-orm';
import { eq, desc, ilike, or, and, inArray, lt, ne, sql } from 'drizzle-orm';
import { NotFoundError } from '../utils/errors.js';
import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js';
import { sendPushNotificationToUsers } from './push.service.js';
@@ -471,3 +471,90 @@ export const getTodosByUserId = async (userId) => {
.where(inArray(todos.id, todoIds))
.orderBy(desc(todos.createdAt));
};
/**
* Get count of overdue todos for a user
* Overdue = dueDate < now AND status !== 'completed'
* For members: only counts todos they are assigned to
*/
export const getOverdueCount = async (userId, userRole) => {
const now = new Date();
// Get accessible todo IDs for non-admin users
let accessibleTodoIds = null;
if (userRole && userRole !== 'admin') {
accessibleTodoIds = await getAccessibleResourceIds('todo', userId);
if (accessibleTodoIds.length === 0) {
return 0;
}
}
const conditions = [
lt(todos.dueDate, now),
ne(todos.status, 'completed'),
];
if (accessibleTodoIds !== null) {
conditions.push(inArray(todos.id, accessibleTodoIds));
}
const result = await db
.select({ count: sql`count(*)::int` })
.from(todos)
.where(and(...conditions));
return result[0]?.count || 0;
};
/**
* Get count of todos created by user that were completed but not yet notified
* Returns todos where createdBy = userId AND status = 'completed' AND completedNotifiedAt IS NULL
*/
export const getCompletedByMeCount = async (userId) => {
const result = await db
.select({ count: sql`count(*)::int` })
.from(todos)
.where(
and(
eq(todos.createdBy, userId),
eq(todos.status, 'completed'),
sql`${todos.completedNotifiedAt} IS NULL`
)
);
return result[0]?.count || 0;
};
/**
* Mark all completed todos created by user as notified
* Called when user opens the Todos page
*/
export const markCompletedAsNotified = async (userId) => {
await db
.update(todos)
.set({ completedNotifiedAt: new Date() })
.where(
and(
eq(todos.createdBy, userId),
eq(todos.status, 'completed'),
sql`${todos.completedNotifiedAt} IS NULL`
)
);
return { success: true };
};
/**
* Get combined todo counts for sidebar badges
*/
export const getTodoCounts = async (userId, userRole) => {
const [overdueCount, completedByMeCount] = await Promise.all([
getOverdueCount(userId, userRole),
getCompletedByMeCount(userId),
]);
return {
overdueCount,
completedByMeCount,
};
};