apps/recallassess/recallassess-api/src/api/admin/contact-enquiry/services/contact-enquiry.service.ts
BNestBaseModuleService
Properties |
|
Methods |
|
constructor(prismaService: BNestPrismaService, gVarsService: BNestGlobalVarsService)
|
|||||||||
|
Parameters :
|
| Async getDetail | ||||||
getDetail(id: number)
|
||||||
|
Override the generic getDetail() to enrich the response with the joined "First Read By" user's full name. Without this override the admin UI would show a raw user ID (e.g. "42") in the detail view; with it, the UI shows "Hameed Muhammad" — much friendlier for non-developer admins. Implementation note: we don't restructure the base class's Prisma query.
Instead we let super.getDetail() do its normal work, then make a small
follow-up
Parameters :
|
| Async markReadOnFirstView | ||||||
markReadOnFirstView(enquiryId: number)
|
||||||
|
Atomically mark an enquiry as read by the current admin user, but only if it has never been read before. Subsequent calls are no-ops — we want to capture FIRST read for accountability, not LAST read. Called by the controller's getDetail() override every time an admin opens
the detail page. Designed so any race between concurrent admins resolves
deterministically: the first one to flip Failures are logged but do NOT throw — the admin viewing the enquiry is the primary use case; tracking who-read-first is secondary, and a stamp failure shouldn't surface as a UI error.
Parameters :
Returns :
Promise<void>
|
| Private Readonly enquiryLogger |
Type : unknown
|
Default value : new Logger(ContactEnquiryService.name)
|
import { BNestBaseModuleService } from "@bish-nest/core/data/module-service/base-module.service";
import { DetailResponseDataInterface } from "@bish-nest/core/interfaces/detail-response-data.interface";
import { BNestPrismaService } from "@bish-nest/core/services/database/prisma/prisma.service";
import { BNestGlobalVarsService } from "@bish-nest/core/services/global-vars.service";
import { Injectable, Logger } from "@nestjs/common";
@Injectable()
export class ContactEnquiryService extends BNestBaseModuleService {
private readonly enquiryLogger = new Logger(ContactEnquiryService.name);
constructor(
private readonly prismaService: BNestPrismaService,
private readonly gVarsService: BNestGlobalVarsService,
) {
super();
}
/**
* Override the generic getDetail() to enrich the response with the joined
* "First Read By" user's full name. Without this override the admin UI
* would show a raw user ID (e.g. "42") in the detail view; with it, the
* UI shows "Hameed Muhammad" — much friendlier for non-developer admins.
*
* Implementation note: we don't restructure the base class's Prisma query.
* Instead we let super.getDetail() do its normal work, then make a small
* follow-up `user.findUnique` to resolve the name and merge it into the
* payload. One extra DB round-trip — fine for a low-traffic detail page,
* and avoids touching the framework's generic select-builder.
*/
override async getDetail(id: number): Promise<DetailResponseDataInterface<unknown>> {
const result = await super.getDetail(id);
const data = (result?.data ?? null) as
| { is_read_by_user_id?: number | null; user_name_read_by?: string | null }
| null;
if (data && data.is_read_by_user_id) {
try {
const prisma = this.prismaService.client as unknown as {
user: {
findUnique: (args: {
where: { id: number };
select: { first_name: true; last_name: true };
}) => Promise<{ first_name: string; last_name: string } | null>;
};
};
const user = await prisma.user.findUnique({
where: { id: data.is_read_by_user_id },
select: { first_name: true, last_name: true },
});
if (user) {
const fullName = `${user.first_name ?? ""} ${user.last_name ?? ""}`.trim();
data.user_name_read_by = fullName.length > 0 ? fullName : null;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.enquiryLogger.warn(
`Failed to resolve read-by user name for enquiry #${id}: ${msg}`,
);
// Leave user_name_read_by undefined; the UI will fall back to showing the ID.
}
}
return result;
}
/**
* Atomically mark an enquiry as read by the current admin user, but only
* if it has never been read before. Subsequent calls are no-ops — we want
* to capture FIRST read for accountability, not LAST read.
*
* Called by the controller's getDetail() override every time an admin opens
* the detail page. Designed so any race between concurrent admins resolves
* deterministically: the first one to flip `is_read` from false→true wins,
* and their user.id + timestamp stick.
*
* Failures are logged but do NOT throw — the admin viewing the enquiry is
* the primary use case; tracking who-read-first is secondary, and a stamp
* failure shouldn't surface as a UI error.
*/
async markReadOnFirstView(enquiryId: number): Promise<void> {
const userId = this.gVarsService.userLoggedIn?.id;
if (!userId) {
// No authenticated user — skip silently. This shouldn't normally happen
// on an admin route, but the JWT/guard pipeline is a separate concern.
return;
}
try {
const prisma = this.prismaService.client as unknown as {
contactEnquiry: {
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<{ count: number }>;
};
};
// updateMany with `is_read: false` in the WHERE clause makes this a
// single atomic SQL statement — concurrent calls cannot both flip the
// row, so "first read" is deterministic at the database level.
await prisma.contactEnquiry.updateMany({
where: {
id: enquiryId,
is_read: false,
},
data: {
is_read: true,
is_read_by_user_id: userId,
is_read_at: new Date(),
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.enquiryLogger.warn(
`Failed to mark enquiry #${enquiryId} as read for user #${userId}: ${msg}`,
);
// Intentionally do not rethrow — keep the detail fetch flow uninterrupted.
}
}
}