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

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

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

140
SQL_QUERIES.txt Normal file
View 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
View 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

View File

@@ -11,7 +11,9 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"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": [],
"author": "Richard Tekula",

230
sql/01_schema_migration.sql Normal file
View 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
View 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
View 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
View 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!

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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