Repository: SpaceUY/NestJS-Template
Pull request #11: Feat/push notifications
Author: login: FloPereira
Created: 2025-04-02T21:16:02Z | Merged: 2025-04-15T20:50:53Z

This PR introduces a Push Notification module for the NestJS application, providing a clean and extensible interface for interacting with various push notifications providers. It includes an implementation of the PushNotificationController with an endpoint to send a test notification to a specific device token, as well as support for EXPO SERVER  SDK integration. Additionally, the module allows easy extension to other push notification providers like Firebase admin.

Key Features:

Push notification Module: A flexible module that allows easy configuration and integration of different push notification providers.

Expo Adapter: Integration with EXPO SERVER SDK for push notification operations such send.

Extensible Provider Architecture: Provides a clear structure for adding additional push notification providers by implementing the PushNotificationService abstract class.

Push Notification Controller: The PushNotificationController includes endpoint for send a test notification to specific device token


Diff:
diff --git a/package-lock.json b/package-lock.json
index 78dbd17..dcbb86c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
         "build": "^0.1.4",
         "class-transformer": "0.5.1",
         "class-validator": "^0.13.2",
+        "expo-server-sdk": "^3.14.0",
         "google-auth-library": "^8.5.2",
         "joi": "^17.6.1",
         "passport": "^0.6.0",
@@ -5999,6 +6000,12 @@
         "node": ">=10.13.0"
       }
     },
+    "node_modules/err-code": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+      "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+      "license": "MIT"
+    },
     "node_modules/error-ex": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -6644,6 +6651,17 @@
         "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
       }
     },
+    "node_modules/expo-server-sdk": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-3.14.0.tgz",
+      "integrity": "sha512-vuDDhEhO+MoNX0678rhRzwxaK+dqLwDb6Onv2/ANVM4qdU3pNR5rqUmjnfCGt8VBq4UgmbzpMABuc8qxmd6mPA==",
+      "license": "MIT",
+      "dependencies": {
+        "node-fetch": "^2.6.0",
+        "promise-limit": "^2.7.0",
+        "promise-retry": "^2.0.1"
+      }
+    },
     "node_modules/express": {
       "version": "4.19.2",
       "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
@@ -10297,6 +10315,25 @@
         "asap": "~2.0.3"
       }
     },
+    "node_modules/promise-limit": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz",
+      "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==",
+      "license": "ISC"
+    },
+    "node_modules/promise-retry": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+      "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+      "license": "MIT",
+      "dependencies": {
+        "err-code": "^2.0.2",
+        "retry": "^0.12.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/promised-io": {
       "version": "0.3.6",
       "resolved": "https://registry.npmjs.org/promised-io/-/promised-io-0.3.6.tgz",
@@ -10703,6 +10740,15 @@
       "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
       "dev": true
     },
+    "node_modules/retry": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+      "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
     "node_modules/reusify": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
diff --git a/package.json b/package.json
index 87ba64a..2bffc05 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
     "build": "^0.1.4",
     "class-transformer": "0.5.1",
     "class-validator": "^0.13.2",
+    "expo-server-sdk": "^3.14.0",
     "google-auth-library": "^8.5.2",
     "joi": "^17.6.1",
     "passport": "^0.6.0",
diff --git a/planetary.json b/planetary.json
index 9b6a815..2aa2e46 100644
--- a/planetary.json
+++ b/planetary.json
@@ -18,7 +18,22 @@
         },
         {
           "name": "s3-adapter",
-          "description": "S3 implementation of cloud storage module"
+          "description": "S3 implementation of cloud storage module",
+          "dependencies": ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner", "@types/multer"]
+        }
+      ]
+    },
+    "push-notification": {
+      "path": "src/push-notification",
+      "implementations": [
+        {
+          "name": "abstract",
+          "description": "Abstract implementation of push notification module"
+        },
+        {
+          "name": "expo-adapter",
+          "description": "Expo server SDK implementation of push notification module",
+          "dependencies": ["expo-server-sdk"]
         }
       ]
     }
diff --git a/src/app.module.ts b/src/app.module.ts
index a9acac4..51a8194 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -12,6 +12,9 @@ import { S3AdapterModule } from './cloud-storage/s3-adapter/s3-adapter.module';
 import { CloudStorageAbstractModule } from './cloud-storage/abstract/cloud-storage-abstract.module.ts';
 import { ConfigType } from '@nestjs/config';
 import awsConfig from 'src/config/aws.config';
+import { PushNotificationAbstractModule } from './push-notification/abstract/push-notification-abstract.module.ts';
+import { ExpoAdapterModule } from './push-notification/expo-adapter/expo-adapter.module';
+import expoConfig from './config/expo.config';
 
 @Module({
   imports: [
@@ -36,6 +39,16 @@ import awsConfig from 'src/config/aws.config';
       useDefaultController: true,
       isGlobal: true,
     }),
+    PushNotificationAbstractModule.forRoot({
+      adapter: ExpoAdapterModule.registerAsync({
+        inject: [expoConfig.KEY],
+        useFactory: (expo: ConfigType<typeof expoConfig>) => ({
+          expoAccessToken: expo.accessToken,
+        }),
+      }),
+      useDefaultController: true,
+      isGlobal: true,
+    }),
   ],
   controllers: [AppController],
   providers: [AppService],
diff --git a/src/cloud-storage/README.md b/src/cloud-storage/README.md
index f9e869b..ceaa0c8 100644
--- a/src/cloud-storage/README.md
+++ b/src/cloud-storage/README.md
@@ -245,7 +245,7 @@ The `CloudStorageAbstractModule` is designed to be flexible and support multiple
 
 1. **Create a New Adapter**  
    - Create a new directory inside `cloud-storage`, for example: `gcs-adapter/` for Google Cloud Storage.  
-   - Implement a service that follows the `ICloudStorageProvider` interface defined in `cloud-storage/abstract/cloud-storage-provider.interface.ts`.
+   - Implement a service that extends the `CloudStorageService` abstract class defined in `cloud-storage/abstract/cloud-storage.service.ts`.
 
 2. **Define the Adapter Module**  
    - Create a module similar to `S3AdapterModule` to initialize the new provider.  
diff --git a/src/cloud-storage/abstract/cloud-storage-abstract.module.ts.ts b/src/cloud-storage/abstract/cloud-storage-abstract.module.ts.ts
index 8d51712..bef17b4 100644
--- a/src/cloud-storage/abstract/cloud-storage-abstract.module.ts.ts
+++ b/src/cloud-storage/abstract/cloud-storage-abstract.module.ts.ts
@@ -13,7 +13,7 @@ interface CloudStorageModuleOptions {
   adapter: AdapterModule;
   useDefaultController?: boolean;
   isGlobal?: boolean;
-  customController?: Type<any>[];
+  controllers?: Type<any>[];
 }
 
 @Module({})
@@ -23,12 +23,12 @@ export class CloudStorageAbstractModule {
       adapter,
       isGlobal = false,
       useDefaultController = true,
-      customController,
+      controllers = [],
     } = options;
 
-    const controllers =
-      customController ||
-      (useDefaultController ? [CloudStorageController] : []);
+    if (useDefaultController) {
+      controllers.push(CloudStorageController);
+    }
 
     return {
       module: CloudStorageAbstractModule,
diff --git a/src/cloud-storage/s3-adapter/s3-adapter.module.ts b/src/cloud-storage/s3-adapter/s3-adapter.module.ts
index 0b2dbec..b8c262b 100644
--- a/src/cloud-storage/s3-adapter/s3-adapter.module.ts
+++ b/src/cloud-storage/s3-adapter/s3-adapter.module.ts
@@ -21,7 +21,7 @@ export class S3AdapterModule {
         },
         { provide: CLOUD_STORAGE_PROVIDER, useClass: S3AdapterService },
       ],
