Files
scoreodonto.com/frontend/views/newwhats/services/chatApiService.ts
T
VPS 4 Builder 21bebd3256 feat(newwhats): ownership de sessões, auto-provisionamento, assinatura e drawer na agenda
- Drawer de WhatsApp focado (WhatsChatDrawer) aberto do slot do agendamento, com
  dropdown de números do paciente + grupo familiar e fluxo de iniciar conversa
- Auto-provisionamento de conta no motor: eager no cadastro/convite + self-heal no
  proxy (404 "conta não encontrada" → provisiona e refaz o request)
- Ownership por sessão (wa_session_authz): dono do workspace (owner_id ou vínculo
  donoclinica/donoconsultorio) tem poder total; delega re-scan, excluir sessão,
  apagar conversa/mensagem, acesso ao Inbox e à Secretária — por conta
- Enforcement no proxy: guardSessionAction (scan/rescan/delete sessão),
  guardAreaAccess (inbox/secretaria), guardInboxDestructive (apagar conversa/msg)
- Endpoints /api/nw/authz (dono gerencia) e /api/nw/access (acesso agregado)
- Assinatura do operador nas mensagens (*Nome*, desambiguação de homônimos → *Rui C.*)
- UI: painel "Gerenciar acesso" com 5 toggles; menu e botões de apagar condicionados
- Proxy de mídia /api/nw/media + re-download sob demanda

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:26:31 +02:00

252 lines
15 KiB
TypeScript

