@open-mercato/core 0.10.1-develop.7707.1.61845a3ef7 → 0.10.1-develop.7708.1.5a5c248637

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.
@@ -10,6 +10,8 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
10
10
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
11
11
  import { CustomerInteraction } from "../../../data/entities.js";
12
12
  import { TERMINAL_INTERACTION_STATUS_LIST } from "../../../lib/interactionStatus.js";
13
+ import { applyEmailVisibilityFilter } from "../../../lib/visibilityFilter.js";
14
+ import { listGrantsForViewer, listSharedChannelIds } from "../../../lib/conversationShares.js";
13
15
  import { createLogger } from "@open-mercato/shared/lib/logger";
14
16
  const logger = createLogger("customers");
15
17
  const querySchema = z.object({
@@ -82,15 +84,27 @@ async function GET(req) {
82
84
  if (organizationIds?.length === 0) {
83
85
  return NextResponse.json({ ok: true, result: { hasConflicts: false, conflicts: [] } });
84
86
  }
85
- const checkUserId = query.userId ?? auth.userId;
87
+ const viewerUserId = auth.isApiKey ? null : auth.sub ?? null;
88
+ const checkUserId = query.userId ?? (auth.isApiKey ? auth.userId : viewerUserId);
86
89
  const em = container.resolve("em").fork();
87
90
  const kysely = em.getKysely();
91
+ const emailShareScope = { tenantId: auth.tenantId, organizationId: rbacOrganizationId };
92
+ const [sharedConversations, sharedChannelIds] = await Promise.all([
93
+ listGrantsForViewer(em, emailShareScope, viewerUserId),
94
+ listSharedChannelIds(em, emailShareScope, viewerUserId)
95
+ ]);
88
96
  let baseQuery = kysely.selectFrom("customer_interactions").select(["id", "scheduled_at", "duration_minutes", "interaction_type"]).where("tenant_id", "=", auth.tenantId).where("status", "not in", [...TERMINAL_INTERACTION_STATUS_LIST]).where("scheduled_at", "is not", null).where("deleted_at", "is", null);
89
97
  if (organizationIds?.length === 1) {
90
98
  baseQuery = baseQuery.where("organization_id", "=", organizationIds[0]);
91
99
  } else if (organizationIds && organizationIds.length > 1) {
92
100
  baseQuery = baseQuery.where("organization_id", "in", organizationIds);
93
101
  }
102
+ baseQuery = applyEmailVisibilityFilter(baseQuery, {
103
+ currentUserId: viewerUserId,
104
+ userFeatures: void 0,
105
+ sharedConversations,
106
+ sharedChannelIds
107
+ });
94
108
  if (checkUserId) {
95
109
  baseQuery = baseQuery.where(
96
110
  (eb) => eb.or([
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/customers/api/interactions/conflicts/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql } from 'kysely'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { CustomerInteraction } from '../../../data/entities'\nimport { TERMINAL_INTERACTION_STATUS_LIST } from '../../../lib/interactionStatus'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n date: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/),\n startTime: z.string().regex(/^\\d{2}:\\d{2}$/),\n duration: z.coerce.number().int().min(1).max(1440),\n excludeId: z.string().uuid().optional(),\n userId: z.string().uuid().optional(),\n timezoneOffsetMinutes: z.coerce.number().int().min(-900).max(900).optional(),\n})\n\nconst conflictItemSchema = z.object({\n id: z.string(),\n title: z.string().nullable(),\n startTime: z.string(),\n endTime: z.string(),\n type: z.string(),\n})\n\nconst responseSchema = z.object({\n ok: z.literal(true),\n result: z.object({\n hasConflicts: z.boolean(),\n conflicts: z.array(conflictItemSchema),\n }),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.interactions.view'] },\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n methods: {\n GET: {\n summary: 'Detect scheduling conflicts',\n description: 'Checks for overlapping planned interactions within the requested time window.',\n query: querySchema,\n responses: [\n {\n status: 200,\n description: 'Conflict detection result',\n schema: responseSchema,\n },\n ],\n },\n },\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const queryUrl = new URL(req.url)\n const query = querySchema.parse(Object.fromEntries(queryUrl.searchParams))\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, {\n error: translate('customers.errors.unauthorized', 'Unauthorized'),\n })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const { organizationIds, rbacOrganizationId } = resolveOrganizationScopeFilter(scope, auth)\n\n const offsetMinutes = query.timezoneOffsetMinutes ?? 0\n const offsetSign = offsetMinutes >= 0 ? '+' : '-'\n const absMinutes = Math.abs(offsetMinutes)\n const offsetHours = String(Math.floor(absMinutes / 60)).padStart(2, '0')\n const offsetMins = String(absMinutes % 60).padStart(2, '0')\n const offsetSuffix = `${offsetSign}${offsetHours}:${offsetMins}`\n const windowStart = new Date(`${query.date}T${query.startTime}:00${offsetSuffix}`)\n const windowEnd = new Date(windowStart.getTime() + query.duration * 60_000)\n\n if (Number.isNaN(windowStart.getTime()) || Number.isNaN(windowEnd.getTime())) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_date_time', 'Invalid date/time') })\n }\n if (organizationIds?.length === 0) {\n return NextResponse.json({ ok: true, result: { hasConflicts: false, conflicts: [] } })\n }\n\n const checkUserId = query.userId ?? auth.userId\n const em = (container.resolve('em') as EntityManager).fork()\n const kysely = em.getKysely<any>()\n\n let baseQuery = (kysely as any)\n .selectFrom('customer_interactions')\n .select(['id', 'scheduled_at', 'duration_minutes', 'interaction_type'])\n .where('tenant_id', '=', auth.tenantId)\n .where('status', 'not in', [...TERMINAL_INTERACTION_STATUS_LIST])\n .where('scheduled_at', 'is not', null)\n .where('deleted_at', 'is', null)\n\n if (organizationIds?.length === 1) {\n baseQuery = baseQuery.where('organization_id', '=', organizationIds[0])\n } else if (organizationIds && organizationIds.length > 1) {\n baseQuery = baseQuery.where('organization_id', 'in', organizationIds)\n }\n\n if (checkUserId) {\n baseQuery = baseQuery.where((eb: any) =>\n eb.or([\n eb('author_user_id', '=', checkUserId),\n eb('owner_user_id', '=', checkUserId),\n ])\n )\n }\n\n if (query.excludeId) {\n baseQuery = baseQuery.where('id', '!=', query.excludeId)\n }\n\n // Overlap condition: existing.start < windowEnd AND existing.end > windowStart\n // end = scheduled_at + duration_minutes (default 30 if null)\n baseQuery = baseQuery.where((eb: any) =>\n eb.and([\n eb('scheduled_at', '<', windowEnd.toISOString()),\n eb(sql`(scheduled_at + make_interval(mins => COALESCE(duration_minutes, 30)))`, '>', windowStart.toISOString()),\n ])\n )\n\n // Raw SELECT: reads only unencrypted columns (id, scheduled_at, duration_minutes, interaction_type); title is excluded to avoid ciphertext leakage and is resolved below via findWithDecryption.\n const rows = await baseQuery\n .orderBy('scheduled_at', 'asc')\n .limit(10)\n .execute() as Array<{\n id: string\n scheduled_at: string | Date\n duration_minutes: number | null\n interaction_type: string\n }>\n\n const decryptionScope = {\n tenantId: auth.tenantId ?? null,\n organizationId: rbacOrganizationId,\n }\n const conflictIds = rows.map((row) => row.id)\n const interactionFilter: Record<string, unknown> = {\n id: { $in: conflictIds },\n tenantId: auth.tenantId,\n deletedAt: null,\n }\n if (organizationIds?.length === 1) {\n interactionFilter.organizationId = organizationIds[0]\n } else if (organizationIds && organizationIds.length > 1) {\n interactionFilter.organizationId = { $in: organizationIds }\n }\n const decryptedInteractions = conflictIds.length > 0\n ? await findWithDecryption(\n em,\n CustomerInteraction,\n interactionFilter as any,\n undefined,\n decryptionScope,\n )\n : []\n const titleById = new Map<string, string | null>()\n for (const record of decryptedInteractions) {\n titleById.set((record as any).id, ((record as any).title ?? null) as string | null)\n }\n\n const conflicts = rows.map((row) => {\n const start = new Date(row.scheduled_at)\n const durationMin = row.duration_minutes ?? 30\n const end = new Date(start.getTime() + durationMin * 60_000)\n return {\n id: row.id,\n title: titleById.has(row.id) ? titleById.get(row.id) ?? null : null,\n startTime: start.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }),\n endTime: end.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }),\n type: row.interaction_type,\n }\n })\n\n return NextResponse.json({\n ok: true,\n result: { hasConflicts: conflicts.length > 0, conflicts },\n })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('GET failed', { component: 'interactions/conflicts', err })\n return NextResponse.json({ error: translate('customers.errors.internal', 'Internal server error') }, { status: 500 })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql } from 'kysely'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { CustomerInteraction } from '../../../data/entities'\nimport { TERMINAL_INTERACTION_STATUS_LIST } from '../../../lib/interactionStatus'\nimport { applyEmailVisibilityFilter } from '../../../lib/visibilityFilter'\nimport { listGrantsForViewer, listSharedChannelIds } from '../../../lib/conversationShares'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n date: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/),\n startTime: z.string().regex(/^\\d{2}:\\d{2}$/),\n duration: z.coerce.number().int().min(1).max(1440),\n excludeId: z.string().uuid().optional(),\n userId: z.string().uuid().optional(),\n timezoneOffsetMinutes: z.coerce.number().int().min(-900).max(900).optional(),\n})\n\nconst conflictItemSchema = z.object({\n id: z.string(),\n title: z.string().nullable(),\n startTime: z.string(),\n endTime: z.string(),\n type: z.string(),\n})\n\nconst responseSchema = z.object({\n ok: z.literal(true),\n result: z.object({\n hasConflicts: z.boolean(),\n conflicts: z.array(conflictItemSchema),\n }),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.interactions.view'] },\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n methods: {\n GET: {\n summary: 'Detect scheduling conflicts',\n description: 'Checks for overlapping planned interactions within the requested time window.',\n query: querySchema,\n responses: [\n {\n status: 200,\n description: 'Conflict detection result',\n schema: responseSchema,\n },\n ],\n },\n },\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const queryUrl = new URL(req.url)\n const query = querySchema.parse(Object.fromEntries(queryUrl.searchParams))\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, {\n error: translate('customers.errors.unauthorized', 'Unauthorized'),\n })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const { organizationIds, rbacOrganizationId } = resolveOrganizationScopeFilter(scope, auth)\n\n const offsetMinutes = query.timezoneOffsetMinutes ?? 0\n const offsetSign = offsetMinutes >= 0 ? '+' : '-'\n const absMinutes = Math.abs(offsetMinutes)\n const offsetHours = String(Math.floor(absMinutes / 60)).padStart(2, '0')\n const offsetMins = String(absMinutes % 60).padStart(2, '0')\n const offsetSuffix = `${offsetSign}${offsetHours}:${offsetMins}`\n const windowStart = new Date(`${query.date}T${query.startTime}:00${offsetSuffix}`)\n const windowEnd = new Date(windowStart.getTime() + query.duration * 60_000)\n\n if (Number.isNaN(windowStart.getTime()) || Number.isNaN(windowEnd.getTime())) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_date_time', 'Invalid date/time') })\n }\n if (organizationIds?.length === 0) {\n return NextResponse.json({ ok: true, result: { hasConflicts: false, conflicts: [] } })\n }\n\n const viewerUserId = auth.isApiKey ? null : (auth.sub ?? null)\n const checkUserId = query.userId ?? (auth.isApiKey ? auth.userId : viewerUserId)\n const em = (container.resolve('em') as EntityManager).fork()\n const kysely = em.getKysely<any>()\n const emailShareScope = { tenantId: auth.tenantId, organizationId: rbacOrganizationId }\n const [sharedConversations, sharedChannelIds] = await Promise.all([\n listGrantsForViewer(em, emailShareScope, viewerUserId),\n listSharedChannelIds(em, emailShareScope, viewerUserId),\n ])\n\n let baseQuery = (kysely as any)\n .selectFrom('customer_interactions')\n .select(['id', 'scheduled_at', 'duration_minutes', 'interaction_type'])\n .where('tenant_id', '=', auth.tenantId)\n .where('status', 'not in', [...TERMINAL_INTERACTION_STATUS_LIST])\n .where('scheduled_at', 'is not', null)\n .where('deleted_at', 'is', null)\n\n if (organizationIds?.length === 1) {\n baseQuery = baseQuery.where('organization_id', '=', organizationIds[0])\n } else if (organizationIds && organizationIds.length > 1) {\n baseQuery = baseQuery.where('organization_id', 'in', organizationIds)\n }\n\n baseQuery = applyEmailVisibilityFilter(baseQuery, {\n currentUserId: viewerUserId,\n userFeatures: undefined,\n sharedConversations,\n sharedChannelIds,\n })\n\n if (checkUserId) {\n baseQuery = baseQuery.where((eb: any) =>\n eb.or([\n eb('author_user_id', '=', checkUserId),\n eb('owner_user_id', '=', checkUserId),\n ])\n )\n }\n\n if (query.excludeId) {\n baseQuery = baseQuery.where('id', '!=', query.excludeId)\n }\n\n // Overlap condition: existing.start < windowEnd AND existing.end > windowStart\n // end = scheduled_at + duration_minutes (default 30 if null)\n baseQuery = baseQuery.where((eb: any) =>\n eb.and([\n eb('scheduled_at', '<', windowEnd.toISOString()),\n eb(sql`(scheduled_at + make_interval(mins => COALESCE(duration_minutes, 30)))`, '>', windowStart.toISOString()),\n ])\n )\n\n // Raw SELECT: reads only unencrypted columns (id, scheduled_at, duration_minutes, interaction_type); title is excluded to avoid ciphertext leakage and is resolved below via findWithDecryption.\n const rows = await baseQuery\n .orderBy('scheduled_at', 'asc')\n .limit(10)\n .execute() as Array<{\n id: string\n scheduled_at: string | Date\n duration_minutes: number | null\n interaction_type: string\n }>\n\n const decryptionScope = {\n tenantId: auth.tenantId ?? null,\n organizationId: rbacOrganizationId,\n }\n const conflictIds = rows.map((row) => row.id)\n const interactionFilter: Record<string, unknown> = {\n id: { $in: conflictIds },\n tenantId: auth.tenantId,\n deletedAt: null,\n }\n if (organizationIds?.length === 1) {\n interactionFilter.organizationId = organizationIds[0]\n } else if (organizationIds && organizationIds.length > 1) {\n interactionFilter.organizationId = { $in: organizationIds }\n }\n const decryptedInteractions = conflictIds.length > 0\n ? await findWithDecryption(\n em,\n CustomerInteraction,\n interactionFilter as any,\n undefined,\n decryptionScope,\n )\n : []\n const titleById = new Map<string, string | null>()\n for (const record of decryptedInteractions) {\n titleById.set((record as any).id, ((record as any).title ?? null) as string | null)\n }\n\n const conflicts = rows.map((row) => {\n const start = new Date(row.scheduled_at)\n const durationMin = row.duration_minutes ?? 30\n const end = new Date(start.getTime() + durationMin * 60_000)\n return {\n id: row.id,\n title: titleById.has(row.id) ? titleById.get(row.id) ?? null : null,\n startTime: start.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }),\n endTime: end.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }),\n type: row.interaction_type,\n }\n })\n\n return NextResponse.json({\n ok: true,\n result: { hasConflicts: conflicts.length > 0, conflicts },\n })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('GET failed', { component: 'interactions/conflicts', err })\n return NextResponse.json({ error: translate('customers.errors.internal', 'Internal server error') }, { status: 500 })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7707.1.61845a3ef7",
3
+ "version": "0.10.1-develop.7708.1.5a5c248637",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -265,16 +265,16 @@
265
265
  "zod": "^4.4.3"
266
266
  },
