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
11 changes: 6 additions & 5 deletions app/(app)/dashboard/auto/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { requireUserContext } from '@/lib/supabase/auth';
import { todayKeyFor } from '@/lib/services/scope';
import { createServer } from '@/lib/supabase/server';
import { describeActionError, wroteNoRows } from '@/lib/supabase/errors';
import { refusalError, refusalForError } from '@/lib/actions/refusal';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/lib/database.types';

Expand Down Expand Up @@ -56,17 +57,17 @@ async function saveRow(
const { data, error } = id
? await supabase.from(table as 'vehicles').update(row as never).eq('id', id).eq('family_id', familyId).select('id')
: await supabase.from(table as 'vehicles').insert({ ...row, family_id: familyId, created_by: userId } as never);
if (error) throw new Error(describeActionError(error, label));
if (id && wroteNoRows(data)) throw new Error(label);
if (error) throw refusalError(describeActionError(error, label), refusalForError(error));
if (id && wroteNoRows(data)) throw refusalError(label, 'notSaved');
}

async function softDelete(supabase: Client, table: string, id: string, familyId: string, userId: string, label = 'Could not delete that record.') {
// A soft delete is an UPDATE, so it carries the same zero-rows hazard as
// `saveRow` above — and the consequence is sharper, because the caller has
// already told the family the record is gone.
const { data, error } = await supabase.from(table as 'vehicles').update({ deleted_at: new Date().toISOString(), updated_by: userId }).eq('id', id).eq('family_id', familyId).select('id');
if (error) throw new Error(describeActionError(error, label));
if (wroteNoRows(data)) throw new Error(label);
if (error) throw refusalError(describeActionError(error, label), refusalForError(error));
if (wroteNoRows(data)) throw refusalError(label, 'notSaved');
}

// ── Vehicles ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -197,7 +198,7 @@ export async function saveAutoServiceAction(fd: FormData) {
provider: str(fd, 'provider'), cost: num(fd, 'cost'), mileage, description: str(fd, 'description'),
next_due_on: str(fd, 'next_due_on'), next_due_mileage: num(fd, 'next_due_mileage'), created_by: userId,
});
if (error) throw new Error(describeActionError(error, t('actions.couldNotSaveThatService')));
if (error) throw refusalError(describeActionError(error, t('actions.couldNotSaveThatService')), refusalForError(error));
// Keep the vehicle odometer fresh — best-effort (the record is already saved),
// but log a failure so a broken update is observable, not silently ignored.
if (vehicleId && mileage != null) {
Expand Down
5 changes: 3 additions & 2 deletions app/(app)/dashboard/contacts/[id]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { todayKeyFor } from '@/lib/services/scope';
import { createServer } from '@/lib/supabase/server';
import type { Tables } from '@/lib/database.types';
import { describeActionError, wroteNoRows } from '@/lib/supabase/errors';
import { refusalError, refusalForError } from '@/lib/actions/refusal';
import { isAIConfigured, resolveProvider, describeAIError } from '@/lib/ai/provider';
import { withAiRequest } from '@/lib/ai/observability';
import { scopeFromUserContext } from '@/lib/services/scope';
Expand Down Expand Up @@ -42,7 +43,7 @@ export async function logInteractionAction(formData: FormData): Promise<void> {
amount: Number.isFinite(amount as number) ? amount : null,
created_by: ctx.user.id,
});
if (error) throw new Error(describeActionError(error, t('actions.couldNotLogThatInteraction')));
if (error) throw refusalError(describeActionError(error, t('actions.couldNotLogThatInteraction')), refusalForError(error));
revalidatePath(`/dashboard/contacts/${contactId}`);
}