-      exports: [CLOUD_STORAGE_PROVIDER, S3_ADAPTER_PROVIDER_CONFIG],
+      exports: [CLOUD_STORAGE_PROVIDER],
     };
   }
 
@@ -41,7 +41,7 @@ export class S3AdapterModule {
         },
         { provide: CLOUD_STORAGE_PROVIDER, useClass: S3AdapterService },
       ],
-      exports: [CLOUD_STORAGE_PROVIDER, S3_ADAPTER_PROVIDER_CONFIG],
+      exports: [CLOUD_STORAGE_PROVIDER],
     };
   }
 }
diff --git a/src/config/config.module.ts b/src/config/config.module.ts
index 3a9ae36..31f47fd 100644
--- a/src/config/config.module.ts
+++ b/src/config/config.module.ts
@@ -6,6 +6,7 @@ import emailConfig from './email.config';
 import googleConfig from './google.config';
 import jwtConfig from './jwt.config';
 import awsConfig from './aws.config';
+import expoConfig from './expo.config';
 
 @Module({
   imports: [
@@ -16,6 +17,7 @@ import awsConfig from './aws.config';
       googleConfig,
       emailConfig,
       awsConfig,
+      expoConfig,
     ]),
   ],
   exports: [ConfigCoreModule],
diff --git a/src/config/expo.config.ts b/src/config/expo.config.ts
new file mode 100644
index 0000000..18eb2a9
--- /dev/null
+++ b/src/config/expo.config.ts
@@ -0,0 +1,12 @@
+import Joi = require('joi');
+import registerWithValidation from './core/register-with-validation.util';
+
+export default registerWithValidation(
+  'expo',
+  () => ({
+    accessToken: process.env.EXPO_ACCESS_TOKEN || '',
+  }),
+  {
+    EXPO_ACCESS_TOKEN: Joi.string(),
+  },
+);
diff --git a/src/push-notification/README.md b/src/push-notification/README.md
new file mode 100644
index 0000000..96c8f50
--- /dev/null
+++ b/src/push-notification/README.md
@@ -0,0 +1,278 @@
+# Push Notification Module
+
+A NestJS module that provides Push Notification functionality with a clean, injectable service interface.
+
+## Overview
+
+This module provides Push Notification files solution for NestJS applications. It includes:
+
+- A configurable Push Notification module
+- A service wrapper for Push Notification operations
+- Endpoint for send a push notification for test purposes
+- Expo Server SDK Integration
+
+## Directory Structure
+
+```
+push-notification/
+├── abstract/
+│   ├── dto/
+│   ├── push-notification-abstract.module.ts
+│   ├── push-notification-error-codes.ts
+│   ├── push-notification-provider.const.ts
+│   ├── push-notification.controller.ts
+│   ├── push-notification.exception.ts
+│   ├── push-notification.service.ts
+├── expo-adapter/
+│   ├── expo-adapter-config.provider.const.ts
+│   ├── expo-adapter-config.interface.ts
+│   ├── expo-adapter.module.ts
+│   ├── expo-adapter.service.ts
+└── README.md
+
+```
+
+## Features
+
+- Configurable Push Notification connection
+- Push Notification provider operations, sendNotification and sendNotificationByChunks
+- Easy-to-use service interface
+- Expo Server SDK support
+- Integration with NestJS dependency injection
+
+## Installation
+
+Ensure you have the required dependencies:
+
+```bash
+npm expo-server-sdk
+```
+
+## Usage
+
+### 1. Import the Module
+
+#### Single Node Mode
+
+```typescript
+import { Module } from '@nestjs/common';
+import { ConfigType } from '@nestjs/config';
+import { PushNotificationAbstractModule } from './push-notification/abstract/push-notification-abstract.module.ts';
+import { ExpoAdapterModule } from './push-notification/expo-adapter/expo-adapter.module';
+import expoConfig from './config/expo.config'; // Your expo config
+
+@Module({
+  imports: [
+    PushNotificationAbstractModule.forRoot({
+      adapter: ExpoAdapterModule.registerAsync({
+        inject: [expoConfig.KEY],
+        useFactory: (expo: ConfigType<typeof expoConfig>) => ({
+          expoAccessToken: expo.accessToken,
+        }),
+      }),
+      useDefaultController: true,
+      isGlobal: true,
+    })
+  ],
+})
+export class AppModule {}
+```
+
+### 2. Use the Push Notification Service
+
+```typescript
+import { Injectable } from '@nestjs/common';
+import { PushNotificationService } from './push-notification.service';
+
+@Injectable()
+export class YourService {
+  constructor(private pushNotificationService: PushNotificationService) {}
+
+   sendPushNotification(pushToken: string,
+    notification: IPushNotification): Promise<PushNotificationSuccessResponse> {
+    return await this.pushNotificationService.sendPushNotification(
+        token,
+        notificationDto,
+      );
+  }
+}
+```
+
+## Using the Expo Adapter with PushNotificationAbstractModule
+
+To make the `PushNotificationAbstractModule` work with the Expo adapter, it's required to extend the `PushNotificationService` class. Specifically, in your `ExpoAdapterService` class that extends `PushNotificationService`. This extension is necessary for the PushNotificationAbstractModule to interact with the Expo provider.
+
+If your project only requires the Expo adapter and you know that you won't need to change the provider, you can skip using the abstract module and directly import the ExpoAdapterModule into your project.
+
+**Fix Import Paths Manually (If Needed)**:
+
+  After copying the files, you might need to fix the import paths manually. Ensure that the import paths for the PushNotificationService and the ExpoAdapterService are correct according to your project structure. If you encounter any errors related to the imports, double-check that the paths are resolved correctly.
+
+  In your project, find a service that extends `PushNotificationService`, and adjust the import path if needed, like this:
+
+  ```typescript
+  import { PushNotificationService } from 'path-to-push-notification-service'; // Adjust the import path
+  import { Injectable } from '@nestjs/common';
+
+  @Injectable()
+  export class ExpoAdapterService extends PushNotificationService {
+  }
+   
+```
+
+
+## Configuration Options
+
+The `PushNotificationAbstractModule.forRoot()` method accepts standard Expo configuration options:
+
+```typescript
+static forRoot(options: { adapter: DynamicModule }): DynamicModule
+```
+
+There's also a `registerAsync` option which allows for dependency injection. For instance, the module can be used in the following fashion:
+
+```typescript
+PushNotificationAbstractModule.forRoot({
+  adapter: ExpoAdapterModule.registerAsync({
+    inject: [expoConfig.KEY],
+    useFactory: (expo: ConfigType<typeof expoConfig>) => ({
+      expoAccessToken: expo.accessToken,
+    }),
+  }),
+}),
+```
+
+Assuming that a `expoConfig` is registered using `@nestjs/common`'s `registerAs` method. 
+
+## API Reference
+
+The `PushNotificationAbstractModule` allows the optional registration of a default controller (`PushNotificationController`), which expose an endpoint for test purposes to send a notification to a specific token device.
+
+### PushNotificationController
+
+The main endpoint for interacting with push notification provider:
+
+```typescript
+class PushNotificationController {
+  // Send a push notification to a specific token device
+  async sendPushNotification(
+    @Param('token') token: string,
+    @Body() notificationDto: PushNotificationDto,
+  ): Promise<void>
+}
+```
+
+### **Enabling the Default Controller**
+
+To use the default controller, set `useDefaultController: true` when registering the module:
+
+```typescript
+import { Module } from '@nestjs/common';
+import { PushNotificationAbstractModule } from './push-notification/push-notification-abstract.module';
+import { ExpoAdapterModule } from './adapters/expo-adapter.module';
+
+@Module({
+  imports: [
+    PushNotificationAbstractModule.forRoot({
+      adapter: ExpoAdapterModule.registerAsync({
+        inject: [expoConfig.KEY],
+        useFactory: (expo: ConfigType<typeof expoConfig>) => ({
+          expoAccessToken: expo.accessToken,
+        }),
+      }),
+      useDefaultController: true, // Enables the default controller
+      isGlobal: true, // If you need, enables the module as global
+    }),
+  ],
+})
+export class AppModule {}
+```
+
+### **Enabling a Custom Controller**
+
+To use a custom controller, set `useDefaultController: false` when registering the module:
+
+```typescript
+import { Module } from '@nestjs/common';
+import { PushNotificationAbstractModule } from './push-notification/push-notification-abstract.module';
+import { ExpoAdapterModule } from './adapters/expo-adapter.module';
+import { CustomNotificationController } from './custom-notification.controller';
+
+@Module({
+  imports: [
+    PushNotificationAbstractModule.forRoot({
+      adapter: ExpoAdapterModule.registerAsync({
+        inject: [expoConfig.KEY],
+        useFactory: (expo: ConfigType<typeof expoConfig>) => ({
+          expoAccessToken: expo.accessToken,
+        }),
+      }),
+      useDefaultController: false, // Disable the default controller
+      customController: [YourController], // Registers the custom controller
+      isGlobal: true, // If you need, enables the module as global
+    }),
+  ], 
+})
+export class AppModule {}
+
+```
+
+## Extending Functionality with Other Push Notification Providers
+
+The `PushNotificationAbstractModule` is designed to be flexible and support multiple push notification providers. To add support for another provider, follow these steps:
+
+1. **Create a New Adapter**  
+   - Create a new directory inside `push-notification`, for example: `firebase-adapter/` for Firebase Admin.  
+   - Implement a service that extends the `PushNotificationService` abstract class defined in `push-notification/abstract/push-notification.service.ts`.
+
+2. **Define the Adapter Module**  
+   - Create a module similar to `ExpoAdapterModule` to initialize the new provider.  
+   - Ensure it provides a configuration mechanism (e.g., `register` or `registerAsync` methods).
+
+3. **Register the Adapter in the Abstract Module**  
+   - Modify `PushNotificationAbstractModule` to accept the new adapter as a dynamic module.  
+   - Example:  
+   ```typescript
+   PushNotificationAbstractModule.forRoot({
+     adapter: FirebaseAdapterModule.registerAsync({
+       inject: [firebaseConfig.KEY],
+       useFactory: (firebase: ConfigType<typeof firebaseConfig>) => ({
+         token: firebase.token,
+       }),
+     }),
+   }),
+   ```
+4. **Implement Provider-Specific Logic**
+  - Ensure the new adapter correctly implements methods such as sendPushNotification and sendPushNotificationByChunks.
+  - Use the respective SDK (e.g., firebase-admin for Firebase).
+  - By following this structure, you can easily extend the push notification module to support different providers like Firebase or others.
+
+## Best Practices
+
+1. **Message Formatting & Consistency**
+
+   - Use a consistent payload structure for all notifications.
+   - Include clear titles and concise messages to improve user engagement.
+   - Use localization when sending notifications to support multiple languages.
+
+2. **Error Handling & Retry Mechanism**
+
+   - Log all notification requests and responses for debugging.
+   - Implement a retry mechanism for failed notifications, considering exponential backoff.
+   - Store failed notifications for future reprocessing, especially for critical messages.
+
+3. **Security & Privacy**
+
+   - Use authentication when managing push tokens in your backend.
+   - Do not expose push tokens in logs or client-side code.
+   - Encrypt sensitive data included in the notification payload.
+
+4. **Performance & Scalability**
+   - Send notifications in batches using chunking mechanisms.
+   - Implement asynchronous processing for large-scale notifications.
+   - Monitor push notification response times and delivery rates to detect bottlenecks.
+   - Implement asynchronous processing for large-scale notifications.
+
+## Contributing
+
+Feel free to submit issues and enhancement requests!
\ No newline at end of file
diff --git a/src/push-notification/abstract/dto/push-notification.dto.ts b/src/push-notification/abstract/dto/push-notification.dto.ts
new file mode 100644
index 0000000..967bef9
--- /dev/null
+++ b/src/push-notification/abstract/dto/push-notification.dto.ts
@@ -0,0 +1,35 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsOptional, Length, IsObject, IsUrl } from 'class-validator';
+
+export class PushNotificationDto {
+  @ApiProperty({
+    description: 'Notification Title',
+    example: 'Notification',
+  })
+  @IsString()
+  @Length(2, 50)
+  readonly title: string;
+
+  @ApiProperty({
+    description: 'Notification Content',
+    example: 'New notification',
+  })
+  @IsString()
+  @Length(2, 250)
+  readonly body: string;
+
+  @ApiProperty({
+    description:
+      'Data object such as IDs or relevant information needed to be retrieved in the frontend',
+  })
+  @IsObject()
+  @IsOptional()
+  readonly data: Record<string, string>;
+
+  @IsOptional()
+  @IsUrl()
+  @ApiProperty({
+    description: 'Deep Link to redirect',
+  })
+  readonly deepLink: string;
+}
diff --git a/src/push-notification/abstract/push-notification-abstract.module.ts.ts b/src/push-notification/abstract/push-notification-abstract.module.ts.ts
new file mode 100644
index 0000000..3ae36ed
--- /dev/null
+++ b/src/push-notification/abstract/push-notification-abstract.module.ts.ts
@@ -0,0 +1,47 @@
+import { DynamicModule, ForwardReference, Module, Type } from '@nestjs/common';
+import { PushNotificationService } from './push-notification.service';
+import { PushNotificationController } from './push-notification.controller';
+import { PUSH_NOTIFICATION_PROVIDER } from './push-notification-provider.const';
+
+type AdapterModule =
+  | Type<any>
+  | DynamicModule
+  | Promise<DynamicModule>
+  | ForwardReference;
+
+interface PushNotificationModuleOptions {
+  adapter: AdapterModule;
+  useDefaultController?: boolean;
+  isGlobal?: boolean;
+  controllers?: Type<any>[];
+}
+
+@Module({})
+export class PushNotificationAbstractModule {
+  static forRoot(options: PushNotificationModuleOptions): DynamicModule {
+    const {
+      adapter,
+      isGlobal = false,
+      useDefaultController = true,
+      controllers = [],
+    } = options;
+
+    if (useDefaultController) {
+      controllers.push(PushNotificationController);
+    }
+    return {
+      module: PushNotificationAbstractModule,
+      global: isGlobal,
+      providers: [
+        {
+          provide: PushNotificationService,
+          useFactory: (adapter) => adapter,
+          inject: [PUSH_NOTIFICATION_PROVIDER],
+        },
+      ],
+      imports: [adapter],
+      exports: [PushNotificationService],
+      controllers,
+    };
+  }
+} // TODO: Add forRootAsync
diff --git a/src/push-notification/abstract/push-notification-error-codes.ts b/src/push-notification/abstract/push-notification-error-codes.ts
new file mode 100644
index 0000000..c901175
--- /dev/null
+++ b/src/push-notification/abstract/push-notification-error-codes.ts
@@ -0,0 +1,25 @@
+import { HttpStatus } from '@nestjs/common';
+
+export const PUSH_NOTIFICATION_ERRORS: Record<string, IErrorDefinition> = {
+  FILE_REQUIRED: {
+    code: 'CLOUD_STORAGE_FILE_REQUIRED',
+    status: HttpStatus.BAD_REQUEST,
+    message: 'A file is required',
+  },
+  FILE_TOO_LARGE: {
+    code: 'CLOUD_STORAGE_FILE_TOO_LARGE',
+    status: HttpStatus.PAYLOAD_TOO_LARGE,
+    message: 'The file is too large',
+  },
+  UPLOAD_FAILED: {
+    code: 'CLOUD_STORAGE_UPLOAD_FAILED',
+    status: HttpStatus.INTERNAL_SERVER_ERROR,
+    message: 'File upload failed',
+  },
+};
+
+export interface IErrorDefinition {
+  code: string;
+  status: HttpStatus;
+  message: string;
+}
diff --git a/src/push-notification/abstract/push-notification-provider.const.ts b/src/push-notification/abstract/push-notification-provider.const.ts
new file mode 100644
index 0000000..87022ae
--- /dev/null
+++ b/src/push-notification/abstract/push-notification-provider.const.ts
@@ -0,0 +1 @@
+export const PUSH_NOTIFICATION_PROVIDER = 'PUSH_NOTIFICATION_PROVIDER';
diff --git a/src/push-notification/abstract/push-notification.controller.ts b/src/push-notification/abstract/push-notification.controller.ts
new file mode 100644
index 0000000..85ae7b4
--- /dev/null
+++ b/src/push-notification/abstract/push-notification.controller.ts
@@ -0,0 +1,46 @@
+import {
+  Body,
+  Controller,
+  InternalServerErrorException,
+  Param,
+  Post,
+} from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiParam,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import { PushNotificationService } from './push-notification.service';
+import { PushNotificationDto } from './dto/push-notification.dto';
+import { PushNotificationException } from './push-notification.exception';
+import { SendPushNotificationMetadata } from './push-notification.metadata';
+
+@ApiTags('Push Notification')
+@Controller('push-notification')
+// Use an authentication guard if the project requires it.
+export class PushNotificationController {
+  constructor(
+    private readonly pushNotificationService: PushNotificationService,
+  ) {}
+
+  @Post(':token/send')
+  @SendPushNotificationMetadata()
+  async sendPushNotification(
+    @Param('token') token: string,
+    @Body() notificationDto: PushNotificationDto,
+  ): Promise<void> {
+    try {
+      await this.pushNotificationService.sendPushNotification(
+        token,
+        notificationDto,
+      );
+    } catch (error) {
+      if (error instanceof PushNotificationException) {
+        throw error;
+      }
+      throw new InternalServerErrorException(error);
+    }
+  }
+}
diff --git a/src/push-notification/abstract/push-notification.exception.ts b/src/push-notification/abstract/push-notification.exception.ts
new file mode 100644
index 0000000..10664fb
--- /dev/null
+++ b/src/push-notification/abstract/push-notification.exception.ts
@@ -0,0 +1,10 @@
+import { HttpException, HttpStatus } from '@nestjs/common';
+
+/**
+ * Custom exception for the PushNotification module.
+ */
+export class PushNotificationException extends HttpException {
+  constructor(message: string, code: string, status: HttpStatus) {
+    super({ message, code }, status);
+  }
+}
diff --git a/src/push-notification/abstract/push-notification.interface.ts b/src/push-notification/abstract/push-notification.interface.ts
new file mode 100644
index 0000000..c1f605d
--- /dev/null
+++ b/src/push-notification/abstract/push-notification.interface.ts
@@ -0,0 +1,6 @@
+export interface IPushNotification {
+  title: string;
+  body: string;
+  data: Record<string, string>;
+  deepLink?: string;
+}
diff --git a/src/push-notification/abstract/push-notification.metadata.ts b/src/push-notification/abstract/push-notification.metadata.ts
new file mode 100644
index 0000000..7227f66
--- /dev/null
+++ b/src/push-notification/abstract/push-notification.metadata.ts
@@ -0,0 +1,17 @@
+import { applyDecorators } from '@nestjs/common';
+import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
+import { PushNotificationDto } from './dto/push-notification.dto';
+
+/**
+ * OpenAPI metadata for the endpoint that sends a push notification to a specific device token.
+ * Adds request body schema, route parameter, operation summary, and response documentation.
+ * @returns {MethodDecorator} Swagger metadata decorators for the push notification handler.
+ */
+export function SendPushNotificationMetadata(): MethodDecorator {
+  return applyDecorators(
+    ApiBody({ type: PushNotificationDto }),
+    ApiParam({ name: 'token' }),
+    ApiOperation({ summary: 'Send notification to a specific device token' }),
+    ApiResponse({ status: 200, description: 'Complete' }),
+  );
+}
diff --git a/src/push-notification/abstract/push-notification.service.ts b/src/push-notification/abstract/push-notification.service.ts
new file mode 100644
index 0000000..6088543
--- /dev/null
+++ b/src/push-notification/abstract/push-notification.service.ts
@@ -0,0 +1,63 @@
+import { IPushNotification } from './push-notification.interface';
+
+export const PUSH_NOTIFICATION_STATUSES = {
+  SUCCESS: 'SUCCESS',
+  ERROR: 'ERROR',
+} as const;
+
+export type PushNotificationStatus =
+  (typeof PUSH_NOTIFICATION_STATUSES)[keyof typeof PUSH_NOTIFICATION_STATUSES];
+/**
+ * Interface for the success push notification object returned by the Push Notification provider.
+ * @property {string} id - The unique identifier assigned to the notification in the push notification provider.
+ * @property {string} status - Indicates the current status of the notification ("SUCCESS").
+ */
+export interface PushNotificationSuccessResponse {
+  id: string;
+  status: PushNotificationStatus;
+}
+/**
+ * Interface for the error push notification object returned by the Push Notification provider.
+ * @property {string} pushToken - The failed push notification token of the target device.
+ * @property {string} status - Indicates the current status of the notification ("ERROR").
+ * @property {string} message - Additional information related to the error status.
+ */
+export interface PushNotificationErrorResponse {
+  pushToken: string;
+  message: string;
+  status: PushNotificationStatus;
+}
+
+/**
+ * Interface for the push notification report object returned by the Push Notification provider when send multiple notifications in chunks.
+ * @property {PushNotificationSuccessResponse[]} successNotifications - Array of success notifications.
+ * @property {PushNotificationErrorResponse[]} errorNotifications - Array of failed notifications.
+ */
+export interface PushNotificationChunkReport {
+  successNotifications: PushNotificationSuccessResponse[];
+  errorNotifications: PushNotificationErrorResponse[];
+}
+/**
+ * Interface for adapters to implement to work alongside the `PushNotificationModule.`
+ */
+export abstract class PushNotificationService {
+  /**
+   * Send a push notification to a specific mobile device.
+   * @param {string} pushToken - The push notification token of the target device.
+   * @returns {Promise<PushNotificationResponse>} An object containing the push notification response.
+   */
+  abstract sendPushNotification(
+    pushToken: string,
+    notification: IPushNotification,
+  ): Promise<PushNotificationSuccessResponse>;
+
+  /**
+   * Send a push notification to a multiples mobile devices.
+   * @param {string[]} pushTokens - The push notification tokens for the target devices
+   * @returns {void}
+   */
+  abstract sendPushNotificationInChunks(
+    pushTokens: string[],
+    notification: IPushNotification,
+  ): Promise<PushNotificationChunkReport>;
+}
diff --git a/src/push-notification/expo-adapter/expo-adapter-config-provider.const.ts b/src/push-notification/expo-adapter/expo-adapter-config-provider.const.ts
new file mode 100644
index 0000000..06d4643
--- /dev/null
+++ b/src/push-notification/expo-adapter/expo-adapter-config-provider.const.ts
@@ -0,0 +1 @@
+export const EXPO_ADAPTER_PROVIDER_CONFIG = 'EXPO_ADAPTER_PROVIDER_CONFIG';
diff --git a/src/push-notification/expo-adapter/expo-adapter-config.interface.ts b/src/push-notification/expo-adapter/expo-adapter-config.interface.ts
new file mode 100644
index 0000000..4849b54
--- /dev/null
+++ b/src/push-notification/expo-adapter/expo-adapter-config.interface.ts
@@ -0,0 +1,3 @@
+export interface ExpoAdapterConfig {
+  expoAccessToken: string;
+}
diff --git a/src/push-notification/expo-adapter/expo-adapter.module.ts b/src/push-notification/expo-adapter/expo-adapter.module.ts
new file mode 100644
index 0000000..d5b639a
--- /dev/null
+++ b/src/push-notification/expo-adapter/expo-adapter.module.ts
@@ -0,0 +1,49 @@
+import {
+  DynamicModule,
+  InjectionToken,
+  Module,
+  ModuleMetadata,
+} from '@nestjs/common';
+import { ExpoAdapterService } from './expo-adapter.service';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { PUSH_NOTIFICATION_PROVIDER } from '../abstract/push-notification-provider.const';
+
+@Module({})
+export class ExpoAdapterModule {
+  static register(config: ExpoAdapterConfig): DynamicModule {
+    return {
+      module: ExpoAdapterModule,
+      providers: [
+        {
+          provide: EXPO_ADAPTER_PROVIDER_CONFIG,
+          useValue: config,
+        },
+        { provide: PUSH_NOTIFICATION_PROVIDER, useClass: ExpoAdapterService },
+      ],
+      exports: [PUSH_NOTIFICATION_PROVIDER],
+    };
+  }
+
+  static registerAsync(options: {
+    imports?: ModuleMetadata['imports'];
+    inject?: InjectionToken[];
+    useFactory: (
+      ...args: any[]
+    ) => Promise<ExpoAdapterConfig> | ExpoAdapterConfig;
+  }): DynamicModule {
+    return {
+      module: ExpoAdapterModule,
+      imports: options.imports || [],
+      providers: [
+        {
+          provide: EXPO_ADAPTER_PROVIDER_CONFIG,
+          useFactory: options.useFactory,
+          inject: options.inject || [],
+        },
+        { provide: PUSH_NOTIFICATION_PROVIDER, useClass: ExpoAdapterService },
+      ],
+      exports: [PUSH_NOTIFICATION_PROVIDER],
+    };
+  }
+}
diff --git a/src/push-notification/expo-adapter/expo-adapter.service.ts b/src/push-notification/expo-adapter/expo-adapter.service.ts
new file mode 100644
index 0000000..a9b837b
--- /dev/null
+++ b/src/push-notification/expo-adapter/expo-adapter.service.ts
@@ -0,0 +1,147 @@
+import {
+  Inject,
+  Injectable,
+  InternalServerErrorException,
+} from '@nestjs/common';
+import Expo, { ExpoPushTicket } from 'expo-server-sdk';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { IPushNotification } from '../abstract/push-notification.interface';
+import {
+  PUSH_NOTIFICATION_STATUSES,
+  PushNotificationChunkReport,
+  PushNotificationErrorResponse,
+  PushNotificationService,
+  PushNotificationSuccessResponse,
+} from '../abstract/push-notification.service';
+import { PUSH_NOTIFICATION_EXPO_STATUSES } from './expo.types';
+
+@Injectable()
+export class ExpoAdapterService extends PushNotificationService {
+  private expo: Expo;
+  private sound: string;
+  constructor(
+    @Inject(EXPO_ADAPTER_PROVIDER_CONFIG)
+    config: ExpoAdapterConfig,
+  ) {
+    super();
+    this.sound = 'default';
+    this.expo = new Expo({
+      accessToken: config.expoAccessToken,
+      useFcmV1: true,
+    });
+  }
+
+  async sendPushNotification(
+    pushToken: string,
+    notification: IPushNotification,
+  ): Promise<PushNotificationSuccessResponse> {
+    try {
+      if (!Expo.isExpoPushToken(pushToken)) {
+        console.error(
+          `[sendPushNotification]: Push token ${pushToken} is invalid`,
+        );
+        throw new InternalServerErrorException(
+          `[sendPushNotification]: Push token ${pushToken} is invalid`,
+        );
+      }
+      const message = [
+        {
+          to: pushToken,
+          sound: this.sound,
+          title: notification.title,
+          body: notification.body,
+          data: {
+            ...notification.data,
+            ...(notification.deepLink && { deepLink: notification.deepLink }),
+          },
+        },
+      ];
+      const expoPushTicket: ExpoPushTicket[] =
+        await this.expo.sendPushNotificationsAsync(message);
+      const relatedTicket = expoPushTicket[0];
+      if (relatedTicket.status === PUSH_NOTIFICATION_EXPO_STATUSES.ERROR) {
+        console.error(
+          `[sendPushNotification]: ERROR: error send push notification to device token. Error: ${relatedTicket.message}`,
+        );
+        throw new InternalServerErrorException(relatedTicket.message);
+      }
+      console.log(
+        `[sendPushNotification]: Send notification succesfully to device token`,
+      );
+      return {
+        id: relatedTicket.id,
+        status: PUSH_NOTIFICATION_STATUSES.SUCCESS,
+      };
+    } catch (error) {
+      console.error(
+        `[sendPushNotification]: ERROR: error send push notification to device token. Error: ${error}`,
+      );
+      // TODO: Integrate log provider in this service
+      throw error;
+    }
+  }
+
+  async sendPushNotificationInChunks(
+    pushTokens: string[],
+    notification: IPushNotification,
+  ): Promise<PushNotificationChunkReport> {
+    try {
+      const validTokens = pushTokens.filter((token) =>
+        Expo.isExpoPushToken(token),
+      );
+      let ticketChunks: ExpoPushTicket[] = [];
+      const messages = validTokens.map((token) => ({
+        to: token,
+        sound: this.sound,
+        title: notification.title,
+        body: notification.body,
+        data: {
+          ...notification.data,
+          ...(notification.deepLink && { deepLink: notification.deepLink }),
+        },
+      }));
+      const chunks = this.expo.chunkPushNotifications(messages);
+      for (const chunk of chunks) {
+        ticketChunks = await this.expo.sendPushNotificationsAsync(chunk);
+      }
+      const { successNotifications, errorNotifications } =
+        this.getExpoPushNotificationChunkReport(ticketChunks);
+      return { successNotifications, errorNotifications };
+    } catch (error) {
+      console.error(
+        `[sendChunksPushNotification]: ERROR: error send push notification to multiples devices. Error: ${error}`,
+      );
+      throw error;
+    }
+  }
+
+  getExpoPushNotificationChunkReport(ticketChunks: ExpoPushTicket[]): {
+    errorNotifications: PushNotificationErrorResponse[];
+    successNotifications: PushNotificationSuccessResponse[];
+  } {
+    const successNotifications: PushNotificationSuccessResponse[] = [];
+    const errorNotifications: PushNotificationErrorResponse[] = [];
+    for (const ticket of ticketChunks) {
+      if (ticket.status === PUSH_NOTIFICATION_EXPO_STATUSES.ERROR) {
+        const errorToken = ticket.details?.expoPushToken;
+        const errorMessage = ticket.message;
+        console.error(
+          `[sendPushNotificationInChunks]: ERROR: error send push notification to device token ${errorToken}. Error: ${errorMessage}`,
+        );
+        errorNotifications.push({
+          message: ticket.message,
+          pushToken: errorToken || '',
+          status: PUSH_NOTIFICATION_STATUSES.ERROR,
+        });
+      }
+      if (ticket.status === PUSH_NOTIFICATION_EXPO_STATUSES.OK) {
+        successNotifications.push({
+          id: ticket.id,
+          status: PUSH_NOTIFICATION_STATUSES.SUCCESS,
+        });
+      }
+    }
+    return { successNotifications, errorNotifications };
+  }
+}
diff --git a/src/push-notification/expo-adapter/expo.types.ts b/src/push-notification/expo-adapter/expo.types.ts
new file mode 100644
index 0000000..de8c040
--- /dev/null
+++ b/src/push-notification/expo-adapter/expo.types.ts
@@ -0,0 +1,7 @@
+export const PUSH_NOTIFICATION_EXPO_STATUSES = {
+  OK: 'ok',
+  ERROR: 'error',
+} as const;
+
+export type PushNotificationExpoStatus =
+  (typeof PUSH_NOTIFICATION_EXPO_STATUSES)[keyof typeof PUSH_NOTIFICATION_EXPO_STATUSES];


