mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
fix issue
This commit is contained in:
@@ -60,6 +60,17 @@ export interface ContractDocumentDraft {
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/**
|
||||
* Mask a phone for display — keep the last 4 digits, star the rest
|
||||
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
||||
* code went without echoing the company's full registered number back to the UI.
|
||||
*/
|
||||
function maskPhone(phone: string): string {
|
||||
const trimmed = phone.trim();
|
||||
if (trimmed.length <= 4) return trimmed;
|
||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -784,6 +795,36 @@ export class ContractTransitionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
|
||||
* the same number {@link sign} verifies against. The client never picks the
|
||||
* number (that is the H12(b) trust property): it only asks us to send, and we
|
||||
* resolve the phone from the contract. Returns a masked hint so the UI can
|
||||
* say where the code went without exposing the full number.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
contractId: string,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<{ sentTo: string }> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// Same ownership gate as signing — only the owning company's customer may
|
||||
// trigger a code for this contract.
|
||||
await this.contractsService.assertCustomerCanAccessContract(
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to send the signing OTP to',
|
||||
);
|
||||
}
|
||||
await this.otpService.sendOtp({ phone: companyPhone });
|
||||
return { sentTo: maskPhone(companyPhone) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
async sign(
|
||||
contractId: string,
|
||||
|
||||
@@ -499,6 +499,19 @@ export class ContractsController {
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/send-signing-otp')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
|
||||
})
|
||||
sendSigningOtp(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.sendSigningOtp(id, { signerUserId: user?.id });
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
|
||||
@@ -1,18 +1,61 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OtpService } from './otp.service';
|
||||
import { OtpService, normalizeOtpTarget } from './otp.service';
|
||||
|
||||
describe('OtpService', () => {
|
||||
let service: OtpService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [OtpService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OtpService>(OtpService);
|
||||
describe('normalizeOtpTarget', () => {
|
||||
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
||||
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
||||
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
||||
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
it('maps local 07… mobile to +2517…', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('passes email targets through untouched', () => {
|
||||
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
|
||||
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtpService — send/verify agree across phone formats', () => {
|
||||
// In-memory fake keyed by the exact phone string the service stores under, so
|
||||
// the test proves normalisation makes send and verify collide on one key.
|
||||
function makeService() {
|
||||
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
|
||||
const repo = {
|
||||
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
|
||||
rows.get(t.email ?? t.phone!) ?? null,
|
||||
),
|
||||
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
|
||||
existing.otp = otp;
|
||||
}),
|
||||
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
|
||||
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
|
||||
}),
|
||||
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
|
||||
rows.delete(row.phone ?? row.email!);
|
||||
}),
|
||||
};
|
||||
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
|
||||
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new OtpService(repo as never, sms as never, email as never);
|
||||
return { service, rows };
|
||||
}
|
||||
|
||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ phone: '+251986680099' });
|
||||
const stored = [...rows.values()][0]!.otp;
|
||||
|
||||
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
|
||||
[...rows.values()][0]!.updatedAt = new Date();
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,28 @@ import { EmailClientService } from "../notifications/email-client.service";
|
||||
// reaches here.
|
||||
export type OtpTarget = { phone?: string; email?: string };
|
||||
|
||||
/**
|
||||
* Canonicalise a phone to E.164 so the code stored on send and the one looked
|
||||
* up on verify collide regardless of how the number was typed. Without this,
|
||||
* `+251986680099`, `251986680099` and `0986680099` are three different keys and
|
||||
* a code sent to one is invisible to the others — the send/verify halves must
|
||||
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
||||
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
||||
* Email targets pass through untouched.
|
||||
*/
|
||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||
if (target.email || !target.phone) return target;
|
||||
const raw = target.phone.trim();
|
||||
const digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return { phone: digits };
|
||||
const bare = digits.replace(/^0+/, '');
|
||||
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
|
||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||
// it looks like a full international number, else leave as typed.
|
||||
return { phone: digits.length >= 11 ? `+${digits}` : raw };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
logger = new Logger(OtpService.name);
|
||||
@@ -35,7 +57,10 @@ export class OtpService {
|
||||
// Send OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendOtp(target: OtpTarget) {
|
||||
async sendOtp(rawTarget: OtpTarget) {
|
||||
// Store under the canonical E.164 key so verify (which normalises the same
|
||||
// way) always finds this row regardless of how either side typed the number.
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
try {
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
@@ -99,7 +124,10 @@ export class OtpService {
|
||||
// Verify OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async verifyOtp(target: OtpTarget, otp: string) {
|
||||
async verifyOtp(rawTarget: OtpTarget, otp: string) {
|
||||
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
||||
// when verify is called with 09… (or any equivalent form).
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
// find the channel's row
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
@@ -173,10 +201,11 @@ export class OtpService {
|
||||
}
|
||||
|
||||
async verifyOtpForAction(
|
||||
target: OtpTarget,
|
||||
rawTarget: OtpTarget,
|
||||
otp: string,
|
||||
ttlMs: number = this.ACTION_OTP_TTL_MS,
|
||||
) {
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
import {
|
||||
DecidePriorityRuleChangeDto,
|
||||
SubmitPriorityRuleChangeDto,
|
||||
@@ -56,7 +57,9 @@ export class PriorityRuleChangeRequestsController {
|
||||
@Body() dto: DecidePriorityRuleChangeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.approve(id, user?.id, dto.decisionNote);
|
||||
// Super admins have full backoffice authority — they may approve a change
|
||||
// they submitted; everyone else is held to separation of duties.
|
||||
return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user));
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import {
|
||||
@@ -70,9 +72,11 @@ export class RatesController {
|
||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.approve(id, resolveAuthUserId(user));
|
||||
// Super admins have full backoffice authority — they may approve a rate
|
||||
// they proposed; everyone else is held to separation of duties.
|
||||
return this.service.approve(id, resolveAuthUserId(user), isSuperAdmin(user));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -80,13 +80,15 @@ export class PriorityRuleChangeRequestsService {
|
||||
id: string,
|
||||
userId?: string | null,
|
||||
decisionNote?: string,
|
||||
canSelfApprove = false,
|
||||
): Promise<PriorityRuleChangeRequest> {
|
||||
const request = await this.findPending(id);
|
||||
|
||||
// Separation of duties: the requester cannot approve their own change.
|
||||
// Separation of duties: the requester cannot approve their own change —
|
||||
// except super admins, who have full backoffice authority.
|
||||
// TODO: split approval into a distinct approver permission rather than
|
||||
// relying on this id check.
|
||||
if (userId && userId === request.requestedByUserId) {
|
||||
if (!canSelfApprove && userId && userId === request.requestedByUserId) {
|
||||
throw new ForbiddenException(
|
||||
'You cannot approve a change request you submitted',
|
||||
);
|
||||
|
||||
@@ -195,15 +195,16 @@ export class RatesService {
|
||||
}
|
||||
|
||||
/** CEO approves a rate — moves to LIVE. */
|
||||
async approve(id: string, approverUserId: string): Promise<Rate> {
|
||||
async approve(id: string, approverUserId: string, canSelfApprove = false): Promise<Rate> {
|
||||
const rate = await this.findById(id);
|
||||
if (rate.status !== 'PENDING_APPROVAL') {
|
||||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||||
}
|
||||
// Separation of duties: the proposer cannot approve their own rate.
|
||||
// TODO: split approval into a distinct CEO/approver permission — a proposer
|
||||
// who also holds the approve permission is still the wrong person to sign off.
|
||||
if (approverUserId === rate.proposedByStaffId) {
|
||||
// Separation of duties: the proposer cannot approve their own rate — except
|
||||
// super admins, who have full backoffice authority (propose + approve).
|
||||
// TODO: split approval into a distinct CEO/approver permission — a normal
|
||||
// proposer who also holds the approve permission is still the wrong signer.
|
||||
if (!canSelfApprove && approverUserId === rate.proposedByStaffId) {
|
||||
throw new ForbiddenException('You cannot approve a rate you proposed');
|
||||
}
|
||||
const updated = await this.repository.update(id, {
|
||||
|
||||
Reference in New Issue
Block a user