Skip to content

Pr/UI language header - [deferred to v4.0.0-rc1]#2144

Open
rafacpti23 wants to merge 28 commits into
diegosouzapw:release/v3.8.0from
rafacpti23:pr/ui-language-header
Open

Pr/UI language header - [deferred to v4.0.0-rc1]#2144
rafacpti23 wants to merge 28 commits into
diegosouzapw:release/v3.8.0from
rafacpti23:pr/ui-language-header

Conversation

@rafacpti23
Copy link
Copy Markdown
Contributor

@rafacpti23 rafacpti23 commented May 11, 2026

Summary

  • Describe the user-facing or operational change.

Related Issues

  • Closes #
  • Related to #

Validation

  • npm run lint
  • npm run test:unit
  • npm run test:coverage
  • Coverage is still >= 60% for statements, lines, functions, and branches
  • SonarQube PR analysis is green or any remaining issues are explicitly documented below

Tests Added Or Updated

  • List every changed or added automated test file.
  • If no production code changed, state that here.

Coverage Notes

  • If this PR changes src/, open-sse/, electron/, or bin/, explain which tests cover the change.
  • If coverage moved down in any touched file, explain why and what follow-up task will recover it.

Reviewer Notes

  • Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.

Deferred to v4.0.0-rc1 — This PR will be integrated in the v4.0.0 release cycle due to its scope and architectural impact.

rafacpti23 and others added 28 commits April 20, 2026 15:20
@rafacpti23 rafacpti23 requested a review from diegosouzapw as a code owner May 11, 2026 04:09
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive SaaS layer named "Easy IA," featuring a new customer landing page, a dedicated client portal, and administrative tools for managing customers, plans, and billing. Key technical enhancements include the integration of Qdrant for semantic memory indexing, a new model cooldown management system, and expanded routing strategies such as global random routing. The deployment infrastructure is updated with new Docker Swarm configurations and refined production environment variables. Feedback focuses on ensuring database atomicity by wrapping multi-step operations (like customer creation and billing approval) in transactions and optimizing performance by moving schema enforcement out of hot paths.

