diff --git a/packages/twenty-chrome-extension/src/background/index.ts b/packages/twenty-chrome-extension/src/background/index.ts index 271224ee05eb..0140c3b6d947 100644 --- a/packages/twenty-chrome-extension/src/background/index.ts +++ b/packages/twenty-chrome-extension/src/background/index.ts @@ -1,4 +1,5 @@ import { openOptionsPage } from '~/background/utils/openOptionsPage'; +import { isDefined } from '~/utils/isDefined'; // Open options page programmatically in a new tab. chrome.runtime.onInstalled.addListener((details) => { @@ -16,11 +17,10 @@ chrome.action.onClicked.addListener((tab) => { // The cases themselves are labelled such that their operations are reflected by their names. chrome.runtime.onMessage.addListener((message, _, sendResponse) => { switch (message.action) { - case 'getActiveTabUrl': // e.g. "https://linkedin.com/company/twenty/" + case 'getActiveTab': // e.g. "https://linkedin.com/company/twenty/" chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - if (tabs && tabs[0]) { - const activeTabUrl: string | undefined = tabs[0].url; - sendResponse({ url: activeTabUrl }); + if (isDefined(tabs) && isDefined(tabs[0])) { + sendResponse({ tab: tabs[0] }); } }); break; @@ -34,27 +34,14 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => { return true; }); -// Keep track of the tabs in which the "Add to Twenty" button has already been injected. -// Could be that the content script is executed at "https://linkedin.com/feed/", but is needed at "https://linkedin.com/in/mabdullahabaid/". -// However, since Linkedin is a SPA, the script would not be re-executed when you navigate to "https://linkedin.com/in/mabdullahabaid/" from a user action. -// Therefore, this tracks if the user is on desired route and then re-executes the content script to create the "Add to Twenty" button. -// We use a "Set" to keep track of tab ids because it could be that the "Add to Twenty" button was created at "https://linkedin/com/company/twenty". -// However, when we change to about on the company page, the url becomes "https://www.linkedin.com/company/twenty/about/" and the button is created again. -// This creates a duplicate button, which we want to avoid. So, we instruct the extension to only create the button once for any of the following urls. -// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/". -const injectedTabs: Set = new Set(); - chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { const isDesiredRoute = tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) || tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/); if (changeInfo.status === 'complete' && tab.active) { - if (isDesiredRoute && !injectedTabs.has(tabId)) { + if (isDefined(isDesiredRoute)) { chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' }); - injectedTabs.add(tabId); - } else if (!isDesiredRoute) { - injectedTabs.delete(tabId); // Clear entry if navigated away from LinkedIn company page. } } }); diff --git a/packages/twenty-chrome-extension/src/contentScript/createButton.ts b/packages/twenty-chrome-extension/src/contentScript/createButton.ts index 1fa9e9a3f154..a566d1465cf7 100644 --- a/packages/twenty-chrome-extension/src/contentScript/createButton.ts +++ b/packages/twenty-chrome-extension/src/contentScript/createButton.ts @@ -1,12 +1,17 @@ -const createNewButton = ( - text: string, - onClickHandler: () => void, -): HTMLDivElement => { +import { isDefined } from '~/utils/isDefined'; + +export const createDefaultButton = ( + buttonId: string, + onClickHandler?: () => void, + buttonText = '', +) => { + const btn = document.getElementById(buttonId); + if (isDefined(btn)) return btn; const div = document.createElement('div'); const img = document.createElement('img'); const span = document.createElement('span'); - span.textContent = text; + span.textContent = buttonText; img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII='; img.height = 16; @@ -32,43 +37,38 @@ const createNewButton = ( Object.assign(div.style, divStyles); - // // Apply common styles to the button. - // Object.assign(buttonDiv.style, buttonDivStyles); - - // // Apply common styles to specifc states of a button. - // newButton.addEventListener('mouseenter', () => { - // const hoverStyles = { - // backgroundColor: '#5e5e5e', - // borderColor: '#5e5e5e', - // }; - // Object.assign(newButton.style, hoverStyles); - // }); + // Apply common styles to specifc states of a button. + div.addEventListener('mouseenter', () => { + const hoverStyles = { + //eslint-disable-next-line @nx/workspace-no-hardcoded-colors + backgroundColor: '#5e5e5e', + //eslint-disable-next-line @nx/workspace-no-hardcoded-colors + borderColor: '#5e5e5e', + }; + Object.assign(div.style, hoverStyles); + }); - // newButton.addEventListener('mouseleave', () => { - // Object.assign(newButton.style, buttonStyles); - // }); + div.addEventListener('mouseleave', () => { + Object.assign(div.style, divStyles); + }); - // Handle the click event. - div.addEventListener('click', async () => { - const { apiKey } = await chrome.storage.local.get('apiKey'); + div.addEventListener('click', async (e) => { + e.preventDefault(); + const store = await chrome.storage.local.get(); // If an api key is not set, the options page opens up to allow the user to configure an api key. - if (!apiKey) { + if (!store.apiKey) { chrome.runtime.sendMessage({ action: 'openOptionsPage' }); return; } - // Update content during the resolution of the request. - span.textContent = 'Saving...'; - - // Call the provided onClickHandler function to handle button click logic - onClickHandler(); + onClickHandler?.(); }); + div.id = buttonId; + div.appendChild(img); div.appendChild(span); return div; }; - -export default createNewButton; diff --git a/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts b/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts index 5096438a0557..7b59566c4969 100644 --- a/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts +++ b/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts @@ -1,123 +1,117 @@ -import createNewButton from '~/contentScript/createButton'; +import { createDefaultButton } from '~/contentScript/createButton'; import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink'; import extractDomain from '~/contentScript/utils/extractDomain'; import { createCompany, fetchCompany } from '~/db/company.db'; import { CompanyInput } from '~/db/types/company.types'; +import { isDefined } from '~/utils/isDefined'; -const insertButtonForCompany = async (): Promise => { - // Select the element in which to create the button. - const parentDiv: HTMLDivElement | null = document.querySelector( - '.org-top-card-primary-actions__inner', - ); +export const checkIfCompanyExists = async () => { + const { tab: activeTab } = await chrome.runtime.sendMessage({ + action: 'getActiveTab', + }); - // Create the button with desired callback funciton to execute upon click. - if (parentDiv) { - // Extract company-specific data from the DOM - const companyNameElement = document.querySelector( - '.org-top-card-summary__title', - ); - const domainNameElement = document.querySelector( - '.org-top-card-primary-actions__inner a', - ); - const addressElement = document.querySelectorAll( - '.org-top-card-summary-info-list__info-item', - )[1]; - const employeesNumberElement = document.querySelectorAll( - '.org-top-card-summary-info-list__info-item', - )[3]; + const companyURL = extractCompanyLinkedinLink(activeTab.url); - // Get the text content or other necessary data from the DOM elements - const companyName = companyNameElement - ? companyNameElement.getAttribute('title') - : ''; - const domainName = extractDomain( - domainNameElement && domainNameElement.getAttribute('href'), - ); - const address = addressElement - ? addressElement.textContent?.trim().replace(/\s+/g, ' ') - : ''; - const employees = employeesNumberElement - ? Number( - employeesNumberElement.textContent - ?.trim() - .replace(/\s+/g, ' ') - .split('-')[0], - ) - : 0; + return await fetchCompany({ + linkedinLink: { + url: { eq: companyURL }, + label: { eq: companyURL }, + }, + }); +}; - // Prepare company data to send to the backend - const companyInputData: CompanyInput = { - name: companyName ?? '', - domainName: domainName, - address: address ?? '', - employees: employees, - }; +export const addCompany = async () => { + // Extract company-specific data from the DOM + const companyNameElement = document.querySelector( + '.org-top-card-summary__title', + ); + const domainNameElement = document.querySelector( + '.org-top-card-primary-actions__inner a', + ); + const addressElement = document.querySelectorAll( + '.org-top-card-summary-info-list__info-item', + )[1]; + const employeesNumberElement = document.querySelectorAll( + '.org-top-card-summary-info-list__info-item', + )[3]; - // Extract active tab url using chrome API - an event is triggered here and is caught by background script. - const { url: activeTabUrl } = await chrome.runtime.sendMessage({ - action: 'getActiveTabUrl', - }); + // Get the text content or other necessary data from the DOM elements + const companyName = companyNameElement + ? companyNameElement.getAttribute('title') + : ''; + const domainName = extractDomain( + domainNameElement && domainNameElement.getAttribute('href'), + ); + const address = addressElement + ? addressElement.textContent?.trim().replace(/\s+/g, ' ') + : ''; + const employees = employeesNumberElement + ? Number( + employeesNumberElement.textContent + ?.trim() + .replace(/\s+/g, ' ') + .split('-')[0], + ) + : 0; - // Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty - const companyURL = extractCompanyLinkedinLink(activeTabUrl); - companyInputData.linkedinLink = { url: companyURL, label: companyURL }; + // Prepare company data to send to the backend + const companyInputData: CompanyInput = { + name: companyName ?? '', + domainName: domainName, + address: address ?? '', + employees: employees, + }; - const company = await fetchCompany({ - linkedinLink: { - url: { eq: companyURL }, - label: { eq: companyURL }, - }, - }); - if (company) { - const savedCompany: HTMLDivElement = createNewButton( - 'Saved', - async () => {}, - ); - // Include the button in the DOM. - parentDiv.prepend(savedCompany); + // Extract active tab url using chrome API - an event is triggered here and is caught by background script. + const { tab: activeTab } = await chrome.runtime.sendMessage({ + action: 'getActiveTab', + }); - // Write button specific styles here - common ones can be found in createButton.ts. - const buttonSpecificStyles = { - alignSelf: 'end', - }; + // Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty + const companyURL = extractCompanyLinkedinLink(activeTab.url); + companyInputData.linkedinLink = { url: companyURL, label: companyURL }; - Object.assign(savedCompany.style, buttonSpecificStyles); - } else { - const newButtonCompany: HTMLDivElement = createNewButton( - 'Add to Twenty', - async () => { - const response = await createCompany(companyInputData); + const company = await createCompany(companyInputData); + return company; +}; - if (response) { - newButtonCompany.textContent = 'Saved'; - newButtonCompany.setAttribute('disabled', 'true'); +export const insertButtonForCompany = async () => { + const companyButtonDiv = createDefaultButton( + 'twenty-company-btn', + async () => { + if (isDefined(companyButtonDiv)) { + const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0]; + companyBtnSpan.textContent = 'Saving...'; + const company = await addCompany(); + if (isDefined(company)) { + companyBtnSpan.textContent = 'Saved'; + Object.assign(companyButtonDiv.style, { pointerEvents: 'none' }); + } else { + companyBtnSpan.textContent = 'Try again'; + } + } + }, + ); - // Button specific styles once the button is unclickable after successfully sending data to server. - newButtonCompany.addEventListener('mouseenter', () => { - const hoverStyles = { - backgroundColor: 'black', - borderColor: 'black', - cursor: 'default', - }; - Object.assign(newButtonCompany.style, hoverStyles); - }); - } else { - newButtonCompany.textContent = 'Try Again'; - } - }, - ); + const parentDiv: HTMLDivElement | null = document.querySelector( + '.org-top-card-primary-actions__inner', + ); - // Include the button in the DOM. - parentDiv.prepend(newButtonCompany); + if (isDefined(parentDiv)) { + Object.assign(companyButtonDiv.style, { + marginLeft: '.8rem', + marginTop: '.4rem', + }); + parentDiv.prepend(companyButtonDiv); + } - // Write button specific styles here - common ones can be found in createButton.ts. - const buttonSpecificStyles = { - alignSelf: 'end', - }; + const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0]; + const company = await checkIfCompanyExists(); - Object.assign(newButtonCompany.style, buttonSpecificStyles); - } + if (isDefined(company)) { + companyBtnSpan.textContent = 'Saved'; + Object.assign(companyButtonDiv.style, { pointerEvents: 'none' }); + } else { + companyBtnSpan.textContent = 'Add to Twenty'; } }; - -export default insertButtonForCompany; diff --git a/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts b/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts index 2d5076dfa30a..0fb36d0b1938 100644 --- a/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts +++ b/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts @@ -1,127 +1,125 @@ -import createNewButton from '~/contentScript/createButton'; +import { createDefaultButton } from '~/contentScript/createButton'; import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName'; import { createPerson, fetchPerson } from '~/db/person.db'; import { PersonInput } from '~/db/types/person.types'; +import { isDefined } from '~/utils/isDefined'; -const insertButtonForPerson = async (): Promise => { - // Select the element in which to create the button. - const parentDiv: HTMLDivElement | null = document.querySelector( - '.pv-top-card-v2-ctas', - ); +export const checkIfPersonExists = async () => { + const { tab: activeTab } = await chrome.runtime.sendMessage({ + action: 'getActiveTab', + }); - // Create the button with desired callback funciton to execute upon click. - if (parentDiv) { - // Extract person-specific data from the DOM. - const personNameElement = document.querySelector('.text-heading-xlarge'); + let activeTabUrl = ''; + if (isDefined(activeTab.url.endsWith('/'))) { + activeTabUrl = activeTab.url.slice(0, -1); + } - const separatorElement = document.querySelector( - '.pv-text-details__separator', - ); - const personCityElement = separatorElement?.previousElementSibling; + const personNameElement = document.querySelector('.text-heading-xlarge'); + const personName = personNameElement ? personNameElement.textContent : ''; + + const { firstName, lastName } = extractFirstAndLastName(String(personName)); + const person = await fetchPerson({ + name: { + firstName: { eq: firstName }, + lastName: { eq: lastName }, + }, + linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } }, + }); + return person; +}; - const profilePictureElement = document.querySelector( - '.pv-top-card-profile-picture__image', - ); +export const addPerson = async () => { + const personNameElement = document.querySelector('.text-heading-xlarge'); - const firstListItem = document.querySelector( - 'div[data-view-name="profile-component-entity"]', - ); - const secondDivElement = firstListItem?.querySelector('div:nth-child(2)'); - const ariaHiddenSpan = secondDivElement?.querySelector( - 'span[aria-hidden="true"]', - ); + const separatorElement = document.querySelector( + '.pv-text-details__separator', + ); + const personCityElement = separatorElement?.previousElementSibling; - // Get the text content or other necessary data from the DOM elements. - const personName = personNameElement ? personNameElement.textContent : ''; - const personCity = personCityElement - ? personCityElement.textContent?.trim().replace(/\s+/g, ' ').split(',')[0] - : ''; - const profilePicture = profilePictureElement - ? profilePictureElement?.getAttribute('src') - : ''; - const jobTitle = ariaHiddenSpan ? ariaHiddenSpan.textContent?.trim() : ''; - - const { firstName, lastName } = extractFirstAndLastName(String(personName)); - - // Prepare person data to send to the backend. - const personData: PersonInput = { - name: { firstName, lastName }, - city: personCity ?? '', - avatarUrl: profilePicture ?? '', - jobTitle: jobTitle ?? '', - linkedinLink: { url: '', label: '' }, - }; - - // Extract active tab url using chrome API - an event is triggered here and is caught by background script. - let { url: activeTabUrl } = await chrome.runtime.sendMessage({ - action: 'getActiveTabUrl', - }); - - // Remove last slash from the URL for consistency when saving usernames. - if (activeTabUrl.endsWith('/')) { - activeTabUrl = activeTabUrl.slice(0, -1); - } + const profilePictureElement = document.querySelector( + '.pv-top-card-profile-picture__image', + ); - personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl }; + const firstListItem = document.querySelector( + 'div[data-view-name="profile-component-entity"]', + ); + const secondDivElement = firstListItem?.querySelector('div:nth-child(2)'); + const ariaHiddenSpan = secondDivElement?.querySelector( + 'span[aria-hidden="true"]', + ); - const person = await fetchPerson({ - name: { - firstName: { eq: firstName }, - lastName: { eq: lastName }, - }, - linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } }, - }); + // Get the text content or other necessary data from the DOM elements. + const personName = personNameElement ? personNameElement.textContent : ''; + const personCity = personCityElement + ? personCityElement.textContent?.trim().replace(/\s+/g, ' ').split(',')[0] + : ''; + const profilePicture = profilePictureElement + ? profilePictureElement?.getAttribute('src') + : ''; + const jobTitle = ariaHiddenSpan ? ariaHiddenSpan.textContent?.trim() : ''; + + const { firstName, lastName } = extractFirstAndLastName(String(personName)); + + // Prepare person data to send to the backend. + const personData: PersonInput = { + name: { firstName, lastName }, + city: personCity ?? '', + avatarUrl: profilePicture ?? '', + jobTitle: jobTitle ?? '', + linkedinLink: { url: '', label: '' }, + }; + + // Extract active tab url using chrome API - an event is triggered here and is caught by background script. + const { tab: activeTab } = await chrome.runtime.sendMessage({ + action: 'getActiveTab', + }); + + let activeTabUrl = ''; + + // Remove last slash from the URL for consistency when saving usernames. + if (isDefined(activeTab.url.endsWith('/'))) { + activeTabUrl = activeTab.url.slice(0, -1); + } - if (person) { - const savedPerson: HTMLDivElement = createNewButton( - 'Saved', - async () => {}, - ); + personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl }; + const person = await createPerson(personData); + return person; +}; - // Include the button in the DOM. - parentDiv.prepend(savedPerson); +export const insertButtonForPerson = async () => { + const personButtonDiv = createDefaultButton('twenty-person-btn', async () => { + if (isDefined(personButtonDiv)) { + const personBtnSpan = personButtonDiv.getElementsByTagName('span')[0]; + personBtnSpan.textContent = 'Saving...'; + const person = await addPerson(); + if (isDefined(person)) { + personBtnSpan.textContent = 'Saved'; + Object.assign(personButtonDiv.style, { pointerEvents: 'none' }); + } else { + personBtnSpan.textContent = 'Try again'; + } + } + }); - // Write button specific styles here - common ones can be found in createButton.ts. - const buttonSpecificStyles = { - marginRight: '0.5em', - }; + if (isDefined(personButtonDiv)) { + const parentDiv: HTMLDivElement | null = document.querySelector( + '.pv-top-card-v2-ctas', + ); + + if (isDefined(parentDiv)) { + Object.assign(personButtonDiv.style, { + marginRight: '.8rem', + }); + parentDiv.prepend(personButtonDiv); + } - Object.assign(savedPerson.style, buttonSpecificStyles); + const personBtnSpan = personButtonDiv.getElementsByTagName('span')[0]; + const person = await checkIfPersonExists(); + if (isDefined(person)) { + personBtnSpan.textContent = 'Saved'; + Object.assign(personButtonDiv.style, { pointerEvents: 'none' }); } else { - const newButtonPerson: HTMLDivElement = createNewButton( - 'Add to Twenty', - async () => { - const response = await createPerson(personData); - if (response) { - newButtonPerson.textContent = 'Saved'; - newButtonPerson.setAttribute('disabled', 'true'); - - // Button specific styles once the button is unclickable after successfully sending data to server. - newButtonPerson.addEventListener('mouseenter', () => { - const hoverStyles = { - backgroundColor: 'black', - borderColor: 'black', - cursor: 'default', - }; - Object.assign(newButtonPerson.style, hoverStyles); - }); - } else { - newButtonPerson.textContent = 'Try Again'; - } - }, - ); - - // Include the button in the DOM. - parentDiv.prepend(newButtonPerson); - - // Write button specific styles here - common ones can be found in createButton.ts. - const buttonSpecificStyles = { - marginRight: '0.5em', - }; - - Object.assign(newButtonPerson.style, buttonSpecificStyles); + personBtnSpan.textContent = 'Add to Twenty'; } } }; - -export default insertButtonForPerson; diff --git a/packages/twenty-chrome-extension/src/contentScript/index.ts b/packages/twenty-chrome-extension/src/contentScript/index.ts index 2cf8d378e85b..4df810e5cbeb 100644 --- a/packages/twenty-chrome-extension/src/contentScript/index.ts +++ b/packages/twenty-chrome-extension/src/contentScript/index.ts @@ -1,10 +1,13 @@ -import insertButtonForCompany from '~/contentScript/extractCompanyProfile'; -import insertButtonForPerson from '~/contentScript/extractPersonProfile'; +import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile'; +import { insertButtonForPerson } from '~/contentScript/extractPersonProfile'; // Inject buttons into the DOM when SPA is reloaded on the resource url. // e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/ -await insertButtonForCompany(); -await insertButtonForPerson(); +// await insertButtonForCompany(); +(async () => { + await insertButtonForCompany(); + await insertButtonForPerson(); +})(); // The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/. // However, there would never be another reload in a single page application unless triggered manually. diff --git a/packages/twenty-chrome-extension/src/contentScript/utils/extractCompanyLinkedinLink.ts b/packages/twenty-chrome-extension/src/contentScript/utils/extractCompanyLinkedinLink.ts index 28e4f2d3ffd5..31424e4cf286 100644 --- a/packages/twenty-chrome-extension/src/contentScript/utils/extractCompanyLinkedinLink.ts +++ b/packages/twenty-chrome-extension/src/contentScript/utils/extractCompanyLinkedinLink.ts @@ -1,4 +1,7 @@ // Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page. + +import { isDefined } from '~/utils/isDefined'; + // "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/". const extractCompanyLinkedinLink = (activeTabUrl: string) => { // Regular expression to match the company ID @@ -7,7 +10,7 @@ const extractCompanyLinkedinLink = (activeTabUrl: string) => { // Extract the company ID using the regex const match = activeTabUrl.match(regex); - if (match && match[1]) { + if (isDefined(match) && isDefined(match[1])) { const companyID = match[1]; const cleanCompanyURL = `https://www.linkedin.com/company/${companyID}`; return cleanCompanyURL; diff --git a/packages/twenty-chrome-extension/src/db/company.db.ts b/packages/twenty-chrome-extension/src/db/company.db.ts index f0936e1b4ef9..7c3c7889100f 100644 --- a/packages/twenty-chrome-extension/src/db/company.db.ts +++ b/packages/twenty-chrome-extension/src/db/company.db.ts @@ -6,33 +6,42 @@ import { import { Company, CompanyFilterInput } from '~/generated/graphql'; import { CREATE_COMPANY } from '~/graphql/company/mutations'; import { FIND_COMPANY } from '~/graphql/company/queries'; +import { isDefined } from '~/utils/isDefined'; import { callMutation, callQuery } from '../utils/requestDb'; export const fetchCompany = async ( companyfilerInput: CompanyFilterInput, ): Promise => { - const data = await callQuery(FIND_COMPANY, { - filter: { - ...companyfilerInput, - }, - }); - if (data?.companies.edges) { - return data?.companies.edges.length > 0 - ? data?.companies.edges[0].node - : null; + try { + const data = await callQuery(FIND_COMPANY, { + filter: { + ...companyfilerInput, + }, + }); + if (isDefined(data?.companies.edges)) { + return data?.companies.edges.length > 0 + ? data?.companies.edges[0].node + : null; + } + return null; + } catch (error) { + return null; } - return null; }; export const createCompany = async ( company: CompanyInput, ): Promise => { - const data = await callMutation(CREATE_COMPANY, { - input: company, - }); - if (data) { - return data.createCompany.id; + try { + const data = await callMutation(CREATE_COMPANY, { + input: company, + }); + if (isDefined(data)) { + return data.createCompany.id; + } + return null; + } catch (error) { + return null; } - return null; }; diff --git a/packages/twenty-chrome-extension/src/db/person.db.ts b/packages/twenty-chrome-extension/src/db/person.db.ts index 29c782b3879e..a7df64710ece 100644 --- a/packages/twenty-chrome-extension/src/db/person.db.ts +++ b/packages/twenty-chrome-extension/src/db/person.db.ts @@ -6,31 +6,40 @@ import { import { Person, PersonFilterInput } from '~/generated/graphql'; import { CREATE_PERSON } from '~/graphql/person/mutations'; import { FIND_PERSON } from '~/graphql/person/queries'; +import { isDefined } from '~/utils/isDefined'; import { callMutation, callQuery } from '../utils/requestDb'; export const fetchPerson = async ( personFilterData: PersonFilterInput, ): Promise => { - const data = await callQuery(FIND_PERSON, { - filter: { - ...personFilterData, - }, - }); - if (data?.people.edges) { - return data?.people.edges.length > 0 ? data?.people.edges[0].node : null; + try { + const data = await callQuery(FIND_PERSON, { + filter: { + ...personFilterData, + }, + }); + if (isDefined(data?.people.edges)) { + return data?.people.edges.length > 0 ? data?.people.edges[0].node : null; + } + return null; + } catch (error) { + return null; } - return null; }; export const createPerson = async ( person: PersonInput, ): Promise => { - const data = await callMutation(CREATE_PERSON, { - input: person, - }); - if (data?.createPerson) { - return data.createPerson.id; + try { + const data = await callMutation(CREATE_PERSON, { + input: person, + }); + if (isDefined(data?.createPerson)) { + return data.createPerson.id; + } + return null; + } catch (error) { + return null; } - return null; }; diff --git a/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx b/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx index 524dc0c1faa2..1d198756ef49 100644 --- a/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx +++ b/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx @@ -5,6 +5,7 @@ import { H2Title } from '@/ui/display/typography/components/H2Title'; import { Button } from '@/ui/input/button/Button'; import { TextInput } from '@/ui/input/components/TextInput'; import { Toggle } from '@/ui/input/components/Toggle'; +import { isDefined } from '~/utils/isDefined'; const StyledContainer = styled.div<{ isToggleOn: boolean }>` width: 400px; @@ -71,11 +72,11 @@ export const ApiKeyForm = () => { const getState = async () => { const localStorage = await chrome.storage.local.get(); - if (localStorage.apiKey) { + if (isDefined(localStorage.apiKey)) { setApiKey(localStorage.apiKey); } - if (localStorage.serverBaseUrl) { + if (isDefined(localStorage.serverBaseUrl)) { setShowSection(true); setRoute(localStorage.serverBaseUrl); } diff --git a/packages/twenty-chrome-extension/src/options/modules/ui/input/components/Toggle.tsx b/packages/twenty-chrome-extension/src/options/modules/ui/input/components/Toggle.tsx index 61345232ef7e..b390a94b9cbf 100644 --- a/packages/twenty-chrome-extension/src/options/modules/ui/input/components/Toggle.tsx +++ b/packages/twenty-chrome-extension/src/options/modules/ui/input/components/Toggle.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; +import { isDefined } from '~/utils/isDefined'; + export type ToggleSize = 'small' | 'medium'; type ContainerProps = { @@ -54,7 +56,7 @@ export const Toggle = ({ const handleChange = () => { setIsOn(!isOn); - if (onChange) { + if (isDefined(onChange)) { onChange(!isOn); } }; diff --git a/packages/twenty-chrome-extension/src/utils/handleQueryParams.ts b/packages/twenty-chrome-extension/src/utils/handleQueryParams.ts index 0157fe0a7da6..c0396653feb9 100644 --- a/packages/twenty-chrome-extension/src/utils/handleQueryParams.ts +++ b/packages/twenty-chrome-extension/src/utils/handleQueryParams.ts @@ -14,7 +14,7 @@ const handleQueryParams = (inputData: { [x: string]: unknown }): string => { result = result.concat(`${key}: ${quote}${inputData[key]}${quote}, `); } }); - if (result.length) result = result.slice(0, -2); // Remove the last ', ' + if (result.length > 0) result = result.slice(0, -2); // Remove the last ', ' return result; }; diff --git a/packages/twenty-chrome-extension/src/utils/isDefined.ts b/packages/twenty-chrome-extension/src/utils/isDefined.ts new file mode 100644 index 000000000000..81eb67203a03 --- /dev/null +++ b/packages/twenty-chrome-extension/src/utils/isDefined.ts @@ -0,0 +1,4 @@ +import { isNull, isUndefined } from '@sniptt/guards'; + +export const isDefined = (value: T | null | undefined): value is T => + !isUndefined(value) && !isNull(value); diff --git a/packages/twenty-chrome-extension/tsconfig.app.json b/packages/twenty-chrome-extension/tsconfig.app.json index 2f311488e902..4b6b7d6c5327 100644 --- a/packages/twenty-chrome-extension/tsconfig.app.json +++ b/packages/twenty-chrome-extension/tsconfig.app.json @@ -8,7 +8,7 @@ "**/*.test.ts", "**/*.spec.tsx", "**/*.test.tsx", - "jest.config.ts", + "jest.config.ts" ], "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] } diff --git a/packages/twenty-docs/src/components/graphql-playground.tsx b/packages/twenty-docs/src/components/graphql-playground.tsx index 21018adb1cb5..3c2e333d4064 100644 --- a/packages/twenty-docs/src/components/graphql-playground.tsx +++ b/packages/twenty-docs/src/components/graphql-playground.tsx @@ -3,6 +3,7 @@ import BrowserOnly from '@docusaurus/BrowserOnly'; import { explorerPlugin } from '@graphiql/plugin-explorer'; import { Theme, useTheme } from '@graphiql/react'; import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { SubDoc } from '@site/src/components/token-form'; import Layout from '@theme/Layout'; import { GraphiQL } from 'graphiql'; @@ -21,9 +22,6 @@ const GraphQlComponent = ({ token, baseUrl, path }) => { const explorer = explorerPlugin({ showAttribution: true, }); - if (!baseUrl || !token) { - return <>; - } const fetcher = createGraphiQLFetcher({ url: baseUrl + '/' + path, @@ -47,6 +45,10 @@ const GraphQlComponent = ({ token, baseUrl, path }) => { }; }, []); + if (!baseUrl || !token) { + return <>; + } + return (
{ ); }; -const GraphQlPlayground = ({ subDoc }: { subDoc: 'core' | 'metadata' }) => { - const [token, setToken] = useState(); - const [baseUrl, setBaseUrl] = useState(); +const GraphQlPlayground = ({ subDoc }: { subDoc: SubDoc }) => { + const [token, setToken] = useState(); + const [baseUrl, setBaseUrl] = useState(); const { setTheme } = useTheme(); useEffect(() => { @@ -99,7 +101,7 @@ const GraphQlPlayground = ({ subDoc }: { subDoc: 'core' | 'metadata' }) => { children={children} setToken={setToken} setBaseUrl={setBaseUrl} - subdocName={subDoc} + subDoc={subDoc} /> )} diff --git a/packages/twenty-docs/src/components/playground.tsx b/packages/twenty-docs/src/components/playground.tsx index 687900f82683..af96e2c19648 100644 --- a/packages/twenty-docs/src/components/playground.tsx +++ b/packages/twenty-docs/src/components/playground.tsx @@ -9,9 +9,11 @@ const Playground = ({ setToken, setBaseUrl, subDoc, -}: Partial & { - subDoc: string; -}) => { +}: Partial & + Omit< + TokenFormProps, + 'isTokenValid' | 'setIsTokenValid' | 'setLoadingState' + >) => { const [isTokenValid, setIsTokenValid] = useState(false); const [isLoading, setIsLoading] = useState(false); return ( diff --git a/packages/twenty-docs/src/components/token-form.tsx b/packages/twenty-docs/src/components/token-form.tsx index 8c9ee1fc15ed..3c59ba0969fe 100644 --- a/packages/twenty-docs/src/components/token-form.tsx +++ b/packages/twenty-docs/src/components/token-form.tsx @@ -1,18 +1,19 @@ import React, { useEffect, useState } from 'react'; +import { TbApi, TbChevronLeft, TbLink } from 'react-icons/tb'; import { useHistory, useLocation } from '@docusaurus/router'; -import { TbApi, TbChevronLeft, TbLink } from '@theme/icons'; import { parseJson } from 'nx/src/utils/json'; import tokenForm from '!css-loader!./token-form.css'; +export type SubDoc = 'core' | 'metadata'; export type TokenFormProps = { setOpenApiJson?: (json: object) => void; setToken?: (token: string) => void; setBaseUrl?: (baseUrl: string) => void; - isTokenValid: boolean; - setIsTokenValid: (boolean) => void; - setLoadingState: (boolean) => void; - subDoc?: string; + isTokenValid?: boolean; + setIsTokenValid?: (boolean) => void; + setLoadingState?: (boolean) => void; + subDoc?: SubDoc; }; const TokenForm = ({ @@ -141,25 +142,23 @@ const TokenForm = ({ onBlur={() => submitToken(token)} />
- {!location.pathname.includes('rest-api') && ( -
- + history.replace( + '/' + + location.pathname.split('/').at(-2) + '/' + - location.pathname.split('/').at(-2) + - '/' + - event.target.value, - ) - } - value={location.pathname.split('/').at(-1)} - > - - - -
- )} + event.target.value, + ) + } + value={location.pathname.split('/').at(-1)} + > + + + + ); diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/useLinkedObject.ts b/packages/twenty-front/src/modules/activities/timeline/hooks/useLinkedObject.ts new file mode 100644 index 000000000000..b628b56907d5 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/hooks/useLinkedObject.ts @@ -0,0 +1,16 @@ +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const useLinkedObject = (id: string) => { + const objectMetadataItems: ObjectMetadataItem[] = useRecoilValue( + objectMetadataItemsState, + ); + + return ( + objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.id === id, + ) ?? null + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventList.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx similarity index 70% rename from packages/twenty-front/src/modules/activities/events/components/EventList.tsx rename to packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx index 92219b4e8430..43f73126491a 100644 --- a/packages/twenty-front/src/modules/activities/events/components/EventList.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx @@ -1,16 +1,17 @@ import { ReactElement } from 'react'; import styled from '@emotion/styled'; -import { EventsGroup } from '@/activities/events/components/EventsGroup'; -import { Event } from '@/activities/events/types/Event'; -import { groupEventsByMonth } from '@/activities/events/utils/groupEventsByMonth'; +import { EventsGroup } from '@/activities/timelineActivities/components/EventsGroup'; +import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { groupEventsByMonth } from '@/activities/timelineActivities/utils/groupEventsByMonth'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; type EventListProps = { targetableObject: ActivityTargetableObject; title: string; - events: Event[]; + events: TimelineActivity[]; button?: ReactElement | false; }; @@ -31,12 +32,16 @@ const StyledTimelineContainer = styled.div` export const EventList = ({ events, targetableObject }: EventListProps) => { const groupedEvents = groupEventsByMonth(events); + const mainObjectMetadataItem = useObjectMetadataItem({ + objectNameSingular: targetableObject.targetObjectNameSingular, + }).objectMetadataItem; + return ( {groupedEvents.map((group, index) => ( (useIsMobile() ? 'wrap' : 'nowrap')}; - gap: ${({ theme }) => theme.spacing(1)}; - overflow: hidden; -`; - const StyledItemAuthorText = styled.span` display: flex; color: ${({ theme }) => theme.font.color.primary}; @@ -65,6 +67,11 @@ const StyledItemTitle = styled.span` white-space: nowrap; `; +const StyledLinkedObject = styled.span` + cursor: pointer; + text-decoration: underline; +`; + const StyledItemTitleDate = styled.div` align-items: center; color: ${({ theme }) => theme.font.color.tertiary}; @@ -117,16 +124,24 @@ const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>` white-space: nowrap; `; +const StyledSummary = styled.summary` + display: flex; + flex: 1; + flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')}; + gap: ${({ theme }) => theme.spacing(1)}; + overflow: hidden; +`; + type EventRowProps = { - targetableObject: ActivityTargetableObject; + mainObjectMetadataItem: ObjectMetadataItem | null; isLastEvent?: boolean; - event: Event; + event: TimelineActivity; }; export const EventRow = ({ isLastEvent, event, - targetableObject, + mainObjectMetadataItem, }: EventRowProps) => { const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt); const exactCreatedAt = beautifyExactDateTime(event.createdAt); @@ -135,47 +150,108 @@ export const EventRow = ({ const diff: Record = properties?.diff; const isEventType = (type: 'created' | 'updated') => { - return ( - event.name === type + '.' + targetableObject.targetObjectNameSingular - ); + if (event.name.includes('.')) { + return event.name.split('.')[1] === type; + } + return false; }; + const { getIcon } = useIcons(); + + const linkedObjectMetadata = useLinkedObject(event.linkedObjectMetadataId); + + const linkedObjectLabel = event.name.includes('note') + ? 'note' + : event.name.includes('task') + ? 'task' + : linkedObjectMetadata?.labelSingular; + + const ActivityIcon = event.linkedObjectMetadataId + ? event.name.includes('note') + ? IconNotes + : event.name.includes('task') + ? IconCheckbox + : getIcon(linkedObjectMetadata?.icon) + : isEventType('created') + ? IconCirclePlus + : isEventType('updated') + ? IconEditCircle + : IconFocusCentered; + + const author = + event.workspaceMember?.name.firstName + + ' ' + + event.workspaceMember?.name.lastName; + + const action = isEventType('created') + ? 'created' + : isEventType('updated') + ? 'updated' + : event.name; + + let description; + + if (!isUndefinedOrNull(linkedObjectMetadata)) { + description = 'a ' + linkedObjectLabel; + } else if (!event.linkedObjectMetadataId && isEventType('created')) { + description = `a new ${mainObjectMetadataItem?.labelSingular}`; + } else if (isEventType('updated')) { + const diffKeys = Object.keys(diff); + if (diffKeys.length === 0) { + description = `a ${mainObjectMetadataItem?.labelSingular}`; + } else if (diffKeys.length === 1) { + const [key, value] = Object.entries(diff)[0]; + description = [ + , + ]; + } else if (diffKeys.length === 2) { + description = + mainObjectMetadataItem?.fields.find( + (field) => diffKeys[0] === field.name, + )?.label + + ' and ' + + mainObjectMetadataItem?.fields.find( + (field) => diffKeys[1] === field.name, + )?.label; + } else if (diffKeys.length > 2) { + description = + diffKeys[0] + ' and ' + (diffKeys.length - 1) + ' other fields'; + } + } else if (!isEventType('created') && !isEventType('updated')) { + description = JSON.stringify(diff); + } + const details = JSON.stringify(diff); + + const openActivityRightDrawer = useOpenActivityRightDrawer(); + return ( <> - {isEventType('created') && } - {isEventType('updated') && } - {!isEventType('created') && !isEventType('updated') && ( - - )} + - - - {event.workspaceMember?.name.firstName}{' '} - {event.workspaceMember?.name.lastName} - - - {isEventType('created') && 'created'} - {isEventType('updated') && 'updated'} - {!isEventType('created') && !isEventType('updated') && event.name} - - - {isEventType('created') && - `a new ${targetableObject.targetObjectNameSingular}`} - {isEventType('updated') && - Object.entries(diff).map(([key, value]) => ( - - ))} - {!isEventType('created') && - !isEventType('updated') && - JSON.stringify(diff)} - - +
+ + {author} + {action} + {description} + {isUndefinedOrNull(linkedObjectMetadata) ? ( + <> + ) : ( + openActivityRightDrawer(event.linkedRecordId)} + > + {event.linkedRecordCachedName} + + )} + + {details} +
+ {beautifiedCreatedAt} diff --git a/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventUpdateProperty.tsx similarity index 84% rename from packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx rename to packages/twenty-front/src/modules/activities/timelineActivities/components/EventUpdateProperty.tsx index cbeb157e2db3..46f4061db96c 100644 --- a/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventUpdateProperty.tsx @@ -4,6 +4,7 @@ import { IconArrowRight } from 'twenty-ui'; type EventUpdatePropertyProps = { propertyName: string; + before?: string; after?: string; }; @@ -23,9 +24,9 @@ export const EventUpdateProperty = ({ const theme = useTheme(); return ( - {propertyName} + {propertyName ?? '(empty)'} - {after} + {JSON.stringify(after)} ); }; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx similarity index 82% rename from packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx rename to packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx index 091e9913e68e..b441b7ab77dc 100644 --- a/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx @@ -1,14 +1,14 @@ import styled from '@emotion/styled'; -import { EventRow } from '@/activities/events/components/EventRow'; -import { EventGroup } from '@/activities/events/utils/groupEventsByMonth'; -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { EventRow } from '@/activities/timelineActivities/components/EventRow'; +import { EventGroup } from '@/activities/timelineActivities/utils/groupEventsByMonth'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; type EventsGroupProps = { group: EventGroup; month: string; year?: number; - targetableObject: ActivityTargetableObject; + mainObjectMetadataItem: ObjectMetadataItem | null; }; const StyledActivityGroup = styled.div` @@ -57,7 +57,7 @@ export const EventsGroup = ({ group, month, year, - targetableObject, + mainObjectMetadataItem, }: EventsGroupProps) => { return ( @@ -69,7 +69,7 @@ export const EventsGroup = ({ {group.items.map((event, index) => ( { - const { events } = useEvents(targetableObject); + const { timelineActivities } = useTimelineActivities(targetableObject); - if (!isNonEmptyArray(events)) { + if (!isNonEmptyArray(timelineActivities)) { return ( - No Events + Add your first Activity - There are no events associated with this record.{' '} + There are no activities associated with this record.{' '} + ); } @@ -53,7 +55,7 @@ export const Events = ({ ); diff --git a/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.ts similarity index 56% rename from packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts rename to packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.ts index 25c6d5bf1209..1115a4c709e2 100644 --- a/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts +++ b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.ts @@ -1,53 +1,21 @@ import { renderHook } from '@testing-library/react'; -import { useEvents } from '@/activities/events/hooks/useEvents'; +import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities'; jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ useFindManyRecords: jest.fn(), })); -describe('useEvent', () => { +describe('useTimelineActivities', () => { afterEach(() => { jest.clearAllMocks(); }); it('fetches events correctly for a given targetableObject', () => { - const mockEvents = [ + const mockedTimelineActivities = [ { __typename: 'Event', id: '166ec73f-26b1-4934-bb3b-c86c8513b99b', - opportunityId: null, - opportunity: null, - personId: null, - person: null, - company: { - __typename: 'Company', - address: 'Paris', - linkedinLink: { - __typename: 'Link', - label: '', - url: '', - }, - xLink: { - __typename: 'Link', - label: '', - url: '', - }, - position: 4, - domainName: 'microsoft.com', - employees: null, - createdAt: '2024-03-21T16:01:41.809Z', - annualRecurringRevenue: { - __typename: 'Currency', - amountMicros: 100000000, - currencyCode: 'USD', - }, - idealCustomerProfile: false, - accountOwnerId: null, - updatedAt: '2024-03-22T08:28:44.812Z', - name: 'Microsoft', - id: '460b6fb1-ed89-413a-b31a-962986e67bb4', - }, workspaceMember: { __typename: 'WorkspaceMember', locale: 'en', @@ -81,11 +49,13 @@ describe('useEvent', () => { '@/object-record/hooks/useFindManyRecords', ); useFindManyRecordsMock.useFindManyRecords.mockReturnValue({ - records: mockEvents, + records: mockedTimelineActivities, }); - const { result } = renderHook(() => useEvents(mockTargetableObject)); + const { result } = renderHook(() => + useTimelineActivities(mockTargetableObject), + ); - expect(result.current.events).toEqual(mockEvents); + expect(result.current.timelineActivities).toEqual(mockedTimelineActivities); }); }); diff --git a/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.tsx similarity index 63% rename from packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx rename to packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.tsx index 9e5cbecc3e19..d80726468572 100644 --- a/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.tsx @@ -1,17 +1,19 @@ -import { Event } from '@/activities/events/types/Event'; +import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; // do we need to test this? -export const useEvents = (targetableObject: ActivityTargetableObject) => { +export const useTimelineActivities = ( + targetableObject: ActivityTargetableObject, +) => { const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ nameSingular: targetableObject.targetObjectNameSingular, }); - const { records: events } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Event, + const { records: TimelineActivities } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.TimelineActivity, filter: { [targetableObjectFieldIdName]: { eq: targetableObject.id, @@ -20,9 +22,10 @@ export const useEvents = (targetableObject: ActivityTargetableObject) => { orderBy: { createdAt: 'DescNullsFirst', }, + fetchPolicy: 'network-only', }); return { - events: events as Event[], + timelineActivities: TimelineActivities as TimelineActivity[], }; }; diff --git a/packages/twenty-front/src/modules/activities/events/types/Event.ts b/packages/twenty-front/src/modules/activities/timelineActivities/types/TimelineActivity.ts similarity index 65% rename from packages/twenty-front/src/modules/activities/events/types/Event.ts rename to packages/twenty-front/src/modules/activities/timelineActivities/types/TimelineActivity.ts index c39ceecd2bb4..d7cf52b680b3 100644 --- a/packages/twenty-front/src/modules/activities/events/types/Event.ts +++ b/packages/twenty-front/src/modules/activities/timelineActivities/types/TimelineActivity.ts @@ -1,15 +1,15 @@ import { WorkspaceMember } from '~/generated/graphql'; -export type Event = { +export type TimelineActivity = { id: string; createdAt: string; updatedAt: string; deletedAt: string | null; - opportunityId: string | null; - companyId: string | null; - personId: string | null; workspaceMemberId: string; workspaceMember: WorkspaceMember; properties: any; name: string; + linkedRecordCachedName: string; + linkedRecordId: string; + linkedObjectMetadataId: string; }; diff --git a/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts b/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts similarity index 76% rename from packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts rename to packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts index 419e40a5801e..b88b89c1dcd7 100644 --- a/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts +++ b/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts @@ -1,10 +1,10 @@ -import { mockedEvents } from '~/testing/mock-data/events'; +import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities'; import { groupEventsByMonth } from '../groupEventsByMonth'; describe('groupEventsByMonth', () => { it('should group activities by month', () => { - const grouped = groupEventsByMonth(mockedEvents); + const grouped = groupEventsByMonth(mockedTimelineActivities); expect(grouped).toHaveLength(2); expect(grouped[0].items).toHaveLength(1); diff --git a/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts b/packages/twenty-front/src/modules/activities/timelineActivities/utils/groupEventsByMonth.ts similarity index 78% rename from packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts rename to packages/twenty-front/src/modules/activities/timelineActivities/utils/groupEventsByMonth.ts index 316bd25e858e..fa0779f538c3 100644 --- a/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts +++ b/packages/twenty-front/src/modules/activities/timelineActivities/utils/groupEventsByMonth.ts @@ -1,13 +1,13 @@ -import { Event } from '@/activities/events/types/Event'; +import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; import { isDefined } from '~/utils/isDefined'; export type EventGroup = { month: number; year: number; - items: Event[]; + items: TimelineActivity[]; }; -export const groupEventsByMonth = (events: Event[]) => { +export const groupEventsByMonth = (events: TimelineActivity[]) => { const acitivityGroups: EventGroup[] = []; for (const event of events) { diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts index b264758fbaef..5409526ec5ad 100644 --- a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts @@ -33,7 +33,6 @@ export const objectMetadataItemFamilySelector = selectorFamily< ) ?? null ); } - return null; }, }); diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index 18405ff2232c..5fafc5c6465a 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -9,7 +9,7 @@ export enum CoreObjectNameSingular { Comment = 'comment', Company = 'company', ConnectedAccount = 'connectedAccount', - Event = 'event', + TimelineActivity = 'timelineActivity', Favorite = 'favorite', Message = 'message', MessageChannel = 'messageChannel', diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index c32e0c17f9cd..535373a1575f 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { useQuery } from '@apollo/client'; +import { useQuery, WatchQueryFetchPolicy } from '@apollo/client'; import { isNonEmptyArray } from '@apollo/client/utilities'; import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; @@ -32,6 +32,7 @@ export const useFindManyRecords = ({ onCompleted, skip, queryFields, + fetchPolicy, }: ObjectMetadataItemIdentifier & ObjectRecordQueryVariables & { onCompleted?: ( @@ -44,6 +45,7 @@ export const useFindManyRecords = ({ skip?: boolean; depth?: number; queryFields?: Record; + fetchPolicy?: WatchQueryFetchPolicy; }) => { const findManyQueryStateIdentifier = objectNameSingular + @@ -84,6 +86,7 @@ export const useFindManyRecords = ({ limit, orderBy, }, + fetchPolicy: fetchPolicy, onCompleted: (data) => { if (!isDefined(data)) { onCompleted?.([]); diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx index 0362a13e5e36..f665994b8309 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx @@ -11,12 +11,12 @@ import { import { Calendar } from '@/activities/calendar/components/Calendar'; import { EmailThreads } from '@/activities/emails/components/EmailThreads'; -import { Events } from '@/activities/events/components/Events'; import { Attachments } from '@/activities/files/components/Attachments'; import { Notes } from '@/activities/notes/components/Notes'; import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks'; import { Timeline } from '@/activities/timeline/components/Timeline'; import { TimelineQueryEffect } from '@/activities/timeline/components/TimelineQueryEffect'; +import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { TabList } from '@/ui/layout/tab/components/TabList'; @@ -144,7 +144,9 @@ export const ShowPageRightContainer = ({ {activeTabId === 'calendar' && ( )} - {activeTabId === 'logs' && } + {activeTabId === 'logs' && ( + + )} ); }; diff --git a/packages/twenty-front/src/testing/mock-data/events.ts b/packages/twenty-front/src/testing/mock-data/timeline-activities.ts similarity index 81% rename from packages/twenty-front/src/testing/mock-data/events.ts rename to packages/twenty-front/src/testing/mock-data/timeline-activities.ts index 2be81b414f8b..35df59b20bfa 100644 --- a/packages/twenty-front/src/testing/mock-data/events.ts +++ b/packages/twenty-front/src/testing/mock-data/timeline-activities.ts @@ -1,14 +1,14 @@ -import { Event } from '@/activities/events/types/Event'; +import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; -export const mockedEvents: Array = [ +export const mockedTimelineActivities: Array = [ { properties: '{"diff": {"address": {"after": "TEST", "before": ""}}}', updatedAt: '2023-04-26T10:12:42.33625+00:00', id: '79f84835-b2f9-4ab5-8ab9-17dbcc45dda3', - personId: null, - companyId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0', + linkedObjectMetadataId: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', + linkedRecordCachedName: 'Test', + linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0', name: 'updated.company', - opportunityId: null, createdAt: '2023-04-26T10:12:42.33625+00:00', workspaceMember: { __typename: 'WorkspaceMember', @@ -30,10 +30,10 @@ export const mockedEvents: Array = [ '{"after": {"id": "ce40eca0-8f4b-4bba-ba91-5cbd870c64d0", "name": "", "xLink": {"url": "", "label": ""}, "events": {"edges": [], "__typename": "eventConnection"}, "people": {"edges": [], "__typename": "personConnection"}, "address": "", "position": 0.5, "createdAt": "2024-03-24T21:33:45.765295", "employees": null, "favorites": {"edges": [], "__typename": "favoriteConnection"}, "updatedAt": "2024-03-24T21:33:45.765295", "__typename": "company", "domainName": "", "attachments": {"edges": [], "__typename": "attachmentConnection"}, "accountOwner": null, "linkedinLink": {"url": "", "label": ""}, "opportunities": {"edges": [], "__typename": "opportunityConnection"}, "accountOwnerId": null, "activityTargets": {"edges": [], "__typename": "activityTargetConnection"}, "idealCustomerProfile": false, "annualRecurringRevenue": {"amountMicros": null, "currencyCode": ""}}}', updatedAt: new Date().toISOString(), id: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', - personId: null, - companyId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0', name: 'created.company', - opportunityId: null, + linkedObjectMetadataId: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', + linkedRecordCachedName: 'Test', + linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0', createdAt: new Date().toISOString(), workspaceMember: { __typename: 'WorkspaceMember', diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts index cd7d0ac59e98..6ac775c8536c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts @@ -7,16 +7,15 @@ import { Repository } from 'typeorm'; import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service'; import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event'; -import { - SaveEventToDbJobData, - SaveEventToDbJob, -} from 'src/engine/api/graphql/workspace-query-runner/jobs/save-event-to-db.job'; +import { CreateAuditLogFromInternalEvent } from 'src/modules/timeline/jobs/create-audit-log-from-internal-event'; import { FeatureFlagEntity, FeatureFlagKeys, } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { objectRecordChangedValues } from 'src/engine/integrations/event-emitter/utils/object-record-changed-values'; import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event'; +import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; +import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job'; @Injectable() export class EntityEventsToDbListener { @@ -29,26 +28,27 @@ export class EntityEventsToDbListener { @OnEvent('*.created') async handleCreate(payload: ObjectRecordCreateEvent) { - return this.handle(payload, 'created'); + return this.handle(payload); } @OnEvent('*.updated') async handleUpdate(payload: ObjectRecordUpdateEvent) { - payload.details.diff = objectRecordChangedValues( - payload.details.before, - payload.details.after, + payload.properties.diff = objectRecordChangedValues( + payload.properties.before, + payload.properties.after, + payload.objectMetadata, ); - return this.handle(payload, 'updated'); + return this.handle(payload); } - // @OnEvent('*.deleted') - TODO: implement when we have soft deleted + // @OnEvent('*.deleted') - TODO: implement when we soft delete has been implemented + // .... + + // @OnEvent('*.restored') - TODO: implement when we soft delete has been implemented // .... - private async handle( - payload: ObjectRecordCreateEvent, - operation: string, - ) { + private async handle(payload: ObjectRecordCreateEvent) { if (!payload.objectMetadata.isAuditLogged) { return; } @@ -67,13 +67,14 @@ export class EntityEventsToDbListener { return; } - this.messageQueueService.add(SaveEventToDbJob.name, { - workspaceId: payload.workspaceId, - userId: payload.userId, - recordId: payload.recordId, - objectName: payload.objectMetadata.nameSingular, - operation: operation, - details: payload.details, - }); + this.messageQueueService.add( + CreateAuditLogFromInternalEvent.name, + payload, + ); + + this.messageQueueService.add( + UpsertTimelineActivityFromInternalEvent.name, + payload, + ); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/record-position.listener.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/record-position.listener.ts index 9d9ea3803f33..2ea9d8afb2ae 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/record-position.listener.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/record-position.listener.ts @@ -24,7 +24,7 @@ export class RecordPositionListener { return; } - if (hasPositionSet(payload.details.after)) { + if (hasPositionSet(payload.properties.after)) { return; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts index 9b4efe05b090..eccae092086c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module.ts @@ -9,7 +9,6 @@ import { RecordPositionListener } from 'src/engine/api/graphql/workspace-query-r import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; @@ -24,10 +23,7 @@ import { EntityEventsToDbListener } from './listeners/entity-events-to-db.listen WorkspaceDataSourceModule, WorkspacePreQueryHookModule, TypeOrmModule.forFeature([Workspace, FeatureFlagEntity], 'core'), - ObjectMetadataRepositoryModule.forFeature([ - WorkspaceMemberObjectMetadata, - EventObjectMetadata, - ]), + ObjectMetadataRepositoryModule.forFeature([WorkspaceMemberObjectMetadata]), ], providers: [ WorkspaceQueryRunnerService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service.ts index 712372e4411b..0b3c37b6571f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service.ts @@ -249,11 +249,12 @@ export class WorkspaceQueryRunnerService { parsedResults.forEach((record) => { this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.created`, { + name: `${objectMetadataItem.nameSingular}.created`, workspaceId, userId, recordId: record.id, objectMetadata: objectMetadataItem, - details: { + properties: { after: record, }, } satisfies ObjectRecordCreateEvent); @@ -306,11 +307,12 @@ export class WorkspaceQueryRunnerService { ); this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.updated`, { + name: `${objectMetadataItem.nameSingular}.updated`, workspaceId, userId, recordId: (existingRecord as Record).id, objectMetadata: objectMetadataItem, - details: { + properties: { before: this.removeNestedProperties(existingRecord as Record), after: this.removeNestedProperties(parsedResults?.[0]), }, @@ -397,11 +399,12 @@ export class WorkspaceQueryRunnerService { parsedResults.forEach((record) => { this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.deleted`, { + name: `${objectMetadataItem.nameSingular}.deleted`, workspaceId, userId, recordId: record.id, objectMetadata: objectMetadataItem, - details: { + properties: { before: [this.removeNestedProperties(record)], }, } satisfies ObjectRecordDeleteEvent); @@ -448,11 +451,12 @@ export class WorkspaceQueryRunnerService { ); this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.deleted`, { + name: `${objectMetadataItem.nameSingular}.deleted`, workspaceId, userId, recordId: args.id, objectMetadata: objectMetadataItem, - details: { + properties: { before: { ...(deletedWorkspaceMember ?? {}), ...this.removeNestedProperties(parsedResults?.[0]), diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 30703d0d3568..e61d1337f176 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -62,7 +62,7 @@ export class UserWorkspaceService extends TypeOrmQueryService { new ObjectRecordCreateEvent(); payload.workspaceId = workspaceId; - payload.details = { + payload.properties = { after: workspaceMember[0], }; payload.recordId = workspaceMember[0].id; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace-workspace-member.listener.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace-workspace-member.listener.ts index 3b6982cfe0aa..3c93e6938698 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace-workspace-member.listener.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace-workspace-member.listener.ts @@ -21,7 +21,7 @@ export class WorkspaceWorkspaceMemberListener { async handleDeleteEvent( payload: ObjectRecordDeleteEvent, ) { - const userId = payload.details.before.userId; + const userId = payload.properties.before.userId; if (!userId) { return; diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts index 3e72f4fa3151..0e3d6e22c686 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-create.event.ts @@ -1,7 +1,7 @@ import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; export class ObjectRecordCreateEvent extends ObjectRecordBaseEvent { - details: { + properties: { after: T; }; } diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts index b02f0a87b2c5..01e981bdc76e 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-delete.event.ts @@ -1,7 +1,7 @@ import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; export class ObjectRecordDeleteEvent extends ObjectRecordBaseEvent { - details: { + properties: { before: T; }; } diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-job-data.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-job-data.ts new file mode 100644 index 000000000000..d1f7aa5ec0ef --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-job-data.ts @@ -0,0 +1,11 @@ +import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; + +export class ObjectRecordJobData extends ObjectRecordBaseEvent { + getOperation() { + return this.name.split('.')[1]; + } + + getObjectName() { + return this.name.split('.')[0]; + } +} diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts index 7f6dbfd04207..037b38178c14 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record-update.event.ts @@ -1,7 +1,7 @@ import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; export class ObjectRecordUpdateEvent extends ObjectRecordBaseEvent { - details: { + properties: { before: T; after: T; diff?: Partial; diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts index a82ed3e0a254..d34fbd1afcc5 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/types/object-record.base.event.ts @@ -1,9 +1,11 @@ import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; export class ObjectRecordBaseEvent { + name: string; workspaceId: string; recordId: string; userId?: string; + workspaceMemberId?: string; objectMetadata: ObjectMetadataInterface; - details: any; + properties: any; } diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/utils/__tests__/object-record-changed-values.spec.ts b/packages/twenty-server/src/engine/integrations/event-emitter/utils/__tests__/object-record-changed-values.spec.ts index abe8dad89c92..9da50ae4c899 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/utils/__tests__/object-record-changed-values.spec.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/utils/__tests__/object-record-changed-values.spec.ts @@ -1,11 +1,35 @@ +import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; + import { objectRecordChangedValues } from 'src/engine/integrations/event-emitter/utils/object-record-changed-values'; +const mockObjectMetadata: ObjectMetadataInterface = { + id: '1', + nameSingular: 'Object', + namePlural: 'Objects', + labelSingular: 'Object', + labelPlural: 'Objects', + description: 'Test object metadata', + targetTableName: 'test_table', + fromRelations: [], + toRelations: [], + fields: [], + isSystem: false, + isCustom: false, + isActive: true, + isRemote: false, + isAuditLogged: true, +}; + describe('objectRecordChangedValues', () => { it('detects changes in scalar values correctly', () => { const oldRecord = { id: 1, name: 'Original Name', updatedAt: new Date() }; const newRecord = { id: 1, name: 'Updated Name', updatedAt: new Date() }; - const result = objectRecordChangedValues(oldRecord, newRecord); + const result = objectRecordChangedValues( + oldRecord, + newRecord, + mockObjectMetadata, + ); expect(result).toEqual({ name: { before: 'Original Name', after: 'Updated Name' }, @@ -13,20 +37,15 @@ describe('objectRecordChangedValues', () => { }); }); -it('ignores changes in properties that are objects', () => { - const oldRecord = { id: 1, details: { age: 20 } }; - const newRecord = { id: 1, details: { age: 21 } }; - - const result = objectRecordChangedValues(oldRecord, newRecord); - - expect(result).toEqual({}); -}); - it('ignores changes to the updatedAt field', () => { const oldRecord = { id: 1, updatedAt: new Date('2020-01-01') }; const newRecord = { id: 1, updatedAt: new Date('2024-01-01') }; - const result = objectRecordChangedValues(oldRecord, newRecord); + const result = objectRecordChangedValues( + oldRecord, + newRecord, + mockObjectMetadata, + ); expect(result).toEqual({}); }); @@ -35,7 +54,11 @@ it('returns an empty object when there are no changes', () => { const oldRecord = { id: 1, name: 'Name', value: 100 }; const newRecord = { id: 1, name: 'Name', value: 100 }; - const result = objectRecordChangedValues(oldRecord, newRecord); + const result = objectRecordChangedValues( + oldRecord, + newRecord, + mockObjectMetadata, + ); expect(result).toEqual({}); }); @@ -57,9 +80,14 @@ it('correctly handles a mix of changed, unchanged, and special case values', () }; const expectedChanges = { name: { before: 'Original', after: 'Updated' }, + config: { before: { theme: 'dark' }, after: { theme: 'light' } }, }; - const result = objectRecordChangedValues(oldRecord, newRecord); + const result = objectRecordChangedValues( + oldRecord, + newRecord, + mockObjectMetadata, + ); expect(result).toEqual(expectedChanges); }); diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/utils/object-record-changed-values.ts b/packages/twenty-server/src/engine/integrations/event-emitter/utils/object-record-changed-values.ts index 99d82d2bf8bc..ff300042d96a 100644 --- a/packages/twenty-server/src/engine/integrations/event-emitter/utils/object-record-changed-values.ts +++ b/packages/twenty-server/src/engine/integrations/event-emitter/utils/object-record-changed-values.ts @@ -1,21 +1,30 @@ import deepEqual from 'deep-equal'; +import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; + +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + export const objectRecordChangedValues = ( oldRecord: Record, newRecord: Record, + objectMetadata: ObjectMetadataInterface, ) => { - const isObject = (value: any) => { - return typeof value === 'object' && value !== null && !Array.isArray(value); - }; - const changedValues = Object.keys(newRecord).reduce( (acc, key) => { - // Discard if values are objects (e.g. we don't want Company.AccountOwner ; we have AccountOwnerId already) - if (isObject(oldRecord[key]) || isObject(newRecord[key])) { + if ( + objectMetadata.fields.find( + (field) => + field.type === FieldMetadataType.RELATION && field.name === key, + ) + ) { + return acc; + } + + if (objectMetadata.nameSingular === 'activity' && key === 'body') { return acc; } - if (!deepEqual(oldRecord[key], newRecord[key]) && key != 'updatedAt') { + if (!deepEqual(oldRecord[key], newRecord[key]) && key !== 'updatedAt') { acc[key] = { before: oldRecord[key], after: newRecord[key] }; } diff --git a/packages/twenty-server/src/engine/integrations/event-emitter/utils/object-record-diff-merge.ts b/packages/twenty-server/src/engine/integrations/event-emitter/utils/object-record-diff-merge.ts new file mode 100644 index 000000000000..020ab9385599 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/event-emitter/utils/object-record-diff-merge.ts @@ -0,0 +1,30 @@ +export function objectRecordDiffMerge( + oldRecord: Record, + newRecord: Record, +): Record { + const result: Record = { diff: {} }; + + // Iterate over the keys in the oldRecord diff + Object.keys(oldRecord.diff ?? {}).forEach((key) => { + if (newRecord.diff && newRecord.diff[key]) { + // If the key also exists in the newRecord, merge the 'before' from the oldRecord and the 'after' from the newRecord + result.diff[key] = { + before: oldRecord.diff[key].before, + after: newRecord.diff[key].after, + }; + } else { + // If the key does not exist in the newRecord, copy it as is from the oldRecord + result.diff[key] = oldRecord.diff[key]; + } + }); + + // Iterate over the keys in the newRecord diff to catch any that weren't in the oldRecord + Object.keys(newRecord.diff ?? {}).forEach((key) => { + if (!result.diff[key]) { + // If the key was not already added from the oldRecord, add it from the newRecord + result.diff[key] = newRecord.diff[key]; + } + }); + + return result; +} diff --git a/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts index 151e27ffd266..5e694a5bba51 100644 --- a/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts @@ -9,8 +9,15 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { CallWebhookJobsJob } from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook-jobs.job'; import { CallWebhookJob } from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook.job'; import { RecordPositionBackfillJob } from 'src/engine/api/graphql/workspace-query-runner/jobs/record-position-backfill.job'; -import { SaveEventToDbJob } from 'src/engine/api/graphql/workspace-query-runner/jobs/save-event-to-db.job'; import { RecordPositionBackfillModule } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-module'; +import { DeleteConnectedAccountAssociatedCalendarDataJob } from 'src/modules/calendar/jobs/delete-connected-account-associated-calendar-data.job'; +import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.module'; +import { MessageParticipantModule } from 'src/modules/messaging/services/message-participant/message-participant.module'; +import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { ConnectedAccountObjectMetadata } from 'src/modules/connected-account/standard-objects/connected-account.object-metadata'; +import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel.object-metadata'; +import { CreateCompanyAndContactJob } from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job'; +import { AuditLogObjectMetadata } from 'src/modules/timeline/standard-objects/audit-log.object-metadata'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { UpdateSubscriptionJob } from 'src/engine/core-modules/billing/jobs/update-subscription.job'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; @@ -24,24 +31,18 @@ import { EnvironmentModule } from 'src/engine/integrations/environment/environme import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; -import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job'; import { MatchParticipantJob } from 'src/modules/calendar-messaging-participant/jobs/match-participant.job'; import { UnmatchParticipantJob } from 'src/modules/calendar-messaging-participant/jobs/unmatch-participant.job'; import { GoogleCalendarSyncCronJob } from 'src/modules/calendar/crons/jobs/google-calendar-sync.cron.job'; import { CalendarCreateCompanyAndContactAfterSyncJob } from 'src/modules/calendar/jobs/calendar-create-company-and-contact-after-sync.job'; -import { DeleteConnectedAccountAssociatedCalendarDataJob } from 'src/modules/calendar/jobs/delete-connected-account-associated-calendar-data.job'; import { GoogleCalendarSyncJob } from 'src/modules/calendar/jobs/google-calendar-sync.job'; import { CalendarEventCleanerModule } from 'src/modules/calendar/services/calendar-event-cleaner/calendar-event-cleaner.module'; import { CalendarEventParticipantModule } from 'src/modules/calendar/services/calendar-event-participant/calendar-event-participant.module'; import { GoogleCalendarSyncModule } from 'src/modules/calendar/services/google-calendar-sync/google-calendar-sync.module'; import { WorkspaceGoogleCalendarSyncModule } from 'src/modules/calendar/services/workspace-google-calendar-sync/workspace-google-calendar-sync.module'; import { AutoCompaniesAndContactsCreationModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module'; -import { CreateCompanyAndContactJob } from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job'; -import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/services/google-api-refresh-access-token/google-api-refresh-access-token.module'; -import { ConnectedAccountObjectMetadata } from 'src/modules/connected-account/standard-objects/connected-account.object-metadata'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; import { GmailFetchMessagesFromCacheCronJob } from 'src/modules/messaging/crons/jobs/gmail-fetch-messages-from-cache.cron.job'; import { GmailPartialSyncCronJob } from 'src/modules/messaging/crons/jobs/gmail-partial-sync.cron.job'; import { DeleteConnectedAccountAssociatedMessagingDataJob } from 'src/modules/messaging/jobs/delete-connected-account-associated-messaging-data.job'; @@ -50,12 +51,13 @@ import { GmailFullSyncJob } from 'src/modules/messaging/jobs/gmail-full-sync.job import { GmailPartialSyncJob } from 'src/modules/messaging/jobs/gmail-partial-sync.job'; import { MessagingCreateCompanyAndContactAfterSyncJob } from 'src/modules/messaging/jobs/messaging-create-company-and-contact-after-sync.job'; import { GmailFetchMessageContentFromCacheModule } from 'src/modules/messaging/services/gmail-fetch-message-content-from-cache/gmail-fetch-message-content-from-cache.module'; +import { CreateAuditLogFromInternalEvent } from 'src/modules/timeline/jobs/create-audit-log-from-internal-event'; +import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job'; import { GmailFullSyncModule } from 'src/modules/messaging/services/gmail-full-sync/gmail-full-sync.module'; import { GmailPartialSyncModule } from 'src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.module'; -import { MessageParticipantModule } from 'src/modules/messaging/services/message-participant/message-participant.module'; import { ThreadCleanerModule } from 'src/modules/messaging/services/thread-cleaner/thread-cleaner.module'; +import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module'; import { MessageChannelMessageAssociationObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel-message-association.object-metadata'; -import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel.object-metadata'; @Module({ imports: [ @@ -83,13 +85,14 @@ import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-obj ObjectMetadataRepositoryModule.forFeature([ ConnectedAccountObjectMetadata, MessageChannelObjectMetadata, - EventObjectMetadata, + AuditLogObjectMetadata, MessageChannelMessageAssociationObjectMetadata, ]), GmailFullSyncModule, GmailFetchMessageContentFromCacheModule, GmailPartialSyncModule, CalendarEventParticipantModule, + TimelineActivityModule, ], providers: [ { @@ -156,8 +159,12 @@ import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-obj }, { - provide: SaveEventToDbJob.name, - useClass: SaveEventToDbJob, + provide: CreateAuditLogFromInternalEvent.name, + useClass: CreateAuditLogFromInternalEvent, + }, + { + provide: UpsertTimelineActivityFromInternalEvent.name, + useClass: UpsertTimelineActivityFromInternalEvent, }, { provide: GmailFetchMessagesFromCacheCronJob.name, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 1f9f239ecfec..c83b72b3ae81 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -44,8 +44,8 @@ import { attachmentStandardFieldIds, baseObjectStandardFieldIds, customObjectStandardFieldIds, - eventStandardFieldIds, favoriteStandardFieldIds, + timelineActivityStandardFieldIds, } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { createForeignKeyDeterministicUuid, @@ -475,10 +475,11 @@ export class ObjectMetadataService extends TypeOrmQueryService { - if (fieldMetadata.type === FieldMetadataType.RELATION) { - acc[fieldMetadata.objectMetadataId] = fieldMetadata; - } + const timelineActivityRelationFieldMetadataMap = + timelineActivityRelationFieldMetadata.reduce( + (acc, fieldMetadata: FieldMetadataEntity) => { + if (fieldMetadata.type === FieldMetadataType.RELATION) { + acc[fieldMetadata.objectMetadataId] = fieldMetadata; + } - return acc; - }, - {}, - ); + return acc; + }, + {}, + ); await this.relationMetadataRepository.save([ { workspaceId: workspaceId, relationType: RelationMetadataType.ONE_TO_MANY, fromObjectMetadataId: createdObjectMetadata.id, - toObjectMetadataId: eventObjectMetadata.id, + toObjectMetadataId: timelineActivityObjectMetadata.id, fromFieldMetadataId: - eventRelationFieldMetadataMap[createdObjectMetadata.id].id, + timelineActivityRelationFieldMetadataMap[createdObjectMetadata.id].id, toFieldMetadataId: - eventRelationFieldMetadataMap[eventObjectMetadata.id].id, + timelineActivityRelationFieldMetadataMap[ + timelineActivityObjectMetadata.id + ].id, onDeleteAction: RelationOnDeleteAction.CASCADE, }, ]); - return { eventObjectMetadata }; + return { timelineActivityObjectMetadata }; } private async createFavoriteRelation( diff --git a/packages/twenty-server/src/engine/object-metadata-repository/metadata-to-repository.mapping.ts b/packages/twenty-server/src/engine/object-metadata-repository/metadata-to-repository.mapping.ts index 33d7685e90a2..fc5c95188153 100644 --- a/packages/twenty-server/src/engine/object-metadata-repository/metadata-to-repository.mapping.ts +++ b/packages/twenty-server/src/engine/object-metadata-repository/metadata-to-repository.mapping.ts @@ -5,7 +5,8 @@ import { CalendarEventRepository } from 'src/modules/calendar/repositories/calen import { CompanyRepository } from 'src/modules/company/repositories/company.repository'; import { BlocklistRepository } from 'src/modules/connected-account/repositories/blocklist.repository'; import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; -import { EventRepository } from 'src/modules/event/repositiories/event.repository'; +import { AuditLogRepository } from 'src/modules/timeline/repositiories/audit-log.repository'; +import { TimelineActivityRepository } from 'src/modules/timeline/repositiories/timeline-activity.repository'; import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/repositories/message-channel-message-association.repository'; import { MessageChannelRepository } from 'src/modules/messaging/repositories/message-channel.repository'; import { MessageParticipantRepository } from 'src/modules/messaging/repositories/message-participant.repository'; @@ -15,6 +16,7 @@ import { PersonRepository } from 'src/modules/person/repositories/person.reposit import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; export const metadataToRepositoryMapping = { + AuditLogObjectMetadata: AuditLogRepository, BlocklistObjectMetadata: BlocklistRepository, CalendarChannelEventAssociationObjectMetadata: CalendarChannelEventAssociationRepository, @@ -23,7 +25,6 @@ export const metadataToRepositoryMapping = { CalendarEventObjectMetadata: CalendarEventRepository, CompanyObjectMetadata: CompanyRepository, ConnectedAccountObjectMetadata: ConnectedAccountRepository, - EventObjectMetadata: EventRepository, MessageChannelMessageAssociationObjectMetadata: MessageChannelMessageAssociationRepository, MessageChannelObjectMetadata: MessageChannelRepository, @@ -31,5 +32,6 @@ export const metadataToRepositoryMapping = { MessageParticipantObjectMetadata: MessageParticipantRepository, MessageThreadObjectMetadata: MessageThreadRepository, PersonObjectMetadata: PersonRepository, + TimelineActivityObjectMetadata: TimelineActivityRepository, WorkspaceMemberObjectMetadata: WorkspaceMemberRepository, }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index c9354a364d92..899aab966b2d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -122,7 +122,7 @@ export const companyStandardFieldIds = { opportunities: '20202020-add3-4658-8e23-d70dccb6d0ec', favorites: '20202020-4d1d-41ac-b13b-621631298d55', attachments: '20202020-c1b5-4120-b0f0-987ca401ed53', - events: '20202020-0414-4daf-9c0d-64fe7b27f89f', + timelineActivities: '72d5d7d3-8782-446c-a54b-1c25024f55db', }; export const connectedAccountStandardFieldIds = { @@ -146,6 +146,38 @@ export const eventStandardFieldIds = { custom: '20202020-4a71-41b0-9f83-9cdcca3f8b14', }; +export const auditLogStandardFieldIds = { + name: '20202020-2462-4b9d-b5d9-745febb3b095', + properties: '20202020-5d36-470e-8fad-d56ea3ab2fd0', + context: '20202020-b9d1-4058-9a75-7469cab5ca8c', + objectName: '20202020-76ba-4c47-b7e5-96034005d00a', + recordId: '20202020-c578-4acf-bf94-eb53b035cea2', + workspaceMember: '20202020-6e96-4300-b3f5-67a707147385', +}; + +export const behavioralEventStandardFieldIds = { + name: '20202020-2462-4b9d-b5d9-745febb3b095', + properties: '20202020-5d36-470e-8fad-d56ea3ab2fd0', + context: '20202020-bd62-4b5b-8385-6caeed8f8078', + objectName: '20202020-a744-406c-a2e1-9d83d74f4341', + recordId: '20202020-6d8b-4ca5-9869-f882cb335673', +}; + +export const timelineActivityStandardFieldIds = { + happensAt: '20202020-9526-4993-b339-c4318c4d39f0', + type: '20202020-5e7b-4ccd-8b8a-86b94b474134', + name: '20202020-7207-46e8-9dab-849505ae8497', + properties: '20202020-f142-4b04-b91b-6a2b4af3bf11', + workspaceMember: '20202020-af23-4479-9a30-868edc474b36', + person: '20202020-c414-45b9-a60a-ac27aa96229f', + company: '20202020-04ad-4221-a744-7a8278a5ce21', + opportunity: '20202020-7664-4a35-a3df-580d389fd527', + custom: '20202020-4a71-41b0-9f83-9cdcca3f8b15', + linkedRecordCachedName: '20202020-cfdb-4bef-bbce-a29f41230934', + linkedRecordId: '20202020-2e0e-48c0-b445-ee6c1e61687d', + linkedObjectMetadataId: '20202020-c595-449d-9f89-562758c9ee69', +}; + export const favoriteStandardFieldIds = { position: '20202020-dd26-42c6-8c3c-2a7598c204f6', workspaceMember: '20202020-ce63-49cb-9676-fdc0c45892cd', @@ -214,7 +246,7 @@ export const opportunityStandardFieldIds = { favorites: '20202020-a1c2-4500-aaae-83ba8a0e827a', activityTargets: '20202020-220a-42d6-8261-b2102d6eab35', attachments: '20202020-87c7-4118-83d6-2f4031005209', - events: '20202020-30e2-421f-96c7-19c69d1cf631', + timelineActivities: '863a6f5c-493a-47c8-9e14-34ed929d2ba6', }; export const personStandardFieldIds = { @@ -234,7 +266,7 @@ export const personStandardFieldIds = { attachments: '20202020-cd97-451f-87fa-bcb789bdbf3a', messageParticipants: '20202020-498e-4c61-8158-fa04f0638334', calendarEventParticipants: '20202020-52ee-45e9-a702-b64b3753e3a9', - events: '20202020-a43e-4873-9c23-e522de906ce5', + timelineActivities: 'f23d6471-78e0-458a-bdd0-9a84cd7d0b70', }; export const viewFieldStandardFieldIds = { @@ -295,7 +327,8 @@ export const workspaceMemberStandardFieldIds = { messageParticipants: '20202020-8f99-48bc-a5eb-edd33dd54188', blocklist: '20202020-6cb2-4161-9f29-a4b7f1283859', calendarEventParticipants: '20202020-0dbc-4841-9ce1-3e793b5b3512', - events: '20202020-e15b-47b8-94fe-8200e3c66615', + timelineActivities: '20202020-f0d9-4ba3-a123-69cc2c185071', + auditLogs: '20202020-2f54-4739-a5e2-99563385e83d', }; export const customObjectStandardFieldIds = { @@ -304,5 +337,5 @@ export const customObjectStandardFieldIds = { activityTargets: '20202020-7f42-40ae-b96c-c8a61acc83bf', favorites: '20202020-a4a7-4686-b296-1c6c3482ee21', attachments: '20202020-8d59-46ca-b7b2-73d167712134', - events: '20202020-a508-4334-9724-5c2bf1b05998', + timelineActivities: '20202020-f1ef-4ba4-8f33-1a4577afa477', }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts index 7af744af56ba..9fb5b56bca5e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts @@ -11,6 +11,7 @@ export const standardObjectIds = { apiKey: '20202020-4c00-401d-8cda-ec6a4c41cd7d', attachment: '20202020-bd3d-4c60-8dca-571c71d4447a', blocklist: '20202020-0408-4f38-b8a8-4d5e3e26e24d', + behavioralEvent: '20202020-983d-416b-a5ee-bdd0da3d0f8f', calendarChannelEventAssociation: '20202020-491b-4aaa-9825-afd1bae6ae00', calendarChannel: '20202020-e8f2-40e1-a39c-c0e0039c5034', calendarEventParticipant: '20202020-a1c3-47a6-9732-27e5b1e8436d', @@ -18,8 +19,9 @@ export const standardObjectIds = { comment: '20202020-435f-4de9-89b5-97e32233bf5f', company: '20202020-b374-4779-a561-80086cb2e17f', connectedAccount: '20202020-977e-46b2-890b-c3002ddfd5c5', - event: '20202020-6736-4337-b5c4-8b39fae325a5', + event: '20202020-6736-4337-b5c4-8b39fae325a5', // Todo: remove favorite: '20202020-ab56-4e05-92a3-e2414a499860', + auditLog: '20202020-0566-476a-b4c4-a0f9781bd80a', messageChannelMessageAssociation: '20202020-ad1e-4127-bccb-d83ae04d2ccb', messageChannel: '20202020-fe8c-40bc-a681-b80b771449b7', messageParticipant: '20202020-a433-4456-aa2d-fd9cb26b774a', @@ -27,6 +29,7 @@ export const standardObjectIds = { message: '20202020-3f6b-4425-80ab-e468899ab4b2', opportunity: '20202020-9549-49dd-b2b2-883999db8938', person: '20202020-e674-48e5-a542-72570eee7213', + timelineActivity: '20202020-6736-4337-b5c4-8b39fae325a5', viewField: '20202020-4d19-4655-95bf-b2a04cf206d4', viewFilter: '20202020-6fb6-4631-aded-b7d67e952ec8', viewSort: '20202020-e46a-47a8-939a-e5d911f83531', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata.ts index c38fb3a958fa..299690c00061 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata.ts @@ -13,7 +13,7 @@ import { RelationMetadata } from 'src/engine/workspace-manager/workspace-sync-me import { FavoriteObjectMetadata } from 'src/modules/favorite/standard-objects/favorite.object-metadata'; import { AttachmentObjectMetadata } from 'src/modules/attachment/standard-objects/attachment.object-metadata'; import { customObjectStandardFieldIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; @BaseCustomObjectMetadata() export class CustomObjectMetadata extends BaseObjectMetadata { @@ -88,18 +88,20 @@ export class CustomObjectMetadata extends BaseObjectMetadata { attachments: AttachmentObjectMetadata[]; @FieldMetadata({ - standardId: customObjectStandardFieldIds.events, + standardId: customObjectStandardFieldIds.timelineActivities, type: FieldMetadataType.RELATION, - label: 'Events', + label: 'Timeline Activities', description: (objectMetadata) => - `Events tied to the ${objectMetadata.labelSingular}`, - icon: 'IconJson', + `Timeline Activities tied to the ${objectMetadata.labelSingular}`, + + icon: 'IconIconTimelineEvent', }) @RelationMetadata({ type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => EventObjectMetadata, + inverseSideTarget: () => TimelineActivityObjectMetadata, onDelete: RelationOnDeleteAction.CASCADE, }) @IsNullable() - events: EventObjectMetadata[]; + @IsSystem() + timelineActivities: TimelineActivityObjectMetadata[]; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts index f0eba612c0ea..f55e01609256 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts @@ -24,21 +24,29 @@ import { ViewObjectMetadata } from 'src/modules/view/standard-objects/view.objec import { WebhookObjectMetadata } from 'src/modules/webhook/standard-objects/webhook.object-metadata'; import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; import { CalendarChannelEventAssociationObjectMetadata } from 'src/modules/calendar/standard-objects/calendar-channel-event-association.object-metadata'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; +import { AuditLogObjectMetadata } from 'src/modules/timeline/standard-objects/audit-log.object-metadata'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; +import { BehavioralEventObjectMetadata } from 'src/modules/timeline/standard-objects/behavioral-event.object-metadata'; export const standardObjectMetadataDefinitions = [ ActivityTargetObjectMetadata, ActivityObjectMetadata, ApiKeyObjectMetadata, + AuditLogObjectMetadata, AttachmentObjectMetadata, + BehavioralEventObjectMetadata, BlocklistObjectMetadata, + CalendarEventObjectMetadata, + CalendarChannelObjectMetadata, + CalendarChannelEventAssociationObjectMetadata, + CalendarEventParticipantObjectMetadata, CommentObjectMetadata, CompanyObjectMetadata, ConnectedAccountObjectMetadata, - EventObjectMetadata, FavoriteObjectMetadata, OpportunityObjectMetadata, PersonObjectMetadata, + TimelineActivityObjectMetadata, ViewFieldObjectMetadata, ViewFilterObjectMetadata, ViewSortObjectMetadata, @@ -50,8 +58,4 @@ export const standardObjectMetadataDefinitions = [ MessageChannelObjectMetadata, MessageParticipantObjectMetadata, MessageChannelMessageAssociationObjectMetadata, - CalendarEventObjectMetadata, - CalendarChannelObjectMetadata, - CalendarChannelEventAssociationObjectMetadata, - CalendarEventParticipantObjectMetadata, ]; diff --git a/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-person.listener.ts b/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-person.listener.ts index 925d48997d90..52b8119fa625 100644 --- a/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-person.listener.ts +++ b/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-person.listener.ts @@ -27,7 +27,7 @@ export class ParticipantPersonListener { async handleCreatedEvent( payload: ObjectRecordCreateEvent, ) { - if (payload.details.after.email === null) { + if (payload.properties.after.email === null) { return; } @@ -35,7 +35,7 @@ export class ParticipantPersonListener { MatchParticipantJob.name, { workspaceId: payload.workspaceId, - email: payload.details.after.email, + email: payload.properties.after.email, personId: payload.recordId, }, ); @@ -47,15 +47,15 @@ export class ParticipantPersonListener { ) { if ( objectRecordUpdateEventChangedProperties( - payload.details.before, - payload.details.after, + payload.properties.before, + payload.properties.after, ).includes('email') ) { await this.messageQueueService.add( UnmatchParticipantJob.name, { workspaceId: payload.workspaceId, - email: payload.details.before.email, + email: payload.properties.before.email, personId: payload.recordId, }, ); @@ -64,7 +64,7 @@ export class ParticipantPersonListener { MatchParticipantJob.name, { workspaceId: payload.workspaceId, - email: payload.details.after.email, + email: payload.properties.after.email, personId: payload.recordId, }, ); diff --git a/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-workspace-member.listener.ts b/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-workspace-member.listener.ts index fadb10b921e4..467764253b1e 100644 --- a/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-workspace-member.listener.ts +++ b/packages/twenty-server/src/modules/calendar-messaging-participant/listeners/participant-workspace-member.listener.ts @@ -27,7 +27,7 @@ export class ParticipantWorkspaceMemberListener { async handleCreatedEvent( payload: ObjectRecordCreateEvent, ) { - if (payload.details.after.userEmail === null) { + if (payload.properties.after.userEmail === null) { return; } @@ -35,8 +35,8 @@ export class ParticipantWorkspaceMemberListener { MatchParticipantJob.name, { workspaceId: payload.workspaceId, - email: payload.details.after.userEmail, - workspaceMemberId: payload.details.after.id, + email: payload.properties.after.userEmail, + workspaceMemberId: payload.properties.after.id, }, ); } @@ -47,15 +47,15 @@ export class ParticipantWorkspaceMemberListener { ) { if ( objectRecordUpdateEventChangedProperties( - payload.details.before, - payload.details.after, + payload.properties.before, + payload.properties.after, ).includes('userEmail') ) { await this.messageQueueService.add( UnmatchParticipantJob.name, { workspaceId: payload.workspaceId, - email: payload.details.before.userEmail, + email: payload.properties.before.userEmail, personId: payload.recordId, }, ); @@ -64,7 +64,7 @@ export class ParticipantWorkspaceMemberListener { MatchParticipantJob.name, { workspaceId: payload.workspaceId, - email: payload.details.after.userEmail, + email: payload.properties.after.userEmail, workspaceMemberId: payload.recordId, }, ); diff --git a/packages/twenty-server/src/modules/calendar/listeners/calendar-channel.listener.ts b/packages/twenty-server/src/modules/calendar/listeners/calendar-channel.listener.ts index 5d1ee4e212c4..bfa9f897364e 100644 --- a/packages/twenty-server/src/modules/calendar/listeners/calendar-channel.listener.ts +++ b/packages/twenty-server/src/modules/calendar/listeners/calendar-channel.listener.ts @@ -24,10 +24,10 @@ export class CalendarChannelListener { ) { if ( objectRecordChangedProperties( - payload.details.before, - payload.details.after, + payload.properties.before, + payload.properties.after, ).includes('isContactAutoCreationEnabled') && - payload.details.after.isContactAutoCreationEnabled + payload.properties.after.isContactAutoCreationEnabled ) { await this.messageQueueService.add( CalendarCreateCompanyAndContactAfterSyncJob.name, diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.object-metadata.ts b/packages/twenty-server/src/modules/company/standard-objects/company.object-metadata.ts index e4a2ea05f7f8..faff568623f2 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.object-metadata.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.object-metadata.ts @@ -21,7 +21,7 @@ import { FavoriteObjectMetadata } from 'src/modules/favorite/standard-objects/fa import { OpportunityObjectMetadata } from 'src/modules/opportunity/standard-objects/opportunity.object-metadata'; import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; @ObjectMetadata({ standardId: standardObjectIds.company, @@ -213,18 +213,18 @@ export class CompanyObjectMetadata extends BaseObjectMetadata { attachments: Relation; @FieldMetadata({ - standardId: companyStandardFieldIds.events, + standardId: companyStandardFieldIds.timelineActivities, type: FieldMetadataType.RELATION, - label: 'Events', - description: 'Events linked to the company', + label: 'Timeline Activities', + description: 'Timeline Activities linked to the company', icon: 'IconIconTimelineEvent', }) @RelationMetadata({ type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => EventObjectMetadata, + inverseSideTarget: () => TimelineActivityObjectMetadata, onDelete: RelationOnDeleteAction.CASCADE, }) @IsNullable() @IsSystem() - events: Relation; + timelineActivities: Relation; } diff --git a/packages/twenty-server/src/modules/event/standard-objects/event.object-metadata.ts b/packages/twenty-server/src/modules/event/standard-objects/event.object-metadata.ts index cf3dd809ec90..ed9f611ddccd 100644 --- a/packages/twenty-server/src/modules/event/standard-objects/event.object-metadata.ts +++ b/packages/twenty-server/src/modules/event/standard-objects/event.object-metadata.ts @@ -16,6 +16,9 @@ import { OpportunityObjectMetadata } from 'src/modules/opportunity/standard-obje import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; +// TODO: Depricate +// This should be removed in the next release +// We use AuditLog and ActivityTimeline instead @ObjectMetadata({ standardId: standardObjectIds.event, namePlural: 'events', diff --git a/packages/twenty-server/src/modules/messaging/listeners/messaging-message-channel.listener.ts b/packages/twenty-server/src/modules/messaging/listeners/messaging-message-channel.listener.ts index 0af01d281f62..8c53167bb35a 100644 --- a/packages/twenty-server/src/modules/messaging/listeners/messaging-message-channel.listener.ts +++ b/packages/twenty-server/src/modules/messaging/listeners/messaging-message-channel.listener.ts @@ -24,10 +24,10 @@ export class MessagingMessageChannelListener { ) { if ( objectRecordChangedProperties( - payload.details.before, - payload.details.after, + payload.properties.before, + payload.properties.after, ).includes('isContactAutoCreationEnabled') && - payload.details.after.isContactAutoCreationEnabled + payload.properties.after.isContactAutoCreationEnabled ) { await this.messageQueueService.add( MessagingCreateCompanyAndContactAfterSyncJob.name, diff --git a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.object-metadata.ts b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.object-metadata.ts index 0d16e06800fd..cebedb83f289 100644 --- a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.object-metadata.ts +++ b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.object-metadata.ts @@ -19,7 +19,7 @@ import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync- import { CompanyObjectMetadata } from 'src/modules/company/standard-objects/company.object-metadata'; import { FavoriteObjectMetadata } from 'src/modules/favorite/standard-objects/favorite.object-metadata'; import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; import { IsNotAuditLogged } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-not-audit-logged.decorator'; @ObjectMetadata({ @@ -173,17 +173,17 @@ export class OpportunityObjectMetadata extends BaseObjectMetadata { attachments: Relation; @FieldMetadata({ - standardId: opportunityStandardFieldIds.events, + standardId: opportunityStandardFieldIds.timelineActivities, type: FieldMetadataType.RELATION, - label: 'Events', - description: 'Events linked to the opportunity.', + label: 'Timeline Activities', + description: 'Timeline Activities linked to the opportunity.', icon: 'IconTimelineEvent', }) @RelationMetadata({ type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => EventObjectMetadata, + inverseSideTarget: () => TimelineActivityObjectMetadata, onDelete: RelationOnDeleteAction.SET_NULL, }) @IsNullable() - events: Relation; + timelineActivities: Relation; } diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.object-metadata.ts b/packages/twenty-server/src/modules/person/standard-objects/person.object-metadata.ts index 14ff30468b06..33c9fad19e72 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.object-metadata.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.object-metadata.ts @@ -23,7 +23,7 @@ import { CompanyObjectMetadata } from 'src/modules/company/standard-objects/comp import { FavoriteObjectMetadata } from 'src/modules/favorite/standard-objects/favorite.object-metadata'; import { MessageParticipantObjectMetadata } from 'src/modules/messaging/standard-objects/message-participant.object-metadata'; import { OpportunityObjectMetadata } from 'src/modules/opportunity/standard-objects/opportunity.object-metadata'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; @ObjectMetadata({ standardId: standardObjectIds.person, @@ -226,7 +226,7 @@ export class PersonObjectMetadata extends BaseObjectMetadata { calendarEventParticipants: Relation; @FieldMetadata({ - standardId: personStandardFieldIds.events, + standardId: personStandardFieldIds.timelineActivities, type: FieldMetadataType.RELATION, label: 'Events', description: 'Events linked to the company', @@ -234,10 +234,10 @@ export class PersonObjectMetadata extends BaseObjectMetadata { }) @RelationMetadata({ type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => EventObjectMetadata, + inverseSideTarget: () => TimelineActivityObjectMetadata, onDelete: RelationOnDeleteAction.CASCADE, }) @IsNullable() @IsSystem() - events: Relation; + timelineActivities: Relation; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/jobs/save-event-to-db.job.ts b/packages/twenty-server/src/modules/timeline/jobs/create-audit-log-from-internal-event.ts similarity index 56% rename from packages/twenty-server/src/engine/api/graphql/workspace-query-runner/jobs/save-event-to-db.job.ts rename to packages/twenty-server/src/modules/timeline/jobs/create-audit-log-from-internal-event.ts index 7810ae2f8fcb..32d4ca26961c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/jobs/save-event-to-db.job.ts +++ b/packages/twenty-server/src/modules/timeline/jobs/create-audit-log-from-internal-event.ts @@ -2,32 +2,25 @@ import { Injectable } from '@nestjs/common'; import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface'; +import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; -import { EventRepository } from 'src/modules/event/repositiories/event.repository'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; +import { AuditLogRepository } from 'src/modules/timeline/repositiories/audit-log.repository'; +import { AuditLogObjectMetadata } from 'src/modules/timeline/standard-objects/audit-log.object-metadata'; import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; -export type SaveEventToDbJobData = { - workspaceId: string; - recordId: string; - userId: string | undefined; - objectName: string; - operation: string; - details: any; -}; - @Injectable() -export class SaveEventToDbJob implements MessageQueueJob { +export class CreateAuditLogFromInternalEvent + implements MessageQueueJob +{ constructor( @InjectObjectMetadataRepository(WorkspaceMemberObjectMetadata) private readonly workspaceMemberService: WorkspaceMemberRepository, - @InjectObjectMetadataRepository(EventObjectMetadata) - private readonly eventService: EventRepository, + @InjectObjectMetadataRepository(AuditLogObjectMetadata) + private readonly auditLogRepository: AuditLogRepository, ) {} - // TODO: need to support objects others than "person", "company", "opportunity" - async handle(data: SaveEventToDbJobData): Promise { + async handle(data: ObjectRecordBaseEvent): Promise { let workspaceMemberId: string | null = null; if (data.userId) { @@ -39,18 +32,19 @@ export class SaveEventToDbJob implements MessageQueueJob { workspaceMemberId = workspaceMember.id; } - if (data.details.diff) { + if (data.properties.diff) { // we remove "before" and "after" property for a cleaner/slimmer event payload - data.details = { - diff: data.details.diff, + data.properties = { + diff: data.properties.diff, }; } - await this.eventService.insert( - `${data.operation}.${data.objectName}`, - data.details, + await this.auditLogRepository.insert( + data.name, + data.properties, workspaceMemberId, - data.objectName, + data.name.split('.')[0], + data.objectMetadata.id, data.recordId, data.workspaceId, ); diff --git a/packages/twenty-server/src/modules/timeline/jobs/upsert-timeline-activity-from-behavioral-event.ts b/packages/twenty-server/src/modules/timeline/jobs/upsert-timeline-activity-from-behavioral-event.ts new file mode 100644 index 000000000000..70b786d12ed0 --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/jobs/upsert-timeline-activity-from-behavioral-event.ts @@ -0,0 +1 @@ +// TODO diff --git a/packages/twenty-server/src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job.ts b/packages/twenty-server/src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job.ts new file mode 100644 index 000000000000..36410bb4e9b2 --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; + +import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface'; + +import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; +import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; +import { TimelineActivityService } from 'src/modules/timeline/services/timeline-activity.service'; + +@Injectable() +export class UpsertTimelineActivityFromInternalEvent + implements MessageQueueJob +{ + constructor( + @InjectObjectMetadataRepository(WorkspaceMemberObjectMetadata) + private readonly workspaceMemberService: WorkspaceMemberRepository, + private readonly timelineActivityService: TimelineActivityService, + ) {} + + async handle(data: ObjectRecordBaseEvent): Promise { + if (data.userId) { + const workspaceMember = await this.workspaceMemberService.getByIdOrFail( + data.userId, + data.workspaceId, + ); + + data.workspaceMemberId = workspaceMember.id; + } + + if (data.properties.diff) { + // we remove "before" and "after" property for a cleaner/slimmer event payload + data.properties = { + diff: data.properties.diff, + }; + } + + // Temporary + // We ignore every that is not a LinkedObject or a Business Object + if ( + data.objectMetadata.isSystem && + data.objectMetadata.nameSingular !== 'activityTarget' && + data.objectMetadata.nameSingular !== 'activity' + ) { + return; + } + + await this.timelineActivityService.upsertEvent(data); + } +} diff --git a/packages/twenty-server/src/modules/event/repositiories/event.repository.ts b/packages/twenty-server/src/modules/timeline/repositiories/audit-log.repository.ts similarity index 60% rename from packages/twenty-server/src/modules/event/repositiories/event.repository.ts rename to packages/twenty-server/src/modules/timeline/repositiories/audit-log.repository.ts index 1da98ee590be..001ecb751d67 100644 --- a/packages/twenty-server/src/modules/event/repositiories/event.repository.ts +++ b/packages/twenty-server/src/modules/timeline/repositiories/audit-log.repository.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; @Injectable() -export class EventRepository { +export class AuditLogRepository { constructor( private readonly workspaceDataSourceService: WorkspaceDataSourceService, ) {} @@ -13,17 +13,25 @@ export class EventRepository { properties: string, workspaceMemberId: string | null, objectName: string, - objectId: string, + objectMetadataId: string, + recordId: string, workspaceId: string, ): Promise { const dataSourceSchema = this.workspaceDataSourceService.getSchemaName(workspaceId); await this.workspaceDataSourceService.executeRawQuery( - `INSERT INTO ${dataSourceSchema}."event" - ("name", "properties", "workspaceMemberId", "${objectName}Id") - VALUES ($1, $2, $3, $4)`, - [name, properties, workspaceMemberId, objectId], + `INSERT INTO ${dataSourceSchema}."auditLog" + ("name", "properties", "workspaceMemberId", "objectName", "objectMetadataId", "recordId") + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + name, + properties, + workspaceMemberId, + objectName, + objectMetadataId, + recordId, + ], workspaceId, ); } diff --git a/packages/twenty-server/src/modules/timeline/repositiories/timeline-activity.repository.ts b/packages/twenty-server/src/modules/timeline/repositiories/timeline-activity.repository.ts new file mode 100644 index 000000000000..0c88db351147 --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/repositiories/timeline-activity.repository.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { objectRecordDiffMerge } from 'src/engine/integrations/event-emitter/utils/object-record-diff-merge'; + +@Injectable() +export class TimelineActivityRepository { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + async upsertOne( + name: string, + properties: Record, + objectName: string, + recordId: string, + workspaceId: string, + workspaceMemberId?: string, + linkedRecordCachedName?: string, + linkedRecordId?: string, + linkedObjectMetadataId?: string, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const recentTimelineActivity = await this.findRecentTimelineActivity( + dataSourceSchema, + name, + objectName, + recordId, + workspaceMemberId, + linkedRecordId, + workspaceId, + ); + + if (recentTimelineActivity.length !== 0) { + const newProps = objectRecordDiffMerge( + recentTimelineActivity[0].properties, + properties, + ); + + return this.updateTimelineActivity( + dataSourceSchema, + recentTimelineActivity[0].id, + newProps, + workspaceMemberId, + workspaceId, + ); + } + + return this.insertTimelineActivity( + dataSourceSchema, + name, + properties, + objectName, + recordId, + workspaceMemberId, + linkedRecordCachedName ?? '', + linkedRecordId, + linkedObjectMetadataId, + workspaceId, + ); + } + + private async findRecentTimelineActivity( + dataSourceSchema: string, + name: string, + objectName: string, + recordId: string, + workspaceMemberId: string | undefined, + linkedRecordId: string | undefined, + workspaceId: string, + ) { + return this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."timelineActivity" + WHERE "${objectName}Id" = $1 + AND ("name" = $2 OR "name" = $3) + AND "workspaceMemberId" = $4 + AND "linkedRecordId" = $5 + AND "createdAt" >= NOW() - interval '10 minutes'`, + [ + recordId, + name, + name.replace(/\.updated$/, '.created'), + workspaceMemberId, + linkedRecordId, + ], + workspaceId, + ); + } + + private async updateTimelineActivity( + dataSourceSchema: string, + id: string, + properties: Record, + workspaceMemberId: string | undefined, + workspaceId: string, + ) { + return this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."timelineActivity" + SET "properties" = $2, "workspaceMemberId" = $3 + WHERE "id" = $1`, + [id, properties, workspaceMemberId], + workspaceId, + ); + } + + private async insertTimelineActivity( + dataSourceSchema: string, + name: string, + properties: Record, + objectName: string, + recordId: string, + workspaceMemberId: string | undefined, + linkedRecordCachedName: string, + linkedRecordId: string | undefined, + linkedObjectMetadataId: string | undefined, + workspaceId: string, + ) { + return this.workspaceDataSourceService.executeRawQuery( + `INSERT INTO ${dataSourceSchema}."timelineActivity" + ("name", "properties", "workspaceMemberId", "${objectName}Id", "linkedRecordCachedName", "linkedRecordId", "linkedObjectMetadataId") + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + name, + properties, + workspaceMemberId, + recordId, + linkedRecordCachedName ?? '', + linkedRecordId, + linkedObjectMetadataId, + ], + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts b/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts new file mode 100644 index 000000000000..74117c362cd6 --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts @@ -0,0 +1,173 @@ +import { Injectable } from '@nestjs/common'; + +import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { TimelineActivityRepository } from 'src/modules/timeline/repositiories/timeline-activity.repository'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; + +type TransformedEvent = ObjectRecordBaseEvent & { + objectName?: string; + linkedRecordCachedName?: string; + linkedRecordId?: string; + linkedObjectMetadataId?: string; +}; + +@Injectable() +export class TimelineActivityService { + constructor( + @InjectObjectMetadataRepository(TimelineActivityObjectMetadata) + private readonly timelineActivityRepository: TimelineActivityRepository, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + async upsertEvent(event: ObjectRecordBaseEvent) { + const events = await this.transformEvent(event); + + if (!events || events.length === 0) return; + + for (const event of events) { + return await this.timelineActivityRepository.upsertOne( + event.name, + event.properties, + event.objectName ?? event.objectMetadata.nameSingular, + event.recordId, + event.workspaceId, + event.workspaceMemberId, + event.linkedRecordCachedName, + event.linkedRecordId, + event.linkedObjectMetadataId, + ); + } + } + + private async transformEvent( + event: ObjectRecordBaseEvent, + ): Promise { + if ( + ['activity', 'messageParticipant', 'activityTarget'].includes( + event.objectMetadata.nameSingular, + ) + ) { + return await this.handleLinkedObjects(event); + } + + return [event]; + } + + private async handleLinkedObjects(event: ObjectRecordBaseEvent) { + const dataSourceSchema = this.workspaceDataSourceService.getSchemaName( + event.workspaceId, + ); + + switch (event.objectMetadata.nameSingular) { + case 'activityTarget': + return this.processActivityTarget(event, dataSourceSchema); + case 'activity': + return this.processActivity(event, dataSourceSchema); + default: + return []; + } + } + + private async processActivity( + event: ObjectRecordBaseEvent, + dataSourceSchema: string, + ) { + const activityTargets = + await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."activityTarget" + WHERE "activityId" = $1`, + [event.recordId], + event.workspaceId, + ); + + const activity = await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."activity" + WHERE "id" = $1`, + [event.recordId], + event.workspaceId, + ); + + if (activityTargets.length === 0) return; + if (activity.length === 0) return; + + return activityTargets + .map((activityTarget) => { + const targetColumn: string[] = Object.entries(activityTarget) + .map(([columnName, columnValue]: [string, string]) => { + if (columnName === 'activityId' || !columnName.endsWith('Id')) + return; + if (columnValue === null) return; + + return columnName; + }) + .filter((column): column is string => column !== undefined); + + if (targetColumn.length === 0) return; + + return { + ...event, + name: activity[0].type.toLowerCase() + '.' + event.name.split('.')[1], + objectName: targetColumn[0].replace(/Id$/, ''), + recordId: activityTarget[targetColumn[0]], + linkedRecordCachedName: activity[0].title, + linkedRecordId: activity[0].id, + linkedObjectMetadataId: event.objectMetadata.id, + } as TransformedEvent; + }) + .filter((event): event is TransformedEvent => event !== undefined); + } + + private async processActivityTarget( + event: ObjectRecordBaseEvent, + dataSourceSchema: string, + ) { + const activityTarget = + await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."activityTarget" + WHERE "id" = $1`, + [event.recordId], + event.workspaceId, + ); + + if (activityTarget.length === 0) return; + + const activity = await this.workspaceDataSourceService.executeRawQuery( + `SELECT * FROM ${dataSourceSchema}."activity" + WHERE "id" = $1`, + [activityTarget[0].activityId], + event.workspaceId, + ); + + if (activity.length === 0) return; + + const activityObjectMetadataId = event.objectMetadata.fields.find( + (field) => field.name === 'activity', + )?.toRelationMetadata?.fromObjectMetadataId; + + const targetColumn: string[] = Object.entries(activityTarget[0]) + .map(([columnName, columnValue]: [string, string]) => { + if (columnName === 'activityId' || !columnName.endsWith('Id')) return; + if (columnValue === null) return; + + return columnName; + }) + .filter((column): column is string => column !== undefined); + + if (targetColumn.length === 0) return; + + return [ + { + ...event, + name: activity[0].type.toLowerCase() + '.' + event.name.split('.')[1], + properties: {}, + objectName: targetColumn[0].replace(/Id$/, ''), + recordId: activityTarget[0][targetColumn[0]], + linkedRecordCachedName: activity[0].title, + linkedRecordId: activity[0].id, + linkedObjectMetadataId: activityObjectMetadataId, + }, + ] as TransformedEvent[]; + } +} diff --git a/packages/twenty-server/src/modules/timeline/standard-objects/audit-log.object-metadata.ts b/packages/twenty-server/src/modules/timeline/standard-objects/audit-log.object-metadata.ts new file mode 100644 index 000000000000..7adda5dfda15 --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/standard-objects/audit-log.object-metadata.ts @@ -0,0 +1,96 @@ +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { FeatureFlagKeys } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { auditLogStandardFieldIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { standardObjectIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { FieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator'; +import { Gate } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/gate.decorator'; +import { IsNullable } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-nullable.decorator'; +import { IsSystem } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-system.decorator'; +import { ObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/object-metadata.decorator'; +import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata'; +import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; + +@ObjectMetadata({ + standardId: standardObjectIds.auditLog, + namePlural: 'auditLogs', + labelSingular: 'Audit Log', + labelPlural: 'Audit Logs', + description: 'An audit log of actions performed in the system', + icon: 'IconIconTimelineEvent', +}) +@IsSystem() +@Gate({ + featureFlag: FeatureFlagKeys.IsEventObjectEnabled, +}) +export class AuditLogObjectMetadata extends BaseObjectMetadata { + @FieldMetadata({ + standardId: auditLogStandardFieldIds.name, + type: FieldMetadataType.TEXT, + label: 'Event name', + description: 'Event name/type', + icon: 'IconAbc', + }) + name: string; + + @FieldMetadata({ + standardId: auditLogStandardFieldIds.properties, + type: FieldMetadataType.RAW_JSON, + label: 'Event details', + description: 'Json value for event details', + icon: 'IconListDetails', + }) + @IsNullable() + properties: JSON; + + @FieldMetadata({ + standardId: auditLogStandardFieldIds.context, + type: FieldMetadataType.RAW_JSON, + label: 'Event context', + description: + 'Json object to provide context (user, device, workspace, etc.)', + icon: 'IconListDetails', + }) + @IsNullable() + context: JSON; + + @FieldMetadata({ + standardId: auditLogStandardFieldIds.objectName, + type: FieldMetadataType.TEXT, + label: 'Object name', + description: 'If the event is related to a particular object', + icon: 'IconAbc', + }) + objectName: string; + + @FieldMetadata({ + standardId: auditLogStandardFieldIds.objectName, + type: FieldMetadataType.TEXT, + label: 'Object name', + description: 'If the event is related to a particular object', + icon: 'IconAbc', + }) + objectMetadataId: string; + + @FieldMetadata({ + standardId: auditLogStandardFieldIds.recordId, + type: FieldMetadataType.UUID, + label: 'Object id', + description: 'Event name/type', + icon: 'IconAbc', + }) + @IsNullable() + recordId: string; + + @FieldMetadata({ + standardId: auditLogStandardFieldIds.workspaceMember, + type: FieldMetadataType.RELATION, + label: 'Workspace Member', + description: 'Event workspace member', + icon: 'IconCircleUser', + joinColumn: 'workspaceMemberId', + }) + @IsNullable() + workspaceMember: Relation; +} diff --git a/packages/twenty-server/src/modules/timeline/standard-objects/behavioral-event.object-metadata.ts b/packages/twenty-server/src/modules/timeline/standard-objects/behavioral-event.object-metadata.ts new file mode 100644 index 000000000000..b39938886d88 --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/standard-objects/behavioral-event.object-metadata.ts @@ -0,0 +1,90 @@ +import { FeatureFlagKeys } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { behavioralEventStandardFieldIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { standardObjectIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { FieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator'; +import { Gate } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/gate.decorator'; +import { IsNullable } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-nullable.decorator'; +import { IsSystem } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-system.decorator'; +import { ObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/object-metadata.decorator'; +import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata'; + +@ObjectMetadata({ + standardId: standardObjectIds.behavioralEvent, + namePlural: 'behavioralEvents', + labelSingular: 'Behavioral Event', + labelPlural: 'Behavioral Events', + description: 'An event related to user behavior', + icon: 'IconIconTimelineEvent', +}) +@IsSystem() +@Gate({ + featureFlag: FeatureFlagKeys.IsEventObjectEnabled, +}) +export class BehavioralEventObjectMetadata extends BaseObjectMetadata { + /** + * + * Common in Segment, Rudderstack, etc. + * = Track, Screen, Page... + * But doesn't feel that useful. + * Let's try living without it. + * + @FieldMetadata({ + standardId: behavioralEventStandardFieldIds.type, + type: FieldMetadataType.TEXT, + label: 'Event type', + description: 'Event type', + icon: 'IconAbc', + }) + type: string; + */ + + @FieldMetadata({ + standardId: behavioralEventStandardFieldIds.name, + type: FieldMetadataType.TEXT, + label: 'Event name', + description: 'Event name', + icon: 'IconAbc', + }) + name: string; + + @FieldMetadata({ + standardId: behavioralEventStandardFieldIds.properties, + type: FieldMetadataType.RAW_JSON, + label: 'Event details', + description: 'Json value for event details', + icon: 'IconListDetails', + }) + @IsNullable() + properties: JSON; + + @FieldMetadata({ + standardId: behavioralEventStandardFieldIds.context, + type: FieldMetadataType.RAW_JSON, + label: 'Event context', + description: + 'Json object to provide context (user, device, workspace, etc.)', + icon: 'IconListDetails', + }) + @IsNullable() + context: JSON; + + @FieldMetadata({ + standardId: behavioralEventStandardFieldIds.objectName, + type: FieldMetadataType.TEXT, + label: 'Object name', + description: 'If the event is related to a particular object', + icon: 'IconAbc', + }) + objectName: string; + + @FieldMetadata({ + standardId: behavioralEventStandardFieldIds.recordId, + type: FieldMetadataType.UUID, + label: 'Object id', + description: 'Event name/type', + icon: 'IconAbc', + }) + @IsNullable() + recordId: string; +} diff --git a/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.object-metadata.ts b/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.object-metadata.ts new file mode 100644 index 000000000000..5532615e742c --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.object-metadata.ts @@ -0,0 +1,148 @@ +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { timelineActivityStandardFieldIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { standardObjectIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { DynamicRelationFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/dynamic-field-metadata.interface'; +import { FieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator'; +import { IsNotAuditLogged } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-not-audit-logged.decorator'; +import { IsNullable } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-nullable.decorator'; +import { IsSystem } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-system.decorator'; +import { ObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/object-metadata.decorator'; +import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata'; +import { CompanyObjectMetadata } from 'src/modules/company/standard-objects/company.object-metadata'; +import { OpportunityObjectMetadata } from 'src/modules/opportunity/standard-objects/opportunity.object-metadata'; +import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; +import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; +import { FeatureFlagKeys } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { Gate } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/gate.decorator'; +import { CustomObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata'; + +@ObjectMetadata({ + standardId: standardObjectIds.timelineActivity, + namePlural: 'timelineActivities', + labelSingular: 'Timeline Activity', + labelPlural: 'Timeline Activities', + description: 'Aggregated / filtered event to be displayed on the timeline', + icon: 'IconIconTimelineEvent', +}) +@IsSystem() +@IsNotAuditLogged() +@Gate({ + featureFlag: FeatureFlagKeys.IsEventObjectEnabled, +}) +export class TimelineActivityObjectMetadata extends BaseObjectMetadata { + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.happensAt, + type: FieldMetadataType.DATE_TIME, + label: 'Creation date', + description: 'Creation date', + icon: 'IconCalendar', + defaultValue: 'now', + }) + happensAt: Date; + + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.name, + type: FieldMetadataType.TEXT, + label: 'Event name', + description: 'Event name', + icon: 'IconAbc', + }) + name: string; + + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.properties, + type: FieldMetadataType.RAW_JSON, + label: 'Event details', + description: 'Json value for event details', + icon: 'IconListDetails', + }) + @IsNullable() + properties: JSON; + + // Who made the action + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.workspaceMember, + type: FieldMetadataType.RELATION, + label: 'Workspace Member', + description: 'Event workspace member', + icon: 'IconCircleUser', + joinColumn: 'workspaceMemberId', + }) + @IsNullable() + workspaceMember: Relation; + + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.person, + type: FieldMetadataType.RELATION, + label: 'Person', + description: 'Event person', + icon: 'IconUser', + joinColumn: 'personId', + }) + @IsNullable() + person: Relation; + + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.company, + type: FieldMetadataType.RELATION, + label: 'Company', + description: 'Event company', + icon: 'IconBuildingSkyscraper', + joinColumn: 'companyId', + }) + @IsNullable() + company: Relation; + + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.opportunity, + type: FieldMetadataType.RELATION, + label: 'Opportunity', + description: 'Events opportunity', + icon: 'IconTargetArrow', + joinColumn: 'opportunityId', + }) + @IsNullable() + opportunity: Relation; + + @DynamicRelationFieldMetadata((oppositeObjectMetadata) => ({ + standardId: timelineActivityStandardFieldIds.custom, + name: oppositeObjectMetadata.nameSingular, + label: oppositeObjectMetadata.labelSingular, + description: `Event ${oppositeObjectMetadata.labelSingular}`, + joinColumn: `${oppositeObjectMetadata.nameSingular}Id`, + icon: 'IconTimeline', + })) + custom: Relation; + + // Special objects that don't have their own timeline and are 'link' to the main object + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.linkedRecordCachedName, + type: FieldMetadataType.TEXT, + label: 'Linked Record cached name', + description: 'Cached record name', + icon: 'IconAbc', + }) + linkedRecordCachedName: string; + + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.linkedRecordId, + type: FieldMetadataType.UUID, + label: 'Linked Record id', + description: 'Linked Record id', + icon: 'IconAbc', + }) + @IsNullable() + linkedRecordId: string; + + @FieldMetadata({ + standardId: timelineActivityStandardFieldIds.linkedObjectMetadataId, + type: FieldMetadataType.UUID, + label: 'Linked Object Metadata Id', + description: 'inked Object Metadata Id', + icon: 'IconAbc', + }) + @IsNullable() + linkedObjectMetadataId: string; +} diff --git a/packages/twenty-server/src/modules/timeline/timeline-activity.module.ts b/packages/twenty-server/src/modules/timeline/timeline-activity.module.ts new file mode 100644 index 000000000000..e7b044d27375 --- /dev/null +++ b/packages/twenty-server/src/modules/timeline/timeline-activity.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { TimelineActivityService } from 'src/modules/timeline/services/timeline-activity.service'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; + +@Module({ + imports: [ + WorkspaceDataSourceModule, + ObjectMetadataRepositoryModule.forFeature([TimelineActivityObjectMetadata]), + ], + providers: [TimelineActivityService], + exports: [TimelineActivityService], +}) +export class TimelineActivityModule {} diff --git a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.object-metadata.ts b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.object-metadata.ts index 002c00605cfb..bbb976c8548a 100644 --- a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.object-metadata.ts +++ b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.object-metadata.ts @@ -24,7 +24,8 @@ import { ConnectedAccountObjectMetadata } from 'src/modules/connected-account/st import { FavoriteObjectMetadata } from 'src/modules/favorite/standard-objects/favorite.object-metadata'; import { MessageParticipantObjectMetadata } from 'src/modules/messaging/standard-objects/message-participant.object-metadata'; import { IsNullable } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-nullable.decorator'; -import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; +import { TimelineActivityObjectMetadata } from 'src/modules/timeline/standard-objects/timeline-activity.object-metadata'; +import { AuditLogObjectMetadata } from 'src/modules/timeline/standard-objects/audit-log.object-metadata'; import { IsNotAuditLogged } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-not-audit-logged.decorator'; @ObjectMetadata({ @@ -248,7 +249,7 @@ export class WorkspaceMemberObjectMetadata extends BaseObjectMetadata { calendarEventParticipants: Relation; @FieldMetadata({ - standardId: workspaceMemberStandardFieldIds.events, + standardId: workspaceMemberStandardFieldIds.timelineActivities, type: FieldMetadataType.RELATION, label: 'Events', description: 'Events linked to the workspace member', @@ -256,10 +257,26 @@ export class WorkspaceMemberObjectMetadata extends BaseObjectMetadata { }) @RelationMetadata({ type: RelationMetadataType.ONE_TO_MANY, - inverseSideTarget: () => EventObjectMetadata, + inverseSideTarget: () => TimelineActivityObjectMetadata, onDelete: RelationOnDeleteAction.CASCADE, }) @IsNullable() @IsSystem() - events: Relation; + timelineActivities: Relation; + + @FieldMetadata({ + standardId: workspaceMemberStandardFieldIds.auditLogs, + type: FieldMetadataType.RELATION, + label: 'Aud tLogs', + description: 'Audit Logs linked to the workspace member', + icon: 'IconTimelineEvent', + }) + @RelationMetadata({ + type: RelationMetadataType.ONE_TO_MANY, + inverseSideTarget: () => AuditLogObjectMetadata, + onDelete: RelationOnDeleteAction.SET_NULL, + }) + @IsNullable() + @IsSystem() + auditLogs: Relation; }