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
29 changes: 28 additions & 1 deletion src/portal/__tests__/activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
let searchAccounts: Set<string>; // account_ids com ao menos uma linha em ai_search_log
function memPool() {
return {
query: async (sql: string, params: any[]) => {
Expand All @@ -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 () => {
Expand All @@ -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");
Expand Down
10 changes: 8 additions & 2 deletions src/portal/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -39,8 +43,10 @@ export async function getActivationState(accountId: string): Promise<ActivationS
const flags = await readFlags(accountId);
const tasks = (await getTasksDbId(accountId)) != null;
const granola = (await getGranolaMasked(accountId)).set;
const ical = (await getIcalLinks(accountId)).length > 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;
Expand Down
21 changes: 21 additions & 0 deletions src/rag/search-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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;
Expand Down
Loading