add searching, total unread message, create user
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user