Skip to content
Merged
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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ node_modules
build
.turbo

# TypeScript compiled JS alongside source (use dist/ instead)
apps/*/src/**/*.js
packages/*/src/**/*.js

.yarn/*
!.yarn/patches
!.yarn/plugins
Expand Down Expand Up @@ -60,4 +64,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

Pulumi.*.yaml
/generated/prisma
*.db
*.db
.eslintcache
2 changes: 1 addition & 1 deletion apps/notifications-service/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PrismaService } from './prisma/prisma.service';
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../.env',
envFilePath: ['.env', '../../.env'],
}),
HttpModule,
],
Expand Down
7 changes: 5 additions & 2 deletions apps/notifications-service/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ async function bootstrap() {
res.status(200).json({ status: 'ok' });
});

const port = process.env.PORT || 3000;
app.enableShutdownHooks();

const port =
process.env.NOTIFICATIONS_SERVICE_PORT || process.env.PORT || 3003;
await app.listen(port);

console.log(`Notifications service is running on: http://localhost:${port}`);
}

bootstrap();
void bootstrap();
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
import { Controller, Post, Body, Get, Param, Query, Put } from '@nestjs/common';
import { Controller, Post, Body, Get, Param, Query } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationType } from '@shapeshift/shared-types';
import {
NotificationType,
PushNotificationData,
} from '@shapeshift/shared-types';

@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}

@Post()
async createNotification(@Body() data: {
userId: string;
title: string;
body: string;
type: NotificationType;
swapId?: string;
}) {
async createNotification(
@Body()
data: {
userId: string;
title: string;
body: string;
type: NotificationType;
swapId?: string;
},
) {
return this.notificationsService.createNotification(data);

Check failure on line 23 in apps/notifications-service/src/notifications/notifications.controller.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe return of a value of type `Promise<any>`
}

@Post('register-device')
async registerDevice(
@Body() data: { userId: string; deviceToken: string; deviceType: 'MOBILE' | 'WEB' },
@Body()
data: {
userId: string;
deviceToken: string;
deviceType: 'MOBILE' | 'WEB';
},
) {
return this.notificationsService.registerDevice(

Check failure on line 35 in apps/notifications-service/src/notifications/notifications.controller.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe return of a value of type `Promise<any>`
data.userId,
data.deviceToken,
data.deviceType,
Expand All @@ -45,12 +56,15 @@
}

@Post('send-to-user')
async sendToUser(@Body() data: {
userId: string;
title: string;
body: string;
data?: any;
}) {
async sendToUser(
@Body()
data: {
userId: string;
title: string;
body: string;
data?: PushNotificationData;
},
) {
return this.notificationsService.sendPushNotificationToUser(
data.userId,
data.title,
Expand All @@ -60,12 +74,15 @@
}

@Post('send-to-device')
async sendToDevice(@Body() data: {
deviceToken: string;
title: string;
body: string;
data?: any;
}) {
async sendToDevice(
@Body()
data: {
deviceToken: string;
title: string;
body: string;
data?: PushNotificationData;
},
) {
return this.notificationsService.sendPushNotificationToDevice(
data.deviceToken,
data.title,
Expand Down
103 changes: 69 additions & 34 deletions apps/notifications-service/src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,33 @@
import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk';
import { PrismaService } from '../prisma/prisma.service';
import { getRequiredEnvVar } from '@shapeshift/shared-utils';
import {
import {
CreateNotificationDto,
Device,
PushNotificationData
PushNotificationData,
} from '@shapeshift/shared-types';
import { Notification } from '@prisma/client';


@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private expo: Expo;

constructor(
private prisma: PrismaService,
private httpService: HttpService
private httpService: HttpService,
) {
this.expo = new Expo({ accessToken: getRequiredEnvVar('EXPO_ACCESS_TOKEN') });
this.expo = new Expo({
accessToken: getRequiredEnvVar('EXPO_ACCESS_TOKEN'),

Check failure on line 23 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe call of a(n) `error` type typed value

Check failure on line 23 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe assignment of an error typed value
});
}

async createNotification(data: CreateNotificationDto): Promise<Notification> {
try {
const notification = await this.prisma.notification.create({

Check failure on line 29 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe member access .create on an `error` typed value

Check failure on line 29 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe call of a(n) `error` type typed value

Check failure on line 29 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe assignment of an error typed value
data: {
userId: data.userId,

Check failure on line 31 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe member access .userId on an `error` typed value

Check failure on line 31 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe assignment of an error typed value
title: data.title,

Check failure on line 32 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe assignment of an error typed value
body: data.body,
type: data.type,
swapId: data.swapId,
Expand All @@ -49,12 +50,16 @@
try {
// Get user devices from user service
const userServiceUrl = getRequiredEnvVar('USER_SERVICE_URL');
const response = await this.httpService.axiosRef.get<Device[]>(`${userServiceUrl}/users/${notification.userId}/devices`);
const response = await this.httpService.axiosRef.get<Device[]>(
`${userServiceUrl}/users/${notification.userId}/devices`,
);
const devices = response.data;
const activeDevices = devices.filter((device) => device.isActive);

if (activeDevices.length === 0) {
throw new BadRequestException(`No active devices found for user ${notification.userId}`);
throw new BadRequestException(
`No active devices found for user ${notification.userId}`,
);
}

const messages: ExpoPushMessage[] = activeDevices
Expand All @@ -73,7 +78,10 @@
channelId: 'swap-notifications',
}));

const tickets = await this.sendExpoPushNotifications(messages, notification.id);
const tickets = await this.sendExpoPushNotifications(
messages,
notification.id,

Check warning on line 83 in apps/notifications-service/src/notifications/notifications.service.ts

View workflow job for this annotation

GitHub Actions / Lint and Test

Unsafe argument of type error typed assigned to a parameter of type `string`
);
return tickets;
} catch (error) {
this.logger.error('Failed to send push notification', error);
Expand All @@ -82,14 +90,16 @@
}

async sendPushNotificationToDevice(
deviceToken: string,
title: string,
body: string,
data?: PushNotificationData
deviceToken: string,
title: string,
body: string,
data?: PushNotificationData,
): Promise<ExpoPushTicket[]> {
try {
if (!Expo.isExpoPushToken(deviceToken)) {
throw new BadRequestException(`Invalid Expo push token: ${deviceToken}`);
throw new BadRequestException(
`Invalid Expo push token: ${String(deviceToken)}`,
);
}

const message: ExpoPushMessage = {
Expand All @@ -106,28 +116,34 @@
return tickets;
} catch (error) {
this.logger.error('Failed to send push notification to device', error);
throw new BadRequestException('Failed to send push notification to device');
throw new BadRequestException(
'Failed to send push notification to device',
);
}
}

async sendPushNotificationToUser(
userId: string,
title: string,
body: string,
data?: PushNotificationData
userId: string,
title: string,
body: string,
data?: PushNotificationData,
): Promise<ExpoPushTicket[]> {
try {
// Get user devices from user service
const userServiceUrl = getRequiredEnvVar('USER_SERVICE_URL');
const response = await this.httpService.axiosRef.get<Device[]>(`${userServiceUrl}/users/${userId}/devices`);
const response = await this.httpService.axiosRef.get<Device[]>(
`${userServiceUrl}/users/${userId}/devices`,
);
const devices = response.data;
const activeDevices = devices.filter((device) => device.isActive);

if (activeDevices.length === 0) {
throw new BadRequestException(`No active devices found for user ${userId}`);
throw new BadRequestException(
`No active devices found for user ${userId}`,
);
}

const messages: ExpoPushMessage[] = activeDevices.map((device: any) => ({
const messages: ExpoPushMessage[] = activeDevices.map((device) => ({
to: device.deviceToken,
sound: 'default',
title,
Expand All @@ -139,12 +155,15 @@

const tickets = await this.sendExpoPushNotifications(messages);
return tickets;
} catch (error) {
} catch {
throw new BadRequestException('Failed to send push notification to user');
}
}

private async sendExpoPushNotifications(messages: ExpoPushMessage[], notificationId?: string): Promise<ExpoPushTicket[]> {
private async sendExpoPushNotifications(
messages: ExpoPushMessage[],
notificationId?: string,
): Promise<ExpoPushTicket[]> {
const chunks = this.expo.chunkPushNotifications(messages);
const tickets: ExpoPushTicket[] = [];

Expand All @@ -170,10 +189,16 @@
return tickets;
}

async registerDevice(userId: string, deviceToken: string, deviceType: 'MOBILE' | 'WEB') {
async registerDevice(
userId: string,
deviceToken: string,
deviceType: 'MOBILE' | 'WEB',
) {
try {
this.logger.log(`registerDevice called with userId: ${userId}, deviceType: ${deviceType}, deviceToken: ${deviceToken}`);

this.logger.log(
`registerDevice called with userId: ${userId}, deviceType: ${deviceType}, deviceToken: ${deviceToken}`,
);

// Only validate Expo push token for mobile devices
if (deviceType === 'MOBILE' && !Expo.isExpoPushToken(deviceToken)) {
throw new BadRequestException('Invalid Expo push token');
Expand All @@ -186,20 +211,28 @@

// Register device with user service
const userServiceUrl = getRequiredEnvVar('USER_SERVICE_URL');
const response = await this.httpService.axiosRef.post<Device>(`${userServiceUrl}/users/${userId}/devices`, {
deviceToken,
deviceType,
});
const response = await this.httpService.axiosRef.post<Device>(
`${userServiceUrl}/users/${userId}/devices`,
{
deviceToken,
deviceType,
},
);
const device = response.data;
this.logger.log(`Device registered: ${deviceToken} for user ${userId} (${deviceType})`);
this.logger.log(
`Device registered: ${deviceToken} for user ${userId} (${deviceType})`,
);
return device;
} catch (error) {
this.logger.error('Failed to register device', error);
throw new BadRequestException('Failed to register device');
}
}

async getUserNotifications(userId: string, limit = 50): Promise<Notification[]> {
async getUserNotifications(
userId: string,
limit = 50,
): Promise<Notification[]> {
return this.prisma.notification.findMany({
where: { userId },
orderBy: { sentAt: 'desc' },
Expand All @@ -210,7 +243,9 @@
async getUserDevices(userId: string): Promise<Device[]> {
try {
const userServiceUrl = getRequiredEnvVar('USER_SERVICE_URL');
const response = await this.httpService.axiosRef.get<Device[]>(`${userServiceUrl}/users/${userId}/devices`);
const response = await this.httpService.axiosRef.get<Device[]>(
`${userServiceUrl}/users/${userId}/devices`,
);
return response.data;
} catch (error) {
this.logger.error('Failed to get user devices', error);
Expand Down
5 changes: 4 additions & 1 deletion apps/notifications-service/src/prisma/prisma.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
Expand Down
Loading
Loading