add searching, total unread message, create user

This commit is contained in:
richardtekula
2025-11-19 08:45:37 +01:00
parent da01d586fc
commit 97f437c1c4
8 changed files with 1338 additions and 90 deletions

997
README.md

File diff suppressed because it is too large Load Diff

24
check-tables.js Normal file
View File

@@ -0,0 +1,24 @@
import pkg from 'pg';
const { Pool } = pkg;
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'admin',
password: process.env.DB_PASSWORD || 'heslo123',
database: process.env.DB_NAME || 'crm',
});
const query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';";
pool.query(query).then(res => {
console.log('Tabuľky v databáze:');
res.rows.forEach(row => console.log(' -', row.tablename));
pool.end();
process.exit(0);
}).catch(err => {
console.error('Chyba:', err.message);
pool.end();
process.exit(1);
});

View File

@@ -1,16 +1,18 @@
import { db } from '../config/database.js';
import { users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { hashPassword, generateTempPassword } from '../utils/password.js';
import { hashPassword, generateTempPassword, encryptPassword } from '../utils/password.js';
import { logUserCreation, logRoleChange } from '../services/audit.service.js';
import { formatErrorResponse, ConflictError, NotFoundError } from '../utils/errors.js';
import { validateJmapCredentials } from '../services/email.service.js';
/**
* Vytvorenie nového usera s temporary password (admin only)
* Vytvorenie nového usera s automatic temporary password (admin only)
* Ak je poskytnutý email a emailPassword, automaticky sa fetchne JMAP account ID
* POST /api/admin/users
*/
export const createUser = async (req, res) => {
const { username, tempPassword, role, firstName, lastName } = req.body;
const { username, email, emailPassword, firstName, lastName } = req.body;
const adminId = req.userId;
const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
@@ -27,16 +29,34 @@ export const createUser = async (req, res) => {
throw new ConflictError('Username už existuje');
}
// Hash temporary password
// Automaticky vygeneruj temporary password
const tempPassword = generateTempPassword(12);
const hashedTempPassword = await hashPassword(tempPassword);
// Ak sú poskytnuté email credentials, validuj ich a získaj JMAP account ID
let jmapAccountId = null;
let encryptedEmailPassword = null;
if (email && emailPassword) {
try {
const { accountId } = await validateJmapCredentials(email, emailPassword);
jmapAccountId = accountId;
encryptedEmailPassword = encryptPassword(emailPassword);
} catch (emailError) {
throw new ConflictError(`Nepodarilo sa overiť emailový účet: ${emailError.message}`);
}
}
// Vytvor usera
const [newUser] = await db
.insert(users)
.values({
username,
email: email || null,
emailPassword: encryptedEmailPassword,
jmapAccountId,
tempPassword: hashedTempPassword,
role: role || 'member',
role: 'member', // Vždy member, nie admin
firstName: firstName || null,
lastName: lastName || null,
changedPassword: false,
@@ -44,7 +64,7 @@ export const createUser = async (req, res) => {
.returning();
// Log user creation
await logUserCreation(adminId, newUser.id, username, role || 'member', ipAddress, userAgent);
await logUserCreation(adminId, newUser.id, username, 'member', ipAddress, userAgent);
res.status(201).json({
success: true,
@@ -52,11 +72,18 @@ export const createUser = async (req, res) => {
user: {
id: newUser.id,
username: newUser.username,
email: newUser.email,
firstName: newUser.firstName,
lastName: newUser.lastName,
role: newUser.role,
tempPassword: tempPassword, // Vráti plain text password pre admina
jmapAccountId: newUser.jmapAccountId,
emailSetup: !!newUser.jmapAccountId,
tempPassword: tempPassword, // Vráti plain text password pre admina aby ho mohol poslať userovi
},
},
message: 'Používateľ úspešne vytvorený',
message: newUser.jmapAccountId
? 'Používateľ úspešne vytvorený s emailovým účtom.'
: 'Používateľ úspešne vytvorený. Email môže byť nastavený neskôr.',
});
} catch (error) {
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');

View File

@@ -1,5 +1,6 @@
import * as crmEmailService from '../services/crm-email.service.js';
import { markEmailAsRead, sendEmail, getJmapConfig } from '../services/jmap.service.js';
import * as contactService from '../services/contact.service.js';
import { markEmailAsRead, sendEmail, getJmapConfig, syncEmailsFromSender, searchEmailsJMAP as searchEmailsJMAPService } from '../services/jmap.service.js';
import { formatErrorResponse } from '../utils/errors.js';
import { getUserById } from '../services/auth.service.js';
@@ -76,9 +77,89 @@ export const getUnreadCount = async (req, res) => {
const userId = req.userId;
const count = await crmEmailService.getUnreadCount(userId);
const accounts = [];
if (req.user?.email) {
accounts.push({
id: req.user.jmapAccountId || req.user.email,
email: req.user.email,
label: req.user.email,
unread: count,
});
}
res.status(200).json({
success: true,
data: { count },
data: {
count,
totalUnread: count,
accounts,
lastUpdatedAt: new Date().toISOString(),
},
});
} catch (error) {
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
res.status(error.statusCode || 500).json(errorResponse);
}
};
/**
* Sync latest emails for all contacts from JMAP
* POST /api/emails/sync
*/
export const syncEmails = async (req, res) => {
try {
const userId = req.userId;
const user = await getUserById(userId);
if (!user.email || !user.emailPassword || !user.jmapAccountId) {
return res.status(400).json({
success: false,
error: {
message: 'Najprv musíš pripojiť email účet v Profile',
statusCode: 400,
},
});
}
const contacts = await contactService.getUserContacts(userId);
if (!contacts.length) {
return res.status(200).json({
success: true,
message: 'Žiadne kontakty na synchronizáciu',
data: { contacts: 0, synced: 0, newEmails: 0 },
});
}
const jmapConfig = getJmapConfig(user);
let totalSynced = 0;
let totalNew = 0;
for (const contact of contacts) {
try {
const { total, saved } = await syncEmailsFromSender(
jmapConfig,
userId,
contact.id,
contact.email,
{ limit: 50 }
);
totalSynced += total;
totalNew += saved;
} catch (syncError) {
console.error(`Failed to sync emails for contact ${contact.email}`, syncError);
}
}
return res.status(200).json({
success: true,
message: 'Emaily synchronizované',
data: {
contacts: contacts.length,
synced: totalSynced,
newEmails: totalNew,
},
});
} catch (error) {
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
@@ -121,12 +202,34 @@ export const markThreadRead = async (req, res) => {
const userId = req.userId;
const { threadId } = req.params;
const result = await crmEmailService.markThreadAsRead(userId, threadId);
const user = await getUserById(userId);
const threadEmails = await crmEmailService.getEmailThread(userId, threadId);
const unreadEmails = threadEmails.filter((email) => !email.isRead);
let jmapConfig = null;
if (user?.email && user?.emailPassword && user?.jmapAccountId) {
jmapConfig = getJmapConfig(user);
}
if (jmapConfig) {
for (const email of unreadEmails) {
if (!email.jmapId) {
continue;
}
try {
await markEmailAsRead(jmapConfig, userId, email.jmapId, true);
} catch (jmapError) {
console.error(`Failed to mark JMAP email ${email.jmapId} as read`, jmapError);
}
}
}
await crmEmailService.markThreadAsRead(userId, threadId);
res.status(200).json({
success: true,
message: 'Konverzácia označená ako prečítaná',
count: result.count,
count: unreadEmails.length,
});
} catch (error) {
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
@@ -191,3 +294,48 @@ export const getContactEmails = async (req, res) => {
res.status(error.statusCode || 500).json(errorResponse);
}
};
/**
* Search emails using JMAP full-text search
* GET /api/emails/search-jmap?query=text&limit=50&offset=0
* Searches in: from, to, subject, and email body
*/
export const searchEmailsJMAP = async (req, res) => {
try {
const userId = req.userId;
const { query = '', limit = 50, offset = 0 } = req.query;
// Get user to access JMAP config
const user = await getUserById(userId);
// Check if user has JMAP email configured
if (!user.email || !user.emailPassword || !user.jmapAccountId) {
return res.status(400).json({
success: false,
error: {
message: 'Najprv musíš pripojiť email účet v Profile',
statusCode: 400,
},
});
}
const jmapConfig = getJmapConfig(user);
const results = await searchEmailsJMAPService(
jmapConfig,
userId,
query,
parseInt(limit),
parseInt(offset)
);
res.status(200).json({
success: true,
count: results.length,
data: results,
});
} catch (error) {
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
res.status(error.statusCode || 500).json(errorResponse);
}
};

View File

@@ -16,12 +16,18 @@ router.use(authenticate);
// Get all emails
router.get('/', crmEmailController.getEmails);
// Search emails
// Search emails (DB search - searches in stored emails only)
router.get('/search', crmEmailController.searchEmails);
// Search emails using JMAP full-text search (searches in all emails via JMAP)
router.get('/search-jmap', crmEmailController.searchEmailsJMAP);
// Get unread count
router.get('/unread-count', crmEmailController.getUnreadCount);
// Sync latest emails from JMAP
router.post('/sync', crmEmailController.syncEmails);
// Get email thread (conversation)
router.get(
'/thread/:threadId',

View File

@@ -193,10 +193,148 @@ export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm =
}
};
/**
* Search emails using JMAP full-text search
* Searches in: from, to, subject, and email body
* Returns list of unique senders grouped by email address
*/
export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, offset = 0) => {
try {
logger.info(`Searching emails in JMAP (query: "${query}", limit: ${limit}, offset: ${offset})`);
if (!query || query.trim().length < 1) {
return [];
}
// Use JMAP search with wildcards for substring matching
// Add wildcards (*) to enable partial matching: "ander" -> "*ander*"
const wildcardQuery = query.includes('*') ? query : `*${query}*`;
let queryResponse;
try {
// Try with 'text' filter first (full-text search if supported)
queryResponse = await jmapRequest(jmapConfig, [
[
'Email/query',
{
accountId: jmapConfig.accountId,
filter: {
text: wildcardQuery, // Full-text search with wildcards
},
sort: [{ property: 'receivedAt', isAscending: false }],
position: offset,
limit: 200,
},
'query1',
],
]);
} catch (textFilterError) {
// If 'text' filter fails, fall back to OR conditions with wildcards
logger.warn('Text filter failed, falling back to OR conditions', textFilterError);
queryResponse = await jmapRequest(jmapConfig, [
[
'Email/query',
{
accountId: jmapConfig.accountId,
filter: {
operator: 'OR',
conditions: [
{ from: wildcardQuery },
{ to: wildcardQuery },
{ subject: wildcardQuery },
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
position: offset,
limit: 200,
},
'query1',
],
]);
}
const emailIds = queryResponse.methodResponses?.[0]?.[1]?.ids;
if (!emailIds || emailIds.length === 0) {
logger.info('No emails found matching search query');
return [];
}
logger.info(`Found ${emailIds.length} emails matching query`);
// Fetch email metadata
const getResponse = await jmapRequest(jmapConfig, [
[
'Email/get',
{
accountId: jmapConfig.accountId,
ids: emailIds,
properties: ['from', 'to', 'subject', 'receivedAt', 'preview'],
},
'get1',
],
]);
const emailsList = getResponse.methodResponses[0][1].list;
// Get existing contacts for this user
const existingContacts = await db
.select()
.from(contacts)
.where(eq(contacts.userId, userId));
const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase()));
// Group by sender (unique senders)
const sendersMap = new Map();
const myEmail = jmapConfig.username.toLowerCase();
emailsList.forEach((email) => {
const fromEmail = email.from?.[0]?.email;
if (!fromEmail || fromEmail.toLowerCase() === myEmail) {
return; // Skip my own emails
}
// Keep only the most recent email from each sender
if (!sendersMap.has(fromEmail)) {
sendersMap.set(fromEmail, {
email: fromEmail,
name: email.from?.[0]?.name || fromEmail.split('@')[0],
latestSubject: email.subject || '(No Subject)',
latestDate: email.receivedAt,
snippet: email.preview || '',
isContact: contactEmailsSet.has(fromEmail.toLowerCase()),
});
}
});
// Convert to array, sort by date, and apply limit
const senders = Array.from(sendersMap.values())
.sort((a, b) => new Date(b.latestDate) - new Date(a.latestDate))
.slice(0, limit);
logger.success(`Found ${senders.length} unique senders matching query`);
return senders;
} catch (error) {
logger.error('Failed to search emails in JMAP', error);
throw error;
}
};
/**
* Sync emails from a specific sender (when adding as contact)
*/
export const syncEmailsFromSender = async (jmapConfig, userId, contactId, senderEmail) => {
export const syncEmailsFromSender = async (
jmapConfig,
userId,
contactId,
senderEmail,
options = {}
) => {
const { limit = 500 } = options;
try {
logger.info(`Syncing emails from sender: ${senderEmail}`);
@@ -211,7 +349,7 @@ export const syncEmailsFromSender = async (jmapConfig, userId, contactId, sender
conditions: [{ from: senderEmail }, { to: senderEmail }],
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 500,
limit,
},
'query1',
],

View File

@@ -58,7 +58,8 @@ export const linkEmailSchema = z.object({
});
// Create user schema (admin only)
// Create user schema (admin only) - temp password sa generuje automaticky
// Ak je poskytnutý email, môže byť poskytnuté aj emailPassword pre automatické nastavenie JMAP
export const createUserSchema = z.object({
username: z
.string({
@@ -70,15 +71,8 @@ export const createUserSchema = z.object({
/^[a-zA-Z0-9_-]+$/,
'Username môže obsahovať iba písmená, čísla, pomlčky a podčiarkovníky'
),
tempPassword: z
.string({
required_error: 'Dočasné heslo je povinné',
})
.min(8, 'Dočasné heslo musí mať aspoň 8 znakov'),
role: z.enum(['admin', 'member'], {
required_error: 'Rola je povinná',
invalid_type_error: 'Neplatná rola',
}),
email: z.string().email('Neplatný formát emailu').max(255).optional(),
emailPassword: z.string().min(1).optional(),
firstName: z.string().max(100).optional(),
lastName: z.string().max(100).optional(),
});

44
test-search.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Quick test script for JMAP search endpoint
* Run with: node test-search.js
*/
import axios from 'axios';
const API_URL = 'http://localhost:5000/api';
async function testSearch() {
try {
console.log('Testing /emails/search-jmap endpoint...\n');
// You'll need to replace this with a valid session cookie
// Get it from browser DevTools after logging in
const cookie = process.env.TEST_COOKIE || '';
if (!cookie) {
console.error('❌ Please set TEST_COOKIE environment variable');
console.log(' Get it from browser DevTools > Application > Cookies');
process.exit(1);
}
const response = await axios.get(`${API_URL}/emails/search-jmap`, {
params: {
query: 'test',
limit: 10,
offset: 0,
},
headers: {
Cookie: cookie,
},
});
console.log('✅ Success!');
console.log('Status:', response.status);
console.log('Data:', JSON.stringify(response.data, null, 2));
} catch (error) {
console.error('❌ Error:', error.response?.data || error.message);
console.error('Status:', error.response?.status);
}
}
testSearch();