- Backend NestJS con módulos de autenticación, inventario, créditos - Frontend React con dashboard y componentes UI - Base de datos PostgreSQL con migraciones - Tests E2E configurados - Configuración de Docker y deployment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
229 lines
6.4 KiB
TypeScript
229 lines
6.4 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import * as admin from 'firebase-admin';
|
|
import {
|
|
Notification,
|
|
NotificationType,
|
|
} from './entities/notification.entity';
|
|
import { UsersService } from '../users/users.service';
|
|
|
|
@Injectable()
|
|
export class NotificationsService {
|
|
private readonly logger = new Logger(NotificationsService.name);
|
|
private firebaseApp: admin.app.App | null = null;
|
|
|
|
constructor(
|
|
@InjectRepository(Notification)
|
|
private readonly notificationsRepository: Repository<Notification>,
|
|
private readonly usersService: UsersService,
|
|
private readonly configService: ConfigService,
|
|
) {
|
|
this.initializeFirebase();
|
|
}
|
|
|
|
private initializeFirebase() {
|
|
const projectId = this.configService.get('FIREBASE_PROJECT_ID');
|
|
const clientEmail = this.configService.get('FIREBASE_CLIENT_EMAIL');
|
|
const privateKey = this.configService.get('FIREBASE_PRIVATE_KEY');
|
|
|
|
if (projectId && clientEmail && privateKey && !privateKey.includes('YOUR_KEY')) {
|
|
try {
|
|
this.firebaseApp = admin.initializeApp({
|
|
credential: admin.credential.cert({
|
|
projectId,
|
|
clientEmail,
|
|
privateKey: privateKey.replace(/\\n/g, '\n'),
|
|
}),
|
|
});
|
|
this.logger.log('Firebase initialized successfully');
|
|
} catch (error) {
|
|
this.logger.error('Failed to initialize Firebase:', error.message);
|
|
}
|
|
} else {
|
|
this.logger.warn('Firebase not configured - push notifications disabled');
|
|
}
|
|
}
|
|
|
|
async sendPush(
|
|
userId: string,
|
|
type: NotificationType,
|
|
title: string,
|
|
body: string,
|
|
data?: Record<string, unknown>,
|
|
): Promise<Notification> {
|
|
// Create notification record
|
|
const notification = this.notificationsRepository.create({
|
|
userId,
|
|
type,
|
|
title,
|
|
body,
|
|
data,
|
|
});
|
|
|
|
await this.notificationsRepository.save(notification);
|
|
|
|
// Try to send push notification
|
|
const user = await this.usersService.findById(userId);
|
|
if (user?.fcmToken && this.firebaseApp) {
|
|
try {
|
|
await admin.messaging().send({
|
|
token: user.fcmToken,
|
|
notification: { title, body },
|
|
data: data ? this.stringifyData(data) : undefined,
|
|
android: {
|
|
priority: 'high',
|
|
notification: {
|
|
channelId: 'miinventario_default',
|
|
},
|
|
},
|
|
apns: {
|
|
payload: {
|
|
aps: {
|
|
sound: 'default',
|
|
badge: 1,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
notification.isPushSent = true;
|
|
await this.notificationsRepository.save(notification);
|
|
this.logger.log(`Push sent to user ${userId}: ${title}`);
|
|
} catch (error) {
|
|
this.logger.error(`Failed to send push to ${userId}:`, error.message);
|
|
|
|
// If token is invalid, clear it
|
|
if (error.code === 'messaging/invalid-registration-token' ||
|
|
error.code === 'messaging/registration-token-not-registered') {
|
|
await this.usersService.updateFcmToken(userId, null);
|
|
}
|
|
}
|
|
} else if (!this.firebaseApp) {
|
|
this.logger.warn(`Push simulation for ${userId}: ${title}`);
|
|
}
|
|
|
|
return notification;
|
|
}
|
|
|
|
async notifyVideoProcessingComplete(
|
|
userId: string,
|
|
videoId: string,
|
|
itemsDetected: number,
|
|
) {
|
|
return this.sendPush(
|
|
userId,
|
|
NotificationType.VIDEO_PROCESSING_COMPLETE,
|
|
'Video procesado',
|
|
`Se detectaron ${itemsDetected} productos en tu anaquel`,
|
|
{ videoId, itemsDetected },
|
|
);
|
|
}
|
|
|
|
async notifyVideoProcessingFailed(
|
|
userId: string,
|
|
videoId: string,
|
|
errorMessage: string,
|
|
) {
|
|
return this.sendPush(
|
|
userId,
|
|
NotificationType.VIDEO_PROCESSING_FAILED,
|
|
'Error al procesar video',
|
|
'Hubo un problema al procesar tu video. Intenta de nuevo.',
|
|
{ videoId, error: errorMessage },
|
|
);
|
|
}
|
|
|
|
async notifyLowCredits(userId: string, currentBalance: number) {
|
|
return this.sendPush(
|
|
userId,
|
|
NotificationType.LOW_CREDITS,
|
|
'Creditos bajos',
|
|
`Te quedan ${currentBalance} creditos. Recarga para seguir escaneando.`,
|
|
{ balance: currentBalance },
|
|
);
|
|
}
|
|
|
|
async notifyPaymentComplete(
|
|
userId: string,
|
|
paymentId: string,
|
|
creditsGranted: number,
|
|
) {
|
|
return this.sendPush(
|
|
userId,
|
|
NotificationType.PAYMENT_COMPLETE,
|
|
'Pago completado',
|
|
`Se agregaron ${creditsGranted} creditos a tu cuenta`,
|
|
{ paymentId, creditsGranted },
|
|
);
|
|
}
|
|
|
|
async notifyPaymentFailed(userId: string, paymentId: string) {
|
|
return this.sendPush(
|
|
userId,
|
|
NotificationType.PAYMENT_FAILED,
|
|
'Pago fallido',
|
|
'Tu pago no pudo ser procesado. Intenta con otro metodo.',
|
|
{ paymentId },
|
|
);
|
|
}
|
|
|
|
async notifyReferralBonus(
|
|
userId: string,
|
|
bonusCredits: number,
|
|
referredName: string,
|
|
) {
|
|
return this.sendPush(
|
|
userId,
|
|
NotificationType.REFERRAL_BONUS,
|
|
'Bonus de referido',
|
|
`${referredName} uso tu codigo. Ganaste ${bonusCredits} creditos.`,
|
|
{ bonusCredits, referredName },
|
|
);
|
|
}
|
|
|
|
async getUserNotifications(
|
|
userId: string,
|
|
page = 1,
|
|
limit = 20,
|
|
): Promise<{ notifications: Notification[]; total: number }> {
|
|
const [notifications, total] = await this.notificationsRepository.findAndCount({
|
|
where: { userId },
|
|
order: { createdAt: 'DESC' },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
});
|
|
|
|
return { notifications, total };
|
|
}
|
|
|
|
async markAsRead(userId: string, notificationId: string): Promise<void> {
|
|
await this.notificationsRepository.update(
|
|
{ id: notificationId, userId },
|
|
{ isRead: true },
|
|
);
|
|
}
|
|
|
|
async markAllAsRead(userId: string): Promise<void> {
|
|
await this.notificationsRepository.update(
|
|
{ userId, isRead: false },
|
|
{ isRead: true },
|
|
);
|
|
}
|
|
|
|
async getUnreadCount(userId: string): Promise<number> {
|
|
return this.notificationsRepository.count({
|
|
where: { userId, isRead: false },
|
|
});
|
|
}
|
|
|
|
private stringifyData(data: Record<string, unknown>): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(data)) {
|
|
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
|
|
}
|
|
return result;
|
|
}
|
|
}
|