Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dotenv": "^16.3.1",
"graphql": "^16.8.1",
"joi": "^17.12.2",
"moment-timezone": "^0.5.45",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.3",
Expand Down Expand Up @@ -97,3 +98,5 @@
"test/**/*.{ts,tsx}": "./prettier/prettier-format.sh"
}
}


2 changes: 1 addition & 1 deletion src/app.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ describe('AppController', () => {
expect(appController.healthCheck()).toBe('Hello World!');
});
});
});
});
16 changes: 8 additions & 8 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import getConfig from './config';
import { AuthModule } from './modules/auth/auth.module';
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './global-exception.filter';
import { TimezoneModule } from './timezone/timezone.module';

@Module({
imports: [
Expand All @@ -32,17 +33,16 @@ import { AllExceptionsFilter } from './global-exception.filter';
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql',
formatError: (error) => {
const errorMessage = {
message: error.message,
path: error.path,
};
return errorMessage;
},
formatError: (error) => ({
message: error.message,
path: error.path,
}),
context: ({ req }) => ({ req }),
}),
UserModule,
AuthModule,
ExchangeKeyModule,
TimezoneModule,
],
controllers: [AppController],
providers: [
Expand All @@ -53,4 +53,4 @@ import { AllExceptionsFilter } from './global-exception.filter';
},
],
})
export class AppModule {}
export class AppModule {}
4 changes: 4 additions & 0 deletions src/common/interfaces/timezone.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ITimezone {
id: string; // "America/New_York"
displayName: string; // "Eastern Standard Time (EST)"
}
31 changes: 31 additions & 0 deletions src/common/utils/time-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { majorTimezones,convertToUTC, convertToLocalTime,getFilteredMajorTimezones } from './time-utils';
import * as moment from 'moment-timezone';

describe('time-utils', () => {
it('should convert local time to UTC correctly', () => {
// Use moment to create a local time with an explicit time zone
const localTime = moment.tz('2023-05-01T12:00:00', 'America/New_York').toDate();
const timezone = 'America/New_York';
const expected = '2023-05-01T16:00:00.000Z'; // New York Daylight Time, UTC-4

const result = convertToUTC(localTime, timezone);

expect(result.toISOString()).toEqual(expected);
});

it('should convert UTC time to local time correctly', () => {
const utcTime = new Date('2023-05-01T16:00:00Z');
const timezone = 'America/New_York';
const expected = moment.tz('2023-05-01T12:00:00', 'America/New_York').toISOString();
const result = convertToLocalTime(utcTime, timezone);
expect(result.toISOString()).toEqual(expected);
});
it('should filter major timezones correctly', () => {
const filteredTimezones = getFilteredMajorTimezones();
expect(filteredTimezones.length).toBeLessThanOrEqual(majorTimezones.length);
filteredTimezones.forEach(timezone => {
expect(majorTimezones).toContain(timezone);
});
});
});

47 changes: 47 additions & 0 deletions src/common/utils/time-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as moment from 'moment-timezone';
import 'moment-timezone/builds/moment-timezone-with-data';

export const majorTimezones: string[] = [
'UTC', // Coordinated Universal Time
'America/New_York', // Eastern Time (US & Canada)
'America/Chicago', // Central Time (US & Canada)
'America/Denver', // Mountain Time (US & Canada)
'America/Los_Angeles', // Pacific Time (US & Canada)
'America/Anchorage', // Alaska Time
'America/Honolulu', // Hawaii Time
'America/Sao_Paulo', // Brazil Time
'America/Bogota', // Colombia Time
'America/Buenos_Aires', // Argentina Time
'Europe/London', // United Kingdom Time
'Europe/Berlin', // Germany Time (represents Central European Time)
'Europe/Moscow', // Moscow Time (Russia)
'Europe/Athens', // Greece Time
'Europe/Istanbul', // Turkey Time
'Africa/Cairo', // Egypt Time
'Africa/Johannesburg', // South Africa Time
'Africa/Lagos', // Nigeria Time
'Asia/Shanghai', // China Standard Time
'Asia/Tokyo', // Japan Time
'Asia/Kolkata', // India Time
'Asia/Dubai', // United Arab Emirates Time
'Asia/Bangkok', // Thailand Time
'Asia/Jakarta', // Jakarta Time (Indonesia)
'Australia/Sydney', // Eastern Australia Time
'Australia/Perth', // Western Australia Time
'Pacific/Auckland', // New Zealand Time
'Pacific/Fiji', // Fiji Time
'Europe/Stockholm', // Sweden Time (represents Scandinavian Time)
];

