feat(backend): complete AI and business migration
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
Reference in New Issue
Block a user