Files
tehran_taj/apps/api/src/modules/admin-misc/admin-misc.service.ts
T

533 lines
19 KiB
TypeScript
Raw Normal View History

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 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,
) {}
// ── Cities ─────────────────────────────────────────────────────────
/** 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', 'province'],
allowed: ['id', 'name', 'province', '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: { _count: { select: { customers: true, branches: 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,
province: c.province,
percent: c.percent,
percent_aux: c.percentAux,
customers_count: c._count.customers,
branches_count: c._count.branches,
})),
total,
query,
page,
limit,
);
}
/** Legacy `POST cities`. */
async createCity(data: { title?: string; name?: string; province?: string }) {
const name = String(data.title ?? data.name ?? '').trim();
if (!name) throw new BadRequestException('نام شهر الزامی است');
return this.prisma.city.create({ data: { name, province: data.province ?? null } });
}
/** Legacy `PUT cities/{id}` — the grid's inline edit also sets the percents. */
async updateCity(
id: string,
data: { title?: string; name?: string; province?: string; percent?: unknown; percent_aux?: unknown },
) {
const existing = await this.prisma.city.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('شهر یافت نشد');
const patch: Prisma.CityUpdateInput = {};
if (data.title !== undefined || data.name !== undefined) {
patch.name = String(data.title ?? data.name);
}
if (data.province !== undefined) patch.province = data.province ?? null;
if (data.percent !== undefined) {
patch.percent = data.percent === null ? null : new Prisma.Decimal(String(data.percent));
}
if (data.percent_aux !== undefined) {
patch.percentAux =
data.percent_aux === null ? null : new Prisma.Decimal(String(data.percent_aux));
}
return this.prisma.city.update({ where: { id }, data: patch });
}
/**
* Legacy `DELETE cities/{id}`.
*
* Refuses while customers or branches 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 } } },
});
if (!c) throw new NotFoundException('شهر یافت نشد');
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. */
async getCityPercent(id: string, aux: boolean) {
const c = await this.prisma.city.findUnique({ where: { id } });
if (!c) throw new NotFoundException('شهر یافت نشد');
return aux ? c.percentAux : c.percent;
}
// ── 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: { sentByAdminUser: 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.sentByAdminUser?.fullName ?? null,
caj: s.createdAt,
})),
total,
query,
page,
limit,
);
}
/**
* Legacy `POST sms` — records the send.
*
* NOTE no SMS gateway is wired up: legacy handed the message to an external
* provider, and this port has no credentials for one. The message is stored
* with the audience it resolved to, so the log is accurate, but nothing is
* actually transmitted. Flagged in docs/ADMIN-PORT-PLAN.md.
*/
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);
return this.prisma.smsMessage.create({
data: {
audience,
recipients: recipients.join(','),
body,
sentCount: recipients.length,
sentByAdminUserId: actorId,
},
});
}
/**
* 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,
sentByAdminUserId: 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,
sentByAdminUserId: 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: {
sentByAdminUser: 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.sentByAdminUser?.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: {
sentByAdminUser: 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.sentByAdminUser?.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 ?? [];
}
}