diff --git a/server/.env b/server/.env index aa4c878..0b20156 100644 --- a/server/.env +++ b/server/.env @@ -74,3 +74,4 @@ ARCA_WSAA_URL=https://wsaahomo.afip.gov.ar/ws/services/LoginCms ARCA_WSFE_URL=https://wswhomo.afip.gov.ar/wsfev1/service.asmx ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= +META_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= \ No newline at end of file diff --git a/server/.env.prod b/server/.env.prod index 85a305b..f559414 100644 --- a/server/.env.prod +++ b/server/.env.prod @@ -72,3 +72,4 @@ ARCA_WSAA_URL=https://wsaa.afip.gov.ar/ws/services/LoginCms ARCA_WSFE_URL=https://servicios1.afip.gov.ar/wsfev1/service.asmx ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= +META_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= \ No newline at end of file diff --git a/server/src/Models/MetaWapCredentials/MetaWapCredentials.Adapter.Mongoose.ts b/server/src/Models/MetaWapCredentials/MetaWapCredentials.Adapter.Mongoose.ts new file mode 100644 index 0000000..e628cb3 --- /dev/null +++ b/server/src/Models/MetaWapCredentials/MetaWapCredentials.Adapter.Mongoose.ts @@ -0,0 +1,65 @@ +import { Document, FilterQuery, Model, Schema, model } from "mongoose"; +import { + IMetaWapCredential, + IMetaWapCredentialAdapter, + META_WAP_CREDENTIAL_STATUS, +} from "./MetaWapCredentials.Interface"; + +export interface IMetaWapCredentialDocument extends Omit, Document {} + +export class MetaWapCredentialsAdapterMongoose implements IMetaWapCredentialAdapter { + schema: Schema; + metaWapCredentialList: Model; + + constructor() { + this.schema = new Schema({ + companyId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Company", + unique: true, + index: true, + }, + phoneNumberId: { type: String, required: true }, + wabaId: { type: String, required: true }, + encryptedAccessToken: { type: String, required: true }, + accessTokenIv: { type: String, required: true }, + accessTokenAuthTag: { type: String, required: true }, + encryptionKeyVersion: { type: String, required: false, default: "v1" }, + status: { + type: String, + required: true, + enum: Object.values(META_WAP_CREDENTIAL_STATUS), + default: META_WAP_CREDENTIAL_STATUS.CONFIGURED, + index: true, + }, + lastTestedAt: { type: Date, required: false }, + lastTestError: { type: String, required: false }, + createdAt: { type: Date, required: true, default: Date.now }, + updatedAt: { type: Date, required: true, default: Date.now }, + }); + + this.metaWapCredentialList = model("MetaWapCredential", this.schema); + } + + private buildSearchCriteria(filters: { companyId?: string }): FilterQuery { + const searchCriteria: FilterQuery = {}; + + if (filters.companyId) searchCriteria.companyId = filters.companyId; + + return searchCriteria; + } + + public async findOne(filters: { companyId?: string }): Promise { + const searchCriteria = this.buildSearchCriteria(filters); + return this.metaWapCredentialList.findOne(searchCriteria).exec(); + } + + public async upsert(data: IMetaWapCredential): Promise { + const { companyId, ...updateData } = data; + + return this.metaWapCredentialList + .findOneAndUpdate({ companyId }, { ...updateData, updatedAt: Date.now() }, { new: true, upsert: true }) + .exec(); + } +} \ No newline at end of file diff --git a/server/src/Models/MetaWapCredentials/MetaWapCredentials.Interface.ts b/server/src/Models/MetaWapCredentials/MetaWapCredentials.Interface.ts new file mode 100644 index 0000000..0bbd84d --- /dev/null +++ b/server/src/Models/MetaWapCredentials/MetaWapCredentials.Interface.ts @@ -0,0 +1,75 @@ +import { IMetaWapCredentialDocument } from "./MetaWapCredentials.Adapter.Mongoose"; + +export enum META_WAP_CREDENTIAL_STATUS { + CONFIGURED = "CONFIGURED", + TESTED = "TESTED", + INVALID = "INVALID", + DISABLED = "DISABLED", +} + +export type SaveMetaWapCredentialParams = { + companyId: string; + phoneNumberId: string; + wabaId: string; + accessToken: string; + sessionUser: string; +}; + +export type FindMetaWapCredentialParams = { + companyId: string; + sessionUser: string; +}; + +export type TestMetaWapCredentialParams = { + companyId: string; + phoneNumber: string; + templateName: string; + languageCode: string; + templateVariables?: string[]; + sessionUser: string; +}; + +export type DisableMetaWapCredentialParams = { + companyId: string; + sessionUser: string; +}; + +export interface IMetaWapCredential { + id?: string; + companyId: string; + phoneNumberId: string; + wabaId: string; + encryptedAccessToken: string; + accessTokenIv: string; + accessTokenAuthTag: string; + encryptionKeyVersion?: string; + status: META_WAP_CREDENTIAL_STATUS; + lastTestedAt?: Date; + lastTestError?: string; + createdAt?: Date; + updatedAt?: Date; +} + +export type MetaWapCredentialView = Omit< + IMetaWapCredential, + "encryptedAccessToken" | "accessTokenIv" | "accessTokenAuthTag" +>; + +export type TestMetaWapCredentialResult = { + success: boolean; + message: string; + testedAt: Date; +}; + +export interface IMetaWapCredentialAdapter { + findOne(filters: { companyId?: string }): Promise; + upsert(data: IMetaWapCredential): Promise; +} + +export interface IMetaWapCredentialManager { + metaWapCredentials: IMetaWapCredentialAdapter; + save(data: SaveMetaWapCredentialParams): Promise; + find(data: FindMetaWapCredentialParams): Promise; + test(data: TestMetaWapCredentialParams): Promise; + disable(data: DisableMetaWapCredentialParams): Promise; +} \ No newline at end of file diff --git a/server/src/Models/MetaWapCredentials/MetaWapCredentials.ts b/server/src/Models/MetaWapCredentials/MetaWapCredentials.ts new file mode 100644 index 0000000..c23bc0c --- /dev/null +++ b/server/src/Models/MetaWapCredentials/MetaWapCredentials.ts @@ -0,0 +1,242 @@ +import crypto from "crypto"; +import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; +import { NotificationsManager } from "../Notifications/Notifications"; +import { MetaWapCredentialsAdapterMongoose } from "./MetaWapCredentials.Adapter.Mongoose"; +import { + DisableMetaWapCredentialParams, + FindMetaWapCredentialParams, + IMetaWapCredential, + IMetaWapCredentialManager, + META_WAP_CREDENTIAL_STATUS, + MetaWapCredentialView, + SaveMetaWapCredentialParams, + TestMetaWapCredentialParams, + TestMetaWapCredentialResult, +} from "./MetaWapCredentials.Interface"; + +export type MetaWapConnectionCredentials = { + phoneNumberId: string; + authToken: string; +}; + +class MetaWapCredentialsManager implements IMetaWapCredentialManager { + metaWapCredentials: MetaWapCredentialsAdapterMongoose; + + constructor() { + this.metaWapCredentials = new MetaWapCredentialsAdapterMongoose(); + } + + private getMasterKey(): Buffer { + const configuredKey = process.env.META_CREDENTIALS_MASTER_KEY; + + if (!configuredKey) { + throw new Error("META_CREDENTIALS_MASTER_KEY no está configurada"); + } + + const key = /^[a-fA-F0-9]{64}$/.test(configuredKey) + ? Buffer.from(configuredKey, "hex") + : Buffer.from(configuredKey, "base64"); + + if (key.length !== 32) { + throw new Error("META_CREDENTIALS_MASTER_KEY debe tener 32 bytes en base64 o 64 caracteres hexadecimales"); + } + + return key; + } + + private encryptAccessToken(accessToken: string): { + encryptedAccessToken: string; + accessTokenIv: string; + accessTokenAuthTag: string; + } { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv( + "aes-256-gcm", + crypto.createSecretKey(new Uint8Array(this.getMasterKey())), + new Uint8Array(iv) + ); + const encryptedAccessToken = cipher.update(accessToken, "utf8", "base64") + cipher.final("base64"); + + return { + encryptedAccessToken, + accessTokenIv: iv.toString("base64"), + accessTokenAuthTag: cipher.getAuthTag().toString("base64"), + }; + } + + private decryptAccessToken(credential: IMetaWapCredential): string { + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + crypto.createSecretKey(new Uint8Array(this.getMasterKey())), + new Uint8Array(Buffer.from(credential.accessTokenIv, "base64")) + ); + decipher.setAuthTag(new Uint8Array(Buffer.from(credential.accessTokenAuthTag, "base64"))); + + return decipher.update(credential.encryptedAccessToken, "base64", "utf8") + decipher.final("utf8"); + } + + private sanitizeCredential(credential: IMetaWapCredential): MetaWapCredentialView { + const rawCredential = typeof (credential as any).toObject === "function" + ? (credential as any).toObject() + : credential; + const { encryptedAccessToken, accessTokenIv, accessTokenAuthTag, ...safeCredential } = rawCredential; + return safeCredential; + } + + private async validateCompanyAdmin(companyId: string, sessionUser: string): Promise { + await validateSessionUser({ sessionUser }); + + if (!companyId || !companyId.trim()) { + throw new Error("Se requiere una organización para operar credenciales de Meta"); + } + + await validatePermissionsByCompany({ companyId, sessionUser }); + } + + private async requireConfiguredCredentials(companyId: string): Promise { + const credential = await this.metaWapCredentials.findOne({ companyId }); + + if (!credential) { + throw new Error("La organización no tiene credenciales de Meta configuradas"); + } + + if (credential.status === META_WAP_CREDENTIAL_STATUS.DISABLED) { + throw new Error("Las credenciales de Meta están desactivadas"); + } + + return credential; + } + + public async save(data: SaveMetaWapCredentialParams): Promise { + await this.validateCompanyAdmin(data.companyId, data.sessionUser); + + if (!data.phoneNumberId || !data.phoneNumberId.trim()) { + throw new Error("phoneNumberId es obligatorio"); + } + + if (!data.wabaId || !data.wabaId.trim()) { + throw new Error("wabaId es obligatorio"); + } + + if (!data.accessToken || !data.accessToken.trim()) { + throw new Error("accessToken es obligatorio"); + } + + const { encryptedAccessToken, accessTokenIv, accessTokenAuthTag } = this.encryptAccessToken(data.accessToken); + + const credential = await this.metaWapCredentials.upsert({ + companyId: data.companyId, + phoneNumberId: data.phoneNumberId, + wabaId: data.wabaId, + encryptedAccessToken, + accessTokenIv, + accessTokenAuthTag, + encryptionKeyVersion: process.env.META_CREDENTIALS_KEY_VERSION || "v1", + status: META_WAP_CREDENTIAL_STATUS.CONFIGURED, + }); + + return this.sanitizeCredential(credential); + } + + public async find(data: FindMetaWapCredentialParams): Promise { + await this.validateCompanyAdmin(data.companyId, data.sessionUser); + + const credential = await this.metaWapCredentials.findOne({ companyId: data.companyId }); + + if (!credential) { + throw new Error("La organización no tiene credenciales de Meta configuradas"); + } + + return this.sanitizeCredential(credential); + } + + public async test(data: TestMetaWapCredentialParams): Promise { + await this.validateCompanyAdmin(data.companyId, data.sessionUser); + + const credential = await this.requireConfiguredCredentials(data.companyId); + + const authToken = this.decryptAccessToken(credential); + + try { + await NotificationsManager.sendWhatsAppTemplate( + { + companyId: data.companyId, + phoneNumber: data.phoneNumber, + templateName: data.templateName, + languageCode: data.languageCode, + templateVariables: data.templateVariables, + }, + { + phoneNumberId: credential.phoneNumberId, + authToken, + } + ); + + await this.metaWapCredentials.upsert({ + companyId: data.companyId, + phoneNumberId: credential.phoneNumberId, + wabaId: credential.wabaId, + encryptedAccessToken: credential.encryptedAccessToken, + accessTokenIv: credential.accessTokenIv, + accessTokenAuthTag: credential.accessTokenAuthTag, + encryptionKeyVersion: credential.encryptionKeyVersion, + status: META_WAP_CREDENTIAL_STATUS.TESTED, + lastTestedAt: new Date(), + lastTestError: undefined, + }); + + return { + success: true, + message: "Test sent successfully", + testedAt: new Date(), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + await this.metaWapCredentials.upsert({ + companyId: data.companyId, + phoneNumberId: credential.phoneNumberId, + wabaId: credential.wabaId, + encryptedAccessToken: credential.encryptedAccessToken, + accessTokenIv: credential.accessTokenIv, + accessTokenAuthTag: credential.accessTokenAuthTag, + encryptionKeyVersion: credential.encryptionKeyVersion, + status: META_WAP_CREDENTIAL_STATUS.INVALID, + lastTestedAt: new Date(), + lastTestError: errorMessage, + }); + + throw new Error(`Test failed: ${errorMessage}`); + } + } + + public async disable(data: DisableMetaWapCredentialParams): Promise { + await this.validateCompanyAdmin(data.companyId, data.sessionUser); + + const credential = await this.requireConfiguredCredentials(data.companyId); + + const updated = await this.metaWapCredentials.upsert({ + companyId: data.companyId, + phoneNumberId: credential.phoneNumberId, + wabaId: credential.wabaId, + encryptedAccessToken: credential.encryptedAccessToken, + accessTokenIv: credential.accessTokenIv, + accessTokenAuthTag: credential.accessTokenAuthTag, + encryptionKeyVersion: credential.encryptionKeyVersion, + status: META_WAP_CREDENTIAL_STATUS.DISABLED, + }); + + return this.sanitizeCredential(updated); + } + + public async getCredentialsForSending(companyId: string): Promise { + const credential = await this.requireConfiguredCredentials(companyId); + + return { + phoneNumberId: credential.phoneNumberId, + authToken: this.decryptAccessToken(credential), + }; + } +} + +export default new MetaWapCredentialsManager(); \ No newline at end of file diff --git a/server/src/Models/Notifications/Notifications.Adapter.Meta.ts b/server/src/Models/Notifications/Notifications.Adapter.Meta.ts index b4719d6..39d9d0f 100644 --- a/server/src/Models/Notifications/Notifications.Adapter.Meta.ts +++ b/server/src/Models/Notifications/Notifications.Adapter.Meta.ts @@ -1,15 +1,21 @@ import axios from "axios"; import { INotificationsAdapter, NotificationDataMeta } from "./Notifications.Interface"; +export type MetaConnectionCredentials = { + phoneNumberId: string; + authToken: string; +}; + export class NotificationsMetaAdapter implements INotificationsAdapter { - // ── Datos hardcodeados para pruebas (etapa 1) ── - private readonly phoneNumberId = "1379557278565544"; // ej: "1379557278565544" - private readonly authToken = - "EAAeZCf8kXotYBSuVCLO7smu3jGxv7qqndFn3OZB6E55QZBRKB2ZCPmOkylVfseI8PuZBzGBIrGa3QO0hUJZCGq7GwwJBgaxhu4A07LJZCOna82ZAQZAYZBljlQVApTurK1Hj0YHVZA4cnSdQa5xKmoYBa8YTuzyqrC1ZC2s9qbIvWHImUkVUgG2ps7F25ZAZCMISympdL1c34iO2IDLW2NGndy3o4E11K0c1FA3RFTCQJJXPmuddK360KLz5Tpj8BOTZC12FztytNQkwqpHZBBdpCpJfrOyQy546"; // tu token de Meta + private readonly credentials: MetaConnectionCredentials; private readonly apiVersion = "v25.0"; + constructor(credentials: MetaConnectionCredentials) { + this.credentials = credentials; + } + get baseUrl(): string { - return `https://graph.facebook.com/${this.apiVersion}/${this.phoneNumberId}/messages`; + return `https://graph.facebook.com/${this.apiVersion}/${this.credentials.phoneNumberId}/messages`; } async send(data: NotificationDataMeta): Promise { @@ -38,11 +44,11 @@ export class NotificationsMetaAdapter implements INotificationsAdapter { - await this.notificationsMeta.send(data); + async sendWhatsAppTemplate(data: NotificationDataMeta, credentials: MetaConnectionCredentials): Promise { + const notificationsMeta = new NotificationsMetaAdapter(credentials); + await notificationsMeta.send(data); } async sendSystemNotification(data: NotificationDataSystem): Promise { diff --git a/server/src/api/MetaWapCredentials/MetaWapCredentials.Controller.ts b/server/src/api/MetaWapCredentials/MetaWapCredentials.Controller.ts new file mode 100644 index 0000000..9261a92 --- /dev/null +++ b/server/src/api/MetaWapCredentials/MetaWapCredentials.Controller.ts @@ -0,0 +1,97 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { authenticateMiddleware } from "../../middleware/authentication"; +import { rateLimiter } from "../../middleware/ratelimiter"; +import { ApiValidationError } from "../../Models/Server.Error.model"; +import { + DisableMetaWapCredentialParams, + FindMetaWapCredentialParams, + MetaWapCredentialView, + SaveMetaWapCredentialParams, + TestMetaWapCredentialParams, + TestMetaWapCredentialResult, +} from "../../Models/MetaWapCredentials/MetaWapCredentials.Interface"; +import { MetaWapCredentialsService } from "./MetaWapCredentials.Service"; + +@Route("meta-credentials/save") +@Middlewares(authenticateMiddleware) +export class MetaWapCredentialsSaveController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async save( + @Body() requestBody: SaveMetaWapCredentialParams + ): Promise { + try { + const credential = await new MetaWapCredentialsService().save(requestBody); + this.setStatus(200); + return credential; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("meta-credentials/find") +@Middlewares(authenticateMiddleware) +export class MetaWapCredentialsFindController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async find( + @Body() requestBody: FindMetaWapCredentialParams + ): Promise { + try { + const credential = await new MetaWapCredentialsService().find(requestBody); + this.setStatus(200); + return credential; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("meta-credentials/test") +@Middlewares([rateLimiter, authenticateMiddleware]) +export class MetaWapCredentialsTestController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async test( + @Body() requestBody: TestMetaWapCredentialParams + ): Promise { + try { + const result = await new MetaWapCredentialsService().test(requestBody); + this.setStatus(200); + return result; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("meta-credentials/disable") +@Middlewares(authenticateMiddleware) +export class MetaWapCredentialsDisableController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async disable( + @Body() requestBody: DisableMetaWapCredentialParams + ): Promise { + try { + const credential = await new MetaWapCredentialsService().disable(requestBody); + this.setStatus(200); + return credential; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} \ No newline at end of file diff --git a/server/src/api/MetaWapCredentials/MetaWapCredentials.Service.ts b/server/src/api/MetaWapCredentials/MetaWapCredentials.Service.ts new file mode 100644 index 0000000..8460ba1 --- /dev/null +++ b/server/src/api/MetaWapCredentials/MetaWapCredentials.Service.ts @@ -0,0 +1,32 @@ +import { connect } from "mongoose"; +import MetaWapCredentials from "../../Models/MetaWapCredentials/MetaWapCredentials"; +import { + DisableMetaWapCredentialParams, + FindMetaWapCredentialParams, + MetaWapCredentialView, + SaveMetaWapCredentialParams, + TestMetaWapCredentialParams, + TestMetaWapCredentialResult, +} from "../../Models/MetaWapCredentials/MetaWapCredentials.Interface"; + +export class MetaWapCredentialsService { + public async save(data: SaveMetaWapCredentialParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await MetaWapCredentials.save(data); + } + + public async find(data: FindMetaWapCredentialParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await MetaWapCredentials.find(data); + } + + public async test(data: TestMetaWapCredentialParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await MetaWapCredentials.test(data); + } + + public async disable(data: DisableMetaWapCredentialParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await MetaWapCredentials.disable(data); + } +} \ No newline at end of file diff --git a/server/src/api/Notifications/Notifications.Controller.ts b/server/src/api/Notifications/Notifications.Controller.ts index c671309..78c56f3 100644 --- a/server/src/api/Notifications/Notifications.Controller.ts +++ b/server/src/api/Notifications/Notifications.Controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Middlewares, Post, Route, SuccessResponse } from "tso import { rateLimiter } from "../../middleware/ratelimiter"; import { systemTokenMiddleware } from "../../middleware/systemToken"; import { NotificationsManager } from "../../Models/Notifications/Notifications"; +import MetaWapCredentialsManager from "../../Models/MetaWapCredentials/MetaWapCredentials"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; interface SendEmailByContentParams { @@ -23,6 +24,7 @@ interface SendWapByContentParams { interface SendWhatsAppTemplateParams { systemToken: string; + companyId: string; phoneNumber: string; templateName: string; languageCode: string; @@ -72,45 +74,24 @@ export class SendWapByContentController extends Controller { } } -/** - * TEMPORARY TEST ENDPOINT — remove after testing meta_wap integration - * No auth required. Just for curl testing sendWhatsAppTemplate flow. - */ -@Route("notifications/test-meta-send") -export class TestMetaSendController extends Controller { - @SuccessResponse(200, "Done") - @Post() - public async testMetaSend(@Body() requestBody: SendWhatsAppTemplateParams): Promise { - try { - await NotificationsManager.sendWhatsAppTemplate({ - phoneNumber: requestBody.phoneNumber, - templateName: requestBody.templateName, - languageCode: requestBody.languageCode, - templateVariables: requestBody.templateVariables, - }); - - this.setStatus(200); - return { message: "Test sent successfully", success: true }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - this.setStatus(500); - return { message: `Test failed: ${msg}`, success: false }; - } - } -} - @Route("notifications/send-whatsapp-template") @Middlewares([rateLimiter, systemTokenMiddleware]) export class SendWhatsAppTemplateController extends Controller { @SuccessResponse(200, "Done") @Post() public async sendWhatsAppTemplate(@Body() requestBody: SendWhatsAppTemplateParams): Promise { - await NotificationsManager.sendWhatsAppTemplate({ - phoneNumber: requestBody.phoneNumber, - templateName: requestBody.templateName, - languageCode: requestBody.languageCode, - templateVariables: requestBody.templateVariables, - }); + const credentials = await MetaWapCredentialsManager.getCredentialsForSending(requestBody.companyId); + + await NotificationsManager.sendWhatsAppTemplate( + { + companyId: requestBody.companyId, + phoneNumber: requestBody.phoneNumber, + templateName: requestBody.templateName, + languageCode: requestBody.languageCode, + templateVariables: requestBody.templateVariables, + }, + credentials + ); this.setStatus(200); return { diff --git a/txclient/src/Models/MetaWapCredential.model.ts b/txclient/src/Models/MetaWapCredential.model.ts new file mode 100644 index 0000000..e946e6e --- /dev/null +++ b/txclient/src/Models/MetaWapCredential.model.ts @@ -0,0 +1,57 @@ +export enum META_WAP_CREDENTIAL_STATUS { + CONFIGURED = "CONFIGURED", + TESTED = "TESTED", + INVALID = "INVALID", + DISABLED = "DISABLED", +} + +export type SaveMetaWapCredentialParams = { + companyId: string; + phoneNumberId: string; + wabaId: string; + accessToken: string; + sessionUser: string; +}; + +export type FindMetaWapCredentialParams = { + companyId: string; + sessionUser: string; +}; + +export type TestMetaWapCredentialParams = { + companyId: string; + phoneNumber: string; + templateName: string; + languageCode: string; + templateVariables?: string[]; + sessionUser: string; +}; + +export type DisableMetaWapCredentialParams = { + companyId: string; + sessionUser: string; +}; + +export interface IMetaWapCredential { + id?: string; + companyId: string; + phoneNumberId: string; + wabaId: string; + encryptionKeyVersion?: string; + status: META_WAP_CREDENTIAL_STATUS; + lastTestedAt?: Date; + lastTestError?: string; + createdAt?: Date; + updatedAt?: Date; +} + +export type MetaWapCredentialView = IMetaWapCredential; + +export type TestMetaWapCredentialResult = { + success: boolean; + message: string; + testedAt: Date; +}; + +export const META_WAP_CREDENTIAL_NOT_CONFIGURED_MESSAGE = + "La organización no tiene credenciales de Meta configuradas"; \ No newline at end of file diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/MetaWapCredentials.Service.ts b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/MetaWapCredentials.Service.ts new file mode 100644 index 0000000..b245590 --- /dev/null +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/MetaWapCredentials.Service.ts @@ -0,0 +1,89 @@ +import ApiRequest from "@services/Api.Service"; +import * as Yup from "yup"; +import { ApiError } from "@models/Server.Error.model"; +import { + DisableMetaWapCredentialParams, + FindMetaWapCredentialParams, + MetaWapCredentialView, + SaveMetaWapCredentialParams, + TestMetaWapCredentialParams, + TestMetaWapCredentialResult, +} from "@models/MetaWapCredential.model"; + +const baseFields = { + companyId: Yup.string().required("No se ha especificado una organización."), + sessionUser: Yup.string().required( + "No se ha podido determinar el usuario que intenta realizar esta acción" + ), +}; + +const saveSchema = Yup.object().shape({ + ...baseFields, + phoneNumberId: Yup.string().required("Debe introducir el Phone Number ID."), + wabaId: Yup.string().required("Debe introducir el WABA ID."), + accessToken: Yup.string().required("Debe introducir el Access Token."), +}); + +const findSchema = Yup.object().shape({ + ...baseFields, +}); + +const testSchema = Yup.object().shape({ + ...baseFields, + phoneNumber: Yup.string().required("Debe introducir el número de teléfono de destino."), + templateName: Yup.string().required("Debe introducir el nombre del template."), + languageCode: Yup.string().required("Debe introducir el código de idioma."), +}); + +const disableSchema = Yup.object().shape({ + ...baseFields, +}); + +const validateOrThrow = async ( + validationSchema: Yup.ObjectSchema, + data: unknown +): Promise => { + let yupErrors: Yup.ValidationError = new Yup.ValidationError(""); + + try { + await validationSchema.validate(data, { abortEarly: true }); + return; + } catch (error) { + if (error instanceof Yup.ValidationError) { + yupErrors = error as Yup.ValidationError; + } + throw new ApiError(400, yupErrors.message); + } +}; + +export const metaWapCredentialSave = async ( + data: SaveMetaWapCredentialParams +): Promise => { + await validateOrThrow(saveSchema, data); + + return ApiRequest.post("meta-credentials/save", data); +}; + +export const metaWapCredentialFind = async ( + data: FindMetaWapCredentialParams +): Promise => { + await validateOrThrow(findSchema, data); + + return ApiRequest.post("meta-credentials/find", data); +}; + +export const metaWapCredentialTest = async ( + data: TestMetaWapCredentialParams +): Promise => { + await validateOrThrow(testSchema, data); + + return ApiRequest.post("meta-credentials/test", data); +}; + +export const metaWapCredentialDisable = async ( + data: DisableMetaWapCredentialParams +): Promise => { + await validateOrThrow(disableSchema, data); + + return ApiRequest.post("meta-credentials/disable", data); +}; \ No newline at end of file diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/MetaWapCredentials.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/MetaWapCredentials.tsx new file mode 100644 index 0000000..bf61a73 --- /dev/null +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/MetaWapCredentials.tsx @@ -0,0 +1,361 @@ +"use client"; +import { useEffect, useState } from "react"; +import MaterialAlert from "@mui/material/Alert"; +import dayjs from "dayjs"; +import Button from "@core/app/components/Button/Button"; +import Textbox from "@components/Textbox/Textbox"; +import { useConfirmStore } from "@core/Store/Confirm.Store"; +import { useSessionStore } from "@store/Sesion.Store"; +import { useAlert } from "@store/Alert.Store"; +import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; +import { ApiError } from "@models/Server.Error.model"; +import { + META_WAP_CREDENTIAL_NOT_CONFIGURED_MESSAGE, + META_WAP_CREDENTIAL_STATUS, + MetaWapCredentialView, +} from "@models/MetaWapCredential.model"; +import { + metaWapCredentialDisable, + metaWapCredentialFind, + metaWapCredentialSave, + metaWapCredentialTest, +} from "./MetaWapCredentials.Service"; + +export default function MetaWapCredentials({ companyId }: { companyId: string }) { + const SessionInfo = useSessionStore(); + const eventHandler = useEventHandlerStore(); + const alert = useAlert(); + const confirm = useConfirmStore(); + + const [phoneNumberId, setPhoneNumberId] = useState(""); + const [wabaId, setWabaId] = useState(""); + const [accessToken, setAccessToken] = useState(""); + const [templateName, setTemplateName] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [languageCode, setLanguageCode] = useState("es"); + const [templateVariables, setTemplateVariables] = useState(""); + const [credential, setCredential] = useState(undefined); + + const formatError = (error: unknown): string => { + if (error instanceof ApiError) { + return error.format(); + } + return String(error); + }; + + const isNotConfiguredError = (error: unknown): boolean => { + return ( + error instanceof ApiError && + error.desc.includes(META_WAP_CREDENTIAL_NOT_CONFIGURED_MESSAGE) + ); + }; + + const statusLabel = (status: META_WAP_CREDENTIAL_STATUS): string => { + switch (status) { + case META_WAP_CREDENTIAL_STATUS.CONFIGURED: + return "Configuradas"; + case META_WAP_CREDENTIAL_STATUS.TESTED: + return "Probadas"; + case META_WAP_CREDENTIAL_STATUS.INVALID: + return "Inválidas"; + case META_WAP_CREDENTIAL_STATUS.DISABLED: + return "Desactivadas"; + default: + return status; + } + }; + + const statusColor = (status: META_WAP_CREDENTIAL_STATUS): string => { + if (status === META_WAP_CREDENTIAL_STATUS.TESTED) { + return "var(--green-darkestX2)"; + } + if ( + status === META_WAP_CREDENTIAL_STATUS.INVALID || + status === META_WAP_CREDENTIAL_STATUS.DISABLED + ) { + return "var(--error)"; + } + return ""; + }; + + const refreshCredential = (): void => { + if (!SessionInfo.userId) { + return; + } + + metaWapCredentialFind({ + companyId: companyId, + sessionUser: SessionInfo.userId, + }) + .then((view: MetaWapCredentialView) => { + setCredential(view); + setPhoneNumberId(view.phoneNumberId ?? ""); + setWabaId(view.wabaId ?? ""); + }) + .catch((error: unknown) => { + if (isNotConfiguredError(error)) { + setCredential(undefined); + return; + } + alert.showError(formatError(error)); + }); + }; + + useEffect(() => { + if (!SessionInfo.userId) { + return; + } + + eventHandler.setEventType(EVENT_TYPES.LOADING); + metaWapCredentialFind({ + companyId: companyId, + sessionUser: SessionInfo.userId, + }) + .then((view: MetaWapCredentialView) => { + setCredential(view); + setPhoneNumberId(view.phoneNumberId ?? ""); + setWabaId(view.wabaId ?? ""); + }) + .catch((error: unknown) => { + if (isNotConfiguredError(error)) { + setCredential(undefined); + return; + } + alert.showError(formatError(error)); + }) + .finally(() => { + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }, [SessionInfo.userId]); + + const handleSave = () => { + if (!SessionInfo.userId) { + return; + } + + eventHandler.setEventType(EVENT_TYPES.LOADING); + metaWapCredentialSave({ + companyId: companyId, + phoneNumberId: phoneNumberId, + wabaId: wabaId, + accessToken: accessToken, + sessionUser: SessionInfo.userId, + }) + .then(() => { + setAccessToken(""); + alert.showSuccess("Credenciales guardadas correctamente"); + }) + .catch((error: unknown) => { + alert.showError(formatError(error)); + }) + .finally(() => { + refreshCredential(); + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }; + + const handleTest = () => { + if (!SessionInfo.userId) { + return; + } + + if (!credential || credential.status === META_WAP_CREDENTIAL_STATUS.DISABLED) { + alert.showWarning( + "Primero debe configurar las credenciales de Meta para poder enviar un mensaje de prueba." + ); + return; + } + + eventHandler.setEventType(EVENT_TYPES.LOADING); + metaWapCredentialTest({ + companyId: companyId, + phoneNumber: phoneNumber, + templateName: templateName, + languageCode: languageCode, + templateVariables: templateVariables + ? templateVariables + .split(",") + .map((variable) => variable.trim()) + .filter(Boolean) + : undefined, + sessionUser: SessionInfo.userId, + }) + .then(() => { + alert.showSuccess("Mensaje de prueba enviado correctamente"); + }) + .catch((error: unknown) => { + alert.showError(formatError(error)); + }) + .finally(() => { + refreshCredential(); + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }; + + const handleDisable = () => { + confirm.show( + "¿Está seguro de desactivar las credenciales de Meta de esta organización?", + () => { + if (!SessionInfo.userId) { + return; + } + + eventHandler.setEventType(EVENT_TYPES.LOADING); + metaWapCredentialDisable({ + companyId: companyId, + sessionUser: SessionInfo.userId, + }) + .then(() => { + alert.showSuccess("Credenciales desactivadas correctamente"); + }) + .catch((error: unknown) => { + alert.showError(formatError(error)); + }) + .finally(() => { + refreshCredential(); + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }, + () => {} + ); + }; + + return ( +
+ + Configure las credenciales de WhatsApp Business (Meta) para poder enviar + notificaciones mediante este método. + + + { + setPhoneNumberId(e.target.value); + }} + /> + { + setWabaId(e.target.value); + }} + /> + { + setAccessToken(e.target.value); + }} + /> +
+ ); +} \ No newline at end of file diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/page.tsx index 9c02405..4ed71f8 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/wap/page.tsx @@ -10,6 +10,11 @@ import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined"; import OrgConfigIcon from "@mui/icons-material/SettingsOutlined"; import MaterialAlert from "@mui/material/Alert"; import Button from "@core/app/components/Button/Button"; +import { ThemeProvider } from "@emotion/react"; +import turnosXpressTheme from "@core/app/theme/turnosXpress"; +import { ButtonGroup } from "@mui/material"; +import ButtonMaterial from "@mui/material/Button"; +import MetaWapCredentials from "./MetaWapCredentials"; import { wapServerAssign, wapServerBotCreate, @@ -42,6 +47,7 @@ export default function OrganizationEdit() { const [wapView, setWapView] = useState(undefined); const [qr, setQr] = useState(undefined); + const [activeTab, setActiveTab] = useState<"wap" | "meta">("wap"); useEffect(() => { location.clear(); @@ -501,49 +507,78 @@ export default function OrganizationEdit() { <> - -
- -
- - {wapView && ( -
- { - saveAutomaticNotifications(checked); +
+ + + { + setActiveTab("wap"); }} - /> - - {wapView.automaticNotifications - ? "Notificaciones automaticas activadas" - : "Notificaciones automaticas desactivadas"} - -
- )} + WAP + + { + setActiveTab("meta"); + }} + > + META + + + +
-
 
+ + {activeTab === "wap" ? ( + <> +
+ +
+ + {wapView && ( +
+ { + saveAutomaticNotifications(checked); + }} + /> + + {wapView.automaticNotifications + ? "Notificaciones automaticas activadas" + : "Notificaciones automaticas desactivadas"} + +
+ )} + +
 
+ + ) : ( + + )}
);