feat: Add Meta WAP Credentials management

- Introduced Meta WAP Credentials model and interface for managing WhatsApp Business credentials.
- Implemented Mongoose adapter for Meta WAP Credentials with upsert and find functionalities.
- Created service layer for handling Meta WAP Credentials operations including save, find, test, and disable.
- Added API controllers for Meta WAP Credentials with endpoints for saving, finding, testing, and disabling credentials.
- Updated Notifications Manager to utilize Meta WAP Credentials for sending WhatsApp messages.
- Enhanced the front-end to manage Meta WAP Credentials with forms for input and validation.
- Implemented UI components for displaying and interacting with Meta WAP Credentials.
This commit is contained in:
2026-09-21 15:56:11 -03:00
parent ee67ac538e
commit 36b69dd773
15 changed files with 1127 additions and 85 deletions
@@ -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";
@@ -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<Yup.AnyObject>,
data: unknown
): Promise<void> => {
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<MetaWapCredentialView> => {
await validateOrThrow(saveSchema, data);
return ApiRequest.post<MetaWapCredentialView>("meta-credentials/save", data);
};
export const metaWapCredentialFind = async (
data: FindMetaWapCredentialParams
): Promise<MetaWapCredentialView> => {
await validateOrThrow(findSchema, data);
return ApiRequest.post<MetaWapCredentialView>("meta-credentials/find", data);
};
export const metaWapCredentialTest = async (
data: TestMetaWapCredentialParams
): Promise<TestMetaWapCredentialResult> => {
await validateOrThrow(testSchema, data);
return ApiRequest.post<TestMetaWapCredentialResult>("meta-credentials/test", data);
};
export const metaWapCredentialDisable = async (
data: DisableMetaWapCredentialParams
): Promise<MetaWapCredentialView> => {
await validateOrThrow(disableSchema, data);
return ApiRequest.post<MetaWapCredentialView>("meta-credentials/disable", data);
};
@@ -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<string>("");
const [wabaId, setWabaId] = useState<string>("");
const [accessToken, setAccessToken] = useState<string>("");
const [templateName, setTemplateName] = useState<string>("");
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [languageCode, setLanguageCode] = useState<string>("es");
const [templateVariables, setTemplateVariables] = useState<string>("");
const [credential, setCredential] = useState<MetaWapCredentialView | undefined>(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 (
<section style={{ marginTop: "20px", padding: "0 16px" }}>
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
Configure las credenciales de WhatsApp Business (Meta) para poder enviar
notificaciones mediante este método.
</MaterialAlert>
<Textbox
placeholder="Phone Number ID"
type="text"
value={phoneNumberId}
width="100%"
onChange={(e) => {
setPhoneNumberId(e.target.value);
}}
/>
<Textbox
placeholder="WABA ID"
type="text"
value={wabaId}
width="100%"
onChange={(e) => {
setWabaId(e.target.value);
}}
/>
<Textbox
placeholder="Access Token"
type="password"
value={accessToken}
width="100%"
onChange={(e) => {
setAccessToken(e.target.value);
}}
/>
<Button
text="Guardar credenciales"
width="100%"
color="primary"
style={{ height: "42px", marginTop: "10px" }}
onClick={handleSave}
/>
{credential && (
<div
style={{
marginTop: "20px",
padding: "12px 16px",
borderRadius: "4px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)",
}}
>
<b style={{ color: statusColor(credential.status) }}>
Estado: {statusLabel(credential.status)}
</b>
<div>Phone Number ID: {credential.phoneNumberId}</div>
<div>WABA ID: {credential.wabaId}</div>
{credential.lastTestedAt && (
<div>
Última prueba:{" "}
{dayjs(credential.lastTestedAt).format("DD/MM/YYYY HH:mm")}
</div>
)}
{credential.lastTestError && (
<div style={{ color: "var(--error)" }}>
Último error: {credential.lastTestError}
</div>
)}
{credential.status === META_WAP_CREDENTIAL_STATUS.DISABLED && (
<MaterialAlert severity="warning" sx={{ marginTop: "10px" }}>
Las credenciales están desactivadas. Guarde nuevamente para
reactivarlas.
</MaterialAlert>
)}
{credential.status !== META_WAP_CREDENTIAL_STATUS.DISABLED && (
<div style={{ marginTop: "10px" }}>
<Button
text="Desactivar credenciales"
width="100%"
color="error"
style={{ height: "42px", marginTop: "10px" }}
onClick={handleDisable}
/>
</div>
)}
</div>
)}
<div style={{ marginTop: "30px" }}>
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
Envía un mensaje de prueba con las credenciales guardadas.
</MaterialAlert>
<Textbox
placeholder="Número de destino (con código de país, ej 54911...)"
type="text"
value={phoneNumber}
width="100%"
onChange={(e) => {
setPhoneNumber(e.target.value);
}}
/>
<Textbox
placeholder="Template (ej: hello_world)"
type="text"
value={templateName}
width="100%"
onChange={(e) => {
setTemplateName(e.target.value);
}}
/>
<Textbox
placeholder="Código de idioma (ej: es)"
type="text"
value={languageCode}
width="100%"
onChange={(e) => {
setLanguageCode(e.target.value);
}}
/>
<Textbox
placeholder="Variables separadas por coma (opcional)"
type="text"
value={templateVariables}
width="100%"
onChange={(e) => {
setTemplateVariables(e.target.value);
}}
/>
<Button
text="Enviar mensaje de prueba"
width="100%"
color="success"
style={{ height: "42px", marginTop: "10px" }}
onClick={handleTest}
/>
</div>
</section>
);
}
@@ -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<BotView | undefined>(undefined);
const [qr, setQr] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState<"wap" | "meta">("wap");
useEffect(() => {
location.clear();
@@ -501,49 +507,78 @@ export default function OrganizationEdit() {
<>
<Location />
<SecureByPlan content={SECURE_CONTENTS.WAP} companyId={id}>
<div
style={{
marginTop: "20px",
display: "flex",
flexDirection: "column",
justifyContent: "start",
alignItems: "center",
}}
>
<BotContent />
</div>
{wapView && (
<div
style={{
marginTop: "10px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Switch
checked={wapView.automaticNotifications}
onChange={(checked) => {
saveAutomaticNotifications(checked);
<div style={{ display: "flex", justifyContent: "center", marginTop: "15px" }}>
<ThemeProvider theme={turnosXpressTheme}>
<ButtonGroup variant="outlined" aria-label="Basic button group">
<ButtonMaterial
variant={activeTab === "wap" ? "contained" : "outlined"}
onClick={() => {
setActiveTab("wap");
}}
/>
<span
style={
wapView.automaticNotifications
? { color: "var(--green-darkestX2)" }
: { color: "var(--error)" }
}
>
{wapView.automaticNotifications
? "Notificaciones automaticas activadas"
: "Notificaciones automaticas desactivadas"}
</span>
</div>
)}
WAP
</ButtonMaterial>
<ButtonMaterial
variant={activeTab === "meta" ? "contained" : "outlined"}
onClick={() => {
setActiveTab("meta");
}}
>
META
</ButtonMaterial>
</ButtonGroup>
</ThemeProvider>
</div>
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
<SecureByPlan content={SECURE_CONTENTS.WAP} companyId={id}>
{activeTab === "wap" ? (
<>
<div
style={{
marginTop: "20px",
display: "flex",
flexDirection: "column",
justifyContent: "start",
alignItems: "center",
}}
>
<BotContent />
</div>
{wapView && (
<div
style={{
marginTop: "10px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Switch
checked={wapView.automaticNotifications}
onChange={(checked) => {
saveAutomaticNotifications(checked);
}}
/>
<span
style={
wapView.automaticNotifications
? { color: "var(--green-darkestX2)" }
: { color: "var(--error)" }
}
>
{wapView.automaticNotifications
? "Notificaciones automaticas activadas"
: "Notificaciones automaticas desactivadas"}
</span>
</div>
)}
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
</>
) : (
<MetaWapCredentials companyId={id} />
)}
</SecureByPlan>
</>
);