export function convertToUTC(localTime: Date, timezone: string): Date {
return moment(localTime).tz(timezone).utc().toDate();
}

export function convertToLocalTime(utcTime: Date, timezone: string): Date {
return moment.utc(utcTime).tz(timezone).toDate();
}
// Get the filtered primary time zone
export function getFilteredMajorTimezones(): string[] {
const allTimezones = moment.tz.names();
return allTimezones.filter(timezone => majorTimezones.includes(timezone));
}
2 changes: 1 addition & 1 deletion src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ import { User } from '../user/models/user.entity';
providers: [ConsoleLogger, AuthService, AuthResolver, UserService, JwtStrategy],
exports: [],
})
export class AuthModule {}
export class AuthModule {}
7 changes: 7 additions & 0 deletions src/modules/user/models/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ export class User extends CommonEntity {
})
captchaCreateAt: Date;

@Column({
comment: 'User timezone',
type: 'varchar',
default: 'UTC',
})
timezone: string;

@OneToMany(() => ExchangeKey, (key) => key.user)
keys: ExchangeKey[];
}
1 change: 0 additions & 1 deletion src/modules/user/user.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { UserService } from './user.service';
import { GqlAuthGuard } from '@/common/guards/auth.guard';
import { ExecutionContext, INestApplication } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { JwtService } from '@nestjs/jwt';

describe('UserResolver', () => {
let resolver: UserResolver;
Expand Down
8 changes: 4 additions & 4 deletions src/modules/user/user.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Args, Context, GqlExecutionContext, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CreateUserInput } from './dto/new-user.input';
import { UserType } from './dto/user.type';
import { UserService } from './user.service';
Expand Down Expand Up @@ -28,8 +28,8 @@ export class UserResolver {

@Query(() => UserType, { description: 'Find user by context' })
@UseGuards(GqlAuthGuard)
async getUserInfo(@Context() cxt: any): Promise<UserType> {
const id = cxt.req.user.id;
async getUserInfo(@Context() ctx: any): Promise<UserType> {
const id = ctx.req.user.id;
return await this.userService.find(id);
}

Expand All @@ -47,7 +47,7 @@ export class UserResolver {
return await this.userService.update(id, input);
}

@Mutation(() => Boolean, { description: 'Hard delete an user' })
@Mutation(() => Boolean, { description: 'Hard delete a user' })
async deleteUser(@Args('id') id: string): Promise<boolean> {
return await this.userService.del(id);
}
Expand Down
2 changes: 1 addition & 1 deletion src/modules/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ export class UserService {
});
return res;
}
}
}
10 changes: 10 additions & 0 deletions src/timezone/dto/update-timezone-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ObjectType, Field } from '@nestjs/graphql';

@ObjectType()
export class UpdateTimezoneResponse {
@Field(() => Boolean)
success: boolean;

@Field(() => String)
message: string;
}
12 changes: 12 additions & 0 deletions src/timezone/timezone.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TimezoneService } from './timezone.service';
import { AuthModule } from '../modules/auth/auth.module';
import { TimezoneResolver } from './timezone.resolver';
import { UserModule } from '../modules/user/user.module';

@Module({
imports: [AuthModule, UserModule],
providers: [TimezoneService, TimezoneResolver],
exports: [TimezoneService]
})
export class TimezoneModule {}
83 changes: 83 additions & 0 deletions src/timezone/timezone.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TimezoneResolver } from './timezone.resolver';
import { TimezoneService } from './timezone.service';
import { UnauthorizedException } from '@nestjs/common';