Reviews:
user: login: frankmangone
body: @FloPereira acordate de lo del logger, estaría bueno portearlo como está armado en el `TasksModule`!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T19:27:24Z
user: login: FloPereira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T19:35:36Z
user: login: FloPereira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:32:59Z
user: login: frankmangone
body: Un par de comentarios más nomás, pero queda aprobado!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T20:32:42Z

Review comments:
diff_hunk: @@ -21,6 +21,19 @@
           "description": "S3 implementation of cloud storage module"
         }
       ]
+    },
+    "push-notification": {
+      "path": "src/push-notification",
+      "implementations": [
+        {
+          "name": "abstract",
+          "description": "Abstract implementation of push notification module"
+        },
+        {
+          "name": "expo-adapter",
+          "description": "Expo server SDK implementation of push notification module"
path: planetary.json
user: login: frankmangone
body: Agregate una key que sea `dependencies`, que sea un array de las dependencias, y tenga un solo valor: `expo-server-sdk` (como string):

```json
{
    "name": "expo-adapter",
    "description": "Expo server SDK implementation of push notification module",
    "dependencies": ["expo-server-sdk"],
}
```

Así le agrego a Planetary
created_at: 2025-04-04T19:06:24Z
author_association: CONTRIBUTOR
path: src/push-notification/abstract/dto/file-response.dto.ts
user: login: frankmangone
body: Este DTO tiene sentido? Parece haber quedado del CloudStorage
created_at: 2025-04-04T19:07:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+import { DynamicModule, ForwardReference, Module, Type } from '@nestjs/common';
+import { PushNotificationService } from './push-notification.service';
+import { PushNotificationController } from './push-notification.controller';
+import { PUSH_NOTIFICATION_PROVIDER } from './push-notification-provider.const';
+
+type AdapterModule =
+  | Type<any>
+  | DynamicModule
+  | Promise<DynamicModule>
+  | ForwardReference;
+
+interface PushNotificationModuleOptions {
+  adapter: AdapterModule;
+  useDefaultController?: boolean;
+  isGlobal?: boolean;
+  customController?: Type<any>[];
+}
+
+@Module({})
+export class PushNotificationAbstractModule {
+  static forRoot(options: PushNotificationModuleOptions): DynamicModule {
+    const {
+      adapter,
+      isGlobal = false,
+      useDefaultController = true,
+      customController,
path: src/push-notification/abstract/push-notification-abstract.module.ts.ts
user: login: frankmangone
body: Si hacemos esto, sugiero llamarlo `controllers` como es el default de Nest digamos!

Y asignale un valor por defecto. Ahora más abajo explico por qué: 

```ts
controllers = [],
```
created_at: 2025-04-04T19:13:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+import { DynamicModule, ForwardReference, Module, Type } from '@nestjs/common';
+import { PushNotificationService } from './push-notification.service';
+import { PushNotificationController } from './push-notification.controller';
+import { PUSH_NOTIFICATION_PROVIDER } from './push-notification-provider.const';
+
+type AdapterModule =
+  | Type<any>
+  | DynamicModule
+  | Promise<DynamicModule>
+  | ForwardReference;
+
+interface PushNotificationModuleOptions {
+  adapter: AdapterModule;
+  useDefaultController?: boolean;
+  isGlobal?: boolean;
+  customController?: Type<any>[];
+}
+
+@Module({})
+export class PushNotificationAbstractModule {
+  static forRoot(options: PushNotificationModuleOptions): DynamicModule {
+    const {
+      adapter,
+      isGlobal = false,
+      useDefaultController = true,
+      customController,
+    } = options;
+
+    const controllers =
path: src/push-notification/abstract/push-notification-abstract.module.ts.ts
user: login: frankmangone
body: Y acá hacés:

```ts
if (useDefaultController) {
    controllers.push(PushNotificationsController);
}
```
created_at: 2025-04-04T19:16:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+import {
+  Body,
+  Controller,
+  InternalServerErrorException,
+  Param,
+  Post,
+} from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiParam,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import { PushNotificationService } from './push-notification.service';
+import { PushNotificationDto } from './dto/push-notification.dto';
+import { PushNotificationException } from './push-notification.exception';
+
+@ApiTags('Push Notification')
+@Controller('push-notification')
+// Use an authentication guard if the project requires it.
+export class PushNotificationController {
+  constructor(
+    private readonly pushNotificationService: PushNotificationService,
+  ) {}
+
+  @Post(':token/send')
+  @ApiBody({ type: PushNotificationDto })
path: src/push-notification/abstract/push-notification.controller.ts
user: login: frankmangone
body: Cuando son muchos parámetros, me gusta mover la metadata a un archivo aparte, y hacer algo así:

```ts
/**
 * OpenAPI metadata for the start onboarding (POST /onboarding) endpoint.
 * @returns {MethodDecorator} The metadata decorator for the handler.
 */
export function SendPushNotificationMetadata() {
  return applyDecorators(
    ApiBody({ type: PushNotificationDto }),
    ApiBadRequestResponse(ERROR_RESPONSE_METADATA[400]),
    ApiUnauthorizedResponse(ERROR_RESPONSE_METADATA[401]),
    ApiParam({ name: 'token' })
    ApiOperation({ summary: 'Send notification to a scpecific device token' })
    ApiResponse({ status: 200, description: 'Complete' })
  );
}
```

Also, agregale `ApiTags`. Y esto se usaría así en el controller:

```ts
@Post(':token/send')
@SendPushNotificationMetadata()
async sendPushNotification (
...
```
created_at: 2025-04-04T19:20:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+import {
+  Body,
+  Controller,
+  InternalServerErrorException,
+  Param,
+  Post,
+} from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiParam,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import { PushNotificationService } from './push-notification.service';
+import { PushNotificationDto } from './dto/push-notification.dto';
+import { PushNotificationException } from './push-notification.exception';
+
+@ApiTags('Push Notification')
+@Controller('push-notification')
+// Use an authentication guard if the project requires it.
+export class PushNotificationController {
+  constructor(
+    private readonly pushNotificationService: PushNotificationService,
+  ) {}
+
+  @Post(':token/send')
+  @ApiBody({ type: PushNotificationDto })
+  @ApiParam({ name: 'token' })
+  @ApiOperation({ summary: 'Send notification to a scpecific device token' })
path: src/push-notification/abstract/push-notification.controller.ts
user: login: frankmangone
body: Typo: es `specific`, dice `scpecific`! :D
created_at: 2025-04-04T19:20:33Z
author_association: CONTRIBUTOR
path: src/push-notification/abstract/dto/push-notification.dto.ts
user: login: frankmangone
body: Les suelo llamar `InputDto` al final, porque también pueden haber DTO de salida (output). Fijate si te convence!
created_at: 2025-04-04T19:21:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+import { IPushNotification } from './push-notification.interface';
+
+export enum PushNotificationStatusEnum {
path: src/push-notification/abstract/push-notification.service.ts
user: login: frankmangone
body: No usemos enums así. Tienen un montón de problemas.

Hacelo así:

```ts
export const PUSH_NOTIFICATION_STATUSES = {
    SUCCESS: 'SUCCESS',
    ERROR: 'ERROR'
} as const
```
created_at: 2025-04-04T19:23:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,49 @@
+import {
+  DynamicModule,
+  InjectionToken,
+  Module,
+  ModuleMetadata,
+} from '@nestjs/common';
+import { ExpoAdapterService } from './expo-adapter.service';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { PUSH_NOTIFICATION_PROVIDER } from '../abstract/push-notification-provider.const';
+
+@Module({})
+export class ExpoAdapterModule {
+  static register(config: ExpoAdapterConfig): DynamicModule {
+    return {
+      module: ExpoAdapterModule,
+      providers: [
+        {
+          provide: EXPO_ADAPTER_PROVIDER_CONFIG,
+          useValue: config,
+        },
+        { provide: PUSH_NOTIFICATION_PROVIDER, useClass: ExpoAdapterService },
+      ],
+      exports: [PUSH_NOTIFICATION_PROVIDER, EXPO_ADAPTER_PROVIDER_CONFIG],
path: src/push-notification/expo-adapter/expo-adapter.module.ts
user: login: frankmangone
body: Por qué necesitás exportar la configuración? No quedaría encapsulada?
created_at: 2025-04-04T19:24:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+import {
+  Inject,
+  Injectable,
+  InternalServerErrorException,
+} from '@nestjs/common';
+import Expo, { ExpoPushTicket } from 'expo-server-sdk';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { IPushNotification } from '../abstract/push-notification.interface';
+import {
+  PushNotificationChunkReport,
+  PushNotificationErrorResponse,
+  PushNotificationService,
+  PushNotificationStatusEnum,
+  PushNotificationSuccessResponse,
+} from '../abstract/push-notification.service';
+
+@Injectable()
+export class ExpoAdapterService extends PushNotificationService {
+  expo: Expo;
path: src/push-notification/expo-adapter/expo-adapter.service.ts
user: login: frankmangone
body: Estas propiedades pueden ser `private`, no?
created_at: 2025-04-04T19:25:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+import {
+  Inject,
+  Injectable,
+  InternalServerErrorException,
+} from '@nestjs/common';
+import Expo, { ExpoPushTicket } from 'expo-server-sdk';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { IPushNotification } from '../abstract/push-notification.interface';
+import {
+  PushNotificationChunkReport,
+  PushNotificationErrorResponse,
+  PushNotificationService,
+  PushNotificationStatusEnum,
+  PushNotificationSuccessResponse,
+} from '../abstract/push-notification.service';
+
+@Injectable()
+export class ExpoAdapterService extends PushNotificationService {
+  expo: Expo;
+  sound: string;
+  errorLabel: string;
+  constructor(
+    @Inject(EXPO_ADAPTER_PROVIDER_CONFIG)
+    config: ExpoAdapterConfig,
+  ) {
+    super();
+    this.errorLabel = 'error';
+    this.sound = 'default';
+    this.expo = new Expo({
+      accessToken: config.expoAccessToken,
+      useFcmV1: true,
+    });
+  }
+
+  async sendPushNotification(
+    pushToken: string,
+    notification: IPushNotification,
+  ): Promise<PushNotificationSuccessResponse> {
+    try {
+      if (!Expo.isExpoPushToken(pushToken)) {
+        console.error(
path: src/push-notification/expo-adapter/expo-adapter.service.ts
user: login: frankmangone
body: Por qué no usás el logger?
created_at: 2025-04-04T19:26:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+import {
+  Inject,
+  Injectable,
+  InternalServerErrorException,
+} from '@nestjs/common';
+import Expo, { ExpoPushTicket } from 'expo-server-sdk';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { IPushNotification } from '../abstract/push-notification.interface';
+import {
+  PushNotificationChunkReport,
+  PushNotificationErrorResponse,
+  PushNotificationService,
+  PushNotificationStatusEnum,
+  PushNotificationSuccessResponse,
+} from '../abstract/push-notification.service';
+
+@Injectable()
+export class ExpoAdapterService extends PushNotificationService {
+  expo: Expo;
+  sound: string;
+  errorLabel: string;
+  constructor(
+    @Inject(EXPO_ADAPTER_PROVIDER_CONFIG)
+    config: ExpoAdapterConfig,
+  ) {
+    super();
+    this.errorLabel = 'error';
+    this.sound = 'default';
+    this.expo = new Expo({
+      accessToken: config.expoAccessToken,
+      useFcmV1: true,
+    });
+  }
+
+  async sendPushNotification(
+    pushToken: string,
+    notification: IPushNotification,
+  ): Promise<PushNotificationSuccessResponse> {
+    try {
+      if (!Expo.isExpoPushToken(pushToken)) {
+        console.error(
+          `[sendPushNotification]: Push token ${pushToken} is invalid`,
+        );
+        throw new InternalServerErrorException(
+          `[sendPushNotification]: Push token ${pushToken} is invalid`,
+        );
+      }
+      const message = [
+        {
+          to: pushToken,
+          sound: this.sound,
+          title: notification.title,
+          body: notification.body,
+          data: {
+            ...notification.data,
+            ...(notification.deepLink && { deepLink: notification.deepLink }),
+          },
+        },
+      ];
+      const expoPushTicket: ExpoPushTicket[] =
+        await this.expo.sendPushNotificationsAsync(message);
+      const relatedTicket = expoPushTicket[0];
+      if (relatedTicket.status === ExpoStatusNotificationEnum.ERROR) {
+        console.error(
+          `[sendPushNotification]: ERROR: error send push notification to device token. Error: ${relatedTicket.message}`,
+        );
+        throw new InternalServerErrorException(relatedTicket.message);
+      }
+      console.log(
+        `[sendPushNotification]: Send notification succesfully to device token`,
+      );
+      return {
+        id: relatedTicket.id,
+        status: PushNotificationStatusEnum.SUCCESS,
+      };
+    } catch (error) {
+      console.error(
+        `[sendPushNotification]: ERROR: error send push notification to device token. Error: ${error}`,
+      );
+      throw error;
+    }
+  }
+
+  async sendPushNotificationInChunks(
+    pushTokens: string[],
+    notification: IPushNotification,
+  ): Promise<PushNotificationChunkReport> {
+    try {
+      const validTokens = pushTokens.filter((token) =>
+        Expo.isExpoPushToken(token),
+      );
+      let ticketChunks: ExpoPushTicket[] = [];
+      const messages = validTokens.map((token) => ({
+        to: token,
+        sound: this.sound,
+        title: notification.title,
+        body: notification.body,
+        data: {
+          ...notification.data,
+          ...(notification.deepLink && { deepLink: notification.deepLink }),
+        },
+      }));
+      const chunks = this.expo.chunkPushNotifications(messages);
+      for (const chunk of chunks) {
+        ticketChunks = await this.expo.sendPushNotificationsAsync(chunk);
+      }
+      const { successNotifications, errorNotifications } =
+        this.getExpoPushNotificationChunkReport(ticketChunks);
+      return { successNotifications, errorNotifications };
+    } catch (error) {
+      console.error(
+        `[sendChunksPushNotification]: ERROR: error send push notification to multiples devices. Error: ${error}`,
+      );
+      throw error;
+    }
+  }
+
+  getExpoPushNotificationChunkReport(ticketChunks: ExpoPushTicket[]): {
+    errorNotifications: PushNotificationErrorResponse[];
+    successNotifications: PushNotificationSuccessResponse[];
+  } {
+    const successNotifications: PushNotificationSuccessResponse[] = [];
+    const errorNotifications: PushNotificationErrorResponse[] = [];
+    for (const ticket of ticketChunks) {
+      if (ticket.status === ExpoStatusNotificationEnum.ERROR) {
+        const errorToken = ticket.details?.expoPushToken;
+        const errorMessage = ticket.message;
+        console.error(
+          `[sendPushNotificationInChunks]: ERROR: error send push notification to device token ${errorToken}. Error: ${errorMessage}`,
+        );
+        errorNotifications.push({
+          message: ticket.message,
+          pushToken: errorToken || '',
+          status: PushNotificationStatusEnum.ERROR,
+        });
+      }
+      if (ticket.status === ExpoStatusNotificationEnum.OK) {
+        successNotifications.push({
+          id: ticket.id,
+          status: PushNotificationStatusEnum.SUCCESS,
+        });
+      }
+    }
+    return { successNotifications, errorNotifications };
+  }
+}
+
+enum ExpoStatusNotificationEnum {
path: src/push-notification/expo-adapter/expo-adapter.service.ts
user: login: frankmangone
body: Hacé esto una constante, como en el comentario anterior, y al menos movámoslo al comienzo del archivo.
created_at: 2025-04-04T19:26:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,6 +21,19 @@
           "description": "S3 implementation of cloud storage module"
         }
       ]
+    },
+    "push-notification": {
+      "path": "src/push-notification",
+      "implementations": [
+        {
+          "name": "abstract",
+          "description": "Abstract implementation of push notification module"
+        },
+        {
+          "name": "expo-adapter",
+          "description": "Expo server SDK implementation of push notification module"
path: planetary.json
user: login: FloPereira
body: Done
created_at: 2025-04-09T19:35:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+import {
+  Inject,
+  Injectable,
+  InternalServerErrorException,
+} from '@nestjs/common';
+import Expo, { ExpoPushTicket } from 'expo-server-sdk';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { IPushNotification } from '../abstract/push-notification.interface';
+import {
+  PushNotificationChunkReport,
+  PushNotificationErrorResponse,
+  PushNotificationService,
+  PushNotificationStatusEnum,
+  PushNotificationSuccessResponse,
+} from '../abstract/push-notification.service';
+
+@Injectable()
+export class ExpoAdapterService extends PushNotificationService {
+  expo: Expo;
+  sound: string;
+  errorLabel: string;
+  constructor(
+    @Inject(EXPO_ADAPTER_PROVIDER_CONFIG)
+    config: ExpoAdapterConfig,
+  ) {
+    super();
+    this.errorLabel = 'error';
+    this.sound = 'default';
+    this.expo = new Expo({
+      accessToken: config.expoAccessToken,
+      useFcmV1: true,
+    });
+  }
+
+  async sendPushNotification(
+    pushToken: string,
+    notification: IPushNotification,
+  ): Promise<PushNotificationSuccessResponse> {
+    try {
+      if (!Expo.isExpoPushToken(pushToken)) {
+        console.error(
path: src/push-notification/expo-adapter/expo-adapter.service.ts
user: login: FloPereira
body: @frankmangone recien lo charlamos, lo dejamos para despues cuando tengamos lo de pasar loggers dinamicos
created_at: 2025-04-09T21:32:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import { DynamicModule, ForwardReference, Module, Type } from '@nestjs/common';
+import { PushNotificationService } from './push-notification.service';
+import { PushNotificationController } from './push-notification.controller';
+import { PUSH_NOTIFICATION_PROVIDER } from './push-notification-provider.const';
+
+type AdapterModule =
+  | Type<any>
+  | DynamicModule
+  | Promise<DynamicModule>
+  | ForwardReference;
+
+interface PushNotificationModuleOptions {
+  adapter: AdapterModule;
+  useDefaultController?: boolean;
+  isGlobal?: boolean;
+  controllers?: Type<any>[];
+}
+
+@Module({})
+export class PushNotificationAbstractModule {
+  static forRoot(options: PushNotificationModuleOptions): DynamicModule {
+    const {
+      adapter,
+      isGlobal = false,
+      useDefaultController = true,
+      controllers = [],
+    } = options;
+
+    if (useDefaultController) {
+      controllers.push(PushNotificationController);
+    }
+    return {
+      module: PushNotificationAbstractModule,
+      global: isGlobal,
+      providers: [
+        {
+          provide: PushNotificationService,
+          useFactory: (adapter) => adapter,
+          inject: [PUSH_NOTIFICATION_PROVIDER],
+        },
+      ],
+      imports: [adapter],
+      exports: [PushNotificationService],
+      controllers,
+    };
+  }
path: src/push-notification/abstract/push-notification-abstract.module.ts.ts
user: login: frankmangone
body: Si querés, dejale un TODO para agregar el `forRootAsync`. No hace falta que lo hagas ahora, pero así nos acordamos!
created_at: 2025-04-15T20:30:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,17 @@
+import { applyDecorators } from '@nestjs/common';
+import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
+import { PushNotificationDto } from './dto/push-notification.dto';
+
+/**
+ * OpenAPI metadata for the endpoint that sends a push notification to a specific device token.
+ * Adds request body schema, route parameter, operation summary, and response documentation.
+ * @returns {MethodDecorator} Swagger metadata decorators for the push notification handler.
+ */
+export function SendPushNotificationMetadata(): MethodDecorator {
+  return applyDecorators(
+    ApiBody({ type: PushNotificationDto }),
+    ApiParam({ name: 'token' }),
+    ApiOperation({ summary: 'Send notification to a scpecific device token' }),
path: src/push-notification/abstract/push-notification.metadata.ts
user: login: frankmangone
body: Sigue diciendo `scpecific`! :D
created_at: 2025-04-15T20:31:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+import {
+  Inject,
+  Injectable,
+  InternalServerErrorException,
+} from '@nestjs/common';
+import Expo, { ExpoPushTicket } from 'expo-server-sdk';
+import { EXPO_ADAPTER_PROVIDER_CONFIG } from './expo-adapter-config-provider.const';
+import { ExpoAdapterConfig } from './expo-adapter-config.interface';
+import { IPushNotification } from '../abstract/push-notification.interface';
+import {
+  PushNotificationChunkReport,
+  PushNotificationErrorResponse,
+  PushNotificationService,
+  PushNotificationStatusEnum,
+  PushNotificationSuccessResponse,
+} from '../abstract/push-notification.service';
+
+@Injectable()
+export class ExpoAdapterService extends PushNotificationService {
+  expo: Expo;
+  sound: string;
+  errorLabel: string;
+  constructor(
+    @Inject(EXPO_ADAPTER_PROVIDER_CONFIG)
+    config: ExpoAdapterConfig,
+  ) {
+    super();
+    this.errorLabel = 'error';
+    this.sound = 'default';
+    this.expo = new Expo({
+      accessToken: config.expoAccessToken,
+      useFcmV1: true,
+    });
+  }
+
+  async sendPushNotification(
+    pushToken: string,
+    notification: IPushNotification,
+  ): Promise<PushNotificationSuccessResponse> {
+    try {
+      if (!Expo.isExpoPushToken(pushToken)) {
+        console.error(
path: src/push-notification/expo-adapter/expo-adapter.service.ts
user: login: frankmangone
body: Dejemos un `TODO` capaz!
created_at: 2025-04-15T20:32:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-15T20:43:02Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=SpaceUY_NestJS-Template&pullRequest=11) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [11 New issues](https://sonarcloud.io/project/issues?id=SpaceUY_NestJS-Template&pullRequest=11&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=SpaceUY_NestJS-Template&pullRequest=11&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=SpaceUY_NestJS-Template&pullRequest=11&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=SpaceUY_NestJS-Template&pullRequest=11&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=SpaceUY_NestJS-Template&pullRequest=11&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=SpaceUY_NestJS-Template&pullRequest=11)


author_association: NONE