import { Hono } from 'hono';
import { getDatabase } from '../lib/db';
import { getDatabaseUrl } from '../lib/env';
import { clinicModules } from '../schema';
import { eq, and } from 'drizzle-orm';
import { encrypt, decrypt, isEncrypted } from '../lib/encryption';
import { requireClinicContext } from '../middleware/clinic-context';
import { handleError, AppError } from '../lib/errors';
const whatsappConfigRoute = new Hono();
whatsappConfigRoute.use('*', requireClinicContext);
// ─── Helpers ─────────────────────────────────────────────────────────────────
function maskSecret(value: string | undefined): string {
if (!value) return '';
const plain = isEncrypted(value) ? decrypt(value) : value;
if (plain.length <= 4) return '****';
return '****' + plain.slice(-4);
}
interface WhatsAppConfigInput {
phoneNumberId: string;
businessAccountId: string;
accessToken: string;
appSecret: string;
webhookVerifyToken: string;
}
// ─── GET /whatsapp/config ────────────────────────────────────────────────────
whatsappConfigRoute.get('/config', async (c) => {
try {
const { currentClinicId } = c.get('clinicContext')!;
const db = await getDatabase(getDatabaseUrl());
const [row] = await db
.select()
.from(clinicModules)
.where(and(
eq(clinicModules.clinicId, currentClinicId),
eq(clinicModules.moduleKey, 'whatsapp'),
))
.limit(1);
if (!row?.config) {
return c.json({ configured: false, enabled: false });
}
const stored = JSON.parse(row.config) as Record<string, string>;
return c.json({
configured: !!(stored.phoneNumberId && stored.accessToken),
enabled: row.isEnabled,
phoneNumberId: stored.phoneNumberId ?? '',
businessAccountId: stored.businessAccountId ?? '',
// Secrets returned masked — raw values never leave the server
accessToken: maskSecret(stored.accessToken),
appSecret: maskSecret(stored.appSecret),
webhookVerifyToken: maskSecret(stored.webhookVerifyToken),
});
} catch (error) {
return handleError(error, c);
}
});
// ─── PUT /whatsapp/config ────────────────────────────────────────────────────
whatsappConfigRoute.put('/config', async (c) => {
try {
const { currentClinicId } = c.get('clinicContext')!;
const body = await c.req.json() as Partial<WhatsAppConfigInput>;
const { phoneNumberId, businessAccountId, accessToken, appSecret, webhookVerifyToken } = body;
if (!phoneNumberId?.trim()) throw new AppError('phoneNumberId is required', 400);
if (!accessToken?.trim()) throw new AppError('accessToken is required', 400);
if (!webhookVerifyToken?.trim()) throw new AppError('webhookVerifyToken is required', 400);
const db = await getDatabase(getDatabaseUrl());
// Check if there's an existing row (for partial updates — don't overwrite unchanged secrets)
const [existing] = await db
.select()
.from(clinicModules)
.where(and(
eq(clinicModules.clinicId, currentClinicId),
eq(clinicModules.moduleKey, 'whatsapp'),
))
.limit(1);
const existingConfig = existing?.config ? JSON.parse(existing.config) as Record<string, string> : {};
// Only re-encrypt if the client sent a new (non-masked) value
const isMasked = (val: string) => val.startsWith('****');
const newConfig = {
phoneNumberId: phoneNumberId.trim(),
businessAccountId: (businessAccountId ?? '').trim(),
accessToken: accessToken && !isMasked(accessToken)
? encrypt(accessToken.trim())
: (existingConfig.accessToken ?? encrypt(accessToken!.trim())),
appSecret: appSecret && !isMasked(appSecret)
? encrypt(appSecret.trim())
: (existingConfig.appSecret ?? ''),
webhookVerifyToken: webhookVerifyToken && !isMasked(webhookVerifyToken)
? encrypt(webhookVerifyToken.trim())
: (existingConfig.webhookVerifyToken ?? encrypt(webhookVerifyToken!.trim())),
};
if (existing) {
await db
.update(clinicModules)
.set({
config: JSON.stringify(newConfig),
isEnabled: true,
updatedAt: new Date(),
})
.where(eq(clinicModules.id, existing.id));
} else {
await db.insert(clinicModules).values({
clinicId: currentClinicId,
moduleKey: 'whatsapp',
isEnabled: true,
config: JSON.stringify(newConfig),
});
}
return c.json({ ok: true });
} catch (error) {
return handleError(error, c);
}
});
// ─── DELETE /whatsapp/config ─────────────────────────────────────────────────
whatsappConfigRoute.delete('/config', async (c) => {
try {
const { currentClinicId } = c.get('clinicContext')!;
const db = await getDatabase(getDatabaseUrl());
await db
.update(clinicModules)
.set({ isEnabled: false, config: null, updatedAt: new Date() })
.where(and(
eq(clinicModules.clinicId, currentClinicId),
eq(clinicModules.moduleKey, 'whatsapp'),
));
return c.json({ ok: true });
} catch (error) {
return handleError(error, c);
}
});
// ─── POST /whatsapp/test ─────────────────────────────────────────────────────
whatsappConfigRoute.post('/test', async (c) => {
try {
const { currentClinicId } = c.get('clinicContext')!;
const db = await getDatabase(getDatabaseUrl());
const [row] = await db
.select()
.from(clinicModules)
.where(and(
eq(clinicModules.clinicId, currentClinicId),
eq(clinicModules.moduleKey, 'whatsapp'),
))
.limit(1);
if (!row?.config) {
return c.json({ ok: false, error: 'WhatsApp not configured for this clinic' }, 400);
}
const stored = JSON.parse(row.config) as Record<string, string>;
if (!stored.phoneNumberId || !stored.accessToken) {
return c.json({ ok: false, error: 'Incomplete configuration' }, 400);
}
const accessToken = isEncrypted(stored.accessToken) ? decrypt(stored.accessToken) : stored.accessToken;
// Hit Meta's Graph API to verify credentials — server-side only, token never reaches client
const resp = await fetch(
`https://graph.facebook.com/v21.0/${stored.phoneNumberId}?fields=display_phone_number,verified_name`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (!resp.ok) {
const err = await resp.json() as any;
return c.json({
ok: false,
error: err?.error?.message ?? `Meta API returned ${resp.status}`,
});
}
const data = await resp.json() as any;
return c.json({
ok: true,
phoneNumber: data.display_phone_number,
displayName: data.verified_name,
});
} catch (error) {
return handleError(error, c);
}
});
export default whatsappConfigRoute;