import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { MessageAudience, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { TableService } from '../admin-table/table.service'; import { SmsService } from '../sms/sms.service'; import type { TableQueryDto } from '../admin-table/table-query.dto'; import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config'; import { PRICING_SETTING_PREFIX } from '../pricing/wage.service'; /** * The remaining admin screens — legacy views/city, views/sms, views/notify, * views/newsletters, views/statics and views/manage/*: * * cities, cities/{id}, cities/get-percent/{id}, cities/get-percent-aux/{id} * sms/table, sms * notify, notify/customer, notify/{id}, notify/read/{id} * ddf -> the newsletter list (legacy's own opaque name) * log/list/customer, log/ago/{id} * const, const/{key}, const/by-id/{id} * dashboard/link */ @Injectable() export class AdminMiscService { constructor( private readonly prisma: PrismaService, private readonly table: TableService, private readonly sms: SmsService, ) {} // ── Cities ───────────────────────────────────────────────────────── // // Legacy cities are a tree (country > province > city > district) and so are // the target's again (PERS-02): each city may hang under a `parent`. /** Legacy `POST cities/list` grid. */ async listCities(query: TableQueryDto) { this.table.assertNotExport(query); const { where, orderBy, skip, take, page, limit } = this.table.build(query, { searchable: ['name'], allowed: ['id', 'name', 'parentId', 'percent', 'percentAux'], defaultOrderBy: { name: 'asc' }, }); const [rows, total] = await Promise.all([ this.prisma.city.findMany({ where: where as Prisma.CityWhereInput, orderBy: orderBy as Prisma.CityOrderByWithRelationInput, skip, take, include: { parent: { select: { id: true, name: true } }, _count: { select: { customers: true, branches: true, children: true } }, }, }), this.prisma.city.count({ where: where as Prisma.CityWhereInput }), ]); return this.table.respond( rows.map((c) => ({ id: c.id, title: c.name, name: c.name, parent: c.parentId, parent_title: c.parent?.name ?? null, percent: c.percent, percent_aux: c.percentAux, customers_count: c._count.customers, branches_count: c._count.branches, children_count: c._count.children, })), total, query, page, limit, ); } /** * The parent a create/update asks for: `parent` (legacy's field name) or * `parent_id`; empty means top level. Refuses an unknown city, the city * itself, or one of its own descendants (that would make a loop). */ private async resolveCityParent( data: { parent?: unknown; parent_id?: unknown }, selfId?: string, ): Promise { const raw = data.parent !== undefined ? data.parent : data.parent_id; if (raw === undefined) return undefined; if (raw === null || String(raw).trim() === '') return null; const parentId = String(raw).trim(); // Walk up from the requested parent; meeting selfId means a loop. let cur: string | null = parentId; for (let guard = 0; cur && guard < 50; guard++) { if (cur === selfId) throw new BadRequestException('شهر نمی‌تواند زیرمجموعه‌ی خودش باشد.'); const row: { parentId: string | null } | null = await this.prisma.city.findUnique({ where: { id: cur }, select: { parentId: true }, }); if (!row) { if (cur === parentId) throw new BadRequestException('والد انتخاب‌شده پیدا نشد.'); break; } cur = row.parentId; } return parentId; } private static cityPercent(v: unknown): Prisma.Decimal | null | undefined { if (v === undefined) return undefined; if (v === null || String(v).trim() === '') return null; try { return new Prisma.Decimal(String(v)); } catch { throw new BadRequestException('درصد باید عدد باشد.'); } } /** Legacy `POST cities` — name, parent and the two percents. */ async createCity(data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown }) { const name = String(data.title ?? data.name ?? '').trim(); if (!name) throw new BadRequestException('نام شهر الزامی است'); const parentId = await this.resolveCityParent(data); return this.prisma.city.create({ data: { name, parentId: parentId ?? null, percent: AdminMiscService.cityPercent(data.percent) ?? null, percentAux: AdminMiscService.cityPercent(data.percent_aux) ?? null, }, }); } /** Legacy `PUT cities/{id}` — name, parent and the two percents. */ async updateCity( id: string, data: { title?: string; name?: string; parent?: unknown; parent_id?: unknown; percent?: unknown; percent_aux?: unknown }, ) { const existing = await this.prisma.city.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('شهر یافت نشد'); const patch: Prisma.CityUncheckedUpdateInput = {}; if (data.title !== undefined || data.name !== undefined) { const name = String(data.title ?? data.name ?? '').trim(); if (!name) throw new BadRequestException('نام شهر الزامی است'); patch.name = name; } const parentId = await this.resolveCityParent(data, id); if (parentId !== undefined) patch.parentId = parentId; const percent = AdminMiscService.cityPercent(data.percent); if (percent !== undefined) patch.percent = percent; const percentAux = AdminMiscService.cityPercent(data.percent_aux); if (percentAux !== undefined) patch.percentAux = percentAux; return this.prisma.city.update({ where: { id }, data: patch }); } /** * Legacy `DELETE cities/{id}`. * * Refuses while customers, branches or child cities still point at the city — * legacy deleted regardless, orphaning those rows. */ async removeCity(id: string) { const c = await this.prisma.city.findUnique({ where: { id }, include: { _count: { select: { customers: true, branches: true, addresses: true, children: true } } }, }); if (!c) throw new NotFoundException('شهر یافت نشد'); if (c._count.children > 0) { throw new BadRequestException(`حذف این شهر ممکن نیست، چون ${c._count.children} شهر زیرمجموعه‌ی آن است.`); } const refs = c._count.customers + c._count.branches + c._count.addresses; if (refs > 0) { throw new BadRequestException( `حذف این شهر ممکن نیست، چون ${refs} مشتری، شعبه یا آدرس به آن ارجاع دارد.`, ); } await this.prisma.city.delete({ where: { id } }); return { success: true }; } /** * Legacy `GET cities/get-percent/{id}` — returns the bare number. As legacy's * CitiesController::_getP: a city without its own percent (null or 0) takes * its parent's, and so on up; 0 when no ancestor has one. */ async getCityPercent(id: string, aux: boolean) { let cur: string | null = id; for (let guard = 0; cur && guard < 50; guard++) { const c: { parentId: string | null; percent: Prisma.Decimal | null; percentAux: Prisma.Decimal | null } | null = await this.prisma.city.findUnique({ where: { id: cur }, select: { parentId: true, percent: true, percentAux: true }, }); if (!c) { if (cur === id) throw new NotFoundException('شهر یافت نشد'); break; } const v = aux ? c.percentAux : c.percent; if (v !== null && v.gt(0)) return v; cur = c.parentId; } return 0; } // ── SMS ──────────────────────────────────────────────────────────── /** Legacy `POST sms/table` — the sent-message log. */ async listSms(query: TableQueryDto) { this.table.assertNotExport(query); const { where, orderBy, skip, take, page, limit } = this.table.build(query, { searchable: ['body', 'recipients'], allowed: ['id', 'audience', 'body', 'createdAt', 'sentCount'], defaultOrderBy: { createdAt: 'desc' }, }); const [rows, total] = await Promise.all([ this.prisma.smsMessage.findMany({ where: where as Prisma.SmsMessageWhereInput, orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput, skip, take, include: { sentByAdmin: true }, }), this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }), ]); return this.table.respond( rows.map((s) => ({ id: s.id, type: s.audience, text: s.body, mobile: s.recipients, sent_count: s.sentCount, sender: s.sentByAdmin?.fullName ?? null, caj: s.createdAt, })), total, query, page, limit, ); } /** * Legacy `POST sms` — resolves the audience, substitutes `%name` per * recipient (legacy SmsController::batch + replaceName), delivers via the * configured gateway (D26 relay), and records the send. */ async sendSms( data: { type?: string | number; mobile?: string; text?: string }, actorId: string, ) { const body = String(data.text ?? '').trim(); if (!body) throw new BadRequestException('متن پیامک الزامی است'); const audience = this.resolveAudience(data.type); const recipients = await this.resolveRecipients(audience, data.mobile); const needsNameSub = body.includes('%name'); const customersByMobile = needsNameSub ? await this.customerNamesByMobile(recipients) : new Map(); for (const mobile of recipients) { const text = needsNameSub ? body.replace(/%name/g, customersByMobile.get(mobile) ?? 'مشتری عزیز') : body; this.sms.send(mobile, text, { audience }); } return this.prisma.smsMessage.create({ data: { audience, recipients: recipients.join(','), body, sentCount: recipients.length, sentByAdminId: actorId, }, }); } private async customerNamesByMobile(mobiles: string[]): Promise> { const rows = await this.prisma.customer.findMany({ where: { mobile: { in: mobiles } }, select: { mobile: true, fullName: true }, }); const map = new Map(); for (const r of rows) { if (r.fullName) map.set(r.mobile, r.fullName + ' عزیز'); } return map; } /** * Legacy's numeric audience codes, read off views/sms/create.vue where each * option is gated by its own rid: * -1 sms.create.single | 0 sms.create.all | 1 sms.create.reagent * 2 sms.create.omde | 9 sms.create.multiple * (there is no option for sms.create.single-customer "tak" in the legacy * markup even though the rid exists — preserved as unreachable.) */ private resolveAudience(type?: string | number): MessageAudience { const byCode: Record = { '-1': MessageAudience.SINGLE_NUMBER, '0': MessageAudience.ALL_CUSTOMERS, '1': MessageAudience.SALES_AGENTS, '2': MessageAudience.WHOLESALE_CUSTOMERS, '9': MessageAudience.MULTIPLE_NUMBERS, }; const byName: Record = { single: MessageAudience.SINGLE_NUMBER, multiple: MessageAudience.MULTIPLE_NUMBERS, all: MessageAudience.ALL_CUSTOMERS, omde: MessageAudience.WHOLESALE_CUSTOMERS, tak: MessageAudience.RETAIL_CUSTOMERS, reagent: MessageAudience.SALES_AGENTS, }; const key = String(type ?? ''); return byCode[key] ?? byName[key] ?? MessageAudience.SINGLE_NUMBER; } /** Expands an audience into the actual phone numbers it covers. */ private async resolveRecipients( audience: MessageAudience, explicit?: string, ): Promise { if ( audience === MessageAudience.SINGLE_NUMBER || audience === MessageAudience.MULTIPLE_NUMBERS ) { const list = String(explicit ?? '') .split(/[,\s\n]+/) .map((x) => x.trim()) .filter(Boolean); if (list.length === 0) throw new BadRequestException('شماره موبایل وارد نشده است'); return list; } if (audience === MessageAudience.SALES_AGENTS) { const rows = await this.prisma.adminUser.findMany({ where: { role: 'SALES_EXPERT', isActive: true }, select: { mobile: true }, }); return rows.map((r) => r.mobile); } // The customer audiences. WHOLESALE/RETAIL had a Customer.type column in // legacy that the schema redesign removed, so both currently resolve to all // active customers — the same gap recorded for the omde/tak lists. const rows = await this.prisma.customer.findMany({ where: { isActive: true }, select: { mobile: true }, }); return rows.map((r) => r.mobile); } // ── Notifications ────────────────────────────────────────────────── /** Legacy `POST notify` — broadcast to an audience. */ async createNotification( data: { title?: string; text?: string; body?: string; type?: string | number }, actorId: string, ) { const title = String(data.title ?? '').trim(); const body = String(data.body ?? data.text ?? '').trim(); if (!title) throw new BadRequestException('عنوان نوتیفیکیشن الزامی است'); if (!body) throw new BadRequestException('متن نوتیفیکیشن الزامی است'); const audience = this.resolveAudience(data.type); const customers = await this.prisma.customer.findMany({ where: { isActive: true }, select: { id: true }, }); return this.prisma.notification.create({ data: { title, body, audience, sentByAdminId: actorId, recipients: { create: customers.map((c) => ({ customerId: c.id })) }, }, include: { _count: { select: { recipients: true } } }, }); } /** Legacy `POST notify/customer` — send to one named customer. */ async notifyCustomer( data: { customer_id?: string; title?: string; text?: string; body?: string }, actorId: string, ) { const customerId = String(data.customer_id ?? ''); if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است'); const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) throw new NotFoundException('مشتری یافت نشد'); const title = String(data.title ?? '').trim(); const body = String(data.body ?? data.text ?? '').trim(); if (!title || !body) throw new BadRequestException('عنوان و متن الزامی است'); return this.prisma.notification.create({ data: { title, body, audience: MessageAudience.SINGLE_NUMBER, sentByAdminId: actorId, recipients: { create: [{ customerId }] }, }, }); } /** Legacy `GET notify/{id}` — one notification with its delivery state. */ async findNotification(id: string) { const n = await this.prisma.notification.findUnique({ where: { id }, include: { sentByAdmin: true, recipients: { include: { customer: true } }, }, }); if (!n) throw new NotFoundException('نوتیفیکیشن یافت نشد'); return { id: n.id, title: n.title, body: n.body, text: n.body, audience: n.audience, sender: n.sentByAdmin?.fullName ?? null, caj: n.createdAt, recipients: n.recipients.map((r) => ({ id: r.id, customer: { id: r.customer.id, name: r.customer.fullName, mobile: r.customer.mobile }, read_at: r.readAt, is_read: Boolean(r.readAt), })), read_count: n.recipients.filter((r) => r.readAt).length, total_count: n.recipients.length, }; } /** Legacy `POST notify/list` — the notification log. */ async listNotifications(query: TableQueryDto) { this.table.assertNotExport(query); const { where, orderBy, skip, take, page, limit } = this.table.build(query, { searchable: ['title', 'body'], allowed: ['id', 'title', 'body', 'audience', 'createdAt'], defaultOrderBy: { createdAt: 'desc' }, }); const [rows, total] = await Promise.all([ this.prisma.notification.findMany({ where: where as Prisma.NotificationWhereInput, orderBy: orderBy as Prisma.NotificationOrderByWithRelationInput, skip, take, include: { sentByAdmin: true, _count: { select: { recipients: true } }, }, }), this.prisma.notification.count({ where: where as Prisma.NotificationWhereInput }), ]); return this.table.respond( rows.map((n) => ({ id: n.id, title: n.title, text: n.body, audience: n.audience, sender: n.sentByAdmin?.fullName ?? null, total_count: n._count.recipients, caj: n.createdAt, })), total, query, page, limit, ); } /** Legacy `GET notify/read/{id}` — mark one recipient row read. */ async markNotificationRead(recipientId: string) { const r = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } }); if (!r) throw new NotFoundException('یافت نشد'); if (r.readAt) return r; return this.prisma.notificationRecipient.update({ where: { id: recipientId }, data: { readAt: new Date() }, }); } // ── Newsletter ───────────────────────────────────────────────────── /** Legacy `POST ddf` — the newsletter signup list (legacy's own endpoint name). */ async listNewsletter(query: TableQueryDto) { this.table.assertNotExport(query); const { where, orderBy, skip, take, page, limit } = this.table.build(query, { searchable: ['email', 'mobile'], allowed: ['id', 'email', 'mobile', 'createdAt'], defaultOrderBy: { createdAt: 'desc' }, }); const [rows, total] = await Promise.all([ this.prisma.newsletterSubscriber.findMany({ where: where as Prisma.NewsletterSubscriberWhereInput, orderBy: orderBy as Prisma.NewsletterSubscriberOrderByWithRelationInput, skip, take, }), this.prisma.newsletterSubscriber.count({ where: where as Prisma.NewsletterSubscriberWhereInput, }), ]); return this.table.respond( rows.map((n) => ({ id: n.id, email: n.email, mobile: n.mobile, caj: n.createdAt })), total, query, page, limit, ); } // ── Activity log ("آمار") ───────────────────────────────────────── /** Legacy `GET log/list/customer` — customers that have any activity. */ async logCustomers() { const rows = await this.prisma.customer.findMany({ where: { activityLogs: { some: {} } }, orderBy: { fullName: 'asc' }, select: { id: true, fullName: true, mobile: true }, }); return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile })); } /** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */ async customerLog(customerId: string) { const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) throw new NotFoundException('مشتری یافت نشد'); const rows = await this.prisma.customerActivityLog.findMany({ where: { customerId }, orderBy: { createdAt: 'desc' }, take: 200, }); return rows.map((l) => ({ id: l.id, action: l.action, detail: l.detail, caj: l.createdAt, })); } // ── Settings ("مدیریت ثابت ها") ─────────────────────────────────── /** Legacy `GET const/{key}`. */ async getConst(key: string) { const row = await this.prisma.setting.findUnique({ where: { key } }); return row?.value ?? null; } /** Legacy `POST const` / `PUT const/{key}`. */ async setConst(key: string, value: unknown) { const row = await this.prisma.setting.upsert({ where: { key }, update: { value: value as Prisma.InputJsonValue }, create: { key, value: value as Prisma.InputJsonValue }, }); return row.value; } async listConsts() { // The accounting integration's rows (connection, encrypted token, worker // lease) have their own `config.accounting` screen and never appear here // (D27); the single-key routes refuse them in the controller. const rows = await this.prisma.setting.findMany({ where: { AND: [{ NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } }, { NOT: { key: { startsWith: PRICING_SETTING_PREFIX } } }] }, orderBy: { key: 'asc' }, }); return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt })); } async removeConst(key: string) { const row = await this.prisma.setting.findUnique({ where: { key } }); if (!row) throw new NotFoundException('تنظیم یافت نشد'); await this.prisma.setting.delete({ where: { key } }); return { success: true }; } // ── Brands (managed from the same "ثابت ها" screen) ─────────────── async createBrand(title: string) { if (!title?.trim()) throw new BadRequestException('عنوان برند الزامی است'); return this.prisma.brand.create({ data: { title: title.trim() } }); } async updateBrand(id: string, title: string) { const b = await this.prisma.brand.findUnique({ where: { id } }); if (!b) throw new NotFoundException('برند یافت نشد'); return this.prisma.brand.update({ where: { id }, data: { title } }); } async removeBrand(id: string) { const b = await this.prisma.brand.findUnique({ where: { id }, include: { _count: { select: { products: true } } }, }); if (!b) throw new NotFoundException('برند یافت نشد'); if (b._count.products > 0) { throw new BadRequestException( `حذف این برند ممکن نیست، چون ${b._count.products} محصول به آن ارجاع دارد.`, ); } await this.prisma.brand.delete({ where: { id } }); return { success: true }; } /** Legacy `GET dashboard/link` — the report shortcuts on views/manage/report. */ async dashboardLinks() { const row = await this.prisma.setting.findUnique({ where: { key: 'dashboard.links' } }); return row?.value ?? []; } }