Expand All @@ -55,7 +56,7 @@ export async function deleteInteractionAction(input: { id: string; contactId: st
// Audit C1-S9-60.
const { data: deleted, error } = await supabase.from('contact_interactions')
.delete().eq('id', input.id).eq('family_id', ctx.active.familyId).select('id');
if (error) throw new Error(describeActionError(error, t('actions.couldNotDeleteThatInteraction')));
if (error) throw refusalError(describeActionError(error, t('actions.couldNotDeleteThatInteraction')), refusalForError(error));
if (wroteNoRows(deleted)) throw new Error(t('actions.couldNotDeleteThatInteraction'));
revalidatePath(`/dashboard/contacts/${input.contactId}`);
}
Expand Down
13 changes: 7 additions & 6 deletions app/(app)/dashboard/home/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { requireUserContext } from '@/lib/supabase/auth';
import { todayKeyFor } from '@/lib/services/scope';
import { createServer } from '@/lib/supabase/server';
import { describeActionError, wroteNoRows } from '@/lib/supabase/errors';
import { refusalError, refusalForError } from '@/lib/actions/refusal';
import { DEFAULT_CADENCES } from '@/lib/home/maintenance';

async function ctx() {
Expand Down Expand Up @@ -70,7 +71,7 @@ export async function saveWarrantyAction(fd: FormData) {
const { data: saved, error } = id
? await supabase.from('home_warranties').update(row).eq('id', id).eq('family_id', familyId).select('id')
: await supabase.from('home_warranties').insert({ ...row, family_id: familyId, created_by: userId });
if (error) throw new Error(describeActionError(error, tr('actions.couldNotSaveThatWarranty')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotSaveThatWarranty')), refusalForError(error));
if (id && wroteNoRows(saved)) throw new Error(tr('actions.couldNotSaveThatWarranty'));
revalidatePath('/dashboard/home/warranties');
revalidatePath('/dashboard/home');
Expand All @@ -83,7 +84,7 @@ export async function deleteWarrantyAction(id: string) {
// A soft delete is an update, so it carries the zero-rows hazard — and the
// caller has already told the family the record is gone. Audit C1-S9-47.
const { data: removed, error } = await supabase.from('home_warranties').update({ deleted_at: new Date().toISOString(), updated_by: userId }).eq('id', id).eq('family_id', familyId).select('id');
if (error) throw new Error(describeActionError(error, tr('actions.couldNotDeleteThatWarranty')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotDeleteThatWarranty')), refusalForError(error));
if (wroteNoRows(removed)) throw new Error(tr('actions.couldNotDeleteThatWarranty'));
revalidatePath('/dashboard/home/warranties');
}
Expand Down Expand Up @@ -111,7 +112,7 @@ export async function saveContractorAction(fd: FormData) {
const { data: saved, error } = id
? await supabase.from('home_contractors').update(row).eq('id', id).eq('family_id', familyId).select('id')
: await supabase.from('home_contractors').insert({ ...row, family_id: familyId, created_by: userId });
if (error) throw new Error(describeActionError(error, tr('actions.couldNotSaveThatContractor')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotSaveThatContractor')), refusalForError(error));
if (id && wroteNoRows(saved)) throw new Error(tr('actions.couldNotSaveThatContractor'));
revalidatePath('/dashboard/home/pros');
}
Expand All @@ -122,7 +123,7 @@ export async function deleteContractorAction(id: string) {
// A soft delete is an update, so it carries the zero-rows hazard — and the
// caller has already told the family the record is gone. Audit C1-S9-47.
const { data: removed, error } = await supabase.from('home_contractors').update({ deleted_at: new Date().toISOString(), updated_by: userId }).eq('id', id).eq('family_id', familyId).select('id');
if (error) throw new Error(describeActionError(error, tr('actions.couldNotDeleteThatContractor')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotDeleteThatContractor')), refusalForError(error));
if (wroteNoRows(removed)) throw new Error(tr('actions.couldNotDeleteThatContractor'));
revalidatePath('/dashboard/home/pros');
}
Expand All @@ -144,7 +145,7 @@ export async function saveServiceRecordAction(fd: FormData) {
next_due_on: str(fd, 'next_due_on'),
created_by: userId,
});
if (error) throw new Error(describeActionError(error, tr('actions.couldNotSaveThatService')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotSaveThatService')), refusalForError(error));
// Keep the asset's last-serviced date fresh for life/forecast math. Best-effort
// (the record itself is already saved), but log a failure so a broken update is
// observable instead of silently drifting the forecast math.
Expand Down Expand Up @@ -172,7 +173,7 @@ export async function deleteServiceRecordAction(id: string) {
// A soft delete is an update, so it carries the zero-rows hazard — and the
// caller has already told the family the record is gone. Audit C1-S9-47.
const { data: removed, error } = await supabase.from('home_service_records').update({ deleted_at: new Date().toISOString(), updated_by: userId }).eq('id', id).eq('family_id', familyId).select('id');
if (error) throw new Error(describeActionError(error, tr('actions.couldNotDeleteThatService')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotDeleteThatService')), refusalForError(error));
if (wroteNoRows(removed)) throw new Error(tr('actions.couldNotDeleteThatService'));
revalidatePath('/dashboard/home/service');
}
Expand Down
9 changes: 5 additions & 4 deletions app/(app)/dashboard/paperwork/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { scopeFromUserContext } from '@/lib/services/scope';
import { createReminder } from '@/lib/services/reminders';
import { fenceUntrustedBlock, UNTRUSTED_CONTENT_RULE } from '@/lib/ai/safety/untrusted';
import { describeActionError, wroteNoRows } from '@/lib/supabase/errors';
import { refusalError, refusalForError } from '@/lib/actions/refusal';
import { isPaperworkExtractionPartial } from '@/lib/paperwork/extraction';
import { enforceAIRateLimit } from '@/lib/server/ai-rate-limit';

Expand Down Expand Up @@ -81,7 +82,7 @@ export async function addPaperworkAction(formData: FormData): Promise<PaperworkA
familyId: ctx.active.familyId, userId: ctx.user.id, text, sender,
});
const { error } = await supabase.from('paperwork_items').insert(row);
if (error) throw new Error(describeActionError(error, tr('actions.couldNotSaveThatPaperwork')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotSaveThatPaperwork')), refusalForError(error));
revalidatePath(PATH);
return { ok: true };
}
Expand Down Expand Up @@ -109,7 +110,7 @@ export async function materializePaperworkActionAction(input: {
const { data: item, error: itemError } = await supabase
.from('paperwork_items').select('*')
.eq('id', input.itemId).eq('family_id', ctx.active.familyId).maybeSingle();
if (itemError) throw new Error(describeActionError(itemError, tr('actions.couldNotLoadThatDocument')));
if (itemError) throw refusalError(describeActionError(itemError, tr('actions.couldNotLoadThatDocument')), refusalForError(itemError));
if (!item) return { ok: true }; // nothing to do — unchanged from the void form
if (isPaperworkExtractionPartial(item.meta)) throw new Error(tr('paperwork.partialExtractionWarning'));

Expand Down Expand Up @@ -146,7 +147,7 @@ export async function materializePaperworkActionAction(input: {
all_day: Boolean(dueOn),
created_by: ctx.user.id,
}).select('id').single();
if (error) throw new Error(describeActionError(error, tr('actions.couldNotAddThatTo')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotAddThatTo')), refusalForError(error));
materializedAs = 'calendar_event';
materializedId = data?.id ?? null;
} else {
Expand Down Expand Up @@ -334,7 +335,7 @@ export async function setPaperworkStatusAction(input: {
.update({ status: input.status })
.eq('id', input.itemId).eq('family_id', ctx.active.familyId)
.select('id');
if (error) throw new Error(describeActionError(error, tr('actions.couldNotUpdateThatPaperwork')));
if (error) throw refusalError(describeActionError(error, tr('actions.couldNotUpdateThatPaperwork')), refusalForError(error));
// No error is not the same as saved: row-level security FILTERS an update it
// refuses, so the statement matches nothing and succeeds. Ask for the row
// back and answer a refusal when none changed (lib/supabase/errors.ts).
Expand Down
11 changes: 6 additions & 5 deletions app/(app)/dashboard/social/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { requireUserContext } from '@/lib/supabase/auth';
import { createServer } from '@/lib/supabase/server';
import { requireSocialPermission } from '@/lib/social/access';
import { describeActionError } from '@/lib/supabase/errors';
import { refusalError, refusalForError } from '@/lib/actions/refusal';
import {
isProviderConfigured, isPlatform, PROVIDERS, type SocialPlatform,
} from '@/lib/social/capabilities';
Expand Down Expand Up @@ -337,7 +338,7 @@ export async function resolveCommentAction(id: string) {
.eq('family_id', fid)
.select('id')
.single();
if (error || !data) throw new Error(describeActionError(error, tr('actions.couldNotResolveThatComment')));
if (error || !data) throw refusalError(describeActionError(error, tr('actions.couldNotResolveThatComment')), refusalForError(error));
revalidatePath('/dashboard/social/inbox');
}

Expand Down Expand Up @@ -369,7 +370,7 @@ export async function createMediaAction(formData: FormData) {
family_id: fid, user_id: userId, kind, title, url, alt_text: alt, tags,
source: url ? 'upload' : 'prompt', status: url ? 'ready' : 'prompt_only', created_by: userId,
}).select('id').single();
if (error || !data) throw new Error(describeActionError(error, tr('actions.couldNotSaveThatMedia')));
if (error || !data) throw refusalError(describeActionError(error, tr('actions.couldNotSaveThatMedia')), refusalForError(error));
revalidatePath('/dashboard/social/media-library');
}

Expand All @@ -391,7 +392,7 @@ export async function updateSettingsAction(formData: FormData) {
{ family_id: fid, default_timezone, require_approval, auto_hashtags, signature, ai_tone, default_platforms, updated_by: userId },
{ onConflict: 'family_id' },
).select('id').single();
if (error || !data) throw new Error(describeActionError(error, tr('actions.couldNotSaveSocialSettings')));
if (error || !data) throw refusalError(describeActionError(error, tr('actions.couldNotSaveSocialSettings')), refusalForError(error));
revalidatePath('/dashboard/social/settings');
}

Expand All @@ -411,11 +412,11 @@ export async function grantAccessAction(formData: FormData) {
.eq('user_id', targetUserId)
.eq('is_active', true)
.maybeSingle();
if (memberError || !member) throw new Error(describeActionError(memberError, tr('actions.thatUserIsNotAn')));
if (memberError || !member) throw refusalError(describeActionError(memberError, tr('actions.thatUserIsNotAn')), refusalForError(memberError));
const { data, error } = await supabase.from('social_access_permissions').upsert(
{ family_id: fid, user_id: targetUserId, social_role: role as never, granted_by: userId, created_by: userId, status: 'active' },
{ onConflict: 'family_id,user_id' },
).select('id').single();
if (error || !data) throw new Error(describeActionError(error, tr('actions.couldNotUpdateSocialAccess')));
if (error || !data) throw refusalError(describeActionError(error, tr('actions.couldNotUpdateSocialAccess')), refusalForError(error));
revalidatePath('/dashboard/social/settings');
}
7 changes: 6 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, isAIConfigured } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured, describeAIError } 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 Down Expand Up @@ -183,6 +183,11 @@ export async function POST() {
return NextResponse.json({ digest, context });
} catch (err) {
console.error('Relationship AI error:', err);
// An engine with no key, no credit or no connection is unavailable, as
// every other AI route answers it — not a server fault behind a 500.
if (describeAIError(err).code !== 'unknown') {
return NextResponse.json({ error: t('ai.recommendationsAreTemporarilyUnavailable') }, { status: 503 });
}
return NextResponse.json({ error: t('relationship.somethingWentWrongGeneratingSuggestions') }, { status: 500 });
}
}
7 changes: 6 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, isAIConfigured } from '@/lib/ai/provider';
import { resolveProvider, isAIConfigured, describeAIError } 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 Down Expand Up @@ -245,6 +245,11 @@ Rules:
return NextResponse.json({ briefing, generatedAt: new Date().toISOString(), weekKey: w.days[0] });
} catch (err) {
console.error('Weekly briefing error:', err);
// An engine with no key, no credit or no connection is unavailable, not a
// server fault behind a 500 (P-25).
if (describeAIError(err).code !== 'unknown') {
return NextResponse.json({ error: t('ai.recommendationsAreTemporarilyUnavailable') }, { status: 503 });
}
return NextResponse.json({ error: t('weeklyBriefing.failedToGenerateWeeklyBriefing') }, { status: 500 });
}
}
Loading