Pr/UI language header - [deferred to v4.0.0-rc1]#2144
Conversation
…bal routing/memory customizations)
…hen token missing
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
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);
})();| 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; | ||
| } |
There was a problem hiding this comment.
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 || []);
})();| 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 }); | ||
| } | ||
| } |
| 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 }); | ||
| } | ||
| } |
| 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 } | ||
| ); | ||
| } | ||
| } |
| 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 }); | ||
| } | ||
| } |
| | "requestCountsFailed" | ||
| > | ||
| ): BatchRecord { | ||
| ensureBatchesSchema(); |
| }; | ||
| } | ||
|
|
||
| export function ensureSaasSchema(): void { |
|
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! 🙌 |
|
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. |
|
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! |
Summary
Related Issues
Validation
npm run lintnpm run test:unitnpm run test:coverage>= 60%for statements, lines, functions, and branchesTests Added Or Updated
Coverage Notes
src/,open-sse/,electron/, orbin/, explain which tests cover the change.Reviewer Notes