hexabot/api/src/chat/services/subscriber.service.ts
2025-06-17 16:12:21 +01:00

284 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import {
Injectable,
InternalServerErrorException,
Optional,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import mime from 'mime';
import { v4 as uuidv4 } from 'uuid';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
AttachmentAccess,
AttachmentCreatedByRef,
AttachmentFile,
AttachmentResourceRef,
} from '@/attachment/types';
import { config } from '@/config';
import { BaseService } from '@/utils/generics/base-service';
import { TFilterQuery } from '@/utils/types/filter.types';
import {
SocketGet,
SocketPost,
} from '@/websocket/decorators/socket-method.decorator';
import { SocketReq } from '@/websocket/decorators/socket-req.decorator';
import { SocketRes } from '@/websocket/decorators/socket-res.decorator';
import { IOOutgoingSubscribeMessage } from '@/websocket/pipes/io-message.pipe';
import { Room } from '@/websocket/types';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { SubscriberDto, SubscriberUpdateDto } from '../dto/subscriber.dto';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Label } from '../schemas/label.schema';
import {
Subscriber,
SubscriberFull,
SubscriberPopulate,
} from '../schemas/subscriber.schema';
@Injectable()
export class SubscriberService extends BaseService<
Subscriber,
SubscriberPopulate,
SubscriberFull,
SubscriberDto
> {
private readonly gateway: WebsocketGateway;
constructor(
readonly repository: SubscriberRepository,
protected readonly attachmentService: AttachmentService,
@Optional() gateway?: WebsocketGateway,
) {
super(repository);
if (gateway) this.gateway = gateway;
}
/**
* Internally subscribe web-sockets to user's event
* For example : Notify chat if new user interacted with the chatbot
*
* @param req - The socket request object
* @param res - The socket response object
*/
@SocketGet('/subscriber/subscribe/')
@SocketPost('/subscriber/subscribe/')
async subscribe(
@SocketReq() req: SocketRequest,
@SocketRes() res: SocketResponse,
): Promise<IOOutgoingSubscribeMessage> {
try {
await this.gateway.joinNotificationSockets(
req.sessionID,
Room.SUBSCRIBER,
);
return res.status(200).json({
success: true,
subscribe: Room.SUBSCRIBER,
});
} catch (e) {
this.logger.error('Websocket subscription');
throw new InternalServerErrorException(e);
}
}
/**
* Finds and returns a single subscriber based on a foreign ID.
*
* @param id - The foreign ID used to find the subscriber.
*
* @returns The subscriber matching the foreign ID.
*/
async findOneByForeignId(id: string) {
return await this.repository.findOneByForeignId(id);
}
/**
* Finds and returns a single subscriber based on a foreign ID,
* and populates the result with related data.
*
* @param id - The foreign ID used to find the subscriber.
*
* @returns The subscriber with populated related data.
*/
async findOneByForeignIdAndPopulate(id: string) {
return await this.repository.findOneByForeignIdAndPopulate(id);
}
/**
* Updates a subscriber's details based on a foreign ID.
*
* @param id - The foreign ID of the subscriber to update.
* @param updates - The updates to apply to the subscriber.
*
* @returns The updated subscriber data.
*/
async updateOneByForeignId(id: string, updates: SubscriberUpdateDto) {
return await this.repository.updateOneByForeignIdQuery(id, updates);
}
/**
* Hands back control or association of a subscriber based on a foreign ID.
*
* @param foreignId - The foreign ID of the subscriber.
*
* @returns The result of the hand-back operation.
*/
async handBackByForeignId(foreignId: string) {
return await this.repository.handBackByForeignIdQuery(foreignId);
}
/**
* Hands over control or association of a subscriber to another user
* based on the foreign ID and the new user's ID.
*
* @param foreignId - The foreign ID of the subscriber.
* @param userId - The ID of the user to whom control is handed over.
*
* @returns The result of the hand-over operation.
*/
async handOverByForeignId(foreignId: string, userId: string) {
return await this.repository.handOverByForeignIdQuery(foreignId, userId);
}
/**
* Persist a new avatar image for a given **Subscriber** and attach it to their profile.
* The method is **idempotent** regarding subscriber updates: calling it again simply
* replaces the existing avatar reference with the new one.
*
* @param subscriberId The unique identifier of the subscriber
* @param avatar The uploaded avatar payload containing:
* - `file` Raw binary buffer
* - `type` MIME type (e.g. `image/png`)
* - `size` File size in bytes
*
* @returns Resolves once the subscriber avatar is stored
*/
async storeAvatar(
subscriberId: string,
avatar: AttachmentFile,
): Promise<Subscriber> {
const { file, type, size } = avatar;
const extension = mime.extension(type);
const attachment = await this.attachmentService.store(file, {
name: `avatar-${uuidv4()}.${extension}`,
size,
type,
resourceRef: AttachmentResourceRef.SubscriberAvatar,
access: AttachmentAccess.Private,
createdByRef: AttachmentCreatedByRef.Subscriber,
createdBy: subscriberId,
});
return await this.updateOne(subscriberId, {
avatar: attachment.id,
});
}
/**
* Apply updates on end-user such as :
* - Assign labels to specific end-user
* - Handover discussion to human
*
* @param profile - The end-user (subscriber) profile
* @param labels - Array of label ids that represent new labels to assign to end-user
* @param assignTo - User ID to handover the discussion to
*
* @returns The updated profile
*/
async applyUpdates(
profile: Subscriber,
labels: string[],
assignTo: string | null,
) {
try {
const updates: SubscriberUpdateDto = {};
if (labels.length > 0) {
let userLabels = profile.labels ? profile.labels : [];
// Filter unique
userLabels = [...new Set(userLabels.concat(labels))];
updates.labels = userLabels;
}
if (assignTo) {
updates.assignedTo = assignTo;
}
const updated = await this.updateOne(profile.id, updates);
this.logger.debug('Block updates has been applied!', updates);
return updated;
} catch (err) {
this.logger.error('Unable to perform block updates!', err);
throw err;
}
}
/**
* Updates the `lastvisit` and `retainedFrom` fields of a subscriber when a new message is received.
*
* This method checks if the subscriber has a `lastvisit` field, and if so, it attempts to update the subscriber's
* information. Specifically, it resets the `retainedFrom` field if the subscriber has not been active for a
* configured period of time (`retentionReset` threshold). The `lastvisit` field is always updated to the current time.
*
* If the update is successful, it logs the updated user's information.
*
* @param subscriber The subscriber whose is being handled.
*/
@OnEvent('hook:user:lastvisit')
async handleLastVisit(subscriber: Subscriber) {
if (subscriber.lastvisit) {
try {
const user = await this.updateOne(subscriber.id, {
// Retentioned user is lost if not chating for a giving duration
// at a first time if a user passes a day without chating we reset
retainedFrom:
+new Date() - +subscriber.lastvisit >
config.analytics.thresholds.retentionReset
? new Date()
: subscriber.retainedFrom,
lastvisit: new Date(),
});
this.logger.debug(
'lastVisit Hook : user retainedFrom/lastvisit updated !',
JSON.stringify(user),
);
} catch (err) {
this.logger.error(err);
}
}
}
/**
* Before deleting a `Label`, this method updates the `labels` field of a subscriber.
*
* @param _query - The Mongoose query object used for deletion.
* @param criteria - The filter criteria for finding the labels to be deleted.
*/
@OnEvent('hook:label:preDelete')
async handleLabelDelete(
_query: unknown,
criteria: TFilterQuery<Label>,
): Promise<void> {
if (criteria._id) {
await this.getRepository().model.updateMany(
{ labels: criteria._id },
{ $pull: { labels: criteria._id } },
);
} else {
throw new Error('Attempted to delete label using unknown criteria');
}
}
}