import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Prisma, type MontifyConnection, type MontifyOrder as MontifyOrderRow, type MontifyTransaction as MontifyTransactionRow, } from '@prisma/client'; import type { MontifyConnectionDto, MontifyEngagementDto, MontifyOrderDto, MontifyOrderItemDto, MontifyPersonStoreDto, MontifySyncResultDto, MontifyTransactionDto, } from '@mento/shared'; import { parseBirthDate } from '../../common/excel'; import { PrismaService } from '../../prisma/prisma.service'; import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; import { carryPreviousSource } from '../../contacts/person-source'; import { ownerWhere, type Actor } from '../../common/record-scope'; import { MontifyConnectionsService } from './connections.service'; import { forEachPage, MontifyClient, MontifyHttpError, type MontifyAddress, type MontifyCustomer, type MontifyEngagement, type MontifyOrder, type MontifyTransaction, } from './montify-client'; const CUSTOMER_PAGE_SIZE = 50; const ENGAGEMENT_KEEP = 50; /** مسیری که پشت‌سرهم 404 بدهد (پیاده نشده) تا پایان همان دور رد می‌شود */ const SKIP_AFTER_NOT_FOUND = 3; type EndpointStats = Map; /** پروفایل فروشگاهی مشتری (MontifyLink.data) — نام‌ها طبق جدول نگاشت مستند */ type CustomerProfile = { fullName: string | null; username: string | null; avatar: string | null; birthday: string | null; gender: string | null; type: string | null; job: string | null; storeId: string | null; walletBalance: number | null; goldWalletBalance: number | null; summary: { totalOrders: number | null; totalSpent: number | null }; addresses: MontifyAddress[]; engagement: { saved: unknown[]; likes: unknown[]; comments: unknown[]; } | null; }; function num(v: unknown): number | null { if (v === null || v === undefined || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } function dec(v: unknown): Prisma.Decimal | null { const n = num(v); return n === null ? null : new Prisma.Decimal(n); } function decNum(v: Prisma.Decimal | null): number | null { return v === null ? null : Number(v); } function str(v: unknown): string | null { if (v === null || v === undefined) return null; const s = String(v).trim(); return s ? s : null; } /** متن نمایشی فروشگاه (مثل sts_txt) اگر باشد، وگرنه مقدار خام */ function label(obj: unknown, key: string): string | null { if (!obj || typeof obj !== 'object') return null; const o = obj as Record; return str(o[`${key}_txt`]) ?? str(o[key]); } function parseDate(v: unknown): Date | null { const s = str(v); if (!s) return null; const d = new Date(s); return Number.isNaN(d.getTime()) ? null : d; } function arr(v: unknown): unknown[] { return Array.isArray(v) ? v : []; } function mobileVariants(mobile: string): string[] { const digits = mobile.replace(/\D/g, ''); let core = digits; if (core.startsWith('0098')) core = core.slice(4); else if (core.startsWith('98') && core.length === 12) core = core.slice(2); else if (core.startsWith('0')) core = core.slice(1); if (!core) return [mobile]; return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])]; } function formatAddress(a: MontifyAddress): string | null { const parts = [str(a.province), str(a.city), str(a.address)].filter(Boolean); if (!parts.length) return null; let line = parts.join('، '); const postal = str(a.postal_code); if (postal) line += ` — کدپستی ${postal}`; const receiver = [str(a.receiver_name), str(a.receiver_phone)].filter(Boolean).join(' '); if (receiver) line += ` — گیرنده: ${receiver}`; return line; } function splitName(c: MontifyCustomer): { firstName: string; lastName: string; } { let firstName = str(c.first_name) ?? ''; let lastName = str(c.last_name) ?? ''; if (!firstName && !lastName) { const parts = (str(c.name) ?? '').split(/\s+/).filter(Boolean); if (parts.length > 1) { firstName = parts[0]!; lastName = parts.slice(1).join(' '); } else { lastName = parts[0] ?? ''; } } if (!firstName && !lastName) lastName = str(c.username) ?? `مشتری ${c.id}`; return { firstName, lastName }; } function customerLinkKey(connectionId: string, externalId: string) { return { connectionId_entityType_externalId: { connectionId, entityType: 'customer', externalId, }, }; } @Injectable() export class MontifySyncService { private readonly logger = new Logger(MontifySyncService.name); private readonly running = new Set(); constructor( private readonly prisma: PrismaService, private readonly connections: MontifyConnectionsService, ) {} /* ——— فاز ۱ و ۲: Polling و Incremental Sync ——— */ async startSync( organizationId: string, connectionId: string, mode: 'incremental' | 'full', ): Promise { const row = await this.connections.getOrThrow(organizationId, connectionId); if (!row.enabled) throw new BadRequestException('این اتصال غیرفعال است'); if (!this.running.has(connectionId)) { await new Promise((started) => { void this.runSync(row, mode, started); }); } return this.connections.getDto(organizationId, connectionId); } @Cron('*/10 * * * *') async cronSyncAll(): Promise { const rows = await this.prisma.montifyConnection.findMany({ where: { enabled: true }, take: 50, }); for (const row of rows) { if (this.running.has(row.id)) continue; await this.runSync(row, 'incremental'); } } private async runSync( row: MontifyConnection, mode: 'incremental' | 'full', onStarted?: () => void, ): Promise { if (this.running.has(row.id)) { onStarted?.(); return; } this.running.add(row.id); const startedAt = new Date(); const result: MontifySyncResultDto = { mode, customersFetched: 0, customersCreated: 0, customersUpdated: 0, ordersFetched: 0, transactionsFetched: 0, errors: [], startedAt: startedAt.toISOString(), finishedAt: null, }; const saveProgress = () => this.prisma.montifyConnection.update({ where: { id: row.id }, data: { lastSyncResult: result as unknown as Prisma.InputJsonValue }, }); try { await this.prisma.montifyConnection.update({ where: { id: row.id }, data: { syncStatus: 'running', syncStartedAt: startedAt, lastSyncResult: result as unknown as Prisma.InputJsonValue, }, }); } catch (e) { this.running.delete(row.id); this.logger.warn(`Montify sync ${row.id} start: ${e instanceof Error ? e.message : e}`); return; } finally { onStarted?.(); } const endpoints: EndpointStats = new Map(); let cursor = row.customersCursor; let fatal: string | null = null; try { const client = this.connections.clientFor(row); const updatedAfter = mode === 'incremental' && row.customersCursor ? row.customersCursor.toISOString() : undefined; this.logger.log( `Montify sync ${row.id} (${row.label}) start ${mode}${updatedAfter ? ` after ${updatedAfter}` : ''}`, ); await forEachPage( (page) => client.listCustomers({ page, limit: CUSTOMER_PAGE_SIZE, updatedAfter, }), async (res, page) => { this.logger.log( `Montify sync ${row.id} page ${page}/${res.meta.last_page || '?'}: ${res.data.length} customers (total ${res.meta.total || '?'})`, ); for (const customer of res.data) { result.customersFetched += 1; try { const outcome = await this.syncCustomerDeep( row, client, customer, mode, result, endpoints, ); if (outcome === 'created') result.customersCreated += 1; else result.customersUpdated += 1; const updatedAt = parseDate(customer.updated_at); if (updatedAt && (!cursor || updatedAt > cursor)) cursor = updatedAt; } catch (e) { if (e instanceof MontifyHttpError && e.status === 401) throw e; const msg = e instanceof Error ? e.message : String(e); result.errors.push(`مشتری ${customer.id}: ${msg}`); this.logger.warn(`Montify ${row.id} customer ${customer.id}: ${msg}`); } if (result.customersFetched % 5 === 0) await saveProgress(); } await saveProgress(); }, ); } catch (e) { fatal = e instanceof Error ? e.message : String(e); result.errors.push(fatal); this.logger.warn(`Montify sync ${row.id}: ${fatal}`); } finally { this.running.delete(row.id); } const missing = [...endpoints.entries()] .filter(([, s]) => s.notFound > 0) .map( ([label, s]) => `مسیر ${label}: ${s.notFound} بار 404 داد` + (s.ok ? '' : ' (احتمالاً در این فروشگاه پیاده نشده)'), ); result.finishedAt = new Date().toISOString(); result.errors = [...missing, ...result.errors].slice(0, 50); this.logger.log( `Montify sync ${row.id} done: ${result.customersFetched} customers, ${result.ordersFetched} orders, ` + `${result.transactionsFetched} transactions, ${result.errors.length} errors${fatal ? ` — fatal: ${fatal}` : ''}`, ); await this.prisma.montifyConnection.update({ where: { id: row.id }, data: { syncStatus: fatal ? 'error' : 'ok', lastSyncAt: fatal ? undefined : new Date(), customersCursor: cursor, lastSyncResult: result as unknown as Prisma.InputJsonValue, lastHealthAt: new Date(), lastHealthOk: !fatal, lastError: fatal ? fatal.slice(0, 500) : result.errors.length ? result.errors.slice(0, 3).join(' | ').slice(0, 500) : null, }, }); } /** * گام ۲ استراتژی مستند: برای هر مشتری، آدرس‌ها، سفارش‌ها، تراکنش‌ها و داده‌های تعاملی * جداگانه دریافت و Upsert می‌شوند. */ private async syncCustomerDeep( row: MontifyConnection, client: MontifyClient, customer: MontifyCustomer, mode: 'incremental' | 'full', result: MontifySyncResultDto | null, endpoints: EndpointStats = new Map(), ): Promise<'created' | 'updated'> { const externalId = String(customer.id); const step = async (label: string, fn: () => Promise): Promise => { const stats = endpoints.get(label) ?? { ok: 0, notFound: 0 }; endpoints.set(label, stats); if (stats.ok === 0 && stats.notFound >= SKIP_AFTER_NOT_FOUND) return null; try { const value = await fn(); stats.ok += 1; return value; } catch (e) { if (e instanceof MontifyHttpError && e.status === 401) throw e; if (e instanceof MontifyHttpError && e.status === 404) { stats.notFound += 1; return null; } const msg = e instanceof Error ? e.message : String(e); result?.errors.push(`مشتری ${externalId} (${label}): ${msg}`); return null; } }; const addresses = (await step('آدرس‌ها', () => client.listCustomerAddresses(externalId))) ?? customer.addresses ?? []; const engagement = (await step('تعاملات', () => client.getCustomerEngagement(externalId))) ?? customer.engagement ?? null; const { personId, outcome, ordersSyncedAt } = await this.upsertCustomer( row, customer, addresses, engagement, ); const ordersStartedAt = new Date(); const ordersOk = await step('سفارش‌ها', async () => { const since = mode === 'full' ? null : ordersSyncedAt; await forEachPage( (page) => client.listCustomerOrders(externalId, { page, updatedAfter: since ? new Date(since.getTime() - 60_000).toISOString() : undefined, }), async (res) => { if (result) result.ordersFetched += res.data.length; for (const order of res.data) await this.upsertOrder(row, order, personId, externalId); }, 200, ); return true; }); if (ordersOk) { await this.prisma.montifyLink.update({ where: customerLinkKey(row.id, externalId), data: { ordersSyncedAt: ordersStartedAt }, }); } await step('تراکنش‌ها', async () => { const list = await client.listCustomerTransactions(externalId); if (result) result.transactionsFetched += list.length; for (const t of list) await this.upsertTransaction(row, t, personId, externalId); }); return outcome; } /* ——— مشتری → شخص ——— */ private async upsertCustomer( row: MontifyConnection, c: MontifyCustomer, addresses: MontifyAddress[], engagement: MontifyEngagement | null, ): Promise<{ personId: string; outcome: 'created' | 'updated'; ordersSyncedAt: Date | null; }> { const externalId = String(c.id); const { firstName, lastName } = splitName(c); const mobile = normalizePhone(c.phone ?? null); const email = normalizeEmail(c.email ?? null); const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a)); const profile: CustomerProfile = { fullName: str(c.name) ?? (`${firstName} ${lastName}`.trim() || null), username: str(c.username), avatar: str(c.avatar), birthday: str(c.birthday), gender: label(c, 'gender'), type: label(c, 'type'), job: str(c.job), storeId: str(c.store_id), walletBalance: num(c.cash_balance), goldWalletBalance: num(c.gold_balance), summary: { totalOrders: num(c.summary?.total_orders), totalSpent: num(c.summary?.total_spent), }, addresses, engagement: engagement ? { saved: arr(engagement.saved).slice(0, ENGAGEMENT_KEEP), likes: arr(engagement.likes).slice(0, ENGAGEMENT_KEEP), comments: arr(engagement.comments).slice(0, ENGAGEMENT_KEEP), } : null, }; const existingLink = await this.prisma.montifyLink.findUnique({ where: customerLinkKey(row.id, externalId), }); let person = existingLink ? await this.prisma.person.findFirst({ where: { id: existingLink.entityId, organizationId: row.organizationId, }, }) : null; if (!person && (mobile || email)) { person = await this.prisma.person.findFirst({ where: { organizationId: row.organizationId, OR: [ ...(mobile ? mobileVariants(mobile).flatMap((m) => [{ mobile: m }, { phone: m }]) : []), ...(email ? [{ email }] : []), ], }, orderBy: { createdAt: 'asc' }, }); } let personId: string; let outcome: 'created' | 'updated'; if (person) { const ownedByStore = person.importSource === 'montify'; const current = Array.isArray(person.addresses) ? (person.addresses as unknown[]).map((a) => String(a)) : []; const merged = [...current]; for (const a of addressLines) if (!merged.includes(a)) merged.push(a); const tags = person.tags.includes(row.label) ? person.tags : [...person.tags, row.label]; await this.prisma.person.update({ where: { id: person.id }, data: { ...(ownedByStore ? { firstName, lastName } : {}), ...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}), ...(email && (ownedByStore || !person.email) ? { email } : {}), ...(str(c.job) && !person.position ? { position: str(c.job) } : {}), ...(!person.birthDate && parseBirthDate(profile.birthday) ? { birthDate: parseBirthDate(profile.birthday) } : {}), addresses: merged as Prisma.InputJsonValue, tags, ...(!existingLink && !ownedByStore && !person.previousImportSource ? carryPreviousSource(person, 'montify') : {}), }, }); personId = person.id; outcome = 'updated'; } else { const notes = [str(c.bio), str(c.description)].filter(Boolean).join('\n') || null; const created = await this.prisma.person.create({ data: { organizationId: row.organizationId, firstName, lastName, mobile, email, position: str(c.job), notes, addresses: addressLines as Prisma.InputJsonValue, tags: [row.label], importSource: 'montify', sourceRef: externalId, birthDate: parseBirthDate(profile.birthday), }, }); personId = created.id; outcome = 'created'; } const link = await this.prisma.montifyLink.upsert({ where: customerLinkKey(row.id, externalId), create: { organizationId: row.organizationId, connectionId: row.id, entityType: 'customer', externalId, entityId: personId, externalUpdatedAt: parseDate(c.updated_at), data: profile as unknown as Prisma.InputJsonValue, }, update: { entityId: personId, externalUpdatedAt: parseDate(c.updated_at), data: profile as unknown as Prisma.InputJsonValue, syncedAt: new Date(), }, }); if (!existingLink || existingLink.entityId !== personId) { await Promise.all([ this.prisma.montifyOrder.updateMany({ where: { connectionId: row.id, customerExternalId: externalId }, data: { personId }, }), this.prisma.montifyTransaction.updateMany({ where: { connectionId: row.id, customerExternalId: externalId }, data: { personId }, }), ]); } return { personId, outcome, ordersSyncedAt: link.ordersSyncedAt }; } /* ——— سفارش و تراکنش ——— */ private async personForCustomer(connectionId: string, customerExternalId: string | null) { if (!customerExternalId) return null; const link = await this.prisma.montifyLink.findUnique({ where: customerLinkKey(connectionId, customerExternalId), select: { entityId: true }, }); return link?.entityId ?? null; } private async upsertOrder( row: MontifyConnection, o: MontifyOrder, personIdHint: string | null, customerHint: string | null, ): Promise { if (!o?.id) return; const externalId = String(o.id); const customerExternalId = str(o.user_id) ?? customerHint; const personId = personIdHint ?? (await this.personForCustomer(row.id, customerExternalId)); const items = (o.order_items ?? []).map((i) => ({ id: str(i.id), productId: str(i.product_id), productName: str(i.product_name), qty: num(i.qty), price: num(i.price), weight: num(i.weight), totalPrice: num(i.total_price), })); const data = { customerExternalId, personId, orderReference: str(o.code), orderStatus: str(o.sts), paymentStatus: str(o.payment_sts), paymentType: str(o.payment_type), totalPrice: dec(o.total_price), discountTotalPrice: dec(o.discount_total_price), totalDiscounts: dec(o.total_discounts), totalTax: dec(o.total_tax), totalQty: dec(o.total_qty), profit: dec(o.profit), percentProfit: dec(o.percent_profit), sumGold: dec(o.sum_gold), sumSilver: dec(o.sum_silver), address: (o.address ?? Prisma.JsonNull) as Prisma.InputJsonValue, note: str(o.note), cancelReason: str(o.cancel_reason), cancelledAt: parseDate(o.cancelled_at), closedAt: parseDate(o.closed_at), codeDiscount: str(o.code_discount), items: items as unknown as Prisma.InputJsonValue, externalCreatedAt: parseDate(o.created_at), externalUpdatedAt: parseDate(o.updated_at), deletedAt: parseDate(o.deleted_at), raw: o as unknown as Prisma.InputJsonValue, syncedAt: new Date(), }; await this.prisma.montifyOrder.upsert({ where: { connectionId_externalId: { connectionId: row.id, externalId } }, create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data, }, update: data, }); } private async upsertTransaction( row: MontifyConnection, t: MontifyTransaction, personId: string, customerExternalId: string, ): Promise { if (!t?.id) return; const externalId = String(t.id); const data = { customerExternalId: str(t.user_id) ?? customerExternalId, personId, type: str(t.type), amount: dec(t.amount), currency: str(t.currency), status: str(t.status), referenceCode: str(t.reference_code), externalCreatedAt: parseDate(t.created_at), raw: t as unknown as Prisma.InputJsonValue, syncedAt: new Date(), }; await this.prisma.montifyTransaction.upsert({ where: { connectionId_externalId: { connectionId: row.id, externalId } }, create: { organizationId: row.organizationId, connectionId: row.id, externalId, ...data, }, update: data, }); } /* ——— فاز ۳: وب‌هوک ——— */ async processWebhook( row: MontifyConnection, deliveryRowId: string, event: string, payload: { data?: Record } | null, ): Promise { try { const data = payload?.data ?? {}; const id = str(data.id); if (!id) throw new Error('شناسه رکورد در payload نیست'); const client = this.connections.clientFor(row); if (event.startsWith('customer.')) { const customer = await client.getCustomer(id); await this.syncCustomerDeep(row, client, customer, 'incremental', null); } else if (event.startsWith('order.')) { let order: MontifyOrder; try { order = await client.getOrder(id); } catch { order = data as unknown as MontifyOrder; } const customerId = str(order.user_id); const personId = await this.personForCustomer(row.id, customerId); if (!personId && customerId) { const customer = await client.getCustomer(customerId); await this.syncCustomerDeep(row, client, customer, 'incremental', null); } await this.upsertOrder(row, order, null, customerId); } await this.prisma.montifyWebhookDelivery.update({ where: { id: deliveryRowId }, data: { status: 'processed' }, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); this.logger.warn(`Montify webhook ${row.id} ${event}: ${msg}`); await this.prisma.montifyWebhookDelivery.update({ where: { id: deliveryRowId }, data: { status: 'failed', error: msg.slice(0, 500) }, }); } } /* ——— نمایش ——— */ async listOrders(organizationId: string, connectionId?: string): Promise { const rows = await this.prisma.montifyOrder.findMany({ where: { organizationId, ...(connectionId ? { connectionId } : {}) }, include: { connection: { select: { label: true } } }, orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }], take: 50, }); const personIds = [...new Set(rows.map((r) => r.personId).filter((x): x is string => !!x))]; const people = personIds.length ? await this.prisma.person.findMany({ where: { id: { in: personIds } }, select: { id: true, firstName: true, lastName: true }, }) : []; const nameOf = new Map(people.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()])); return rows.map((r) => this.orderDto(r, r.connection.label, nameOf)); } async personStores(actor: Actor, personId: string): Promise { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor), }, select: { id: true, firstName: true, lastName: true }, }); if (!person) throw new NotFoundException('شخص پیدا نشد'); const links = await this.prisma.montifyLink.findMany({ where: { organizationId: actor.organizationId, entityType: 'customer', entityId: personId, }, include: { connection: { select: { label: true } } }, orderBy: { syncedAt: 'desc' }, }); const nameOf = new Map([[person.id, `${person.firstName} ${person.lastName}`.trim()]]); const out: MontifyPersonStoreDto[] = []; for (const l of links) { const [orders, transactions] = await Promise.all([ this.prisma.montifyOrder.findMany({ where: { connectionId: l.connectionId, customerExternalId: l.externalId, }, orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }], take: 20, }), this.prisma.montifyTransaction.findMany({ where: { connectionId: l.connectionId, customerExternalId: l.externalId, }, orderBy: [{ externalCreatedAt: 'desc' }, { createdAt: 'desc' }], take: 20, }), ]); const p = (l.data ?? {}) as Partial; const engagement: MontifyEngagementDto | null = p.engagement ? { saved: arr(p.engagement.saved).length, likes: arr(p.engagement.likes).length, comments: arr(p.engagement.comments).length, } : null; out.push({ connectionId: l.connectionId, connectionLabel: l.connection.label, externalCustomerId: l.externalId, fullName: p.fullName ?? null, username: p.username ?? null, customerType: p.type ?? null, walletBalance: p.walletBalance ?? null, goldWalletBalance: p.goldWalletBalance ?? null, totalOrders: p.summary?.totalOrders ?? null, totalSpent: p.summary?.totalSpent ?? null, engagement, orders: orders.map((o) => this.orderDto(o, l.connection.label, nameOf)), transactions: transactions.map((t) => this.transactionDto(t)), syncedAt: l.syncedAt.toISOString(), }); } return out; } private orderDto( r: MontifyOrderRow, connectionLabel: string, nameOf: Map, ): MontifyOrderDto { const items = (Array.isArray(r.items) ? r.items : []) as unknown as MontifyOrderItemDto[]; const raw = r.raw; return { id: r.id, connectionId: r.connectionId, connectionLabel, externalId: r.externalId, customerExternalId: r.customerExternalId, personId: r.personId, personName: r.personId ? (nameOf.get(r.personId) ?? null) : null, orderReference: r.orderReference, orderStatus: label(raw, 'sts') ?? r.orderStatus, paymentStatus: label(raw, 'payment_sts') ?? r.paymentStatus, paymentType: label(raw, 'payment_type') ?? r.paymentType, totalPrice: decNum(r.totalPrice), discountTotalPrice: decNum(r.discountTotalPrice), totalDiscounts: decNum(r.totalDiscounts), totalQty: decNum(r.totalQty), sumGold: decNum(r.sumGold), sumSilver: decNum(r.sumSilver), note: r.note, cancelReason: r.cancelReason, cancelledAt: r.cancelledAt?.toISOString() ?? null, closedAt: r.closedAt?.toISOString() ?? null, items: items.map((i) => ({ productId: i.productId ?? null, productName: i.productName ?? null, qty: i.qty ?? null, price: i.price ?? null, weight: i.weight ?? null, totalPrice: i.totalPrice ?? null, })), createdAt: r.externalCreatedAt?.toISOString() ?? null, updatedAt: r.externalUpdatedAt?.toISOString() ?? null, }; } private transactionDto(t: MontifyTransactionRow): MontifyTransactionDto { return { id: t.id, externalId: t.externalId, type: label(t.raw, 'type') ?? t.type, amount: decNum(t.amount), currency: label(t.raw, 'currency') ?? t.currency, status: label(t.raw, 'status') ?? t.status, referenceCode: t.referenceCode, createdAt: t.externalCreatedAt?.toISOString() ?? null, }; } }