feat: Add user management APIs, status enum, enhanced notifications
- Add updateUser and resetUserPassword admin endpoints - Change company status from boolean to enum (registered, lead, customer, inactive) - Add 'important' event type to calendar validators and email templates - Add 1-hour-before event notifications cron job - Add 18:00 evening notifications for next-day events - Add contact description field support - Fix count() function usage in admin service - Add SQL migrations for schema changes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,47 @@ export const changeUserRole = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user details (admin only)
|
||||||
|
* PATCH /api/admin/users/:userId
|
||||||
|
*/
|
||||||
|
export const updateUser = async (req, res, next) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { firstName, lastName } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await adminService.updateUser(userId, { firstName, lastName });
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: updated,
|
||||||
|
message: 'Používateľ bol aktualizovaný',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset user password (admin only)
|
||||||
|
* POST /api/admin/users/:userId/reset-password
|
||||||
|
*/
|
||||||
|
export const resetUserPassword = async (req, res, next) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await adminService.resetUserPassword(userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: { tempPassword: result.tempPassword },
|
||||||
|
message: 'Heslo bolo resetované',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zmazanie usera (admin only)
|
* Zmazanie usera (admin only)
|
||||||
* DELETE /api/admin/users/:userId
|
* DELETE /api/admin/users/:userId
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ const formatTime = (date) => {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
const getTypeLabel = (type) => {
|
const getTypeLabel = (type) => {
|
||||||
return type === 'meeting' ? 'Stretnutie' : 'Udalosť';
|
if (type === 'meeting') return 'Stretnutie';
|
||||||
|
if (type === 'important') return 'Dôležité';
|
||||||
|
return 'Udalosť';
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +45,9 @@ const getTypeLabel = (type) => {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
const getTypeBadgeColor = (type) => {
|
const getTypeBadgeColor = (type) => {
|
||||||
return type === 'meeting' ? '#22c55e' : '#3b82f6';
|
if (type === 'meeting') return '#22c55e';
|
||||||
|
if (type === 'important') return '#ef4444';
|
||||||
|
return '#3b82f6';
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ const getTomorrowRange = () => {
|
|||||||
return { startOfTomorrow, endOfTomorrow };
|
return { startOfTomorrow, endOfTomorrow };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get range for events starting in the next hour
|
||||||
|
* @returns {{ startOfRange: Date, endOfRange: Date }}
|
||||||
|
*/
|
||||||
|
const getOneHourRange = () => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Start of range: now
|
||||||
|
const startOfRange = new Date(now);
|
||||||
|
|
||||||
|
// End of range: 1 hour from now
|
||||||
|
const endOfRange = new Date(now);
|
||||||
|
endOfRange.setHours(endOfRange.getHours() + 1);
|
||||||
|
|
||||||
|
return { startOfRange, endOfRange };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get events starting tomorrow with assigned users
|
* Get events starting tomorrow with assigned users
|
||||||
* @returns {Promise<Array>} Events with user info
|
* @returns {Promise<Array>} Events with user info
|
||||||
@@ -102,6 +119,42 @@ const getTomorrowEvents = async () => {
|
|||||||
return tomorrowEvents;
|
return tomorrowEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get events starting in the next hour with assigned users
|
||||||
|
* @returns {Promise<Array>} Events with user info
|
||||||
|
*/
|
||||||
|
const getUpcomingEvents = async () => {
|
||||||
|
const { startOfRange, endOfRange } = getOneHourRange();
|
||||||
|
|
||||||
|
logger.info(`Hľadám udalosti začínajúce v najbližšej hodine: ${startOfRange.toISOString()} - ${endOfRange.toISOString()}`);
|
||||||
|
|
||||||
|
// Get events starting in the next hour
|
||||||
|
const upcomingEvents = await db
|
||||||
|
.select({
|
||||||
|
eventId: events.id,
|
||||||
|
title: events.title,
|
||||||
|
description: events.description,
|
||||||
|
type: events.type,
|
||||||
|
start: events.start,
|
||||||
|
end: events.end,
|
||||||
|
userId: users.id,
|
||||||
|
username: users.username,
|
||||||
|
firstName: users.firstName,
|
||||||
|
lastName: users.lastName,
|
||||||
|
})
|
||||||
|
.from(events)
|
||||||
|
.innerJoin(eventUsers, eq(events.id, eventUsers.eventId))
|
||||||
|
.innerJoin(users, eq(eventUsers.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(events.start, startOfRange),
|
||||||
|
lt(events.start, endOfRange)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return upcomingEvents;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user's primary email address
|
* Get user's primary email address
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
@@ -453,3 +506,98 @@ export const sendEventNotifications = async () => {
|
|||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notifications for events starting in the next hour (1 hour before meeting)
|
||||||
|
* @returns {Promise<{ sent: number, failed: number, skipped: number }>}
|
||||||
|
*/
|
||||||
|
export const sendOneHourBeforeNotifications = async () => {
|
||||||
|
logger.info('=== Spúšťam kontrolu udalostí začínajúcich v najbližšej hodine ===');
|
||||||
|
|
||||||
|
const stats = { sent: 0, failed: 0, skipped: 0 };
|
||||||
|
|
||||||
|
// Get sender account
|
||||||
|
const senderAccount = await getSenderAccount();
|
||||||
|
if (!senderAccount) {
|
||||||
|
logger.error('Nemôžem pokračovať bez platného odosielacieho účtu');
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmapConfig = {
|
||||||
|
server: process.env.JMAP_SERVER,
|
||||||
|
username: senderAccount.email,
|
||||||
|
password: senderAccount.password,
|
||||||
|
accountId: senderAccount.jmapAccountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get events starting in the next hour
|
||||||
|
const upcomingEvents = await getUpcomingEvents();
|
||||||
|
|
||||||
|
if (upcomingEvents.length === 0) {
|
||||||
|
logger.info('Žiadne udalosti v najbližšej hodine');
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Nájdených ${upcomingEvents.length} priradení udalostí v najbližšej hodine`);
|
||||||
|
|
||||||
|
// Group events by user to avoid duplicate notifications for same event
|
||||||
|
const userNotifications = new Map();
|
||||||
|
|
||||||
|
for (const row of upcomingEvents) {
|
||||||
|
const key = `${row.userId}-${row.eventId}`;
|
||||||
|
if (!userNotifications.has(key)) {
|
||||||
|
userNotifications.set(key, {
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
firstName: row.firstName,
|
||||||
|
lastName: row.lastName,
|
||||||
|
event: {
|
||||||
|
id: row.eventId,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
type: row.type,
|
||||||
|
start: row.start,
|
||||||
|
end: row.end,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Unikátnych notifikácií na odoslanie: ${userNotifications.size}`);
|
||||||
|
|
||||||
|
// Send notifications
|
||||||
|
for (const [key, data] of userNotifications) {
|
||||||
|
const { userId, username, firstName, event } = data;
|
||||||
|
|
||||||
|
// Get user's email
|
||||||
|
const userEmail = await getUserEmail(userId);
|
||||||
|
|
||||||
|
if (!userEmail) {
|
||||||
|
stats.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate email content with "1 hour before" subject
|
||||||
|
const typeLabel = event.type === 'meeting' ? 'Stretnutie' : (event.type === 'important' ? 'Dôležité' : 'Udalosť');
|
||||||
|
const subject = `Pripomienka: ${typeLabel} - ${event.title} (o 1 hodinu)`;
|
||||||
|
const htmlBody = generateEventNotificationHtml({ firstName, username, event });
|
||||||
|
const textBody = generateEventNotificationText({ firstName, username, event });
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
logger.info(`Odosielam 1h notifikáciu pre ${username} - udalosť: ${event.title}`);
|
||||||
|
|
||||||
|
const success = await sendNotificationEmail(jmapConfig, userEmail, subject, htmlBody, textBody);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
logger.success(`Email úspešne odoslaný na ${userEmail}`);
|
||||||
|
stats.sent++;
|
||||||
|
} else {
|
||||||
|
logger.error(`Nepodarilo sa odoslať email na ${userEmail}`);
|
||||||
|
stats.failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`=== Hotovo (1h notifikácie): odoslaných ${stats.sent}, neúspešných ${stats.failed}, preskočených ${stats.skipped} ===`);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { sendEventNotifications, sendSingleEventNotification } from './event-notifier.js';
|
import { sendEventNotifications, sendSingleEventNotification, sendOneHourBeforeNotifications } from './event-notifier.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse NOTIFICATION_TIME from env (format: "HH:mm")
|
* Parse NOTIFICATION_TIME from env (format: "HH:mm")
|
||||||
@@ -58,6 +58,54 @@ export const startCalendarNotificationCron = () => {
|
|||||||
return { name: `Calendar (${schedule})`, schedule };
|
return { name: `Calendar (${schedule})`, schedule };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the 1-hour-before notification cron job (runs every hour)
|
||||||
|
* @returns {{ name: string, schedule: string }}
|
||||||
|
*/
|
||||||
|
export const startOneHourBeforeNotificationCron = () => {
|
||||||
|
const cronExpression = '0 * * * *'; // Every hour at minute 0
|
||||||
|
const schedule = 'every hour';
|
||||||
|
|
||||||
|
cron.schedule(cronExpression, async () => {
|
||||||
|
logger.info('Running 1-hour-before notifications...');
|
||||||
|
try {
|
||||||
|
const stats = await sendOneHourBeforeNotifications();
|
||||||
|
logger.info(`1h notifications done: sent=${stats.sent}, failed=${stats.failed}, skipped=${stats.skipped}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('1-hour-before notification cron failed', error);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
scheduled: true,
|
||||||
|
timezone: 'Europe/Bratislava',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { name: `1h Before (${schedule})`, schedule };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the 18:00 notification cron job (tomorrow's events at 18:00)
|
||||||
|
* @returns {{ name: string, schedule: string }}
|
||||||
|
*/
|
||||||
|
export const startEveningNotificationCron = () => {
|
||||||
|
const cronExpression = '0 18 * * *'; // Every day at 18:00
|
||||||
|
const schedule = '18:00';
|
||||||
|
|
||||||
|
cron.schedule(cronExpression, async () => {
|
||||||
|
logger.info('Running evening notifications for tomorrow...');
|
||||||
|
try {
|
||||||
|
const stats = await sendEventNotifications();
|
||||||
|
logger.info(`Evening notifications done: sent=${stats.sent}, failed=${stats.failed}, skipped=${stats.skipped}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Evening notification cron failed', error);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
scheduled: true,
|
||||||
|
timezone: 'Europe/Bratislava',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { name: `Evening (${schedule})`, schedule };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manually trigger event notifications (for testing)
|
* Manually trigger event notifications (for testing)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { startCalendarNotificationCron, triggerEventNotifications, triggerSingleEventNotification } from './calendar/index.js';
|
import { startCalendarNotificationCron, startOneHourBeforeNotificationCron, startEveningNotificationCron, triggerEventNotifications, triggerSingleEventNotification } from './calendar/index.js';
|
||||||
import { startAuditCleanupCron, cleanupOldAuditLogs } from './cleanupAuditLogs.js';
|
import { startAuditCleanupCron, cleanupOldAuditLogs } from './cleanupAuditLogs.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start all cron jobs
|
* Start all cron jobs
|
||||||
*/
|
*/
|
||||||
export const startAllCronJobs = () => {
|
export const startAllCronJobs = () => {
|
||||||
// Calendar event notifications
|
// Calendar event notifications (morning - configurable time)
|
||||||
const calendarJob = startCalendarNotificationCron();
|
const calendarJob = startCalendarNotificationCron();
|
||||||
logger.info(`Cron: ${calendarJob.name}`);
|
logger.info(`Cron: ${calendarJob.name}`);
|
||||||
|
|
||||||
|
// 1-hour-before notifications (every hour)
|
||||||
|
const oneHourJob = startOneHourBeforeNotificationCron();
|
||||||
|
logger.info(`Cron: ${oneHourJob.name}`);
|
||||||
|
|
||||||
|
// Evening notifications at 18:00 for tomorrow's events
|
||||||
|
const eveningJob = startEveningNotificationCron();
|
||||||
|
logger.info(`Cron: ${eveningJob.name}`);
|
||||||
|
|
||||||
// Audit logs cleanup
|
// Audit logs cleanup
|
||||||
const auditJob = startAuditCleanupCron();
|
const auditJob = startAuditCleanupCron();
|
||||||
logger.info(`Cron: ${auditJob.name}`);
|
logger.info(`Cron: ${auditJob.name}`);
|
||||||
|
|||||||
24
src/db/migrations/fix_status_type.sql
Normal file
24
src/db/migrations/fix_status_type.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Fix: Convert status column from boolean to company_status enum
|
||||||
|
-- The previous migration renamed is_active to status but kept the boolean type
|
||||||
|
|
||||||
|
-- Step 1: Add a temporary column with the correct enum type
|
||||||
|
ALTER TABLE "companies" ADD COLUMN "status_new" company_status;
|
||||||
|
|
||||||
|
-- Step 2: Migrate data - convert boolean to enum
|
||||||
|
-- true (active) -> 'registered', false (inactive) -> 'inactive'
|
||||||
|
UPDATE "companies" SET "status_new" =
|
||||||
|
CASE
|
||||||
|
WHEN "status"::text = 'true' OR "status"::text = 't' THEN 'registered'::company_status
|
||||||
|
WHEN "status"::text = 'false' OR "status"::text = 'f' THEN 'inactive'::company_status
|
||||||
|
ELSE 'registered'::company_status
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Step 3: Drop the old column
|
||||||
|
ALTER TABLE "companies" DROP COLUMN "status";
|
||||||
|
|
||||||
|
-- Step 4: Rename the new column
|
||||||
|
ALTER TABLE "companies" RENAME COLUMN "status_new" TO "status";
|
||||||
|
|
||||||
|
-- Step 5: Set the default and not null constraint
|
||||||
|
ALTER TABLE "companies" ALTER COLUMN "status" SET DEFAULT 'registered';
|
||||||
|
ALTER TABLE "companies" ALTER COLUMN "status" SET NOT NULL;
|
||||||
38
src/db/migrations/hotfix_migration.sql
Normal file
38
src/db/migrations/hotfix_migration.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Hotfix Migration Script
|
||||||
|
-- Run this manually after accepting drizzle-kit push prompts
|
||||||
|
|
||||||
|
-- 1. Create the company_status enum type if it doesn't exist
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE company_status AS ENUM ('registered', 'lead', 'customer', 'inactive');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Add the status column to companies table if it doesn't exist
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE companies ADD COLUMN status company_status NOT NULL DEFAULT 'registered';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_column THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. Migrate data from is_active to status (if is_active column exists)
|
||||||
|
DO $$ BEGIN
|
||||||
|
UPDATE companies SET status = CASE
|
||||||
|
WHEN is_active = true THEN 'customer'::company_status
|
||||||
|
ELSE 'inactive'::company_status
|
||||||
|
END;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN undefined_column THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 4. Drop the is_active column (optional - run after verifying migration)
|
||||||
|
-- ALTER TABLE companies DROP COLUMN IF EXISTS is_active;
|
||||||
|
|
||||||
|
-- 5. Add description column to personal_contacts if it doesn't exist
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE personal_contacts ADD COLUMN description TEXT;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_column THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Note: Event type 'important' is stored as text, no schema change needed for events table
|
||||||
@@ -5,6 +5,7 @@ export const roleEnum = pgEnum('role', ['admin', 'member']);
|
|||||||
export const projectStatusEnum = pgEnum('project_status', ['active', 'completed', 'on_hold', 'cancelled']);
|
export const projectStatusEnum = pgEnum('project_status', ['active', 'completed', 'on_hold', 'cancelled']);
|
||||||
export const todoStatusEnum = pgEnum('todo_status', ['pending', 'in_progress', 'completed', 'cancelled']);
|
export const todoStatusEnum = pgEnum('todo_status', ['pending', 'in_progress', 'completed', 'cancelled']);
|
||||||
export const todoPriorityEnum = pgEnum('todo_priority', ['low', 'medium', 'high', 'urgent']);
|
export const todoPriorityEnum = pgEnum('todo_priority', ['low', 'medium', 'high', 'urgent']);
|
||||||
|
export const companyStatusEnum = pgEnum('company_status', ['registered', 'lead', 'customer', 'inactive']);
|
||||||
|
|
||||||
// Users table - používatelia systému
|
// Users table - používatelia systému
|
||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
@@ -89,6 +90,7 @@ export const personalContacts = pgTable('personal_contacts', {
|
|||||||
phone: text('phone').notNull(),
|
phone: text('phone').notNull(),
|
||||||
email: text('email').notNull(),
|
email: text('email').notNull(),
|
||||||
secondaryEmail: text('secondary_email'),
|
secondaryEmail: text('secondary_email'),
|
||||||
|
description: text('description'), // popis kontaktu
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -128,7 +130,7 @@ export const companies = pgTable('companies', {
|
|||||||
phone: text('phone'),
|
phone: text('phone'),
|
||||||
email: text('email'),
|
email: text('email'),
|
||||||
website: text('website'),
|
website: text('website'),
|
||||||
isActive: boolean('is_active').default(true).notNull(), // či je firma aktívna
|
status: companyStatusEnum('status').default('registered').notNull(), // stav firmy
|
||||||
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
|
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ router.get(
|
|||||||
adminController.getUser
|
adminController.getUser
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update user details
|
||||||
|
router.patch(
|
||||||
|
'/users/:userId',
|
||||||
|
validateParams(z.object({ userId: z.string().uuid() })),
|
||||||
|
validateBody(z.object({
|
||||||
|
firstName: z.string().max(100).optional(),
|
||||||
|
lastName: z.string().max(100).optional(),
|
||||||
|
})),
|
||||||
|
adminController.updateUser
|
||||||
|
);
|
||||||
|
|
||||||
// Zmena role usera
|
// Zmena role usera
|
||||||
router.patch(
|
router.patch(
|
||||||
'/users/:userId/role',
|
'/users/:userId/role',
|
||||||
@@ -39,6 +50,13 @@ router.patch(
|
|||||||
adminController.changeUserRole
|
adminController.changeUserRole
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reset user password
|
||||||
|
router.post(
|
||||||
|
'/users/:userId/reset-password',
|
||||||
|
validateParams(z.object({ userId: z.string().uuid() })),
|
||||||
|
adminController.resetUserPassword
|
||||||
|
);
|
||||||
|
|
||||||
// Zmazanie usera
|
// Zmazanie usera
|
||||||
router.delete(
|
router.delete(
|
||||||
'/users/:userId',
|
'/users/:userId',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { users, userEmailAccounts, emailAccounts } from '../db/schema.js';
|
import { users, userEmailAccounts, emailAccounts } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, count } from 'drizzle-orm';
|
||||||
import { hashPassword, generateTempPassword } from '../utils/password.js';
|
import { hashPassword, generateTempPassword } from '../utils/password.js';
|
||||||
import { ConflictError, NotFoundError } from '../utils/errors.js';
|
import { ConflictError, NotFoundError } from '../utils/errors.js';
|
||||||
import * as emailAccountService from './email-account.service.js';
|
import * as emailAccountService from './email-account.service.js';
|
||||||
@@ -162,6 +162,74 @@ export const changeUserRole = async (userId, newRole) => {
|
|||||||
return { userId, oldRole, newRole };
|
return { userId, oldRole, newRole };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user details (firstName, lastName)
|
||||||
|
*/
|
||||||
|
export const updateUser = async (userId, data) => {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName } = data;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
firstName: firstName !== undefined ? firstName : user.firstName,
|
||||||
|
lastName: lastName !== undefined ? lastName : user.lastName,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.returning({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
firstName: users.firstName,
|
||||||
|
lastName: users.lastName,
|
||||||
|
role: users.role,
|
||||||
|
changedPassword: users.changedPassword,
|
||||||
|
lastLogin: users.lastLogin,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset user password (generate new temp password)
|
||||||
|
*/
|
||||||
|
export const resetUserPassword = async (userId) => {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = generateTempPassword(12);
|
||||||
|
const hashedTempPassword = await hashPassword(tempPassword);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
tempPassword: hashedTempPassword,
|
||||||
|
password: null,
|
||||||
|
changedPassword: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return { userId, tempPassword };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zmazanie usera
|
* Zmazanie usera
|
||||||
*/
|
*/
|
||||||
@@ -179,11 +247,11 @@ export const deleteUser = async (userId) => {
|
|||||||
// Zabraň zmazaniu posledného admina
|
// Zabraň zmazaniu posledného admina
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
const [adminCount] = await db
|
const [adminCount] = await db
|
||||||
.select({ count: db.$count(users) })
|
.select({ count: count() })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.role, 'admin'));
|
.where(eq(users.role, 'admin'));
|
||||||
|
|
||||||
if (adminCount.count <= 1) {
|
if ((adminCount?.count || 0) <= 1) {
|
||||||
throw new ConflictError('Nemôžete zmazať posledného administrátora');
|
throw new ConflictError('Nemôžete zmazať posledného administrátora');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,11 +271,11 @@ export const deleteUser = async (userId) => {
|
|||||||
let deletedEmailAccounts = 0;
|
let deletedEmailAccounts = 0;
|
||||||
for (const emailAccountId of emailAccountIds) {
|
for (const emailAccountId of emailAccountIds) {
|
||||||
const [remainingLinks] = await db
|
const [remainingLinks] = await db
|
||||||
.select({ count: db.$count(userEmailAccounts) })
|
.select({ count: count() })
|
||||||
.from(userEmailAccounts)
|
.from(userEmailAccounts)
|
||||||
.where(eq(userEmailAccounts.emailAccountId, emailAccountId));
|
.where(eq(userEmailAccounts.emailAccountId, emailAccountId));
|
||||||
|
|
||||||
if (remainingLinks.count === 0) {
|
if ((remainingLinks?.count || 0) === 0) {
|
||||||
await db.delete(emailAccounts).where(eq(emailAccounts.id, emailAccountId));
|
await db.delete(emailAccounts).where(eq(emailAccounts.id, emailAccountId));
|
||||||
deletedEmailAccounts++;
|
deletedEmailAccounts++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const getAllCompanies = async (searchTerm = null, userId = null, userRole
|
|||||||
phone: companies.phone,
|
phone: companies.phone,
|
||||||
email: companies.email,
|
email: companies.email,
|
||||||
website: companies.website,
|
website: companies.website,
|
||||||
isActive: companies.isActive,
|
status: companies.status,
|
||||||
createdBy: companies.createdBy,
|
createdBy: companies.createdBy,
|
||||||
createdAt: companies.createdAt,
|
createdAt: companies.createdAt,
|
||||||
updatedAt: companies.updatedAt,
|
updatedAt: companies.updatedAt,
|
||||||
@@ -85,7 +85,7 @@ export const getCompanyById = async (companyId) => {
|
|||||||
phone: companies.phone,
|
phone: companies.phone,
|
||||||
email: companies.email,
|
email: companies.email,
|
||||||
website: companies.website,
|
website: companies.website,
|
||||||
isActive: companies.isActive,
|
status: companies.status,
|
||||||
createdBy: companies.createdBy,
|
createdBy: companies.createdBy,
|
||||||
createdAt: companies.createdAt,
|
createdAt: companies.createdAt,
|
||||||
updatedAt: companies.updatedAt,
|
updatedAt: companies.updatedAt,
|
||||||
@@ -110,7 +110,7 @@ export const getCompanyById = async (companyId) => {
|
|||||||
* Create new company
|
* Create new company
|
||||||
*/
|
*/
|
||||||
export const createCompany = async (userId, data) => {
|
export const createCompany = async (userId, data) => {
|
||||||
const { name, description, address, city, country, phone, email, website } = data;
|
const { name, description, address, city, country, phone, email, website, status } = data;
|
||||||
|
|
||||||
// Check if company with same name already exists
|
// Check if company with same name already exists
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -134,6 +134,7 @@ export const createCompany = async (userId, data) => {
|
|||||||
phone: phone || null,
|
phone: phone || null,
|
||||||
email: email || null,
|
email: email || null,
|
||||||
website: website || null,
|
website: website || null,
|
||||||
|
status: status || 'registered',
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -147,7 +148,7 @@ export const createCompany = async (userId, data) => {
|
|||||||
export const updateCompany = async (companyId, data) => {
|
export const updateCompany = async (companyId, data) => {
|
||||||
const company = await getCompanyById(companyId);
|
const company = await getCompanyById(companyId);
|
||||||
|
|
||||||
const { name, description, address, city, country, phone, email, website, isActive } = data;
|
const { name, description, address, city, country, phone, email, website, status } = data;
|
||||||
|
|
||||||
// If name is being changed, check for duplicates
|
// If name is being changed, check for duplicates
|
||||||
if (name && name !== company.name) {
|
if (name && name !== company.name) {
|
||||||
@@ -173,7 +174,7 @@ export const updateCompany = async (companyId, data) => {
|
|||||||
phone: phone !== undefined ? phone : company.phone,
|
phone: phone !== undefined ? phone : company.phone,
|
||||||
email: email !== undefined ? email : company.email,
|
email: email !== undefined ? email : company.email,
|
||||||
website: website !== undefined ? website : company.website,
|
website: website !== undefined ? website : company.website,
|
||||||
isActive: isActive !== undefined ? isActive : company.isActive,
|
status: status !== undefined ? status : company.status,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(companies.id, companyId))
|
.where(eq(companies.id, companyId))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const listPersonalContacts = async (userId) => {
|
|||||||
phone: personalContacts.phone,
|
phone: personalContacts.phone,
|
||||||
email: personalContacts.email,
|
email: personalContacts.email,
|
||||||
secondaryEmail: personalContacts.secondaryEmail,
|
secondaryEmail: personalContacts.secondaryEmail,
|
||||||
|
description: personalContacts.description,
|
||||||
createdAt: personalContacts.createdAt,
|
createdAt: personalContacts.createdAt,
|
||||||
updatedAt: personalContacts.updatedAt,
|
updatedAt: personalContacts.updatedAt,
|
||||||
companyName: companies.name,
|
companyName: companies.name,
|
||||||
@@ -69,6 +70,7 @@ export const createPersonalContact = async (userId, contact) => {
|
|||||||
phone: contact.phone,
|
phone: contact.phone,
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
secondaryEmail: contact.secondaryEmail || null,
|
secondaryEmail: contact.secondaryEmail || null,
|
||||||
|
description: contact.description || null,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ export const updatePersonalContact = async (contactId, userId, updates) => {
|
|||||||
if (updates.email !== undefined) updateData.email = updates.email
|
if (updates.email !== undefined) updateData.email = updates.email
|
||||||
if (updates.secondaryEmail !== undefined) updateData.secondaryEmail = updates.secondaryEmail
|
if (updates.secondaryEmail !== undefined) updateData.secondaryEmail = updates.secondaryEmail
|
||||||
if (updates.companyId !== undefined) updateData.companyId = updates.companyId || null
|
if (updates.companyId !== undefined) updateData.companyId = updates.companyId || null
|
||||||
|
if (updates.description !== undefined) updateData.description = updates.description || null
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(personalContacts)
|
.update(personalContacts)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const createCompanySchema = z.object({
|
|||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
email: z.string().email('Neplatný formát emailu').max(255).optional().or(z.literal('')),
|
email: z.string().email('Neplatný formát emailu').max(255).optional().or(z.literal('')),
|
||||||
website: z.string().url('Neplatný formát URL').max(255).optional().or(z.literal('')),
|
website: z.string().url('Neplatný formát URL').max(255).optional().or(z.literal('')),
|
||||||
|
status: z.enum(['registered', 'lead', 'customer', 'inactive']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateCompanySchema = z.object({
|
export const updateCompanySchema = z.object({
|
||||||
@@ -26,7 +27,7 @@ export const updateCompanySchema = z.object({
|
|||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
email: z.string().email('Neplatný formát emailu').max(255).optional().or(z.literal('')),
|
email: z.string().email('Neplatný formát emailu').max(255).optional().or(z.literal('')),
|
||||||
website: z.string().url('Neplatný formát URL').max(255).optional().or(z.literal('')),
|
website: z.string().url('Neplatný formát URL').max(255).optional().or(z.literal('')),
|
||||||
isActive: z.boolean().optional(),
|
status: z.enum(['registered', 'lead', 'customer', 'inactive']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Project validators
|
// Project validators
|
||||||
@@ -176,7 +177,7 @@ export const updateTimeEntrySchema = z.object({
|
|||||||
export const createEventSchema = z.object({
|
export const createEventSchema = z.object({
|
||||||
title: z.string().min(1, 'Názov je povinný'),
|
title: z.string().min(1, 'Názov je povinný'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
type: z.enum(['meeting', 'event']).default('meeting'),
|
type: z.enum(['meeting', 'event', 'important']).default('meeting'),
|
||||||
start: z.string().min(1, 'Začiatok je povinný'),
|
start: z.string().min(1, 'Začiatok je povinný'),
|
||||||
end: z.string().min(1, 'Koniec je povinný'),
|
end: z.string().min(1, 'Koniec je povinný'),
|
||||||
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
|
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
|
||||||
@@ -185,7 +186,7 @@ export const createEventSchema = z.object({
|
|||||||
export const updateEventSchema = z.object({
|
export const updateEventSchema = z.object({
|
||||||
title: z.string().min(1).optional(),
|
title: z.string().min(1).optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
type: z.enum(['meeting', 'event']).optional(),
|
type: z.enum(['meeting', 'event', 'important']).optional(),
|
||||||
start: z.string().optional(),
|
start: z.string().optional(),
|
||||||
end: z.string().optional(),
|
end: z.string().optional(),
|
||||||
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
|
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user