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:
@@ -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) => {
|
||||
|
||||
243
src/controllers/ai-kurzy.controller.js
Normal file
243
src/controllers/ai-kurzy.controller.js
Normal 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);
|
||||
}
|
||||
};
|
||||
88
src/controllers/project-document.controller.js
Normal file
88
src/controllers/project-document.controller.js
Normal 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);
|
||||
}
|
||||
};
|
||||
54
src/controllers/service-document.controller.js
Normal file
54
src/controllers/service-document.controller.js
Normal 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);
|
||||
}
|
||||
};
|
||||
52
src/controllers/service-folder.controller.js
Normal file
52
src/controllers/service-folder.controller.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
193
src/db/migrations/0001_living_natasha_romanoff.sql
Normal file
193
src/db/migrations/0001_living_natasha_romanoff.sql
Normal 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");
|
||||
8
src/db/migrations/0003_add_group_last_read.sql
Normal file
8
src/db/migrations/0003_add_group_last_read.sql
Normal 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;
|
||||
17
src/db/migrations/0004_add_project_documents.sql
Normal file
17
src/db/migrations/0004_add_project_documents.sql
Normal 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);
|
||||
26
src/db/migrations/0005_add_service_folders_documents.sql
Normal file
26
src/db/migrations/0005_add_service_folders_documents.sql
Normal 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);
|
||||
88
src/db/migrations/0006_add_ai_kurzy.sql
Normal file
88
src/db/migrations/0006_add_ai_kurzy.sql
Normal 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);
|
||||
3
src/db/migrations/0007_add_completed_notified_at.sql
Normal file
3
src/db/migrations/0007_add_completed_notified_at.sql
Normal 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;
|
||||
3553
src/db/migrations/meta/0001_snapshot.json
Normal file
3553
src/db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
115
src/db/schema.js
115
src/db/schema.js
@@ -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(),
|
||||
});
|
||||
|
||||
303
src/db/seeds/ai-kurzy-csv-import.seed.js
Normal file
303
src/db/seeds/ai-kurzy-csv-import.seed.js
Normal 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);
|
||||
});
|
||||
292
src/db/seeds/ai-kurzy-import.seed.js
Normal file
292
src/db/seeds/ai-kurzy-import.seed.js
Normal 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);
|
||||
});
|
||||
232
src/routes/ai-kurzy.routes.js
Normal file
232
src/routes/ai-kurzy.routes.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
414
src/services/ai-kurzy.service.js
Normal file
414
src/services/ai-kurzy.service.js
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
166
src/services/project-document.service.js
Normal file
166
src/services/project-document.service.js
Normal 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ý' };
|
||||
};
|
||||
166
src/services/service-document.service.js
Normal file
166
src/services/service-document.service.js
Normal 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ý' };
|
||||
};
|
||||
112
src/services/service-folder.service.js
Normal file
112
src/services/service-folder.service.js
Normal 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ý' };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user