Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/addie-email-domain-linking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": patch
---

Link email contacts to organizations when domains are added, and show email activities on org detail pages via join
72 changes: 72 additions & 0 deletions server/src/db/contacts-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,75 @@ export async function upsertEmailContacts(

return results;
}

/**
* Result from linking contacts by domain
*/
export interface LinkContactsByDomainResult {
contactsLinked: number;
contacts: Array<{
contactId: string;
email: string;
}>;
}

/**
* Link all unmapped email contacts with a specific domain to an organization.
*
* This should be called when a verified domain is added to an organization.
*
* @param domain The email domain to match (e.g., "charter.com")
* @param organizationId The WorkOS organization ID to link contacts to
* @param mappedBy Optional user ID of who triggered the mapping
*/
export async function linkContactsByDomain(
domain: string,
organizationId: string,
mappedBy?: string
): Promise<LinkContactsByDomainResult> {
const pool = getPool();
const normalizedDomain = domain.toLowerCase();

// Update all unmapped contacts with this domain
// Using 'manual_admin' as the mapping source since domain linking is an admin action
const updateResult = await pool.query<{
id: string;
email: string;
}>(
`UPDATE email_contacts
SET organization_id = $1,
mapping_status = 'mapped',
mapping_source = 'manual_admin',
mapped_at = NOW(),
mapped_by_user_id = $2,
updated_at = NOW()
WHERE LOWER(domain) = $3
AND mapping_status = 'unmapped'
RETURNING id, email`,
[organizationId, mappedBy || null, normalizedDomain]
);

const linkedContacts = updateResult.rows;
const contactsLinked = linkedContacts.length;

if (contactsLinked === 0) {
logger.info(
{ domain: normalizedDomain, organizationId },
'No unmapped contacts found for domain'
);
return { contactsLinked: 0, contacts: [] };
}

logger.info(
{ domain: normalizedDomain, organizationId, contactsLinked },
'Linked contacts to organization by domain'
);

return {
contactsLinked,
contacts: linkedContacts.map(c => ({
contactId: c.id,
email: c.email,
})),
};
}
16 changes: 16 additions & 0 deletions server/src/routes/admin/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
removePersonalDomain,
listPersonalDomains,
} from "../../db/personal-domains-db.js";
import { linkContactsByDomain } from "../../db/contacts-db.js";

const slackDb = new SlackDatabase();
const logger = createLogger("admin-domains");
Expand Down Expand Up @@ -1014,11 +1015,26 @@ export function setupDomainRoutes(

logger.info({ orgId, domain: normalizedDomain, isPrimary: is_primary }, "Added domain to organization via WorkOS");

// Link any existing unmapped email contacts with this domain to the org
const linkResult = await linkContactsByDomain(
normalizedDomain,
orgId,
req.user?.id
);

if (linkResult.contactsLinked > 0) {
logger.info(
{ orgId, domain: normalizedDomain, contactsLinked: linkResult.contactsLinked },
"Linked email contacts to organization"
);
}

res.json({
success: true,
domain: normalizedDomain,
is_primary: is_primary || false,
synced_to_workos: true,
contacts_linked: linkResult.contactsLinked,
});
} catch (error) {
logger.error({ err: error }, "Error adding organization domain");
Expand Down
54 changes: 50 additions & 4 deletions server/src/routes/admin/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,58 @@ export function setupOrganizationRoutes(
[orgId]
);

// Get recent activities
// Get recent activities (combines org_activities with email activities via contacts)
const activitiesResult = await pool.query(
`
SELECT *
FROM org_activities
WHERE organization_id = $1
SELECT * FROM (
-- Direct org activities (manual logs, etc.)
SELECT
id::text as id,
activity_type,
description,
logged_by_user_id,
logged_by_name,
activity_date,
is_next_step,
next_step_due_date,
next_step_owner_user_id,
next_step_owner_name,
next_step_completed_at,
metadata,
created_at,
updated_at
FROM org_activities
WHERE organization_id = $1

UNION ALL

-- Email activities via linked contacts
SELECT
eca.id::text as id,
'email_inbound' as activity_type,
eca.insights as description,
NULL as logged_by_user_id,
'Addie' as logged_by_name,
eca.email_date as activity_date,
false as is_next_step,
NULL as next_step_due_date,
NULL as next_step_owner_user_id,
NULL as next_step_owner_name,
NULL as next_step_completed_at,
jsonb_build_object(
'email_id', eca.email_id,
'message_id', eca.message_id,
'subject', eca.subject,
'contact_email', ec.email,
'source', 'email_contact_activities'
) as metadata,
eca.created_at,
eca.created_at as updated_at
FROM email_contact_activities eca
INNER JOIN email_activity_contacts eac ON eac.activity_id = eca.id AND eac.is_primary = true
INNER JOIN email_contacts ec ON ec.id = eac.contact_id
WHERE ec.organization_id = $1
) combined
ORDER BY activity_date DESC
LIMIT 50
`,
Expand Down
30 changes: 3 additions & 27 deletions server/src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,33 +682,9 @@ async function handleProspectEmail(data: ResendInboundPayload['data']): Promise<
);
}

// If primary contact is linked to an org, also store in org_activities
if (primaryContact.organizationId) {
await pool.query(
`INSERT INTO org_activities (
organization_id,
activity_type,
description,
logged_by_name,
activity_date,
metadata
) VALUES ($1, $2, $3, $4, $5, $6)`,
[
primaryContact.organizationId,
'email_inbound',
insights,
'Addie',
new Date(data.created_at),
JSON.stringify({
...metadata,
message_id: data.message_id,
primary_contact_email: primaryContact.email,
insight_method: method,
tokens_used: tokensUsed,
}),
]
);
}
// Note: Email activities are shown on org detail pages via a JOIN query
// through email_contacts.organization_id, so we don't need to duplicate
// the activity into org_activities here.

return {
activityId: activityResult.rows[0].id,
Expand Down