add email threads to companies
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import * as companyService from '../services/company.service.js';
|
import * as companyService from '../services/company.service.js';
|
||||||
import * as noteService from '../services/note.service.js';
|
import * as noteService from '../services/note.service.js';
|
||||||
import * as companyReminderService from '../services/company-reminder.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';
|
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 company with relations (projects, todos, notes)
|
||||||
* GET /api/companies/:companyId/details
|
* GET /api/companies/:companyId/details
|
||||||
|
|||||||
20
src/db/migrations/0008_add_company_id_to_emails.sql
Normal file
20
src/db/migrations/0008_add_company_id_to_emails.sql
Normal file
@@ -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);
|
||||||
@@ -85,6 +85,7 @@ export const emails = pgTable('emails', {
|
|||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(),
|
emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(),
|
||||||
contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),
|
contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),
|
||||||
|
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'set null' }),
|
||||||
jmapId: text('jmap_id').unique(),
|
jmapId: text('jmap_id').unique(),
|
||||||
messageId: text('message_id').unique(),
|
messageId: text('message_id').unique(),
|
||||||
threadId: text('thread_id'),
|
threadId: text('thread_id'),
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ router.use(authenticate);
|
|||||||
router.get('/reminders/summary', companyController.getReminderSummary);
|
router.get('/reminders/summary', companyController.getReminderSummary);
|
||||||
router.get('/reminders/counts', companyController.getReminderCountsByCompany);
|
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
|
* Company management
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -46,6 +46,40 @@ router.patch(
|
|||||||
contactController.updateContact
|
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
|
// Remove a contact
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:contactId',
|
'/:contactId',
|
||||||
|
|||||||
100
src/services/company-email.service.js
Normal file
100
src/services/company-email.service.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -88,7 +88,10 @@ export const addContact = async (emailAccountId, jmapConfig, email, name = '', n
|
|||||||
for (const emailToReassign of emailsToReassign) {
|
for (const emailToReassign of emailsToReassign) {
|
||||||
await db
|
await db
|
||||||
.update(emails)
|
.update(emails)
|
||||||
.set({ contactId: newContact.id })
|
.set({
|
||||||
|
contactId: newContact.id,
|
||||||
|
companyId: newContact.companyId || null,
|
||||||
|
})
|
||||||
.where(eq(emails.id, emailToReassign.id));
|
.where(eq(emails.id, emailToReassign.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +175,15 @@ export const linkCompanyToContact = async (contactId, emailAccountId, companyId)
|
|||||||
.where(eq(contacts.id, contactId))
|
.where(eq(contacts.id, contactId))
|
||||||
.returning();
|
.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;
|
return updated;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,6 +202,15 @@ export const unlinkCompanyFromContact = async (contactId, emailAccountId) => {
|
|||||||
.where(eq(contacts.id, contactId))
|
.where(eq(contacts.id, contactId))
|
||||||
.returning();
|
.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;
|
return updated;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,6 +260,15 @@ export const createCompanyFromContact = async (contactId, emailAccountId, userId
|
|||||||
.where(eq(contacts.id, contactId))
|
.where(eq(contacts.id, contactId))
|
||||||
.returning();
|
.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 {
|
return {
|
||||||
company: newCompany,
|
company: newCompany,
|
||||||
contact: updatedContact,
|
contact: updatedContact,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const getEmailsForAccount = async (emailAccountId) => {
|
|||||||
sentByUserId: emails.sentByUserId,
|
sentByUserId: emails.sentByUserId,
|
||||||
date: emails.date,
|
date: emails.date,
|
||||||
createdAt: emails.createdAt,
|
createdAt: emails.createdAt,
|
||||||
|
companyId: emails.companyId,
|
||||||
emailAccountId: emails.emailAccountId,
|
emailAccountId: emails.emailAccountId,
|
||||||
contact: {
|
contact: {
|
||||||
id: contacts.id,
|
id: contacts.id,
|
||||||
|
|||||||
@@ -358,6 +358,12 @@ export const syncEmailsFromSender = async (
|
|||||||
try {
|
try {
|
||||||
logger.info(`Syncing emails with contact: ${senderEmail} for account ${emailAccountId}`);
|
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
|
// Get Inbox and Sent mailboxes ONLY
|
||||||
const mailboxes = await getMailboxes(jmapConfig);
|
const mailboxes = await getMailboxes(jmapConfig);
|
||||||
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' || m.name === 'Inbox' || m.name === 'INBOX');
|
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({
|
await db.insert(emails).values({
|
||||||
emailAccountId,
|
emailAccountId,
|
||||||
contactId,
|
contactId,
|
||||||
|
companyId: contact?.companyId || null,
|
||||||
jmapId: email.id,
|
jmapId: email.id,
|
||||||
messageId,
|
messageId,
|
||||||
threadId: email.threadId || messageId,
|
threadId: email.threadId || messageId,
|
||||||
@@ -722,6 +729,7 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject,
|
|||||||
await db.insert(emails).values({
|
await db.insert(emails).values({
|
||||||
emailAccountId,
|
emailAccountId,
|
||||||
contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts
|
contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts
|
||||||
|
companyId: recipientContact?.companyId || null,
|
||||||
jmapId: createdEmailId,
|
jmapId: createdEmailId,
|
||||||
messageId,
|
messageId,
|
||||||
threadId: threadId || messageId,
|
threadId: threadId || messageId,
|
||||||
|
|||||||
Reference in New Issue
Block a user