@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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/api/interactions/route.js +3 -1
  3. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  4. package/dist/modules/customers/commands/interactions.js.map +1 -1
  5. package/dist/modules/customers/components/calendar/AgendaList.js +2 -2
  6. package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
  7. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +1 -1
  8. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
  9. package/dist/modules/customers/components/calendar/CalendarScreen.js +1 -1
  10. package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
  11. package/dist/modules/customers/components/calendar/EventBlock.js +1 -1
  12. package/dist/modules/customers/components/calendar/EventBlock.js.map +2 -2
  13. package/dist/modules/customers/components/calendar/editor/PeopleField.js +40 -5
  14. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  15. package/dist/modules/customers/components/calendar/editor/hooks.js +2 -1
  16. package/dist/modules/customers/components/calendar/editor/hooks.js.map +2 -2
  17. package/dist/modules/customers/components/calendar/types.js +1 -1
  18. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  19. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +4 -4
  20. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  21. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +3 -3
  22. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  23. package/dist/modules/customers/data/entities.js.map +1 -1
  24. package/dist/modules/customers/data/validators.js +22 -1
  25. package/dist/modules/customers/data/validators.js.map +2 -2
  26. package/dist/modules/customers/lib/calendar/conflicts.js +8 -2
  27. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  28. package/dist/modules/customers/lib/calendar/editorPayload.js +3 -2
  29. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  30. package/dist/modules/customers/lib/calendar/mapItem.js +8 -3
  31. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  32. package/dist/modules/customers/lib/calendar/participantIdentity.js +9 -0
  33. package/dist/modules/customers/lib/calendar/participantIdentity.js.map +7 -0
  34. package/package.json +7 -7
  35. package/src/modules/customers/api/interactions/route.ts +8 -2
  36. package/src/modules/customers/commands/interactions.ts +1 -1
  37. package/src/modules/customers/components/calendar/AgendaList.tsx +3 -3
  38. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +1 -1
  39. package/src/modules/customers/components/calendar/CalendarScreen.tsx +1 -1
  40. package/src/modules/customers/components/calendar/EventBlock.tsx +2 -2
  41. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +61 -5
  42. package/src/modules/customers/components/calendar/editor/hooks.ts +7 -1
  43. package/src/modules/customers/components/calendar/types.ts +2 -2
  44. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +5 -5
  45. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +5 -5
  46. package/src/modules/customers/components/detail/types.ts +1 -1
  47. package/src/modules/customers/data/entities.ts +1 -1
  48. package/src/modules/customers/data/validators.ts +32 -6
  49. package/src/modules/customers/i18n/de.json +4 -0
  50. package/src/modules/customers/i18n/en.json +4 -0
  51. package/src/modules/customers/i18n/es.json +4 -0
  52. package/src/modules/customers/i18n/ko.json +4 -0
  53. package/src/modules/customers/i18n/pl.json +4 -0
  54. package/src/modules/customers/lib/calendar/conflicts.ts +10 -2
  55. package/src/modules/customers/lib/calendar/editorPayload.ts +4 -3
  56. package/src/modules/customers/lib/calendar/mapItem.ts +8 -3
  57. 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: string; name?: string; email?: string; status?: string }> | null
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((entry) => entry.userId !== participant.userId))}
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
- const participantsKey = form.participants.map((participant) => participant.userId).join(',')
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: string; name?: string; email?: string }
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: (userId: string) => void
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(p.userId)} className="h-auto text-muted-foreground hover:text-foreground p-0" aria-label={t('customers.schedule.removeParticipant', 'Remove participant')}>
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: string
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: string; name?: string; email?: string; status?: string }> | null
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((userId: string) => {
223
- setParticipants((prev) => prev.filter((p) => p.userId !== userId))
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: string; name?: string; email?: string; status?: string }> | null
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: string; name?: string; email?: string; status?: string }> | null
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
- const interactionParticipantSchema = z.object({
428
- userId: z.string().uuid(),
429
- name: z.string().trim().max(200).optional(),
430
- email: z.string().trim().max(320).optional(),
431
- status: z.string().trim().max(50).optional(),
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 firstParticipantIds = new Set(first.participants.map((participant) => participant.userId))
18
- return second.participants.some((participant) => firstParticipantIds.has(participant.userId))
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: string
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
- if (seen.has(participant.userId)) continue
47
- seen.add(participant.userId)
48
- const entry: CalendarParticipant = { userId: participant.userId }
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
+ }