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:
140
SQL_QUERIES.txt
Normal file
140
SQL_QUERIES.txt
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
================================================================================
|
||||||
|
KOMPLETNE SQL PRE COOLIFY - KOPIRUJ A PRILEP DO PSQL
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CAST 1: SCHEMA MIGRATION (spusti prvy)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 TABLE IF NOT EXISTS "chat_groups" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "name" text NOT NULL, "created_by_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "chat_group_members" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "group_id" uuid NOT NULL REFERENCES "chat_groups"("id") ON DELETE CASCADE, "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, "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"));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "group_messages" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "group_id" uuid NOT NULL REFERENCES "chat_groups"("id") ON DELETE CASCADE, "sender_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, "content" text NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "push_subscriptions" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, "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"));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "email_signatures" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "user_id" uuid NOT NULL UNIQUE REFERENCES "users"("id") ON DELETE CASCADE, "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);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "services" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "name" text NOT NULL, "price" text NOT NULL, "description" text, "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 TABLE IF NOT EXISTS "service_folders" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "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 TABLE IF NOT EXISTS "service_documents" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "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 TABLE IF NOT EXISTS "company_documents" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "company_id" uuid NOT NULL REFERENCES "companies"("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 TABLE IF NOT EXISTS "project_documents" ("id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "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 TABLE IF NOT EXISTS "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);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "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 UNIQUE, "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);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "registracie" ("id" serial PRIMARY KEY NOT NULL, "kurz_id" integer NOT NULL REFERENCES "kurzy"("id") ON DELETE CASCADE, "ucastnik_id" integer NOT NULL REFERENCES "ucastnici"("id") ON DELETE CASCADE, "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);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "prilohy" ("id" serial PRIMARY KEY NOT NULL, "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" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ucastnici_email_idx" ON "ucastnici" USING btree ("email");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "registracie_kurz_ucastnik_idx" ON "registracie" USING btree ("kurz_id","ucastnik_id");
|
||||||
|
|
||||||
|
DO $$ BEGIN ALTER TABLE "todos" ADD COLUMN "completed_notified_at" timestamp; EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
ALTER TABLE "personal_contacts" ALTER COLUMN "phone" DROP NOT NULL;
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CAST 2: AI KURZY DATA IMPORT (spusti po schema migration)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DELETE FROM prilohy;
|
||||||
|
DELETE FROM registracie;
|
||||||
|
DELETE FROM ucastnici;
|
||||||
|
DELETE FROM kurzy;
|
||||||
|
|
||||||
|
ALTER SEQUENCE kurzy_id_seq RESTART WITH 1;
|
||||||
|
ALTER SEQUENCE ucastnici_id_seq RESTART WITH 1;
|
||||||
|
ALTER SEQUENCE registracie_id_seq RESTART WITH 1;
|
||||||
|
|
||||||
|
INSERT INTO kurzy (nazov, typ_kurzu, cena, aktivny) VALUES ('AI 1+2 (2 dni) - 290€', 'AI', 290.00, true), ('AI 1 (1 deň) - 150€', 'AI', 150.00, true), ('AI 2 (1 deň) - 150€', 'AI', 150.00, true), ('AI v SEO (1 deň) - 150€', 'SEO', 150.00, true), ('AI I+II Marec 2026', 'AI', 290.00, true), ('AI I+II Apríl 2026', 'AI', 290.00, true);
|
||||||
|
|
||||||
|
INSERT INTO ucastnici (titul, meno, priezvisko, email, telefon, firma, mesto, ulica, psc) VALUES (NULL, 'Martin', 'Sovák', 'info@energium.sk', '0918986172', 'energium sro', 'Bratislava', 'Topolcianska 5', '85105'), (NULL, 'Michal', 'Farkaš', 'michal.farkas83@gmail.com', '0911209122', 'SLOVWELD', 'Dunajska Lužná', 'Mandlova 30', '90042'), (NULL, 'Alena', 'Šranková', 'alena.srankova@gmail.com', '0917352580', NULL, 'Bratislava', 'Šándorova 1', '82103'), (NULL, 'Katarina', 'Tomaníková', 'k.tomanikova@riseday.net', '0948 070 611', 'Classica Shipping Limited', 'Bratislava', 'Keltska 104', '85110'), (NULL, 'Róbert', 'Brišák', 'robert.brisak@ss-nizna.sk', '0910583883', 'Spojená škola, Hattalova 471, 02743 Nižná', 'Nižná', 'Hattalova 471', '02743'), (NULL, 'Marián', 'Bača', 'baca.marian@gmail.com', '0907994126', NULL, 'Petrovany', '8', '08253'), ('Mgr. MBA', 'Nikola', 'Horáčková', 'nikolahorackova11@gmail.com', '0918482184', NULL, 'Zákopčie', 'Zákopčie stred 12', '023 11'), (NULL, 'Tomáš', 'Kupec', 'kupec.tom@gmail.com', '0911030190', 'Jamajka', 'Liptovská Sielnica', NULL, '032 23'), (NULL, 'Anton', 'Považský', 'anton.povazsky@example.com', NULL, NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, true, false, 'registrovany', 'FA 2026020' FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'info@energium.sk';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'online', 1, true, true, 'registrovany', NULL FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'alena.srankova@gmail.com';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, true, true, 'registrovany', 'presunuta z oktobra, chce až január' FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'k.tomanikova@riseday.net';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, true, false, 'registrovany', 'FA 2026019' FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'robert.brisak@ss-nizna.sk';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, false, false, 'potencialny', 'vzdelávací poukaz' FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'nikolahorackova11@gmail.com';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-02', '2026-02-02', 'online', 1, true, true, 'registrovany', 'Fa 2025 338, Súhlasil so zmeneným termínom' FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1 (1 deň) - 150€' AND u.email = 'michal.farkas83@gmail.com';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-03', '2026-02-03', 'prezencne', 1, true, false, 'registrovany', 'Fa Gablasova' FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 2 (1 deň) - 150€' AND u.email = 'baca.marian@gmail.com';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-13', '2026-02-13', 'prezencne', 1, true, false, 'registrovany', 'FA 2026021' FROM kurzy k, ucastnici u WHERE k.nazov = 'AI v SEO (1 deň) - 150€' AND u.email = 'kupec.tom@gmail.com';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka) SELECT k.id, u.id, '2026-02-13', '2026-02-13', 'prezencne', 1, true, false, 'registrovany', NULL FROM kurzy k, ucastnici u WHERE k.nazov = 'AI v SEO (1 deň) - 150€' AND u.email = 'anton.povazsky@example.com';
|
||||||
|
|
||||||
|
SELECT 'Kurzy:' as info, COUNT(*) as pocet FROM kurzy;
|
||||||
|
SELECT 'Ucastnici:' as info, COUNT(*) as pocet FROM ucastnici;
|
||||||
|
SELECT 'Registracie:' as info, COUNT(*) as pocet FROM registracie;
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CAST 3: SERVICES DATA IMPORT (sluzby z firmy.slovensko.ai)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DELETE FROM service_documents;
|
||||||
|
DELETE FROM service_folders;
|
||||||
|
DELETE FROM services;
|
||||||
|
|
||||||
|
INSERT INTO services (name, price, description) VALUES
|
||||||
|
('AI Hlasový Agent', 'od 149€/mesiac', 'Virtuálny telefónny operátor pre automatizáciu prichádzajúcich hovorov. Dostupný 24/7, spracováva rezervácie, poskytuje informácie, zbiera kontaktné údaje. Balíky: Silver (149€/mes, 500 min), Gold (399€/mes, 1500 min), VIP (899€/mes, 5000 min), Enterprise (individuálne).'),
|
||||||
|
('AI Server', 'od 5 000€', 'Výkonné servery optimalizované pre AI úlohy. Dáta zostávajú pod vašou kontrolou. Bronze (od 5000€, 24 jadier, 64GB RAM), Silver (od 15000€, 64 jadier, 256GB RAM), Gold (od 40000€, 128+ jadier, 512GB RAM), Platinum (od 100000€, 256+ jadier, 1TB RAM).'),
|
||||||
|
('AI Automatizácia procesov', 'individuálne', 'Zefektívnite firemné procesy pomocou AI riešení na mieru. Analýza workflow, návrh riešenia, vývoj prototypu, testovanie s reálnymi dátami, nasadenie a optimalizácia. Zníženie chýb, nákladov a zlepšenie výkonu.'),
|
||||||
|
('AI Chatbot', 'individuálne', 'Automatizovaná komunikácia so zákazníkmi pomocou ChatGPT a Dialogflow. Dostupný 24/7, integrácia s WhatsApp, Instagram a inými platformami. Analýza dokumentov, základné právne poradenstvo, administratívna automatizácia.'),
|
||||||
|
('AI Školenia', 'od 150€/osoba', 'Firemné školenia zamerané na praktické AI zručnosti. AI I: 150€ (1 deň), AI I+II: 290€ (2 dni). Možnosť prispôsobenia programu podľa odvetvia. Formát: prezenčne, online alebo u klienta.'),
|
||||||
|
('E-mailový AI Agent', 'individuálne', 'Inteligentný systém na automatizáciu spracovania e-mailov. Automatická analýza, generovanie odpovedí, kategorizácia, smerovanie a tvorba súhrnov. Podpora viacerých schránok s prispôsobiteľnými pravidlami automatizácie.'),
|
||||||
|
('E-shop (AI Hardware)', 'od 4 490€', 'Profesionálne grafické karty a servery optimalizované pre AI. Nvidia Tesla A100 40GB (od 4490€), A100 64GB (od 9900€), A100 80GB (od 12500€), RTX PRO 6000 Blackwell (12900€). Servery na vyžiadanie.'),
|
||||||
|
('AI Konzultácie', 'od 0€', 'Bezplatná 30-minútová konzultácia. Telefónna konzultácia: 0-80€/hod, Online/osobná: 70-80€/hod, On-site (do 50km vrátane): 80-100€/hod. AI poradenstvo, odporúčanie nástrojov, integrácia a pilotné nasadenie.');
|
||||||
|
|
||||||
|
SELECT 'Services imported:' as info, COUNT(*) as count FROM services;
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CAST 4: CLEANUP (VOLITELNE - MAZE DATA!)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Pozor: Toto zmaze vsetky data okrem services (tie sa importuju v CAST 3)!
|
||||||
|
|
||||||
|
DELETE FROM todo_users;
|
||||||
|
DELETE FROM todos;
|
||||||
|
DELETE FROM company_remind;
|
||||||
|
DELETE FROM company_users;
|
||||||
|
DELETE FROM company_documents;
|
||||||
|
DELETE FROM companies;
|
||||||
|
DELETE FROM project_users;
|
||||||
|
DELETE FROM project_documents;
|
||||||
|
DELETE FROM projects;
|
||||||
|
DELETE FROM notes;
|
||||||
|
DELETE FROM time_entries;
|
||||||
|
DELETE FROM timesheets;
|
||||||
|
DELETE FROM event_users;
|
||||||
|
DELETE FROM events;
|
||||||
|
DELETE FROM messages;
|
||||||
|
DELETE FROM group_messages;
|
||||||
|
DELETE FROM chat_group_members;
|
||||||
|
DELETE FROM chat_groups;
|
||||||
|
DELETE FROM service_documents;
|
||||||
|
DELETE FROM service_folders;
|
||||||
|
DELETE FROM services;
|
||||||
|
DELETE FROM push_subscriptions;
|
||||||
|
DELETE FROM email_signatures;
|
||||||
|
|
||||||
|
SELECT 'Cleanup hotovo!' as status;
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
KONIEC
|
||||||
|
================================================================================
|
||||||
328
ai-kurzy-tables.md
Normal file
328
ai-kurzy-tables.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# AI Kurzy - Drizzle Schema
|
||||||
|
|
||||||
|
**Databázová schéma pre Node.js backend s Drizzle ORM**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Štruktúra Databázy
|
||||||
|
|
||||||
|
**4 tabuľky:**
|
||||||
|
|
||||||
|
```
|
||||||
|
kurzy ←──┐
|
||||||
|
│
|
||||||
|
├─→ registracie ←─→ ucastnici
|
||||||
|
│ │
|
||||||
|
└───────────┴─→ prilohy
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`kurzy`** - definície kurzov a termínov
|
||||||
|
- **`ucastnici`** - osobné údaje účastníkov
|
||||||
|
- **`registracie`** - väzba many-to-many (kto-kde-kedy + fakturácia)
|
||||||
|
- **`prilohy`** - dokumenty (certifikáty, faktúry) pripnuté k registráciám
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Schéma: `src/db/schema.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
serial,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
numeric,
|
||||||
|
date,
|
||||||
|
integer,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
pgEnum,
|
||||||
|
bigint,
|
||||||
|
uniqueIndex,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ENUM DEFINÍCIE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const formaKurzuEnum = pgEnum('forma_kurzu_enum', [
|
||||||
|
'prezenčne',
|
||||||
|
'online',
|
||||||
|
'hybridne',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const stavRegistracieEnum = pgEnum('stav_registracie_enum', [
|
||||||
|
'potencialny', // Potenciálny záujemca
|
||||||
|
'registrovany', // Registrovaný
|
||||||
|
'potvrdeny', // Potvrdená účasť
|
||||||
|
'absolvoval', // Absolvoval kurz (odškolené)
|
||||||
|
'zruseny', // Zrušená registrácia
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const typPrilohyEnum = pgEnum('typ_prilohy_enum', [
|
||||||
|
'certifikat',
|
||||||
|
'faktura',
|
||||||
|
'prihlaska',
|
||||||
|
'doklad_o_platbe',
|
||||||
|
'ine',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TABUĽKA: kurzy
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const kurzy = pgTable('kurzy', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
|
||||||
|
// Základné informácie
|
||||||
|
nazov: varchar('nazov', { length: 255 }).notNull(),
|
||||||
|
typKurzu: varchar('typ_kurzu', { length: 100 }).notNull(), // "AI 1+2", "AI 1", "SEO"
|
||||||
|
popis: text('popis'),
|
||||||
|
|
||||||
|
// Cenník
|
||||||
|
cena: numeric('cena', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
|
||||||
|
// Termín kurzu
|
||||||
|
datumOd: date('datum_od', { mode: 'date' }).notNull(),
|
||||||
|
datumDo: date('datum_do', { mode: 'date' }).notNull(),
|
||||||
|
|
||||||
|
// Kapacita
|
||||||
|
maxKapacita: integer('max_kapacita'), // NULL = neobmedzené
|
||||||
|
|
||||||
|
// Stav kurzu
|
||||||
|
aktivny: boolean('aktivny').default(true).notNull(),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TABUĽKA: ucastnici
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ucastnici = pgTable(
|
||||||
|
'ucastnici',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
|
||||||
|
// Osobné údaje
|
||||||
|
titul: varchar('titul', { length: 50 }),
|
||||||
|
meno: varchar('meno', { length: 100 }).notNull(),
|
||||||
|
priezvisko: varchar('priezvisko', { length: 100 }).notNull(),
|
||||||
|
|
||||||
|
// Kontaktné údaje
|
||||||
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
|
telefon: varchar('telefon', { length: 50 }),
|
||||||
|
|
||||||
|
// Firemné údaje
|
||||||
|
firma: varchar('firma', { length: 255 }),
|
||||||
|
|
||||||
|
// Adresa
|
||||||
|
mesto: varchar('mesto', { length: 100 }),
|
||||||
|
ulica: varchar('ulica', { length: 255 }),
|
||||||
|
psc: varchar('psc', { length: 10 }),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TABUĽKA: registracie
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const registracie = pgTable(
|
||||||
|
'registracie',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
|
||||||
|
// Foreign keys
|
||||||
|
kurzId: integer('kurz_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => kurzy.id, { onDelete: 'cascade' }),
|
||||||
|
ucastnikId: integer('ucastnik_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ucastnici.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Forma účasti
|
||||||
|
formaKurzu: formaKurzuEnum('forma_kurzu').default('prezenčne').notNull(),
|
||||||
|
|
||||||
|
// Počet účastníkov (ak firma prihlasuje viacerých)
|
||||||
|
pocetUcastnikov: integer('pocet_ucastnikov').default(1).notNull(),
|
||||||
|
|
||||||
|
// Fakturácia
|
||||||
|
fakturaCislo: varchar('faktura_cislo', { length: 100 }),
|
||||||
|
fakturaVystavena: boolean('faktura_vystavena').default(false).notNull(),
|
||||||
|
zaplatene: boolean('zaplatene').default(false).notNull(),
|
||||||
|
|
||||||
|
// Stav registrácie
|
||||||
|
stav: stavRegistracieEnum('stav').default('registrovany').notNull(),
|
||||||
|
|
||||||
|
// Poznámky
|
||||||
|
poznamka: text('poznamka'),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// Unikátne obmedzenie: jeden účastník sa nemôže prihlásiť na ten istý kurz viackrát
|
||||||
|
uniqRegistracia: uniqueIndex('registracie_kurz_ucastnik_idx').on(
|
||||||
|
table.kurzId,
|
||||||
|
table.ucastnikId
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TABUĽKA: prilohy
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const prilohy = pgTable('prilohy', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
|
||||||
|
// Foreign key
|
||||||
|
registraciaId: integer('registracia_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => registracie.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Informácie o súbore
|
||||||
|
nazovSuboru: varchar('nazov_suboru', { length: 255 }).notNull(),
|
||||||
|
typPrilohy: typPrilohyEnum('typ_prilohy').default('ine').notNull(),
|
||||||
|
|
||||||
|
// Úložisko
|
||||||
|
cestaKSuboru: varchar('cesta_k_suboru', { length: 500 }).notNull(),
|
||||||
|
mimeType: varchar('mime_type', { length: 100 }),
|
||||||
|
velkostSuboru: bigint('velkost_suboru', { mode: 'number' }), // veľkosť v bytoch
|
||||||
|
|
||||||
|
// Popis
|
||||||
|
popis: text('popis'),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RELAČNÉ VÄZBY (Relations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Kurzy Relations
|
||||||
|
export const kurzyRelations = relations(kurzy, ({ many }) => ({
|
||||||
|
registracie: many(registracie),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Účastníci Relations
|
||||||
|
export const ucastniciRelations = relations(ucastnici, ({ many }) => ({
|
||||||
|
registracie: many(registracie),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Registrácie Relations
|
||||||
|
export const registracieRelations = relations(registracie, ({ one, many }) => ({
|
||||||
|
kurz: one(kurzy, {
|
||||||
|
fields: [registracie.kurzId],
|
||||||
|
references: [kurzy.id],
|
||||||
|
}),
|
||||||
|
ucastnik: one(ucastnici, {
|
||||||
|
fields: [registracie.ucastnikId],
|
||||||
|
references: [ucastnici.id],
|
||||||
|
}),
|
||||||
|
prilohy: many(prilohy),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Prílohy Relations
|
||||||
|
export const prilohyRelations = relations(prilohy, ({ one }) => ({
|
||||||
|
registracia: one(registracie, {
|
||||||
|
fields: [prilohy.registraciaId],
|
||||||
|
references: [registracie.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📘 TypeScript Typy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
||||||
|
import { kurzy, ucastnici, registracie, prilohy } from './schema';
|
||||||
|
|
||||||
|
// SELECT typy (čítanie z DB)
|
||||||
|
export type Kurz = InferSelectModel<typeof kurzy>;
|
||||||
|
export type Ucastnik = InferSelectModel<typeof ucastnici>;
|
||||||
|
export type Registracia = InferSelectModel<typeof registracie>;
|
||||||
|
export type Priloha = InferSelectModel<typeof prilohy>;
|
||||||
|
|
||||||
|
// INSERT typy (vkladanie do DB)
|
||||||
|
export type NewKurz = InferInsertModel<typeof kurzy>;
|
||||||
|
export type NewUcastnik = InferInsertModel<typeof ucastnici>;
|
||||||
|
export type NewRegistracia = InferInsertModel<typeof registracie>;
|
||||||
|
export type NewPriloha = InferInsertModel<typeof prilohy>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Vysvetlenie Relácií
|
||||||
|
|
||||||
|
### **1:N (One-to-Many)**
|
||||||
|
```typescript
|
||||||
|
// Jeden kurz má VIAC registrácií
|
||||||
|
kurzyRelations = relations(kurzy, ({ many }) => ({
|
||||||
|
registracie: many(registracie),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### **N:1 (Many-to-One)**
|
||||||
|
```typescript
|
||||||
|
// Viac registrácií patrí k JEDNÉMU kurzu
|
||||||
|
registracieRelations = relations(registracie, ({ one }) => ({
|
||||||
|
kurz: one(kurzy, {
|
||||||
|
fields: [registracie.kurzId], // FK
|
||||||
|
references: [kurzy.id], // PK
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### **N:M (Many-to-Many)**
|
||||||
|
```
|
||||||
|
kurzy ↔ registracie ↔ ucastnici
|
||||||
|
- Jeden kurz má viac účastníkov
|
||||||
|
- Jeden účastník môže ísť na viac kurzov
|
||||||
|
- Junction table: registracie
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Použitie
|
||||||
|
|
||||||
|
### Základný query s relačnými dátami
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from './db';
|
||||||
|
import { kurzy } from './db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Kurz s registráciami a účastníkmi
|
||||||
|
const kurzDetail = await db.query.kurzy.findFirst({
|
||||||
|
where: eq(kurzy.id, 1),
|
||||||
|
with: {
|
||||||
|
registracie: {
|
||||||
|
with: {
|
||||||
|
ucastnik: true,
|
||||||
|
prilohy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Vytvorené:** 2026-01-20
|
||||||
|
**Stack:** Node.js + Drizzle ORM + PostgreSQL + TypeScript
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "node src/db/seeds/admin.seed.js",
|
"db:seed": "node src/db/seeds/admin.seed.js",
|
||||||
"db:seed:testuser": "node src/db/seeds/testuser.seed.js"
|
"db:seed:testuser": "node src/db/seeds/testuser.seed.js",
|
||||||
|
"db:import:ai-kurzy": "node src/db/seeds/ai-kurzy-import.seed.js",
|
||||||
|
"db:import:ai-kurzy-csv": "node src/db/seeds/ai-kurzy-csv-import.seed.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Richard Tekula",
|
"author": "Richard Tekula",
|
||||||
|
|||||||
230
sql/01_schema_migration.sql
Normal file
230
sql/01_schema_migration.sql
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- COMPLETE SCHEMA MIGRATION FOR COOLIFY
|
||||||
|
-- Run this first to update the database schema
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Create ENUMs for AI Kurzy (if not exist)
|
||||||
|
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 $$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- NEW TABLES
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Chat Groups
|
||||||
|
CREATE TABLE IF NOT EXISTS "chat_groups" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"created_by_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Chat Group Members
|
||||||
|
CREATE TABLE IF NOT EXISTS "chat_group_members" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"group_id" uuid NOT NULL REFERENCES "chat_groups"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"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")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Group Messages
|
||||||
|
CREATE TABLE IF NOT EXISTS "group_messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"group_id" uuid NOT NULL REFERENCES "chat_groups"("id") ON DELETE CASCADE,
|
||||||
|
"sender_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Push Subscriptions
|
||||||
|
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"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")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Email Signatures
|
||||||
|
CREATE TABLE IF NOT EXISTS "email_signatures" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL UNIQUE REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Services
|
||||||
|
CREATE TABLE IF NOT EXISTS "services" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"price" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Service Folders
|
||||||
|
CREATE TABLE IF NOT EXISTS "service_folders" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Service Documents
|
||||||
|
CREATE TABLE IF NOT EXISTS "service_documents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Company Documents
|
||||||
|
CREATE TABLE IF NOT EXISTS "company_documents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL REFERENCES "companies"("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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Project Documents
|
||||||
|
CREATE TABLE IF NOT EXISTS "project_documents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- AI KURZY TABLES
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Kurzy (Courses) - without dates (dates are per registration)
|
||||||
|
CREATE TABLE IF NOT EXISTS "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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ucastnici (Participants)
|
||||||
|
CREATE TABLE IF NOT EXISTS "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 UNIQUE,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ucastnici_email_idx" ON "ucastnici" USING btree ("email");
|
||||||
|
|
||||||
|
-- Registracie (Registrations) - with dates
|
||||||
|
CREATE TABLE IF NOT EXISTS "registracie" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"kurz_id" integer NOT NULL REFERENCES "kurzy"("id") ON DELETE CASCADE,
|
||||||
|
"ucastnik_id" integer NOT NULL REFERENCES "ucastnici"("id") ON DELETE CASCADE,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "registracie_kurz_ucastnik_idx" ON "registracie" USING btree ("kurz_id","ucastnik_id");
|
||||||
|
|
||||||
|
-- Prilohy (Attachments)
|
||||||
|
CREATE TABLE IF NOT EXISTS "prilohy" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"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" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- ALTER EXISTING TABLES (add new columns)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Add completed_notified_at to todos (if not exists)
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "todos" ADD COLUMN "completed_notified_at" timestamp;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Make phone nullable in personal_contacts (if needed)
|
||||||
|
ALTER TABLE "personal_contacts" ALTER COLUMN "phone" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- DONE
|
||||||
|
-- ============================================================
|
||||||
|
SELECT 'Schema migration completed successfully!' as status;
|
||||||
97
sql/02_ai_kurzy_data.sql
Normal file
97
sql/02_ai_kurzy_data.sql
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- AI KURZY DATA IMPORT
|
||||||
|
-- Run this after schema migration to import course data
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Clear existing AI Kurzy data (optional - remove if you want to keep existing data)
|
||||||
|
DELETE FROM prilohy;
|
||||||
|
DELETE FROM registracie;
|
||||||
|
DELETE FROM ucastnici;
|
||||||
|
DELETE FROM kurzy;
|
||||||
|
|
||||||
|
-- Reset sequences
|
||||||
|
ALTER SEQUENCE kurzy_id_seq RESTART WITH 1;
|
||||||
|
ALTER SEQUENCE ucastnici_id_seq RESTART WITH 1;
|
||||||
|
ALTER SEQUENCE registracie_id_seq RESTART WITH 1;
|
||||||
|
ALTER SEQUENCE prilohy_id_seq RESTART WITH 1;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- INSERT COURSES (without dates - dates are per registration)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO kurzy (nazov, typ_kurzu, cena, aktivny) VALUES
|
||||||
|
('AI 1+2 (2 dni) - 290€', 'AI', 290.00, true),
|
||||||
|
('AI 1 (1 deň) - 150€', 'AI', 150.00, true),
|
||||||
|
('AI 2 (1 deň) - 150€', 'AI', 150.00, true),
|
||||||
|
('AI v SEO (1 deň) - 150€', 'SEO', 150.00, true),
|
||||||
|
('AI I+II Marec 2026', 'AI', 290.00, true),
|
||||||
|
('AI I+II Apríl 2026', 'AI', 290.00, true);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- INSERT PARTICIPANTS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO ucastnici (titul, meno, priezvisko, email, telefon, firma, mesto, ulica, psc) VALUES
|
||||||
|
(NULL, 'Martin', 'Sovák', 'info@energium.sk', '0918986172', 'energium sro', 'Bratislava', 'Topolcianska 5', '85105'),
|
||||||
|
(NULL, 'Michal', 'Farkaš', 'michal.farkas83@gmail.com', '0911209122', 'SLOVWELD', 'Dunajska Lužná', 'Mandlova 30', '90042'),
|
||||||
|
(NULL, 'Alena', 'Šranková', 'alena.srankova@gmail.com', '0917352580', NULL, 'Bratislava', 'Šándorova 1', '82103'),
|
||||||
|
(NULL, 'Katarina', 'Tomaníková', 'k.tomanikova@riseday.net', '0948 070 611', 'Classica Shipping Limited', 'Bratislava', 'Keltska 104', '85110'),
|
||||||
|
(NULL, 'Róbert', 'Brišák', 'robert.brisak@ss-nizna.sk', '0910583883', 'Spojená škola, Hattalova 471, 02743 Nižná', 'Nižná', 'Hattalova 471', '02743'),
|
||||||
|
(NULL, 'Marián', 'Bača', 'baca.marian@gmail.com', '0907994126', NULL, 'Petrovany', '8', '08253'),
|
||||||
|
('Mgr. MBA', 'Nikola', 'Horáčková', 'nikolahorackova11@gmail.com', '0918482184', NULL, 'Zákopčie', 'Zákopčie stred 12', '023 11'),
|
||||||
|
(NULL, 'Tomáš', 'Kupec', 'kupec.tom@gmail.com', '0911030190', 'Jamajka', 'Liptovská Sielnica', NULL, '032 23'),
|
||||||
|
(NULL, 'Anton', 'Považský', 'anton.povazsky@example.com', NULL, NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- INSERT REGISTRATIONS (with dates)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- AI 1+2 (2 dni) - Februar 2026
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, true, false, 'registrovany', 'FA 2026020'
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'info@energium.sk';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'online', 1, true, true, 'registrovany', NULL
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'alena.srankova@gmail.com';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, true, true, 'registrovany', 'presunuta z oktobra, chce až január'
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'k.tomanikova@riseday.net';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, true, false, 'registrovany', 'FA 2026019'
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'robert.brisak@ss-nizna.sk';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-02', '2026-02-03', 'prezencne', 1, false, false, 'potencialny', 'vzdelávací poukaz'
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1+2 (2 dni) - 290€' AND u.email = 'nikolahorackova11@gmail.com';
|
||||||
|
|
||||||
|
-- AI 1 (1 den) - Februar 2026
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-02', '2026-02-02', 'online', 1, true, true, 'registrovany', 'Fa 2025 338, Súhlasil so zmeneným termínom'
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 1 (1 deň) - 150€' AND u.email = 'michal.farkas83@gmail.com';
|
||||||
|
|
||||||
|
-- AI 2 (1 den) - Februar 2026
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-03', '2026-02-03', 'prezencne', 1, true, false, 'registrovany', 'Fa Gablasova'
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI 2 (1 deň) - 150€' AND u.email = 'baca.marian@gmail.com';
|
||||||
|
|
||||||
|
-- AI v SEO - Februar 2026
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-13', '2026-02-13', 'prezencne', 1, true, false, 'registrovany', 'FA 2026021'
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI v SEO (1 deň) - 150€' AND u.email = 'kupec.tom@gmail.com';
|
||||||
|
|
||||||
|
INSERT INTO registracie (kurz_id, ucastnik_id, datum_od, datum_do, forma_kurzu, pocet_ucastnikov, faktura_vystavena, zaplatene, stav, poznamka)
|
||||||
|
SELECT k.id, u.id, '2026-02-13', '2026-02-13', 'prezencne', 1, true, false, 'registrovany', NULL
|
||||||
|
FROM kurzy k, ucastnici u WHERE k.nazov = 'AI v SEO (1 deň) - 150€' AND u.email = 'anton.povazsky@example.com';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VERIFY DATA
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SELECT 'Courses imported:' as info, COUNT(*) as count FROM kurzy;
|
||||||
|
SELECT 'Participants imported:' as info, COUNT(*) as count FROM ucastnici;
|
||||||
|
SELECT 'Registrations imported:' as info, COUNT(*) as count FROM registracie;
|
||||||
|
|
||||||
|
SELECT 'AI Kurzy data import completed successfully!' as status;
|
||||||
82
sql/03_cleanup_data.sql
Normal file
82
sql/03_cleanup_data.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- CLEANUP DATA - Clear test/development data
|
||||||
|
-- WARNING: This will DELETE data! Use with caution!
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- OPTION 1: SOFT CLEANUP (keeps structure, removes data)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Clear Todos and related
|
||||||
|
DELETE FROM todo_users;
|
||||||
|
DELETE FROM todos;
|
||||||
|
|
||||||
|
-- Clear Companies and related
|
||||||
|
DELETE FROM company_remind;
|
||||||
|
DELETE FROM company_users;
|
||||||
|
DELETE FROM company_documents;
|
||||||
|
DELETE FROM companies;
|
||||||
|
|
||||||
|
-- Clear Projects and related
|
||||||
|
DELETE FROM project_users;
|
||||||
|
DELETE FROM project_documents;
|
||||||
|
DELETE FROM projects;
|
||||||
|
|
||||||
|
-- Clear Notes
|
||||||
|
DELETE FROM notes;
|
||||||
|
|
||||||
|
-- Clear Time Entries
|
||||||
|
DELETE FROM time_entries;
|
||||||
|
|
||||||
|
-- Clear Timesheets
|
||||||
|
DELETE FROM timesheets;
|
||||||
|
|
||||||
|
-- Clear Events and related
|
||||||
|
DELETE FROM event_users;
|
||||||
|
DELETE FROM events;
|
||||||
|
|
||||||
|
-- Clear Messages (both direct and group)
|
||||||
|
DELETE FROM messages;
|
||||||
|
DELETE FROM group_messages;
|
||||||
|
DELETE FROM chat_group_members;
|
||||||
|
DELETE FROM chat_groups;
|
||||||
|
|
||||||
|
-- Clear Services
|
||||||
|
DELETE FROM service_documents;
|
||||||
|
DELETE FROM service_folders;
|
||||||
|
DELETE FROM services;
|
||||||
|
|
||||||
|
-- Clear Email related (contacts, emails) - BE CAREFUL
|
||||||
|
-- DELETE FROM emails;
|
||||||
|
-- DELETE FROM contacts;
|
||||||
|
|
||||||
|
-- Clear Push subscriptions
|
||||||
|
DELETE FROM push_subscriptions;
|
||||||
|
|
||||||
|
-- Clear Email signatures
|
||||||
|
DELETE FROM email_signatures;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- OPTION 2: RESET SEQUENCES (optional)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- If you have serial IDs and want to reset them:
|
||||||
|
-- ALTER SEQUENCE todos_id_seq RESTART WITH 1;
|
||||||
|
-- etc.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VERIFY CLEANUP
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SELECT 'Cleanup results:' as info;
|
||||||
|
SELECT 'Todos:' as table_name, COUNT(*) as remaining FROM todos;
|
||||||
|
SELECT 'Companies:' as table_name, COUNT(*) as remaining FROM companies;
|
||||||
|
SELECT 'Projects:' as table_name, COUNT(*) as remaining FROM projects;
|
||||||
|
SELECT 'Notes:' as table_name, COUNT(*) as remaining FROM notes;
|
||||||
|
SELECT 'Events:' as table_name, COUNT(*) as remaining FROM events;
|
||||||
|
SELECT 'Messages:' as table_name, COUNT(*) as remaining FROM messages;
|
||||||
|
SELECT 'Chat Groups:' as table_name, COUNT(*) as remaining FROM chat_groups;
|
||||||
|
SELECT 'Time Entries:' as table_name, COUNT(*) as remaining FROM time_entries;
|
||||||
|
SELECT 'Services:' as table_name, COUNT(*) as remaining FROM services;
|
||||||
|
|
||||||
|
SELECT 'Cleanup completed!' as status;
|
||||||
46
sql/README.md
Normal file
46
sql/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# SQL Migration Scripts
|
||||||
|
|
||||||
|
## Pouzitie na Coolify
|
||||||
|
|
||||||
|
### 1. Pripojenie k databaze cez terminal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Na Coolify serveri najdi PostgreSQL container a pripoj sa
|
||||||
|
docker exec -it <postgres_container_id> psql -U <username> -d <database>
|
||||||
|
|
||||||
|
# Alebo ak mas pristup cez SSH:
|
||||||
|
psql -h localhost -U <username> -d <database>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Poradie spustenia scriptov
|
||||||
|
|
||||||
|
**DOLEZITE: Spusti scripty v tomto poradi!**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Najprv schema migration (vytvori nove tabulky a stlpce)
|
||||||
|
\i /path/to/01_schema_migration.sql
|
||||||
|
|
||||||
|
-- 2. Potom AI Kurzy data (vlozi kurzy a ucastnikov)
|
||||||
|
\i /path/to/02_ai_kurzy_data.sql
|
||||||
|
|
||||||
|
-- 3. Volitelne: Cleanup existujucich dat (POZOR - maze data!)
|
||||||
|
\i /path/to/03_cleanup_data.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Alternativne - copy/paste
|
||||||
|
|
||||||
|
Mozes tiez otvorit subory a copy/paste obsah priamo do psql terminalu.
|
||||||
|
|
||||||
|
### Popis suborov
|
||||||
|
|
||||||
|
| Subor | Popis |
|
||||||
|
|-------|-------|
|
||||||
|
| `01_schema_migration.sql` | Vytvori vsetky nove tabulky (chat groups, push notifications, AI kurzy, atd.) a prida nove stlpce do existujucich tabuliek |
|
||||||
|
| `02_ai_kurzy_data.sql` | Importuje kurzy a ucastnikov z CSV - 6 kurzov, 9 ucastnikov |
|
||||||
|
| `03_cleanup_data.sql` | Vymaze test data z todos, companies, projects, notes, events, messages |
|
||||||
|
|
||||||
|
### Poznamky
|
||||||
|
|
||||||
|
- `01_schema_migration.sql` je bezpecny - pouziva `IF NOT EXISTS` takze nevytvori duplicity
|
||||||
|
- `02_ai_kurzy_data.sql` najprv ZMAZE existujuce AI kurzy data!
|
||||||
|
- `03_cleanup_data.sql` ZMAZE data! Pouzi opatrne!
|
||||||
@@ -31,6 +31,7 @@ import pushRoutes from './routes/push.routes.js';
|
|||||||
import userRoutes from './routes/user.routes.js';
|
import userRoutes from './routes/user.routes.js';
|
||||||
import serviceRoutes from './routes/service.routes.js';
|
import serviceRoutes from './routes/service.routes.js';
|
||||||
import emailSignatureRoutes from './routes/email-signature.routes.js';
|
import emailSignatureRoutes from './routes/email-signature.routes.js';
|
||||||
|
import aiKurzyRoutes from './routes/ai-kurzy.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ app.use('/api/push', pushRoutes);
|
|||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/services', serviceRoutes);
|
app.use('/api/services', serviceRoutes);
|
||||||
app.use('/api/email-signature', emailSignatureRoutes);
|
app.use('/api/email-signature', emailSignatureRoutes);
|
||||||
|
app.use('/api/ai-kurzy', aiKurzyRoutes);
|
||||||
|
|
||||||
// Basic route
|
// Basic route
|
||||||
app.get('/', (req, res) => {
|
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);
|
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,
|
"when": 1768469306890,
|
||||||
"tag": "0000_fat_unus",
|
"tag": "0000_fat_unus",
|
||||||
"breakpoints": true
|
"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
|
// Enums
|
||||||
export const roleEnum = pgEnum('role', ['admin', 'member']);
|
export const roleEnum = pgEnum('role', ['admin', 'member']);
|
||||||
@@ -196,6 +196,7 @@ export const todos = pgTable('todos', {
|
|||||||
priority: todoPriorityEnum('priority').default('medium').notNull(),
|
priority: todoPriorityEnum('priority').default('medium').notNull(),
|
||||||
dueDate: timestamp('due_date'),
|
dueDate: timestamp('due_date'),
|
||||||
completedAt: timestamp('completed_at'),
|
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' }),
|
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_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(),
|
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
|
// Services table - služby ponúkané firmou
|
||||||
export const services = pgTable('services', {
|
export const services = pgTable('services', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
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(),
|
groupId: uuid('group_id').references(() => chatGroups.id, { onDelete: 'cascade' }).notNull(),
|
||||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
joinedAt: timestamp('joined_at').defaultNow().notNull(),
|
joinedAt: timestamp('joined_at').defaultNow().notNull(),
|
||||||
|
lastReadAt: timestamp('last_read_at').defaultNow().notNull(), // Track when user last read group messages
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
uniqueMember: unique('chat_group_member_unique').on(table.groupId, table.userId),
|
uniqueMember: unique('chat_group_member_unique').on(table.groupId, table.userId),
|
||||||
}));
|
}));
|
||||||
@@ -377,3 +394,99 @@ export const pushSubscriptions = pgTable('push_subscriptions', {
|
|||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
uniqueEndpoint: unique('push_subscription_endpoint_unique').on(table.userId, table.endpoint),
|
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 express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
import * as projectController from '../controllers/project.controller.js';
|
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 { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||||
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
||||||
import { checkProjectAccess } from '../middlewares/auth/resourceAccessMiddleware.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 { createProjectSchema, updateProjectSchema } from '../validators/crm.validators.js';
|
||||||
import { z } from 'zod';
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
// All project routes require authentication
|
// All project routes require authentication
|
||||||
@@ -136,4 +146,40 @@ router.delete(
|
|||||||
projectController.removeUserFromProject
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
import * as serviceController from '../controllers/service.controller.js';
|
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 { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||||
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
||||||
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
|
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
|
||||||
@@ -8,27 +11,129 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
|
||||||
|
});
|
||||||
|
|
||||||
const serviceIdSchema = z.object({
|
const serviceIdSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
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
|
// All service routes require authentication
|
||||||
router.use(authenticate);
|
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)
|
* GET /api/services - Get all services (all authenticated users)
|
||||||
*/
|
*/
|
||||||
router.get('/', serviceController.getAllServices);
|
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)
|
* POST /api/services - Create new service (admin only)
|
||||||
*/
|
*/
|
||||||
@@ -39,6 +144,15 @@ router.post(
|
|||||||
serviceController.createService
|
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)
|
* PUT /api/services/:serviceId - Update service (admin only)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ router.use(authenticate);
|
|||||||
// Get all todos
|
// Get all todos
|
||||||
router.get('/', todoController.getAllTodos);
|
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
|
// Get todo by ID
|
||||||
router.get(
|
router.get(
|
||||||
'/:todoId',
|
'/: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 { db } from '../config/database.js';
|
||||||
import { chatGroups, chatGroupMembers, groupMessages, users } from '../db/schema.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 { NotFoundError, ForbiddenError } from '../utils/errors.js';
|
||||||
import { sendPushNotificationToUsers } from './push.service.js';
|
import { sendPushNotificationToUsers } from './push.service.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
@@ -34,15 +34,22 @@ export const createGroup = async (name, creatorId, memberIds) => {
|
|||||||
* Get all groups for a user
|
* Get all groups for a user
|
||||||
*/
|
*/
|
||||||
export const getUserGroups = async (userId) => {
|
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
|
const memberOf = await db
|
||||||
.select({ groupId: chatGroupMembers.groupId })
|
.select({
|
||||||
|
groupId: chatGroupMembers.groupId,
|
||||||
|
lastReadAt: chatGroupMembers.lastReadAt,
|
||||||
|
})
|
||||||
.from(chatGroupMembers)
|
.from(chatGroupMembers)
|
||||||
.where(eq(chatGroupMembers.userId, userId));
|
.where(eq(chatGroupMembers.userId, userId));
|
||||||
|
|
||||||
if (memberOf.length === 0) return [];
|
if (memberOf.length === 0) return [];
|
||||||
|
|
||||||
const groupIds = memberOf.map((m) => m.groupId);
|
const groupIds = memberOf.map((m) => m.groupId);
|
||||||
|
const lastReadMap = memberOf.reduce((acc, m) => {
|
||||||
|
acc[m.groupId] = m.lastReadAt;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const groups = await db
|
const groups = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -56,10 +63,13 @@ export const getUserGroups = async (userId) => {
|
|||||||
.where(inArray(chatGroups.id, groupIds))
|
.where(inArray(chatGroups.id, groupIds))
|
||||||
.orderBy(desc(chatGroups.updatedAt));
|
.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(
|
const result = await Promise.all(
|
||||||
groups.map(async (group) => {
|
groups.map(async (group) => {
|
||||||
const lastMessage = await db
|
const lastReadAt = lastReadMap[group.id];
|
||||||
|
|
||||||
|
const [lastMessage, members, unreadResult] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
content: groupMessages.content,
|
content: groupMessages.content,
|
||||||
createdAt: groupMessages.createdAt,
|
createdAt: groupMessages.createdAt,
|
||||||
@@ -71,12 +81,23 @@ export const getUserGroups = async (userId) => {
|
|||||||
.leftJoin(users, eq(groupMessages.senderId, users.id))
|
.leftJoin(users, eq(groupMessages.senderId, users.id))
|
||||||
.where(eq(groupMessages.groupId, group.id))
|
.where(eq(groupMessages.groupId, group.id))
|
||||||
.orderBy(desc(groupMessages.createdAt))
|
.orderBy(desc(groupMessages.createdAt))
|
||||||
.limit(1);
|
.limit(1),
|
||||||
|
db
|
||||||
const members = await db
|
|
||||||
.select({ id: chatGroupMembers.id })
|
.select({ id: chatGroupMembers.id })
|
||||||
.from(chatGroupMembers)
|
.from(chatGroupMembers)
|
||||||
.where(eq(chatGroupMembers.groupId, group.id));
|
.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 {
|
return {
|
||||||
...group,
|
...group,
|
||||||
@@ -87,6 +108,7 @@ export const getUserGroups = async (userId) => {
|
|||||||
isMine: lastMessage[0].senderId === userId,
|
isMine: lastMessage[0].senderId === userId,
|
||||||
} : null,
|
} : null,
|
||||||
memberCount: members.length,
|
memberCount: members.length,
|
||||||
|
unreadCount: unreadResult[0]?.count || 0,
|
||||||
type: 'group',
|
type: 'group',
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -163,6 +185,17 @@ export const getGroupMessages = async (groupId, userId) => {
|
|||||||
throw new ForbiddenError('Nie ste členom tejto skupiny');
|
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
|
const messages = await db
|
||||||
.select({
|
.select({
|
||||||
id: groupMessages.id,
|
id: groupMessages.id,
|
||||||
@@ -402,3 +435,38 @@ export const deleteGroup = async (groupId, requesterId) => {
|
|||||||
|
|
||||||
return { success: true };
|
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 { NotFoundError } from '../utils/errors.js';
|
||||||
import { sendPushNotification } from './push.service.js';
|
import { sendPushNotification } from './push.service.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { getGroupUnreadCount } from './group.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all conversations for a user
|
* 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
|
const result = await db
|
||||||
.select({ count: sql`count(*)::int` })
|
.select({ count: sql`count(*)::int` })
|
||||||
.from(messages)
|
.from(messages)
|
||||||
@@ -268,6 +269,22 @@ export const getUnreadCount = async (userId) => {
|
|||||||
return result[0]?.count || 0;
|
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)
|
* 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 { db } from '../config/database.js';
|
||||||
import { todos, todoUsers, notes, projects, companies, users } from '../db/schema.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 { NotFoundError } from '../utils/errors.js';
|
||||||
import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js';
|
import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js';
|
||||||
import { sendPushNotificationToUsers } from './push.service.js';
|
import { sendPushNotificationToUsers } from './push.service.js';
|
||||||
@@ -471,3 +471,90 @@ export const getTodosByUserId = async (userId) => {
|
|||||||
.where(inArray(todos.id, todoIds))
|
.where(inArray(todos.id, todoIds))
|
||||||
.orderBy(desc(todos.createdAt));
|
.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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Reference in New Issue
Block a user