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
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,25 +1,36 @@
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);
}

@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(
data.userId,
Expand All @@ -45,12 +56,15 @@ export class NotificationsController {
}

@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 @@ export class NotificationsController {
}

@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,24 +3,25 @@ import { HttpService } from '@nestjs/axios';
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'),
});
}

async createNotification(data: CreateNotificationDto): Promise<Notification> {
Expand Down Expand Up @@ -49,12 +50,16 @@ export class NotificationsService {
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 @@ export class NotificationsService {
channelId: 'swap-notifications',
}));

const tickets = await this.sendExpoPushNotifications(messages, notification.id);
const tickets = await this.sendExpoPushNotifications(
messages,
notification.id,
);
return tickets;
} catch (error) {
this.logger.error('Failed to send push notification', error);
Expand All @@ -82,14 +90,16 @@ export class NotificationsService {
}

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 @@ export class NotificationsService {
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 @@ export class NotificationsService {

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 @@ export class NotificationsService {
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 @@ export class NotificationsService {

// 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 @@ export class NotificationsService {
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