@open-mercato/core 0.6.8-develop.7063.1.664341cf65 → 0.6.8-develop.7065.1.b7b351a126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/api/interactions/route.js +3 -1
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js.map +1 -1
- package/dist/modules/customers/components/calendar/AgendaList.js +2 -2
- package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js +1 -1
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarScreen.js +1 -1
- package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
- package/dist/modules/customers/components/calendar/EventBlock.js +1 -1
- package/dist/modules/customers/components/calendar/EventBlock.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js +40 -5
- package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js +2 -1
- package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
- package/dist/modules/customers/components/calendar/types.js +1 -1
- package/dist/modules/customers/components/calendar/types.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +4 -4
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +3 -3
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/dist/modules/customers/data/entities.js.map +1 -1
- package/dist/modules/customers/data/validators.js +22 -1
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/calendar/conflicts.js +8 -2
- package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
- package/dist/modules/customers/lib/calendar/editorPayload.js +3 -2
- package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
- package/dist/modules/customers/lib/calendar/mapItem.js +8 -3
- package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
- package/dist/modules/customers/lib/calendar/participantIdentity.js +9 -0
- package/dist/modules/customers/lib/calendar/participantIdentity.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/customers/api/interactions/route.ts +8 -2
- package/src/modules/customers/commands/interactions.ts +1 -1
- package/src/modules/customers/components/calendar/AgendaList.tsx +3 -3
- package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +1 -1
- package/src/modules/customers/components/calendar/CalendarScreen.tsx +1 -1
- package/src/modules/customers/components/calendar/EventBlock.tsx +2 -2
- package/src/modules/customers/components/calendar/editor/PeopleField.tsx +61 -5
- package/src/modules/customers/components/calendar/editor/hooks.ts +7 -1
- package/src/modules/customers/components/calendar/types.ts +2 -2
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +5 -5
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +5 -5
- package/src/modules/customers/components/detail/types.ts +1 -1
- package/src/modules/customers/data/entities.ts +1 -1
- package/src/modules/customers/data/validators.ts +32 -6
- package/src/modules/customers/i18n/de.json +4 -0
- package/src/modules/customers/i18n/en.json +4 -0
- package/src/modules/customers/i18n/es.json +4 -0
- package/src/modules/customers/i18n/ko.json +4 -0
- package/src/modules/customers/i18n/pl.json +4 -0
- package/src/modules/customers/lib/calendar/conflicts.ts +10 -2
- package/src/modules/customers/lib/calendar/editorPayload.ts +4 -3
- package/src/modules/customers/lib/calendar/mapItem.ts +8 -3
- package/src/modules/customers/lib/calendar/participantIdentity.ts +17 -0
|
@@ -114,7 +114,7 @@ type InteractionSnapshot = {
|
|
|
114
114
|
allDay: boolean | null
|
|
115
115
|
recurrenceRule: string | null
|
|
116
116
|
recurrenceEnd: Date | null
|
|
117
|
-
participants: Array<{ userId
|
|
117
|
+
participants: Array<{ userId?: string; name?: string; email?: string; status?: string }> | null
|
|
118
118
|
reminderMinutes: number | null
|
|
119
119
|
visibility: string | null
|
|
120
120
|
linkedEntities: Array<{ id: string; type: string; label: string }> | null
|
|
@@ -56,7 +56,7 @@ function deriveTypeLabel(interactionType: string): string {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function participantLabel(participant: CalendarItem['participants'][number]): string {
|
|
59
|
-
return participant.name ?? participant.email ?? participant.userId
|
|
59
|
+
return participant.name ?? participant.email ?? participant.userId ?? ''
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function buildDayGroups(anchor: Date, horizonDays: number, items: CalendarItem[]): AgendaDayGroup[] {
|
|
@@ -171,8 +171,8 @@ function AgendaRow({
|
|
|
171
171
|
<div className="flex shrink-0 items-center gap-2">
|
|
172
172
|
{item.participants.length > 0 ? (
|
|
173
173
|
<span className="hidden items-center gap-0.5 sm:flex">
|
|
174
|
-
{item.participants.slice(0, MAX_AVATARS_PER_ROW).map((participant) => (
|
|
175
|
-
<Avatar key={participant.userId} size="xs" label={participantLabel(participant)} />
|
|
174
|
+
{item.participants.slice(0, MAX_AVATARS_PER_ROW).map((participant, index) => (
|
|
175
|
+
<Avatar key={participant.userId ?? participant.email ?? index} size="xs" label={participantLabel(participant)} />
|
|
176
176
|
))}
|
|
177
177
|
{item.participants.length > MAX_AVATARS_PER_ROW ? (
|
|
178
178
|
<span className="ps-0.5 text-xs font-medium text-muted-foreground">
|
|
@@ -170,7 +170,7 @@ function EditorBody({
|
|
|
170
170
|
const ids: string[] = []
|
|
171
171
|
if (form.relatedTo && form.relatedTo.kind !== 'company') ids.push(form.relatedTo.id)
|
|
172
172
|
for (const participant of form.participants) {
|
|
173
|
-
if (participant.isCustomer) ids.push(participant.userId)
|
|
173
|
+
if (participant.isCustomer && participant.userId) ids.push(participant.userId)
|
|
174
174
|
}
|
|
175
175
|
return Array.from(new Set(ids))
|
|
176
176
|
}, [form.relatedTo, form.participants])
|
|
@@ -238,7 +238,7 @@ export function CalendarScreen({ resourcesEnabled = false, staffEnabled = true }
|
|
|
238
238
|
const participantNames = new Map<string, string>()
|
|
239
239
|
for (const item of visibleItems) {
|
|
240
240
|
for (const participant of item.participants) {
|
|
241
|
-
if (participant.name && !participantNames.has(participant.userId)) {
|
|
241
|
+
if (participant.userId && participant.name && !participantNames.has(participant.userId)) {
|
|
242
242
|
participantNames.set(participant.userId, participant.name)
|
|
243
243
|
}
|
|
244
244
|
}
|
|
@@ -180,8 +180,8 @@ export const EventBlock = React.forwardRef<HTMLButtonElement, EventBlockProps>(f
|
|
|
180
180
|
{visibleParticipants.length > 0 ? (
|
|
181
181
|
<>
|
|
182
182
|
<AvatarStack size="xs" max={MAX_VISIBLE_AVATARS} className="shrink-0 gap-px [&>*:not(:first-child)]:-ml-1">
|
|
183
|
-
{visibleParticipants.map((participant) => (
|
|
184
|
-
<Avatar key={participant.userId} size="xs" label={participantLabel(participant)} />
|
|
183
|
+
{visibleParticipants.map((participant, index) => (
|
|
184
|
+
<Avatar key={participant.userId ?? participant.email ?? index} size="xs" label={participantLabel(participant)} />
|
|
185
185
|
))}
|
|
186
186
|
</AvatarStack>
|
|
187
187
|
{overflowCount > 0 ? (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
+
import { z } from 'zod'
|
|
4
5
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
7
|
import { Avatar } from '@open-mercato/ui/primitives/avatar'
|
|
@@ -11,6 +12,15 @@ import { composeAccessibleName } from '../../../lib/calendar/labels'
|
|
|
11
12
|
import { searchPeopleOptions, type PersonOption } from './lookups'
|
|
12
13
|
import { CONTROL_BORDER, DROPDOWN_PANEL_CLASS, PersonChip, UppercaseBadge, useDropdownDismiss } from './inputs'
|
|
13
14
|
|
|
15
|
+
// Mirrors the server-side guest rule in `interactionParticipantSchema`: a
|
|
16
|
+
// participant with no user record is addressable only by a routable email.
|
|
17
|
+
const guestEmailSchema = z.string().email().max(320)
|
|
18
|
+
|
|
19
|
+
function normalizeEmail(email: string | null | undefined): string | null {
|
|
20
|
+
const normalized = email?.trim().toLowerCase()
|
|
21
|
+
return normalized ? normalized : null
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export function PeopleField({
|
|
15
25
|
mode,
|
|
16
26
|
placeholder,
|
|
@@ -63,12 +73,38 @@ export function PeopleField({
|
|
|
63
73
|
const selectedIds = new Set(value.map((participant) => participant.userId))
|
|
64
74
|
const visibleOptions = options.filter((option) => !selectedIds.has(option.userId))
|
|
65
75
|
|
|
76
|
+
// An external guest has no record to search for, so a typed address that
|
|
77
|
+
// matches nothing is the only way to invite one (#5115). Multi mode only —
|
|
78
|
+
// the single-mode field is the task assignee, which becomes `ownerUserId` and
|
|
79
|
+
// must resolve to a real staff user.
|
|
80
|
+
const typedEmail = normalizeEmail(query)
|
|
81
|
+
const selectedEmails = new Set(
|
|
82
|
+
value.map((participant) => normalizeEmail(participant.email)).filter((email): email is string => email !== null),
|
|
83
|
+
)
|
|
84
|
+
const guestEmail =
|
|
85
|
+
mode === 'multi' &&
|
|
86
|
+
typedEmail !== null &&
|
|
87
|
+
guestEmailSchema.safeParse(typedEmail).success &&
|
|
88
|
+
!selectedEmails.has(typedEmail) &&
|
|
89
|
+
!options.some((option) => normalizeEmail(option.email) === typedEmail)
|
|
90
|
+
? typedEmail
|
|
91
|
+
: null
|
|
92
|
+
const guestLabel = guestEmail
|
|
93
|
+
? t('customers.calendar.editor.addGuest', 'Invite {email} as a guest', { email: guestEmail })
|
|
94
|
+
: ''
|
|
95
|
+
|
|
66
96
|
const customerBadge = (
|
|
67
97
|
<UppercaseBadge className="bg-status-info-bg text-status-info-text">
|
|
68
98
|
{t('customers.calendar.editor.customerBadge', 'Customer')}
|
|
69
99
|
</UppercaseBadge>
|
|
70
100
|
)
|
|
71
101
|
|
|
102
|
+
const guestBadge = (
|
|
103
|
+
<UppercaseBadge className="bg-muted text-muted-foreground">
|
|
104
|
+
{t('customers.calendar.editor.guestBadge', 'Guest')}
|
|
105
|
+
</UppercaseBadge>
|
|
106
|
+
)
|
|
107
|
+
|
|
72
108
|
return (
|
|
73
109
|
<div
|
|
74
110
|
ref={rootRef}
|
|
@@ -84,12 +120,12 @@ export function PeopleField({
|
|
|
84
120
|
CONTROL_BORDER,
|
|
85
121
|
)}
|
|
86
122
|
>
|
|
87
|
-
{value.map((participant) => (
|
|
123
|
+
{value.map((participant, index) => (
|
|
88
124
|
<PersonChip
|
|
89
|
-
key={participant.userId}
|
|
125
|
+
key={participant.userId ?? participant.email ?? index}
|
|
90
126
|
name={participant.name}
|
|
91
|
-
badge={participant.isCustomer ? customerBadge : undefined}
|
|
92
|
-
onRemove={() => onChange(value.filter((
|
|
127
|
+
badge={participant.isCustomer ? customerBadge : participant.userId ? undefined : guestBadge}
|
|
128
|
+
onRemove={() => onChange(value.filter((_, entryIndex) => entryIndex !== index))}
|
|
93
129
|
removeLabel={t('customers.calendar.editor.removePerson', 'Remove {name}', { name: participant.name })}
|
|
94
130
|
/>
|
|
95
131
|
))}
|
|
@@ -113,11 +149,31 @@ export function PeopleField({
|
|
|
113
149
|
{t('customers.calendar.editor.searching', 'Searching…')}
|
|
114
150
|
</p>
|
|
115
151
|
) : null}
|
|
116
|
-
{!loading && visibleOptions.length === 0 ? (
|
|
152
|
+
{!loading && visibleOptions.length === 0 && !guestEmail ? (
|
|
117
153
|
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
|
118
154
|
{t('customers.calendar.editor.noResults', 'No results')}
|
|
119
155
|
</p>
|
|
120
156
|
) : null}
|
|
157
|
+
{!loading && guestEmail ? (
|
|
158
|
+
<Button
|
|
159
|
+
type="button"
|
|
160
|
+
variant="ghost"
|
|
161
|
+
role="option"
|
|
162
|
+
aria-selected={false}
|
|
163
|
+
aria-label={guestLabel}
|
|
164
|
+
title={guestLabel}
|
|
165
|
+
onClick={() => {
|
|
166
|
+
const participant: EditorParticipant = { name: guestEmail, email: guestEmail, isCustomer: false }
|
|
167
|
+
onChange([...value, participant])
|
|
168
|
+
setQuery('')
|
|
169
|
+
}}
|
|
170
|
+
className="h-auto w-full justify-start gap-2 whitespace-normal px-2 py-1.5 text-left text-sm font-normal text-foreground"
|
|
171
|
+
>
|
|
172
|
+
<Avatar size="xs" label={guestEmail} />
|
|
173
|
+
<span className="min-w-0 flex-1 truncate">{guestLabel}</span>
|
|
174
|
+
{guestBadge}
|
|
175
|
+
</Button>
|
|
176
|
+
) : null}
|
|
121
177
|
{!loading
|
|
122
178
|
? visibleOptions.map((option) => (
|
|
123
179
|
<Button
|
|
@@ -8,6 +8,7 @@ import { computeDurationMinutes, type EditorFormState, type EditorKindConfig } f
|
|
|
8
8
|
import { findEditorConflictItems } from '../../../lib/calendar/conflicts'
|
|
9
9
|
import type { ConflictScope } from '../../../lib/calendar/preferences'
|
|
10
10
|
import { mapInteractionToCalendarItem } from '../../../lib/calendar/mapItem'
|
|
11
|
+
import { participantActorKey } from '../../../lib/calendar/participantIdentity'
|
|
11
12
|
import { expandOccurrences } from '../../../lib/calendar/recurrence'
|
|
12
13
|
import { getFetchWindow } from '../../../lib/calendar/range'
|
|
13
14
|
import type { CalendarItem } from '../types'
|
|
@@ -72,7 +73,12 @@ export function useConflictProbe(
|
|
|
72
73
|
): string | null {
|
|
73
74
|
const t = useT()
|
|
74
75
|
const [conflict, setConflict] = React.useState<string | null>(null)
|
|
75
|
-
|
|
76
|
+
// Keys on the canonical actor key, not the raw userId — an external guest has
|
|
77
|
+
// no userId, so a userId-only key never changes when guests are added or
|
|
78
|
+
// removed and the probe would keep reporting the previous attendee set.
|
|
79
|
+
const participantsKey = form.participants
|
|
80
|
+
.map((participant) => participantActorKey(participant) ?? '')
|
|
81
|
+
.join(',')
|
|
76
82
|
React.useEffect(() => {
|
|
77
83
|
if (!open || !form.date) {
|
|
78
84
|
setConflict(null)
|
|
@@ -18,7 +18,7 @@ export type CalendarRange = { from: Date; to: Date }
|
|
|
18
18
|
|
|
19
19
|
const calendarParticipantSchema = z
|
|
20
20
|
.object({
|
|
21
|
-
userId: z.string(),
|
|
21
|
+
userId: z.string().optional(),
|
|
22
22
|
name: z.string().optional(),
|
|
23
23
|
email: z.string().optional(),
|
|
24
24
|
})
|
|
@@ -49,7 +49,7 @@ export const calendarInteractionPayloadSchema = z
|
|
|
49
49
|
|
|
50
50
|
export type CalendarInteractionPayload = z.infer<typeof calendarInteractionPayloadSchema>
|
|
51
51
|
|
|
52
|
-
export type CalendarParticipant = { userId
|
|
52
|
+
export type CalendarParticipant = { userId?: string; name?: string; email?: string }
|
|
53
53
|
|
|
54
54
|
export interface CalendarItem {
|
|
55
55
|
id: string
|
|
@@ -173,7 +173,7 @@ interface ParticipantsFieldProps {
|
|
|
173
173
|
activityType: ActivityType
|
|
174
174
|
participants: Participant[]
|
|
175
175
|
setParticipants: React.Dispatch<React.SetStateAction<Participant[]>>
|
|
176
|
-
removeParticipant: (
|
|
176
|
+
removeParticipant: (index: number) => void
|
|
177
177
|
guestPermissions: { canInviteOthers: boolean; canModify: boolean; canSeeList: boolean }
|
|
178
178
|
setGuestPermissions: React.Dispatch<React.SetStateAction<{ canInviteOthers: boolean; canModify: boolean; canSeeList: boolean }>>
|
|
179
179
|
}
|
|
@@ -205,19 +205,19 @@ export function ParticipantsField({
|
|
|
205
205
|
{sectionLabel}
|
|
206
206
|
</label>
|
|
207
207
|
<div className="mt-2.5 flex flex-wrap content-center items-center gap-2 rounded-lg border border-border bg-background px-3 py-2.5">
|
|
208
|
-
{participants.map((p) => (
|
|
209
|
-
<div key={p.userId} className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-2.5 py-1.5">
|
|
208
|
+
{participants.map((p, index) => (
|
|
209
|
+
<div key={p.userId ?? p.email ?? index} className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-2.5 py-1.5">
|
|
210
210
|
<span className={cn('inline-flex size-5 items-center justify-center rounded-full text-xs font-bold text-white', p.color ?? 'bg-primary')}>
|
|
211
211
|
{p.name.charAt(0).toUpperCase()}
|
|
212
212
|
</span>
|
|
213
213
|
<span className="text-xs text-foreground">{p.name}</span>
|
|
214
|
-
<IconButton type="button" variant="ghost" size="sm" onClick={() => removeParticipant(
|
|
214
|
+
<IconButton type="button" variant="ghost" size="sm" onClick={() => removeParticipant(index)} className="h-auto text-muted-foreground hover:text-foreground p-0" aria-label={t('customers.schedule.removeParticipant', 'Remove participant')}>
|
|
215
215
|
<X className="size-3" />
|
|
216
216
|
</IconButton>
|
|
217
217
|
</div>
|
|
218
218
|
))}
|
|
219
219
|
<ParticipantSearchPopover
|
|
220
|
-
existingIds={new Set(participants.map((p) => p.userId))}
|
|
220
|
+
existingIds={new Set(participants.map((p) => p.userId).filter((userId): userId is string => Boolean(userId)))}
|
|
221
221
|
onAdd={(p) => setParticipants((prev) => [...prev, { ...p, status: 'pending' as RsvpStatus }])}
|
|
222
222
|
onAddMany={(nextParticipants) => {
|
|
223
223
|
setParticipants((prev) => [
|
|
@@ -4,7 +4,7 @@ import type { ActivityType } from './fieldConfig'
|
|
|
4
4
|
export type RsvpStatus = 'pending' | 'accepted' | 'declined' | 'tentative'
|
|
5
5
|
|
|
6
6
|
export type Participant = {
|
|
7
|
-
userId
|
|
7
|
+
userId?: string
|
|
8
8
|
name: string
|
|
9
9
|
email?: string
|
|
10
10
|
color?: string
|
|
@@ -36,7 +36,7 @@ export type ScheduleActivityEditData = {
|
|
|
36
36
|
allDay?: boolean | null
|
|
37
37
|
recurrenceRule?: string | null
|
|
38
38
|
recurrenceEnd?: string | null
|
|
39
|
-
participants?: Array<{ userId
|
|
39
|
+
participants?: Array<{ userId?: string; name?: string; email?: string; status?: string }> | null
|
|
40
40
|
reminderMinutes?: number | null
|
|
41
41
|
visibility?: string | null
|
|
42
42
|
linkedEntities?: Array<{ id: string; type: string; label: string }> | null
|
|
@@ -136,7 +136,7 @@ export function useScheduleFormState({ open, editData }: UseScheduleFormStatePar
|
|
|
136
136
|
Array.isArray(editData.participants)
|
|
137
137
|
? editData.participants.map((p, i) => ({
|
|
138
138
|
userId: p.userId,
|
|
139
|
-
name: p.name ?? p.userId,
|
|
139
|
+
name: p.name ?? p.email ?? p.userId ?? '',
|
|
140
140
|
email: p.email,
|
|
141
141
|
color: PARTICIPANT_COLORS[i % PARTICIPANT_COLORS.length],
|
|
142
142
|
status: (p.status ?? 'pending') as RsvpStatus,
|
|
@@ -219,8 +219,8 @@ export function useScheduleFormState({ open, editData }: UseScheduleFormStatePar
|
|
|
219
219
|
setReminderMinutes(DEFAULT_REMINDER_MINUTES[activityType])
|
|
220
220
|
}, [activityType, editData, open])
|
|
221
221
|
|
|
222
|
-
const removeParticipant = React.useCallback((
|
|
223
|
-
setParticipants((prev) => prev.filter((
|
|
222
|
+
const removeParticipant = React.useCallback((index: number) => {
|
|
223
|
+
setParticipants((prev) => prev.filter((_, i) => i !== index))
|
|
224
224
|
}, [])
|
|
225
225
|
|
|
226
226
|
const toggleRecurrenceDay = React.useCallback((index: number) => {
|
|
@@ -144,7 +144,7 @@ export type InteractionSummary = {
|
|
|
144
144
|
authorEmail?: string | null
|
|
145
145
|
dealTitle?: string | null
|
|
146
146
|
customValues?: Record<string, unknown> | null
|
|
147
|
-
participants?: Array<{ userId
|
|
147
|
+
participants?: Array<{ userId?: string; name?: string; email?: string; status?: string }> | null
|
|
148
148
|
reminderMinutes?: number | null
|
|
149
149
|
visibility?: string | null
|
|
150
150
|
linkedEntities?: Array<{ id: string; type: string; label: string }> | null
|
|
@@ -650,7 +650,7 @@ export class CustomerInteraction {
|
|
|
650
650
|
recurrenceEnd?: Date | null
|
|
651
651
|
|
|
652
652
|
@Property({ name: 'participants', type: 'jsonb', nullable: true })
|
|
653
|
-
participants?: Array<{ userId
|
|
653
|
+
participants?: Array<{ userId?: string; name?: string; email?: string; status?: string }> | null
|
|
654
654
|
|
|
655
655
|
@Property({ name: 'reminder_minutes', type: 'int', nullable: true })
|
|
656
656
|
reminderMinutes?: number | null
|
|
@@ -10,6 +10,8 @@ export const ACTIVITY_DATE_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.d
|
|
|
10
10
|
export const ACTIVITY_TIME_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.timeRequired'
|
|
11
11
|
export const ACTIVITY_PHONE_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.phoneRequired'
|
|
12
12
|
export const ACTIVITY_PHONE_INVALID_MESSAGE_KEY = 'customers.activities.errors.phoneInvalid'
|
|
13
|
+
export const INTERACTION_PARTICIPANT_IDENTITY_REQUIRED_MESSAGE_KEY = 'customers.activities.errors.participantIdentityRequired'
|
|
14
|
+
export const INTERACTION_PARTICIPANT_EMAIL_INVALID_MESSAGE_KEY = 'customers.activities.errors.participantEmailInvalid'
|
|
13
15
|
|
|
14
16
|
// customer_deals.description is an unbounded `text` column; this cap only exists to keep
|
|
15
17
|
// request bodies, fulltext search documents and query-index documents from growing without limit.
|
|
@@ -424,12 +426,36 @@ export const interactionStatusValues = ['planned', 'done', 'canceled'] as const
|
|
|
424
426
|
/** @deprecated See {@link interactionStatusValues}. */
|
|
425
427
|
export type InteractionStatus = typeof interactionStatusValues[number]
|
|
426
428
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
429
|
+
// A participant is either a real record (`userId`) or an external guest carrying
|
|
430
|
+
// no id at all. A guest is only addressable through its email, so that email is
|
|
431
|
+
// required and must actually be routable — an unparseable string would persist
|
|
432
|
+
// and only fail later, at invitation or calendar-sync time. Participants that DO
|
|
433
|
+
// have a userId keep an unvalidated auxiliary email, as before.
|
|
434
|
+
const interactionParticipantSchema = z
|
|
435
|
+
.object({
|
|
436
|
+
userId: z.string().uuid().optional(),
|
|
437
|
+
name: z.string().trim().max(200).optional(),
|
|
438
|
+
email: z.string().trim().max(320).optional(),
|
|
439
|
+
status: z.string().trim().max(50).optional(),
|
|
440
|
+
})
|
|
441
|
+
.superRefine((participant, ctx) => {
|
|
442
|
+
if (participant.userId) return
|
|
443
|
+
if (!participant.email) {
|
|
444
|
+
ctx.addIssue({
|
|
445
|
+
code: z.ZodIssueCode.custom,
|
|
446
|
+
path: ['email'],
|
|
447
|
+
message: INTERACTION_PARTICIPANT_IDENTITY_REQUIRED_MESSAGE_KEY,
|
|
448
|
+
})
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
if (!z.string().email().safeParse(participant.email).success) {
|
|
452
|
+
ctx.addIssue({
|
|
453
|
+
code: z.ZodIssueCode.custom,
|
|
454
|
+
path: ['email'],
|
|
455
|
+
message: INTERACTION_PARTICIPANT_EMAIL_INVALID_MESSAGE_KEY,
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
})
|
|
433
459
|
|
|
434
460
|
const interactionLinkedEntitySchema = z.object({
|
|
435
461
|
id: z.string().uuid(),
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"customers.activities.card.overdue": "{count} überfällig",
|
|
29
29
|
"customers.activities.card.title": "Aktivitäten",
|
|
30
30
|
"customers.activities.errors.dateRequired": "Datum ist erforderlich",
|
|
31
|
+
"customers.activities.errors.participantEmailInvalid": "Geben Sie eine gültige E-Mail-Adresse für einen Gastteilnehmer ein (z. B. gast@example.org)",
|
|
32
|
+
"customers.activities.errors.participantIdentityRequired": "Ein Teilnehmer benötigt entweder einen Benutzerdatensatz oder eine E-Mail-Adresse",
|
|
31
33
|
"customers.activities.errors.phoneInvalid": "Geben Sie eine gültige Telefonnummer mit Ländervorwahl ein (z. B. +49 30 1234567)",
|
|
32
34
|
"customers.activities.errors.phoneRequired": "Telefonnummer ist für Anrufaktivitäten erforderlich",
|
|
33
35
|
"customers.activities.errors.timeRequired": "Uhrzeit ist erforderlich",
|
|
@@ -222,6 +224,7 @@
|
|
|
222
224
|
"customers.calendar.day.thu": "DO",
|
|
223
225
|
"customers.calendar.day.tue": "DI",
|
|
224
226
|
"customers.calendar.day.wed": "MI",
|
|
227
|
+
"customers.calendar.editor.addGuest": "{email} als Gast einladen",
|
|
225
228
|
"customers.calendar.editor.addPeoplePlaceholder": "Mitarbeiter oder Kunden hinzufügen…",
|
|
226
229
|
"customers.calendar.editor.addRecipientPlaceholder": "Empfänger hinzufügen…",
|
|
227
230
|
"customers.calendar.editor.allDay": "Ganztägig",
|
|
@@ -247,6 +250,7 @@
|
|
|
247
250
|
"customers.calendar.editor.description": "Beschreibung",
|
|
248
251
|
"customers.calendar.editor.descriptionPlaceholder": "Details hinzufügen…",
|
|
249
252
|
"customers.calendar.editor.error": "Termin konnte nicht gespeichert werden",
|
|
253
|
+
"customers.calendar.editor.guestBadge": "Gast",
|
|
250
254
|
"customers.calendar.editor.location": "Ort",
|
|
251
255
|
"customers.calendar.editor.locationPlaceholder": "Ort oder Link hinzufügen…",
|
|
252
256
|
"customers.calendar.editor.multiDayHint": "Mehrtägiger Termin · {count} Tage",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"customers.activities.card.overdue": "{count} overdue",
|
|
29
29
|
"customers.activities.card.title": "Activities",
|
|
30
30
|
"customers.activities.errors.dateRequired": "Date is required",
|
|
31
|
+
"customers.activities.errors.participantEmailInvalid": "Enter a valid email address for a guest participant (e.g. guest@example.org)",
|
|
32
|
+
"customers.activities.errors.participantIdentityRequired": "A participant needs either a user record or an email address",
|
|
31
33
|
"customers.activities.errors.phoneInvalid": "Enter a valid phone number with country code (e.g. +1 212 555 1234)",
|
|
32
34
|
"customers.activities.errors.phoneRequired": "Phone number is required for Call activities",
|
|
33
35
|
"customers.activities.errors.timeRequired": "Time is required",
|
|
@@ -222,6 +224,7 @@
|
|
|
222
224
|
"customers.calendar.day.thu": "THU",
|
|
223
225
|
"customers.calendar.day.tue": "TUE",
|
|
224
226
|
"customers.calendar.day.wed": "WED",
|
|
227
|
+
"customers.calendar.editor.addGuest": "Invite {email} as a guest",
|
|
225
228
|
"customers.calendar.editor.addPeoplePlaceholder": "Add staff or customer…",
|
|
226
229
|
"customers.calendar.editor.addRecipientPlaceholder": "Add recipient…",
|
|
227
230
|
"customers.calendar.editor.allDay": "All day",
|
|
@@ -247,6 +250,7 @@
|
|
|
247
250
|
"customers.calendar.editor.description": "Description",
|
|
248
251
|
"customers.calendar.editor.descriptionPlaceholder": "Add details…",
|
|
249
252
|
"customers.calendar.editor.error": "Failed to save event",
|
|
253
|
+
"customers.calendar.editor.guestBadge": "Guest",
|
|
250
254
|
"customers.calendar.editor.location": "Location",
|
|
251
255
|
"customers.calendar.editor.locationPlaceholder": "Add a location or link…",
|
|
252
256
|
"customers.calendar.editor.multiDayHint": "Multi-day event · {count} days",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"customers.activities.card.overdue": "{count} vencidas",
|
|
29
29
|
"customers.activities.card.title": "Actividades",
|
|
30
30
|
"customers.activities.errors.dateRequired": "La fecha es obligatoria",
|
|
31
|
+
"customers.activities.errors.participantEmailInvalid": "Introduce una dirección de correo electrónico válida para un invitado (p. ej., invitado@example.org)",
|
|
32
|
+
"customers.activities.errors.participantIdentityRequired": "Un participante necesita un registro de usuario o una dirección de correo electrónico",
|
|
31
33
|
"customers.activities.errors.phoneInvalid": "Introduce un número de teléfono válido con prefijo internacional (p. ej., +34 912 345 678)",
|
|
32
34
|
"customers.activities.errors.phoneRequired": "El número de teléfono es obligatorio para actividades de llamada",
|
|
33
35
|
"customers.activities.errors.timeRequired": "La hora es obligatoria",
|
|
@@ -222,6 +224,7 @@
|
|
|
222
224
|
"customers.calendar.day.thu": "JUE",
|
|
223
225
|
"customers.calendar.day.tue": "MAR",
|
|
224
226
|
"customers.calendar.day.wed": "MIÉ",
|
|
227
|
+
"customers.calendar.editor.addGuest": "Invitar a {email} como invitado",
|
|
225
228
|
"customers.calendar.editor.addPeoplePlaceholder": "Añadir personal o cliente…",
|
|
226
229
|
"customers.calendar.editor.addRecipientPlaceholder": "Añadir destinatario…",
|
|
227
230
|
"customers.calendar.editor.allDay": "Todo el día",
|
|
@@ -247,6 +250,7 @@
|
|
|
247
250
|
"customers.calendar.editor.description": "Descripción",
|
|
248
251
|
"customers.calendar.editor.descriptionPlaceholder": "Añadir detalles…",
|
|
249
252
|
"customers.calendar.editor.error": "No se pudo guardar el evento",
|
|
253
|
+
"customers.calendar.editor.guestBadge": "Invitado",
|
|
250
254
|
"customers.calendar.editor.location": "Ubicación",
|
|
251
255
|
"customers.calendar.editor.locationPlaceholder": "Añadir una ubicación o enlace…",
|
|
252
256
|
"customers.calendar.editor.multiDayHint": "Evento de varios días · {count} días",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"customers.activities.card.overdue": "{count}건 기한 초과",
|
|
29
29
|
"customers.activities.card.title": "활동",
|
|
30
30
|
"customers.activities.errors.dateRequired": "날짜는 필수입니다",
|
|
31
|
+
"customers.activities.errors.participantEmailInvalid": "게스트 참가자의 유효한 이메일 주소를 입력하세요(예: guest@example.org)",
|
|
32
|
+
"customers.activities.errors.participantIdentityRequired": "참가자에게는 사용자 레코드 또는 이메일 주소가 필요합니다",
|
|
31
33
|
"customers.activities.errors.phoneInvalid": "국가 코드를 포함한 유효한 전화번호를 입력하세요(예: +1 212 555 1234)",
|
|
32
34
|
"customers.activities.errors.phoneRequired": "통화 활동에는 전화번호가 필요합니다",
|
|
33
35
|
"customers.activities.errors.timeRequired": "시간은 필수입니다",
|
|
@@ -222,6 +224,7 @@
|
|
|
222
224
|
"customers.calendar.day.thu": "목",
|
|
223
225
|
"customers.calendar.day.tue": "화",
|
|
224
226
|
"customers.calendar.day.wed": "수",
|
|
227
|
+
"customers.calendar.editor.addGuest": "{email}을(를) 게스트로 초대",
|
|
225
228
|
"customers.calendar.editor.addPeoplePlaceholder": "직원 또는 고객 추가…",
|
|
226
229
|
"customers.calendar.editor.addRecipientPlaceholder": "받는 사람 추가…",
|
|
227
230
|
"customers.calendar.editor.allDay": "종일",
|
|
@@ -247,6 +250,7 @@
|
|
|
247
250
|
"customers.calendar.editor.description": "설명",
|
|
248
251
|
"customers.calendar.editor.descriptionPlaceholder": "세부 정보 추가…",
|
|
249
252
|
"customers.calendar.editor.error": "이벤트를 저장하지 못했습니다",
|
|
253
|
+
"customers.calendar.editor.guestBadge": "게스트",
|
|
250
254
|
"customers.calendar.editor.location": "장소",
|
|
251
255
|
"customers.calendar.editor.locationPlaceholder": "장소 또는 링크 추가…",
|
|
252
256
|
"customers.calendar.editor.multiDayHint": "여러 날 이벤트 · {count}일",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"customers.activities.card.overdue": "{count} zaległych",
|
|
29
29
|
"customers.activities.card.title": "Aktywności",
|
|
30
30
|
"customers.activities.errors.dateRequired": "Data jest wymagana",
|
|
31
|
+
"customers.activities.errors.participantEmailInvalid": "Wprowadź prawidłowy adres e-mail uczestnika zewnętrznego (np. gosc@example.org)",
|
|
32
|
+
"customers.activities.errors.participantIdentityRequired": "Uczestnik wymaga rekordu użytkownika lub adresu e-mail",
|
|
31
33
|
"customers.activities.errors.phoneInvalid": "Wprowadź prawidłowy numer telefonu z numerem kierunkowym kraju (np. +48 123 456 789)",
|
|
32
34
|
"customers.activities.errors.phoneRequired": "Numer telefonu jest wymagany dla aktywności typu Połączenie",
|
|
33
35
|
"customers.activities.errors.timeRequired": "Godzina jest wymagana",
|
|
@@ -222,6 +224,7 @@
|
|
|
222
224
|
"customers.calendar.day.thu": "CZ",
|
|
223
225
|
"customers.calendar.day.tue": "WT",
|
|
224
226
|
"customers.calendar.day.wed": "ŚR",
|
|
227
|
+
"customers.calendar.editor.addGuest": "Zaproś {email} jako gościa",
|
|
225
228
|
"customers.calendar.editor.addPeoplePlaceholder": "Dodaj pracownika lub klienta…",
|
|
226
229
|
"customers.calendar.editor.addRecipientPlaceholder": "Dodaj odbiorcę…",
|
|
227
230
|
"customers.calendar.editor.allDay": "Cały dzień",
|
|
@@ -247,6 +250,7 @@
|
|
|
247
250
|
"customers.calendar.editor.description": "Opis",
|
|
248
251
|
"customers.calendar.editor.descriptionPlaceholder": "Dodaj szczegóły…",
|
|
249
252
|
"customers.calendar.editor.error": "Nie udało się zapisać wydarzenia",
|
|
253
|
+
"customers.calendar.editor.guestBadge": "Gość",
|
|
250
254
|
"customers.calendar.editor.location": "Lokalizacja",
|
|
251
255
|
"customers.calendar.editor.locationPlaceholder": "Dodaj lokalizację lub link…",
|
|
252
256
|
"customers.calendar.editor.multiDayHint": "Wydarzenie wielodniowe · {count} dni",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CalendarItem, CalendarItemStatus, CalendarParticipant } from '../../components/calendar/types'
|
|
2
|
+
import { participantActorKey } from './participantIdentity'
|
|
2
3
|
import type { ConflictScope } from './preferences'
|
|
3
4
|
|
|
4
5
|
export const EDITOR_DRAFT_CONFLICT_ID = '__draft__'
|
|
@@ -14,8 +15,15 @@ export type FindConflictsOptions = {
|
|
|
14
15
|
function sharesActor(first: CalendarItem, second: CalendarItem): boolean {
|
|
15
16
|
if (first.ownerUserId && second.ownerUserId && first.ownerUserId === second.ownerUserId) return true
|
|
16
17
|
if (first.participants.length === 0 || second.participants.length === 0) return false
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const firstActorKeys = new Set(
|
|
19
|
+
first.participants
|
|
20
|
+
.map(participantActorKey)
|
|
21
|
+
.filter((actorKey): actorKey is string => actorKey !== null),
|
|
22
|
+
)
|
|
23
|
+
return second.participants.some((participant) => {
|
|
24
|
+
const actorKey = participantActorKey(participant)
|
|
25
|
+
return actorKey !== null && firstActorKeys.has(actorKey)
|
|
26
|
+
})
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
function isActor(item: CalendarItem, userId: string): boolean {
|
|
@@ -112,7 +112,7 @@ export type EditorRelatedTo = {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export type EditorParticipant = {
|
|
115
|
-
userId
|
|
115
|
+
userId?: string
|
|
116
116
|
name: string
|
|
117
117
|
email?: string
|
|
118
118
|
isCustomer: boolean
|
|
@@ -405,13 +405,14 @@ function parseParticipants(item: CalendarItem): EditorParticipant[] {
|
|
|
405
405
|
const rawParticipants = Array.isArray(item.raw.participants) ? item.raw.participants : []
|
|
406
406
|
const statusByUserId = new Map<string, string | null>()
|
|
407
407
|
for (const raw of rawParticipants) {
|
|
408
|
+
if (!raw.userId) continue
|
|
408
409
|
statusByUserId.set(raw.userId, readUnknownString((raw as Record<string, unknown>).status))
|
|
409
410
|
}
|
|
410
411
|
return item.participants.map((participant) => ({
|
|
411
412
|
userId: participant.userId,
|
|
412
|
-
name: participant.name ?? participant.email ?? participant.userId,
|
|
413
|
+
name: participant.name ?? participant.email ?? participant.userId ?? '',
|
|
413
414
|
email: participant.email,
|
|
414
|
-
isCustomer: statusByUserId.get(participant.userId) === 'customer',
|
|
415
|
+
isCustomer: participant.userId ? statusByUserId.get(participant.userId) === 'customer' : false,
|
|
415
416
|
}))
|
|
416
417
|
}
|
|
417
418
|
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
CalendarPlatform,
|
|
11
11
|
} from '../../components/calendar/types'
|
|
12
12
|
import { categoryOf } from './categories'
|
|
13
|
+
import { participantActorKey } from './participantIdentity'
|
|
13
14
|
|
|
14
15
|
const DEFAULT_DURATION_MINUTES = 30
|
|
15
16
|
|
|
@@ -43,9 +44,13 @@ function mapParticipants(payload: CalendarInteractionPayload): CalendarParticipa
|
|
|
43
44
|
const seen = new Set<string>()
|
|
44
45
|
const mapped: CalendarParticipant[] = []
|
|
45
46
|
for (const participant of participants) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const dedupeKey = participantActorKey(participant)
|
|
48
|
+
if (dedupeKey) {
|
|
49
|
+
if (seen.has(dedupeKey)) continue
|
|
50
|
+
seen.add(dedupeKey)
|
|
51
|
+
}
|
|
52
|
+
const entry: CalendarParticipant = {}
|
|
53
|
+
if (typeof participant.userId === 'string') entry.userId = participant.userId
|
|
49
54
|
if (typeof participant.name === 'string') entry.name = participant.name
|
|
50
55
|
if (typeof participant.email === 'string') entry.email = participant.email
|
|
51
56
|
mapped.push(entry)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one canonical actor key for a calendar participant.
|
|
3
|
+
*
|
|
4
|
+
* A staff/customer attendee is identified by `userId`. An external guest has no
|
|
5
|
+
* record in the system, so the normalized email is the only stable identity it
|
|
6
|
+
* carries. Mapping, dedupe, conflict detection and the editor's conflict-probe
|
|
7
|
+
* dependency key all derive their key from here, so a guest is treated as the
|
|
8
|
+
* same actor across events instead of dropping out of actor comparisons.
|
|
9
|
+
*
|
|
10
|
+
* Returns `null` for a participant with neither — such a participant cannot be
|
|
11
|
+
* compared to any other and must never collide with one that can.
|
|
12
|
+
*/
|
|
13
|
+
export function participantActorKey(participant: { userId?: string; email?: string }): string | null {
|
|
14
|
+
if (participant.userId) return `user:${participant.userId}`
|
|
15
|
+
const email = participant.email?.trim().toLowerCase()
|
|
16
|
+
return email ? `email:${email}` : null
|
|
17
|
+
}
|