Skip to content
Closed
29 changes: 0 additions & 29 deletions phase-1-foundation.md

This file was deleted.

1 change: 1 addition & 0 deletions src/common/config/config-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export const ConfigKey = {
Database: {
Url: 'DATABASE_URL',
TransactionTimeoutMs: 'PRISMA_TX_TIMEOUT_MS',
},
Redis: {
Url: 'REDIS_URL',
Expand Down
1 change: 1 addition & 0 deletions src/common/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class EnvSchema {
@IsOptional() @IsInt() SEND_WINDOW?: number;
@IsOptional() @IsInt() BASE_BLOCK_TIME?: number;
@IsOptional() @IsInt() CORRIDOR_WIDTH?: number;
@IsOptional() @IsInt() PRISMA_TX_TIMEOUT_MS?: number;

@IsOptional() @IsString() AWS_REGION?: string;

Expand Down
22 changes: 22 additions & 0 deletions src/common/utils/phone.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { maskPhoneNumber } from './phone';

describe('maskPhoneNumber', () => {
describe('masking', () => {
it.each([
['+989121112233', '+98•••••233'],
['+98912111233', '+98•••••233'],
['09121112233', '091•••••233'],
])('should mask %p as %p', (input, expected) => {
expect(maskPhoneNumber(input)).toBe(expected);
});

it('should mask short numbers without a suffix', () => {
expect(maskPhoneNumber('+98912')).toBe('+98•••••');
});

it('should fully mask tiny or empty input', () => {
expect(maskPhoneNumber('+98')).toBe('•••••');
expect(maskPhoneNumber('')).toBe('•••••');
});
});
});
20 changes: 20 additions & 0 deletions src/common/utils/phone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Phone-number masking for public responses (Phase 4 Task 5, S-8).
*
* Keeps the country prefix and the last three digits so UIs can still
* display a recognizable hint without exposing PII:
* `+989121112233` → `+98•••••233`.
*/
const VISIBLE_PREFIX_LENGTH = 3;
const VISIBLE_SUFFIX_LENGTH = 3;
const MASK = '•••••';

export function maskPhoneNumber(phoneNumber: string): string {
if (!phoneNumber || phoneNumber.length <= VISIBLE_PREFIX_LENGTH) {
return MASK;
}
if (phoneNumber.length <= VISIBLE_PREFIX_LENGTH + VISIBLE_SUFFIX_LENGTH) {
return `${phoneNumber.slice(0, VISIBLE_PREFIX_LENGTH)}${MASK}`;
}
return `${phoneNumber.slice(0, VISIBLE_PREFIX_LENGTH)}${MASK}${phoneNumber.slice(-VISIBLE_SUFFIX_LENGTH)}`;
}
39 changes: 34 additions & 5 deletions src/modules/map/map.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,18 @@ import { MapService } from './map.service';
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '../../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { CityRepository } from '../prisma/repositories/city.repository';

describe('MapService', () => {
let service: MapService;
let httpService: DeepMockProxy<HttpService>;
let configService: DeepMockProxy<ConfigService>;
let prismaService: DeepMockProxy<PrismaClient>;
let cities: DeepMockProxy<CityRepository>;

beforeEach(async () => {
httpService = mockDeep<HttpService>();
configService = mockDeep<ConfigService>();
prismaService = mockDeep<PrismaClient>();
cities = mockDeep<CityRepository>();

configService.get.mockImplementation((key: string, defaultValue?: any) => {
const config = {
Expand All @@ -30,7 +29,7 @@ describe('MapService', () => {
MapService,
{ provide: HttpService, useValue: httpService },
{ provide: ConfigService, useValue: configService },
{ provide: PrismaService, useValue: prismaService },
{ provide: CityRepository, useValue: cities },
],
}).compile();

Expand All @@ -40,4 +39,34 @@ describe('MapService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});

describe('getIntermediateCitiesWithIds', () => {
it('should resolve cities through the repository and delegate', async () => {
const originCity = { id: 'origin', latitude: '35.6', longitude: '51.3' };
const destinationCity = {
id: 'dest',
latitude: '35.9',
longitude: '51.6',
};
cities.findCityOrThrow
.mockResolvedValueOnce(originCity as any)
.mockResolvedValueOnce(destinationCity as any);
const inner = jest
.spyOn(service as any, 'getIntermediateCities')
.mockResolvedValue([]);

const result = await service.getIntermediateCitiesWithIds(
'origin',
'dest',
);

expect(result).toEqual([]);
expect(cities.findCityOrThrow).toHaveBeenCalledWith('origin');
expect(cities.findCityOrThrow).toHaveBeenCalledWith('dest');
expect(inner).toHaveBeenCalledWith(
{ latitude: '35.6', longitude: '51.3' },
{ latitude: '35.9', longitude: '51.6' },
);
});
});
});
23 changes: 4 additions & 19 deletions src/modules/map/map.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { AxiosResponse } from 'axios';
import { CityDto } from './dto/city.dto';
import { formatPrismaError } from '../../common/utilities';
import { PrismaService } from '../prisma/prisma.service';
import { CityRepository } from '../prisma/repositories/city.repository';
import { CoordinatesQueryDto } from './coordinates-query.dto';

@Injectable()
Expand All @@ -29,7 +28,7 @@ export class MapService {
constructor(
private httpService: HttpService,
config: ConfigService,
private prisma: PrismaService,
private cities: CityRepository,
) {
this.mapApiKey = config.getOrThrow<string>('MAP_API_KEY');
this.mapApiUrl = config.getOrThrow<string>('MAP_API_URL');
Expand Down Expand Up @@ -112,23 +111,9 @@ export class MapService {
}

async getIntermediateCitiesWithIds(originId: string, destinationId: string) {
const originCity = await this.prisma.city
.findUniqueOrThrow({
where: { id: originId },
})
.catch((error: Error) => {
formatPrismaError(error);
throw error;
});
const originCity = await this.cities.findCityOrThrow(originId);

const destinationCity = await this.prisma.city
.findUniqueOrThrow({
where: { id: destinationId },
})
.catch((error: Error) => {
formatPrismaError(error);
throw error;
});
const destinationCity = await this.cities.findCityOrThrow(destinationId);

return this.getIntermediateCities(
{
Expand Down
65 changes: 65 additions & 0 deletions src/modules/notification/notification-messages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
getNotificationMessage,
NotificationMessages,
} from './notification-messages';

/**
* Placeholder substitution (Phase 4 Task 6).
*
* Locks the current template behavior: templates carry a `#` prefix
* (`#{packageCode}`) but substitution targets single-brace `{key}`, so the
* rendered message keeps the `#` (`#123`). Missing keys are left untouched.
*/
describe('getNotificationMessage', () => {
describe('substitution', () => {
it('should substitute package and trip codes keeping the hash prefix', () => {
expect(
getNotificationMessage(NotificationMessages.PackageCreated, {
packageCode: 123,
}),
).toBe(
'📦 بسته شما با کد #123 با موفقیت ایجاد شد. حال می‌توانید برای سفرهای متناسب درخواست بفرستید.',
);
});

it('should substitute every placeholder of a multi-key template', () => {
expect(
getNotificationMessage(NotificationMessages.TripRequestAccepted, {
tripCode: 7,
packageCode: 42,
}),
).toBe('✅ عالی! درخواست سفر #7 برای بسته #42 پذیرفته شد.');
});

it('should substitute single-brace placeholders without hash', () => {
expect(
getNotificationMessage(NotificationMessages.NewTransporterNote, {
transporterName: 'رضا',
packageCode: 5,
noteContent: 'سلام',
}),
).toBe('📝 سفیر رضا برای بسته #5 یک یادداشت جدید ارسال کرده: سلام.');
});
});

describe('missing context', () => {
it('should leave missing placeholders untouched', () => {
const message = getNotificationMessage(
NotificationMessages.TripRequestAccepted,
);

expect(message).toContain('{tripCode}');
expect(message).toContain('{packageCode}');
});

it('should substitute only the provided keys', () => {
const message = getNotificationMessage(
NotificationMessages.TripRequestAccepted,
{ tripCode: 7 },
);

expect(message).toContain('#7');
expect(message).toContain('{packageCode}');
});
});
});
Loading