Files
tehran_taj/apps/api/src/modules/admin-misc/admin-misc.service.ts
T
s.esmaeiliandClaude Sonnet 5.5 06c8a5f434 feat(auth): split panel admins out of AdminUser into their own Admin table
AdminUser held both panel staff and sales experts, so one mobile could not be
both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser"
untouched. Uniqueness is per table.

- migration 20261026120000_split_admin_table: additive only. Creates "Admin",
  copies non-expert rows with their ids, re-points the "who did this" foreign
  keys (ledger, order status, sms, notification, upload, allocation, workshop,
  admin session) to "Admin" without moving data. Never writes to "AdminUser".
- login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin";
  SALES_EXPERT create/edit still goes through admin/users into "AdminUser".
- an admin cannot be turned into an expert or the reverse (different tables).
- manual/: optional phase-2 script to drop the dormant admin copies from
  "AdminUser", and a read-only expert-login diagnostic.
- docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback.

Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before
and after; full migration chain applies and matches schema.prisma.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:52:33 +03:30

633 lines
23 KiB
TypeScript

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<string | null | undefined> {
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<string, string>();
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<Map<string, string>> {
const rows = await this.prisma.customer.findMany({
where: { mobile: { in: mobiles } },
select: { mobile: true, fullName: true },
});
const map = new Map<string, string>();
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<string, MessageAudience> = {
'-1': MessageAudience.SINGLE_NUMBER,
'0': MessageAudience.ALL_CUSTOMERS,
'1': MessageAudience.SALES_AGENTS,
'2': MessageAudience.WHOLESALE_CUSTOMERS,
'9': MessageAudience.MULTIPLE_NUMBERS,
};
const byName: Record<string, MessageAudience> = {
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<string[]> {
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 ?? [];
}
}