describe('TimezoneResolver', () => {
let resolver: TimezoneResolver;
let timezoneService: TimezoneService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TimezoneResolver,
{
provide: TimezoneService,
useValue: {
getAllTimezones: jest.fn().mockReturnValue([
{ id: 'UTC', displayName: 'Coordinated Universal Time (UTC)' },
{ id: 'GMT', displayName: 'Greenwich Mean Time (GMT)' },
{ id: 'Asia/Shanghai', displayName: 'China Standard Time (CST)' }
]),
getMajorTimezones: jest.fn().mockReturnValue([
{ id: 'UTC', displayName: 'Coordinated Universal Time (UTC)' }
]),
updateUserTimezone: jest.fn().mockResolvedValue({
success: true,
message: 'Timezone updated successfully.',
}),
},
},
],
}).compile();

resolver = module.get<TimezoneResolver>(TimezoneResolver);
timezoneService = module.get<TimezoneService>(TimezoneService);
});

it('should be defined', () => {
expect(resolver).toBeDefined();
expect(timezoneService).toBeDefined();
});

describe('getTimezones', () => {
it('should return a list of all available timezones', () => {
const result = resolver.getTimezones();
expect(result).toEqual(['Coordinated Universal Time (UTC)', 'Greenwich Mean Time (GMT)', 'China Standard Time (CST)']);
expect(timezoneService.getAllTimezones).toHaveBeenCalled();
});
});

describe('getMajorTimezones', () => {
it('should return a list of major timezones', () => {
const result = resolver.getMajorTimezones();
expect(result).toEqual(['Coordinated Universal Time (UTC)']);
expect(timezoneService.getMajorTimezones).toHaveBeenCalled();
});
});

describe('updateTimezone', () => {
it('should return a successful update message', async () => {
const userId = 'some-uuid';
const timezone = 'Asia/Tokyo';
const req = { user: { id: userId }, headers: { authorization: 'Bearer some-valid-jwt-token' } };
const context = { req };

const result = await resolver.updateTimezone(userId, timezone, context);
expect(result).toEqual({
success: true,
message: 'Timezone updated successfully.',
});
expect(timezoneService.updateUserTimezone).toHaveBeenCalledWith(userId, timezone);
});

it('should throw an UnauthorizedException if user ids do not match', async () => {
const userId = 'some-uuid';
const timezone = 'Asia/Tokyo';
const req = { user: { id: 'different-uuid' }, headers: { authorization: 'Bearer some-valid-jwt-token' } };
const context = { req };

await expect(resolver.updateTimezone(userId, timezone, context)).rejects.toThrow(UnauthorizedException);
});
});
});
41 changes: 41 additions & 0 deletions src/timezone/timezone.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { UnauthorizedException, UseGuards } from '@nestjs/common';
import { Resolver, Query, Mutation, Args, Context } from '@nestjs/graphql';
import { TimezoneService } from './timezone.service';
import { UpdateTimezoneResponse } from './dto/update-timezone-response.dto';
import { GqlAuthGuard } from '../common/guards/auth.guard';

@Resolver()
export class TimezoneResolver {
constructor(private readonly timezoneService: TimezoneService) {}

@Query(() => [String], {
description: 'Get a list of all available timezones',
})
getTimezones(): string[] {
return this.timezoneService.getAllTimezones().map(tz => tz.displayName);
}

@Query(() => [String], {
description: 'Get a list of major timezones',
})
getMajorTimezones(): string[] {
return this.timezoneService.getMajorTimezones().map(tz => tz.displayName);
}

@Mutation(() => UpdateTimezoneResponse)
@UseGuards(GqlAuthGuard)
async updateTimezone(
@Args('userId') userId: string,
@Args('timezone') timezone: string,
@Context() context: any
): Promise<UpdateTimezoneResponse> {
const req = context.req;
const user = req.user;

if (user.id !== userId) {
throw new UnauthorizedException('User authentication failed');
}

return this.timezoneService.updateUserTimezone(userId, timezone);
}
}
Loading