From ec155424ee0a2504172f15dcdd3bc427b4bebdf9 Mon Sep 17 00:00:00 2001 From: bruno moniz Date: Fri, 19 Jun 2026 14:43:08 -0300 Subject: [PATCH] =?UTF-8?q?fix(portal):=20checklist=20de=20ativa=C3=A7?= =?UTF-8?q?=C3=A3o=20marca=20agenda=20Google=20e=20primeira=20pergunta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duas pontas do checklist "Ative seu cérebro" eram impossíveis de completar pelo uso normal do produto: - "Conecte uma agenda" só contava link iCal (getIcalLinks), ignorando a conta Google OAuth — o caminho recomendado, com toggles por calendário. Agora marca com iCal OU conta Google conectada. - "Faça a primeira pergunta" dependia do flag setado por markAskDone(), que é código morto (nenhum caller no front; ask() do chat nunca o dispara) e ainda não cobriria perguntas via Claude.ai. Agora marca pelo flag legado OU por qualquer busca em ai_search_log (portal + Claude.ai + Claude Code), a fonte de verdade de atividade de consulta. hasSearchActivity() é best-effort (no-op false sem DB, swallow de erro), mesmo padrão de recordSearchEvent. Aditivo, sem migração. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/portal/__tests__/activation.test.ts | 29 ++++++++++++++++++++++++- src/portal/activation.ts | 10 +++++++-- src/rag/search-log.ts | 21 ++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/portal/__tests__/activation.test.ts b/src/portal/__tests__/activation.test.ts index ab33124..b8b2312 100644 --- a/src/portal/__tests__/activation.test.ts +++ b/src/portal/__tests__/activation.test.ts @@ -7,9 +7,11 @@ process.env.SECRETS_KEY = "0".repeat(64); import { getActivationState, markAsked, dismissActivation } from "../activation.js"; import { setTasksDbId } from "../task-tracker.js"; import { setGranolaKey, addIcalLink } from "../sources.js"; +import { addGoogleAccount } from "../../google/google-accounts.js"; import { __setPoolForTest } from "../../rag/storage.js"; let store: Map; +let searchAccounts: Set; // account_ids com ao menos uma linha em ai_search_log function memPool() { return { query: async (sql: string, params: any[]) => { @@ -25,11 +27,14 @@ function memPool() { store.delete(`${params[0]}|${params[1]}`); return { rows: [], rowCount: 1 }; } + if (/FROM ai_search_log/i.test(sql)) { + return { rows: searchAccounts.has(params[0]) ? [{ "?column?": 1 }] : [] }; + } return { rows: [] }; }, }; } -beforeEach(() => { store = new Map(); __setPoolForTest(memPool() as never); }); +beforeEach(() => { store = new Map(); searchAccounts = new Set(); __setPoolForTest(memPool() as never); }); afterEach(() => __setPoolForTest(null)); test("conta nova: nada feito, não completa", async () => { @@ -53,6 +58,28 @@ test("itens refletem fontes + tasks_db_id + ask; completa quando os 4 batem", as assert.equal(s.complete, true); }); +test("agenda: conta Google OAuth (sem iCal) marca o item de agenda", async () => { + let s = await getActivationState("friend:1"); + assert.equal(s.items.ical, false); + + await addGoogleAccount("friend:1", { + email: "bruno@gmail.com", + refresh_token: "rt_zzz", + scopes: ["calendar.readonly"], + }); + s = await getActivationState("friend:1"); + assert.equal(s.items.ical, true); // Google conta como agenda, mesmo sem iCal +}); + +test("ask: busca registrada em ai_search_log marca a pergunta (sem flag)", async () => { + let s = await getActivationState("friend:1"); + assert.equal(s.items.ask, false); + + searchAccounts.add("friend:1"); // simula uma busca via portal OU Claude.ai + s = await getActivationState("friend:1"); + assert.equal(s.items.ask, true); // detectado pelo log, sem precisar do flag +}); + test("dismiss esconde o checklist mesmo sem completar", async () => { await dismissActivation("friend:1"); const s = await getActivationState("friend:1"); diff --git a/src/portal/activation.ts b/src/portal/activation.ts index 1c6ec8e..b95279b 100644 --- a/src/portal/activation.ts +++ b/src/portal/activation.ts @@ -2,9 +2,13 @@ // 001-account-portal / ativação — estado do checklist one-time, derivado das // fontes conectadas + tasks_db_id + um flag "ask"/"dismissed" no vault (kind // "activation"). Sem migração. complete = 4 itens OU dismissed (p/ esconder). +// "agenda" = iCal por link OU conta Google OAuth; "ask" = flag legado OU +// qualquer busca registrada em ai_search_log (portal + Claude.ai + Claude Code). import { getAccountSecret, setAccountSecret } from "../secrets.js"; import { getTasksDbId } from "./task-tracker.js"; import { getGranolaMasked, getIcalLinks } from "./sources.js"; +import { getGoogleAccounts } from "../google/google-accounts.js"; +import { hasSearchActivity } from "../rag/search-log.js"; const ACTIVATION_KIND = "activation"; @@ -39,8 +43,10 @@ export async function getActivationState(accountId: string): Promise 0; - const ask = flags.ask === true; + const ical = + (await getIcalLinks(accountId)).length > 0 || + (await getGoogleAccounts(accountId)).length > 0; + const ask = flags.ask === true || (await hasSearchActivity(accountId)); const items = { tasks, granola, ical, ask }; const allDone = tasks && granola && ical && ask; const dismissed = flags.dismissed === true; diff --git a/src/rag/search-log.ts b/src/rag/search-log.ts index 3625589..ce4aef0 100644 --- a/src/rag/search-log.ts +++ b/src/rag/search-log.ts @@ -34,6 +34,27 @@ export async function recordSearchEvent( } } +/** True if the account ever logged at least one brain search (any client: + * portal "Consultar", "Claude.ai", "Claude Code"...). Best-effort: no-ops to + * false without a DB (unit tests / light dev) and swallows errors, mirroring + * recordSearchEvent — a read failure must never break the activation checklist. + * All-time window on purpose: "fez a primeira pergunta" is a one-time milestone. */ +export async function hasSearchActivity(accountId: string): Promise { + try { + const { getPool, hasInjectedPool } = await import("./storage.js"); + if (!process.env.POSTGRES_URL && !hasInjectedPool()) return false; + const p = getPool(); + const { rows } = await p.query( + `SELECT 1 FROM ai_search_log WHERE account_id = $1 LIMIT 1`, + [accountId], + ); + return rows.length > 0; + } catch (err: any) { + console.warn(`[search-log] hasSearchActivity failed (log only): ${err?.message ?? err}`); + return false; + } +} + export interface SearchLogEntry { query: string; results: number;