feat(backend): complete AI and business migration

This commit is contained in:
poyrazavsever
2026-07-17 09:12:45 +03:00
parent 42f165b27e
commit 64a5f96872
23 changed files with 1286 additions and 409 deletions
+97
View File
@@ -109,6 +109,29 @@ try {
db.prepare(
"insert into project_planning_sections (id, owner_user_id, project_id, category, title, content, metadata, sort_order) values (?, ?, ?, ?, ?, ?, ?, ?)",
).run("planning-portal", ownerUserId, "project-alpha", "overview", "Portal Plan", "Visible planning content", "{}", 0);
db.prepare(
"insert into finance_transactions (id, owner_user_id, type, amount_minor, currency, transaction_date, payment_status) values (?, ?, ?, ?, ?, ?, ?)",
).run(
"phase7-finance",
ownerUserId,
"income",
10_000,
"TRY",
new Date().toISOString().slice(0, 10),
"paid",
);
db.prepare(
"insert into chat_sessions (id, owner_user_id, title) values (?, ?, ?)",
).run("phase7-chat", ownerUserId, "Phase 7 Chat");
db.prepare(
"insert into proposals (id, owner_user_id, client_id, project_id, title, amount_minor, currency, status) values (?, ?, ?, ?, ?, ?, ?, ?)",
).run("phase7-proposal", ownerUserId, "client-alpha", "project-alpha", "Phase 7 Proposal", 25_000, "TRY", "draft");
db.prepare(
"insert into invoices (id, owner_user_id, client_id, project_id, invoice_number, amount_minor, currency, status, issue_date) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run("phase7-invoice", ownerUserId, "client-alpha", "project-alpha", "P7-001", 25_000, "TRY", "draft", "2026-07-17");
db.prepare(
"insert into subscriptions (id, owner_user_id, name, amount_minor, currency, billing_cycle, status) values (?, ?, ?, ?, ?, ?, ?)",
).run("phase7-subscription", ownerUserId, "Phase 7 Hosting", 5_000, "TRY", "monthly", "active");
const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
name: "Public Attacker",
@@ -141,6 +164,10 @@ try {
"/journal",
"/analytics",
"/settings",
"/chat",
"/business/proposals",
"/business/invoices",
"/business/subscriptions",
]) {
const page = await fetch(`${baseUrl}${pathname}`, {
headers: { cookie: ownerCookie },
@@ -150,6 +177,54 @@ try {
assert.doesNotMatch(await page.text(), /lib\/supabase|supabase\.co/i, `SSR output leaked Supabase: ${pathname}`);
}
for (const [pathname, body] of [
["/api/finance-analysis", undefined],
["/api/project-risk", { projectId: "project-alpha" }],
]) {
const anonymousAi = await jsonRequest(pathname, {
method: "POST",
body,
});
assert.equal(anonymousAi.response.status, 401, `Anonymous AI request must fail: ${pathname}`);
const missingAiSettings = await jsonRequest(pathname, {
method: "POST",
cookie: ownerCookie,
body,
});
assert.equal(missingAiSettings.response.status, 400, `Missing AI key must fail: ${pathname}`);
}
const chatBody = {
sessionId: "phase7-chat",
messages: [{
id: "phase7-user-message",
role: "user",
parts: [{ type: "text", text: "Projeyi özetle" }],
}],
};
const anonymousChat = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json", origin: baseUrl },
body: JSON.stringify(chatBody),
});
assert.equal(anonymousChat.status, 401, "Anonymous chat request must fail");
const missingChatSettings = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: baseUrl,
},
body: JSON.stringify(chatBody),
});
assert.equal(missingChatSettings.status, 400, "Missing AI key must fail: /api/chat");
assert.equal(
db.prepare("select count(*) as value from chat_messages where session_id = ?")
.get("phase7-chat").value,
0,
"A rejected AI request must not append chat messages",
);
const anonymousUpload = await uploadFile("avatar", { fileName: "anonymous.png" });
assert.equal(anonymousUpload.response.status, 401, "Anonymous file upload must fail");
assert.deepEqual(
@@ -297,6 +372,28 @@ try {
assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload));
const clientCookie = cookieHeader(clientSignIn.response);
for (const [pathname, body] of [
["/api/finance-analysis", undefined],
["/api/project-risk", { projectId: "project-alpha" }],
]) {
const forbiddenAi = await jsonRequest(pathname, {
method: "POST",
cookie: clientCookie,
body,
});
assert.equal(forbiddenAi.response.status, 403, `Client AI access must fail: ${pathname}`);
}
const forbiddenClientChat = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie: clientCookie,
origin: baseUrl,
},
body: JSON.stringify(chatBody),
});
assert.equal(forbiddenClientChat.status, 403, "Client chat access must fail");
for (const pathname of [
"/portal",
"/portal/projects",
+89
View File
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const runtimeRoots = [
"app/(dashboard)/chat",
"app/(dashboard)/business",
"app/api/chat",
"app/api/finance-analysis",
"app/api/project-risk",
];
const files = runtimeRoots
.flatMap(walk)
.filter((file) => /\.(ts|tsx)$/.test(file));
const violations = [];
for (const file of files) {
const content = fs.readFileSync(path.join(process.cwd(), file), "utf8");
if (/[@/]lib\/supabase|createServiceRoleClient|\bsupabase\b/i.test(content)) {
violations.push(`${file}: Supabase runtime reference`);
}
if (/NEXT_PUBLIC_SUPABASE|SUPABASE_SERVICE_ROLE/.test(content)) {
violations.push(`${file}: Supabase environment dependency`);
}
if (/\blocalStorage\b/.test(content)) {
violations.push(`${file}: browser localStorage dependency`);
}
}
for (const route of [
"app/api/chat/route.ts",
"app/api/finance-analysis/route.ts",
"app/api/project-risk/route.ts",
]) {
const content = fs.readFileSync(path.join(process.cwd(), route), "utf8");
if (/body\.(apiKey|provider)|create(OpenAI|Groq|GoogleGenerativeAI)/.test(content)) {
violations.push(`${route}: provider or secret is selected from the route/browser boundary`);
}
}
const chatPage = fs.readFileSync(
path.join(process.cwd(), "app/(dashboard)/chat/page.tsx"),
"utf8",
);
if (/\bapiKey\b|\bprovider\b/.test(chatPage)) {
violations.push("app/(dashboard)/chat/page.tsx: AI secret/provider leaked to browser code");
}
assert.deepEqual(
violations,
[],
`Phase 7 backend boundary violations:\n${violations.join("\n")}`,
);
for (const required of [
"server/ai/context.ts",
"server/ai/provider.ts",
"server/ai/responses.ts",
"app/(dashboard)/chat/actions.ts",
"scripts/phase7-domain-smoke.ts",
]) {
assert.ok(fs.existsSync(path.join(process.cwd(), required)), `Missing Phase 7 artifact: ${required}`);
}
const allRuntimeSources = [
...walk("app"),
...walk("server"),
].filter((file) => /\.(ts|tsx)$/.test(file));
for (const file of allRuntimeSources) {
const content = fs.readFileSync(path.join(process.cwd(), file), "utf8");
assert.doesNotMatch(
content,
/from\s+["'][^"']*lib\/ai\/embeddings["']/,
`Legacy Supabase embeddings helper is imported at runtime by ${file}`,
);
}
console.log(`Phase 7 backend boundary passed (${files.length} files scanned).`);
function walk(relativePath) {
const absolutePath = path.join(process.cwd(), relativePath);
if (!fs.existsSync(absolutePath)) return [];
const stat = fs.statSync(absolutePath);
if (stat.isFile()) return [relativePath];
return fs.readdirSync(absolutePath, { withFileTypes: true }).flatMap((entry) => {
const child = path.join(relativePath, entry.name);
return entry.isDirectory() ? walk(child) : [child];
});
}
+23
View File
@@ -0,0 +1,23 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const dataDir = path.join(process.cwd(), ".data", `phase7-domain-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.phase7-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
execFileSync(
process.execPath,
[path.join(".next", "phase7-domain-smoke-dist", "scripts", "phase7-domain-smoke.js"), databasePath],
{ cwd: process.cwd(), stdio: "inherit" },
);
+215
View File
@@ -0,0 +1,215 @@
import assert from "node:assert/strict";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../server/db/schema";
import { buildChatContext, buildFinanceAnalysisContext, buildProjectRiskContext } from "../server/ai/context";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { DomainService } from "../server/services/domain";
const databasePath = process.argv[2];
assert.ok(databasePath, "Database path is required");
const sqlite = new Database(databasePath);
sqlite.pragma("foreign_keys = ON");
const db = drizzle({ client: sqlite, schema });
let generatedId = 0;
const service = new DomainService(db, () => `phase7-generated-${++generatedId}`);
const owner: DomainActor = {
authUserId: "phase7-owner",
role: "freelancer",
clientId: null,
disabled: false,
};
const otherOwner: DomainActor = {
authUserId: "phase7-other-owner",
role: "freelancer",
clientId: null,
disabled: false,
};
const clientActor: DomainActor = {
authUserId: "phase7-client-user",
role: "client",
clientId: "phase7-client",
disabled: false,
};
try {
for (const actor of [owner, otherOwner, clientActor]) {
db.insert(schema.user).values({
id: actor.authUserId,
name: actor.authUserId,
email: `${actor.authUserId}@example.com`,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
}).run();
}
service.createClient(owner, {
id: "phase7-client",
name: "Visible Client",
email: "visible@example.com",
});
service.createClient(otherOwner, {
id: "phase7-foreign-client",
name: "Foreign Secret Client",
});
service.createProject(owner, {
id: "phase7-project",
clientId: "phase7-client",
name: "Visible Project",
status: "active",
budgetAmountMinor: 250_000,
currency: "TRY",
});
service.createProject(otherOwner, {
id: "phase7-foreign-project",
clientId: "phase7-foreign-client",
name: "Foreign Secret Project",
status: "active",
});
service.createTask(owner, {
id: "phase7-task",
clientId: "phase7-client",
projectId: "phase7-project",
title: "Visible Task",
status: "done",
});
service.createFinanceTransaction(owner, {
id: "phase7-finance",
type: "income",
amountMinor: 12_345,
currency: "TRY",
transactionDate: "2026-07-17",
paymentStatus: "paid",
});
service.createFinanceTransaction(otherOwner, {
id: "phase7-foreign-finance",
type: "income",
amountMinor: 999_999,
currency: "TRY",
transactionDate: "2026-07-17",
paymentStatus: "paid",
description: "Foreign Secret Finance",
});
service.saveJournalEntry(owner, {
id: "phase7-journal",
entryDate: "2026-07-17",
moodScore: 4,
note: "Visible journal note",
});
const chatContext = buildChatContext(service, owner, new Date("2026-07-17T12:00:00.000Z"));
assert.match(chatContext, /Visible Project/);
assert.match(chatContext, /Visible Task/);
assert.match(chatContext, /Visible journal note/);
assert.doesNotMatch(chatContext, /Foreign Secret/);
const financeContext = buildFinanceAnalysisContext(
service,
owner,
new Date("2026-07-17T12:00:00.000Z"),
);
assert.equal(financeContext.hasData, true);
assert.match(financeContext.text, /123\.45/);
assert.doesNotMatch(financeContext.text, /9999\.99|Foreign Secret/);
assert.match(buildProjectRiskContext(service, owner, "phase7-project"), /Visible Client/);
assertDomainError(
() => buildProjectRiskContext(service, owner, "phase7-foreign-project"),
"NOT_FOUND",
);
service.createChatSession(owner, { id: "phase7-chat", title: "Owner chat" });
service.addChatMessage(owner, {
id: "phase7-message",
sessionId: "phase7-chat",
role: "user",
content: "Owner question",
});
assert.equal(service.listChatSessions(owner).length, 1);
assert.equal(service.listChatMessages(owner, "phase7-chat")[0]?.content, "Owner question");
assertDomainError(() => service.getChatSession(otherOwner, "phase7-chat"), "NOT_FOUND");
assertDomainError(() => service.listChatMessages(otherOwner, "phase7-chat"), "NOT_FOUND");
assertDomainError(() => service.deleteChatSession(otherOwner, "phase7-chat"), "NOT_FOUND");
service.deleteChatSession(owner, "phase7-chat");
const remainingMessages = sqlite
.prepare("select count(*) as value from chat_messages where session_id = ?")
.get("phase7-chat") as { value: number };
assert.equal(
remainingMessages.value,
0,
"Chat session deletion must cascade to messages",
);
service.createProposal(owner, {
id: "phase7-proposal",
clientId: "phase7-client",
projectId: "phase7-project",
title: "Proposal",
amountMinor: 100_00,
});
service.createContract(owner, {
id: "phase7-contract",
proposalId: "phase7-proposal",
clientId: "phase7-client",
title: "Contract",
});
service.createInvoice(owner, {
id: "phase7-invoice",
clientId: "phase7-client",
projectId: "phase7-project",
invoiceNumber: "P7-001",
amountMinor: 100_00,
issueDate: "2026-07-17",
});
service.createSubscription(owner, {
id: "phase7-subscription",
name: "Hosting",
amountMinor: 500_00,
});
assert.equal(service.listProposals(owner).length, 1);
assert.equal(service.updateProposal(owner, "phase7-proposal", { status: "sent" }).status, "sent");
assert.equal(service.listContracts(owner).length, 1);
assert.equal(service.updateContract(owner, "phase7-contract", { status: "active" }).status, "active");
assert.equal(service.listInvoices(owner).length, 1);
assert.equal(service.updateInvoice(owner, "phase7-invoice", { status: "paid" }).status, "paid");
assert.equal(service.listSubscriptions(owner).length, 1);
assert.equal(
service.updateSubscription(owner, "phase7-subscription", { status: "cancelled" }).status,
"cancelled",
);
for (const run of [
() => service.getProposal(otherOwner, "phase7-proposal"),
() => service.updateContract(otherOwner, "phase7-contract", { status: "active" }),
() => service.deleteInvoice(otherOwner, "phase7-invoice"),
() => service.getSubscription(otherOwner, "phase7-subscription"),
]) {
assertDomainError(run, "NOT_FOUND");
}
assertDomainError(() => service.listProposals(clientActor), "FORBIDDEN");
service.deleteContract(owner, "phase7-contract");
service.deleteProposal(owner, "phase7-proposal");
service.deleteInvoice(owner, "phase7-invoice");
service.deleteSubscription(owner, "phase7-subscription");
assert.deepEqual(
[
service.listContracts(owner).length,
service.listProposals(owner).length,
service.listInvoices(owner).length,
service.listSubscriptions(owner).length,
],
[0, 0, 0, 0],
);
console.log("Phase 7 domain smoke passed: owner-scoped chat, AI context and business CRUD verified.");
} finally {
sqlite.close();
}
function assertDomainError(run: () => unknown, code: DomainError["code"]) {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
+11
View File
@@ -0,0 +1,11 @@
import { execFileSync } from "node:child_process";
for (const [command, args] of [
[process.execPath, ["scripts/phase7-backend-boundary.mjs"]],
[process.execPath, ["scripts/phase7-domain-smoke.mjs"]],
[process.execPath, ["scripts/phase1-auth-smoke.mjs"]],
]) {
execFileSync(command, args, { cwd: process.cwd(), stdio: "inherit" });
}
console.log("Phase 7 AI and business backend smoke passed.");