feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const rows = await this.prisma.setting.findMany({ 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 ?? [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user