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
5 changes: 3 additions & 2 deletions app/(app)/admin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ export async function adminResendInviteAction(inviteId: string): Promise<Result>
? (await supabase.from('profiles').select('full_name, email').eq('id', invite.invited_by).maybeSingle()).data
: null;

const { ok } = await sendReactEmail({
const { ok, skipped } = await sendReactEmail({
to: invite.email,
subject: `Reminder: you’re invited to join ${family?.name ?? 'a family'} on Bubaly`,
react: React.createElement(InviteEmail, {
Expand All @@ -454,7 +454,8 @@ export async function adminResendInviteAction(inviteId: string): Promise<Result>
role: invite.role,
}),
});
if (!ok) return { ok: false, error: t('actions.couldNotSendTheInvite') };
// `skipped` is no mail provider: nothing went out, so it is not a resend.
if (!ok || skipped) return { ok: false, error: t('actions.couldNotSendTheInvite') };

await adminAuditLog({ familyId: invite.family_id, action: 'resend', resource: 'invites', resourceId: inviteId, metadata: { email: invite.email } });
revalidatePath('/admin/users');
Expand Down
5 changes: 3 additions & 2 deletions app/(app)/referrals/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export async function sendReferralEmailAction(rawEmail: string): Promise<SendRef

const inviterName = ctx.active.member.display_name;
const familyName = ctx.active.family.name;
const { ok } = await sendReactEmail({
const { ok, skipped } = await sendReactEmail({
to: email,
subject: `${inviterName} sent you their Bubaly referral code`,
react: React.createElement(ReferralEmail, {
Expand All @@ -92,7 +92,8 @@ export async function sendReferralEmailAction(rawEmail: string): Promise<SendRef
rewardLabel: config.rewardLabel,
}),
});
if (!ok) {
// `skipped` is no mail provider: nothing went out, so the invite is rolled back like a failure.
if (!ok || skipped) {
await rollbackReferralEmailInvite(service, { rowId: record.rowId, created: record.created, sentAt });
return { ok: false, reason: t('referralActions.couldNotSendEmail') };
}
Expand Down
5 changes: 4 additions & 1 deletion app/api/admin/marketing/ai/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { settleAll } from '@/lib/supabase/settle';
import { getTranslations } from '@/lib/i18n/server';
import { requireMarketingAdmin, logMarketingAudit, isMarketingAuthError, marketingRefusalBody } from '@/lib/marketing/admin';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { getMarketingCustomers, summarizeCustomers } from '@/lib/marketing/customers';
import { fmtMoney } from '@/lib/utils/format';
import { enforceAIRateLimit } from '@/lib/server/ai-rate-limit';
Expand Down Expand Up @@ -33,6 +33,9 @@ export async function POST(req: NextRequest) {
const t = await getTranslations();
try {
const { supabase, actorId, actorEmail } = await requireMarketingAdmin();
// No key: say so before any work, rather than let the provider's throw
// reach the catch below as a generic failure.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `marketing-ai:${actorId}`, { limit: 20 });
if (!limited.ok) return NextResponse.json(
{ error: t('ai.tooManyMarketingAiRequests') },
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/gift/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getTranslations } from '@/lib/i18n/server';
import { createServiceClient } from '@/lib/supabase/server';
import { settleAll, describeReadError } from '@/lib/supabase/settle';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { rateLimit, clientIp } from '@/lib/server/rate-limit';
import { rateLimitDb } from '@/lib/server/rate-limit-db';
import { buildGiftAssistPrompt, parseGiftSuggestions } from '@/lib/wallet/gift-ai';
Expand Down Expand Up @@ -38,6 +38,9 @@ export async function POST(req: NextRequest) {
if (!token) return NextResponse.json({ error: t('gift.missingGiftLink') }, { status: 400 });
const relationship = typeof body.relationship === 'string' ? body.relationship.slice(0, 40).trim() || null : null;

// No key: say so before any work, rather than let the provider's throw
// reach the catch below as a generic failure.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
// The twin of C1-S9-31, in the same feature: /pay/<handle> redirects here.
// A refused read left `link` null and produced "This gift link is no longer
// available" to someone outside the family trying to send money — a dead end
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/habits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { readAll } from '@/lib/supabase/read-all';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext } from '@/lib/services/scope';
import { requireUserContext } from '@/lib/supabase/auth';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { enforceAIRateLimit } from '@/lib/server/ai-rate-limit';
import { buildCoachPrompt, parseCoachResponse, type HabitStat } from '@/lib/habits/ai';
import {
Expand All @@ -21,6 +21,9 @@ export async function POST() {
const ctx = await requireUserContext();
const { familyId } = ctx.active;
const supabase = await createServer();
// No key: say so before any work, rather than let the provider's throw
// reach the catch below as a generic failure.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-habits:${ctx.user.id}`, { limit: 15 });
if (!limited.ok) return NextResponse.json(
{ error: t('habits.tooManyHabitCoachRequests') },
Expand Down
7 changes: 5 additions & 2 deletions app/api/ai/health/coach/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { settleAll } from '@/lib/supabase/settle';
import { refuseUnlessEntitled } from '@/lib/server/route-feature-gate';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext } from '@/lib/services/scope';
import { resolveProvider, describeAIError } from '@/lib/ai/provider';
import { resolveProvider, describeAIError, isAIConfigured } from '@/lib/ai/provider';
import { enforceAIRateLimit } from '@/lib/server/ai-rate-limit';
import { MAX_PROVIDER_JSON_BYTES, readBoundedRequestJsonOrEmpty } from '@/lib/server/bounded-request-body';

Expand All @@ -25,7 +25,10 @@ export async function POST(req: Request) {
let ctx;
try { ctx = await requireUserContext(); } catch { return NextResponse.json({ error: t('coach.unauthorized') }, { status: 401 }); }

if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) {
// isAIConfigured, not the environment alone: a key saved in Admin → AI Engine
// is the one resolveProvider uses, and reading only the environment told a
// family with that key that the engine was not set up.
if (!(await isAIConfigured())) {
return NextResponse.json({ error: t('coach.theAiEngineIsnT') }, { status: 503 });
}

Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getTranslations } from '@/lib/i18n/server';
import { createServer } from '@/lib/supabase/server';
import { refuseUnlessEntitled } from '@/lib/server/route-feature-gate';
import { requireUserContext } from '@/lib/supabase/auth';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext } from '@/lib/services/scope';
import { AI_TOOLS, runAction } from '@/lib/ai/actions';
Expand Down Expand Up @@ -134,6 +134,9 @@ export async function POST(req: NextRequest) {
const refused = await refuseUnlessEntitled(supabase, ctx.active.familyId, ['/dashboard/inbox']);
if (refused) return refused;

// No key: say so before any work, rather than let the provider's throw
// reach the catch below as a generic failure.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-import:${userId}`, { limit: 20 });
if (!limited.ok) {
return NextResponse.json(
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/invest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getTranslations } from '@/lib/i18n/server';
import { createServer } from '@/lib/supabase/server';
import { settleAll } from '@/lib/supabase/settle';
import { requireUserContext, effectivePlanLevel } from '@/lib/supabase/auth';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext, dayKeyInTz, zonedDayBoundsMs } from '@/lib/services/scope';
import { resolveFamilyPlanLevel } from '@/lib/server/plan';
Expand All @@ -29,6 +29,9 @@ export async function POST(req: NextRequest) {
if (aiCoachLevel(tier) === 'none') {
return NextResponse.json({ error: t('invest.theMoneyMentorIsAvailable') }, { status: 403 });
}
// With no key the provider threw "not configured" and the catch below
// answered a generic 500; the other AI routes say so with a 503 first.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-invest:${ctx.user.id}`, { limit: 10 });
if (!limited.ok) return NextResponse.json(
{ error: t('invest.tooManyMoneyMentorRequests') },
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getTranslations } from '@/lib/i18n/server';
import { requireUserContext } from '@/lib/supabase/auth';
import { createServer } from '@/lib/supabase/server';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext } from '@/lib/services/scope';
import { buildNotesPrompt, parseNotesResponse } from '@/lib/notes/ai';
Expand All @@ -18,6 +18,9 @@ export async function POST(req: NextRequest) {
try {
const ctx = await requireUserContext();
const supabase = await createServer();
// No key: say so before any work, rather than let the provider's throw
// reach the catch below as a generic failure.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-notes:${ctx.user.id}`, { limit: 20 });
if (!limited.ok) return NextResponse.json(
{ error: t('notes.tooManyNoteAnalysisRequests') },
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/relationship/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getTranslations } from '@/lib/i18n/server';
import { createServer } from '@/lib/supabase/server';
import { settleAll, describeReadError } from '@/lib/supabase/settle';
import { requireUserContext } from '@/lib/supabase/auth';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext, dayKeyInTz, todayKeyFor, zonedDayBoundsMs } from '@/lib/services/scope';
import { isMissingRelationError } from '@/lib/supabase/errors';
Expand All @@ -30,6 +30,9 @@ export async function POST() {
const ctx = await requireUserContext();
const familyId = ctx.active.familyId;
const supabase = await createServer();
// With no key the provider threw "not configured" and the catch below
// answered a generic 500; the other AI routes say so with a 503 first.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-relationship:${ctx.user.id}`, { limit: 10 });
if (!limited.ok) return NextResponse.json(
{ error: t('relationship.tooManyRelationshipHelperRequests') },
Expand Down
6 changes: 6 additions & 0 deletions app/api/ai/savings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { refuseUnlessEntitled } from '@/lib/server/route-feature-gate';
import { settleAll, describeReadError } from '@/lib/supabase/settle';
import { readAllAsQuery } from '@/lib/supabase/read-all';
import { requireUserContext } from '@/lib/supabase/auth';
import { isManager } from '@/lib/constants/roles';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext } from '@/lib/services/scope';
import { resolveProvider } from '@/lib/ai/provider';
Expand All @@ -26,6 +27,11 @@ export async function POST() {
const tr = await getTranslations();
const ctx = await requireUserContext();
const { familyId } = ctx.active;
// The family's money is the adults': the finance service refuses anyone else
// and the privacy export withholds `finances` from them. This route reads the
// same tables through its own queries, and answered a child with the
// family's overspending and subscriptions.
if (!isManager(ctx.active.role)) return NextResponse.json({ error: tr('savings.privateToTheAdults') }, { status: 403 });
const supabase = await createServer();
// The page in front of this is feature-gated; this endpoint was not, and it
// calls a model. Same resolver, so the two cannot disagree.
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/wallet/child/[childId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getTranslations } from '@/lib/i18n/server';
import { createServer } from '@/lib/supabase/server';
import { settleAll, describeReadError } from '@/lib/supabase/settle';
import { requireUserContext, effectivePlanLevel } from '@/lib/supabase/auth';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext, dayKeyInTz, zonedDayBoundsMs } from '@/lib/services/scope';
import { resolveFamilyPlanLevel } from '@/lib/server/plan';
Expand All @@ -24,6 +24,9 @@ export async function POST(_req: Request, { params }: { params: Promise<{ childI
const ctx = await requireUserContext();
const familyId = ctx.active.familyId;
const supabase = await createServer();
// No key: say so before any work, rather than let the provider's throw
// reach the catch below as a generic failure.
if (!(await isAIConfigured())) return NextResponse.json({ error: tr('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-wallet-child:${ctx.user.id}:${childId}`, { limit: 10 });
if (!limited.ok) return NextResponse.json(
{ error: tr('child.tooManyChildMoneyCoach') },
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/wallet/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getTranslations } from '@/lib/i18n/server';
import { createServer } from '@/lib/supabase/server';
import { settleAll } from '@/lib/supabase/settle';
import { requireUserContext, effectivePlanLevel } from '@/lib/supabase/auth';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext, dayKeyInTz, zonedDayBoundsMs } from '@/lib/services/scope';
import { resolveFamilyPlanLevel } from '@/lib/server/plan';
Expand All @@ -28,6 +28,9 @@ export async function POST() {
if (aiCoachLevel(tier) === 'none') {
return NextResponse.json({ error: tr('wallet.theAiMoneyCoachIs') }, { status: 403 });
}
// With no key the provider threw "not configured" and the catch below
// answered a generic 500; the other AI routes say so with a 503 first.
if (!(await isAIConfigured())) return NextResponse.json({ error: tr('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-wallet:${ctx.user.id}`, { limit: 10 });
if (!limited.ok) return NextResponse.json(
{ error: tr('wallet.tooManyAiMoneyCoach') },
Expand Down
5 changes: 4 additions & 1 deletion app/api/ai/weekly-briefing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { requireUserContext, effectivePlanLevel } from '@/lib/supabase/auth';
import { resolveFamilyPlanLevel } from '@/lib/server/plan';
import { weekWindow, weekRangeLabel, choreCompletionRate, bucketByDay, dayLoad } from '@/lib/ai/weekly';
import { dayKeyInZone } from '@/lib/schedule/zoned';
import { resolveProvider } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { enforceAIRateLimit } from '@/lib/server/ai-rate-limit';
import { MAX_SMALL_JSON_BYTES, readBoundedRequestJsonOrEmpty } from '@/lib/server/bounded-request-body';

Expand All @@ -29,6 +29,9 @@ export async function POST(req: NextRequest) {
if ((await effectivePlanLevel(await resolveFamilyPlanLevel(supabase, familyId))) < 2) {
return NextResponse.json({ error: t('weeklyBriefing.weeklyAiBriefingIsA') }, { status: 402 });
}
// With no key the provider threw "not configured" and the catch below
// answered a generic 500; the other AI routes say so with a 503 first.
if (!(await isAIConfigured())) return NextResponse.json({ error: t('ai.theAiEngineIsnT'), code: 'not_configured' }, { status: 503 });
const limited = await enforceAIRateLimit(supabase, `ai-weekly-briefing:${ctx.user.id}`, { limit: 5 });
if (!limited.ok) return NextResponse.json(
{ error: t('weeklyBriefing.tooManyWeeklyBriefingRequests') },
Expand Down
9 changes: 7 additions & 2 deletions app/api/billing/cancel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { getTranslations } from '@/lib/i18n/server';
import { requireUserContext } from '@/lib/supabase/auth';
import { createServer, createServiceClient } from '@/lib/supabase/server';
import { wroteNoRows } from '@/lib/supabase/errors';
import { getStripe } from '@/lib/stripe';
import { stripeFromKey } from '@/lib/stripe';
import { getStripeSettings, effectiveSecretKey } from '@/lib/stripe/settings';
import { isAdmin } from '@/lib/constants/roles';
import { enforceRequestRateLimit } from '@/lib/server/request-rate-limit';
import { readBoundedRequestJson } from '@/lib/server/bounded-request-body';
Expand Down Expand Up @@ -57,7 +58,11 @@ export async function POST(req: NextRequest) {
{ error: t('cancel.tooManyBillingRequestsPlease') },
{ status: 429, headers: { 'Retry-After': String(limited.retryAfter) } },
);
await getStripe().subscriptions.update(sub.provider_ref, { cancel_at_period_end: cancelAtPeriodEnd });
// The key checkout used: Super Admin → Stripe Setup first, then the
// environment (getStripe() read only the environment).
const secretKey = effectiveSecretKey(await getStripeSettings());
if (!secretKey) return NextResponse.json({ error: t('checkout.billingIsNotSetUp') }, { status: 503 });
await stripeFromKey(secretKey).subscriptions.update(sub.provider_ref, { cancel_at_period_end: cancelAtPeriodEnd });

// Stripe has already changed by here. A sync matching no rows left the local
// row saying the opposite of what the family just chose — "cancels at period
Expand Down
9 changes: 7 additions & 2 deletions app/api/billing/change-plan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { getTranslations } from '@/lib/i18n/server';
import { getUserContext, requireUserContext, type UserContext } from '@/lib/supabase/auth';
import { createServer, createServiceClient } from '@/lib/supabase/server';
import { wroteNoRows } from '@/lib/supabase/errors';
import { getStripe, STRIPE_PLANS } from '@/lib/stripe';
import { stripeFromKey, STRIPE_PLANS } from '@/lib/stripe';
import { getStripeSettings, effectiveSecretKey } from '@/lib/stripe/settings';
import { isAdmin } from '@/lib/constants/roles';
import { canChangeSubscriptionInPlace, slugToStripePlan } from '@/lib/billing/plans';
import { canonicalStripePlan, isStripePlanKey, verifyStripePlanPrice } from '@/lib/billing/price-catalog';
Expand Down Expand Up @@ -86,7 +87,11 @@ export async function POST(req: NextRequest) {
{ error: t('changePlan.tooManyBillingRequestsPlease') },
{ status: 429, headers: { 'Retry-After': String(limited.retryAfter) } },
);
const stripe = getStripe();
// The key checkout used: Super Admin → Stripe Setup first, then the
// environment (getStripe() read only the environment).
const secretKey = effectiveSecretKey(await getStripeSettings());
if (!secretKey) return NextResponse.json({ error: t('checkout.billingIsNotSetUp') }, { status: 503 });
const stripe = stripeFromKey(secretKey);

if (!await verifyStripePlanPrice(stripe, plan, priceId)) {
console.error('[billing-change-plan] Configured Stripe price is unavailable or does not match the plan');
Expand Down
Loading
Loading