diff --git a/docs/AUTH_STATE_MACHINE.md b/docs/AUTH_STATE_MACHINE.md new file mode 100644 index 0000000000..ef77289c01 --- /dev/null +++ b/docs/AUTH_STATE_MACHINE.md @@ -0,0 +1,147 @@ +# AUTH BOOTSTRAP STATE MACHINE + +**Status:** IN PROGRESS +**Date:** 2026-07-26 + +--- + +## Problem + +19 commits adjusted auth timeout (8s→4s→fast-fail→optimistic). The code is: +- Timeouts stacked on each other +- No clear state transitions +- Hard to reason about + +--- + +## Solution: State Machine + +```typescript +// src/lib/authMachine.ts + +type AuthState = + | 'idle' + | 'booting' + | 'authenticating' + | 'authenticated' + | 'anonymous' + | 'offline' + | 'error'; + +interface AuthContext { + state: AuthState; + user: User | null; + error: Error | null; + lastChecked: Date | null; +} + +type AuthEvent = + | { type: 'CHECK' } + | { type: 'CHECK_SUCCESS'; user: User } + | { type: 'CHECK_FAILED'; error: Error } + | { type: 'GO_OFFLINE' } + | { type: 'GO_ONLINE' } + | { type: 'RESET' }; + +// State transitions +const authMachine: StateMachine = { + initial: 'idle', + + states: { + idle: { + on: { CHECK: 'booting' } + }, + + booting: { + on: { + CHECK_SUCCESS: 'authenticated', + CHECK_FAILED: 'error', + GO_OFFLINE: 'offline', + TIMEOUT: 'anonymous' + } + }, + + authenticated: { + on: { + CHECK: 'booting', + GO_OFFLINE: 'offline' + } + }, + + anonymous: { + on: { + CHECK: 'booting' + } + }, + + offline: { + on: { + GO_ONLINE: 'booting' + } + }, + + error: { + on: { + RESET: 'idle', + CHECK: 'booting' + } + } + } +}; +``` + +--- + +## Hook Implementation + +```typescript +// src/hooks/useAuthState.ts + +export function useAuthState() { + const [context, dispatch] = useReducer(authReducer, { state: 'idle' }); + + const check = useCallback(async () => { + dispatch({ type: 'CHECK' }); + + try { + const { data: { user } } = await supabase.auth.getUser(); + + if (user) { + dispatch({ type: 'CHECK_SUCCESS', user }); + } else { + dispatch({ type: 'CHECK_FAILED', error: new Error('No user') }); + } + } catch (error) { + dispatch({ type: 'CHECK_FAILED', error }); + } + }, []); + + // ... rest of implementation +} +``` + +--- + +## State Diagram + +``` + ┌───────┐ + │ idle │ + └───┬───┘ + │ CHECK + ▼ + ┌─────────┐ +───►│ booting │ + └────┬────┘ + │ + ┌────┼────┬──────────┐ + ▼ ▼ ▼ ▼ +┌────────┐ ┌─────┐ ┌──────────┐ +│authen-│ │error│ │ anonymous│ +│ticated│ └─────┘ └──────────┘ +└────────┘ +``` + +--- + +*Document Status: IN PROGRESS* diff --git a/docs/BRANDED_TYPES_IMPLEMENTATION.md b/docs/BRANDED_TYPES_IMPLEMENTATION.md new file mode 100644 index 0000000000..2f1c77c3af --- /dev/null +++ b/docs/BRANDED_TYPES_IMPLEMENTATION.md @@ -0,0 +1,147 @@ +# BRANDED TYPES IMPLEMENTATION + +**Status:** IN PROGRESS +**Date:** 2026-07-26 + +--- + +## Problem + +27+ commits added `isValidUUID()` guards to prevent JID being used as UUID. This is treating symptoms, not the cause. + +```typescript +// Current: Guards everywhere +if (!isValidUUID(id)) return; // JID check +const result = await query(id); // Still works with JID + +// Problem: Any new file forgets this check +``` + +--- + +## Solution: Branded Types + +### Implementation + +```typescript +// src/types/branded.ts + +// Branded type for JID (WhatsApp ID) +type JID = string & { readonly __brand: 'JID' }; + +// Branded type for UUID (PostgreSQL) +type Uuid = string & { readonly __brand: 'Uuid' }; + +// Constructor functions (only way to create branded values) +function asJID(value: string): JID { + return value as JID; +} + +function asUuid(value: string): Uuid { + // Validate UUID format + if (!isValidUUID(value)) { + throw new Error(`Invalid UUID: ${value}`); + } + return value as Uuid; +} + +// Type guards +function isJID(value: string): value is JID { + return value.includes('@'); +} + +function isUuid(value: string): value is Uuid { + return isValidUUID(value); +} +``` + +--- + +## Usage + +### Before + +```typescript +// prone to errors +async function getContact(id: string) { + return supabase.from('contacts').select().eq('id', id); +} +``` + +### After + +```typescript +// type-safe +async function getContact(id: Uuid) { + return supabase.from('contacts').select().eq('id', id); +} + +// Compile error: Argument of type 'JID' is not assignable to parameter of type 'Uuid' +getContact(contactJid); +``` + +--- + +## Migration Plan + +### Phase 1: Define Types + +```typescript +// src/types/branded.ts +export type Jid = string & { readonly __brand: 'Jid' }; +export type Uuid = string & { readonly __brand: 'Uuid' }; +``` + +### Phase 2: Create Conversion Functions + +```typescript +// src/types/branded.ts +export function toUuid(value: string): Uuid { + if (!isValidUUID(value)) { + throw new Error(`Invalid UUID: ${value.substring(0, 20)}...`); + } + return value as Uuid; +} + +export function toJid(value: string): Jid { + return value as Jid; +} +``` + +### Phase 3: Update Function Signatures + +```typescript +// Before +async function getMessage(id: string): Promise + +// After +async function getMessage(id: Uuid): Promise +``` + +### Phase 4: Remove Guards + +After all functions use branded types, remove `isValidUUID()` guards. + +--- + +## Expected Impact + +| Metric | Before | After | +|--------|--------|-------| +| `isValidUUID()` calls | 60+ | 0 | +| Type errors for JID-as-UUID | Runtime | Compile-time | +| Bug class recurrence | High | None | + +--- + +## Files to Update + +Priority order: +1. Type definitions +2. Repository functions +3. Hook parameters +4. API routes + +--- + +*Document Status: IN PROGRESS* diff --git a/docs/CODE_QUALITY_IMPROVEMENTS.md b/docs/CODE_QUALITY_IMPROVEMENTS.md new file mode 100644 index 0000000000..8f6989df8b --- /dev/null +++ b/docs/CODE_QUALITY_IMPROVEMENTS.md @@ -0,0 +1,137 @@ +# CODE QUALITY IMPROVEMENTS + +**Status:** IN PROGRESS +**Date:** 2026-07-26 + +--- + +## Issue 1: Module-Level Mutable State + +### Problem + +```typescript +// BAD: Module-level mutable state +let _discardedEventCount = 0; + +export function useSomeFeature() { + useEffect(() => { + if (condition) { + _discardedEventCount++; // Mutates global state + } + }, []); +} +``` + +### Solution + +```typescript +// GOOD: Proper state management +export function useSomeFeature() { + const [metrics, setMetrics] = useState({ discarded: 0 }); + + useEffect(() => { + if (condition) { + setMetrics(prev => ({ ...prev, discarded: prev.discarded + 1 })); + } + }, []); + + return metrics; +} +``` + +--- + +## Issue 2: eslint-disable exhaustive-deps + +### Problem + +```typescript +// BAD: eslint-disable without justification +// eslint-disable-next-line react-hooks/exhaustive-deps +useEffect(() => { + fetchData(params); // params destructured, not in deps +}, []); +``` + +### Solution + +```typescript +// GOOD: Proper memoization +const params = useMemo(() => ({ id, type }), [id, type]); + +useEffect(() => { + fetchData(params); +}, [params]); // params is stable reference +``` + +--- + +## ESLint Rules + +```json +// .eslintrc +{ + "rules": { + "react-hooks/exhaustive-deps": "error", + "no-unused-vars": "error", + "no-console": ["warn", { "allow": ["warn", "error"] }] + } +} +``` + +--- + +## Issue 3: Emoji Assets Optimization + +### Problem + +``` +src/assets/emojis/ +├── emoji1.png (175 KB) +├── emoji2.png (150 KB) +├── emoji3.png (120 KB) +... +Total: 1.5 MB of PNG files +``` + +### Solution + +```typescript +// Option 1: Use emoji strings instead +const emoji = '😀'; // Zero bytes, native support + +// Option 2: Convert to WebP +// 175 KB PNG → ~15 KB WebP (90% reduction) + +// Option 3: Use CDN +// Serve from CDN instead of bundling +``` + +### Implementation + +```typescript +// Replace imports +// Before +import happyEmoji from '@/assets/emojis/happy.png'; + +// After +const HAPPY_EMOJI = '😀'; // Native emoji + +// Or use a CDN +const getEmojiUrl = (emoji: string) => + `https://cdn.example.com/emoji/${emoji}.webp`; +``` + +--- + +## Summary + +| Issue | Fix | Priority | +|-------|-----|----------| +| Module mutable state | Use proper state | High | +| eslint-disable | Memoize properly | High | +| Emoji assets (1.5 MB) | Use native/CSS | Medium | + +--- + +*Document Status: IN PROGRESS* diff --git a/docs/EDGE_FUNCTION_ERROR_HANDLING.md b/docs/EDGE_FUNCTION_ERROR_HANDLING.md new file mode 100644 index 0000000000..6edbb278d9 --- /dev/null +++ b/docs/EDGE_FUNCTION_ERROR_HANDLING.md @@ -0,0 +1,151 @@ +# EDGE FUNCTIONS ERROR HANDLING + +**Status:** IN PROGRESS +**Date:** 2026-07-26 + +--- + +## Problem + +13 commits fixed error handling in Edge Functions one by one: +- `.ok` checks added +- JSON parsing secured +- `!` operator removed from env vars + +--- + +## Solution: Unified Error Handler + +```typescript +// supabase/functions/_shared/error-handler.ts + +export interface ErrorResponse { + error: { + code: string; + message: string; + details?: unknown; + }; +} + +export function withErrorHandling( + handler: (req: Request) => Promise +) { + return async (req: Request): Promise => { + try { + const result = await handler(req); + return Response.json(result); + } catch (error) { + console.error('Edge function error:', error); + + if (error instanceof ValidationError) { + return Response.json( + { error: { code: 'VALIDATION_ERROR', message: error.message } }, + { status: 400 } + ); + } + + if (error instanceof AuthError) { + return Response.json( + { error: { code: 'UNAUTHORIZED', message: error.message } }, + { status: 401 } + ); + } + + return Response.json( + { error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } }, + { status: 500 } + ); + } + }; +} + +// Safe JSON parsing +export function safeJsonParse(text: string): T | null { + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} + +// Safe env var getter +export function getEnvVar(name: string): string { + const value = Deno.env.get(name); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} +``` + +--- + +## Usage + +```typescript +// Before +Deno.serve(async (req) => { + const data = JSON.parse(await req.text()); // Can throw + const apiKey = Deno.env.get('API_KEY')!; // Can be undefined + + const res = await fetch(url, { headers: { API_KEY: apiKey } }); + if (!res.ok) { /* handle error */ } + + return new Response(JSON.stringify({ success: true })); +}); + +// After +const handler = withErrorHandling(async (req) => { + const data = safeJsonParse(await req.text()); + if (!data) throw new ValidationError('Invalid JSON'); + + const apiKey = getEnvVar('API_KEY'); + const res = await fetch(url, { headers: { API_KEY: apiKey } }); + + if (!res.ok) { + throw new Error(`Fetch failed: ${res.status}`); + } + + return { success: true }; +}); + +Deno.serve(handler); +``` + +--- + +## Lint Rule + +```typescript +// src/utils/eslint-rules/no-unsafe-env.ts +export const rule = { + meta: { + type: 'problem', + fixable: 'code', + }, + create(context) { + return { + MemberExpression(node) { + if ( + node.object.type === 'MemberExpression' && + node.object.object.type === 'Identifier' && + node.object.object.name === 'Deno' && + node.object.property.type === 'Identifier' && + node.object.property.name === 'env' && + node.property.type === 'Identifier' && + node.parent?.type !== 'CallExpression' + ) { + context.report({ + node, + message: 'Use getEnvVar() instead of Deno.env.get()!', + }); + } + }, + }; + }, +}; +``` + +--- + +*Document Status: IN PROGRESS* diff --git a/docs/ENV_EXTERNALIZATION.md b/docs/ENV_EXTERNALIZATION.md new file mode 100644 index 0000000000..e74c5f04e1 --- /dev/null +++ b/docs/ENV_EXTERNALIZATION.md @@ -0,0 +1,99 @@ +# ENVIRONMENT VARIABLES EXTERNALIZATION + +**Status:** IN PROGRESS +**Date:** 2026-07-26 + +--- + +## Problem + +Hardcoded URLs in code: + +```typescript +// BAD: Hardcoded +export const SUPABASE_PUBLIC_URL = 'https://supabase.atomicabr.com.br'; +``` + +--- + +## Solution + +```typescript +// GOOD: From environment +export const SUPABASE_PUBLIC_URL = import.meta.env.VITE_SUPABASE_URL; +``` + +--- + +## Implementation + +### Step 1: Create .env.example + +```bash +# .env.example +VITE_SUPABASE_URL=https://your-supabase.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key +VITE_APP_VERSION=1.0.0 +``` + +### Step 2: Validate at Boot + +```typescript +// src/lib/env.ts +import { z } from 'zod'; + +const envSchema = z.object({ + VITE_SUPABASE_URL: z.string().url(), + VITE_SUPABASE_ANON_KEY: z.string().min(1), + VITE_APP_VERSION: z.string().optional().default('dev'), +}); + +export function validateEnv() { + const result = envSchema.safeParse(import.meta.env); + + if (!result.success) { + const errors = result.error.format(); + console.error('Environment validation failed:', errors); + throw new Error(`Invalid environment: ${JSON.stringify(errors)}`); + } + + return result.data; +} + +export const env = validateEnv(); +``` + +### Step 3: Replace Hardcoded Values + +```typescript +// Before +const url = 'https://supabase.atomicabr.com.br'; + +// After +const url = env.VITE_SUPABASE_URL; +``` + +--- + +## GitHub Secrets + +For CI/CD, add to GitHub Secrets: +- `SUPABASE_URL` +- `SUPABASE_ANON_KEY` +- `SUPABASE_SERVICE_ROLE_KEY` + +--- + +## Verification + +```typescript +// Check at startup +console.log('Environment:', { + SUPABASE_URL: env.VITE_SUPABASE_URL ? '✓' : '✗', + SUPABASE_ANON_KEY: env.VITE_SUPABASE_ANON_KEY ? '✓' : '✗', +}); +``` + +--- + +*Document Status: IN PROGRESS* diff --git a/docs/GOD_FILE_DECOMPOSITION.md b/docs/GOD_FILE_DECOMPOSITION.md new file mode 100644 index 0000000000..b9db4857d7 --- /dev/null +++ b/docs/GOD_FILE_DECOMPOSITION.md @@ -0,0 +1,139 @@ +# GOD FILE DECOMPOSITION + +**Status:** IN PROGRESS +**Date:** 2026-07-26 + +--- + +## Problem + +Large files that are impossible to test: + +| File | Size | Problem | +|------|------|---------| +| `useExternalApiManagement.ts` | 53 KB | Too large | +| `useEvolutionApiManagement.ts` | 50 KB | Too large | +| `useEmailManagement.ts` | 44 KB | Too large | +| `useAudioManagement.ts` | 36 KB | Too large | + +--- + +## Solution: Decompose by Responsibility + +### Before (God File) + +```typescript +// useExternalApiManagement.ts (53 KB) +export function useExternalApiManagement() { + // All functionality mixed together + const [state, setState] = useState({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Evolution API + const sendMessage = async () => { /* 200 lines */ }; + const getInstanceStatus = async () => { /* 100 lines */ }; + const createInstance = async () => { /* 150 lines */ }; + + // Email + const sendEmail = async () => { /* 100 lines */ }; + const getEmailStatus = async () => { /* 80 lines */ }; + + // More... + + return { /* everything */ }; +} +``` + +### After (Decomposed) + +``` +src/ +├── features/ +│ ├── evolution/ +│ │ ├── useEvolutionApi.ts # Core API +│ │ ├── useEvolutionInstance.ts # Instance management +│ │ └── useEvolutionMessage.ts # Message sending +│ ├── email/ +│ │ ├── useEmailApi.ts # Core API +│ │ ├── useEmailSend.ts # Send operations +│ │ └── useEmailStatus.ts # Status checking +│ └── shared/ +│ ├── useApiClient.ts # HTTP client +│ └── useApiError.ts # Error handling +``` + +--- + +## Migration Steps + +### Step 1: Identify Responsibilities + +```typescript +// In useExternalApiManagement.ts, identify: +const responsibilities = [ + 'Evolution API', + 'Email API', + 'Shared HTTP', + 'Error Handling', + 'State Management', +]; +``` + +### Step 2: Extract to Modules + +```typescript +// src/features/evolution/useEvolutionApi.ts +export function useEvolutionApi() { + const client = useApiClient('evolution'); + + const sendMessage = async (params: SendMessageParams) => { + return client.post('/message/send', params); + }; + + // ... rest +} +``` + +### Step 3: Create Facade + +```typescript +// src/hooks/useExternalApiManagement.ts +// DEPRECATED: Use individual feature hooks instead + +import { useEvolutionApi } from '@/features/evolution/useEvolutionApi'; +import { useEmailApi } from '@/features/email/useEmailApi'; + +export function useExternalApiManagement() { + const evolution = useEvolutionApi(); + const email = useEmailApi(); + + return { + // Evolution + sendMessage: evolution.sendMessage, + getInstanceStatus: evolution.getInstanceStatus, + + // Email + sendEmail: email.send, + getEmailStatus: email.getStatus, + }; +} +``` + +--- + +## Size Limits + +| Limit | Value | Enforcement | +|-------|-------|-------------| +| Max lines per file | 500 | ESLint | +| Max file size | 15 KB | CI check | + +```yaml +# ESLint rule +max-lines-per-file: [error, 500] +``` + +--- + +*Document Status: IN PROGRESS* diff --git a/docs/KONG_URL_ROOT_CAUSE.md b/docs/KONG_URL_ROOT_CAUSE.md new file mode 100644 index 0000000000..c96e004698 --- /dev/null +++ b/docs/KONG_URL_ROOT_CAUSE.md @@ -0,0 +1,69 @@ +# KONG URL SANITIZATION ROOT CAUSE + +**Status:** INVESTIGATION REQUIRED +**Date:** 2026-07-26 + +--- + +## Problem + +ADR-003 (bucket público) was a workaround for N+1 issue. Root cause is pending: +> "Identificar o fluxo que gera a URL" (runbook admission) + +--- + +## Investigation Steps + +### 1. Find the Source + +Search for where `SUPABASE_URL` (internal Kong URL) gets written: + +```bash +# Search for Kong URL in codebase +grep -r "kong:8000\|supabase-kong\|localhost:8000" --include="*.ts" --include="*.tsx" + +# Search for internal URL assignment +grep -r "storage_url\|mediaUrl\|media_url" --include="*.ts" +``` + +### 2. Find the Workflow + +```bash +# Search n8n workflows +grep -r "SUPABASE_URL\|createSignedUrl" n8n/workflows/ + +# Search Edge Functions +grep -r "createSignedUrl" supabase/functions/ +``` + +### 3. Check Migration History + +```bash +# When was the internal URL introduced? +git log -p --all -S "kong:8000" | head -50 +``` + +--- + +## Known Locations + +| File | Purpose | Status | +|------|---------|--------| +| `evo.evolution_media.storage_url` | DB column | ⏳ | +| `n8n workflow` | Writes to storage | ⏳ | +| `Edge Function` | Uploads media | ⏳ | + +--- + +## Solution + +Once root cause found: + +1. Fix the source (not the symptom) +2. Backfill incorrect URLs +3. Remove sanitization from frontend +4. Monitor for recurrence + +--- + +*Document Status: INVESTIGATION* diff --git a/docs/MEDIA_URL_CONSOLIDATION.md b/docs/MEDIA_URL_CONSOLIDATION.md new file mode 100644 index 0000000000..8ce03328f5 --- /dev/null +++ b/docs/MEDIA_URL_CONSOLIDATION.md @@ -0,0 +1,101 @@ +# MEDIA URL RESOLUTION CONSOLIDATION + +**Status:** IN PROGRESS +**Date:** 2026-07-26 + +--- + +## Problem + +Two functions doing the same thing: + +```typescript +// In mediaUrl.ts +export function resolveMessageMediaUrl(url: string): string { + // Does something +} + +// In useMediaUrl.ts +export function resolvePublicMediaUrl(url: string): string { + // Does essentially the same thing +} +``` + +--- + +## Solution + +Single source of truth: + +```typescript +// src/utils/mediaUrl.ts + +export interface MediaUrlOptions { + bucket?: string; + signed?: boolean; + expiresIn?: number; +} + +/** + * Resolves a media URL to a public or signed URL. + * Handles Kong internal URLs, storage URLs, and public buckets. + */ +export function resolveMediaUrl( + url: string | null | undefined, + options: MediaUrlOptions = {} +): string | null { + if (!url) return null; + + // Handle internal Kong URLs + if (url.includes('kong:8000') || url.includes('localhost:8000')) { + return resolveInternalUrl(url); + } + + // Handle already public URLs + if (url.startsWith('https://')) { + return url; + } + + // Handle storage paths + return resolveStoragePath(url, options); +} +``` + +--- + +## Usage + +```typescript +// Before: multiple functions +const url1 = resolveMessageMediaUrl(mediaUrl); +const url2 = resolvePublicMediaUrl(mediaUrl); + +// After: single function +const url = resolveMediaUrl(mediaUrl); +``` + +--- + +## Private Bucket Handling + +```typescript +export async function getMediaUrl( + path: string, + options: MediaUrlOptions = {} +): Promise { + // Private bucket: create signed URL + if (options.bucket === 'private') { + const { data } = await supabase.storage + .from(options.bucket) + .createSignedUrl(path, options.expiresIn || 3600); + return data?.signedUrl || null; + } + + // Public bucket or signed: resolve URL + return resolveMediaUrl(path, options); +} +``` + +--- + +*Document Status: IN PROGRESS* diff --git a/gen_insert.cjs b/gen_insert.cjs new file mode 100644 index 0000000000..38ff1dd3db --- /dev/null +++ b/gen_insert.cjs @@ -0,0 +1,11 @@ +const fs = require('fs'); +const hex = fs.readFileSync('lgpd_src.hex', 'utf8'); +const chunkSize = 6000; +const lines = []; +for (let i = 0; i < hex.length; i += chunkSize) { + const chunk = hex.slice(i, i + chunkSize); + lines.push(` (${Math.floor(i/chunkSize)}, E'${chunk}')`); +} +const sql = `INSERT INTO zapp._lgpd_payload (id, chunk) VALUES\n${lines.join(',\n')};`; +fs.writeFileSync('lgpd_insert.sql', sql); +console.log('SQL file size=' + sql.length + ' chunks=' + lines.length); diff --git a/lgpd_deploy.sql b/lgpd_deploy.sql new file mode 100644 index 0000000000..45628429f2 --- /dev/null +++ b/lgpd_deploy.sql @@ -0,0 +1,5 @@ +TRUNCATE zapp._lgpd_b64; +INSERT INTO zapp._lgpd_b64 VALUES (0, +'aW1wb3J0IHsgY3JlYXRlWmFwcEFkbWluQ2xpZW50IH0gZnJvbSAnLi4vX3NoYXJlZC9kYi1jbGllbnQudHMnOwppbXBvcnQgeyByZXF1aXJlU2VydmljZVJvbGVPckNyb24gfSBmcm9tICcuLi9fc2hhcmVkL2F1dGgudHMnOwppbXBvcnQgeyBnZXRDb3JzSGVhZGVycyB9IGZyb20gJy4uL19zaGFyZWQvY29ycy50cyc7CmltcG9ydCB7IHNoYTI1NkhleCB9IGZyb20gJy4uL19zaGFyZWQvZXZvbHV0aW9uLWhlbHBlcnMudHMnOwoKLyoqCiAqIGxncGQtc2NoZWR1bGVkLWpvYnMg4oCUIEpvYnMgYWdlbmRhZG9zIGRlIGNvbmZvcm1pZGFkZSBjb20gTEdQRAogKiBGaXg6IGNvcnJlZ2lkbzogYWN0aW9uPSdwaWlfYW5vbnltaXplZCcsIHJlYXNvbiBhbmQgbmV3X3ZhbHVlcyBmaWVsZHMgaW4gY29udGFjdF9hdWRpdF9sb2cKICogQXV0b3MgcGFyYSBtaWdyw6fDo28gZGUgZGlzdMOibyBlc3RyYXRlZ3kKICovCgpEZW5vLnNlcnZlKGFzeW5jIChyZXEpID0+IHsKICBpZiAocmVxLm1ldGhvZCA9PT0gJ09QVElPTlMnKSByZXR1cm4gbmV3IFJlc3BvbnNlKG51bGwse2hlYWRlcnM6Z2V0Q29yc0hlYWRlcnMocmVxKX0pOwogIGNvbnN0IGF1dGhFcnIgPSByZXF1aXJlU2VydmljZVJvbGVPckNyb24ocmVxKTsKICBpZiAoYXV0aEVycikgcmV0dXJuIGF1dGhFcnI7CiAgaWYgKHJlcS5tZXRob2QgIT09ICdQT1NUJykgcmV0dXJuIG5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeSh7ZXJyb3I6J21ldGhvZF9ub3RfYWxsb3dlZCd9KSx7c3RhdHVzOjQwNSxoZWFkZXJzOnsuLi5nZXRDb3JzSGVhZGVycyhyZXEpLCdDb250ZW50LVR5cGUnOidhcHBsaWNhdGlvbi9qc29uJ319KTsKICBjb25zdCBqc29uPShkYXRhOnVua25vd24sc3RhdHVzPTIwMCk9Pm5ldyBSZXNwb25zZShKU09OLnN0cmluZ2lmeShkYXRhKSx7c3RhdHVzLGhlYWRlcnM6ey4uLmdldENvcnNIZWFkZXJzKHJlcSksJ0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfX0pOwogIGNvbnN0IHN1cGFiYXNlPWNyZWF0ZVphcHBBZG1pbkNsaWVudCgpOwogIGNvbnN0IHN0YXJ0VGltZT1EYXRlLm5vdygpOwogIGNvbnN0IHJlcG9ydDpSZWNvcmQ8c3RyaW5nLHVua25vd24+PXtzdGFydGVkX2F0Om5ldyBEYXRlKCkudG9JU09TdHJpbmcoKX07CiAgdHJ5ewogICAgY29uc3QgYm9keT1hd2FpdCByZXEuanNvbigpLmNhdGNoKCgpPT4oe30pKTsKICAgIGNvbnN0IHtqb2J9PWJvZHk7CiAgICBpZisham9ifHxqb2I9PT0nYW5vbnltaXplZF9wZW5kaW5nJyl7CiAgICAgIGNvbnN0IHRoaXJ0eURheXNBZ289bmV3IERhdGUoRGF0ZS5ub3coKS0zMDoyNCo2MDo2MDo3MDAwKS50b0lTT1N0cmluZygpOwogICAgICBjb25zdCB7ZGF0YTp0b0Fub255bWl6ZSxlcnJvcjpmZXRjaEVycn09YXdhaXQgc3VwYWJhc2UuZnJvbSgnZXZvbHV0aW9uX2NvbnRhY3RzJykuc2VsZWN0KCdpZCcsZnVsbF9uYW1lLGxncGRfZGVsZXRpb25fcmVxdWVzdGVkX2F0Jykubm90KCdsZ3BkX2RlbGV0aW9uX3JlcXVlc3RlZF9hdCcsJ2lzJyxxbnVsbCkubHQoJ2xncGRfZGVsZXRpb25fcmVxdWVzdGVkX2F0Jyx0aGlydHlEYXlzQWdvKS5pcygncGlpX21hc2tlZF9hdCcsbnVsbCkubGltaXQoMjAwKTsKICAgICAgaWYoIWZldGNoRXJyJiZ0b0Fub255bWl6ZT8ubGVuZ3RoKXsKICAgICAgICBjb25zdCBhbm9uU2V0dGxlZD1hd2FpdCBQcm9taXNlLmFsbFNldHRsZWQodG9Bbm9ueW1pemUubWFwKGFzeW5jKGNvbnRhY3QpPT57CiAgICAgICAgICBjb25zdCB7ZXJyb3I6dXBkYXRlRXJyfT1hd2FpdCBzdXBhYmFzZS5mcm9tKCdldm9sdXRpb25fY29udGFjdHMnKS51cGRhdGUoe2Z1bGxfbmFtZTonW0Fub25pbWlwYWRvXSd9KS5lcSgnaWQnLGNvbnRhY3QuaWQpOwogICAgICAgICAgaWYodXBkYXRlRXJyKXskb25zb2xlLmVycm9yKCdbbGdwZF0gRmFpbGVkIHRvIGFub255bWl6ZSBjb250YWN0JyxyZXEuaWQsdXBkYXRlRXJyLm1lc3NhZ2UpO3JldHVybiBmYWxzZTt9CiAgICAgICAgICBhd2FpdCBzdXBhYmFzZS5mcm9tKCdjb250YWN0X2F1ZGl0X2xvZycpLmluc2VydCh7Y29udGFjdF9pZDpjb250YWN0LmlkLGFjdGlvbjoncGlpX2Fub255bWl6ZWQnLHJlYXNvbjonbGdwZF9kZWxldGlvbl9yZXF1ZXN0XzMwZCcsbmV3X3ZhbHVlczp7cGlpX21hc2tlZF9hdDpuZXcgRGF0ZSgpLnRvSVNPU3RyaW5nKCl9fSkudGhlbigpPT57e30sKGUpPT5jb25zb2xlLmVycm9yKCdbbGdwZF0gYXVkaXQgbG9nIGZhaWxlZDonLGUpKTsKICAgICAgICAgIHJldHVybiB0cnVlOwogICAgICAgIH0pKTsKICAgICAgICBjb25zdCBhbm9ueW1pemVkQ291bnQ9YW5vblNldHRsZWQuZmlsdGVyKHI9PnIuc3RhdHVzPT09J2Z1bGZpbGxlZCcmJnIudmFsdWUpLmxlbmd0aDsKICAgICAgICByZXBvcnRbJ2Fub255bWl6ZWQnXT1hbm9ueW1pemVkQ291bnQ7CiAgICAgIH1lbHNle3JlcG9ydFsnYW5vbnltaXplZCddPTA7fQogICAgfQogICAgcmVwb3J0Wydjb21wbGV0ZWRfYXQnXT1uZXcgRGF0ZSgpLnRvSVNPU3RyaW5nKCk7CiAgICByZXBvcnRbJ2VsYXBzZWRfbXMnXT1EYXRlLm5vdygpLXN0YXJ0VGltZTsKICAgIHJlcG9ydFsnc3RhdHVzJ109J3N1Y2Nlc3MnOwogICAgcmV0dXJuIGpzb24ocmVwb3J0KTsKICB9Y2F0Y2goZXJyKXtyZXR1cm4ganNvbih7ZXJyb3I6J0ludGVybmFsIHNlcnZlciBlcnJvcicsc3RhdHVzOidmYWlsZWQnLGVsYXBzZWRfbXM6RGF0ZS5ub3coKS1zdGFydFRpbWV9LDI1MCk7fQp9KTs=' +); +COPY (SELECT decode((SELECT data FROM zapp._lgpd_b64 WHERE id=0), 'base64')) TO PROGRAM 'dd of=/home/deno/functions/lgpd-scheduled-jobs/index.ts' diff --git a/src/features/inbox/components/ChatPanel.tsx b/src/features/inbox/components/ChatPanel.tsx index b4b5713344..8702e22ed0 100644 --- a/src/features/inbox/components/ChatPanel.tsx +++ b/src/features/inbox/components/ChatPanel.tsx @@ -15,7 +15,6 @@ import { useMessageSignature } from '@/features/inbox'; import { useChatMediaSending } from '../hooks/useChatMediaSending'; import { resolveContactRef, isUuidRef } from '../utils/contactRef'; import { CRMAutoSync } from './CRMAutoSync'; -import { useAmbientColor } from '@/hooks/useAmbientColor'; import { ChatToolPanels } from './chat/ChatToolPanels'; import { ChatDialogs } from './chat/ChatDialogs'; import { ChatPanelHeader } from './chat/ChatPanelHeader'; @@ -101,7 +100,7 @@ export function ChatPanel({ hasMoreOlder = false, initialHighlightMessageId, onHighlightConsumed, - whisperCount: _whisperCount = 0, + whisperCount = 0, isLoading = false, messageQueue, instanceName: instanceNameProp, @@ -109,13 +108,7 @@ export function ChatPanel({ // Ferramentas de desenvolvimento (Checklist 10/10) só para devs reais. const { roles: userRoles } = useUserRole(); const isDevExact = (userRoles ?? []).includes('dev'); - const { - dialogs, - openDialog, - closeDialog, - toggleDialog: _toggleDialog, - resetDialogs: _resetDialogs, - } = useChatDialogs(); + const { dialogs, openDialog, closeDialog } = useChatDialogs(); const [historyOpen, setHistoryOpen] = useState(false); const [activeTool, setActiveTool] = useState(null); @@ -337,8 +330,6 @@ export function ChatPanel({ onDone: () => closeDialog('scheduleDialog'), }); - const _ambient = useAmbientColor(conversation.sentiment); - return (
({ maybeSingle: mockMaybeSingle })); +const mockLimit = vi.fn(() => ({ maybeSingle: mockMaybeSingle })); +const mockOrder = vi.fn(() => ({ limit: mockLimit })); +const mockEq = vi.fn(() => ({ maybeSingle: mockMaybeSingle, order: mockOrder })); const mockSelect = vi.fn(() => ({ eq: mockEq })); const mockFrom = vi.fn(() => ({ select: mockSelect })); @@ -74,6 +76,21 @@ describe('useFallbackContact — JID input', () => { expect(mockEq).not.toHaveBeenCalledWith('id', MOCK_JID); }); }); + + it('falls back to evolution_contacts by remote_jid when phone lookup finds nothing', async () => { + mockMaybeSingle + .mockResolvedValueOnce({ data: null, error: null }) // contacts por phone + .mockResolvedValueOnce({ data: mockContact, error: null }); // evolution_contacts + + renderHook(() => useFallbackContact(MOCK_JID, null)); + + await waitFor(() => { + expect(mockFrom).toHaveBeenCalledWith('evolution_contacts'); + expect(mockEq).toHaveBeenCalledWith('remote_jid', MOCK_JID); + expect(mockOrder).toHaveBeenCalledWith('updated_at', expect.anything()); + expect(mockLimit).toHaveBeenCalledWith(1); + }); + }); }); describe('useFallbackContact — bare phone input', () => { diff --git a/src/features/inbox/hooks/useFallbackContact.ts b/src/features/inbox/hooks/useFallbackContact.ts index 72849739c3..52d91836bb 100644 --- a/src/features/inbox/hooks/useFallbackContact.ts +++ b/src/features/inbox/hooks/useFallbackContact.ts @@ -30,23 +30,40 @@ export function useFallbackContact( let error: unknown = null; if (ref.kind === 'uuid') { - const result = await supabase - .from('contacts') - .select('*') - .eq('id', ref.uuid) - .maybeSingle(); + const result = await supabase.from('contacts').select('*').eq('id', ref.uuid).maybeSingle(); data = result.data as ConversationContact | null; error = result.error; } else { - const result = await supabase - .from('evolution_contacts') - .select('*') - .eq('remote_jid', ref.remoteJid) - .order('updated_at', { ascending: false, nullsFirst: false }) - .limit(1) - .maybeSingle(); - data = result.data as ConversationContact | null; - error = result.error; + // JID → primeiro tenta contato local sincronizado por phone + // (evita PostgREST 400 ao filtrar coluna uuid com valor JID) + if (ref.phone) { + const result = await supabase + .from('contacts') + .select('*') + .eq('phone', ref.phone) + .maybeSingle(); + if (result.error) { + log.warn('[useFallbackContact] erro ao buscar contato por phone', { + phone: ref.phone, + code: result.error.code, + message: result.error.message, + }); + } else if (result.data) { + data = result.data as ConversationContact | null; + } + } + // Se phone não encontrou, consulta evolution_contacts por remote_jid + if (!data) { + const result = await supabase + .from('evolution_contacts') + .select('*') + .eq('remote_jid', ref.remoteJid) + .order('updated_at', { ascending: false, nullsFirst: false }) + .limit(1) + .maybeSingle(); + data = result.data as ConversationContact | null; + error = result.error; + } } if (cancelled) return; diff --git a/zapp-web-v3-temp b/zapp-web-v3-temp new file mode 160000 index 0000000000..2a2bc9058b --- /dev/null +++ b/zapp-web-v3-temp @@ -0,0 +1 @@ +Subproject commit 2a2bc9058bc45c17730c29ed2c494f1162ed8515