Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f962bdd2 | ||
|
|
caf80b0943 | ||
|
|
392f70856b | ||
|
|
934371ce9c |
+12
-2
@@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatus';
|
||||
import { LinkifiedTextBody } from '@/ui/display/components/LinkifiedTextBody';
|
||||
import { type CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
@@ -107,9 +108,9 @@ export const CalendarEventDetails = ({
|
||||
const standardFieldOrder = [
|
||||
'startsAt',
|
||||
'endsAt',
|
||||
'recurrence',
|
||||
'conferenceLink',
|
||||
'location',
|
||||
'description',
|
||||
];
|
||||
|
||||
const standardFields = standardFieldOrder
|
||||
@@ -121,7 +122,10 @@ export const CalendarEventDetails = ({
|
||||
.filter(isDefined);
|
||||
|
||||
const customFields = inlineFieldMetadataItems.filter(
|
||||
(field) => field.isCustom && !standardFieldOrder.includes(field.name),
|
||||
(field) =>
|
||||
field.isCustom &&
|
||||
!standardFieldOrder.includes(field.name) &&
|
||||
field.name !== 'description',
|
||||
);
|
||||
|
||||
const { calendarEventParticipants } = calendarEvent;
|
||||
@@ -235,6 +239,12 @@ export const CalendarEventDetails = ({
|
||||
/>
|
||||
)}
|
||||
{standardFields.slice(2).map(renderField)}
|
||||
{calendarEvent.description && (
|
||||
<LinkifiedTextBody
|
||||
body={calendarEvent.description}
|
||||
isDisplayed
|
||||
/>
|
||||
)}
|
||||
{customFields.map(renderField)}
|
||||
</StyledFields>
|
||||
</StyledContainer>
|
||||
|
||||
@@ -58,6 +58,12 @@ const StyledTime = styled.div`
|
||||
width: ${themeCssVariables.spacing[26]};
|
||||
`;
|
||||
|
||||
const StyledRecurrence = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-weight: ${themeCssVariables.font.weight.regular};
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div<{ active: boolean; canceled: boolean }>`
|
||||
color: ${({ active }) =>
|
||||
active ? themeCssVariables.font.color.primary : 'inherit'};
|
||||
@@ -120,6 +126,9 @@ export const CalendarEventRow = ({
|
||||
{showTitle ? (
|
||||
<StyledTitle active={!hasEnded} canceled={!!calendarEvent.isCanceled}>
|
||||
{calendarEvent.title}
|
||||
{calendarEvent.recurrence && (
|
||||
<StyledRecurrence>{` · ${calendarEvent.recurrence}`}</StyledRecurrence>
|
||||
)}
|
||||
</StyledTitle>
|
||||
) : (
|
||||
<CalendarEventNotSharedContent />
|
||||
|
||||
+26
-1
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { format, getYear } from 'date-fns';
|
||||
|
||||
import { CalendarDayCardContent } from '@/activities/calendar/components/CalendarDayCardContent';
|
||||
import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard';
|
||||
import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from '@/activities/calendar/constants/Calendar';
|
||||
import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext';
|
||||
@@ -14,6 +15,7 @@ import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomRes
|
||||
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
|
||||
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
|
||||
import { H3Title } from 'twenty-ui/display';
|
||||
import {
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
Card,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
Section,
|
||||
} from 'twenty-ui/layout';
|
||||
@@ -80,12 +83,18 @@ export const CalendarEventsCard = () => {
|
||||
const { timelineCalendarEvents, totalNumberOfCalendarEvents } =
|
||||
data?.[queryName] ?? {};
|
||||
|
||||
const allEvents = timelineCalendarEvents || [];
|
||||
|
||||
const recurringEvents = allEvents.filter((event) =>
|
||||
isDefined(event.recurrence),
|
||||
);
|
||||
|
||||
const {
|
||||
calendarEventsByDayTime,
|
||||
daysByMonthTime,
|
||||
monthTimes,
|
||||
monthTimesByYear,
|
||||
} = useCalendarEvents(timelineCalendarEvents || []);
|
||||
} = useCalendarEvents(allEvents);
|
||||
|
||||
const hasMoreCalendarEvents =
|
||||
timelineCalendarEvents && totalNumberOfCalendarEvents
|
||||
@@ -131,6 +140,22 @@ export const CalendarEventsCard = () => {
|
||||
}}
|
||||
>
|
||||
<StyledContainer>
|
||||
{recurringEvents.length > 0 && (
|
||||
<Section>
|
||||
<StyledTitleContainer>
|
||||
<H3Title title={t`Recurring`} />
|
||||
</StyledTitleContainer>
|
||||
<Card fullWidth>
|
||||
{recurringEvents.map((event, index) => (
|
||||
<CalendarDayCardContent
|
||||
key={event.id}
|
||||
calendarEvents={[event]}
|
||||
divider={index < recurringEvents.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</Section>
|
||||
)}
|
||||
{monthTimes.map((monthTime) => {
|
||||
const monthDayTimes = daysByMonthTime[monthTime] || [];
|
||||
const year = getYear(monthTime);
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ export const timelineCalendarEventFragment = gql`
|
||||
startsAt
|
||||
endsAt
|
||||
isFullDay
|
||||
recurrence
|
||||
visibility
|
||||
participants {
|
||||
...TimelineCalendarEventParticipantFragment
|
||||
|
||||
@@ -14,6 +14,7 @@ export type CalendarEvent = {
|
||||
isCanceled?: boolean;
|
||||
isFullDay: boolean;
|
||||
location?: string;
|
||||
recurrence?: string;
|
||||
startsAt: string;
|
||||
title?: string;
|
||||
visibility: CalendarChannelVisibility;
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmailThreadMessageBody } from '@/activities/emails/components/EmailThreadMessageBody';
|
||||
import { LinkifiedTextBody } from '@/ui/display/components/LinkifiedTextBody';
|
||||
import { EmailThreadMessageBodyPreview } from '@/activities/emails/components/EmailThreadMessageBodyPreview';
|
||||
import { EmailThreadMessageReceivers } from '@/activities/emails/components/EmailThreadMessageReceivers';
|
||||
import { EmailThreadMessageSender } from '@/activities/emails/components/EmailThreadMessageSender';
|
||||
@@ -82,7 +82,7 @@ export const EmailThreadMessage = ({
|
||||
visibility={MessageChannelVisibility.METADATA}
|
||||
/>
|
||||
) : isOpen ? (
|
||||
<EmailThreadMessageBody body={body} isDisplayed />
|
||||
<LinkifiedTextBody body={body} isDisplayed />
|
||||
) : (
|
||||
<EmailThreadMessageBodyPreview body={body} />
|
||||
)}
|
||||
|
||||
+8
-11
@@ -4,7 +4,7 @@ import Linkify from 'linkify-react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledThreadMessageBody = styled(motion.div)`
|
||||
const StyledTextBody = styled(motion.div)`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -13,30 +13,27 @@ const StyledThreadMessageBody = styled(motion.div)`
|
||||
white-space: pre-line;
|
||||
|
||||
a {
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
|
||||
color: ${themeCssVariables.color.blue};
|
||||
text-decoration: underline;
|
||||
text-decoration-color: ${themeCssVariables.font.color.primary};
|
||||
|
||||
&:hover {
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
text-decoration-color: ${themeCssVariables.border.color.strong};
|
||||
text-decoration-color: ${themeCssVariables.color.blue};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type EmailThreadMessageBodyProps = {
|
||||
type LinkifiedTextBodyProps = {
|
||||
body: string;
|
||||
isDisplayed: boolean;
|
||||
};
|
||||
|
||||
export const EmailThreadMessageBody = ({
|
||||
export const LinkifiedTextBody = ({
|
||||
body,
|
||||
isDisplayed,
|
||||
}: EmailThreadMessageBodyProps) => {
|
||||
}: LinkifiedTextBodyProps) => {
|
||||
return (
|
||||
<AnimatedEaseInOut isOpen={isDisplayed} duration="fast">
|
||||
<StyledThreadMessageBody>
|
||||
<StyledTextBody>
|
||||
<Linkify
|
||||
options={{
|
||||
target: '_blank',
|
||||
@@ -45,7 +42,7 @@ export const EmailThreadMessageBody = ({
|
||||
>
|
||||
{body}
|
||||
</Linkify>
|
||||
</StyledThreadMessageBody>
|
||||
</StyledTextBody>
|
||||
</AnimatedEaseInOut>
|
||||
);
|
||||
};
|
||||
@@ -170,6 +170,7 @@
|
||||
"react-dom": "18.3.1",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rrule": "^2.8.1",
|
||||
"rxjs": "7.8.1",
|
||||
"semver": "7.6.3",
|
||||
"sharp": "0.32.6",
|
||||
|
||||
+3
@@ -54,6 +54,9 @@ export class TimelineCalendarEventDTO {
|
||||
@Field()
|
||||
conferenceSolution: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
recurrence: string;
|
||||
|
||||
@Field(() => LinksMetadataDTO)
|
||||
conferenceLink: LinksMetadataDTO;
|
||||
|
||||
|
||||
+17
@@ -383,6 +383,23 @@ export const buildCalendarEventStandardFlatFieldMetadatas = ({
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
recurrence: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'recurrence',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: i18nLabel(msg`Recurrence`),
|
||||
description: i18nLabel(msg`Recurrence pattern`),
|
||||
icon: 'IconRepeat',
|
||||
isNullable: true,
|
||||
isUIReadOnly: true,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
conferenceLink: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import { type calendar_v3 as calendarV3 } from 'googleapis';
|
||||
|
||||
import { parseRRule } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/parse-rrule.util';
|
||||
import { sanitizeCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/sanitizeCalendarEvent';
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
@@ -44,6 +45,7 @@ const formatGoogleCalendarEvent = (
|
||||
conferenceLinkLabel: event.conferenceData?.entryPoints?.[0]?.uri ?? '',
|
||||
conferenceLinkUrl: event.conferenceData?.entryPoints?.[0]?.uri ?? '',
|
||||
recurringEventExternalId: event.recurringEventId ?? '',
|
||||
recurrence: parseRRule(event.recurrence?.[0] ?? ''),
|
||||
participants:
|
||||
event.attendees?.map((attendee) => ({
|
||||
handle: attendee.email ?? '',
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { parseRRule } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/parse-rrule.util';
|
||||
|
||||
describe('parseRRule', () => {
|
||||
it('should return undefined for invalid input', () => {
|
||||
expect(parseRRule('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse a weekly rule with BYDAY and UNTIL', () => {
|
||||
expect(
|
||||
parseRRule('RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20270331T000000Z'),
|
||||
).toBe('every week on Friday until March 31, 2027');
|
||||
});
|
||||
|
||||
it('should parse a daily rule with COUNT', () => {
|
||||
expect(parseRRule('RRULE:FREQ=DAILY;COUNT=5')).toBe(
|
||||
'every day for 5 times',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a weekly rule with multiple days', () => {
|
||||
expect(parseRRule('RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR')).toBe(
|
||||
'every week on Monday, Wednesday, Friday',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a biweekly rule', () => {
|
||||
expect(parseRRule('RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TH')).toBe(
|
||||
'every 2 weeks on Thursday',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a monthly rule with BYMONTHDAY', () => {
|
||||
expect(parseRRule('RRULE:FREQ=MONTHLY;BYMONTHDAY=15')).toBe(
|
||||
'every month on the 15th',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a monthly rule with ordinal BYDAY', () => {
|
||||
expect(parseRRule('RRULE:FREQ=MONTHLY;BYDAY=2TU')).toBe(
|
||||
'every month on the 2nd Tuesday',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a yearly rule with BYMONTH and BYMONTHDAY', () => {
|
||||
expect(parseRRule('RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=1')).toBe(
|
||||
'every June on the 1st',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a real Google Calendar weekly recurring event', () => {
|
||||
expect(
|
||||
parseRRule(
|
||||
'RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20270331T133000Z;INTERVAL=1;BYDAY=FR',
|
||||
),
|
||||
).toBe('every week on Friday until March 31, 2027');
|
||||
});
|
||||
|
||||
it('should handle RRULE prefix being absent', () => {
|
||||
expect(parseRRule('FREQ=WEEKLY;BYDAY=TU')).toBe('every week on Tuesday');
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { RRule } from 'rrule';
|
||||
|
||||
export const parseRRule = (rruleString: string): string | undefined => {
|
||||
if (!rruleString) return undefined;
|
||||
|
||||
try {
|
||||
const normalized = rruleString.startsWith('RRULE:')
|
||||
? rruleString
|
||||
: `RRULE:${rruleString}`;
|
||||
|
||||
return RRule.fromString(normalized).toText();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { CalendarSaveEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service';
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
let eventStore: Record<string, any>[];
|
||||
let associationStore: Record<string, any>[];
|
||||
|
||||
const mockCalendarEventRepository = {
|
||||
find: jest.fn(async ({ where }: any) => {
|
||||
const iCalUids: string[] = where.iCalUid.value;
|
||||
|
||||
return eventStore.filter((e) => iCalUids.includes(e.iCalUid));
|
||||
}),
|
||||
insert: jest.fn(async (entities: any[]) => {
|
||||
eventStore.push(...entities);
|
||||
}),
|
||||
updateMany: jest.fn(async (updates: any[]) => {
|
||||
for (const { criteria, partialEntity } of updates) {
|
||||
const idx = eventStore.findIndex((e) => e.id === criteria);
|
||||
|
||||
if (idx !== -1) {
|
||||
eventStore[idx] = { ...eventStore[idx], ...partialEntity };
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAssociationRepository = {
|
||||
insert: jest.fn(async (entities: any[]) => {
|
||||
associationStore.push(...entities);
|
||||
}),
|
||||
};
|
||||
|
||||
const mockCalendarEventParticipantService = {
|
||||
upsertAndDeleteCalendarEventParticipants: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGlobalWorkspaceOrmManager = {
|
||||
executeInWorkspaceContext: jest.fn(async (callback: () => Promise<void>) => {
|
||||
await callback();
|
||||
}),
|
||||
getRepository: jest.fn(async (_workspaceId: string, entityName: string) => {
|
||||
if (entityName === 'calendarEvent') return mockCalendarEventRepository;
|
||||
if (entityName === 'calendarChannelEventAssociation')
|
||||
return mockAssociationRepository;
|
||||
|
||||
return {};
|
||||
}),
|
||||
getGlobalWorkspaceDataSource: jest.fn(async () => ({
|
||||
transaction: async (callback: (manager: unknown) => Promise<void>) => {
|
||||
await callback({} as any);
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const workspaceId = 'workspace-123';
|
||||
|
||||
const calendarChannel = {
|
||||
id: 'channel-123',
|
||||
} as unknown as CalendarChannelEntity;
|
||||
|
||||
const connectedAccount = {
|
||||
id: 'account-123',
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const SHARED_ICAL_UID =
|
||||
'040000008200E00074C5B7101A82E0080000000030EA36F0D6ACDC01000000000000000010000000DAD2907D895870419A226EB4E0FE7607';
|
||||
const MASTER_ID = 'recurring_master_abc';
|
||||
|
||||
const createRecurringEventInstance = (
|
||||
date: string,
|
||||
startsAt: string,
|
||||
): FetchedCalendarEvent => ({
|
||||
id: `${MASTER_ID}_${date}`,
|
||||
iCalUid: SHARED_ICAL_UID,
|
||||
recurringEventExternalId: MASTER_ID,
|
||||
title: 'Weekly Sync',
|
||||
startsAt,
|
||||
endsAt: startsAt,
|
||||
description: '',
|
||||
location: '',
|
||||
isFullDay: false,
|
||||
isCanceled: false,
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: '',
|
||||
externalCreatedAt: '',
|
||||
externalUpdatedAt: '',
|
||||
conferenceSolution: '',
|
||||
participants: [],
|
||||
status: 'confirmed',
|
||||
});
|
||||
|
||||
describe('CalendarSaveEventsService', () => {
|
||||
let service: CalendarSaveEventsService;
|
||||
|
||||
const save = (events: FetchedCalendarEvent[]) =>
|
||||
service.saveCalendarEventsAndEnqueueContactCreationJob(
|
||||
events,
|
||||
calendarChannel,
|
||||
connectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
eventStore = [];
|
||||
associationStore = [];
|
||||
|
||||
service = new CalendarSaveEventsService(
|
||||
mockGlobalWorkspaceOrmManager as any,
|
||||
mockCalendarEventParticipantService as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('recurring event instances with shared iCalUID', () => {
|
||||
it('should store only one record per recurring series', async () => {
|
||||
await save([
|
||||
createRecurringEventInstance('20260410', '2026-04-10T16:30:00+03:00'),
|
||||
]);
|
||||
await save([
|
||||
createRecurringEventInstance('20260417', '2026-04-17T16:30:00+03:00'),
|
||||
]);
|
||||
|
||||
expect(eventStore).toHaveLength(1);
|
||||
expect(associationStore).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should store one record when multiple instances arrive in the same batch', async () => {
|
||||
await save([
|
||||
createRecurringEventInstance('20260410', '2026-04-10T16:30:00+03:00'),
|
||||
createRecurringEventInstance('20260417', '2026-04-17T16:30:00+03:00'),
|
||||
createRecurringEventInstance('20260424', '2026-04-24T16:30:00+03:00'),
|
||||
]);
|
||||
|
||||
expect(eventStore).toHaveLength(1);
|
||||
expect(associationStore).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should store the parsed recurrence text from the master event', async () => {
|
||||
const masterEvent: FetchedCalendarEvent = {
|
||||
id: MASTER_ID,
|
||||
iCalUid: SHARED_ICAL_UID,
|
||||
title: 'Weekly Sync',
|
||||
startsAt: '2026-04-10T16:30:00+03:00',
|
||||
endsAt: '2026-04-10T17:00:00+03:00',
|
||||
description: '',
|
||||
location: '',
|
||||
isFullDay: false,
|
||||
isCanceled: false,
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: '',
|
||||
externalCreatedAt: '',
|
||||
externalUpdatedAt: '',
|
||||
conferenceSolution: '',
|
||||
recurrence: 'every week on Friday until March 31, 2027',
|
||||
participants: [],
|
||||
status: 'confirmed',
|
||||
};
|
||||
|
||||
await save([masterEvent]);
|
||||
|
||||
expect(eventStore).toHaveLength(1);
|
||||
expect(eventStore[0].recurrence).toBe(
|
||||
'every week on Friday until March 31, 2027',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
-14
@@ -52,11 +52,21 @@ export class CalendarSaveEventsService {
|
||||
|
||||
await workspaceDataSource.transaction(
|
||||
async (transactionManager: WorkspaceEntityManager) => {
|
||||
const deduplicatedByICalUid = new Map<string, FetchedCalendarEvent>();
|
||||
|
||||
for (const event of fetchedCalendarEvents) {
|
||||
if (!deduplicatedByICalUid.has(event.iCalUid) || event.recurrence) {
|
||||
deduplicatedByICalUid.set(event.iCalUid, event);
|
||||
}
|
||||
}
|
||||
|
||||
const eventsToProcess = [...deduplicatedByICalUid.values()];
|
||||
|
||||
const existingCalendarEvents = await calendarEventRepository.find(
|
||||
{
|
||||
where: {
|
||||
iCalUid: Any(
|
||||
fetchedCalendarEvents.map((event) => event.iCalUid as string),
|
||||
eventsToProcess.map((event) => event.iCalUid as string),
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -64,20 +74,17 @@ export class CalendarSaveEventsService {
|
||||
);
|
||||
|
||||
const fetchedCalendarEventsWithDBEvents: FetchedCalendarEventWithDBEvent[] =
|
||||
fetchedCalendarEvents.map(
|
||||
(event): FetchedCalendarEventWithDBEvent => {
|
||||
const existingEventWithSameiCalUid =
|
||||
existingCalendarEvents.find(
|
||||
(existingEvent) => existingEvent.iCalUid === event.iCalUid,
|
||||
);
|
||||
eventsToProcess.map((event): FetchedCalendarEventWithDBEvent => {
|
||||
const existingEventWithSameiCalUid = existingCalendarEvents.find(
|
||||
(existingEvent) => existingEvent.iCalUid === event.iCalUid,
|
||||
);
|
||||
|
||||
return {
|
||||
fetchedCalendarEvent: event,
|
||||
existingCalendarEvent: existingEventWithSameiCalUid ?? null,
|
||||
newlyCreatedCalendarEvent: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
fetchedCalendarEvent: event,
|
||||
existingCalendarEvent: existingEventWithSameiCalUid ?? null,
|
||||
newlyCreatedCalendarEvent: null,
|
||||
};
|
||||
});
|
||||
|
||||
const newCalendarEventsToInsert = fetchedCalendarEventsWithDBEvents
|
||||
.filter(
|
||||
@@ -94,6 +101,7 @@ export class CalendarSaveEventsService {
|
||||
isFullDay: fetchedCalendarEvent.isFullDay,
|
||||
isCanceled: fetchedCalendarEvent.isCanceled,
|
||||
conferenceSolution: fetchedCalendarEvent.conferenceSolution,
|
||||
recurrence: fetchedCalendarEvent.recurrence ?? null,
|
||||
conferenceLink: {
|
||||
primaryLinkLabel: fetchedCalendarEvent.conferenceLinkLabel,
|
||||
primaryLinkUrl: fetchedCalendarEvent.conferenceLinkUrl,
|
||||
@@ -155,6 +163,9 @@ export class CalendarSaveEventsService {
|
||||
isFullDay: fetchedCalendarEvent.isFullDay,
|
||||
isCanceled: fetchedCalendarEvent.isCanceled,
|
||||
conferenceSolution: fetchedCalendarEvent.conferenceSolution,
|
||||
...(fetchedCalendarEvent.recurrence
|
||||
? { recurrence: fetchedCalendarEvent.recurrence }
|
||||
: {}),
|
||||
conferenceLink: {
|
||||
primaryLinkLabel:
|
||||
fetchedCalendarEvent.conferenceLinkLabel,
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ export class CalendarEventWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
location: string | null;
|
||||
iCalUid: string | null;
|
||||
conferenceSolution: string | null;
|
||||
recurrence: string | null;
|
||||
conferenceLink: LinksMetadata;
|
||||
calendarChannelEventAssociations: EntityRelation<
|
||||
CalendarChannelEventAssociationWorkspaceEntity[]
|
||||
|
||||
@@ -21,6 +21,7 @@ export type FetchedCalendarEvent = {
|
||||
externalUpdatedAt: string;
|
||||
conferenceSolution: string;
|
||||
recurringEventExternalId?: string;
|
||||
recurrence?: string;
|
||||
participants: FetchedCalendarEventParticipant[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
@@ -595,6 +595,9 @@ export const STANDARD_OBJECTS = {
|
||||
conferenceSolution: {
|
||||
universalIdentifier: '20202020-1c3f-4b5a-b526-5411a82179eb',
|
||||
},
|
||||
recurrence: {
|
||||
universalIdentifier: '20202020-a1b2-4c3d-8e4f-f5a6b7c8d9e0',
|
||||
},
|
||||
conferenceLink: {
|
||||
universalIdentifier: '20202020-35da-43ef-9ca0-e936e9dc237b',
|
||||
},
|
||||
|
||||
@@ -56209,7 +56209,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rrule@npm:2.8.1":
|
||||
"rrule@npm:2.8.1, rrule@npm:^2.8.1":
|
||||
version: 2.8.1
|
||||
resolution: "rrule@npm:2.8.1"
|
||||
dependencies:
|
||||
@@ -60819,6 +60819,7 @@ __metadata:
|
||||
redis: "npm:^4.7.0"
|
||||
reflect-metadata: "npm:0.2.2"
|
||||
rimraf: "npm:^5.0.5"
|
||||
rrule: "npm:^2.8.1"
|
||||
rxjs: "npm:7.8.1"
|
||||
semver: "npm:7.6.3"
|
||||
sharp: "npm:0.32.6"
|
||||
|
||||
Reference in New Issue
Block a user