Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save brandonbryant12/38af91ae41e42d72431789b1d403906b to your computer and use it in GitHub Desktop.
Save brandonbryant12/38af91ae41e42d72431789b1d403906b to your computer and use it in GitHub Desktop.
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