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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
4679e2498c
commit
06c8a5f434
@@ -22,7 +22,7 @@ export interface PostEntryInput {
|
||||
note?: string;
|
||||
/** Order-delivery debt entries are self-confirming; repayments start pending. */
|
||||
confirmed?: boolean;
|
||||
postedByAdminUserId?: string;
|
||||
postedByAdminId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ export class LedgerService {
|
||||
note: input.note,
|
||||
confirmed,
|
||||
confirmedAt: confirmed ? new Date() : null,
|
||||
postedByAdminUserId: input.postedByAdminUserId,
|
||||
postedByAdminId: input.postedByAdminId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export class LedgerService {
|
||||
|
||||
const updated = await tx.ledgerEntry.update({
|
||||
where: { id: entryId },
|
||||
data: { confirmed: true, confirmedAt: new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId },
|
||||
data: { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId },
|
||||
});
|
||||
|
||||
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
||||
@@ -180,7 +180,7 @@ export class LedgerService {
|
||||
karatPerMille?: number;
|
||||
pricePerGramRial?: Prisma.Decimal.Value;
|
||||
note?: string;
|
||||
postedByAdminUserId?: string;
|
||||
postedByAdminId?: string;
|
||||
},
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
@@ -230,7 +230,7 @@ export class LedgerService {
|
||||
pricePerGramRialAtEntry: params.pricePerGramRial,
|
||||
note: params.note,
|
||||
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
postedByAdminId: params.postedByAdminId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -242,7 +242,7 @@ export class LedgerService {
|
||||
* migration analysis) but supported here since the new ledger is symmetric.
|
||||
*/
|
||||
async postRialRepayment(
|
||||
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminUserId?: string },
|
||||
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminId?: string },
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
return this.postEntry(
|
||||
@@ -254,7 +254,7 @@ export class LedgerService {
|
||||
amount: params.amount,
|
||||
note: params.note,
|
||||
confirmed: false,
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
postedByAdminId: params.postedByAdminId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -267,7 +267,7 @@ export class LedgerService {
|
||||
direction: LedgerDirection;
|
||||
amount: Prisma.Decimal.Value;
|
||||
note: string;
|
||||
postedByAdminUserId: string;
|
||||
postedByAdminId: string;
|
||||
},
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
@@ -280,7 +280,7 @@ export class LedgerService {
|
||||
amount: params.amount,
|
||||
note: params.note,
|
||||
confirmed: true,
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
postedByAdminId: params.postedByAdminId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export class AdminBranchesService {
|
||||
async listBranches(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const aliases: Record<string, string> = { 'user.name': 'adminUsers.some.fullName' };
|
||||
const aliases: Record<string, string> = { 'user.name': 'admins.some.fullName' };
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['name', 'address', 'phone'],
|
||||
allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'],
|
||||
@@ -48,8 +48,8 @@ export class AdminBranchesService {
|
||||
take,
|
||||
include: {
|
||||
city: true,
|
||||
adminUsers: { where: { isActive: true }, take: 1 },
|
||||
_count: { select: { adminUsers: true, orders: true } },
|
||||
admins: { where: { isActive: true }, take: 1 },
|
||||
_count: { select: { admins: true, orders: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }),
|
||||
@@ -66,10 +66,10 @@ export class AdminBranchesService {
|
||||
city_id: b.cityId,
|
||||
city: b.city ? { id: b.city.id, title: b.city.name } : null,
|
||||
// legacy column key is `user.name` — the branch manager
|
||||
user: b.adminUsers[0]
|
||||
? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName }
|
||||
user: b.admins[0]
|
||||
? { id: b.admins[0].id, name: b.admins[0].fullName }
|
||||
: null,
|
||||
users_count: b._count.adminUsers,
|
||||
users_count: b._count.admins,
|
||||
orders_count: b._count.orders,
|
||||
})),
|
||||
total,
|
||||
@@ -82,7 +82,7 @@ export class AdminBranchesService {
|
||||
async findBranch(id: string) {
|
||||
const b = await this.prisma.branch.findUnique({
|
||||
where: { id },
|
||||
include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } },
|
||||
include: { city: true, admins: { where: { isActive: true }, take: 1 } },
|
||||
});
|
||||
if (!b) throw new NotFoundException('شعبه یافت نشد');
|
||||
return {
|
||||
@@ -92,8 +92,8 @@ export class AdminBranchesService {
|
||||
phone: b.phone,
|
||||
is_active: b.isActive,
|
||||
city_id: b.cityId,
|
||||
user_id: b.adminUsers[0]?.id ?? null,
|
||||
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null,
|
||||
user_id: b.admins[0]?.id ?? null,
|
||||
user: b.admins[0] ? { id: b.admins[0].id, name: b.admins[0].fullName } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,11 +145,11 @@ export class AdminBranchesService {
|
||||
*/
|
||||
private async assignBranchManager(branchId: string, userId: string | null) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } });
|
||||
await tx.admin.updateMany({ where: { branchId }, data: { branchId: null } });
|
||||
if (userId) {
|
||||
const user = await tx.adminUser.findUnique({ where: { id: userId } });
|
||||
const user = await tx.admin.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('کاربر انتخابشده یافت نشد');
|
||||
await tx.adminUser.update({ where: { id: userId }, data: { branchId } });
|
||||
await tx.admin.update({ where: { id: userId }, data: { branchId } });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -302,15 +302,15 @@ export class AdminBranchesService {
|
||||
const fullName = `${fname} ${lname}`.trim();
|
||||
|
||||
if (currentId) {
|
||||
const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } });
|
||||
const current = await this.prisma.admin.findUnique({ where: { id: currentId } });
|
||||
if (current) {
|
||||
if (phone !== current.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
||||
const clash = await this.prisma.admin.findUnique({ where: { mobile: phone } });
|
||||
if (clash && clash.id !== currentId) {
|
||||
throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
await this.prisma.adminUser.update({
|
||||
await this.prisma.admin.update({
|
||||
where: { id: currentId },
|
||||
data: { fullName, mobile: phone },
|
||||
});
|
||||
@@ -320,10 +320,10 @@ export class AdminBranchesService {
|
||||
|
||||
// Reuse an existing admin with that mobile rather than colliding on the
|
||||
// unique constraint.
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
||||
const existing = await this.prisma.admin.findUnique({ where: { mobile: phone } });
|
||||
if (existing) return existing.id;
|
||||
|
||||
const created = await this.prisma.adminUser.create({
|
||||
const created = await this.prisma.admin.create({
|
||||
data: {
|
||||
fullName,
|
||||
mobile: phone,
|
||||
@@ -347,7 +347,7 @@ export class AdminBranchesService {
|
||||
|
||||
/** The admin picker the branch form uses — legacy `GET user/list`. */
|
||||
async userList() {
|
||||
const rows = await this.prisma.adminUser.findMany({
|
||||
const rows = await this.prisma.admin.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true, branchId: true },
|
||||
|
||||
@@ -147,7 +147,7 @@ export class AdminDocumentsService {
|
||||
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
include: { customer: true, postedByAdmin: true },
|
||||
}),
|
||||
this.prisma.ledgerEntry.count({ where: scoped }),
|
||||
]);
|
||||
@@ -157,7 +157,7 @@ export class AdminDocumentsService {
|
||||
|
||||
/** Legacy field names preserved so the ported table model binds unchanged. */
|
||||
private toRow(
|
||||
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>,
|
||||
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdmin: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: e.id,
|
||||
@@ -190,7 +190,7 @@ export class AdminDocumentsService {
|
||||
lname: e.customer.lastName ?? '',
|
||||
mobile: e.customer.mobile,
|
||||
},
|
||||
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
|
||||
posted_by: e.postedByAdmin ? e.postedByAdmin.fullName : null,
|
||||
caj: e.createdAt,
|
||||
uaj: e.createdAt,
|
||||
};
|
||||
@@ -200,7 +200,7 @@ export class AdminDocumentsService {
|
||||
async findOne(id: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
include: { customer: true, postedByAdmin: true },
|
||||
});
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
return this.toRow(e);
|
||||
@@ -241,7 +241,7 @@ export class AdminDocumentsService {
|
||||
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
|
||||
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
|
||||
note: (data.note as string) ?? undefined,
|
||||
postedByAdminUserId: actorAdminUserId,
|
||||
postedByAdminId: actorAdminUserId,
|
||||
});
|
||||
|
||||
// Attach the instrument metadata the ledger service does not model.
|
||||
|
||||
@@ -28,7 +28,7 @@ export class AdminInvoicesService {
|
||||
'createdAt',
|
||||
'branchId',
|
||||
'branch.name',
|
||||
'allocatorAdminUser.fullName',
|
||||
'allocatorAdmin.fullName',
|
||||
// legacy filter keys
|
||||
'name',
|
||||
'caj',
|
||||
@@ -37,7 +37,7 @@ export class AdminInvoicesService {
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
name: 'branch.name',
|
||||
caj: 'createdAt',
|
||||
'allocator_user.name': 'allocatorAdminUser.fullName',
|
||||
'allocator_user.name': 'allocatorAdmin.fullName',
|
||||
};
|
||||
|
||||
/** Legacy `POST branch/product/table`. */
|
||||
@@ -67,7 +67,7 @@ export class AdminInvoicesService {
|
||||
take,
|
||||
include: {
|
||||
branch: true,
|
||||
allocatorAdminUser: true,
|
||||
allocatorAdmin: true,
|
||||
items: { include: { product: true } },
|
||||
},
|
||||
}),
|
||||
@@ -81,7 +81,7 @@ export class AdminInvoicesService {
|
||||
a: Prisma.BranchAllocationGetPayload<{
|
||||
include: {
|
||||
branch: true;
|
||||
allocatorAdminUser: true;
|
||||
allocatorAdmin: true;
|
||||
items: { include: { product: true } };
|
||||
};
|
||||
}>,
|
||||
@@ -100,8 +100,8 @@ export class AdminInvoicesService {
|
||||
sum_weight: sumWeight,
|
||||
count_item: a.items.length,
|
||||
note: a.note,
|
||||
allocator_user: a.allocatorAdminUser
|
||||
? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName }
|
||||
allocator_user: a.allocatorAdmin
|
||||
? { id: a.allocatorAdmin.id, name: a.allocatorAdmin.fullName }
|
||||
: null,
|
||||
caj: a.createdAt,
|
||||
products: a.items.map((it) => ({
|
||||
@@ -120,7 +120,7 @@ export class AdminInvoicesService {
|
||||
where: { id },
|
||||
include: {
|
||||
branch: true,
|
||||
allocatorAdminUser: true,
|
||||
allocatorAdmin: true,
|
||||
items: { include: { product: true } },
|
||||
},
|
||||
});
|
||||
@@ -155,7 +155,7 @@ export class AdminInvoicesService {
|
||||
const allocation = await this.prisma.branchAllocation.create({
|
||||
data: {
|
||||
branchId,
|
||||
allocatorAdminUserId: actorId,
|
||||
allocatorAdminId: actorId,
|
||||
note: data.note ?? null,
|
||||
items: {
|
||||
create: products.map((p) => ({
|
||||
|
||||
@@ -224,7 +224,7 @@ export class AdminMiscService {
|
||||
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { sentByAdminUser: true },
|
||||
include: { sentByAdmin: true },
|
||||
}),
|
||||
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
|
||||
]);
|
||||
@@ -236,7 +236,7 @@ export class AdminMiscService {
|
||||
text: s.body,
|
||||
mobile: s.recipients,
|
||||
sent_count: s.sentCount,
|
||||
sender: s.sentByAdminUser?.fullName ?? null,
|
||||
sender: s.sentByAdmin?.fullName ?? null,
|
||||
caj: s.createdAt,
|
||||
})),
|
||||
total,
|
||||
@@ -279,7 +279,7 @@ export class AdminMiscService {
|
||||
recipients: recipients.join(','),
|
||||
body,
|
||||
sentCount: recipients.length,
|
||||
sentByAdminUserId: actorId,
|
||||
sentByAdminId: actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export class AdminMiscService {
|
||||
title,
|
||||
body,
|
||||
audience,
|
||||
sentByAdminUserId: actorId,
|
||||
sentByAdminId: actorId,
|
||||
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
|
||||
},
|
||||
include: { _count: { select: { recipients: true } } },
|
||||
@@ -409,7 +409,7 @@ export class AdminMiscService {
|
||||
title,
|
||||
body,
|
||||
audience: MessageAudience.SINGLE_NUMBER,
|
||||
sentByAdminUserId: actorId,
|
||||
sentByAdminId: actorId,
|
||||
recipients: { create: [{ customerId }] },
|
||||
},
|
||||
});
|
||||
@@ -420,7 +420,7 @@ export class AdminMiscService {
|
||||
const n = await this.prisma.notification.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
sentByAdminUser: true,
|
||||
sentByAdmin: true,
|
||||
recipients: { include: { customer: true } },
|
||||
},
|
||||
});
|
||||
@@ -432,7 +432,7 @@ export class AdminMiscService {
|
||||
body: n.body,
|
||||
text: n.body,
|
||||
audience: n.audience,
|
||||
sender: n.sentByAdminUser?.fullName ?? null,
|
||||
sender: n.sentByAdmin?.fullName ?? null,
|
||||
caj: n.createdAt,
|
||||
recipients: n.recipients.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -462,7 +462,7 @@ export class AdminMiscService {
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
sentByAdminUser: true,
|
||||
sentByAdmin: true,
|
||||
_count: { select: { recipients: true } },
|
||||
},
|
||||
}),
|
||||
@@ -475,7 +475,7 @@ export class AdminMiscService {
|
||||
title: n.title,
|
||||
text: n.body,
|
||||
audience: n.audience,
|
||||
sender: n.sentByAdminUser?.fullName ?? null,
|
||||
sender: n.sentByAdmin?.fullName ?? null,
|
||||
total_count: n._count.recipients,
|
||||
caj: n.createdAt,
|
||||
})),
|
||||
|
||||
@@ -243,7 +243,7 @@ export class AdminOrdersService {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
|
||||
statusEvents: { include: { actorAdmin: true }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
|
||||
@@ -63,7 +63,7 @@ export class AdminUploadsService {
|
||||
mimeType: file.mimetype,
|
||||
sizeBytes: file.size,
|
||||
purpose: purpose ?? null,
|
||||
uploadedByAdminUserId: actorAdminUserId,
|
||||
uploadedByAdminId: actorAdminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,43 @@ const STORABLE_RIDS: ReadonlySet<string> = new Set(
|
||||
);
|
||||
|
||||
/**
|
||||
* Admin users and the permission matrix — legacy views/user/*, served by
|
||||
* What the user form and grid read. Admins (table `Admin`) and sales experts
|
||||
* (table `AdminUser`) are different tables, but the panel's forms bind to one
|
||||
* shape, so both are mapped into it. The expert-only fields are optional and
|
||||
* come out null for an admin.
|
||||
*/
|
||||
type UserRecord = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
mobile: string;
|
||||
role: AdminRole;
|
||||
userTypeId: number | null;
|
||||
userType: { id: number; title: string } | null;
|
||||
branchId: string | null;
|
||||
branch: { id: string; name: string } | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
wagePercent?: Prisma.Decimal | null;
|
||||
address?: string | null;
|
||||
storeName?: string | null;
|
||||
pasazhName?: string | null;
|
||||
memberCode?: string | null;
|
||||
avatarFileId?: string | null;
|
||||
avatarFile?: { id: string; url: string } | null;
|
||||
// D17 — present on the detail read; absent from the list query, hence optional.
|
||||
salesExpertProfile?: {
|
||||
cityId: string | null;
|
||||
gramLevel: Prisma.Decimal;
|
||||
phone: string | null;
|
||||
nationalCode: string | null;
|
||||
city?: { id: string; name: string } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Panel users and the permission matrix — legacy views/user/*, served by
|
||||
* UserController and RoleController:
|
||||
* POST/GET/PUT/DELETE users -> the admin user list and form
|
||||
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
|
||||
@@ -30,6 +66,13 @@ const STORABLE_RIDS: ReadonlySet<string> = new Set(
|
||||
* POST roles/create -> grant rids
|
||||
* DELETE roles/{ut}/{rid} -> revoke one rid
|
||||
*
|
||||
* Two tables sit behind the one `users` endpoint:
|
||||
* - `Admin` — panel staff. The grid lists ONLY these.
|
||||
* - `AdminUser` — sales experts (role SALES_EXPERT), created and edited from
|
||||
* the reagent form, which posts here with `role: 'SALES_EXPERT'`. An expert
|
||||
* is never written to `Admin`, and staff management never writes to
|
||||
* `AdminUser`, so the same mobile can be both (each is unique per table).
|
||||
*
|
||||
* The permission matrix writes the `permission(rid, userTypeId)` rows that
|
||||
* Phase 1 modelled, so this screen is what actually administers the ACL.
|
||||
*/
|
||||
@@ -65,7 +108,7 @@ export class AdminUsersService {
|
||||
active: 'isActive',
|
||||
};
|
||||
|
||||
/** Legacy `GET users` — the admin user grid. */
|
||||
/** Legacy `GET users` — the admin user grid (panel staff only). */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
@@ -94,39 +137,26 @@ export class AdminUsersService {
|
||||
});
|
||||
|
||||
// The support super admin is not listed: to everyone else it does not exist.
|
||||
const visible: Prisma.AdminUserWhereInput = {
|
||||
AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
|
||||
const visible: Prisma.AdminWhereInput = {
|
||||
AND: [where as Prisma.AdminWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.adminUser.findMany({
|
||||
this.prisma.admin.findMany({
|
||||
where: visible,
|
||||
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
|
||||
orderBy: orderBy as Prisma.AdminOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { userType: true, branch: true, avatarFile: true },
|
||||
include: { userType: true, branch: true },
|
||||
}),
|
||||
this.prisma.adminUser.count({ where: visible }),
|
||||
this.prisma.admin.count({ where: visible }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names preserved; the password hash is never exposed. */
|
||||
private toRow(
|
||||
u: Prisma.AdminUserGetPayload<{
|
||||
include: { userType: true; branch: true; avatarFile: true };
|
||||
}> & {
|
||||
// D17 — present on the detail read; absent from the list query, hence optional.
|
||||
salesExpertProfile?: {
|
||||
cityId: string | null;
|
||||
gramLevel: Prisma.Decimal;
|
||||
phone: string | null;
|
||||
nationalCode: string | null;
|
||||
city?: { id: string; name: string } | null;
|
||||
} | null;
|
||||
},
|
||||
) {
|
||||
private toRow(u: UserRecord) {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.fullName,
|
||||
@@ -140,20 +170,20 @@ export class AdminUsersService {
|
||||
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
|
||||
branch_id: u.branchId,
|
||||
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
|
||||
percent: u.wagePercent,
|
||||
percent: u.wagePercent ?? null,
|
||||
active: u.isActive ? 1 : -1,
|
||||
is_active: u.isActive,
|
||||
caj: u.createdAt,
|
||||
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
|
||||
// null for every other admin. Same shapes admin-customers.service.ts used
|
||||
// null for every admin. Same shapes admin-customers.service.ts used
|
||||
// when this data still lived on Customer, so the form binds unchanged.
|
||||
address: u.address,
|
||||
store_name: u.storeName,
|
||||
pasazh_name: u.pasazhName,
|
||||
mcode: u.memberCode,
|
||||
image_id: u.avatarFileId,
|
||||
address: u.address ?? null,
|
||||
store_name: u.storeName ?? null,
|
||||
pasazh_name: u.pasazhName ?? null,
|
||||
mcode: u.memberCode ?? null,
|
||||
image_id: u.avatarFileId ?? null,
|
||||
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
|
||||
// D17 — from SalesExpertProfile. Null for a non-expert, and null-safe for
|
||||
// D17 — from SalesExpertProfile. Null for an admin, and null-safe for
|
||||
// an expert whose profile row is somehow missing.
|
||||
city_id: u.salesExpertProfile?.cityId ?? null,
|
||||
city: u.salesExpertProfile?.city
|
||||
@@ -165,9 +195,23 @@ export class AdminUsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const u = await this.prisma.adminUser.findUnique({
|
||||
/** A panel admin by id, or null. The support super admin is never found. */
|
||||
private async findAdmin(id: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id },
|
||||
include: { userType: true, branch: true },
|
||||
});
|
||||
return admin && !isSuperAdmin(admin.mobile) ? admin : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sales expert by id, or null. `role` is part of the lookup on purpose:
|
||||
* "AdminUser" still holds the pre-split copies of the admins (dormant), and
|
||||
* those must never be reachable as experts either.
|
||||
*/
|
||||
private async findExpert(id: string) {
|
||||
const expert = await this.prisma.adminUser.findFirst({
|
||||
where: { id, role: AdminRole.SALES_EXPERT },
|
||||
// D17 — the edit form needs the sales-expert fields too.
|
||||
include: {
|
||||
userType: true,
|
||||
@@ -176,8 +220,17 @@ export class AdminUsersService {
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
return this.toRow(u);
|
||||
return expert && !isSuperAdmin(expert.mobile) ? expert : null;
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const admin = await this.findAdmin(id);
|
||||
if (admin) return this.toRow(admin);
|
||||
|
||||
const expert = await this.findExpert(id);
|
||||
if (expert) return this.toRow(expert);
|
||||
|
||||
throw new NotFoundException('کاربر یافت نشد');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,6 +240,8 @@ export class AdminUsersService {
|
||||
* caller omitted it, which silently turned every admin created without an
|
||||
* explicit role into a sales agent regardless of the access level ("نوع
|
||||
* کاربری") actually chosen. UserCreate.vue now always sends one.
|
||||
*
|
||||
* SALES_EXPERT goes to the `AdminUser` table, every other role to `Admin`.
|
||||
*/
|
||||
async create(data: Record<string, unknown>) {
|
||||
// D0 requirement 7 — store the real name parts, as Customer does. `name`
|
||||
@@ -206,7 +261,43 @@ export class AdminUsersService {
|
||||
if (!mobile) throw new BadRequestException('موبایل الزامی است');
|
||||
if (!password) throw new BadRequestException('رمز عبور الزامی است');
|
||||
if (!role) throw new BadRequestException('نقش کاربر الزامی است');
|
||||
if (!Object.values(AdminRole).includes(role)) throw new BadRequestException('نقش کاربر معتبر نیست');
|
||||
|
||||
if (role === AdminRole.SALES_EXPERT) {
|
||||
return this.createExpert({ data, firstName, lastName, fullName, mobile, password });
|
||||
}
|
||||
|
||||
// Unique within `Admin` only: the same number may also be a sales expert.
|
||||
const existing = await this.prisma.admin.findUnique({ where: { mobile } });
|
||||
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
|
||||
return this.prisma.admin.create({
|
||||
data: {
|
||||
fullName,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
role,
|
||||
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
|
||||
branchId: (data.branch_id as string) || null,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
},
|
||||
include: { userType: true, branch: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async createExpert(input: {
|
||||
data: Record<string, unknown>;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
password: string;
|
||||
}) {
|
||||
const { data, firstName, lastName, fullName, mobile, password } = input;
|
||||
|
||||
// Unique within the expert table only: the same number may also be an admin.
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
|
||||
@@ -217,7 +308,7 @@ export class AdminUsersService {
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
role,
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
|
||||
branchId: (data.branch_id as string) || null,
|
||||
wagePercent:
|
||||
@@ -225,8 +316,7 @@ export class AdminUsersService {
|
||||
? new Prisma.Decimal(String(data.percent))
|
||||
: null,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
// Sales-agent-only fields (ReagentCustomer.vue); undefined for every
|
||||
// other role, which Prisma leaves at column default (null).
|
||||
// Sales-agent-only fields (ReagentCustomer.vue).
|
||||
address: (data.address as string) || null,
|
||||
storeName: (data.store_name as string) || null,
|
||||
pasazhName: (data.pasazh_name as string) || null,
|
||||
@@ -234,9 +324,7 @@ export class AdminUsersService {
|
||||
avatarFileId: (data.image_id as string) || null,
|
||||
// D17 — a sales expert always gets its profile row, so the grid's city
|
||||
// and credit-limit columns have somewhere to read from immediately.
|
||||
...(role === 'SALES_EXPERT'
|
||||
? { salesExpertProfile: { create: this.salesExpertProfileData(data) } }
|
||||
: {}),
|
||||
salesExpertProfile: { create: this.salesExpertProfileData(data) },
|
||||
},
|
||||
include: {
|
||||
userType: true,
|
||||
@@ -268,13 +356,24 @@ export class AdminUsersService {
|
||||
|
||||
/** Legacy `PUT users/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
|
||||
if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
const admin = await this.findAdmin(id);
|
||||
if (admin) return this.updateAdmin(admin, data);
|
||||
|
||||
const patch: Prisma.AdminUserUpdateInput = {};
|
||||
const expert = await this.findExpert(id);
|
||||
if (expert) return this.updateExpert(expert, data);
|
||||
|
||||
throw new NotFoundException('کاربر یافت نشد');
|
||||
}
|
||||
|
||||
private async updateAdmin(
|
||||
existing: NonNullable<Awaited<ReturnType<AdminUsersService['findAdmin']>>>,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
const id = existing.id;
|
||||
const patch: Prisma.AdminUpdateInput = {};
|
||||
|
||||
// D0 requirement 7 — write the parts and let the
|
||||
// admin_user_sync_full_name trigger rebuild `fullName`. When a caller sends
|
||||
// admin_sync_full_name trigger rebuild `fullName`. When a caller sends
|
||||
// both, the parts win (see the migration header).
|
||||
if (data.fname !== undefined || data.lname !== undefined) {
|
||||
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
|
||||
@@ -290,14 +389,82 @@ export class AdminUsersService {
|
||||
// Normalised before the clash check, for the same reason as create().
|
||||
const mobile = faToEn(String(data.mobile));
|
||||
if (mobile !== existing.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
const clash = await this.prisma.admin.findUnique({ where: { mobile } });
|
||||
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
patch.mobile = mobile;
|
||||
}
|
||||
// A blank password field means "leave it unchanged" — legacy did the same.
|
||||
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||
if (data.role !== undefined) patch.role = data.role as AdminRole;
|
||||
if (data.role !== undefined) {
|
||||
const role = data.role as AdminRole;
|
||||
if (!Object.values(AdminRole).includes(role)) throw new BadRequestException('نقش کاربر معتبر نیست');
|
||||
// An admin cannot be turned into an expert: they live in different tables.
|
||||
// Create the expert from the reagent form instead.
|
||||
if (role === AdminRole.SALES_EXPERT) {
|
||||
throw new BadRequestException('نقش کارشناس فروش برای ادمین قابل تنظیم نیست؛ کارشناس را از بخش کارشناسان بسازید');
|
||||
}
|
||||
patch.role = role;
|
||||
}
|
||||
if (data.ut !== undefined) {
|
||||
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||
}
|
||||
if (data.branch_id !== undefined) {
|
||||
patch.branch = data.branch_id
|
||||
? { connect: { id: String(data.branch_id) } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
|
||||
|
||||
const updated = await this.prisma.admin.update({
|
||||
where: { id },
|
||||
data: patch,
|
||||
include: { userType: true, branch: true },
|
||||
});
|
||||
|
||||
// Disabling an account or changing its password used to leave every already
|
||||
// issued token working for up to 12h. With session tracking on, those tokens
|
||||
// end here; with it off, nothing happens and the behaviour is as before.
|
||||
if (patch.isActive === false || patch.passwordHash !== undefined) {
|
||||
await this.sessions.revokeAllForAdmin(
|
||||
id,
|
||||
patch.isActive === false ? 'account_disabled' : 'password_changed',
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async updateExpert(
|
||||
existing: NonNullable<Awaited<ReturnType<AdminUsersService['findExpert']>>>,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
const id = existing.id;
|
||||
const patch: Prisma.AdminUserUpdateInput = {};
|
||||
|
||||
if (data.fname !== undefined || data.lname !== undefined) {
|
||||
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
|
||||
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
|
||||
patch.firstName = firstName || null;
|
||||
patch.lastName = lastName || null;
|
||||
} else if (data.name !== undefined) {
|
||||
patch.fullName = String(data.name);
|
||||
}
|
||||
|
||||
if (data.mobile !== undefined) {
|
||||
const mobile = faToEn(String(data.mobile));
|
||||
if (mobile !== existing.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
patch.mobile = mobile;
|
||||
}
|
||||
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||
// An expert stays an expert: moving them to another role would have to move
|
||||
// them to the other table.
|
||||
if (data.role !== undefined && data.role !== AdminRole.SALES_EXPERT) {
|
||||
throw new BadRequestException('نقش کارشناس فروش قابل تغییر نیست');
|
||||
}
|
||||
if (data.ut !== undefined) {
|
||||
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||
}
|
||||
@@ -322,22 +489,18 @@ export class AdminUsersService {
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
// D17 — the profile follows the role.
|
||||
// D17 — the profile follows the expert.
|
||||
//
|
||||
// `upsert` covers all three cases in one statement: a user becoming a sales
|
||||
// expert gets a profile; an existing expert's profile is updated; and an
|
||||
// expert whose profile went missing gets one back rather than erroring.
|
||||
// A profile is never deleted when the role changes away — that would
|
||||
// silently discard the operator's city and credit limit on a mistaken role
|
||||
// switch, and it costs nothing to keep.
|
||||
const effectiveRole = (patch.role as AdminRole | undefined) ?? existing.role;
|
||||
// `upsert` covers both cases in one statement: an existing expert's profile
|
||||
// is updated, and an expert whose profile went missing gets one back rather
|
||||
// than erroring.
|
||||
const touchesProfile =
|
||||
data.city_id !== undefined ||
|
||||
data.gram_level !== undefined ||
|
||||
data.phone !== undefined ||
|
||||
data.ncode !== undefined;
|
||||
|
||||
if (effectiveRole === 'SALES_EXPERT' && (touchesProfile || patch.role !== undefined)) {
|
||||
if (touchesProfile) {
|
||||
const profile = this.salesExpertProfileData(data);
|
||||
patch.salesExpertProfile = {
|
||||
upsert: {
|
||||
@@ -354,7 +517,9 @@ export class AdminUsersService {
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await this.prisma.adminUser.update({
|
||||
// Experts sign in to the apps, whose sessions are not admin-panel sessions,
|
||||
// so there is nothing to revoke here.
|
||||
return this.prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: patch,
|
||||
include: {
|
||||
@@ -364,18 +529,6 @@ export class AdminUsersService {
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Disabling an account or changing its password used to leave every already
|
||||
// issued token working for up to 12h. With session tracking on, those tokens
|
||||
// end here; with it off, nothing happens and the behaviour is as before.
|
||||
if (patch.isActive === false || patch.passwordHash !== undefined) {
|
||||
await this.sessions.revokeAllForAdmin(
|
||||
id,
|
||||
patch.isActive === false ? 'account_disabled' : 'password_changed',
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,24 +539,35 @@ export class AdminUsersService {
|
||||
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
|
||||
*/
|
||||
async remove(id: string, actorId: string) {
|
||||
const u = await this.prisma.adminUser.findUnique({
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
|
||||
},
|
||||
});
|
||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
if (id === actorId) {
|
||||
throw new BadRequestException('نمیتوانید حساب کاربری خودتان را حذف کنید');
|
||||
if (admin) {
|
||||
if (isSuperAdmin(admin.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
if (id === actorId) {
|
||||
throw new BadRequestException('نمیتوانید حساب کاربری خودتان را حذف کنید');
|
||||
}
|
||||
|
||||
const refs = admin._count.postedLedgerEntries + admin._count.orderStatusEvents;
|
||||
if (refs > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. میتوانید غیرفعالش کنید.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.admin.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
|
||||
if (refs > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. میتوانید غیرفعالش کنید.`,
|
||||
);
|
||||
}
|
||||
// A sales expert. Their orders / referred customers are set null by the
|
||||
// foreign keys, as before the split.
|
||||
const expert = await this.findExpert(id);
|
||||
if (!expert) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
await this.prisma.adminUser.delete({ where: { id } });
|
||||
return { success: true };
|
||||
@@ -415,13 +579,13 @@ export class AdminUsersService {
|
||||
async listUserTypes() {
|
||||
const rows = await this.prisma.userType.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
include: { _count: { select: { adminUsers: true, permissions: true } } },
|
||||
include: { _count: { select: { admins: true, adminUsers: true, permissions: true } } },
|
||||
});
|
||||
// legacy returned {id, title} pairs; the counts are additive, not breaking.
|
||||
return rows.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
users_count: t._count.adminUsers,
|
||||
users_count: t._count.admins + t._count.adminUsers,
|
||||
permissions_count: t._count.permissions,
|
||||
}));
|
||||
}
|
||||
@@ -505,15 +669,19 @@ export class AdminUsersService {
|
||||
|
||||
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
|
||||
async myData(adminId: string) {
|
||||
return this.findOne(adminId);
|
||||
const admin = await this.findAdmin(adminId);
|
||||
if (!admin) throw new NotFoundException('کاربر یافت نشد');
|
||||
return this.toRow(admin);
|
||||
}
|
||||
|
||||
/** The AdminRole picker for the user form. */
|
||||
/**
|
||||
* The AdminRole picker for the staff form. SALES_EXPERT is not offered: an
|
||||
* expert is a different table and is created from the reagent form.
|
||||
*/
|
||||
roleOptions() {
|
||||
return [
|
||||
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
|
||||
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
|
||||
{ value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' },
|
||||
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
|
||||
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
|
||||
];
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('AuthService login', () => {
|
||||
sessions: Partial<AdminSessionService> = untrackedSessions(),
|
||||
) => {
|
||||
const prisma = {
|
||||
adminUser: { findUnique: jest.fn().mockResolvedValue(user) },
|
||||
admin: { findUnique: jest.fn().mockResolvedValue(user) },
|
||||
};
|
||||
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('AuthService login', () => {
|
||||
// Without the IAM session id, a back-channel logout could not find this session
|
||||
expect(tracked.record).toHaveBeenCalledWith({
|
||||
jti: 'jti-1',
|
||||
adminUserId: 'admin-1',
|
||||
adminId: 'admin-1',
|
||||
iamSessionId: 'sid-1',
|
||||
iamSub: 'sub-1',
|
||||
ipAddress: '203.0.113.5',
|
||||
|
||||
@@ -53,7 +53,7 @@ export class AuthService {
|
||||
await this.ensureStaticAdmin();
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
const user = await this.prisma.admin.findUnique({
|
||||
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
@@ -83,7 +83,7 @@ export class AuthService {
|
||||
// this token before it expires — see AdminSessionService.
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
adminId: user.id,
|
||||
iamSessionId: verified.identity?.sessionId ?? null,
|
||||
iamSub: verified.identity?.sub ?? null,
|
||||
ipAddress: clientIp ?? null,
|
||||
@@ -153,7 +153,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findFirst({
|
||||
const user = await this.prisma.admin.findFirst({
|
||||
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
@@ -211,7 +211,7 @@ export class AuthService {
|
||||
if (jti) {
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
adminId: user.id,
|
||||
iamSessionId: identity?.sessionId ?? null,
|
||||
iamSub: identity?.sub ?? null,
|
||||
ipAddress: ip ?? null,
|
||||
@@ -238,7 +238,7 @@ export class AuthService {
|
||||
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
|
||||
const userTypeId = fullAccess ? 85 : null;
|
||||
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
|
||||
await this.prisma.adminUser.upsert({
|
||||
await this.prisma.admin.upsert({
|
||||
where: { mobile: STATIC_ADMIN_MOBILE },
|
||||
update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
|
||||
create: {
|
||||
@@ -334,7 +334,7 @@ export class AuthService {
|
||||
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
||||
*/
|
||||
async showData(adminId: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
const user = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
const user = await this.prisma.admin.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
@@ -332,7 +332,7 @@ export class OrdersService {
|
||||
});
|
||||
|
||||
await tx.orderStatusEvent.create({
|
||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId },
|
||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId },
|
||||
});
|
||||
|
||||
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('AdminSessionService', () => {
|
||||
const { service, prisma } = build();
|
||||
|
||||
expect(service.newTokenId()).toBeNull();
|
||||
await service.record({ jti: 'x', adminUserId: 'a' });
|
||||
await service.record({ jti: 'x', adminId: 'a' });
|
||||
|
||||
expect(prisma.adminSession.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -110,10 +110,10 @@ describe('AdminSessionService', () => {
|
||||
const { service, prisma } = build({ iamEnabled: true });
|
||||
process.env.JWT_EXPIRES_IN = '12h';
|
||||
|
||||
await service.record({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
|
||||
await service.record({ jti: 'jti-1', adminId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
|
||||
|
||||
const data = prisma.adminSession.create.mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1' });
|
||||
expect(data).toMatchObject({ jti: 'jti-1', adminId: 'admin-1', iamSessionId: 'sid-1' });
|
||||
expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000);
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('AdminSessionService', () => {
|
||||
prisma.adminSession.create.mockRejectedValue(new Error('unique violation'));
|
||||
|
||||
// Worst case is a session that cannot be revoked early — not a failed login
|
||||
await expect(service.record({ jti: 'jti-1', adminUserId: 'a' })).resolves.toBeUndefined();
|
||||
await expect(service.record({ jti: 'jti-1', adminId: 'a' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('revokes by the identity service session id', async () => {
|
||||
@@ -150,7 +150,7 @@ describe('AdminSessionService', () => {
|
||||
|
||||
await service.revokeAllForAdmin('admin-1', 'account_disabled');
|
||||
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
||||
where: { adminUserId: 'admin-1', revokedAt: null },
|
||||
where: { adminId: 'admin-1', revokedAt: null },
|
||||
data: expect.objectContaining({ revokedReason: 'account_disabled' }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export class AdminSessionService {
|
||||
*/
|
||||
async record(input: {
|
||||
jti: string;
|
||||
adminUserId: string;
|
||||
adminId: string;
|
||||
iamSessionId?: string | null;
|
||||
iamSub?: string | null;
|
||||
ipAddress?: string | null;
|
||||
@@ -60,7 +60,7 @@ export class AdminSessionService {
|
||||
await this.prisma.adminSession.create({
|
||||
data: {
|
||||
jti: input.jti,
|
||||
adminUserId: input.adminUserId,
|
||||
adminId: input.adminId,
|
||||
iamSessionId: input.iamSessionId ?? null,
|
||||
iamSub: input.iamSub ?? null,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
@@ -103,12 +103,12 @@ export class AdminSessionService {
|
||||
}
|
||||
|
||||
/** Revoke every session of a local admin (account disabled, password changed). */
|
||||
async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise<number> {
|
||||
return this.revokeWhere({ adminUserId }, reason);
|
||||
async revokeAllForAdmin(adminId: string, reason = 'revoked'): Promise<number> {
|
||||
return this.revokeWhere({ adminId }, reason);
|
||||
}
|
||||
|
||||
private async revokeWhere(
|
||||
where: { iamSessionId?: string; iamSub?: string; adminUserId?: string },
|
||||
where: { iamSessionId?: string; iamSub?: string; adminId?: string },
|
||||
reason: string,
|
||||
): Promise<number> {
|
||||
if (!this.enabled) return 0;
|
||||
|
||||
Reference in New Issue
Block a user