21bebd3256
- 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>
252 lines
15 KiB
TypeScript
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[]),
|
|
};
|