Comment thread src/lib/db/saas.ts
Comment on lines +905 to +938
export function activateSaasCustomerBilling(input: {
customerId: string;
planId?: string | null;
paymentId?: string | null;
paymentStatus?: string | null;
approvedAt?: string | null;
}): SaasCustomer | null {
ensureSaasSchema();
const db = getDbInstance();
const customer = getSaasCustomerById(input.customerId, { includeUsage: false });
if (!customer) return null;
const now = input.approvedAt || new Date().toISOString();
const paidUntil = addMonths(new Date(now), 1).toISOString();
db.prepare(
`UPDATE saas_customers
SET status = 'active',
billing_status = 'active',
paid_until = ?,
plan_id = COALESCE(?, plan_id),
updated_at = ?
WHERE id = ?`
).run(paidUntil, input.planId || null, now, input.customerId);
db.prepare(
"UPDATE saas_customer_api_keys SET is_active = 1, updated_at = ? WHERE customer_id = ?"
).run(now, input.customerId);
db.prepare(
`UPDATE api_keys
SET is_active = 1
WHERE id IN (SELECT api_key_id FROM saas_customer_api_keys WHERE customer_id = ?)`
).run(input.customerId);
clearApiKeyCaches();
backupDbFile("pre-write");
return getSaasCustomerById(input.customerId);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The billing approval process (activating customer, ensuring API key, updating billing event) should be wrapped in a transaction to ensure atomicity.

const db = getDbInstance();
db.transaction(() => {
  db.prepare(
    `UPDATE saas_customers
     SET status = 'active',
         billing_status = 'active',
         paid_until = ?,
         plan_id = COALESCE(?, plan_id),
         updated_at = ?
     WHERE id = ?`
  ).run(paidUntil, input.planId || null, now, input.customerId);
  db.prepare(
    "UPDATE saas_customer_api_keys SET is_active = 1, updated_at = ? WHERE customer_id = ?"
  ).run(now, input.customerId);
  db.prepare(
    `UPDATE api_keys
     SET is_active = 1
     WHERE id IN (SELECT api_key_id FROM saas_customer_api_keys WHERE customer_id = ?)`
  ).run(input.customerId);
})();

Comment thread src/lib/db/saas.ts
Comment on lines +616 to +658
export function createSaasCustomer(input: {
name: string;
email: string;
company?: string;
status?: SaasCustomerStatus;
planId?: string | null;
billingStatus?: SaasBillingStatus;
paidUntil?: string | null;
extraTokenCredits?: number;
notes?: string;
allowedModels?: string[];
allowedCombos?: string[];
passwordHash?: string | null;
}): SaasCustomer {
ensureSaasSchema();
const db = getDbInstance();
const now = new Date().toISOString();
const id = randomUUID();
db.prepare(
`INSERT INTO saas_customers (
id, name, email, company, status, billing_status, paid_until, extra_token_credits,
plan_id, password_hash, billing_cycle_anchor, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
input.name,
input.email,
input.company || "",
input.status || "active",
input.billingStatus || "active",
input.paidUntil || null,
Math.max(0, Math.round(input.extraTokenCredits || 0)),
input.planId || null,
input.passwordHash || null,
now,
input.notes || "",
now,
now
);
setSaasCustomerPermissions(id, input.allowedModels || [], input.allowedCombos || []);
backupDbFile("pre-write");
return getSaasCustomerById(id) as SaasCustomer;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The customer creation process (customer record, permissions) should be wrapped in a transaction to ensure atomicity.

const db = getDbInstance();
db.transaction(() => {
  db.prepare(
    `INSERT INTO saas_customers (
      id, name, email, company, status, billing_status, paid_until, extra_token_credits,
      plan_id, password_hash, billing_cycle_anchor, notes, created_at, updated_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
  ).run(
    id,
    input.name,
    input.email,
    input.company || "",
    input.status || "active",
    input.billingStatus || "active",
    input.paidUntil || null,
    Math.max(0, Math.round(input.extraTokenCredits || 0)),
    input.planId || null,
    input.passwordHash || null,
    now,
    input.notes || "",
    now,
    now
  );
  setSaasCustomerPermissions(id, input.allowedModels || [], input.allowedCombos || []);
})();

Comment on lines +50 to +111
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;

try {
const rawBody = await request.json();
const validation = validateBody(customerSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json(
{
error: summarizeValidationError(
validation.error,
"Revise os dados do cliente antes de salvar."
),
},
{ status: 400 }
);
}
const data = validation.data;

const customer = createSaasCustomer({
name: data.name,
email: data.email,
company: data.company || "",
status: data.status || "active",
billingStatus: data.billingStatus || "active",
paidUntil: data.paidUntil || null,
extraTokenCredits: data.extraTokenCredits || 0,
planId: data.planId || null,
passwordHash: data.password ? bcrypt.hashSync(data.password, 10) : null,
notes: data.notes || "",
allowedModels: data.allowedModels || [],
allowedCombos: data.allowedCombos || [],
});

const machineId = await getConsistentMachineId();
const keyName = data.apiKeyLabel || `${data.name} API Key`;
const apiKey = await createApiKey(keyName, machineId);
const keyIsActive =
data.status !== "blocked" &&
data.status !== "inactive" &&
(data.billingStatus || "active") === "active";
await updateApiKeyPermissions(apiKey.id, {
allowedModels: data.allowedModels || [],
noLog: false,
isActive: keyIsActive,
});
linkApiKeyToSaasCustomer({
customerId: customer.id,
apiKeyId: apiKey.id,
label: data.apiKeyLabel || "Principal",
isActive: keyIsActive,
});

return NextResponse.json(
{ customer: getSaasCustomerById(customer.id), apiKey: apiKey.key },
{ status: 201 }
);
} catch (error) {
return NextResponse.json({ error: friendlyCustomerAdminError(error) }, { status: 500 });
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The customer creation process (customer record, API key, permissions) should be wrapped in a transaction to ensure atomicity.

const db = getDbInstance();
db.transaction(() => {
  // ... customer creation ...
})();

Comment on lines +69 to +232
export async function POST(request: Request) {
try {
const rawBody = await request.json();
const validation = validateBody(checkoutSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json(
{
error: summarizeValidationError(
validation.error,
"Revise os dados do checkout e tente novamente."
),
},
{ status: 400 }
);
}

const body = validation.data;
let customer = body.customerId ? getSaasCustomerById(body.customerId) : null;

if (!customer) {
if (!body.email || !body.name) {
return NextResponse.json(
{ error: "Nome e email sao obrigatorios para iniciar a assinatura." },
{ status: 400 }
);
}
customer = getSaasCustomerByEmail(body.email, { includeUsage: false });
if (!customer) {
customer = createSaasCustomer({
name: body.name,
email: body.email,
company: body.company || "",
status: "inactive",
billingStatus: "past_due",
paidUntil: null,
planId: body.planId || null,
allowedModels: [],
allowedCombos: [],
});
} else {
customer = updateSaasCustomer(customer.id, {
name: body.name,
company: body.company || customer.company,
planId: body.planId === undefined ? customer.planId : body.planId,
});
}
if (!customer) {
return NextResponse.json(
{ error: "Nao foi possivel preparar o cadastro." },
{ status: 500 }
);
}
if (body.password) {
setSaasCustomerPassword(customer.id, body.password);
}
}

if (body.password) {
setSaasCustomerPassword(customer.id, body.password);
}

const plan =
body.planId && body.planId !== null
? listSaasPlans().find((item) => item.id === body.planId)
: customer.planId
? listSaasPlans().find((item) => item.id === customer?.planId)
: null;

const tokenCredits = Math.max(0, body.tokenCredits || 0);
const amountCents =
body.kind === "credit_purchase"
? Math.max(100, Math.round(tokenCredits / 1000))
: Math.max(0, plan?.priceMonthlyCents || customer.priceMonthlyCents || 0);

if (body.kind !== "credit_purchase" && !plan) {
return NextResponse.json(
{ error: friendlyCheckoutStartError("Plano nao encontrado para pagamento.") },
{ status: 404 }
);
}

if (amountCents <= 0 && body.kind !== "credit_purchase") {
const activated = updateSaasCustomer(customer.id, {
status: "active",
billingStatus: "active",
paidUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
planId: plan?.id || customer.planId,
});
return NextResponse.json({
checkoutUrl: null,
customer: activated,
freeActivation: true,
});
}

if (!isMercadoPagoConfigured()) {
return NextResponse.json(
{
error: friendlyCheckoutStartError(
"Mercado Pago ainda nao foi configurado neste ambiente. Defina MERCADO_PAGO_ACCESS_TOKEN para liberar o checkout."
),
},
{ status: 400 }
);
}

const event = createSaasBillingEvent({
customerId: customer.id,
planId: plan?.id || customer.planId,
kind: body.kind,
amountCents,
tokenCredits,
metadataJson: JSON.stringify({
customerId: customer.id,
planId: plan?.id || customer.planId || null,
tokenCredits,
}),
});

const sitePublicUrl = getSitePublicUrl(request);
const apiPublicUrl = getApiPublicUrl(request);
const preference = await createMercadoPagoPreference({
items: [
buildCheckoutItem(body.kind, plan?.name || "Recarga de tokens", amountCents, tokenCredits),
],
payer: {
name: customer.name,
email: customer.email,
},
external_reference: event.externalReference,
notification_url: `${apiPublicUrl}/api/saas/checkout/webhook`,
back_urls: {
success: `${sitePublicUrl}/portal?checkout=success`,
failure: `${sitePublicUrl}/portal?checkout=failure`,
pending: `${sitePublicUrl}/portal?checkout=pending`,
},
auto_return: "approved",
metadata: {
customerId: customer.id,
planId: plan?.id || customer.planId || null,
billingEventId: event.id,
kind: body.kind,
tokenCredits,
},
});

updateSaasBillingEvent(event.id, {
checkoutUrl: preference.init_point || preference.sandbox_init_point || null,
preferenceId: preference.id || null,
metadataJson: JSON.stringify(preference),
});

return NextResponse.json({
checkoutUrl: preference.init_point || preference.sandbox_init_point || null,
preferenceId: preference.id || null,
publicKey: getMercadoPagoPublicKey() || null,
customerId: customer.id,
billingEventId: event.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: friendlyCheckoutStartError(message) }, { status: 500 });
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The customer creation and billing event creation should be wrapped in a transaction to ensure atomicity.

const db = getDbInstance();
db.transaction(() => {
  // ... customer creation and billing event creation ...
})();

Comment on lines +36 to +86
export async function POST(request: Request) {
try {
if (!isMercadoPagoConfigured()) {
return NextResponse.json({ ok: true, skipped: "not_configured" });
}

const payload = await request.json().catch(() => ({}));
const paymentId =
payload?.data?.id || payload?.id || payload?.resource?.split("/").pop() || null;
if (!paymentId) {
return NextResponse.json({ ok: true, skipped: "missing_payment_id" });
}

const payment = await getMercadoPagoPayment(String(paymentId));
const event =
getSaasBillingEventByPaymentId(String(payment.id || paymentId)) ||
(payment?.external_reference
? getSaasBillingEventByExternalReference(String(payment.external_reference))
: null);

if (!event) {
return NextResponse.json({ ok: true, skipped: "billing_event_not_found" });
}

const status = String(payment.status || "").toLowerCase();
if (status === "approved") {
await applyBillingApproval(event, payment);
} else {
const normalizedStatus =
status === "rejected"
? "rejected"
: status === "cancelled"
? "cancelled"
: status === "expired"
? "expired"
: "pending";
updateSaasBillingEvent(event.id, {
status: normalizedStatus,
paymentId: String(payment.id || paymentId),
metadataJson: JSON.stringify(payment || {}),
});
}

return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{ ok: false, error: error instanceof Error ? error.message : String(error) },
{ status: 200 }
);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The billing approval process (activating customer, ensuring API key, updating billing event) should be wrapped in a transaction to ensure atomicity.

const db = getDbInstance();
db.transaction(() => {
  // ... billing approval ...
})();

Comment on lines +48 to +83
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;

try {
const { id } = await params;
const rawBody = await request.json();
const validation = validateBody(updateCustomerSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json(
{
error: summarizeValidationError(
validation.error,
"Revise os dados do cliente antes de salvar."
),
},
{ status: 400 }
);
}
const customer = updateSaasCustomer(id, {
...validation.data,
passwordHash: validation.data.password
? bcrypt.hashSync(validation.data.password, 10)
: undefined,
});
if (!customer) {
return NextResponse.json(
{ error: friendlyCustomerAdminError("Customer not found") },
{ status: 404 }
);
}
return NextResponse.json({ customer });
} catch (error) {
return NextResponse.json({ error: friendlyCustomerAdminError(error) }, { status: 500 });
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The customer update process (customer record, permissions) should be wrapped in a transaction to ensure atomicity.

const db = getDbInstance();
db.transaction(() => {
  // ... customer update ...
})();

Comment thread src/lib/db/batches.ts
| "requestCountsFailed"
>
): BatchRecord {
ensureBatchesSchema();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ensureBatchesSchema is called at the beginning of every database function. This causes redundant schema checks on every call. Consider calling it once during application initialization.

Comment thread src/lib/db/saas.ts
};
}

export function ensureSaasSchema(): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ensureSaasSchema is called at the beginning of every database function. This causes redundant schema checks on every call. Consider calling it once during application initialization.

@diegosouzapw diegosouzapw changed the base branch from main to release/v3.8.0 May 11, 2026 12:58
@diegosouzapw
Copy link
Copy Markdown
Owner

Hey @rafacpti23, thanks for this contribution! 🚀

We really appreciate the work you've put into these features — the SaaS module, Qdrant integration, model cooldowns, and resilience improvements are all excellent additions.

However, given the scope of the changes (~28K+ lines, 100 files), this PR is better suited for the next major release cycle. We don't want to rush such a large feature set into v3.8.0 where it could introduce regressions.

Plan: This PR will be deferred to v4.0.0-rc1, where we'll have a dedicated integration window for these features. We'll keep this PR open and track it for the v4.0.0 milestone.

In the meantime, the model cooldown feature (PR #2146) has been extracted as a focused PR and will be integrated into v3.8.0 separately.

Thanks again for the amazing work! 🙌

@diegosouzapw diegosouzapw changed the title Pr/UI language header Pr/UI language header - [deferred to v4.0.0-rc1] May 11, 2026
@diegosouzapw
Copy link
Copy Markdown
Owner

Thank you for this contribution! After review, this PR has been deferred to the v4.0.0-rc1 milestone as it introduces architectural changes that are better suited for the next major release. We'll revisit it there. We appreciate your work and will keep this open.

@diegosouzapw
Copy link
Copy Markdown
Owner

Thank you @rafacpti23! The UI language header PR is deferred to v4.0.0-rc1 together with the related resilience UI work in #2145. Both will be merged with full credit in that release cycle. Thanks for your patience!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants