Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,35 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: 📦 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

- name: 📚 Install dependencies
run: npm ci
run: npm install --no-audit --no-fund

Comment on lines 34 to 41
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI now runs npm install without any committed npm lockfile (there is a bun.lock, but npm doesn’t use it). This makes dependency resolution non-deterministic across runs and can cause flaky CI results. Consider either committing a package-lock.json and using npm ci, or switching CI to bun install/bun test to align with bun.lock (and re-enabling dependency caching).

Copilot uses AI. Check for mistakes.
- name: 🔍 Run ESLint
run: npm run lint
continue-on-error: false
- name: 🔍 Run ESLint (changed files on PRs, full on push)
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="origin/${{ github.base_ref }}"
git fetch origin "${{ github.base_ref }}" --depth=1 || true
CHANGED=$(git diff --name-only --diff-filter=ACMRT "$BASE"...HEAD -- '*.ts' '*.tsx' \
| grep -Ev '^(dist|supabase/functions)/' || true)
if [ -n "$CHANGED" ]; then
echo "Linting changed files:"
printf ' %s\n' $CHANGED
npx eslint $CHANGED
else
echo "No lintable TS/TSX files changed."
fi
else
npx eslint .
fi

- name: 🔎 Run TypeScript check
run: npx tsc --noEmit
Expand All @@ -61,10 +77,9 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

- name: 📚 Install dependencies
run: npm ci
run: npm install --no-audit --no-fund

- name: 🧪 Run Vitest
run: npm run test -- --coverage --reporter=verbose
Expand Down Expand Up @@ -94,10 +109,9 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

- name: 📚 Install dependencies
run: npm ci
run: npm install --no-audit --no-fund

- name: 🏗️ Build for production
run: npm run build
Expand Down Expand Up @@ -140,7 +154,9 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

- name: 📚 Install dependencies
run: npm install --no-audit --no-fund

- name: 🔒 Run npm audit
run: npm audit --audit-level=high
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";

export default tseslint.config(
{ ignores: ["dist"] },
{ ignores: ["dist", "supabase/functions/**"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
Expand Down
198 changes: 150 additions & 48 deletions src/hooks/evolution/useEvolutionApiCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,70 +5,172 @@ import { log } from '@/lib/logger';

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export interface CallApiOptions {
method?: HttpMethod;
/** Max attempts including the first one. Default: 3 for idempotent verbs, 1 for POST unless `idempotencyKey` is set. */
retries?: number;
/** Milliseconds for the first backoff. Default: 250ms; doubles each attempt. */
baseBackoffMs?: number;
/** Overall per-request timeout. Default: 30s. */
timeoutMs?: number;
/** When present, POST requests become retriable and dedup'd by this key (recommended for sends). */
idempotencyKey?: string;
}

const IDEMPOTENT_METHODS = new Set<HttpMethod>(['GET']);

interface EvolutionApiError extends Error {
details?: unknown;
apiStatus?: number;
retries?: number;
retryAfterMs?: number;
}

function isRetriableStatus(status?: number): boolean {
if (status == null) return true; // network / unknown: retry
if (status === 408 || status === 425 || status === 429) return true;
if (status >= 500 && status < 600) return true;
return false;
}

function parseRetryAfter(raw: unknown): number | undefined {
if (typeof raw !== 'string' && typeof raw !== 'number') return undefined;
const n = Number(raw);
if (Number.isFinite(n) && n >= 0) return Math.floor(n * 1000);
const date = Date.parse(String(raw));
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
return undefined;
}

async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
if (signal) {
const onAbort = () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); };
if (signal.aborted) return onAbort();
signal.addEventListener('abort', onAbort, { once: true });
}
});
}

export function useEvolutionApiCore() {
const [isLoading, setIsLoading] = useState(false);
const mountedRef = useRef(true);
const inflightRef = useRef<Map<string, Promise<any>>>(new Map());
const inflightRef = useRef<Map<string, Promise<unknown>>>(new Map());

useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callApi = useCallback(async (action: string, body?: object, method: HttpMethod = 'POST'): Promise<any> => {
const dedupeKey = method === 'GET' ? `${action}:${JSON.stringify(body || {})}` : '';
if (dedupeKey && inflightRef.current.has(dedupeKey)) {
return inflightRef.current.get(dedupeKey);
}
const callApi = useCallback(
async <T = unknown>(action: string, body?: object, methodOrOptions: HttpMethod | CallApiOptions = 'POST'): Promise<T> => {
const opts: CallApiOptions = typeof methodOrOptions === 'string'
? { method: methodOrOptions }
: methodOrOptions;
const method: HttpMethod = opts.method ?? 'POST';
const baseBackoffMs = opts.baseBackoffMs ?? 250;
const timeoutMs = opts.timeoutMs ?? 30_000;
const canRetry = IDEMPOTENT_METHODS.has(method) || !!opts.idempotencyKey;
const retries = Math.max(1, opts.retries ?? (canRetry ? 3 : 1));

// Dedupe identical in-flight requests for idempotent verbs OR any POST with an idempotencyKey.
const dedupeKey = canRetry
? `${method}:${action}:${opts.idempotencyKey ?? JSON.stringify(body ?? {})}`
: '';
if (dedupeKey) {
const existing = inflightRef.current.get(dedupeKey);
if (existing) return existing as Promise<T>;
}

if (mountedRef.current) setIsLoading(true);
if (mountedRef.current) setIsLoading(true);

const promise = (async () => {
try {
const { data, error } = await supabase.functions.invoke(`evolution-api/${action}`, {
method: 'POST',
body: body ?? {},
});
if (error) throw error;
if (data && typeof data === 'object' && data.error === true) {
const apiError = Object.assign(
new Error(data.message || 'Erro na API Evolution'),
{ details: data.details, apiStatus: data.status, retries: data.retries }
);
throw apiError;
const run = (async (): Promise<T> => {
let attempt = 0;
let lastError: EvolutionApiError | null = null;

while (attempt < retries) {
attempt++;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

try {
const invokeOpts: { method: 'POST'; body: object; headers?: Record<string, string>; signal?: AbortSignal } = {
method: 'POST',
body: body ?? {},
signal: controller.signal,
};
if (opts.idempotencyKey) {
invokeOpts.headers = { 'Idempotency-Key': opts.idempotencyKey };
}
const { data, error } = await supabase.functions.invoke(`evolution-api/${action}`, invokeOpts);
Comment on lines +94 to +106
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callApi now always passes an AbortSignal (and sometimes headers) to supabase.functions.invoke. Existing unit tests assert the invoke options object exactly (toHaveBeenCalledWith({ method:'POST', body: ... })), so this change will likely break those tests. Consider updating tests to use expect.objectContaining(...) or keeping the invoke options shape stable (e.g., only add signal/headers when needed).

Suggested change
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const invokeOpts: { method: 'POST'; body: object; headers?: Record<string, string>; signal?: AbortSignal } = {
method: 'POST',
body: body ?? {},
signal: controller.signal,
};
if (opts.idempotencyKey) {
invokeOpts.headers = { 'Idempotency-Key': opts.idempotencyKey };
}
const { data, error } = await supabase.functions.invoke(`evolution-api/${action}`, invokeOpts);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
const invokeOpts: { method: 'POST'; body: object; headers?: Record<string, string> } = {
method: 'POST',
body: body ?? {},
};
if (opts.idempotencyKey) {
invokeOpts.headers = { 'Idempotency-Key': opts.idempotencyKey };
}
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
const timeoutError = Object.assign(new Error('Evolution API request timed out'), {
apiStatus: 408,
}) as EvolutionApiError;
reject(timeoutError);
}, timeoutMs);
});
const { data, error } = await Promise.race([
supabase.functions.invoke(`evolution-api/${action}`, invokeOpts),
timeoutPromise,
]);

Copilot uses AI. Check for mistakes.
if (error) {
const err = Object.assign(new Error(error.message || 'Evolution API error'), {
apiStatus: (error as { status?: number }).status,
}) as EvolutionApiError;
throw err;
}
if (data && typeof data === 'object' && (data as { error?: boolean }).error === true) {
const d = data as { message?: string; details?: unknown; status?: number; retryAfter?: unknown };
const apiError = Object.assign(new Error(d.message || 'Evolution API error'), {
details: d.details,
apiStatus: d.status,
retries: attempt,
retryAfterMs: parseRetryAfter(d.retryAfter),
}) as EvolutionApiError;
throw apiError;
}
return data as T;
} catch (error) {
const err = error as EvolutionApiError;
lastError = err;
const status = err.apiStatus;
if (attempt >= retries || !isRetriableStatus(status)) break;

const backoff = err.retryAfterMs ?? baseBackoffMs * 2 ** (attempt - 1);
const jitter = Math.floor(Math.random() * 100);
try { await sleep(backoff + jitter); } catch { break; }
continue;
} finally {
clearTimeout(timeoutId);
}
}
return data;
} catch (error) {
log.error(`Evolution API error (${action}):`, error);
throw error;
} finally {

log.error(`Evolution API error (${action}) after ${attempt} attempt(s):`, lastError);
throw lastError ?? new Error(`Evolution API failed: ${action}`);
})();

const wrapped = run.finally(() => {
if (dedupeKey) inflightRef.current.delete(dedupeKey);
if (mountedRef.current) setIsLoading(false);
}
})();
});

if (dedupeKey) inflightRef.current.set(dedupeKey, promise);
return promise;
}, []);
if (dedupeKey) inflightRef.current.set(dedupeKey, wrapped);
return wrapped;
},
[],
);

const withToast = useCallback(async (
action: string,
body: object | undefined,
successMsg: string,
errorMsg: string,
method: HttpMethod = 'POST'
) => {
try {
const data = await callApi(action, body, method);
toast.success(successMsg);
return data;
} catch (error) {
const msg = error instanceof Error ? error.message : errorMsg;
toast.error(msg);
throw error;
}
}, [callApi]);
const withToast = useCallback(
async <T = unknown>(
action: string,
body: object | undefined,
successMsg: string,
errorMsg: string,
methodOrOptions: HttpMethod | CallApiOptions = 'POST',
): Promise<T> => {
try {
const data = await callApi<T>(action, body, methodOrOptions);
toast.success(successMsg);
return data;
} catch (error) {
const msg = error instanceof Error ? error.message : errorMsg;
toast.error(msg);
throw error;
}
},
[callApi],
);

return { isLoading, callApi, withToast };
}
50 changes: 50 additions & 0 deletions supabase/functions/_shared/evolution-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,56 @@ export function normalizeEventName(event?: string): string {
return (event || '').trim().toLowerCase().replace(/_/g, '.');
}

// Redacts phone/JID for logs: keeps country+area code, masks the rest.
// "5511998765432@s.whatsapp.net" -> "551199***"
export function redactJid(jid?: string | null): string {
if (!jid) return '';
const raw = String(jid).split('@')[0].replace(/:\d+$/, '');
if (raw.length <= 6) return raw.replace(/.(?=.{0})/g, '*');
return `${raw.slice(0, 6)}***`;
}

export function generateRequestId(): string {
try { return crypto.randomUUID(); } catch { return `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; }
Comment on lines +31 to +32
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateRequestId() can fall back to a non-UUID string (e.g. "req_..."). webhook_audit_log.request_id is defined as uuid NOT NULL, so audit inserts will fail whenever the fallback path is used. Consider generating a RFC4122 v4 UUID in the fallback (e.g., via crypto.getRandomValues) or changing the DB column/type and interface to accept non-UUID request IDs consistently.

Suggested change
export function generateRequestId(): string {
try { return crypto.randomUUID(); } catch { return `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; }
function generateUuidV4Fallback(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// Set version (4) and variant (RFC4122) bits.
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
return `${hex[0]}${hex[1]}${hex[2]}${hex[3]}-${hex[4]}${hex[5]}-${hex[6]}${hex[7]}-${hex[8]}${hex[9]}-${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`;
}
export function generateRequestId(): string {
try {
return crypto.randomUUID();
} catch {
return generateUuidV4Fallback();
}

Copilot uses AI. Check for mistakes.
}

// SHA-256 hex of a string. Used to produce stable deduplication keys from raw webhook bodies.
export async function sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
}

// Marks an event as processed. Returns true if this is the first time (caller should process),
// false if a prior row already exists (caller should treat as duplicate). Non-unique errors are
// treated as "new" so the handler is never blocked by audit-infra failure.
// deno-lint-ignore no-explicit-any
export async function markEventProcessed(supabase: any, eventId: string, instance: string, eventType: string): Promise<boolean> {
const { error } = await supabase.from('webhook_events_processed').insert({
event_id: eventId, instance, event_type: eventType,
});
if (!error) return true;
if (error.code === '23505') return false;
console.warn('[idempotency] insert failed, proceeding as new:', error.message ?? error.code);
return true;
}

export interface WebhookAuditRow {
request_id: string;
instance?: string | null;
event_type?: string | null;
status: 'received' | 'processed' | 'duplicate' | 'error' | 'rejected';
duration_ms?: number | null;
error_message?: string | null;
}

// deno-lint-ignore no-explicit-any
export async function auditWebhookEvent(supabase: any, row: WebhookAuditRow): Promise<void> {
try { await supabase.from('webhook_audit_log').insert(row); } catch (e) {
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auditWebhookEvent() ignores the PostgREST response and only logs on thrown exceptions. In supabase-js, most insert failures (e.g., constraint/type errors) are returned as { error } without throwing, so audit writes can fail silently. Consider capturing the { error } result and logging it when present to avoid losing observability.

Suggested change
try { await supabase.from('webhook_audit_log').insert(row); } catch (e) {
try {
const { error } = await supabase.from('webhook_audit_log').insert(row);
if (!error) return;
console.warn('[audit] insert failed:', error.message ?? error.code ?? String(error));
} catch (e) {

Copilot uses AI. Check for mistakes.
console.warn('[audit] insert failed:', (e as Error).message ?? String(e));
}
}

export function toEventRecords(data: unknown, collectionKeys: string[] = []): Record<string, unknown>[] {
if (Array.isArray(data)) return data.filter(isRecord);
if (!isRecord(data)) return [];
Expand Down
Loading
Loading