diff --git a/src/controllers/company.controller.js b/src/controllers/company.controller.js index e3a6e48..070501a 100644 --- a/src/controllers/company.controller.js +++ b/src/controllers/company.controller.js @@ -1,6 +1,7 @@ import * as companyService from '../services/company.service.js'; import * as noteService from '../services/note.service.js'; import * as companyReminderService from '../services/company-reminder.service.js'; +import * as companyEmailService from '../services/company-email.service.js'; import { formatErrorResponse } from '../utils/errors.js'; /** @@ -44,6 +45,30 @@ export const getCompanyById = async (req, res) => { } }; +/** + * Get company email threads aggregated across user's email accounts + * GET /api/companies/:companyId/email-threads + */ +export const getCompanyEmailThreads = async (req, res) => { + try { + const userId = req.userId; + const { companyId } = req.params; + + // Ensure company exists + await companyService.getCompanyById(companyId); + + const result = await companyEmailService.getCompanyEmailThreads(companyId, userId); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + /** * Get company with relations (projects, todos, notes) * GET /api/companies/:companyId/details diff --git a/src/db/migrations/0008_add_company_id_to_emails.sql b/src/db/migrations/0008_add_company_id_to_emails.sql new file mode 100644 index 0000000..dc3a957 --- /dev/null +++ b/src/db/migrations/0008_add_company_id_to_emails.sql @@ -0,0 +1,20 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'emails' AND column_name = 'company_id' + ) THEN + ALTER TABLE emails + ADD COLUMN company_id UUID REFERENCES companies(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_emails_company_id ON emails(company_id); +CREATE INDEX IF NOT EXISTS idx_emails_company_thread ON emails(company_id, thread_id); + +UPDATE emails e +SET company_id = c.company_id +FROM contacts c +WHERE e.contact_id = c.id + AND c.company_id IS NOT NULL + AND (e.company_id IS NULL OR e.company_id <> c.company_id); diff --git a/src/db/schema.js b/src/db/schema.js index b6ca7c5..6e91bba 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -85,6 +85,7 @@ export const emails = pgTable('emails', { id: uuid('id').primaryKey().defaultRandom(), emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(), contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }), + companyId: uuid('company_id').references(() => companies.id, { onDelete: 'set null' }), jmapId: text('jmap_id').unique(), messageId: text('message_id').unique(), threadId: text('thread_id'), diff --git a/src/routes/company.routes.js b/src/routes/company.routes.js index c999d4e..aa60e6c 100644 --- a/src/routes/company.routes.js +++ b/src/routes/company.routes.js @@ -14,6 +14,13 @@ router.use(authenticate); router.get('/reminders/summary', companyController.getReminderSummary); router.get('/reminders/counts', companyController.getReminderCountsByCompany); +// Company email threads +router.get( + '/:companyId/email-threads', + validateParams(z.object({ companyId: z.string().uuid() })), + companyController.getCompanyEmailThreads +); + /** * Company management */ diff --git a/src/routes/contact.routes.js b/src/routes/contact.routes.js index 2f04422..6e1eaac 100644 --- a/src/routes/contact.routes.js +++ b/src/routes/contact.routes.js @@ -46,6 +46,40 @@ router.patch( contactController.updateContact ); +// Link company to contact +router.post( + '/:contactId/link-company', + validateParams(z.object({ contactId: z.string().uuid() })), + validateBody(z.object({ companyId: z.string().uuid() })), + contactController.linkCompanyToContact +); + +// Unlink company from contact +router.post( + '/:contactId/unlink-company', + validateParams(z.object({ contactId: z.string().uuid() })), + contactController.unlinkCompanyFromContact +); + +// Create company from contact +router.post( + '/:contactId/create-company', + validateParams(z.object({ contactId: z.string().uuid() })), + validateBody( + z.object({ + name: z.string().optional(), + email: z.string().email().optional(), + phone: z.string().optional(), + address: z.string().optional(), + city: z.string().optional(), + country: z.string().optional(), + website: z.string().optional(), + description: z.string().optional(), + }) + ), + contactController.createCompanyFromContact +); + // Remove a contact router.delete( '/:contactId', diff --git a/src/services/company-email.service.js b/src/services/company-email.service.js new file mode 100644 index 0000000..eaebfa2 --- /dev/null +++ b/src/services/company-email.service.js @@ -0,0 +1,100 @@ +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { contacts, emailAccounts, emails, userEmailAccounts } from '../db/schema.js'; + +export const getCompanyEmailThreads = async (companyId, userId) => { + const accountLinks = await db + .select({ + id: emailAccounts.id, + email: emailAccounts.email, + isActive: emailAccounts.isActive, + }) + .from(userEmailAccounts) + .innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id)) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(emailAccounts.isActive, true) + ) + ); + + const accountIds = accountLinks.map((acc) => acc.id); + + if (accountIds.length === 0) { + return { accounts: [], threads: [] }; + } + + const rows = await db + .select({ + threadId: emails.threadId, + emailAccountId: emails.emailAccountId, + accountEmail: emailAccounts.email, + subject: emails.subject, + date: emails.date, + isRead: emails.isRead, + from: emails.from, + to: emails.to, + contactId: contacts.id, + contactEmail: contacts.email, + contactName: contacts.name, + }) + .from(emails) + .innerJoin(emailAccounts, eq(emails.emailAccountId, emailAccounts.id)) + .leftJoin(contacts, eq(emails.contactId, contacts.id)) + .where( + and( + inArray(emails.emailAccountId, accountIds), + eq(emails.companyId, companyId) + ) + ) + .orderBy(desc(emails.date)); + + const threadsMap = new Map(); + + for (const row of rows) { + const threadKey = `${row.emailAccountId}:${row.threadId}`; + const lastDate = row.date ? new Date(row.date) : null; + const existing = threadsMap.get(threadKey); + + if (!existing) { + threadsMap.set(threadKey, { + threadId: row.threadId, + emailAccountId: row.emailAccountId, + accountEmail: row.accountEmail, + contactId: row.contactId, + contactEmail: row.contactEmail, + contactName: row.contactName, + subject: row.subject, + lastFrom: row.from, + lastTo: row.to, + lastMessageAt: lastDate, + unreadCount: row.isRead ? 0 : 1, + totalCount: 1, + }); + continue; + } + + existing.totalCount += 1; + if (!row.isRead) { + existing.unreadCount += 1; + } + + if (lastDate && (!existing.lastMessageAt || lastDate > existing.lastMessageAt)) { + existing.lastMessageAt = lastDate; + existing.subject = row.subject; + existing.lastFrom = row.from; + existing.lastTo = row.to; + } + } + + const threads = Array.from(threadsMap.values()).sort((a, b) => { + const aTime = a.lastMessageAt ? a.lastMessageAt.getTime() : 0; + const bTime = b.lastMessageAt ? b.lastMessageAt.getTime() : 0; + return bTime - aTime; + }); + + return { + accounts: accountLinks, + threads, + }; +}; diff --git a/src/services/contact.service.js b/src/services/contact.service.js index 27d1efe..de52f5e 100644 --- a/src/services/contact.service.js +++ b/src/services/contact.service.js @@ -88,7 +88,10 @@ export const addContact = async (emailAccountId, jmapConfig, email, name = '', n for (const emailToReassign of emailsToReassign) { await db .update(emails) - .set({ contactId: newContact.id }) + .set({ + contactId: newContact.id, + companyId: newContact.companyId || null, + }) .where(eq(emails.id, emailToReassign.id)); } @@ -172,6 +175,15 @@ export const linkCompanyToContact = async (contactId, emailAccountId, companyId) .where(eq(contacts.id, contactId)) .returning(); + // Propagate company assignment to existing emails for this contact + await db + .update(emails) + .set({ + companyId, + updatedAt: new Date(), + }) + .where(eq(emails.contactId, contactId)); + return updated; }; @@ -190,6 +202,15 @@ export const unlinkCompanyFromContact = async (contactId, emailAccountId) => { .where(eq(contacts.id, contactId)) .returning(); + // Remove company assignment from existing emails for this contact + await db + .update(emails) + .set({ + companyId: null, + updatedAt: new Date(), + }) + .where(eq(emails.contactId, contactId)); + return updated; }; @@ -239,6 +260,15 @@ export const createCompanyFromContact = async (contactId, emailAccountId, userId .where(eq(contacts.id, contactId)) .returning(); + // Update existing emails for this contact to reference the new company + await db + .update(emails) + .set({ + companyId: newCompany.id, + updatedAt: new Date(), + }) + .where(eq(emails.contactId, contactId)); + return { company: newCompany, contact: updatedContact, diff --git a/src/services/crm-email.service.js b/src/services/crm-email.service.js index cce9231..4b42324 100644 --- a/src/services/crm-email.service.js +++ b/src/services/crm-email.service.js @@ -22,6 +22,7 @@ export const getEmailsForAccount = async (emailAccountId) => { sentByUserId: emails.sentByUserId, date: emails.date, createdAt: emails.createdAt, + companyId: emails.companyId, emailAccountId: emails.emailAccountId, contact: { id: contacts.id, diff --git a/src/services/jmap.service.js b/src/services/jmap.service.js index 0879993..570cba4 100644 --- a/src/services/jmap.service.js +++ b/src/services/jmap.service.js @@ -358,6 +358,12 @@ export const syncEmailsFromSender = async ( try { logger.info(`Syncing emails with contact: ${senderEmail} for account ${emailAccountId}`); + const [contact] = await db + .select({ companyId: contacts.companyId }) + .from(contacts) + .where(eq(contacts.id, contactId)) + .limit(1); + // Get Inbox and Sent mailboxes ONLY const mailboxes = await getMailboxes(jmapConfig); const inboxMailbox = mailboxes.find(m => m.role === 'inbox' || m.name === 'Inbox' || m.name === 'INBOX'); @@ -510,6 +516,7 @@ export const syncEmailsFromSender = async ( await db.insert(emails).values({ emailAccountId, contactId, + companyId: contact?.companyId || null, jmapId: email.id, messageId, threadId: email.threadId || messageId, @@ -722,6 +729,7 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject, await db.insert(emails).values({ emailAccountId, contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts + companyId: recipientContact?.companyId || null, jmapId: createdEmailId, messageId, threadId: threadId || messageId,