mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
feat: scope notification to permission actions
This commit is contained in:
@@ -46,7 +46,8 @@ export class BackofficeService {
|
||||
|
||||
/**
|
||||
* IAM user ids of every current employee across all organizations — used by
|
||||
* the notification recipients resolver's `allBackoffice` selector.
|
||||
* support chat for staff room membership. Notifications deliberately do NOT
|
||||
* use this: they target a desk via `getEmployeeUserIdsByPermission`.
|
||||
*/
|
||||
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
||||
const employees = await this.employeeRepository.find({
|
||||
@@ -63,24 +64,67 @@ export class BackofficeService {
|
||||
* IAM user ids of current employees (any org) holding ANY of the given
|
||||
* permission keys — used by the notification recipients resolver's
|
||||
* `permissionKeys` selector for department/role-scoped targeting.
|
||||
*
|
||||
* This MUST agree with the request-time guard (`hasFreightPermission`,
|
||||
* common/freight-permission.util.ts), which counts four grant carriers plus
|
||||
* the super_admin bypass. Counting fewer silently drops legitimate
|
||||
* recipients: an earlier version joined only direct position permissions, on
|
||||
* which `bookings:view` resolved to 2 users — against 21 through position
|
||||
* TYPES, which is where admin-created positions actually keep their grants.
|
||||
*
|
||||
* Raw SQL rather than QueryBuilder because `Position.positionTypePermissions`
|
||||
* declares its inverse side against PositionType, so a relation join emits
|
||||
* `ptp.position_type_id = position.id` and silently matches nothing. Same
|
||||
* approach as FreightMeService's position-type lookups.
|
||||
*/
|
||||
async getEmployeeUserIdsByPermission(
|
||||
permissionKeys: string[],
|
||||
): Promise<string[]> {
|
||||
if (!permissionKeys.length) return [];
|
||||
const rows: { userId: string | null }[] = await this.employeeRepository
|
||||
.createQueryBuilder("employee")
|
||||
.innerJoin("employee.employeePositions", "employeePosition")
|
||||
.innerJoin("employeePosition.position", "position")
|
||||
.innerJoin("position.positionPermission", "positionPermission")
|
||||
.innerJoin("positionPermission.permission", "permission")
|
||||
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
|
||||
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
|
||||
.select("DISTINCT employee.user_id", "userId")
|
||||
.getRawMany();
|
||||
return rows
|
||||
.map((r) => r.userId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const rows: { userId: string }[] = await this.dataSource.query(
|
||||
`WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
|
||||
-- 1. IAM role grants (user_roles -> role_permissions).
|
||||
SELECT e.user_id AS "userId"
|
||||
FROM iam.employees e
|
||||
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||
JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND rp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 2. Direct position grants. A delegate keeps their own position AND
|
||||
-- gains the one they stand in for, so both columns count.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.employee_positions ep
|
||||
ON ep.employee_id = e.id AND ep.is_current
|
||||
JOIN iam.position_permissions pp
|
||||
ON pp.position_id IN (ep.position_id, ep.delegatee_position_id)
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND pp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 3. Position TYPE grants — where admin-created positions keep theirs.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.employee_positions ep
|
||||
ON ep.employee_id = e.id AND ep.is_current
|
||||
JOIN iam.positions p
|
||||
ON p.id IN (ep.position_id, ep.delegatee_position_id)
|
||||
JOIN iam.position_type_permissions ptp
|
||||
ON ptp.position_type_id = p.position_type_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND ptp.permission_id IN (SELECT id FROM target)
|
||||
UNION
|
||||
-- 4. super_admin passes every freight permission check, so mirror that
|
||||
-- here or admins go blind on desks nobody else has been granted yet.
|
||||
SELECT e.user_id
|
||||
FROM iam.employees e
|
||||
JOIN iam.user_roles ur ON ur.user_id = e.user_id
|
||||
JOIN iam.roles r ON r.id = ur.role_id
|
||||
WHERE e.is_current AND e.user_id IS NOT NULL
|
||||
AND r.key = 'super_admin'`,
|
||||
[permissionKeys],
|
||||
);
|
||||
return rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
async createOrganizationUser(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* Who hears "Operations wants changes" depends on who owns the booking. A
|
||||
@@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Staff notifications used to go to every employee in every organization. They
|
||||
* now target a desk — and the two desks are disjoint: the GL presets hold no
|
||||
* bookings:view and no intake keys, so intake pings would be noise they cannot
|
||||
* act on. Both branches run through the same `inAppStaff` helper, which is the
|
||||
* easy place to lose the distinction again.
|
||||
*/
|
||||
describe('BookingLifecycleNotifierService — staff desk targeting', () => {
|
||||
const booking = () =>
|
||||
({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking;
|
||||
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: BookingLifecycleNotifierService;
|
||||
|
||||
beforeEach(() => {
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new BookingLifecycleNotifierService(
|
||||
{ directSend: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
inbox as never,
|
||||
{ query: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes intake items to the booking desk and clearance items to the clearance desk', () => {
|
||||
service.submittedToStaff(booking());
|
||||
service.clearanceDocsUploadedToStaff(booking());
|
||||
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
|
||||
permissionKeys: [FREIGHT_PERMS.bookings.getNotification],
|
||||
});
|
||||
expect(inbox.notify.mock.calls[1][0].recipients).toEqual({
|
||||
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* Clearance items are worked by the GL desks, which hold no bookings:view and
|
||||
* no intake keys — so they take their own selector rather than the booking
|
||||
* desk's. Every override using this deep-links to a clearance page.
|
||||
*/
|
||||
const CLEARANCE_DESK = {
|
||||
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||
@@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
/**
|
||||
* Persist + push an in-app item to the booking desk — staff holding
|
||||
* `bookings:get_notification`. Callers whose item belongs to a different desk
|
||||
* override `recipients` (see {@link CLEARANCE_DESK}).
|
||||
*/
|
||||
private inAppStaff(
|
||||
b: Booking,
|
||||
title: string,
|
||||
@@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService {
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
@@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService {
|
||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
|
||||
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
|
||||
});
|
||||
@@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService {
|
||||
`The customs declaration can now be filed.`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
|
||||
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
});
|
||||
@@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService {
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
},
|
||||
@@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService {
|
||||
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
|
||||
`"${note}". Send a corrected draft from the clearance page.`;
|
||||
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
});
|
||||
@@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService {
|
||||
'Payment slip uploaded',
|
||||
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
},
|
||||
|
||||
@@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService {
|
||||
|
||||
private notifyStaff(booking: Booking, title: string, body: string): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: `/bookings/${booking.id}`,
|
||||
// The portal path `/bookings/:id` used to be sent here, which 404s in the
|
||||
// dashboard. The staff view of these lives on the queue page.
|
||||
link: '/dashboard/wagon-cancellations',
|
||||
data: { bookingId: booking.id, reference: booking.reference },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
|
||||
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||
@@ -175,12 +176,9 @@ export class CompanyNotifierService {
|
||||
// ── Backoffice-facing: work has arrived back in the review queue ────────────
|
||||
|
||||
/**
|
||||
* Persist + push an in-app item to every backoffice staff user, deep-linked to
|
||||
* the customer's detail page.
|
||||
*
|
||||
* The recipient resolver has no role/permission targeting (see
|
||||
* `notification-recipients.service.ts`) — `allBackoffice` is the narrowest
|
||||
* selector available, so marketing is reached by notifying all staff.
|
||||
* Persist + push an in-app item to the customer desk — staff holding
|
||||
* `customers:get_notification` — deep-linked to the customer's detail page,
|
||||
* which is itself gated on `customers:view`.
|
||||
*/
|
||||
private notifyStaff(
|
||||
company: Company,
|
||||
@@ -189,7 +187,7 @@ export class CompanyNotifierService {
|
||||
data: Record<string, unknown> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ContractExpiryService } from './contract-expiry.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* The reminder must warn each customer once, ten days out, and must never let a
|
||||
@@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => {
|
||||
inbox.notify.mockRejectedValue(new Error('inbox down'));
|
||||
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// The sweep-failure alert is staff-facing. It used to go to every employee;
|
||||
// it belongs to the people who would notice expired contracts still listed
|
||||
// as active, i.e. the contract desk.
|
||||
it('alerts the contract desk when the sweep itself fails', async () => {
|
||||
repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock'));
|
||||
|
||||
await service.expireLapsedContracts();
|
||||
|
||||
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
|
||||
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* How many days before a contract lapses the customer is reminded. Mirrored by
|
||||
@@ -79,7 +80,11 @@ export class ContractExpiryService {
|
||||
);
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
// The people who would notice expired contracts still listed as
|
||||
// active are the ones working the contract desk.
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.contracts.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title: 'Contract expiry sweep failed',
|
||||
|
||||
@@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* Clearance items are worked by the GL desks, which hold no intake keys — so
|
||||
* they take their own selector rather than the contract desk's. Every override
|
||||
* using this deep-links to a clearance or shipment-request page.
|
||||
*/
|
||||
const CLEARANCE_DESK = {
|
||||
permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification],
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
@@ -86,7 +96,11 @@ export class ContractNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
/**
|
||||
* Persist + push an in-app item to the contract desk — staff holding
|
||||
* `contracts:get_notification`. Callers whose item belongs to a different
|
||||
* desk override `recipients` (see {@link CLEARANCE_DESK}).
|
||||
*/
|
||||
private inAppStaff(
|
||||
c: Contract,
|
||||
title: string,
|
||||
@@ -94,7 +108,7 @@ export class ContractNotifierService {
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
@@ -230,6 +244,7 @@ export class ContractNotifierService {
|
||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
|
||||
});
|
||||
@@ -248,6 +263,7 @@ export class ContractNotifierService {
|
||||
`The customs declaration can now be filed.`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
});
|
||||
@@ -264,6 +280,7 @@ export class ContractNotifierService {
|
||||
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
});
|
||||
@@ -321,6 +338,7 @@ export class ContractNotifierService {
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
@@ -334,6 +352,7 @@ export class ContractNotifierService {
|
||||
'Duty slip uploaded',
|
||||
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
||||
{
|
||||
recipients: CLEARANCE_DESK,
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
@@ -347,6 +366,9 @@ export class ContractNotifierService {
|
||||
'New shipment request',
|
||||
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
||||
{
|
||||
// GL reviews these, and the shipment-requests page is gated on
|
||||
// contracts:create_booking — a key only the GL Ethiopia preset holds.
|
||||
recipients: CLEARANCE_DESK,
|
||||
link: `/dashboard/shipment-requests/${requestId}`,
|
||||
data: { contractId: c.id, requestId, reference: requestRef },
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NotificationAudience } from '@edr/types';
|
||||
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
|
||||
@@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => {
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
// The fleet desk, not every employee in the company.
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
|
||||
},
|
||||
title: 'Maintenance due — ET-9875',
|
||||
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceService {
|
||||
@@ -53,7 +54,9 @@ export class MaintenanceService {
|
||||
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
||||
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title: `Maintenance due — ${item.plateNumber}`,
|
||||
|
||||
@@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
|
||||
* - `companyProfileId` → resolved to its company, then to that company's users.
|
||||
* - `organizationId` → all current employees of the org (backoffice staff).
|
||||
* - `permissionKeys` → current employees (any org) holding any of these
|
||||
* permission keys (e.g. department/role-scoped targeting).
|
||||
* permission keys — how every staff-facing notification is targeted. There is
|
||||
* deliberately no "all backoffice" selector: staff notifications belong to a
|
||||
* desk, and the `<module>:get_notification` keys name which one.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationRecipientsService {
|
||||
@@ -69,18 +71,6 @@ export class NotificationRecipientsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.allBackoffice) {
|
||||
try {
|
||||
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
|
||||
ids.add(uid);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.permissionKeys?.length) {
|
||||
try {
|
||||
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
PriorityRuleChangeStatus,
|
||||
} from '../entities/priority-rule-change-request.entity';
|
||||
import { PriorityConfigsService } from './priority-configs.service';
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
|
||||
/** Backoffice rule-engine page — where both queue and rules live. */
|
||||
const RULES_LINK = '/dashboard/rules/priority-configs';
|
||||
@@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService {
|
||||
): void {
|
||||
void this.inbox
|
||||
.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../entities/rate-change-request.entity';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { RatesService } from './rates.service';
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
|
||||
/** Backoffice page where both the queue and the rates live. */
|
||||
const RATES_LINK = '/dashboard/rules/rates';
|
||||
@@ -234,7 +235,9 @@ export class RateChangeRequestsService {
|
||||
private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
|
||||
void this.inbox
|
||||
.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: {
|
||||
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
interface ItemAttributes {
|
||||
arrivedAt: Date | null;
|
||||
@@ -178,7 +179,11 @@ export class WarehouseFeeService {
|
||||
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
recipients: {
|
||||
permissionKeys: [
|
||||
FREIGHT_PERMS.warehouseFeeInvoices.getNotification,
|
||||
],
|
||||
},
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Warehouse fee accruals need attention',
|
||||
|
||||
Reference in New Issue
Block a user