267
267
  "peerDependencies": {
268
- "@open-mercato/ai-assistant": "0.10.1-develop.7707.1.61845a3ef7",
269
- "@open-mercato/shared": "0.10.1-develop.7707.1.61845a3ef7",
270
- "@open-mercato/ui": "0.10.1-develop.7707.1.61845a3ef7",
268
+ "@open-mercato/ai-assistant": "0.10.1-develop.7708.1.5a5c248637",
269
+ "@open-mercato/shared": "0.10.1-develop.7708.1.5a5c248637",
270
+ "@open-mercato/ui": "0.10.1-develop.7708.1.5a5c248637",
271
271
  "react": "^19.0.0",
272
272
  "react-dom": "^19.0.0"
273
273
  },
274
274
  "devDependencies": {
275
- "@open-mercato/ai-assistant": "0.10.1-develop.7707.1.61845a3ef7",
276
- "@open-mercato/shared": "0.10.1-develop.7707.1.61845a3ef7",
277
- "@open-mercato/ui": "0.10.1-develop.7707.1.61845a3ef7",
275
+ "@open-mercato/ai-assistant": "0.10.1-develop.7708.1.5a5c248637",
276
+ "@open-mercato/shared": "0.10.1-develop.7708.1.5a5c248637",
277
+ "@open-mercato/ui": "0.10.1-develop.7708.1.5a5c248637",
278
278
  "@testing-library/dom": "^10.4.1",
279
279
  "@testing-library/jest-dom": "^7.0.1",
280
280
  "@testing-library/react": "^16.3.3",
@@ -12,6 +12,8 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
12
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
13
13
  import { CustomerInteraction } from '../../../data/entities'
14
14
  import { TERMINAL_INTERACTION_STATUS_LIST } from '../../../lib/interactionStatus'
15
+ import { applyEmailVisibilityFilter } from '../../../lib/visibilityFilter'
16
+ import { listGrantsForViewer, listSharedChannelIds } from '../../../lib/conversationShares'
15
17
  import { createLogger } from '@open-mercato/shared/lib/logger'
16
18
 
17
19
  const logger = createLogger('customers')
@@ -96,9 +98,15 @@ export async function GET(req: Request) {
96
98
  return NextResponse.json({ ok: true, result: { hasConflicts: false, conflicts: [] } })
97
99
  }
98
100
 
99
- const checkUserId = query.userId ?? auth.userId
101
+ const viewerUserId = auth.isApiKey ? null : (auth.sub ?? null)
102
+ const checkUserId = query.userId ?? (auth.isApiKey ? auth.userId : viewerUserId)
100
103
  const em = (container.resolve('em') as EntityManager).fork()
101
104
  const kysely = em.getKysely<any>()
105
+ const emailShareScope = { tenantId: auth.tenantId, organizationId: rbacOrganizationId }
106
+ const [sharedConversations, sharedChannelIds] = await Promise.all([
107
+ listGrantsForViewer(em, emailShareScope, viewerUserId),
108
+ listSharedChannelIds(em, emailShareScope, viewerUserId),
109
+ ])
102
110
 
103
111
  let baseQuery = (kysely as any)
104
112
  .selectFrom('customer_interactions')
@@ -114,6 +122,13 @@ export async function GET(req: Request) {
114
122
  baseQuery = baseQuery.where('organization_id', 'in', organizationIds)
115
123
  }
116
124
 
125
+ baseQuery = applyEmailVisibilityFilter(baseQuery, {
126
+ currentUserId: viewerUserId,
127
+ userFeatures: undefined,
128
+ sharedConversations,
129
+ sharedChannelIds,
130
+ })
131
+
117
132
  if (checkUserId) {
118
133
  baseQuery = baseQuery.where((eb: any) =>
119
134
  eb.or([