// Shims dos services do motor sobre a ext API do satélite (/api/nw/v1/*).
// Mantém a MESMA interface que o chatApiService.ts do motor expõe, normalizando
// os shapes da ext API para o formato que os stores/hooks portados esperam.
import { nw } from './nwClient';
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
export const instanceApi = {
list: () => nw.get('/sessions'),
create: (name: string) => nw.post('/sessions', { name }),
connect: (id: string) => nw.post(`/sessions/${id}/connect`),
disconnect: (id: string) => nw.post(`/sessions/${id}/disconnect`),
getQr: (id: string) => nw.get(`/sessions/${id}/qr`),
delete: (id: string) => nw.delete(`/sessions/${id}`),
};
// ─── Avatar self-heal ── (ext /contacts/:jid/avatar/refresh) ─────────────────
// A URL do CDN do WhatsApp expira e passa a 404; aqui pedimos ao motor para
// re-buscar a foto atual pela conexão viva. Retorna a URL nova ou null.
export const avatarApi = {
refresh: async (instanceId: string, jid: string): Promise<string | null> => {
try {
const r = await nw.get(`/contacts/${encodeURIComponent(jid)}/avatar/refresh?session=${encodeURIComponent(instanceId)}`);
return r?.avatar ?? null;
} catch { return null; }
},
};
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
export const chatApi = {
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
const q = new URLSearchParams({ session: instanceId });
if (opts.search) q.set('search', opts.search);
if (opts.limit) q.set('limit', String(opts.limit));
const raw = await nw.get(`/inbox?${q.toString()}`);
const arr = Array.isArray(raw) ? raw : [];
// ext: { id, jid, displayName, avatar, phone, unreadCount, lastMessageAt,
// isPinned, isArchived, lastMessage } → shape esperado pelo chatStore.
return arr.map((c: any) => ({
id: c.id,
jid: c.jid,
instanceId,
name: c.displayName ?? null,
contactName: c.displayName ?? null,
contactPhone: c.phone ?? null,
contactAvatarUrl: c.avatar ?? null,
unreadCount: c.unreadCount ?? 0,
lastMessageAt: c.lastMessageAt ?? null,
createdAt: c.lastMessageAt ?? null,
isPinned: c.isPinned ?? false,
isArchived: c.isArchived ?? false,
lastMessage: c.lastMessage ?? null,
}));
},
markRead: (chatId: string) => nw.post(`/inbox/${chatId}/read`),
archive: (chatId: string, archived: boolean) => nw.patch(`/inbox/${chatId}/archive`, { archived }),
pin: (chatId: string, pinned: boolean) => nw.patch(`/inbox/${chatId}/pin`, { pinned }),
delete: (chatId: string) => nw.delete(`/inbox/${chatId}`),
};
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
export const messageApi = {
list: async (chatId: string, opts: { before?: string; after?: string; limit?: number } = {}) => {
const q = new URLSearchParams();
if (opts.before) q.set('before', opts.before);
if (opts.limit) q.set('limit', String(opts.limit));
const qs = q.toString();
const raw = await nw.get(`/inbox/${chatId}/messages${qs ? `?${qs}` : ''}`);
const arr = Array.isArray(raw) ? raw : (raw?.messages ?? []);
const limit = opts.limit ?? 50;
return {
messages: arr.map((m: any) => ({ ...m, chatId, replyToId: m.replyToId ?? null, replyTo: m.replyTo ?? null })),
hasMore: arr.length >= limit,
};
},
search: (instanceId: string, q: string, limit?: number) => {
const p = new URLSearchParams({ session: instanceId, q });
if (limit) p.set('limit', String(limit));
return nw.get(`/inbox/messages/search?${p.toString()}`);
},
// Envio por chatId (a ext resolve jid/instância). O motor chamava por
// (instanceId, jid, ...); o chatStore foi adaptado p/ passar (chatId, text, replyTo).
send: (chatId: string, text: string, replyToMessageId?: string) =>
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
};
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
export const mediaApi = {
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: string) => {
const fd = new FormData();
fd.append('file', file);
if (caption) fd.append('caption', caption);
if (replyToMessageId) fd.append('replyToMessageId', replyToMessageId);
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
sendAudio: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('audio', blob, 'audio.ogg');
return nw.post(`/inbox/${chatId}/send-audio`, fd);
},
// Sticker: reaproveita o endpoint de mídia (a ext detecta image/webp → sticker).
sendSticker: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('file', new File([blob], 'sticker.webp', { type: 'image/webp' }));
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
// re-baixa do WhatsApp a partir do WAMessage salvo e devolve { mediaUrl }.
redownload: (_instanceId: string, messageDbId: string): Promise<{ mediaUrl: string }> =>
nw.post(`/media/${encodeURIComponent(messageDbId)}/download`),
};
// ─── Grupos (ext não expõe participantes — stub gracioso) ───────────────────
export const groupApi = {
getParticipants: (_instanceId: string, groupJid: string) =>
Promise.resolve({ groupJid, subject: '', participants: [] as any[] }),
};
// ─── Protocolo (ext ainda não expõe CRUD por chat — stub; task follow-up) ───
export const protocolApi = {
list: (_chatId: string) => Promise.resolve([] as any[]),
open: (_chatId: string, _data: { sectorId?: string; teamId?: string }) => Promise.resolve(null as any),
update: (_chatId: string, _protocolId: string, _data: { status?: string; summary?: string }) => Promise.resolve(null as any),
};
// ─── Reconciliação (ext não expõe — no-op) ──────────────────────────────────
export const reconcileApi = {
reconcileNames: (_instanceId: string) => Promise.resolve({ updated: 0 }),
};
// ─── Tipos (compat com o motor; usados como type-only pelos componentes) ────
export interface GroupParticipant { jid: string; admin: string | null; name: string | null; phone: string }
export interface Sector { id: string; tenantId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { teams: number; protocols: number } }
export interface Team { id: string; tenantId: string; sectorId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { protocols: number } }
export type TemplateType = 'TEXT' | 'BUTTONS' | 'INTERACTIVE' | 'LIST' | 'POLL' | 'CAROUSEL'
export interface MessageTemplate { id: string; tenantId: string; name: string; description: string | null; type: TemplateType; payload: Record<string, unknown>; tags: string[]; usageCount: number; createdAt: string; updatedAt: string }
export interface StickerRecord { id: string; sha256: string; wasabiPath: string; isAnimated: boolean; isFavorite: boolean; useCount?: number; lastSeenAt?: string }
export interface RichPayload { text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any; nativeCarousel?: any; poll?: any }
// ─── Setores/Equipes (ext não expõe protocolo/setores — stubs) ──────────────
export const sectorApi = {
list: () => Promise.resolve([] as Sector[]),
create: (_name: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
listTeams: (_sectorId: string) => Promise.resolve([] as Team[]),
createTeam: (_sectorId: string, _name: string) => Promise.resolve(null as any),
updateTeam: (_sectorId: string, _teamId: string, _d: any) => Promise.resolve(null as any),
deleteTeam: (_sectorId: string, _teamId: string) => Promise.resolve({} as any),
};
// ─── Templates (stub simples) ───────────────────────────────────────────────
export const templateApi = {
list: (_opts: any = {}) => Promise.resolve([] as MessageTemplate[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
markUsed: (_id: string) => Promise.resolve({} as any),
};
// ─── Stickers (ext não expõe catálogo — stub) ───────────────────────────────
export const stickerApi = {
list: () => Promise.resolve({ listVersion: '', stickers: [] as StickerRecord[] }),
recent: () => Promise.resolve({ stickers: [] as StickerRecord[] }),
toggleFavorite: (id: string) => Promise.resolve({ id, isFavorite: false }),
};
// ─── Rich message (composer passa instanceId/jid; sem chatId não mapeia na
// ext → stub que rejeita graciosamente) ─────────────────────────────────────
export const richMessageApi = {
send: (_instanceId: string, _jid: string, _payload: RichPayload) =>
Promise.reject(new Error('Envio rico indisponível no satélite')),
};
// ─── Demais exports do chatApiService do motor (stubs — a ext não os expõe;
// mantidos para os named imports da árvore não quebrarem em runtime) ─────────
export interface ApiKeyRecord { id: string; name: string; keyPreview: string; isActive: boolean; createdAt: string; expiresAt: string | null }
export interface BackendContact { id: string; instanceId: string; jid: string; name: string | null; verifiedName: string | null; notify: string | null; phone: string | null; avatarUrl: string | null; scoreReputacao: number; flagRestricao: boolean; isBlocked: boolean; createdAt: string; updatedAt: string }
export type EventType = 'BIRTHDAY' | 'ANNIVERSARY' | 'SIGNUP_DAYS'
export type RecipientMode = 'MANUAL' | 'ALL_CONTACTS' | 'TAG_FILTER'
export type ScheduleType = 'ONCE' | 'RECURRING' | 'EVENT'
export type ScheduleStatus = 'ACTIVE' | 'PAUSED' | 'DONE' | 'CANCELLED'
export interface ScheduleRecipient { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export interface ScheduledMessage { id: string; tenantId: string; instanceId: string; name: string; templateId: string | null; payload: Record<string, unknown>; recipientMode: RecipientMode; recipients: ScheduleRecipient[]; scheduleType: ScheduleType; cronExpr: string | null; scheduledAt: string | null; eventType: EventType | null; timezone: string; status: ScheduleStatus; lastRunAt: string | null; nextRunAt: string | null; runCount: number; createdAt: string; updatedAt: string; template?: any; _count?: { logs: number } }
export interface ScheduleLog { id: string; scheduleId: string; status: string; sentCount: number; failedCount: number; error: string | null; runAt: string }
// Cliente axios-like genérico sobre o nw (alguns arquivos importam `api` direto).
export const api = {
get: (u: string) => nw.get(u).then((data: any) => ({ data })),
post: (u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data })),
put: (u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data })),
patch: (u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data })),
delete: (u: string) => nw.delete(u).then((data: any) => ({ data })),
};
export const authApi = {
register: (_d: any) => Promise.resolve({} as any),
login: (_d: any) => Promise.resolve({} as any),
logout: () => Promise.resolve({} as any),
me: () => Promise.resolve({} as any),
changePassword: (_d: any) => Promise.resolve({} as any),
};
export const apiKeyApi = {
list: () => Promise.resolve([] as ApiKeyRecord[]),
create: (_name: string, _e?: string) => Promise.resolve({} as any),
toggle: (_id: string, _a: boolean) => Promise.resolve({} as any),
delete: (_id: string) => Promise.resolve({} as any),
};
export const contactApi = {
list: (_opts: any = {}) => Promise.resolve({ total: 0, contacts: [] as BackendContact[] }),
get: (_id: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
stats: (_instanceId?: string) => Promise.resolve({ total: 0, blocked: 0, flagged: 0, active: 0 }),
};
export const broadcastApi = {
create: (_instanceId: string, _d: any) => Promise.resolve({ jobId: '', total: 0 }),
list: (_instanceId: string, _limit?: number) => Promise.resolve([] as any[]),
get: (_instanceId: string, _broadcastId: string) => Promise.resolve(null as any),
};
export const planApi = {
status: () => Promise.resolve({ daysRemaining: null as number | null, planName: '' }),
};
export const chatbotApi = {
getCredentials: (_instanceId: string) => Promise.resolve([] as any[]),
createCredential: (_instanceId: string, _d: any) => Promise.resolve({} as any),
deleteCredential: (_instanceId: string, _credId: string) => Promise.resolve({} as any),
getBots: (_instanceId: string) => Promise.resolve([] as any[]),
createBot: (_instanceId: string, _d: any) => Promise.resolve({} as any),
updateBot: (_instanceId: string, _botId: string, _d: any) => Promise.resolve({} as any),
deleteBot: (_instanceId: string, _botId: string) => Promise.resolve({} as any),
pauseChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
resumeChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
};
export const scheduleApi = {
list: (_opts: any = {}) => Promise.resolve([] as ScheduledMessage[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
pause: (_id: string) => Promise.resolve({} as any),
resume: (_id: string) => Promise.resolve({} as any),
runNow: (_id: string) => Promise.resolve({} as any),
logs: (_id: string) => Promise.resolve([] as ScheduleLog[]),
};