Created
          November 3, 2025 14:17 
        
      - 
      
 - 
        
Save brandonbryant12/38af91ae41e42d72431789b1d403906b to your computer and use it in GitHub Desktop.  
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | Great—let’s switch to moment and update the rules exactly as you described. | |
| Updated Rules (with moment) | |
| • If neither startDate nor endDate is provided → window is the last N days ending today (inclusive), where N = defaultDays (default 30). | |
| • If only startDate is provided → endDate defaults to today (inclusive). | |
| • If only endDate is provided → startDate falls back to endDate - (defaultDays - 1) (inclusive window), with defaultDays coming from the factory options (defaults to 30). | |
| • If both are provided → validate and normalize. | |
| • Normalized output is UTC YYYY-MM-DD (date-only). | |
| • All invalid inputs throw BadRequestException → maps to 400, never 500. | |
| Install | |
| npm i moment @types/moment | |
| We use import * as moment from 'moment' so you don’t need esModuleInterop. | |
| ⸻ | |
| File tree | |
| src/ | |
| common/date-range/ | |
| date-range.dto.ts | |
| date-range.types.ts | |
| date-range.util.ts | |
| date-range.factory.ts | |
| date-range.pipe.ts | |
| index.ts | |
| reports/ | |
| reports.controller.ts | |
| reports.module.ts | |
| reports.service.ts | |
| main.ts | |
| test/ | |
| date-range.factory.spec.ts | |
| reports.e2e-spec.ts | |
| ⸻ | |
| 1) Types | |
| src/common/date-range/date-range.types.ts | |
| export interface DateRangeQueryInput { | |
| startDate?: string; | |
| endDate?: string; | |
| } | |
| export interface DateRangeOptions { | |
| /** Inclusive length (days) when dates are missing/partial. Default: 30 */ | |
| defaultDays?: number; | |
| /** Allow start==end (single-day window). Default: true */ | |
| allowSameDay?: boolean; | |
| /** For deterministic tests */ | |
| nowProvider?: () => Date; | |
| } | |
| export interface ResolvedDateRange { | |
| /** UTC 'YYYY-MM-DD' (inclusive) */ | |
| start: string; | |
| /** UTC 'YYYY-MM-DD' (inclusive) */ | |
| end: string; | |
| /** UTC midnight Date objects for convenience */ | |
| startAt: Date; | |
| endAt: Date; | |
| } | |
| ⸻ | |
| 2) Query DTO (extendable) | |
| src/common/date-range/date-range.dto.ts | |
| import { IsOptional, IsString } from 'class-validator'; | |
| /** | |
| * Base query DTO to compose with other DTOs. | |
| * Cross-field rules & defaults are applied by the factory/pipe. | |
| */ | |
| export class DateRangeQueryDto { | |
| @IsOptional() | |
| @IsString() | |
| startDate?: string; | |
| @IsOptional() | |
| @IsString() | |
| endDate?: string; | |
| } | |
| ⸻ | |
| 3) Utilities (moment-based parsing/formatting) | |
| src/common/date-range/date-range.util.ts | |
| import * as moment from 'moment'; | |
| /** | |
| * Strictly parse a date-like input into a moment (UTC). | |
| * Accepts 'YYYY-MM-DD' or ISO-8601. Rejects impossible dates (strict). | |
| */ | |
| export function parseMomentUTCStrict(input: unknown): moment.Moment | null { | |
| if (typeof input !== 'string' || !input.trim()) return null; | |
| const m = moment.utc(input, ['YYYY-MM-DD', moment.ISO_8601], true); | |
| return m.isValid() ? m : null; | |
| } | |
| /** Ensure moment is set to UTC start-of-day. */ | |
| export function normalizeToUtcDay(m: moment.Moment): moment.Moment { | |
| return m.clone().utc().startOf('day'); | |
| } | |
| /** Format a moment (assumed normalized) to 'YYYY-MM-DD' (UTC). */ | |
| export function formatYYYYMMDD(m: moment.Moment): string { | |
| return m.clone().utc().format('YYYY-MM-DD'); | |
| } | |
| ⸻ | |
| 4) Factory (core logic + new “end-only” rule) | |
| src/common/date-range/date-range.factory.ts | |
| import { BadRequestException } from '@nestjs/common'; | |
| import * as moment from 'moment'; | |
| import { DateRangeOptions, DateRangeQueryInput, ResolvedDateRange } from './date-range.types'; | |
| import { formatYYYYMMDD, normalizeToUtcDay, parseMomentUTCStrict } from './date-range.util'; | |
| function todayUtc(nowProvider?: () => Date): moment.Moment { | |
| const now = nowProvider ? nowProvider() : new Date(); | |
| return moment.utc(now).startOf('day'); | |
| } | |
| /** | |
| * Build a resolved, validated date range using moment. | |
| * Throws BadRequestException for all invalid inputs → 400. | |
| */ | |
| export function buildDateRange( | |
| input: DateRangeQueryInput | undefined, | |
| options: DateRangeOptions = {}, | |
| ): ResolvedDateRange { | |
| const opts = { | |
| defaultDays: Number.isInteger(options.defaultDays) && (options.defaultDays as number) > 0 | |
| ? (options.defaultDays as number) | |
| : 30, | |
| allowSameDay: options.allowSameDay ?? true, | |
| nowProvider: options.nowProvider, | |
| }; | |
| const hasStart = !!input?.startDate; | |
| const hasEnd = !!input?.endDate; | |
| // Base "today" (UTC, start of day) | |
| const today = todayUtc(opts.nowProvider); | |
| // Case A: neither provided -> [today - (N-1), today] | |
| if (!hasStart && !hasEnd) { | |
| const endM = today; | |
| const startM = endM.clone().subtract(opts.defaultDays - 1, 'days'); | |
| return { | |
| start: formatYYYYMMDD(startM), | |
| end: formatYYYYMMDD(endM), | |
| startAt: startM.toDate(), | |
| endAt: endM.toDate(), | |
| }; | |
| } | |
| // Parse inputs (if present) | |
| let startParsed: moment.Moment | null = null; | |
| let endParsed: moment.Moment | null = null; | |
| if (hasStart) { | |
| startParsed = parseMomentUTCStrict(input!.startDate); | |
| if (!startParsed) throw new BadRequestException('startDate must be a valid calendar date.'); | |
| startParsed = normalizeToUtcDay(startParsed); | |
| } | |
| if (hasEnd) { | |
| endParsed = parseMomentUTCStrict(input!.endDate); | |
| if (!endParsed) throw new BadRequestException('endDate must be a valid calendar date.'); | |
| endParsed = normalizeToUtcDay(endParsed); | |
| } | |
| // Case B: only start provided -> end = today | |
| if (hasStart && !hasEnd) { | |
| const startM = startParsed!; | |
| const endM = today; | |
| // chronology | |
| if (opts.allowSameDay ? startM.isAfter(endM) : !startM.isBefore(endM)) { | |
| throw new BadRequestException( | |
| opts.allowSameDay ? 'startDate cannot be after endDate.' : 'startDate must be strictly before endDate.', | |
| ); | |
| } | |
| return { | |
| start: formatYYYYMMDD(startM), | |
| end: formatYYYYMMDD(endM), | |
| startAt: startM.toDate(), | |
| endAt: endM.toDate(), | |
| }; | |
| } | |
| // Case C: only end provided -> start = end - (N - 1) | |
| if (!hasStart && hasEnd) { | |
| const endM = endParsed!; | |
| const startM = endM.clone().subtract(opts.defaultDays - 1, 'days'); | |
| // (By construction, start <= end always holds) | |
| return { | |
| start: formatYYYYMMDD(startM), | |
| end: formatYYYYMMDD(endM), | |
| startAt: startM.toDate(), | |
| endAt: endM.toDate(), | |
| }; | |
| } | |
| // Case D: both provided -> validate ordering | |
| const startM = startParsed!; | |
| const endM = endParsed!; | |
| if (opts.allowSameDay) { | |
| if (startM.isAfter(endM)) { | |
| throw new BadRequestException('startDate cannot be after endDate.'); | |
| } | |
| } else { | |
| if (!startM.isBefore(endM)) { | |
| throw new BadRequestException('startDate must be strictly before endDate.'); | |
| } | |
| } | |
| return { | |
| start: formatYYYYMMDD(startM), | |
| end: formatYYYYMMDD(endM), | |
| startAt: startM.toDate(), | |
| endAt: endM.toDate(), | |
| }; | |
| } | |
| ⸻ | |
| 5) Optional pipe (inject a resolved range directly) | |
| src/common/date-range/date-range.pipe.ts | |
| import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'; | |
| import { buildDateRange } from './date-range.factory'; | |
| import { DateRangeOptions, ResolvedDateRange } from './date-range.types'; | |
| @Injectable() | |
| export class ResolveDateRangePipe implements PipeTransform { | |
| constructor(private readonly options: DateRangeOptions = {}) {} | |
| transform(value: unknown, _metadata: ArgumentMetadata): ResolvedDateRange { | |
| const input = (value ?? {}) as { startDate?: string; endDate?: string }; | |
| return buildDateRange(input, this.options); | |
| } | |
| } | |
| ⸻ | |
| 6) Barrel | |
| src/common/date-range/index.ts | |
| export * from './date-range.types'; | |
| export * from './date-range.dto'; | |
| export * from './date-range.util'; | |
| export * from './date-range.factory'; | |
| export * from './date-range.pipe'; | |
| ⸻ | |
| 7) Example usage | |
| src/reports/reports.controller.ts | |
| import { Controller, Get, Query } from '@nestjs/common'; | |
| import { DateRangeQueryDto } from '../common/date-range/date-range.dto'; | |
| import { buildDateRange } from '../common/date-range/date-range.factory'; | |
| import { ReportsService } from './reports.service'; | |
| export class ReportsQueryDto extends DateRangeQueryDto { | |
| // extend with your own filters as needed | |
| } | |
| @Controller('reports') | |
| export class ReportsController { | |
| constructor(private readonly reports: ReportsService) {} | |
| @Get() | |
| async list(@Query() query: ReportsQueryDto) { | |
| // Customize default window here if desired | |
| const range = buildDateRange(query, { defaultDays: 30 }); | |
| return this.reports.list(range, query); | |
| } | |
| } | |
| src/reports/reports.service.ts | |
| import { Injectable } from '@nestjs/common'; | |
| import { ResolvedDateRange } from '../common/date-range'; | |
| @Injectable() | |
| export class ReportsService { | |
| async list(range: ResolvedDateRange, _query: unknown) { | |
| return { | |
| dateRange: { start: range.start, end: range.end }, | |
| // data: await this.repo.findBetween(range.startAt, range.endAt) | |
| }; | |
| } | |
| } | |
| src/reports/reports.module.ts | |
| import { Module } from '@nestjs/common'; | |
| import { ReportsController } from './reports.controller'; | |
| import { ReportsService } from './reports.service'; | |
| @Module({ | |
| controllers: [ReportsController], | |
| providers: [ReportsService], | |
| }) | |
| export class ReportsModule {} | |
| src/main.ts | |
| import { ValidationPipe } from '@nestjs/common'; | |
| import { NestFactory } from '@nestjs/core'; | |
| import { ReportsModule } from './reports/reports.module'; | |
| async function bootstrap() { | |
| const app = await NestFactory.create(ReportsModule); | |
| app.useGlobalPipes( | |
| new ValidationPipe({ | |
| whitelist: true, | |
| transform: true, | |
| forbidUnknownValues: false, | |
| }), | |
| ); | |
| await app.listen(3000); | |
| } | |
| bootstrap(); | |
| ⸻ | |
| 8) Unit tests (moment-based + new “end-only” rule) | |
| test/date-range.factory.spec.ts | |
| import { buildDateRange } from '../src/common/date-range/date-range.factory'; | |
| const FIXED_NOW = new Date(Date.UTC(2025, 5, 15, 12, 0, 0)); // 2025-06-15T12:00:00Z | |
| describe('buildDateRange (moment)', () => { | |
| const nowProvider = () => FIXED_NOW; | |
| it('defaults to last 30 days ending "today" when both are missing', () => { | |
| const range = buildDateRange({}, { nowProvider }); | |
| expect(range.end).toBe('2025-06-15'); | |
| expect(range.start).toBe('2025-05-17'); // 29 days before end | |
| }); | |
| it('only start -> end defaults to today', () => { | |
| const range = buildDateRange({ startDate: '2025-06-10' }, { nowProvider }); | |
| expect(range.start).toBe('2025-06-10'); | |
| expect(range.end).toBe('2025-06-15'); | |
| }); | |
| it('only end -> start falls back to default window (30 days) anchored at end', () => { | |
| const range = buildDateRange({ endDate: '2025-06-10' }, { nowProvider }); | |
| expect(range.end).toBe('2025-06-10'); | |
| expect(range.start).toBe('2025-05-12'); // 29 days before | |
| }); | |
| it('only end with custom defaultDays', () => { | |
| const range = buildDateRange({ endDate: '2025-06-10' }, { nowProvider, defaultDays: 7 }); | |
| expect(range.start).toBe('2025-06-04'); // 6 days before (inclusive window of 7) | |
| expect(range.end).toBe('2025-06-10'); | |
| }); | |
| it('rejects invalid calendar dates', () => { | |
| expect(() => | |
| buildDateRange({ startDate: '2025-02-30' }, { nowProvider }), | |
| ).toThrow(/startDate must be a valid/); | |
| expect(() => | |
| buildDateRange({ startDate: '2025-01-01', endDate: '2025-13-01' }, { nowProvider }), | |
| ).toThrow(/endDate must be a valid/); | |
| }); | |
| it('rejects start after end', () => { | |
| expect(() => | |
| buildDateRange({ startDate: '2025-06-16', endDate: '2025-06-15' }, { nowProvider }), | |
| ).toThrow(/cannot be after/); | |
| }); | |
| it('allows same-day by default', () => { | |
| const range = buildDateRange({ startDate: '2025-06-15', endDate: '2025-06-15' }, { nowProvider }); | |
| expect(range.start).toBe('2025-06-15'); | |
| expect(range.end).toBe('2025-06-15'); | |
| }); | |
| it('can disallow same-day when configured', () => { | |
| expect(() => | |
| buildDateRange({ startDate: '2025-06-15', endDate: '2025-06-15' }, { nowProvider, allowSameDay: false }), | |
| ).toThrow(/strictly before/); | |
| }); | |
| it('accepts ISO strings with times and normalizes to YYYY-MM-DD', () => { | |
| const range = buildDateRange( | |
| { startDate: '2025-06-01T10:20:00Z', endDate: '2025-06-15T23:59:59Z' }, | |
| { nowProvider }, | |
| ); | |
| expect(range.start).toBe('2025-06-01'); | |
| expect(range.end).toBe('2025-06-15'); | |
| }); | |
| }); | |
| ⸻ | |
| 9) E2E test (400 mapping verified; end-only rule covered) | |
| test/reports.e2e-spec.ts | |
| import { INestApplication, ValidationPipe } from '@nestjs/common'; | |
| import { Test } from '@nestjs/testing'; | |
| import * as request from 'supertest'; | |
| import { ReportsModule } from '../src/reports/reports.module'; | |
| describe('ReportsController (e2e) with moment', () => { | |
| let app: INestApplication; | |
| beforeAll(async () => { | |
| const moduleRef = await Test.createTestingModule({ | |
| imports: [ReportsModule], | |
| }).compile(); | |
| app = moduleRef.createNestApplication(); | |
| app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); | |
| await app.init(); | |
| }); | |
| afterAll(async () => { | |
| await app.close(); | |
| }); | |
| it('GET /reports without dates -> 200 with normalized window', async () => { | |
| const res = await request(app.getHttpServer()).get('/reports').expect(200); | |
| expect(res.body).toHaveProperty('dateRange.start'); | |
| expect(res.body).toHaveProperty('dateRange.end'); | |
| }); | |
| it('GET /reports with endDate only -> 200 and start derived from defaultDays', async () => { | |
| const res = await request(app.getHttpServer()) | |
| .get('/reports?endDate=2025-06-10') | |
| .expect(200); | |
| expect(res.body.dateRange).toEqual({ | |
| start: '2025-05-12', | |
| end: '2025-06-10', | |
| }); | |
| }); | |
| it('GET /reports with impossible date -> 400', async () => { | |
| await request(app.getHttpServer()) | |
| .get('/reports?startDate=2025-02-30') | |
| .expect(400); | |
| }); | |
| it('GET /reports with start > end -> 400', async () => { | |
| await request(app.getHttpServer()) | |
| .get('/reports?startDate=2025-07-01&endDate=2025-06-01') | |
| .expect(400); | |
| }); | |
| }); | |
| ⸻ | |
| How to compose with other DTOs | |
| export class OrdersQueryDto extends DateRangeQueryDto { | |
| // @IsOptional() @IsEnum(OrderStatus) status?: OrderStatus; | |
| // @IsOptional() @IsString() search?: string; | |
| } | |
| @Get('orders') | |
| listOrders(@Query() query: OrdersQueryDto) { | |
| const range = buildDateRange(query, { defaultDays: 14 }); // customize default window | |
| // ... | |
| } | |
| ⸻ | |
| Design notes | |
| • moment everywhere: parsing, normalization, arithmetic → fewer edge cases than native Date. | |
| • UTC-only: consistent YYYY-MM-DD across services; all ranges are inclusive. | |
| • End-only rule: when endDate is given alone, we now anchor the window on it and backfill startDate using defaultDays. | |
| • Errors as 400: all validation failures throw BadRequestException; Nest’s default exception filter maps to HTTP 400. | |
| • Clean & reusable: keep the DTO minimal and composable; centralize business rules in the factory (and optionally inject via the pipe). | 
  
    Sign up for free
    to join this conversation on GitHub.
    Already have an account?
    Sign in to comment