From 3b4855e2c6e4915a2f2784c3fbdaaf83d6a8a033 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:03:43 +0300 Subject: [PATCH 01/16] fix(email): clarify transactional messages and delivery failures --- apps/api/src/routes/webhooks/autumn.test.ts | 233 ++++++++++++++- apps/api/src/routes/webhooks/autumn.ts | 281 +++++++++++++++--- .../src/lib/blocked-traffic-alerts.test.ts | 8 + apps/basket/src/lib/blocked-traffic-alerts.ts | 81 ++--- .../src/uptime-transition-alerts.test.ts | 82 +++++ apps/uptime/src/uptime-transition-alerts.ts | 181 ++++++++--- packages/auth/src/auth.ts | 244 ++++++++------- .../email/src/emails/auth-email-copy.test.tsx | 70 +++++ .../email/src/emails/auth-email-expiry.ts | 17 ++ .../email/src/emails/delete-account-email.tsx | 20 +- packages/email/src/emails/email-brand.test.ts | 38 +++ packages/email/src/emails/email-brand.ts | 6 +- packages/email/src/emails/index.ts | 2 + .../email/src/emails/invitation-email.tsx | 44 ++- .../email/src/emails/magic-link-email.tsx | 15 +- packages/email/src/emails/otp-email.tsx | 123 +++++--- .../email/src/emails/reset-password-email.tsx | 11 +- .../src/emails/uptime-alert-email.test.tsx | 12 +- .../email/src/emails/uptime-alert-email.tsx | 10 +- .../email/src/emails/usage-alert-email.tsx | 104 +++++-- .../src/emails/usage-email-copy.test.tsx | 53 ++++ .../email/src/emails/usage-email-utils.ts | 27 ++ .../email/src/emails/usage-limit-email.tsx | 125 +++++--- .../email/src/emails/verification-email.tsx | 11 +- .../src/__tests__/alarm-config.test.ts | 38 +++ .../src/__tests__/providers/email.test.ts | 53 ++++ .../src/__tests__/providers/slack.test.ts | 37 +++ .../src/__tests__/templates/uptime.test.ts | 4 +- packages/notifications/src/alarm-config.ts | 10 +- packages/notifications/src/client.ts | 12 +- packages/notifications/src/providers/email.ts | 26 +- packages/notifications/src/providers/slack.ts | 123 +++++--- .../notifications/src/templates/uptime.ts | 4 +- packages/rpc/src/index.ts | 1 + packages/rpc/src/routers/alarms.ts | 4 +- 35 files changed, 1651 insertions(+), 459 deletions(-) create mode 100644 packages/email/src/emails/auth-email-copy.test.tsx create mode 100644 packages/email/src/emails/auth-email-expiry.ts create mode 100644 packages/email/src/emails/email-brand.test.ts create mode 100644 packages/email/src/emails/usage-email-copy.test.tsx create mode 100644 packages/email/src/emails/usage-email-utils.ts create mode 100644 packages/notifications/src/__tests__/providers/email.test.ts create mode 100644 packages/notifications/src/__tests__/providers/slack.test.ts diff --git a/apps/api/src/routes/webhooks/autumn.test.ts b/apps/api/src/routes/webhooks/autumn.test.ts index c378d1324c..242fd42f7b 100644 --- a/apps/api/src/routes/webhooks/autumn.test.ts +++ b/apps/api/src/routes/webhooks/autumn.test.ts @@ -1,6 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ + check: vi.fn(async () => ({ + allowed: true, + balance: { + granted: 10_000, + nextResetAt: Date.UTC(2026, 7, 1), + overageAllowed: false, + remaining: 2000, + usage: 8000, + }, + })), inserted: [] as Record[], log: { error: vi.fn(), @@ -8,6 +18,14 @@ const state = vi.hoisted(() => ({ warn: vi.fn(), }, operations: [] as string[], + ownedOrganizations: [] as Array<{ + organizationId: string; + organization: { + emailNotifications: { billing?: { usageWarnings?: boolean } }; + id: string; + name: string; + }; + }>, recentRows: [] as Array<{ id: string }>, send: vi.fn(async () => ({ data: { id: "email-1" }, error: null })), userRow: { email: "customer@example.com", name: "Customer" } as { @@ -18,18 +36,22 @@ const state = vi.hoisted(() => ({ vi.mock("@databuddy/db", () => ({ and: (...conditions: unknown[]) => ({ conditions }), - db: { - query: { - member: { findMany: vi.fn(async () => []) }, + db: { + query: { + member: { + findMany: vi.fn(async () => state.ownedOrganizations), + }, organization: { findFirst: vi.fn(async () => null) }, user: { findFirst: vi.fn(async () => state.userRow) }, }, }, eq: (field: unknown, value: unknown) => ({ field, op: "eq", value }), gt: (field: unknown, value: unknown) => ({ field, op: "gt", value }), - normalizeEmailNotificationSettings: () => ({ - billing: { usageWarnings: true }, - }), + normalizeEmailNotificationSettings: (raw?: { + billing?: { usageWarnings?: boolean }; + }) => ({ + billing: { usageWarnings: raw?.billing?.usageWarnings ?? true }, + }), sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings: Array.from(strings), values, @@ -92,6 +114,10 @@ vi.mock("@databuddy/redis", () => ({ invalidateBillingOwnerCaches: vi.fn(async () => ({ attempted: 0, failed: 0 })), })); +vi.mock("@databuddy/rpc", () => ({ + getAutumn: () => ({ check: state.check }), +})); + vi.mock("@databuddy/services/billing-lifecycle", () => ({ recordPlanChange: vi.fn(async () => undefined), })); @@ -126,14 +152,34 @@ vi.mock("../../lib/tracing", () => ({ mergeWideEvent: vi.fn(), })); -import { sendAlertEmail } from "./autumn"; +import { UsageAlertEmail, UsageLimitEmail } from "@databuddy/email"; +import { + handleLimitReached, + handleUsageAlert, + sendAlertEmail, +} from "./autumn"; beforeEach(() => { + process.env.RESEND_API_KEY = "test-resend-key"; state.inserted = []; state.operations = []; + state.ownedOrganizations = []; state.recentRows = []; state.userRow = { email: "customer@example.com", name: "Customer" }; state.send.mockClear(); + state.check.mockClear(); + state.check.mockResolvedValue({ + allowed: true, + balance: { + granted: 10_000, + nextResetAt: Date.UTC(2026, 7, 1), + overageAllowed: false, + remaining: 2000, + usage: 8000, + }, + }); + vi.mocked(UsageAlertEmail).mockClear(); + vi.mocked(UsageLimitEmail).mockClear(); state.send.mockImplementation(async () => { state.operations.push("send"); return { data: { id: "email-1" }, error: null }; @@ -152,6 +198,7 @@ describe("sendAlertEmail", () => { cooldownKey: "events", customerId: "user-1", react: { type: "email" } as never, + recipient: { email: "member@example.com" }, subject: "Limit reached", }); @@ -167,24 +214,182 @@ describe("sendAlertEmail", () => { cooldownKey: "events", customerId: "user-1", react: { type: "email" } as never, + recipient: { email: "member@example.com" }, subject: "Limit reached", }); expect(result).toEqual({ success: true, message: "Email sent" }); expect(state.operations).toEqual(["lock", "select", "send", "insert"]); - expect(state.send).toHaveBeenCalledWith({ - from: "alerts@databuddy.cc", - to: "customer@example.com", - subject: "Limit reached", - html: "", - }); + expect(state.send).toHaveBeenCalledWith({ + from: "alerts@databuddy.cc", + to: "member@example.com", + subject: "Limit reached", + html: "", + text: "", + }); expect(state.inserted).toEqual([ expect.objectContaining({ alertType: "included", - emailSentTo: "customer@example.com", + emailSentTo: "member@example.com", featureId: "events", userId: "user-1", }), ]); }); + + it("does not record a delivery when Resend rejects the email", async () => { + state.send.mockResolvedValueOnce({ + data: null, + error: { message: "provider unavailable" }, + }); + + const result = await sendAlertEmail({ + alertType: "included", + cooldownKey: "events", + customerId: "user-1", + react: { type: "email" } as never, + recipient: { email: "member@example.com" }, + subject: "Limit reached", + }); + + expect(result).toEqual({ + success: false, + message: "Alert email delivery failed", + }); + expect(state.operations).toEqual(["lock", "select"]); + expect(state.inserted).toEqual([]); + }); + + it("reports delivery unavailable when Resend is not configured", async () => { + delete process.env.RESEND_API_KEY; + + const result = await sendAlertEmail({ + alertType: "included", + cooldownKey: "events", + customerId: "user-1", + react: { type: "email" } as never, + recipient: { email: "member@example.com" }, + subject: "Limit reached", + }); + + expect(result).toEqual({ + success: false, + message: "Alert email delivery unavailable", + }); + expect(state.send).not.toHaveBeenCalled(); + }); +}); + +describe("Autumn usage emails", () => { + beforeEach(() => { + state.ownedOrganizations = [ + { + organizationId: "org-1", + organization: { + emailNotifications: { billing: { usageWarnings: true } }, + id: "org-1", + name: "Acme", + }, + }, + ]; + }); + + it("uses live balance data and purpose-based Databunny wording", async () => { + state.userRow = { email: "recipient@example.com", name: "Recipient" }; + state.check.mockResolvedValueOnce({ + allowed: true, + balance: { + granted: 350, + nextResetAt: Date.UTC(2026, 7, 1), + overageAllowed: false, + remaining: 62, + usage: 288, + }, + }); + + await handleUsageAlert({ + customer_id: "user-1", + entity_id: "org-1", + feature_id: "agent_credits", + usage_alert: { + name: "AI notice", + threshold: 80, + threshold_type: "usage_percentage", + }, + }); + + expect(UsageAlertEmail).toHaveBeenCalledWith( + expect.objectContaining({ + featureName: "Databunny usage", + limitAmount: 350, + organizationName: "Acme", + remainingAmount: 62, + usageAmount: 288, + usageUnit: "allowance units", + }) + ); + expect(UsageAlertEmail).not.toHaveBeenCalledWith( + expect.objectContaining({ userName: expect.anything() }) + ); + expect(state.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "Databunny usage is at 82%", + to: "recipient@example.com", + }) + ); + }); + + it("handles an actual hard limit and tells the template whether use is paused", async () => { + state.check.mockResolvedValueOnce({ + allowed: false, + balance: { + granted: 350, + nextResetAt: Date.UTC(2026, 7, 1), + overageAllowed: false, + remaining: 0, + usage: 350, + }, + }); + + await handleLimitReached({ + customer_id: "user-1", + entity_id: "org-1", + feature_id: "agent_credits", + limit_type: "spend_limit", + }); + + expect(UsageLimitEmail).toHaveBeenCalledWith( + expect.objectContaining({ + featureName: "Databunny usage", + isAvailable: false, + limitAmount: 350, + limitType: "spend_limit", + usageAmount: 350, + }) + ); + expect(state.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "[Action required] Databunny usage is paused", + }) + ); + }); + + it("honors the resolved organization's billing email preference", async () => { + state.ownedOrganizations[0]!.organization.emailNotifications = { + billing: { usageWarnings: false }, + }; + + const result = await handleLimitReached({ + customer_id: "user-1", + entity_id: "org-1", + feature_id: "events", + limit_type: "included", + }); + + expect(result).toEqual({ + success: true, + message: "Billing usage emails disabled", + }); + expect(state.send).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index c41653c300..a24de858d7 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -17,6 +17,7 @@ import { invalidateAgentContextSnapshotsForOwner, invalidateBillingOwnerCaches, } from "@databuddy/redis"; +import { getAutumn } from "@databuddy/rpc"; import { recordPlanChange } from "@databuddy/services/billing-lifecycle"; import { Elysia } from "elysia"; import { useLogger } from "evlog/elysia"; @@ -31,18 +32,19 @@ const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; const SVIX_SECRET = process.env.AUTUMN_WEBHOOK_SECRET; const SLACK_URL = process.env.SLACK_WEBHOOK_URL ?? ""; -const resend = new Resend(process.env.RESEND_API_KEY); const svix = SVIX_SECRET ? new Webhook(SVIX_SECRET) : null; const slack = SLACK_URL ? new SlackProvider({ webhookUrl: SLACK_URL }) : null; const limitReachedSchema = z.object({ customer_id: z.string(), + entity_id: z.string().optional(), feature_id: z.string(), limit_type: z.enum(["included", "max_purchase", "spend_limit"]), }); const usageAlertSchema = z.object({ customer_id: z.string(), + entity_id: z.string().optional(), feature_id: z.string(), usage_alert: z.object({ name: z.string().optional(), @@ -92,23 +94,39 @@ interface WebhookResult { success: boolean; } -async function getOrganizationEmailSettings(customerId: string) { - const row = await db.query.organization.findFirst({ - where: { id: customerId }, - columns: { emailNotifications: true }, - }); - return normalizeEmailNotificationSettings(row?.emailNotifications); +interface BillingRecipient { + email: string | null; +} + +interface BillingOrganizationContext { + emailNotifications: Parameters[0]; + id: string; + name: string; +} + +interface UsageSnapshot { + granted: number; + isAvailable: boolean; + nextResetAt: number | null; + overageAllowed: boolean; + remaining: number; + usage: number; +} + +interface BillingFeatureCopy { + description: string; + name: string; + pausedActivity: string; + unit: string; } -const getUserData = cacheable( - async ( - customerId: string - ): Promise<{ email: string | null; name: string | null }> => { +const getBillingRecipient = cacheable( + async (customerId: string): Promise => { const row = await db.query.user.findFirst({ where: { id: customerId }, - columns: { email: true, name: true }, + columns: { email: true }, }); - return { email: row?.email ?? null, name: row?.name ?? null }; + return { email: row?.email ?? null }; }, { expireInSec: 300, @@ -118,8 +136,105 @@ const getUserData = cacheable( } ); -function formatFeatureId(id: string): string { - return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +export async function resolveBillingOrganization( + customerId: string, + entityId?: string +): Promise { + const memberships = await db.query.member.findMany({ + where: { userId: customerId, role: "owner" }, + columns: { organizationId: true }, + with: { + organization: { + columns: { id: true, name: true, emailNotifications: true }, + }, + }, + }); + + const candidates = entityId + ? memberships.filter((row) => row.organizationId === entityId) + : memberships; + if (candidates.length !== 1) { + return null; + } + + const organization = candidates[0]?.organization; + return organization + ? { + emailNotifications: organization.emailNotifications, + id: organization.id, + name: organization.name, + } + : null; +} + +function getFeatureCopy(featureId: string): BillingFeatureCopy { + if (featureId === "agent_credits") { + return { + description: + "This allowance powers Databunny questions and AI analysis. More complex work uses more of it.", + name: "Databunny usage", + pausedActivity: "Databunny questions and AI analysis", + unit: "allowance units", + }; + } + + if (featureId === "events") { + return { + description: + "Events include page views, custom events, errors, and Web Vitals collected by Databuddy.", + name: "Event tracking", + pausedActivity: "new event collection", + unit: "events", + }; + } + + const label = featureId.replaceAll(/[_-]+/g, " ").trim() || "Feature"; + return { + description: `This allowance controls how much ${label} your plan can use.`, + name: `${label[0]?.toUpperCase() ?? ""}${label.slice(1)} usage`, + pausedActivity: label, + unit: "units", + }; +} + +async function getUsageSnapshot( + customerId: string, + featureId: string +): Promise { + try { + const response = await getAutumn().check({ customerId, featureId }); + const balance = response.balance; + if (!balance) { + return null; + } + return { + granted: balance.granted, + isAvailable: response.allowed, + nextResetAt: balance.nextResetAt, + overageAllowed: balance.overageAllowed, + remaining: balance.remaining, + usage: balance.usage, + }; + } catch (error) { + useLogger().error( + error instanceof Error ? error : new Error(String(error)), + { autumn: { step: "load_usage", customerId, featureId } } + ); + return null; + } +} + +function formatUsageNumber(value: number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format( + value + ); +} + +function usagePercentage(snapshot: UsageSnapshot): number | null { + if (snapshot.granted <= 0) { + return null; + } + return Math.round((snapshot.usage / snapshot.granted) * 100); } export async function sendAlertEmail(opts: { @@ -128,19 +243,29 @@ export async function sendAlertEmail(opts: { alertType: string; subject: string; react: React.ReactElement; + recipient: BillingRecipient; }): Promise { const log = useLogger(); - const { customerId, cooldownKey, alertType, subject, react } = opts; + const { customerId, cooldownKey, alertType, subject, react, recipient } = + opts; - const { email } = await getUserData(customerId); + const { email } = recipient; if (!email) { log.warn("No email for customer", { autumn: { customerId, cooldownKey }, }); return { success: true, message: "No notification recipient found" }; } + const resendApiKey = process.env.RESEND_API_KEY; + if (!resendApiKey) { + log.error(new Error("RESEND_API_KEY is not configured"), { + autumn: { customerId, cooldownKey, step: "email_configuration" }, + }); + return { success: false, message: "Alert email delivery unavailable" }; + } + const resend = new Resend(resendApiKey); - return withTransaction(async (tx) => { + return await withTransaction(async (tx) => { await tx.execute( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`usage-alert:${customerId}:${cooldownKey}`}, 0))` ); @@ -165,12 +290,16 @@ export async function sendAlertEmail(opts: { return { success: true, message: "Already sent recently" }; } - const html = await render(react); + const [html, text] = await Promise.all([ + render(react), + render(react, { plainText: true }), + ]); const result = await resend.emails.send({ from: config.email.alertsFrom, to: email, subject, html, + text, }); if (result.error) { @@ -221,42 +350,93 @@ async function invalidatePlanCaches(customerId: string | null): Promise { } } -function handleLimitReached( +export async function handleLimitReached( data: LimitReachedData -): Promise | WebhookResult { - const { customer_id, feature_id, limit_type } = data; - - if (limit_type !== "included") { - return { success: true, message: `Skipped ${limit_type} limit` }; +): Promise { + const { customer_id, entity_id, feature_id, limit_type } = data; + const [recipient, organization, snapshot] = await Promise.all([ + getBillingRecipient(customer_id), + resolveBillingOrganization(customer_id, entity_id), + getUsageSnapshot(customer_id, feature_id), + ]); + + if ( + organization && + !normalizeEmailNotificationSettings(organization.emailNotifications).billing + .usageWarnings + ) { + return { success: true, message: "Billing usage emails disabled" }; + } + if (!organization) { + useLogger().warn("Could not resolve billing organization", { + autumn: { customerId: customer_id, entityId: entity_id }, + }); + } + if (!snapshot) { + return { success: false, message: "Current usage is unavailable" }; } - const featureName = formatFeatureId(feature_id); + const feature = getFeatureCopy(feature_id); + const isHardStop = !snapshot.isAvailable; + const subject = isHardStop + ? `[Action required] ${feature.name} is paused` + : `${feature.name}: included allowance used`; mergeWideEvent({ customer_id, feature_id, limit_type }); return sendAlertEmail({ customerId: customer_id, - cooldownKey: feature_id, + cooldownKey: `${feature_id}:limit:${limit_type}`, alertType: limit_type, - subject: `[Action required] ${featureName} limit reached — upgrade to continue tracking`, + subject, react: UsageLimitEmail({ - featureName, - thresholdType: "limit_reached", + featureDescription: feature.description, + featureName: feature.name, + isAvailable: snapshot.isAvailable, + limitAmount: snapshot.granted, + limitType: limit_type, + nextResetAt: snapshot.nextResetAt, + organizationName: organization?.name, + overageAllowed: snapshot.overageAllowed, + pausedActivity: feature.pausedActivity, + remainingAmount: snapshot.remaining, + usageAmount: snapshot.usage, + usageUnit: feature.unit, }), + recipient, }); } -async function handleUsageAlert(data: UsageAlertData): Promise { - const { customer_id, feature_id, usage_alert } = data; - const settings = await getOrganizationEmailSettings(customer_id); - if (!settings.billing.usageWarnings) { - return { success: true, message: "Usage warning emails disabled" }; +export async function handleUsageAlert( + data: UsageAlertData +): Promise { + const { customer_id, entity_id, feature_id, usage_alert } = data; + const [recipient, organization, snapshot] = await Promise.all([ + getBillingRecipient(customer_id), + resolveBillingOrganization(customer_id, entity_id), + getUsageSnapshot(customer_id, feature_id), + ]); + if ( + organization && + !normalizeEmailNotificationSettings(organization.emailNotifications).billing + .usageWarnings + ) { + return { success: true, message: "Billing usage emails disabled" }; + } + if (!organization) { + useLogger().warn("Could not resolve billing organization", { + autumn: { customerId: customer_id, entityId: entity_id }, + }); } - const featureName = formatFeatureId(feature_id); - const isPercentage = - usage_alert.threshold_type === "usage_percentage_threshold"; - const label = isPercentage - ? `${usage_alert.threshold}%` - : String(usage_alert.threshold); + if (!snapshot) { + return { success: false, message: "Current usage is unavailable" }; + } + + const feature = getFeatureCopy(feature_id); + const percentage = usagePercentage(snapshot); + const subject = + percentage === null + ? `${feature.name}: ${formatUsageNumber(snapshot.usage)} ${feature.unit} used` + : `${feature.name} is at ${percentage}%`; mergeWideEvent({ customer_id, @@ -267,15 +447,22 @@ async function handleUsageAlert(data: UsageAlertData): Promise { return sendAlertEmail({ customerId: customer_id, - cooldownKey: `${feature_id}_alert_${usage_alert.threshold}`, + cooldownKey: `${feature_id}:alert:${usage_alert.threshold_type}:${usage_alert.threshold}`, alertType: `usage_alert_${usage_alert.threshold_type}`, - subject: `[Action required] You've used ${label} of your ${featureName.toLowerCase()}`, + subject, react: UsageAlertEmail({ - featureName, - threshold: usage_alert.threshold, - thresholdType: isPercentage ? "usage_percentage_threshold" : "usage", - alertName: usage_alert.name ?? undefined, + featureDescription: feature.description, + featureName: feature.name, + limitAmount: snapshot.granted, + nextResetAt: snapshot.nextResetAt, + organizationName: organization?.name, + overageAllowed: snapshot.overageAllowed, + pausedActivity: feature.pausedActivity, + remainingAmount: snapshot.remaining, + usageAmount: snapshot.usage, + usageUnit: feature.unit, }), + recipient, }); } diff --git a/apps/basket/src/lib/blocked-traffic-alerts.test.ts b/apps/basket/src/lib/blocked-traffic-alerts.test.ts index 302c3671b5..62d6173206 100644 --- a/apps/basket/src/lib/blocked-traffic-alerts.test.ts +++ b/apps/basket/src/lib/blocked-traffic-alerts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { decideBlockedTrafficAlert, matchesTrackingAlertIgnoredOrigin, + requireBlockedTrafficEmailApiKey, shouldEvaluateBlockedTrafficAlert, shouldIgnoreBlockedTrafficAlertEvent, } from "./blocked-traffic-alerts"; @@ -148,4 +149,11 @@ describe("blocked traffic alert rules", () => { expect(shouldEvaluateBlockedTrafficAlert(50)).toBe(true); expect(shouldEvaluateBlockedTrafficAlert(75)).toBe(true); }); + + test("fails delivery when the email provider is not configured", () => { + expect(() => requireBlockedTrafficEmailApiKey(undefined)).toThrow( + "Blocked traffic email delivery is not configured" + ); + expect(requireBlockedTrafficEmailApiKey("test-key")).toBe("test-key"); + }); }); diff --git a/apps/basket/src/lib/blocked-traffic-alerts.ts b/apps/basket/src/lib/blocked-traffic-alerts.ts index 83496eb869..ce50326f58 100644 --- a/apps/basket/src/lib/blocked-traffic-alerts.ts +++ b/apps/basket/src/lib/blocked-traffic-alerts.ts @@ -101,7 +101,9 @@ function buildDashboardUrl(clientId: string, reason: string): string { return `${config.urls.dashboard}/websites/${clientId}/settings/${section}`; } -async function incrementWindowCounter(event: BlockedTrafficInsert): Promise { +async function incrementWindowCounter( + event: BlockedTrafficInsert +): Promise { const key = `blocked-traffic-alert:count:${event.client_id}:${event.block_reason}:${getAlertOriginKey(event)}`; const count = await redis.incr(key); if (count === 1) { @@ -124,7 +126,9 @@ async function getTrackingHealth( return rows[0] ?? { baselineEvents: 0, recentEvents: 0 }; } -async function getPreviousBlockedCount(event: BlockedTrafficInsert): Promise { +async function getPreviousBlockedCount( + event: BlockedTrafficInsert +): Promise { const rows = await chQuery( `SELECT count() AS previousBlocked FROM analytics.blocked_traffic @@ -176,6 +180,15 @@ export function shouldEvaluateBlockedTrafficAlert( ); } +export function requireBlockedTrafficEmailApiKey( + apiKey: string | undefined +): string { + if (!apiKey) { + throw new Error("Blocked traffic email delivery is not configured"); + } + return apiKey; +} + function cooldownKey( event: BlockedTrafficInsert, kind: BlockedTrafficAlertDecision["kind"] @@ -194,17 +207,15 @@ async function reserveCooldown( return reserved === "OK" ? key : null; } -async function getOwnerEmail( - ownerId: string -): Promise<{ email: string; name: string | null } | null> { +async function getOwnerEmail(ownerId: string): Promise { const row = await db.query.user.findFirst({ where: { id: ownerId }, - columns: { email: true, name: true }, + columns: { email: true }, }); if (!row?.email) { return null; } - return { email: row.email, name: row.name ?? null }; + return row.email; } async function getOrganizationEmailSettings( @@ -254,14 +265,11 @@ async function sendAlertEmail(input: { decision: BlockedTrafficAlertDecision; event: BlockedTrafficInsert; trackingHealth: TrackingHealthCounts; - ownerEmail: string; + recipientEmail: string; previousBlocked: number; windowBlockedCount: number; }): Promise { - const apiKey = process.env.RESEND_API_KEY; - if (!apiKey) { - return; - } + const apiKey = requireBlockedTrafficEmailApiKey(process.env.RESEND_API_KEY); const siteLabel = input.context.websiteName || @@ -273,25 +281,27 @@ async function sendAlertEmail(input: { ? `[Action required] Tracking may be blocked for ${siteLabel}` : `[Databuddy] Blocked tracking increased for ${siteLabel}`; - const html = await render( - BlockedTrafficAlertEmail({ - baselineEvents: input.trackingHealth.baselineEvents, - baselineHours: BASELINE_SUCCESS_HOURS, - blockReason: input.event.block_reason, - blockedCount: input.windowBlockedCount, - dashboardUrl: buildDashboardUrl( - input.event.client_id || "", - input.event.block_reason - ), - fix: buildRecommendedFix(input.event), - origin: input.event.origin ?? null, - previousBlockedCount: input.previousBlocked, - recentEvents: input.trackingHealth.recentEvents, - severity: input.decision.severity, - siteLabel, - windowMinutes: ALERT_WINDOW_MINUTES, - }) - ); + const email = BlockedTrafficAlertEmail({ + baselineEvents: input.trackingHealth.baselineEvents, + baselineHours: BASELINE_SUCCESS_HOURS, + blockReason: input.event.block_reason, + blockedCount: input.windowBlockedCount, + dashboardUrl: buildDashboardUrl( + input.event.client_id || "", + input.event.block_reason + ), + fix: buildRecommendedFix(input.event), + origin: input.event.origin ?? null, + previousBlockedCount: input.previousBlocked, + recentEvents: input.trackingHealth.recentEvents, + severity: input.decision.severity, + siteLabel, + windowMinutes: ALERT_WINDOW_MINUTES, + }); + const [html, text] = await Promise.all([ + render(email), + render(email, { plainText: true }), + ]); const response = await fetch("https://api.resend.com/emails", { method: "POST", @@ -301,9 +311,10 @@ async function sendAlertEmail(input: { }, body: JSON.stringify({ from: config.email.alertsFrom, - to: input.ownerEmail, + to: input.recipientEmail, subject, html, + text, }), }); @@ -350,8 +361,8 @@ async function maybeSendBlockedTrafficAlertAsync( return; } - const owner = await getOwnerEmail(context.ownerId); - if (!owner) { + const recipientEmail = await getOwnerEmail(context.ownerId); + if (!recipientEmail) { return; } @@ -366,7 +377,7 @@ async function maybeSendBlockedTrafficAlertAsync( decision, event, trackingHealth, - ownerEmail: owner.email, + recipientEmail, previousBlocked, windowBlockedCount, }); diff --git a/apps/uptime/src/uptime-transition-alerts.test.ts b/apps/uptime/src/uptime-transition-alerts.test.ts index 9ab97bfc38..627fcf46b9 100644 --- a/apps/uptime/src/uptime-transition-alerts.test.ts +++ b/apps/uptime/src/uptime-transition-alerts.test.ts @@ -1,12 +1,39 @@ import { describe, expect, test } from "bun:test"; import { MonitorStatus } from "./types"; import { + buildTransitionNotificationPayload, countFiredAlarms, resolveTransitionKind, + shouldReleaseTransitionClaim, } from "./uptime-transition-alerts"; +import type { UptimeData } from "./types"; const { UP, DOWN, PENDING, MAINTENANCE } = MonitorStatus; +const baseUptimeData: UptimeData = { + attempt: 1, + check_type: "http", + content_hash: "", + env: "production", + error: "", + failure_streak: 0, + http_code: 200, + probe_ip: "192.0.2.1", + probe_region: "test-region", + redirect_count: 0, + response_bytes: 100, + retries: 0, + site_id: "monitor-1", + ssl_expiry: 0, + ssl_valid: 1, + status: UP, + timestamp: Date.UTC(2026, 6, 11, 12, 30), + total_ms: 245, + ttfb_ms: 120, + url: "https://example.com", + user_agent: "Databuddy test", +}; + describe("resolveTransitionKind — happy path transitions", () => { test("fresh monitor going UP is silent", () => { expect(resolveTransitionKind(undefined, UP)).toBeNull(); @@ -134,3 +161,58 @@ describe("countFiredAlarms", () => { expect(countFiredAlarms([0, 0, 0])).toBe(0); }); }); + +describe("shouldReleaseTransitionClaim", () => { + test("releases the dedupe claim when every configured alarm delivery fails", () => { + expect(shouldReleaseTransitionClaim(2, 0)).toBe(true); + }); + + test("keeps the claim after any alarm delivers or when nothing was configured", () => { + expect(shouldReleaseTransitionClaim(2, 1)).toBe(false); + expect(shouldReleaseTransitionClaim(0, 0)).toBe(false); + }); +}); + +describe("buildTransitionNotificationPayload", () => { + test("describes a failed check without declaring the whole site down", () => { + const payload = buildTransitionNotificationPayload({ + dashboardUrl: "https://app.databuddy.cc/monitors/monitor-1", + data: { + ...baseUptimeData, + error: "upstream returned an error", + http_code: 503, + status: DOWN, + }, + kind: "down", + monitorId: "monitor-1", + siteLabel: "Example", + }); + + expect(payload.title).toBe("Health check failed: Example"); + expect(payload.message).toContain("A health check failed for Example"); + expect(payload.message).toContain("2026-07-11T12:30:00.000Z"); + expect(payload.message).toContain("HTTP 503"); + expect(payload.message).toContain("Reason: upstream returned an error"); + expect(payload.metadata).not.toHaveProperty("checkedAt"); + expect(payload.message).not.toContain("is down"); + expect(payload.title).not.toContain("[DOWN]"); + }); + + test("recovery copy only claims that the latest check passed", () => { + const payload = buildTransitionNotificationPayload({ + dashboardUrl: "https://app.databuddy.cc/monitors/monitor-1", + data: baseUptimeData, + kind: "recovered", + monitorId: "monitor-1", + siteLabel: "Example", + }); + + expect(payload.title).toBe("Health check passed: Example"); + expect(payload.message).toContain( + "A health check passed for Example after a previous failed check" + ); + expect(payload.message).toContain("Response time 245 ms"); + expect(payload.message).not.toContain("outage"); + expect(payload.message).not.toContain("operational again"); + }); +}); diff --git a/apps/uptime/src/uptime-transition-alerts.ts b/apps/uptime/src/uptime-transition-alerts.ts index d8ff366ddc..00bcf47bad 100644 --- a/apps/uptime/src/uptime-transition-alerts.ts +++ b/apps/uptime/src/uptime-transition-alerts.ts @@ -1,4 +1,5 @@ import { + and, db, eq, normalizeEmailNotificationSettings, @@ -21,6 +22,12 @@ class TransitionClaimError extends Data.TaggedError("TransitionClaimError")<{ cause: unknown; }> {} +class TransitionReleaseError extends Data.TaggedError( + "TransitionReleaseError" +)<{ + cause: unknown; +}> {} + class AlarmLookupError extends Data.TaggedError("AlarmLookupError")<{ cause: unknown; }> {} @@ -36,6 +43,13 @@ interface LinkedAlarm { id: string; } +interface ClaimedTransition { + kind: "down" | "recovered"; + previousStatus: number | null; +} + +type TransitionNotificationPayload = Parameters[0]; + export interface TransitionResult { alarms_fired: number; transition_kind: "down" | "recovered" | null; @@ -69,6 +83,13 @@ export function countFiredAlarms(deliveryCounts: number[]): number { return deliveryCounts.filter((count) => count > 0).length; } +export function shouldReleaseTransitionClaim( + sendableAlarmCount: number, + firedAlarmCount: number +): boolean { + return sendableAlarmCount > 0 && firedAlarmCount === 0; +} + function buildSiteLabel(schedule: ScheduleData): string { const w = schedule.website; if (w?.name) { @@ -87,6 +108,65 @@ function buildSiteLabel(schedule: ScheduleData): string { } } +function formatCheckedAt(timestamp: number): string { + if (!Number.isFinite(timestamp)) { + return "an unknown time"; + } + const checkedAt = new Date(timestamp); + return Number.isNaN(checkedAt.valueOf()) + ? "an unknown time" + : checkedAt.toISOString(); +} + +function formatCheckError(error: string): string | undefined { + const normalized = error.replaceAll(/[\r\n]+/g, " ").trim(); + if (!normalized) { + return; + } + return normalized.length > 200 ? `${normalized.slice(0, 199)}…` : normalized; +} + +export function buildTransitionNotificationPayload(input: { + dashboardUrl: string; + data: UptimeData; + kind: "down" | "recovered"; + monitorId: string; + siteLabel: string; +}): TransitionNotificationPayload { + const checkedAt = formatCheckedAt(input.data.timestamp); + const error = formatCheckError(input.data.error); + const checkContext = [ + `Checked at ${checkedAt}`, + input.data.http_code > 0 + ? `HTTP ${input.data.http_code}` + : "No HTTP response", + error ? `Reason: ${error}` : null, + ] + .filter((value): value is string => value !== null) + .join(" · "); + + return { + title: + input.kind === "down" + ? `Health check failed: ${input.siteLabel}` + : `Health check passed: ${input.siteLabel}`, + message: + input.kind === "down" + ? `A health check failed for ${input.siteLabel}. ${checkContext}. View details: ${input.dashboardUrl}` + : `A health check passed for ${input.siteLabel} after a previous failed check. Checked at ${checkedAt} · Response time ${input.data.total_ms} ms. View details: ${input.dashboardUrl}`, + priority: input.kind === "down" ? "high" : "normal", + metadata: { + template: "uptime-transition", + monitorId: input.monitorId, + monitorName: input.siteLabel, + url: input.data.url, + kind: input.kind, + httpCode: input.data.http_code, + dashboardUrl: input.dashboardUrl, + }, + }; +} + const AlarmCache = Context.Service>( "AlarmCache" @@ -159,11 +239,30 @@ const claimTransition = (scheduleId: string, currentStatus: number) => .set({ lastNotifiedStatus: currentStatus }) .where(eq(uptimeSchedules.id, scheduleId)); - return kind; + return { kind, previousStatus: row.last } satisfies ClaimedTransition; }), catch: (cause) => new TransitionClaimError({ cause }), }); +const releaseTransitionClaim = (input: { + currentStatus: number; + previousStatus: number | null; + scheduleId: string; +}) => + Effect.tryPromise({ + try: () => + db + .update(uptimeSchedules) + .set({ lastNotifiedStatus: input.previousStatus }) + .where( + and( + eq(uptimeSchedules.id, input.scheduleId), + eq(uptimeSchedules.lastNotifiedStatus, input.currentStatus) + ) + ), + catch: (cause) => new TransitionReleaseError({ cause }), + }); + async function getOrganizationEmailSettings(organizationId: string) { const row = await db.query.organization.findFirst({ where: { id: organizationId }, @@ -277,15 +376,7 @@ const handleTransition = (options: { return NO_TRANSITION; } - if ( - options.previousStatus !== undefined && - resolveTransitionKind(options.previousStatus, options.data.status) === - null - ) { - return NO_TRANSITION; - } - - const kind = yield* claimTransition( + const claim = yield* claimTransition( options.schedule.id, options.data.status ).pipe( @@ -295,9 +386,23 @@ const handleTransition = (options: { }) ); - if (kind === null) { + if (claim === null) { return NO_TRANSITION; } + const { kind } = claim; + const releaseClaim = releaseTransitionClaim({ + currentStatus: options.data.status, + previousStatus: claim.previousStatus, + scheduleId: options.schedule.id, + }).pipe( + Effect.catchTag("TransitionReleaseError", (error) => { + captureError(error.cause, { + error_step: "transition_claim_release", + schedule_id: options.schedule.id, + }); + return Effect.void; + }) + ); const linkedAlarms = yield* lookupLinkedAlarms( options.schedule.id, @@ -305,9 +410,13 @@ const handleTransition = (options: { ).pipe( Effect.catchTag("AlarmLookupError", (e) => { captureError(e.cause, { error_step: "alarm_lookup" }); - return Effect.succeed([] as LinkedAlarm[]); + return Effect.succeed(null); }) ); + if (linkedAlarms === null) { + yield* releaseClaim; + return { alarms_fired: 0, transition_kind: kind }; + } if (linkedAlarms.length === 0) { return { alarms_fired: 0, transition_kind: kind }; @@ -316,41 +425,30 @@ const handleTransition = (options: { const emailSettings = yield* Effect.tryPromise(() => getOrganizationEmailSettings(options.schedule.organizationId) ).pipe( - Effect.orElseSucceed(() => normalizeEmailNotificationSettings(null)) + Effect.catch((error) => { + captureError(error, { + error_step: "organization_email_settings", + organization_id: options.schedule.organizationId, + }); + return Effect.succeed(null); + }) ); const emailsEnabled = - kind === "down" + emailSettings !== null && + (kind === "down" ? emailSettings.uptime.downEmails - : emailSettings.uptime.recoveryEmails; + : emailSettings.uptime.recoveryEmails); const siteLabel = buildSiteLabel(options.schedule); const dashboardUrl = `${config.urls.dashboard}/monitors/${options.schedule.id}`; - const httpInfo = options.data.http_code - ? ` (HTTP ${options.data.http_code})` - : ""; - const errorInfo = options.data.error ? ` - ${options.data.error}` : ""; - - const payload = { - title: - kind === "down" - ? `[DOWN] ${siteLabel} is unreachable` - : `[Recovered] ${siteLabel} is back up`, - message: - kind === "down" - ? `${siteLabel} is down${httpInfo}${errorInfo}. View details: ${dashboardUrl}` - : `${siteLabel} has recovered and is operational again. Response time: ${options.data.total_ms}ms. View details: ${dashboardUrl}`, - priority: kind === "down" ? ("high" as const) : ("normal" as const), - metadata: { - template: "uptime-transition" as const, - monitorId: options.schedule.id, - monitorName: siteLabel, - url: options.data.url, - kind, - httpCode: options.data.http_code, - dashboardUrl, - }, - }; + const payload = buildTransitionNotificationPayload({ + dashboardUrl, + data: options.data, + kind, + monitorId: options.schedule.id, + siteLabel, + }); const sendable = linkedAlarms .map((alarm) => filterUptimeEmailDestinations(alarm, emailsEnabled)) @@ -362,6 +460,9 @@ const handleTransition = (options: { ); const fired = countFiredAlarms(results); + if (shouldReleaseTransitionClaim(sendable.length, fired)) { + yield* releaseClaim; + } return { alarms_fired: fired, transition_kind: kind }; }); diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 0a153433f5..9931f991ab 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -10,6 +10,7 @@ import { verification as verificationTable, } from "@databuddy/db/schema"; import { + AUTH_EMAIL_EXPIRY_SECONDS, DeleteAccountEmail, InvitationEmail, MagicLinkEmail, @@ -27,6 +28,7 @@ import { ratelimit, } from "@databuddy/redis"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { APIError } from "better-auth/api"; import { betterAuth } from "better-auth/minimal"; import { emailOTP, @@ -94,10 +96,10 @@ async function provisionDefaultOrg(input: { function getOrgNameFromUser(userName: string, email: string): string { if (userName?.trim()) { - return `${userName.trim()}'s Workspace`; + return `${userName.trim()}'s Organization`; } const emailPrefix = email.split("@").at(0) ?? "user"; - return `${emailPrefix}'s Workspace`; + return `${emailPrefix}'s Organization`; } function isProduction() { @@ -115,6 +117,83 @@ function shouldRequireEmailVerification() { return isProduction() && !isSelfHosted(); } +type EmailTemplate = Parameters[0]; + +async function enforceAuthEmailRateLimit(input: { + callback: string; + email?: string; + key: string; + limit: number; + windowSeconds: number; +}): Promise { + const { success } = await ratelimit( + input.key, + input.limit, + input.windowSeconds + ); + if (success) { + return; + } + + log.warn({ + service: "auth", + auth_rate_limited: true, + auth_callback: input.callback, + ...(input.email ? { auth_rate_limit_email: input.email } : {}), + }); + throw new APIError("TOO_MANY_REQUESTS", { + message: "Too many email requests. Please try again later.", + }); +} + +async function sendAuthEmail(input: { + subject: string; + template: EmailTemplate; + to: string; +}): Promise { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + log.error({ + service: "auth", + auth_email_delivery_failed: true, + email_provider_error: "RESEND_API_KEY is not configured", + }); + throw new APIError("SERVICE_UNAVAILABLE", { + message: "Email delivery is temporarily unavailable. Please try again.", + }); + } + const [html, text] = await Promise.all([ + render(input.template), + render(input.template, { plainText: true }), + ]); + const resend = new Resend(apiKey); + const result = await resend.emails.send({ + from: config.email.from, + to: input.to, + subject: input.subject, + html, + text, + }); + + if (result.error) { + log.error({ + service: "auth", + auth_email_delivery_failed: true, + email_provider_error: result.error.message, + }); + throw new APIError("INTERNAL_SERVER_ERROR", { + message: "We could not send this email. Please try again.", + }); + } +} + +function formatInvitationRole(role: string | string[]): string { + const roles = Array.isArray(role) ? role : [role]; + return roles + .map((value) => value.replaceAll(/[_-]+/g, " ").toLowerCase()) + .join(", "); +} + const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL ?? ""; function notifySlack( @@ -254,6 +333,7 @@ export const auth = betterAuth({ "/forget-password": { window: 60, max: 3 }, "/magic-link/send": { window: 60, max: 3 }, "/email-otp/send": { window: 60, max: 3 }, + "/organization/invite-member": { window: 3600, max: 5 }, }, }, account: { @@ -366,13 +446,12 @@ export const auth = betterAuth({ user: { deleteUser: { enabled: true, + deleteTokenExpiresIn: AUTH_EMAIL_EXPIRY_SECONDS.accountDeletion, sendDeleteAccountVerification: async ({ user: targetUser, url }) => { - const resend = new Resend(process.env.RESEND_API_KEY as string); - await resend.emails.send({ - from: config.email.from, + await sendAuthEmail({ to: targetUser.email, subject: "[Action required] Confirm account deletion", - html: await render(DeleteAccountEmail({ url })), + template: DeleteAccountEmail({ url }), }); }, beforeDelete: async (userToDelete) => { @@ -428,59 +507,45 @@ export const auth = betterAuth({ maxPasswordLength: 128, autoSignIn: false, requireEmailVerification: shouldRequireEmailVerification(), + resetPasswordTokenExpiresIn: AUTH_EMAIL_EXPIRY_SECONDS.passwordReset, revokeSessionsOnPasswordReset: true, onPasswordReset: async ({ user }: { user: { id: string } }) => { await purgeOutstandingResetTokens(user.id); }, - sendResetPassword: async ({ user, url }: { user: any; url: string }) => { - const { success } = await ratelimit(`reset:${user.email}`, 3, 3600); - if (!success) { - log.warn({ - service: "auth", - auth_rate_limited: true, - auth_callback: "reset_password", - auth_rate_limit_email: user.email, - }); - return; - } + sendResetPassword: async ({ user, url }) => { + await enforceAuthEmailRateLimit({ + callback: "reset_password", + email: user.email, + key: `reset:${user.email}`, + limit: 3, + windowSeconds: 3600, + }); - const resend = new Resend(process.env.RESEND_API_KEY as string); - await resend.emails.send({ - from: config.email.from, + await sendAuthEmail({ to: user.email, subject: "[Action required] Reset your password", - html: await render(ResetPasswordEmail({ url })), + template: ResetPasswordEmail({ url }), }); }, }, emailVerification: { + expiresIn: AUTH_EMAIL_EXPIRY_SECONDS.emailVerification, sendOnSignUp: process.env.NODE_ENV === "production", sendOnSignIn: process.env.NODE_ENV === "production", autoSignInAfterVerification: true, - sendVerificationEmail: async ({ - user, - url, - }: { - user: any; - url: string; - }) => { - const { success } = await ratelimit(`verify:${user.email}`, 3, 900); - if (!success) { - log.warn({ - service: "auth", - auth_rate_limited: true, - auth_callback: "verify_email", - auth_rate_limit_email: user.email, - }); - return; - } + sendVerificationEmail: async ({ user, url }) => { + await enforceAuthEmailRateLimit({ + callback: "verify_email", + email: user.email, + key: `verify:${user.email}`, + limit: 3, + windowSeconds: 900, + }); - const resend = new Resend(process.env.RESEND_API_KEY as string); - await resend.emails.send({ - from: config.email.from, + await sendAuthEmail({ to: user.email, subject: "[Action required] Verify your email to get started", - html: await render(VerificationEmail({ url })), + template: VerificationEmail({ url }), }); }, }, @@ -500,20 +565,15 @@ export const auth = betterAuth({ }, }), emailOTP({ + expiresIn: AUTH_EMAIL_EXPIRY_SECONDS.oneTimeCode, async sendVerificationOTP({ email, otp, type }) { - const { success } = await ratelimit(`otp:${email}`, 3, 900); - if (!success) { - log.warn({ - service: "auth", - auth_rate_limited: true, - auth_callback: "verification_otp", - auth_otp_type: type, - auth_rate_limit_email: email, - }); - return; - } - - const resend = new Resend(process.env.RESEND_API_KEY as string); + await enforceAuthEmailRateLimit({ + callback: `verification_otp_${type}`, + email, + key: `otp:${email}`, + limit: 3, + windowSeconds: 900, + }); let subject = `${otp} is your verification code`; if (type === "sign-in") { @@ -524,38 +584,28 @@ export const auth = betterAuth({ subject = `${otp} — Reset your password`; } - const otpHtml = await render(OtpEmail({ otp })); - resend.emails - .send({ - from: config.email.from, - to: email, - subject, - html: otpHtml, - }) - .catch((error) => { - console.error("Failed to send OTP email:", error); - }); + await sendAuthEmail({ + to: email, + subject, + template: OtpEmail({ otp, type }), + }); }, }), magicLink({ + expiresIn: AUTH_EMAIL_EXPIRY_SECONDS.magicLink, sendMagicLink: async ({ email, url }) => { - const { success } = await ratelimit(`magic:${email}`, 3, 900); - if (!success) { - log.warn({ - service: "auth", - auth_rate_limited: true, - auth_callback: "magic_link", - auth_rate_limit_email: email, - }); - return; - } + await enforceAuthEmailRateLimit({ + callback: "magic_link", + email, + key: `magic:${email}`, + limit: 3, + windowSeconds: 900, + }); - const resend = new Resend(process.env.RESEND_API_KEY as string); - resend.emails.send({ - from: config.email.from, + await sendAuthEmail({ to: email, subject: "Your sign-in link for Databuddy", - html: await render(MagicLinkEmail({ url })), + template: MagicLinkEmail({ url }), }); }, }), @@ -568,6 +618,7 @@ export const auth = betterAuth({ twoFactor(), organization({ creatorRole: "owner", + invitationExpiresIn: AUTH_EMAIL_EXPIRY_SECONDS.invitation, teams: { enabled: false, }, @@ -590,34 +641,17 @@ export const auth = betterAuth({ organization, invitation, }) => { - const { success } = await ratelimit( - `invite:${organization.id}`, - 5, - 3600 - ); - if (!success) { - log.warn({ - service: "auth", - auth_rate_limited: true, - auth_callback: "invitation", - auth_organization_id: organization.id, - }); - return; - } - const invitationLink = `${config.urls.dashboard}/invitations/${invitation.id}`; - const resend = new Resend(process.env.RESEND_API_KEY as string); - await resend.emails.send({ - from: config.email.from, + await sendAuthEmail({ to: email, subject: `${inviter.user.name ?? "Someone"} invited you to join ${organization.name}`, - html: await render( - InvitationEmail({ - inviterName: inviter.user.name ?? "", - organizationName: organization.name, - invitationLink, - }) - ), + template: InvitationEmail({ + inviterName: inviter.user.name ?? "", + organizationName: organization.name, + invitationLink, + recipientEmail: email, + role: formatInvitationRole(invitation.role), + }), }); }, }), diff --git a/packages/email/src/emails/auth-email-copy.test.tsx b/packages/email/src/emails/auth-email-copy.test.tsx new file mode 100644 index 0000000000..22a0859c9f --- /dev/null +++ b/packages/email/src/emails/auth-email-copy.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { render } from "react-email"; +import { AUTH_EMAIL_EXPIRY_LABELS } from "./auth-email-expiry"; +import { DeleteAccountEmail } from "./delete-account-email"; +import { InvitationEmail } from "./invitation-email"; +import { MagicLinkEmail } from "./magic-link-email"; +import { OtpEmail } from "./otp-email"; +import { VerificationEmail } from "./verification-email"; + +const asText = (element: React.ReactElement) => + render(element, { plainText: true }); + +describe("authentication email copy", () => { + test("magic-link and verification copy use the shared expiry labels", async () => { + const magicLink = await asText( + MagicLinkEmail({ url: "https://example.com/magic" }) + ); + const verification = await asText( + VerificationEmail({ url: "https://example.com/verify" }) + ); + + expect(magicLink).toContain( + `expires in ${AUTH_EMAIL_EXPIRY_LABELS.magicLink}` + ); + expect(magicLink).not.toContain("expires in 24 hours"); + expect(verification).toContain( + `expires in ${AUTH_EMAIL_EXPIRY_LABELS.emailVerification}` + ); + }); + + test("OTP copy explains the requested action", async () => { + const signIn = await asText(OtpEmail({ otp: "123456", type: "sign-in" })); + const passwordReset = await asText( + OtpEmail({ otp: "123456", type: "forget-password" }) + ); + + expect(signIn).toContain("Enter this code on the sign-in screen"); + expect(passwordReset).toContain("password reset screen"); + expect(passwordReset).not.toContain("complete your sign-in"); + expect(passwordReset).toContain( + `expires in ${AUTH_EMAIL_EXPIRY_LABELS.oneTimeCode}` + ); + }); + + test("invitation copy names the role and recipient email", async () => { + const text = await asText( + InvitationEmail({ + invitationLink: "https://example.com/invitation", + inviterName: "Ada", + organizationName: "Acme", + recipientEmail: "grace@example.com", + role: "admin", + }) + ); + + expect(text).toContain("as an admin"); + expect(text).toContain("for grace@example.com"); + expect(text).toContain(`expires in ${AUTH_EMAIL_EXPIRY_LABELS.invitation}`); + }); + + test("account deletion copy does not claim organization data is deleted", async () => { + const text = await asText( + DeleteAccountEmail({ url: "https://example.com/delete" }) + ); + + expect(text).not.toContain("all your data"); + expect(text).toContain("transfer ownership before continuing"); + expect(text).toContain("cannot be undone"); + }); +}); diff --git a/packages/email/src/emails/auth-email-expiry.ts b/packages/email/src/emails/auth-email-expiry.ts new file mode 100644 index 0000000000..eb1e82699d --- /dev/null +++ b/packages/email/src/emails/auth-email-expiry.ts @@ -0,0 +1,17 @@ +export const AUTH_EMAIL_EXPIRY_SECONDS = { + accountDeletion: 60 * 60, + emailVerification: 24 * 60 * 60, + invitation: 48 * 60 * 60, + magicLink: 15 * 60, + oneTimeCode: 10 * 60, + passwordReset: 60 * 60, +} as const; + +export const AUTH_EMAIL_EXPIRY_LABELS = { + accountDeletion: "1 hour", + emailVerification: "24 hours", + invitation: "48 hours", + magicLink: "15 minutes", + oneTimeCode: "10 minutes", + passwordReset: "1 hour", +} as const; diff --git a/packages/email/src/emails/delete-account-email.tsx b/packages/email/src/emails/delete-account-email.tsx index 60b4dcc38b..4b0fcbea85 100644 --- a/packages/email/src/emails/delete-account-email.tsx +++ b/packages/email/src/emails/delete-account-email.tsx @@ -1,4 +1,5 @@ import { Heading, Section, Text } from "react-email"; +import { AUTH_EMAIL_EXPIRY_LABELS } from "./auth-email-expiry"; import { emailBrand } from "./email-brand"; import { EmailButton } from "./email-button"; import { EmailLayout } from "./email-layout"; @@ -11,31 +12,32 @@ interface DeleteAccountEmailProps { export const DeleteAccountEmail = ({ url }: DeleteAccountEmailProps) => (
- Confirm Account Deletion + Confirm account deletion - You requested to permanently delete your Databuddy account. This action - is irreversible — all your data, organizations, and connected accounts - will be removed. + Deleting your account removes your Databuddy login and access. Shared + organization data may remain for other members. If you own an + organization, transfer ownership before continuing. This action cannot + be undone.
- Delete My Account + Delete my account
- This link expires in 1 hour. If you didn't request this, you can safely - ignore this email — your account will not be deleted. + This link expires in {AUTH_EMAIL_EXPIRY_LABELS.accountDeletion}. If you + did not request this, ignore the email; your account will not be deleted.
diff --git a/packages/email/src/emails/email-brand.test.ts b/packages/email/src/emails/email-brand.test.ts new file mode 100644 index 0000000000..920157cef5 --- /dev/null +++ b/packages/email/src/emails/email-brand.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { emailBrand } from "./email-brand"; + +function relativeLuminance(hex: string): number { + const channels = [1, 3, 5].map((index) => + Number.parseInt(hex.slice(index, index + 2), 16) + ); + const [red = 0, green = 0, blue = 0] = channels.map((channel) => { + const value = channel / 255; + return value <= 0.039_28 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function contrastRatio(foreground: string, background: string): number { + const foregroundLuminance = relativeLuminance(foreground); + const backgroundLuminance = relativeLuminance(background); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +describe("email brand contrast", () => { + test("link color meets WCAG AA for normal text on email surfaces", () => { + expect(contrastRatio(emailBrand.coral, emailBrand.card)).toBeGreaterThanOrEqual( + 4.5 + ); + expect( + contrastRatio(emailBrand.coral, emailBrand.background) + ).toBeGreaterThanOrEqual(4.5); + }); + + test("uses an email-client-safe raster logo", () => { + expect(emailBrand.primaryLogoUrl).toEndWith(".png"); + }); +}); diff --git a/packages/email/src/emails/email-brand.ts b/packages/email/src/emails/email-brand.ts index 564b042cd7..fac4d6e016 100644 --- a/packages/email/src/emails/email-brand.ts +++ b/packages/email/src/emails/email-brand.ts @@ -7,8 +7,8 @@ export const emailBrand = { muted: "#9599a6", amber: "#E3A514", onAmber: "#27282D", - coral: "#B74677", - primaryLogoUrl: "https://www.databuddy.cc/brand/primary-logo/white.svg", + coral: "#F08AB7", + primaryLogoUrl: "https://www.databuddy.cc/web-app-manifest-192x192.png", primaryLogoHeightPx: 40, - primaryLogoWidthPx: 170, + primaryLogoWidthPx: 40, } as const; diff --git a/packages/email/src/emails/index.ts b/packages/email/src/emails/index.ts index 6613ef6c0f..741154373c 100644 --- a/packages/email/src/emails/index.ts +++ b/packages/email/src/emails/index.ts @@ -1,4 +1,5 @@ export { render } from "react-email"; +export * from "./auth-email-expiry"; export * from "./blocked-traffic-alert-email"; export * from "./delete-account-email"; export { emailBrand } from "./email-brand"; @@ -15,5 +16,6 @@ export * from "./reset-password-email"; export { emailTailwindConfig } from "./tailwind.config"; export * from "./uptime-alert-email"; export * from "./usage-alert-email"; +export * from "./usage-email-utils"; export * from "./usage-limit-email"; export * from "./verification-email"; diff --git a/packages/email/src/emails/invitation-email.tsx b/packages/email/src/emails/invitation-email.tsx index 1be04fe79d..019525b6a9 100644 --- a/packages/email/src/emails/invitation-email.tsx +++ b/packages/email/src/emails/invitation-email.tsx @@ -1,53 +1,67 @@ import { Heading, Section, Text } from "react-email"; +import { AUTH_EMAIL_EXPIRY_LABELS } from "./auth-email-expiry"; import { emailBrand } from "./email-brand"; import { EmailButton } from "./email-button"; import { EmailLayout } from "./email-layout"; import { EmailLinkFallback } from "./email-link-fallback"; import { EmailNote } from "./email-note"; +const VOWEL_PREFIX_PATTERN = /^[aeiou]/i; + interface InvitationEmailProps { invitationLink: string; inviterName: string; organizationName: string; + recipientEmail: string; + role: string; } export const InvitationEmail = ({ inviterName, organizationName, invitationLink, + recipientEmail, + role, }: InvitationEmailProps) => { - const org = organizationName || "a team"; - const name = inviterName || "A team member"; + const org = organizationName || "a Databuddy organization"; + const inviter = inviterName || "A team member"; + const roleLabel = role || "member"; + const roleWithArticle = VOWEL_PREFIX_PATTERN.test(roleLabel) + ? `an ${roleLabel}` + : `a ${roleLabel}`; return ( - +
- You're Invited! + Join {org} - {name} + {inviter} {" "} - has invited you to join{" "} - - {org} - {" "} - on Databuddy. + invited you to join {org} on Databuddy as {roleWithArticle}. + + + This invitation is for {recipientEmail}. Sign in or create an account + with that address before accepting it.
- Accept Invitation + Review invitation
- This invitation expires in 48 hours. If you weren't expecting this, you - can safely ignore this email. + This invitation expires in {AUTH_EMAIL_EXPIRY_LABELS.invitation}. If you + did not expect it, you can ignore this email.
@@ -58,6 +72,8 @@ InvitationEmail.PreviewProps = { invitationLink: "https://app.databuddy.cc/invite/abc123", inviterName: "Ada Lovelace", organizationName: "Acme Inc", + recipientEmail: "grace@example.com", + role: "admin", } satisfies InvitationEmailProps; export default InvitationEmail; diff --git a/packages/email/src/emails/magic-link-email.tsx b/packages/email/src/emails/magic-link-email.tsx index 07dbc41faa..a82062b2e7 100644 --- a/packages/email/src/emails/magic-link-email.tsx +++ b/packages/email/src/emails/magic-link-email.tsx @@ -1,4 +1,5 @@ import { Heading, Section, Text } from "react-email"; +import { AUTH_EMAIL_EXPIRY_LABELS } from "./auth-email-expiry"; import { emailBrand } from "./email-brand"; import { EmailButton } from "./email-button"; import { EmailLayout } from "./email-layout"; @@ -11,7 +12,7 @@ interface MagicLinkEmailProps { export const MagicLinkEmail = ({ url }: MagicLinkEmailProps) => (
@@ -19,22 +20,22 @@ export const MagicLinkEmail = ({ url }: MagicLinkEmailProps) => ( className="m-0 mb-3 font-semibold text-xl tracking-tight" style={{ color: emailBrand.foreground }} > - Your Magic Link + Your sign-in link - Click the button below to securely sign in to your account. No password - needed. + Use the button below to sign in to your Databuddy account. You do not + need a password for this sign-in.
- Sign In to Databuddy + Sign in to Databuddy
- This link expires in 24 hours and can only be used once. If you didn't - request this, you can safely ignore this email. + This link expires in {AUTH_EMAIL_EXPIRY_LABELS.magicLink} and can only be + used once. If you did not request it, you can ignore this email.
diff --git a/packages/email/src/emails/otp-email.tsx b/packages/email/src/emails/otp-email.tsx index 40096708d8..706fc451f3 100644 --- a/packages/email/src/emails/otp-email.tsx +++ b/packages/email/src/emails/otp-email.tsx @@ -1,55 +1,98 @@ import { Heading, Section, Text } from "react-email"; +import { AUTH_EMAIL_EXPIRY_LABELS } from "./auth-email-expiry"; import { emailBrand } from "./email-brand"; import { EmailLayout } from "./email-layout"; import { EmailNote } from "./email-note"; +export type OtpPurpose = + | "sign-in" + | "email-verification" + | "forget-password" + | "change-email"; + interface OtpEmailProps { otp: string; + type: OtpPurpose; } -export const OtpEmail = ({ otp }: OtpEmailProps) => ( - -
- - Your One-Time Code - - - Enter this code to complete your sign-in. Do not share this code with - anyone. - -
-
- - {otp} - -
- - This code expires in 10 minutes. If you didn't request this code, someone - may be trying to access your account. Please secure your account - immediately. - -
-); +const PURPOSE_COPY: Record< + OtpPurpose, + { heading: string; instruction: string; preview: string; tagline: string } +> = { + "sign-in": { + heading: "Sign in to Databuddy", + instruction: + "Enter this code on the sign-in screen to access your account.", + preview: "Use this one-time code to sign in to Databuddy.", + tagline: "Sign-in code", + }, + "email-verification": { + heading: "Verify your email", + instruction: "Enter this code to verify your email address.", + preview: "Use this one-time code to verify your email address.", + tagline: "Email verification", + }, + "forget-password": { + heading: "Reset your password", + instruction: "Enter this code on the password reset screen to continue.", + preview: "Use this one-time code to reset your Databuddy password.", + tagline: "Password reset", + }, + "change-email": { + heading: "Confirm your email change", + instruction: "Enter this code to confirm your new email address.", + preview: "Use this one-time code to confirm your email change.", + tagline: "Email change", + }, +}; + +export const OtpEmail = ({ otp, type }: OtpEmailProps) => { + const copy = PURPOSE_COPY[type]; + + return ( + +
+ + {copy.heading} + + + {copy.instruction} Do not share this code with anyone. + +
+
+ + {otp} + +
+ + This code expires in {AUTH_EMAIL_EXPIRY_LABELS.oneTimeCode}. If you did + not request it, you can ignore this email. If this keeps happening, + change your password or contact support. + +
+ ); +}; OtpEmail.PreviewProps = { otp: "482913", + type: "sign-in", } satisfies OtpEmailProps; export default OtpEmail; diff --git a/packages/email/src/emails/reset-password-email.tsx b/packages/email/src/emails/reset-password-email.tsx index 7af77e8447..1138c248da 100644 --- a/packages/email/src/emails/reset-password-email.tsx +++ b/packages/email/src/emails/reset-password-email.tsx @@ -1,4 +1,5 @@ import { Heading, Section, Text } from "react-email"; +import { AUTH_EMAIL_EXPIRY_LABELS } from "./auth-email-expiry"; import { emailBrand } from "./email-brand"; import { EmailButton } from "./email-button"; import { EmailLayout } from "./email-layout"; @@ -11,7 +12,7 @@ interface ResetPasswordEmailProps { export const ResetPasswordEmail = ({ url }: ResetPasswordEmailProps) => (
@@ -19,7 +20,7 @@ export const ResetPasswordEmail = ({ url }: ResetPasswordEmailProps) => ( className="m-0 mb-3 font-semibold text-xl tracking-tight" style={{ color: emailBrand.foreground }} > - Reset Your Password + Reset your password (
- Reset Password + Reset password
- This link expires in 1 hour for security reasons. If you didn't request a - password reset, please ignore this email or contact support. + This link expires in {AUTH_EMAIL_EXPIRY_LABELS.passwordReset}. If you did + not request a password reset, you can ignore this email.
diff --git a/packages/email/src/emails/uptime-alert-email.test.tsx b/packages/email/src/emails/uptime-alert-email.test.tsx index a23a3c82dc..048beba47d 100644 --- a/packages/email/src/emails/uptime-alert-email.test.tsx +++ b/packages/email/src/emails/uptime-alert-email.test.tsx @@ -212,8 +212,8 @@ describe("UptimeAlertEmail — field fallbacks and optional props", () => { test("defaults to kind='down' when kind is missing", async () => { const html = await render({ url: "https://example.com" }); - expect(html).toContain("is down"); - expect(html).not.toContain("back up"); + expect(html).toContain("Health check failed for"); + expect(html).not.toContain("Health check passed for"); }); test("dashboardUrl absent → no dashboard button", async () => { @@ -239,7 +239,7 @@ describe("UptimeAlertEmail — down vs recovered variants", () => { siteLabel: "acme.com", url: "https://acme.com", }); - expect(html).toContain("acme.com is down"); + expect(html).toContain("Health check failed for acme.com"); expect(html).toContain("could not reach this URL"); expect(html.toLowerCase()).toContain("#dc2626"); }); @@ -250,8 +250,8 @@ describe("UptimeAlertEmail — down vs recovered variants", () => { siteLabel: "acme.com", url: "https://acme.com", }); - expect(html).toContain("acme.com is back up"); - expect(html).toContain("latest health check succeeded"); + expect(html).toContain("Health check passed for acme.com"); + expect(html).toContain("latest health check passed"); expect(html.toLowerCase()).toContain("#22c55e"); }); @@ -421,7 +421,7 @@ describe("UptimeAlertEmail — SSL row", () => { describe("UptimeAlertEmail — preview text", () => { test.each([ ["down", "failed health check — HTTP timeout"], - ["recovered", "is responding normally again"], + ["recovered", "passed its latest health check"], ] as const)("%s preview matches current copy", async (kind, copy) => { const html = await render({ kind, url: "https://example.com" }); expect(html).toContain(copy); diff --git a/packages/email/src/emails/uptime-alert-email.tsx b/packages/email/src/emails/uptime-alert-email.tsx index a52fd83310..78fd1286b3 100644 --- a/packages/email/src/emails/uptime-alert-email.tsx +++ b/packages/email/src/emails/uptime-alert-email.tsx @@ -123,16 +123,18 @@ export const UptimeAlertEmail = ({ preview={ isDown ? `${safe} failed health check — HTTP ${httpCode || "timeout"}` - : `${safe} is responding normally again` + : `${safe} passed its latest health check` } - tagline={isDown ? "Uptime alert" : "Site recovered"} + tagline={isDown ? "Health check failed" : "Health check passed"} >
- {isDown ? `${safe} is down` : `${safe} is back up`} + {isDown + ? `Health check failed for ${safe}` + : `Health check passed for ${safe}`} {isDown ? "We could not reach this URL during the latest health check." - : "The latest health check succeeded. Your site responded normally."} + : "The latest health check passed after a previous failed check."}
diff --git a/packages/email/src/emails/usage-alert-email.tsx b/packages/email/src/emails/usage-alert-email.tsx index b20d40fbb0..1432d56b33 100644 --- a/packages/email/src/emails/usage-alert-email.tsx +++ b/packages/email/src/emails/usage-alert-email.tsx @@ -2,57 +2,80 @@ import { Heading, Link, Section, Text } from "react-email"; import { emailBrand } from "./email-brand"; import { EmailButton } from "./email-button"; import { EmailLayout } from "./email-layout"; +import { + formatResetDate, + formatUsageNumber, + formatUsagePercentage, +} from "./usage-email-utils"; -interface UsageAlertEmailProps { - alertName?: string; - featureName?: string; - threshold?: number; - thresholdType?: "usage" | "usage_percentage_threshold"; - userName?: string; +export interface UsageAlertEmailProps { + featureDescription: string; + featureName: string; + limitAmount: number; + nextResetAt?: number | null; + organizationName?: string; + overageAllowed: boolean; + pausedActivity: string; + remainingAmount: number; + usageAmount: number; + usageUnit: string; } export const UsageAlertEmail = ({ - featureName = "Events", - threshold = 80, - thresholdType = "usage_percentage_threshold", - alertName, - userName, + featureDescription, + featureName, + limitAmount, + nextResetAt, + organizationName, + overageAllowed, + pausedActivity, + remainingAmount, + usageAmount, + usageUnit, }: UsageAlertEmailProps) => { - const greeting = userName ? `Hi ${userName},` : "Hi there,"; - const isPercentage = thresholdType === "usage_percentage_threshold"; - const thresholdLabel = isPercentage - ? `${threshold}%` - : threshold.toLocaleString(); - const headingText = alertName ?? `${thresholdLabel} of ${featureName} used`; + const usage = formatUsageNumber(usageAmount); + const limit = formatUsageNumber(limitAmount); + const remaining = formatUsageNumber(Math.max(0, remainingAmount)); + const percentage = formatUsagePercentage(usageAmount, limitAmount); + const resetDate = formatResetDate(nextResetAt); + const context = organizationName ? ` for ${organizationName}` : ""; return (
- {headingText} + {featureName} is at {percentage}
- {greeting} + You have used {usage} of {limit} {usageUnit} + {context} this billing period. {remaining} remain. - {isPercentage - ? `You've used ${thresholdLabel} of your ${featureName.toLowerCase()} allowance this billing period. Consider upgrading your plan to avoid hitting your limit.` - : `Your ${featureName.toLowerCase()} usage has crossed ${thresholdLabel} this billing period. Consider upgrading your plan if you need more capacity.`} + {featureDescription} + + + {overageAllowed + ? "Your billing settings allow usage beyond the included allowance. Additional usage may be billed according to your plan." + : `If the remaining allowance reaches zero, access to ${pausedActivity} will pause until the allowance resets or the plan is changed.`} + {resetDate ? ` The current allowance resets ${resetDate} UTC.` : ""}
@@ -67,19 +90,22 @@ export const UsageAlertEmail = ({ className="m-0 mb-1 text-center text-xs uppercase tracking-wider" style={{ color: emailBrand.muted }} > - Alert Threshold + Current usage - {thresholdLabel} + {usage}{" "} + + / {limit} {usageUnit} +
- View Plans + Review usage and plans
@@ -95,6 +121,13 @@ export const UsageAlertEmail = ({ > documentation + , or manage these emails in your{" "} + + notification settings + . @@ -103,10 +136,17 @@ export const UsageAlertEmail = ({ }; UsageAlertEmail.PreviewProps = { - featureName: "Events", - threshold: 80, - thresholdType: "usage_percentage_threshold", - userName: "Ada", + featureDescription: + "This allowance powers Databunny questions and AI analysis. More complex work uses more of it.", + featureName: "Databunny usage", + limitAmount: 350, + nextResetAt: Date.UTC(2026, 7, 1), + organizationName: "Acme Inc", + overageAllowed: false, + pausedActivity: "Databunny questions and AI analysis", + remainingAmount: 70, + usageAmount: 280, + usageUnit: "allowance units", } satisfies UsageAlertEmailProps; export default UsageAlertEmail; diff --git a/packages/email/src/emails/usage-email-copy.test.tsx b/packages/email/src/emails/usage-email-copy.test.tsx new file mode 100644 index 0000000000..80d842ebda --- /dev/null +++ b/packages/email/src/emails/usage-email-copy.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { render } from "react-email"; +import { UsageAlertEmail } from "./usage-alert-email"; +import { UsageLimitEmail } from "./usage-limit-email"; + +const FEATURE_COPY = { + featureDescription: + "This allowance powers Databunny questions and AI analysis. More complex work uses more of it.", + featureName: "Databunny usage", + limitAmount: 350, + nextResetAt: Date.UTC(2026, 7, 1), + organizationName: "Acme", + overageAllowed: false, + pausedActivity: "Databunny questions and AI analysis", + remainingAmount: 62, + usageAmount: 288, + usageUnit: "allowance units", +} as const; + +describe("billing usage email copy", () => { + test("explains Databunny usage with real values and no owner greeting", async () => { + const text = await render(UsageAlertEmail(FEATURE_COPY), { + plainText: true, + }); + + expect(text).toContain("288 of 350 allowance units"); + expect(text).toContain("62 remain"); + expect(text).toContain("powers Databunny questions and AI analysis"); + expect(text).toContain("More complex work uses more of it"); + expect(text).not.toContain("agent credits"); + expect(text).not.toContain("Hi "); + }); + + test("hard-limit copy reflects the supplied availability instead of inventing grace", async () => { + const text = await render( + UsageLimitEmail({ + ...FEATURE_COPY, + isAvailable: false, + limitType: "spend_limit", + remainingAmount: 0, + usageAmount: 350, + }), + { plainText: true } + ); + + expect(text).toContain( + "Access to Databunny questions and AI analysis is currently paused" + ); + expect(text).toContain("350 of 350 allowance units"); + expect(text).not.toContain("1.5x"); + expect(text).not.toContain("10,000"); + }); +}); diff --git a/packages/email/src/emails/usage-email-utils.ts b/packages/email/src/emails/usage-email-utils.ts new file mode 100644 index 0000000000..2697ab1807 --- /dev/null +++ b/packages/email/src/emails/usage-email-utils.ts @@ -0,0 +1,27 @@ +const usageNumberFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, +}); + +const resetDateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", +}); + +export function formatUsageNumber(value: number): string { + return usageNumberFormatter.format(value); +} + +export function formatUsagePercentage(usage: number, limit: number): string { + if (!(Number.isFinite(usage) && Number.isFinite(limit)) || limit <= 0) { + return "—"; + } + return `${Math.round((usage / limit) * 100)}%`; +} + +export function formatResetDate(timestamp?: number | null): string | undefined { + if (timestamp == null || !Number.isFinite(timestamp)) { + return; + } + return resetDateFormatter.format(new Date(timestamp)); +} diff --git a/packages/email/src/emails/usage-limit-email.tsx b/packages/email/src/emails/usage-limit-email.tsx index e09caebf44..064a508219 100644 --- a/packages/email/src/emails/usage-limit-email.tsx +++ b/packages/email/src/emails/usage-limit-email.tsx @@ -2,69 +2,89 @@ import { Heading, Link, Section, Text } from "react-email"; import { emailBrand } from "./email-brand"; import { EmailButton } from "./email-button"; import { EmailLayout } from "./email-layout"; +import { formatResetDate, formatUsageNumber } from "./usage-email-utils"; -interface UsageLimitEmailProps { - featureName?: string; - limitAmount?: number; - thresholdType?: "limit_reached" | "allowance_used"; - usageAmount?: number; - userName?: string; -} +export type UsageLimitType = "included" | "max_purchase" | "spend_limit"; -function formatNumber(num: number): string { - if (num >= 1_000_000) { - return `${(num / 1_000_000).toFixed(1)}M`; - } - if (num >= 1000) { - return `${(num / 1000).toFixed(1)}K`; - } - return num.toLocaleString(); +export interface UsageLimitEmailProps { + featureDescription: string; + featureName: string; + isAvailable: boolean; + limitAmount: number; + limitType: UsageLimitType; + nextResetAt?: number | null; + organizationName?: string; + overageAllowed: boolean; + pausedActivity: string; + remainingAmount: number; + usageAmount: number; + usageUnit: string; } +const LIMIT_HEADINGS: Record = { + included: "Included allowance used", + max_purchase: "Top-up limit reached", + spend_limit: "Spending limit reached", +}; + export const UsageLimitEmail = ({ - featureName = "Events", - usageAmount = 10_000, - limitAmount = 10_000, - userName, - thresholdType = "limit_reached", + featureDescription, + featureName, + isAvailable, + limitAmount, + limitType, + nextResetAt, + organizationName, + overageAllowed, + pausedActivity, + remainingAmount, + usageAmount, + usageUnit, }: UsageLimitEmailProps) => { - const greeting = userName ? `Hi ${userName},` : "Hi there,"; - const isLimitReached = thresholdType === "limit_reached"; - const usageFormatted = formatNumber(usageAmount); - const limitFormatted = formatNumber(limitAmount); - const graceFormatted = formatNumber(Math.floor(limitAmount * 1.5)); + const usage = formatUsageNumber(usageAmount); + const limit = formatUsageNumber(limitAmount); + const remaining = formatUsageNumber(Math.max(0, remainingAmount)); + const resetDate = formatResetDate(nextResetAt); + const context = organizationName ? ` for ${organizationName}` : ""; + const availability = isAvailable ? "can continue" : "is paused"; return (
- {isLimitReached - ? `${featureName} Limit Reached` - : `${featureName} Allowance Used`} + {featureName}: {LIMIT_HEADINGS[limitType]}
+ Current usage{context} is {usage} of {limit} {usageUnit}, with{" "} + {remaining} remaining. + + - {greeting} + {featureDescription} - You've used all {limitFormatted} of your included{" "} - {featureName.toLowerCase()} this billing period. You can continue up - to {graceFormatted} (1.5x) before tracking is paused. To avoid - interruption, consider upgrading your plan. + {isAvailable + ? overageAllowed + ? `Access to ${pausedActivity} can continue. Additional usage may be billed according to your plan.` + : `Access to ${pausedActivity} can continue with the remaining allowance shown above.` + : `Access to ${pausedActivity} is currently paused. Change the billing limit or plan to resume it${resetDate ? `, or wait until the allowance resets ${resetDate} UTC` : ""}.`}
@@ -79,22 +99,22 @@ export const UsageLimitEmail = ({ className="m-0 mb-1 text-center text-xs uppercase tracking-wider" style={{ color: emailBrand.muted }} > - Current Usage + Current usage - {usageFormatted}{" "} + {usage}{" "} - / {limitFormatted} + / {limit} {usageUnit}
- View Plans + Review billing settings
@@ -110,6 +130,13 @@ export const UsageLimitEmail = ({ > documentation + , or manage these emails in your{" "} + + notification settings + . @@ -118,11 +145,19 @@ export const UsageLimitEmail = ({ }; UsageLimitEmail.PreviewProps = { - featureName: "Events", - limitAmount: 10_000, - thresholdType: "limit_reached", - usageAmount: 10_000, - userName: "Ada", + featureDescription: + "This allowance powers Databunny questions and AI analysis. More complex work uses more of it.", + featureName: "Databunny usage", + isAvailable: false, + limitAmount: 350, + limitType: "included", + nextResetAt: Date.UTC(2026, 7, 1), + organizationName: "Acme Inc", + overageAllowed: false, + pausedActivity: "Databunny questions and AI analysis", + remainingAmount: 0, + usageAmount: 350, + usageUnit: "allowance units", } satisfies UsageLimitEmailProps; export default UsageLimitEmail; diff --git a/packages/email/src/emails/verification-email.tsx b/packages/email/src/emails/verification-email.tsx index 1d375cf4b7..69dd0f7e2a 100644 --- a/packages/email/src/emails/verification-email.tsx +++ b/packages/email/src/emails/verification-email.tsx @@ -1,4 +1,5 @@ import { Heading, Section, Text } from "react-email"; +import { AUTH_EMAIL_EXPIRY_LABELS } from "./auth-email-expiry"; import { emailBrand } from "./email-brand"; import { EmailButton } from "./email-button"; import { EmailLayout } from "./email-layout"; @@ -11,7 +12,7 @@ interface VerificationEmailProps { export const VerificationEmail = ({ url }: VerificationEmailProps) => (
@@ -19,7 +20,7 @@ export const VerificationEmail = ({ url }: VerificationEmailProps) => ( className="m-0 mb-3 font-semibold text-xl tracking-tight" style={{ color: emailBrand.foreground }} > - Verify Your Email + Verify your email (
- Verify Email Address + Verify email address
- This link expires in 24 hours. If you didn't create an account, you can - safely ignore this email. + This link expires in {AUTH_EMAIL_EXPIRY_LABELS.emailVerification}. If you + did not create an account, you can ignore this email.
diff --git a/packages/notifications/src/__tests__/alarm-config.test.ts b/packages/notifications/src/__tests__/alarm-config.test.ts index 77143880f0..1cfc3fd79a 100644 --- a/packages/notifications/src/__tests__/alarm-config.test.ts +++ b/packages/notifications/src/__tests__/alarm-config.test.ts @@ -3,6 +3,7 @@ import { buildAlarmNotificationConfig, buildAlarmNotificationTargets, } from "../alarm-config"; +import { NotificationClient } from "../client"; describe("buildAlarmNotificationTargets", () => { test("keeps same-channel destinations as separate delivery targets", () => { @@ -39,6 +40,43 @@ describe("buildAlarmNotificationTargets", () => { headers: { "X-Alarm": "keep-me" }, }); }); + + test("reports email delivery as failed when Resend is not configured", async () => { + const previousApiKey = process.env.RESEND_API_KEY; + delete process.env.RESEND_API_KEY; + try { + const [target] = buildAlarmNotificationTargets([ + { + type: "email", + identifier: "recipient@example.com", + config: {}, + }, + ]); + expect(target).toBeDefined(); + if (!target) { + return; + } + + const results = await new NotificationClient(target.clientConfig).send( + { title: "Test alert", message: "Test delivery" }, + { channels: [target.channel] } + ); + + expect(results).toEqual([ + { + success: false, + channel: "email", + error: "Email delivery is not configured", + }, + ]); + } finally { + if (previousApiKey === undefined) { + delete process.env.RESEND_API_KEY; + } else { + process.env.RESEND_API_KEY = previousApiKey; + } + } + }); }); describe("buildAlarmNotificationConfig", () => { diff --git a/packages/notifications/src/__tests__/providers/email.test.ts b/packages/notifications/src/__tests__/providers/email.test.ts new file mode 100644 index 0000000000..43c9f69cb5 --- /dev/null +++ b/packages/notifications/src/__tests__/providers/email.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { EmailPayload } from "../../types"; +import { EmailProvider } from "../../providers/email"; + +describe("EmailProvider", () => { + test("builds plain text and hides internal metadata from recipients", async () => { + let delivered: EmailPayload | undefined; + const sendEmailAction = mock(async (payload: EmailPayload) => { + delivered = payload; + }); + const provider = new EmailProvider({ + defaultTo: "recipient@example.com", + sendEmailAction, + }); + + const result = await provider.send({ + title: "Site alert", + message: "The site is unavailable.", + metadata: { + dashboardUrl: "https://app.databuddy.cc/monitors/1", + monitorId: "internal-monitor-id", + template: "uptime", + zScore: 9.42, + }, + }); + + expect(result).toEqual({ success: true, channel: "email" }); + expect(delivered?.text).toContain("Dashboard Url:"); + expect(delivered?.text).not.toContain("internal-monitor-id"); + expect(delivered?.text).not.toContain("Template:"); + expect(delivered?.text).not.toContain("Z score:"); + }); + + test("returns a failed channel result when delivery throws", async () => { + const provider = new EmailProvider({ + defaultTo: "recipient@example.com", + sendEmailAction: async () => { + throw new Error("Email delivery failed: provider unavailable"); + }, + }); + + const result = await provider.send({ + title: "Site alert", + message: "The site is unavailable.", + }); + + expect(result).toEqual({ + success: false, + channel: "email", + error: "Email delivery failed: provider unavailable", + }); + }); +}); diff --git a/packages/notifications/src/__tests__/providers/slack.test.ts b/packages/notifications/src/__tests__/providers/slack.test.ts new file mode 100644 index 0000000000..e17170b26f --- /dev/null +++ b/packages/notifications/src/__tests__/providers/slack.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { buildSlackBlocks } from "../../providers/slack"; + +describe("buildSlackBlocks", () => { + test("splits metadata into Slack-safe sections and hides internal fields", () => { + const metadata = Object.fromEntries( + Array.from({ length: 12 }, (_, index) => [`field${index}`, index]) + ); + const blocks = buildSlackBlocks({ + title: "Anomaly detected", + message: "Traffic changed.", + metadata: { + ...metadata, + alarmId: "internal-alarm-id", + template: "anomaly", + zScore: 7.1, + }, + }); + + const fieldSections = blocks.filter((block) => block.fields); + expect(fieldSections).toHaveLength(2); + expect(fieldSections[0]?.fields).toHaveLength(10); + expect(fieldSections[1]?.fields).toHaveLength(2); + expect(JSON.stringify(blocks)).not.toContain("internal-alarm-id"); + expect(JSON.stringify(blocks)).not.toContain("zScore"); + }); + + test("bounds header and message length before calling Slack", () => { + const blocks = buildSlackBlocks({ + title: "T".repeat(200), + message: "M".repeat(4000), + }); + + expect(blocks[0]?.text?.text.length).toBe(150); + expect(blocks[1]?.text?.text.length).toBe(2900); + }); +}); diff --git a/packages/notifications/src/__tests__/templates/uptime.test.ts b/packages/notifications/src/__tests__/templates/uptime.test.ts index ea535ba027..1353520c45 100644 --- a/packages/notifications/src/__tests__/templates/uptime.test.ts +++ b/packages/notifications/src/__tests__/templates/uptime.test.ts @@ -24,7 +24,7 @@ describe("buildUptimeNotificationPayload", () => { const result = buildUptimeNotificationPayload( makeInput({ kind: "down" }) ); - expect(result.title).toBe("Uptime: Acme Corp is down"); + expect(result.title).toBe("Health check failed: Acme Corp"); expect(result.priority).toBe("urgent"); }); @@ -32,7 +32,7 @@ describe("buildUptimeNotificationPayload", () => { const result = buildUptimeNotificationPayload( makeInput({ kind: "recovered" }) ); - expect(result.title).toBe("Uptime: Acme Corp is back up"); + expect(result.title).toBe("Health check passed: Acme Corp"); expect(result.priority).toBe("normal"); }); }); diff --git a/packages/notifications/src/alarm-config.ts b/packages/notifications/src/alarm-config.ts index c4ee36b313..e27d386f83 100644 --- a/packages/notifications/src/alarm-config.ts +++ b/packages/notifications/src/alarm-config.ts @@ -127,15 +127,21 @@ export function buildAlarmNotificationTargets( const { Resend } = await import("resend"); const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { - return; + throw new Error("Email delivery is not configured"); } const resend = new Resend(apiKey); - await resend.emails.send({ + const result = await resend.emails.send({ from: payload.from || "Databuddy ", to: Array.isArray(payload.to) ? payload.to : [payload.to], subject: payload.subject, html: payload.html || payload.text || "", + ...(payload.text ? { text: payload.text } : {}), }); + if (result.error) { + throw new Error( + `Email delivery failed: ${result.error.message}` + ); + } }, }, }, diff --git a/packages/notifications/src/client.ts b/packages/notifications/src/client.ts index 8fd48ae24e..551d4c6df2 100644 --- a/packages/notifications/src/client.ts +++ b/packages/notifications/src/client.ts @@ -95,13 +95,21 @@ export class NotificationClient { }) ); - return results.map((result, index) => { + return channels.map((channel, index) => { + const result = results[index]; + if (!result) { + return { + success: false, + channel, + error: "Notification provider returned no result", + } satisfies NotificationResult; + } if (result.status === "fulfilled") { return result.value; } return { success: false, - channel: channels[index], + channel, error: result.reason instanceof Error ? result.reason.message diff --git a/packages/notifications/src/providers/email.ts b/packages/notifications/src/providers/email.ts index ec986fd281..e28b4e5e1d 100644 --- a/packages/notifications/src/providers/email.ts +++ b/packages/notifications/src/providers/email.ts @@ -5,6 +5,8 @@ import type { } from "../types"; import { BaseProvider } from "./base"; +const FIRST_CHARACTER_PATTERN = /^./; + export interface EmailProviderConfig { defaultTo?: string | string[]; from?: string; @@ -23,6 +25,22 @@ function escapeHtml(str: string): string { .replaceAll("'", "'"); } +function isUserFacingMetadata(key: string): boolean { + return !( + key === "to" || + key === "template" || + key === "zScore" || + key.endsWith("Id") + ); +} + +function formatMetadataLabel(key: string): string { + return key + .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") + .replaceAll(/[_-]+/g, " ") + .replace(FIRST_CHARACTER_PATTERN, (character) => character.toUpperCase()); +} + export class EmailProvider extends BaseProvider { private readonly sendEmailAction: (payload: EmailPayload) => Promise; private readonly defaultTo?: string | string[]; @@ -73,14 +91,16 @@ export class EmailProvider extends BaseProvider { } const metadataEntries = payload.metadata - ? Object.entries(payload.metadata).filter(([key]) => key !== "to") + ? Object.entries(payload.metadata).filter(([key]) => + isUserFacingMetadata(key) + ) : []; const sanitize = (s: string) => s.replaceAll(/[\r\n]/g, " "); const metadataText = metadataEntries.length > 0 - ? `\n\n${metadataEntries.map(([key, value]) => `${sanitize(key)}: ${sanitize(String(value))}`).join("\n")}` + ? `\n\n${metadataEntries.map(([key, value]) => `${sanitize(formatMetadataLabel(key))}: ${sanitize(String(value))}`).join("\n")}` : ""; const text = `${payload.message}${metadataText}`; @@ -90,7 +110,7 @@ export class EmailProvider extends BaseProvider { ? `${metadataEntries .map( ([key, value]) => - `` + `` ) .join("")}
${escapeHtml(key)}${escapeHtml(String(value))}
${escapeHtml(formatMetadataLabel(key))}${escapeHtml(String(value))}
` : ""; diff --git a/packages/notifications/src/providers/slack.ts b/packages/notifications/src/providers/slack.ts index 9d0a28b752..e8abb4114e 100644 --- a/packages/notifications/src/providers/slack.ts +++ b/packages/notifications/src/providers/slack.ts @@ -5,6 +5,92 @@ import type { } from "../types"; import { BaseProvider } from "./base"; +const MAX_HEADER_LENGTH = 150; +const MAX_MESSAGE_LENGTH = 2900; +const MAX_FIELD_LENGTH = 1900; +const MAX_FIELDS_PER_SECTION = 10; +const FIRST_CHARACTER_PATTERN = /^./; + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 1)}…`; +} + +function isUserFacingMetadata(key: string): boolean { + return !( + key === "to" || + key === "template" || + key === "zScore" || + key.endsWith("Id") + ); +} + +function formatMetadataLabel(key: string): string { + return key + .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") + .replaceAll(/[_-]+/g, " ") + .replace(FIRST_CHARACTER_PATTERN, (character) => character.toUpperCase()); +} + +export function buildSlackBlocks( + payload: NotificationPayload +): NonNullable { + const blocks: NonNullable = [ + { + type: "header", + text: { + type: "plain_text", + text: truncate(payload.title, MAX_HEADER_LENGTH), + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: truncate(payload.message, MAX_MESSAGE_LENGTH), + }, + }, + ]; + + const metadataFields = payload.metadata + ? Object.entries(payload.metadata) + .filter(([key]) => isUserFacingMetadata(key)) + .map(([key, value]) => ({ + type: "mrkdwn" as const, + text: truncate( + `*${formatMetadataLabel(key)}*\n${String(value)}`, + MAX_FIELD_LENGTH + ), + })) + : []; + for ( + let index = 0; + index < metadataFields.length; + index += MAX_FIELDS_PER_SECTION + ) { + blocks.push({ + type: "section", + fields: metadataFields.slice(index, index + MAX_FIELDS_PER_SECTION), + }); + } + + if (payload.priority && payload.priority !== "normal") { + blocks.push({ + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Priority: *${payload.priority.toUpperCase()}*`, + }, + ], + }); + } + + return blocks; +} + export interface SlackProviderConfig { channel?: string; iconEmoji?: string; @@ -82,42 +168,9 @@ export class SlackProvider extends BaseProvider { } private buildPayload(payload: NotificationPayload): SlackPayload { - const blocks: SlackPayload["blocks"] = [ - { - type: "header", - text: { type: "plain_text", text: payload.title }, - }, - { - type: "section", - text: { type: "mrkdwn", text: payload.message }, - }, - ]; - - if (payload.metadata && Object.keys(payload.metadata).length > 0) { - blocks.push({ - type: "section", - fields: Object.entries(payload.metadata).map(([key, value]) => ({ - type: "mrkdwn" as const, - text: `*${key}*\n${String(value)}`, - })), - }); - } - - if (payload.priority && payload.priority !== "normal") { - blocks.push({ - type: "context", - elements: [ - { - type: "mrkdwn", - text: `Priority: *${payload.priority.toUpperCase()}*`, - }, - ], - }); - } - return { - blocks, - text: payload.title, + blocks: buildSlackBlocks(payload), + text: truncate(payload.title, MAX_MESSAGE_LENGTH), ...(this.channel && { channel: this.channel }), ...(this.username && { username: this.username }), ...(this.iconEmoji && { icon_emoji: this.iconEmoji }), diff --git a/packages/notifications/src/templates/uptime.ts b/packages/notifications/src/templates/uptime.ts index 820b221ac6..3d46ee7891 100644 --- a/packages/notifications/src/templates/uptime.ts +++ b/packages/notifications/src/templates/uptime.ts @@ -104,9 +104,9 @@ function priorityForKind(kind: UptimeNotificationKind): NotificationPriority { function titleForInput(input: UptimeNotificationInput): string { if (input.kind === "down") { - return `Uptime: ${input.siteLabel} is down`; + return `Health check failed: ${input.siteLabel}`; } - return `Uptime: ${input.siteLabel} is back up`; + return `Health check passed: ${input.siteLabel}`; } /** diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index e628e609e6..f2ac78af89 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -5,6 +5,7 @@ export { recordORPCError, setRpcRequestLoggerProvider, } from "./lib/rpc-log-context"; +export { getAutumn } from "./lib/autumn-client"; export { setTrackingFn } from "./middleware/track-mutation"; export { type Context, diff --git a/packages/rpc/src/routers/alarms.ts b/packages/rpc/src/routers/alarms.ts index aabba0979f..00098d913c 100644 --- a/packages/rpc/src/routers/alarms.ts +++ b/packages/rpc/src/routers/alarms.ts @@ -410,8 +410,8 @@ export const alarmsRouter = { const targets = toNotificationTargets(alarm.destinations); const payload = { - title: `Test: ${alarm.name}`, - message: `This is a test notification from your "${alarm.name}" alarm. If you're reading this, the channel is working.`, + title: `Test alert: ${alarm.name}`, + message: `This is a test notification from your "${alarm.name}" alert. If you received it, this destination is working.`, priority: "normal" as const, metadata: { template: "test", From 34603834b55648c44432f1f84286dfc584c5d9d3 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:04:45 +0300 Subject: [PATCH 02/16] fix(dashboard): clarify billing auth and recovery states --- apps/dashboard/app/(auth)/auth/error/page.tsx | 37 +++-- .../app/(auth)/login/forgot/page.tsx | 12 +- .../app/(auth)/login/magic-sent/page.tsx | 69 ++++++-- .../dashboard/app/(auth)/login/magic/page.tsx | 38 +++-- apps/dashboard/app/(auth)/login/page.tsx | 15 +- .../(auth)/login/verification-needed/page.tsx | 73 +++++++-- apps/dashboard/app/(auth)/register/page.tsx | 38 +++-- apps/dashboard/app/(dby)/dby/expired/page.tsx | 8 +- .../app/(dby)/dby/not-found/page.tsx | 7 +- .../billing/actions/cancel-feedback-action.ts | 9 + .../components/billing-controls-card.tsx | 17 +- .../components/cancel-subscription-dialog.tsx | 36 ++-- .../billing/components/plan-status-badge.tsx | 2 +- .../(main)/billing/components/topup-card.tsx | 48 ++++-- .../app/(main)/billing/history/page.tsx | 2 +- .../app/(main)/billing/hooks/use-billing.ts | 27 ++- apps/dashboard/app/(main)/billing/page.tsx | 55 ++++--- .../feedback/components/credits-panel.tsx | 23 ++- .../feedback/components/redeem-dialog.tsx | 12 +- apps/dashboard/app/(main)/links/page.tsx | 10 +- .../onboarding/_components/step-explore.tsx | 4 +- .../_components/step-install-tracking.test.ts | 18 ++ .../_components/step-install-tracking.tsx | 67 +------- .../components/api-keys-section.tsx | 6 +- .../components/integrations-settings.tsx | 6 +- .../app/(main)/settings/account/page.tsx | 155 +++++++++++++----- .../_components/email-preferences-card.tsx | 2 +- .../notification-test-result.test.ts | 30 ++++ .../_components/notification-test-result.ts | 58 +++++++ .../(main)/settings/notifications/page.tsx | 21 ++- .../constants/settings-constants.ts | 10 +- .../_components/utils/code-generators.test.ts | 17 ++ .../_components/utils/tracking-defaults.ts | 6 +- .../_components/utils/tracking-helpers.ts | 7 - .../websites/[id]/_components/utils/types.ts | 3 - .../app/(main)/websites/[id]/error.tsx | 3 +- .../_components/error-summary-stats.tsx | 2 +- .../_components/error-table-columns.test.ts | 12 ++ .../_components/error-table-columns.tsx | 11 +- .../websites/[id]/errors/_components/utils.ts | 7 + .../app/(main)/websites/[id]/events/error.tsx | 3 +- .../app/(main)/websites/[id]/goals/page.tsx | 2 +- apps/dashboard/app/(main)/websites/page.tsx | 8 +- apps/dashboard/app/layout.tsx | 1 - apps/dashboard/app/public/[id]/layout.tsx | 7 +- apps/dashboard/autumn.config.ts | 9 +- .../components/agent/agent-chat-surface.tsx | 2 +- .../components/agent/agent-commands.ts | 2 +- .../components/agent/agent-credit-balance.tsx | 12 +- .../components/agent/agent-input.tsx | 16 ++ .../components/agent/chat-history.tsx | 2 +- .../components/autumn/attach-dialog.tsx | 19 ++- .../components/autumn/pricing-table.tsx | 32 +++- .../components/charts/metrics-constants.ts | 4 +- apps/dashboard/components/error-boundary.tsx | 16 +- apps/dashboard/components/feature-gate.tsx | 12 +- .../components/layout/mobile-sidebar.tsx | 12 +- .../layout/navigation/navigation-config.tsx | 2 +- .../layout/organization-selector.tsx | 4 +- apps/dashboard/components/layout/sidebar.tsx | 23 ++- .../organizations/api-key-sheet.tsx | 11 +- .../components/resource-unavailable-state.tsx | 2 +- .../components/ui/command-search.tsx | 8 +- .../components/ui/credit-arc-slider.tsx | 4 +- .../components/website-error-state.tsx | 61 ++++--- apps/dashboard/error.tsx | 8 +- .../hooks/use-organization-invitations.ts | 15 +- apps/dashboard/hooks/use-organizations.ts | 4 +- .../ai-components/renderers/goals/list.tsx | 2 +- .../lib/autumn/attach-dialog-copy.test.ts | 24 +++ .../lib/autumn/attach-dialog-copy.ts | 55 +++++++ .../lib/autumn/customer-plan-name.test.ts | 12 ++ .../lib/autumn/customer-plan-name.ts | 14 ++ apps/dashboard/lib/databunny-usage.ts | 6 + apps/dashboard/lib/query-client.ts | 10 +- apps/dashboard/lib/user-facing-error.test.ts | 28 ++++ apps/dashboard/lib/user-facing-error.ts | 68 ++++++++ apps/dashboard/proxy.test.ts | 26 +++ apps/dashboard/proxy.ts | 21 ++- .../test/e2e/specs/core/organizations.spec.ts | 4 +- apps/insights/src/generation.ts | 2 +- packages/ai/src/ai/mcp/run-agent.ts | 2 +- packages/rpc/src/routers/billing.ts | 6 +- packages/rpc/src/routers/flags.ts | 2 +- packages/rpc/src/routers/link-folders.ts | 2 +- packages/rpc/src/routers/links.ts | 6 +- packages/rpc/src/routers/organizations.ts | 4 +- packages/rpc/src/routers/target-groups.ts | 2 +- packages/rpc/src/routers/uptime.ts | 4 +- packages/rpc/src/routers/websites.ts | 11 +- packages/shared/src/agent-discovery.ts | 14 +- packages/shared/src/types/features.ts | 8 +- 92 files changed, 1136 insertions(+), 481 deletions(-) create mode 100644 apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.test.ts create mode 100644 apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.test.ts create mode 100644 apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.ts create mode 100644 apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.test.ts create mode 100644 apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.test.ts create mode 100644 apps/dashboard/lib/autumn/attach-dialog-copy.test.ts create mode 100644 apps/dashboard/lib/autumn/attach-dialog-copy.ts create mode 100644 apps/dashboard/lib/autumn/customer-plan-name.test.ts create mode 100644 apps/dashboard/lib/autumn/customer-plan-name.ts create mode 100644 apps/dashboard/lib/databunny-usage.ts create mode 100644 apps/dashboard/lib/user-facing-error.test.ts create mode 100644 apps/dashboard/lib/user-facing-error.ts create mode 100644 apps/dashboard/proxy.test.ts diff --git a/apps/dashboard/app/(auth)/auth/error/page.tsx b/apps/dashboard/app/(auth)/auth/error/page.tsx index b03d3b82d2..e63552b905 100644 --- a/apps/dashboard/app/(auth)/auth/error/page.tsx +++ b/apps/dashboard/app/(auth)/auth/error/page.tsx @@ -67,6 +67,16 @@ const ERROR_MESSAGES: Record = { description: "The callback request was invalid. Please try signing in again.", }, + expired_token: { + title: "Magic link expired", + description: + "This magic link has expired. Request a new link to continue signing in.", + }, + invalid_token: { + title: "Magic link no longer works", + description: + "This magic link is invalid or has already been used. Request a new link to continue.", + }, }; const DEFAULT_ERROR = { @@ -77,8 +87,21 @@ const DEFAULT_ERROR = { function AuthErrorPage() { const [errorCode] = useQueryState("error", parseAsString.withDefault("")); + const [callback] = useQueryState( + "callback", + parseAsString.withDefault("/websites") + ); - const errorInfo = ERROR_MESSAGES[errorCode] ?? DEFAULT_ERROR; + const errorInfo = + ERROR_MESSAGES[errorCode] ?? + ERROR_MESSAGES[errorCode.toLowerCase()] ?? + DEFAULT_ERROR; + const isMagicLinkError = ["expired_token", "invalid_token"].includes( + errorCode.toLowerCase() + ); + const recoveryHref = isMagicLinkError + ? `/login/magic?callback=${encodeURIComponent(callback)}` + : `/login?callback=${encodeURIComponent(callback)}`; return ( <> @@ -95,16 +118,10 @@ function AuthErrorPage() { {errorInfo.description} - {errorCode && ( -
- - Error: {errorCode} - -
- )} - diff --git a/apps/dashboard/app/(auth)/login/forgot/page.tsx b/apps/dashboard/app/(auth)/login/forgot/page.tsx index 957ded2dc0..c680caf070 100644 --- a/apps/dashboard/app/(auth)/login/forgot/page.tsx +++ b/apps/dashboard/app/(auth)/login/forgot/page.tsx @@ -3,6 +3,7 @@ import { authClient } from "@databuddy/auth/client"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { parseAsString, useQueryState } from "nuqs"; import { Suspense, useState } from "react"; import { toast } from "sonner"; import { ArrowLeftIcon, EyeIcon, EyeSlashIcon } from "@databuddy/ui/icons"; @@ -11,6 +12,11 @@ import { OtpInput } from "@databuddy/ui/client"; function ForgotPasswordPage() { const router = useRouter(); + const [callback] = useQueryState( + "callback", + parseAsString.withDefault("/websites") + ); + const loginHref = `/login?callback=${encodeURIComponent(callback)}`; const [step, setStep] = useState<"email" | "reset">("email"); const [email, setEmail] = useState(""); const [otp, setOtp] = useState(""); @@ -79,7 +85,7 @@ function ForgotPasswordPage() { setIsLoading(false); toast.success("Password reset successfully. Redirecting to login..."); setTimeout(() => { - router.push("/login"); + router.push(loginHref); }, 1500); }; @@ -142,7 +148,7 @@ function ForgotPasswordPage() {
Back to login @@ -263,7 +269,7 @@ function ForgotPasswordPage() {
Back to login diff --git a/apps/dashboard/app/(auth)/login/magic-sent/page.tsx b/apps/dashboard/app/(auth)/login/magic-sent/page.tsx index dbdc63dec1..596af0d2a0 100644 --- a/apps/dashboard/app/(auth)/login/magic-sent/page.tsx +++ b/apps/dashboard/app/(auth)/login/magic-sent/page.tsx @@ -2,6 +2,7 @@ import { authClient } from "@databuddy/auth/client"; import Link from "next/link"; +import { parseAsString, useQueryState } from "nuqs"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { ArrowLeftIcon, EnvelopeIcon } from "@databuddy/ui/icons"; @@ -10,8 +11,15 @@ import { Button, Spinner, Text } from "@databuddy/ui"; const MAGIC_EMAIL_KEY = "databuddy:magic-email"; function MagicSentPage() { + const [callback] = useQueryState( + "callback", + parseAsString.withDefault("/websites") + ); const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isReady, setIsReady] = useState(false); + const magicLinkHref = `/login/magic?callback=${encodeURIComponent(callback)}`; + const loginHref = `/login?callback=${encodeURIComponent(callback)}`; useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -26,12 +34,14 @@ function MagicSentPage() { "", `${window.location.pathname}${next ? `?${next}` : ""}` ); + setIsReady(true); return; } const stored = sessionStorage.getItem(MAGIC_EMAIL_KEY); if (stored) { setEmail(stored); } + setIsReady(true); }, []); const handleResend = async (e: React.MouseEvent) => { @@ -42,24 +52,51 @@ function MagicSentPage() { } setIsLoading(true); - await authClient.signIn.magicLink({ - email, - callbackURL: "/home", - fetchOptions: { - onSuccess: () => { - setIsLoading(false); - toast.success("Magic link sent! Please check your email."); - }, - onError: () => { - setIsLoading(false); - toast.error("Failed to send magic link. Please try again."); - }, - }, - }); - + try { + const { error } = await authClient.signIn.magicLink({ + email, + callbackURL: callback, + errorCallbackURL: `/auth/error?callback=${encodeURIComponent(callback)}`, + }); + if (error) { + toast.error("We couldn't send the magic link. Try again in a moment."); + } else { + toast.success("Magic link sent. Check your email."); + } + } catch { + toast.error("We couldn't send the magic link. Try again in a moment."); + } setIsLoading(false); }; + if (!isReady) { + return ( +
+ +
+ ); + } + + if (!email) { + return ( + <> +
+ + Request a new magic link + + + We couldn't recover the email address for this request. + +
+
+ +
+ + ); + } + return ( <>
@@ -89,7 +126,7 @@ function MagicSentPage() {
Back to login diff --git a/apps/dashboard/app/(auth)/login/magic/page.tsx b/apps/dashboard/app/(auth)/login/magic/page.tsx index d55749d671..b55be568d6 100644 --- a/apps/dashboard/app/(auth)/login/magic/page.tsx +++ b/apps/dashboard/app/(auth)/login/magic/page.tsx @@ -17,6 +17,7 @@ function MagicLinkPage() { ); const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); + const loginHref = `/login?callback=${encodeURIComponent(callback)}`; const handleMagicLinkLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -26,23 +27,24 @@ function MagicLinkPage() { } setIsLoading(true); - await authClient.signIn.magicLink({ - email, - callbackURL: callback, - fetchOptions: { - onSuccess: () => { - setIsLoading(false); - toast.success("Magic link sent! Please check your email."); - sessionStorage.setItem("databuddy:magic-email", email); - router.push("/login/magic-sent"); - }, - onError: () => { - setIsLoading(false); - toast.error("Failed to send magic link. Please try again."); - }, - }, - }); - + try { + const { error } = await authClient.signIn.magicLink({ + email, + callbackURL: callback, + errorCallbackURL: `/auth/error?callback=${encodeURIComponent(callback)}`, + }); + if (error) { + toast.error("We couldn't send the magic link. Try again in a moment."); + } else { + toast.success("Magic link sent. Check your email."); + sessionStorage.setItem("databuddy:magic-email", email); + router.push( + `/login/magic-sent?callback=${encodeURIComponent(callback)}` + ); + } + } catch { + toast.error("We couldn't send the magic link. Try again in a moment."); + } setIsLoading(false); }; @@ -93,7 +95,7 @@ function MagicLinkPage() {
Back to login diff --git a/apps/dashboard/app/(auth)/login/page.tsx b/apps/dashboard/app/(auth)/login/page.tsx index 6c574f284a..3b48a09392 100644 --- a/apps/dashboard/app/(auth)/login/page.tsx +++ b/apps/dashboard/app/(auth)/login/page.tsx @@ -33,18 +33,21 @@ function LoginPage() { const isHydrated = useHydrated(); const lastUsed = isHydrated ? authClient.getLastUsedLoginMethod() : null; + const callbackQuery = `?callback=${encodeURIComponent(callback)}`; const getProviderLabel = (provider: "github" | "google") => provider === "github" ? "GitHub" : "Google"; const handleSocialLogin = async (provider: "github" | "google") => { setIsLoading(true); + const newUserCallbackURL = + callback === "/websites" ? "/onboarding" : callback; try { const result = await authClient.signIn.social({ provider, callbackURL: callback, - newUserCallbackURL: "/onboarding", + newUserCallbackURL, disableRedirect: true, }); @@ -95,7 +98,9 @@ function LoginPage() { error?.error?.message?.toLowerCase().includes("not verified") ) { storeVerificationEmail(email); - router.push("/login/verification-needed"); + router.push( + `/login/verification-needed?callback=${encodeURIComponent(callback)}` + ); } else { toast.error( error?.error?.message || @@ -166,7 +171,7 @@ function LoginPage() { size="lg" variant="outline" > - + Sign in with Magic Link @@ -258,14 +263,14 @@ function LoginPage() { Don't have an account?{" "} Sign up Forgot password? diff --git a/apps/dashboard/app/(auth)/login/verification-needed/page.tsx b/apps/dashboard/app/(auth)/login/verification-needed/page.tsx index 8f66f45340..62f4276ada 100644 --- a/apps/dashboard/app/(auth)/login/verification-needed/page.tsx +++ b/apps/dashboard/app/(auth)/login/verification-needed/page.tsx @@ -2,6 +2,7 @@ import { authClient } from "@databuddy/auth/client"; import Link from "next/link"; +import { parseAsString, useQueryState } from "nuqs"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { ArrowLeftIcon, WarningIcon } from "@databuddy/ui/icons"; @@ -12,8 +13,14 @@ import { } from "../verification-email-storage"; function VerificationNeededPage() { + const [callback] = useQueryState( + "callback", + parseAsString.withDefault("/websites") + ); const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isReady, setIsReady] = useState(false); + const loginHref = `/login?callback=${encodeURIComponent(callback)}`; useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -28,37 +35,67 @@ function VerificationNeededPage() { "", `${window.location.pathname}${next ? `?${next}` : ""}` ); + setIsReady(true); return; } const stored = readVerificationEmail(); if (stored) { setEmail(stored); } + setIsReady(true); }, []); const sendVerificationEmail = async () => { setIsLoading(true); - await authClient.sendVerificationEmail({ - email, - callbackURL: "/home", - fetchOptions: { - onSuccess: () => { - toast.success("Verification email sent!"); - setIsLoading(false); - }, - onError: () => { - setIsLoading(false); - toast.error( - "Failed to send verification email. Please try again later." - ); - }, - }, - }); - + try { + const { error } = await authClient.sendVerificationEmail({ + email, + callbackURL: callback, + }); + if (error) { + toast.error( + "We couldn't send the verification email. Try again in a moment." + ); + } else { + toast.success("Verification email sent. Check your inbox."); + } + } catch { + toast.error( + "We couldn't send the verification email. Try again in a moment." + ); + } setIsLoading(false); }; + if (!isReady) { + return ( +
+ +
+ ); + } + + if (!email) { + return ( + <> +
+ + Verify your email + + + Sign in again so we know where to send the verification link. + +
+
+ +
+ + ); + } + return ( <>
@@ -93,7 +130,7 @@ function VerificationNeededPage() {
Back to login diff --git a/apps/dashboard/app/(auth)/register/page.tsx b/apps/dashboard/app/(auth)/register/page.tsx index 3a749ad1f7..4f3c2c9794 100644 --- a/apps/dashboard/app/(auth)/register/page.tsx +++ b/apps/dashboard/app/(auth)/register/page.tsx @@ -132,34 +132,38 @@ function RegisterPageContent() { const resendVerificationEmail = async () => { setIsLoading(true); - await authClient.sendVerificationEmail({ - email: formData.email, - callbackURL: "/onboarding", - fetchOptions: { - onSuccess: () => { - toast.success("Verification email sent!"); - }, - onError: () => { - toast.error( - "Failed to send verification email. Please try again later." - ); - }, - }, - }); - + try { + const { error } = await authClient.sendVerificationEmail({ + email: formData.email, + callbackURL: getCallbackUrl(), + }); + if (error) { + toast.error( + "We couldn't send the verification email. Try again in a moment." + ); + } else { + toast.success("Verification email sent. Check your inbox."); + } + } catch { + toast.error( + "We couldn't send the verification email. Try again in a moment." + ); + } setIsLoading(false); }; const handleSocialLogin = async (provider: "github" | "google") => { setIsLoading(true); const signupProperties = getSignupProperties(`social_${provider}`); + const callbackURL = getCallbackUrl(); trackSignup(APP_EVENTS.signupStarted, signupProperties); try { const result = await authClient.signIn.social({ provider, - callbackURL: getCallbackUrl(), - newUserCallbackURL: "/onboarding", + callbackURL, + newUserCallbackURL: + callbackURL === "/websites" ? "/onboarding" : callbackURL, disableRedirect: true, }); diff --git a/apps/dashboard/app/(dby)/dby/expired/page.tsx b/apps/dashboard/app/(dby)/dby/expired/page.tsx index 403192c6b8..5a3ab3180d 100644 --- a/apps/dashboard/app/(dby)/dby/expired/page.tsx +++ b/apps/dashboard/app/(dby)/dby/expired/page.tsx @@ -37,12 +37,12 @@ export default function LinkExpiredPage() { Link has expired

- This link is no longer available. It may have been set to expire - after a certain date. + The sender set this link to expire. Ask them to create and share a + new link if you still need access.

-
diff --git a/apps/dashboard/app/(dby)/dby/not-found/page.tsx b/apps/dashboard/app/(dby)/dby/not-found/page.tsx index 5240be1205..a52c68b95f 100644 --- a/apps/dashboard/app/(dby)/dby/not-found/page.tsx +++ b/apps/dashboard/app/(dby)/dby/not-found/page.tsx @@ -38,11 +38,12 @@ export default function LinkNotFoundPage() { Link not found

- This link doesn't exist or may have been deleted. + Check the address for a typo. If someone shared this link with you, + ask them to confirm it or send a new one.

-
diff --git a/apps/dashboard/app/(main)/billing/actions/cancel-feedback-action.ts b/apps/dashboard/app/(main)/billing/actions/cancel-feedback-action.ts index e04bce029a..dd4bed23e7 100644 --- a/apps/dashboard/app/(main)/billing/actions/cancel-feedback-action.ts +++ b/apps/dashboard/app/(main)/billing/actions/cancel-feedback-action.ts @@ -1,6 +1,8 @@ "use server"; +import { auth } from "@databuddy/auth"; import { Databuddy } from "@databuddy/sdk/node"; +import { headers } from "next/headers"; import type { CancelFeedback } from "../components/cancel-subscription-dialog"; const databuddyApiKey = process.env.DATABUDDY_API_KEY; @@ -34,6 +36,13 @@ export async function trackCancelFeedbackAction({ planName, immediate, }: TrackCancelFeedbackParams): Promise<{ success: boolean }> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return { success: false }; + } + if (!VALID_REASONS.includes(feedback.reason)) { return { success: false }; } diff --git a/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx b/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx index db59dddeaa..5308d32f8e 100644 --- a/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx +++ b/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx @@ -79,7 +79,7 @@ export function BillingControlsCard() { Billing controls - Guardrails for your credit balance and spend. + Control Databunny usage refills, event alerts, and AI spending. @@ -90,7 +90,7 @@ export function BillingControlsCard() { turnOn: "Turn on", }} defaults={TOPUP_DEFAULTS} - description="Never get cut off mid-conversation. Credits refill automatically when you run low." + description="Refill the organization's AI analysis allowance automatically when Databunny usage runs low." icon={} initial={topup} limits={TOPUP_LIMITS} @@ -115,7 +115,7 @@ export function BillingControlsCard() { min={TOPUP_LIMITS.threshold[0]} onChange={(v) => setForm({ threshold: v })} step={10} - suffix="credits" + suffix="units" value={form.threshold} /> setForm({ quantity: v })} step={100} - suffix="credits" + suffix="units" value={form.quantity} />
@@ -178,7 +178,7 @@ export function BillingControlsCard() { turnOn: "Turn on", }} defaults={SPEND_DEFAULTS} - description="Cap how much you can spend on credits each month. Purchases stop once the cap is hit." + description="Cap monthly spending on Databunny usage. Automatic refills stop when the cap is reached." icon={} initial={spend} limits={SPEND_LIMITS} @@ -190,7 +190,7 @@ export function BillingControlsCard() { mutationOptions={orpc.billing.setSpendLimit.mutationOptions()} onSaved={refetch} switchLabel="Enable spend limit" - title="Credit spend limit" + title="Databunny spend limit" > {(form, setForm) => ( - {quantity.toLocaleString()} credits · ≈ ${(cost / quantity).toFixed(4)} - /credit + {quantity.toLocaleString()} usage units · ≈ $ + {(cost / quantity).toFixed(4)} + /unit
); diff --git a/apps/dashboard/app/(main)/billing/components/cancel-subscription-dialog.tsx b/apps/dashboard/app/(main)/billing/components/cancel-subscription-dialog.tsx index 8c840c1df5..534556fb8a 100644 --- a/apps/dashboard/app/(main)/billing/components/cancel-subscription-dialog.tsx +++ b/apps/dashboard/app/(main)/billing/components/cancel-subscription-dialog.tsx @@ -18,7 +18,7 @@ import { Badge, Button, Textarea, dayjs } from "@databuddy/ui"; interface CancelSubscriptionDialogProps { currentPeriodEnd?: number; isLoading: boolean; - onCancel: (immediate: boolean, feedback?: CancelFeedback) => void; + onCancel: (immediate: boolean, feedback?: CancelFeedback) => Promise; onOpenChange: (open: boolean) => void; open: boolean; planName: string; @@ -88,9 +88,13 @@ export function CancelSubscriptionDialog({ reason: selectedReason, details: feedbackDetails.trim() || undefined, }; - await onCancel(selected === "immediate", feedback); + const didCancel = await onCancel(selected === "immediate", feedback).catch( + () => false + ); setConfirming(false); - resetAndClose(); + if (didCancel) { + resetAndClose(); + } }; const resetAndClose = () => { @@ -120,7 +124,7 @@ export function CancelSubscriptionDialog({ Before you go... - We'd love to know why you're cancelling so we can improve + We'd love to know why you're canceling so we can improve @@ -129,9 +133,9 @@ export function CancelSubscriptionDialog({ const IconComponent = reason.icon; const isActive = selectedReason === reason.id; return ( - + ); })} @@ -194,9 +198,9 @@ export function CancelSubscriptionDialog({ - + - + {selected === "immediate" && (
diff --git a/apps/dashboard/app/(main)/billing/components/plan-status-badge.tsx b/apps/dashboard/app/(main)/billing/components/plan-status-badge.tsx index 3b5f3af410..4579f5f057 100644 --- a/apps/dashboard/app/(main)/billing/components/plan-status-badge.tsx +++ b/apps/dashboard/app/(main)/billing/components/plan-status-badge.tsx @@ -10,7 +10,7 @@ export function PlanStatusBadge({ isScheduled, }: PlanStatusBadgeProps) { if (isCanceled) { - return Cancelling; + return Cancellation scheduled; } if (isScheduled) { return Scheduled; diff --git a/apps/dashboard/app/(main)/billing/components/topup-card.tsx b/apps/dashboard/app/(main)/billing/components/topup-card.tsx index b5858b454d..73f737081b 100644 --- a/apps/dashboard/app/(main)/billing/components/topup-card.tsx +++ b/apps/dashboard/app/(main)/billing/components/topup-card.tsx @@ -1,7 +1,12 @@ "use client"; import { CreditArcSlider } from "@/components/ui/credit-arc-slider"; +import { + DATABUNNY_USAGE_EXPLANATION, + DATABUNNY_USAGE_UNIT, +} from "@/lib/databunny-usage"; import { cn } from "@/lib/utils"; +import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; import { blendedRatePerCredit, calculateTopupCost, @@ -63,7 +68,10 @@ export function TopupCard() { }); } catch (error) { toast.error( - error instanceof Error ? error.message : "Failed to start checkout." + getUserFacingErrorMessage( + error, + "We couldn't open checkout. Try again." + ) ); } finally { setIsAttaching(false); @@ -75,35 +83,38 @@ export function TopupCard() { - Top up credits + Top up Databunny usage - Buy more, pay less per credit. Stacks with your plan and never expires - — nothing goes to waste. + {DATABUNNY_USAGE_EXPLANATION} Purchased usage stacks with your plan + and does not expire.
{PRESET_QUANTITIES.map((preset) => ( - + ))}
@@ -113,10 +124,10 @@ export function TopupCard() {
- {quantity.toLocaleString()} credits + {quantity.toLocaleString()} usage units - + ${blendedRate.toFixed(4)} @@ -134,11 +145,12 @@ export function TopupCard() { />
- +
@@ -171,9 +183,9 @@ export function TopupCard() {
- Graduated tiers: each credit is billed by the tier it falls + Graduated tiers: each usage unit is billed by the tier it falls into. Buy 5,000 and only the first 100 cost $ - {BASE_RATE.toFixed(2)} — every credit above that drops to a + {BASE_RATE.toFixed(2)} — every unit above that drops to a cheaper rate.
@@ -206,7 +218,7 @@ export function TopupCard() { )}
- ${tier.amount.toFixed(3)} / credit + ${tier.amount.toFixed(3)} / unit
); @@ -247,7 +259,7 @@ function NudgeSlot({ blendedRate, nudge, quantity, savings }: NudgeSlotProps) { {nudge.unitsUntilNextTier.toLocaleString()} {" "} - more and every credit drops to{" "} + more and every usage unit drops to{" "} ${nudge.nextRate.toFixed(3)} @@ -285,7 +297,7 @@ function NudgeSlot({ blendedRate, nudge, quantity, savings }: NudgeSlotProps) { {FIRST_TIER_TOP.toLocaleString()}+ {" "} - credits — every one drops to{" "} + usage units — every unit drops to{" "} ${SECOND_TIER_RATE.toFixed(3)} diff --git a/apps/dashboard/app/(main)/billing/history/page.tsx b/apps/dashboard/app/(main)/billing/history/page.tsx index aa8aefd0bd..43d201701e 100644 --- a/apps/dashboard/app/(main)/billing/history/page.tsx +++ b/apps/dashboard/app/(main)/billing/history/page.tsx @@ -277,7 +277,7 @@ function SubscriptionItem({ {sub.plan?.name ?? sub.planId} {isActive && Active} - {isCanceled && Cancelled} + {isCanceled && Canceled}
Started {dayjs(sub.startedAt).fromNow()} diff --git a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts index a7883c9d1e..5c1f970918 100644 --- a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts +++ b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts @@ -1,6 +1,7 @@ import { useCustomer, useListPlans } from "autumn-js/react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; import { dayjs } from "@databuddy/ui"; import { trackCancelFeedbackAction } from "../actions/cancel-feedback-action"; import type { CancelFeedback } from "../components/cancel-subscription-dialog"; @@ -38,7 +39,10 @@ export function useBilling(refetch?: () => void) { }); } catch (error) { toast.error( - error instanceof Error ? error.message : "An unexpected error occurred." + getUserFacingErrorMessage( + error, + "We couldn't update this subscription. Try again." + ) ); } }; @@ -52,18 +56,21 @@ export function useBilling(refetch?: () => void) { }); toast.success( immediate - ? "Subscription cancelled immediately." - : "Subscription cancelled." + ? "Subscription canceled immediately." + : "Subscription cancellation scheduled." ); if (refetch) { setTimeout(refetch, 500); } + return true; } catch (error) { toast.error( - error instanceof Error - ? error.message - : "Failed to cancel subscription." + getUserFacingErrorMessage( + error, + "We couldn't cancel the subscription. Try again." + ) ); + return false; } finally { setIsLoading(false); } @@ -95,7 +102,11 @@ export function useBilling(refetch?: () => void) { setCancelTarget({ id, name, currentPeriodEnd }), onCancelConfirm: async (immediate: boolean, feedback?: CancelFeedback) => { if (!cancelTarget) { - return; + return false; + } + const didCancel = await handleCancel(cancelTarget.id, immediate); + if (!didCancel) { + return false; } if (feedback) { trackCancelFeedbackAction({ @@ -105,8 +116,8 @@ export function useBilling(refetch?: () => void) { immediate, }); } - await handleCancel(cancelTarget.id, immediate); setCancelTarget(null); + return true; }, onCancelDialogClose: () => setCancelTarget(null), onManageBilling: () => diff --git a/apps/dashboard/app/(main)/billing/page.tsx b/apps/dashboard/app/(main)/billing/page.tsx index 7104b31d05..7295e22962 100644 --- a/apps/dashboard/app/(main)/billing/page.tsx +++ b/apps/dashboard/app/(main)/billing/page.tsx @@ -2,8 +2,10 @@ import AttachDialog from "@/components/autumn/attach-dialog"; import { useBillingContext } from "@/components/providers/billing-provider"; +import { getCustomerPlanName } from "@/lib/autumn/customer-plan-name"; import { orpc } from "@/lib/orpc"; import { TOPUP_PRODUCT_ID } from "@/lib/topup-math"; +import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; import type { UsageResponse } from "@/types/billing"; import { useQuery } from "@tanstack/react-query"; import type { PreviewAttachResponse } from "autumn-js"; @@ -15,6 +17,7 @@ import { BillingControlsCard } from "./components/billing-controls-card"; import { CancelSubscriptionDialog } from "./components/cancel-subscription-dialog"; import { ConsumptionChart } from "./components/consumption-chart"; import { ErrorState } from "./components/empty-states"; +import { PlanStatusBadge } from "./components/plan-status-badge"; import { TopupCard } from "./components/topup-card"; import { UsageBreakdownTable } from "./components/usage-breakdown-table"; import { UsageRow } from "./components/usage-row"; @@ -152,7 +155,10 @@ function AddOnRow({ setDialogOpen(true); } catch (err) { toast.error( - err instanceof Error ? err.message : "Failed to load preview." + getUserFacingErrorMessage( + err, + "We couldn't load the add-on preview. Try again." + ) ); } finally { setIsLoadingPreview(false); @@ -173,7 +179,7 @@ function AddOnRow({ )}
{isCancelled ? ( - Cancelled + Cancellation scheduled ) : isActive ? (
Active @@ -208,9 +214,10 @@ function AddOnRow({
{preview && ( onAttach(addOn.id)} open={dialogOpen} - planId={addOn.id} + planName={addOn.name} preview={preview} setOpen={setDialogOpen} /> @@ -343,9 +350,16 @@ export default function BillingPage() { } const isFree = currentPlan?.id === "free" || currentPlan?.autoEnable === true; - const isCanceled = currentPlan?.customerEligibility?.canceling === true; + const isCanceled = Boolean( + currentSubscription?.canceledAt || + currentPlan?.customerEligibility?.canceling === true + ); const isMaxPlan = currentPlan?.id === "scale"; const showAddOns = addOns.length > 0 && !isFree; + const currentPlanDisplayName = getCustomerPlanName( + currentPlan?.id, + currentPlan?.name || "Free" + ); return (
@@ -355,7 +369,11 @@ export default function BillingPage() { onCancel={onCancelConfirm} onOpenChange={(open) => !open && onCancelDialogClose()} open={showCancelDialog} - planName={cancelTarget?.name ?? ""} + planName={ + cancelTarget + ? getCustomerPlanName(cancelTarget.id, cancelTarget.name) + : "" + } />
@@ -367,17 +385,10 @@ export default function BillingPage() { Subscription and billing management
- - {currentSubscription?.status === "scheduled" - ? "Scheduled" - : "Active"} - +
@@ -390,7 +401,7 @@ export default function BillingPage() { />
- {currentPlan?.name || "Free"} + {currentPlanDisplayName} {!isFree && currentPlan?.price?.display?.primaryText && ( {currentPlan.price.display.primaryText} @@ -447,7 +458,7 @@ export default function BillingPage() { currentPlan && onCancelClick( currentPlan.id, - currentPlan.name, + currentPlanDisplayName, currentSubscription?.currentPeriodEnd ?? undefined ) } @@ -530,10 +541,12 @@ export default function BillingPage() { toast.success("Add-on attached"); } catch (err) { toast.error( - err instanceof Error - ? err.message - : "Failed to attach add-on." + getUserFacingErrorMessage( + err, + "We couldn't add this add-on. Try again." + ) ); + throw err; } }} onCancel={() => diff --git a/apps/dashboard/app/(main)/feedback/components/credits-panel.tsx b/apps/dashboard/app/(main)/feedback/components/credits-panel.tsx index f057f7fff8..d493271df1 100644 --- a/apps/dashboard/app/(main)/feedback/components/credits-panel.tsx +++ b/apps/dashboard/app/(main)/feedback/components/credits-panel.tsx @@ -8,6 +8,7 @@ import { } from "@databuddy/ui/icons"; import { Button, Card, Skeleton } from "@databuddy/ui"; import { cn } from "@/lib/utils"; +import { DATABUNNY_USAGE_EXPLANATION } from "@/lib/databunny-usage"; interface RewardTier { creditsRequired: number; @@ -28,7 +29,7 @@ interface CreditsPanelProps { const REWARD_LABELS: Record = { events: "events", - "agent-credits": "agent credits", + "agent-credits": "Databunny usage units", }; const REWARD_ICONS: Record = { @@ -77,8 +78,8 @@ function TierRow({

{canAfford - ? `${tier.creditsRequired.toLocaleString()} credits` - : `${remaining.toLocaleString()} credits short`} + ? `${tier.creditsRequired.toLocaleString()} feedback credits` + : `${remaining.toLocaleString()} feedback credits short`}

@@ -123,9 +124,11 @@ function RewardSection({ redeemingTier, title, tiers, + description, }: { available: number; baseIndex: number; + description?: string; onRedeemAction: (tierIndex: number) => void; redeemingTier: number | null; title: string; @@ -133,10 +136,13 @@ function RewardSection({ }) { return (
-

+

{title}

-
+ {description && ( +

{description}

+ )} +
{tiers.map((tier, i) => (

- Available credits + Available feedback credits

{available.toLocaleString()} @@ -197,7 +203,7 @@ export function CreditsPanel({

{nextTier - ? `${Math.max(nextTier.creditsRequired - available, 0).toLocaleString()} credits to next redemption` + ? `${Math.max(nextTier.creditsRequired - available, 0).toLocaleString()} feedback credits to next redemption` : "All rewards are available"}

@@ -233,10 +239,11 @@ export function CreditsPanel({ ); diff --git a/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx b/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx index 831d62d17d..6e381327ac 100644 --- a/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx +++ b/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx @@ -26,13 +26,13 @@ export function RedeemDialog({ }: RedeemDialogProps) { const queryClient = useQueryClient(); const rewardLabel = - rewardType === "agent-credits" ? "Agent credits" : "Events"; + rewardType === "agent-credits" ? "Databunny usage units" : "Events"; const redeemMutation = useMutation({ ...orpc.feedback.redeemCredits.mutationOptions(), onSuccess: (result) => { toast.success( - `Redeemed ${result.rewardAmount.toLocaleString()} ${rewardLabel.toLowerCase()}. ${result.remainingCredits.toLocaleString()} credits remaining.` + `Redeemed ${result.rewardAmount.toLocaleString()} ${rewardLabel.toLowerCase()}. ${result.remainingCredits.toLocaleString()} feedback credits remaining.` ); queryClient.invalidateQueries({ queryKey: orpc.feedback.getCreditsBalance.queryOptions().queryKey, @@ -40,7 +40,7 @@ export function RedeemDialog({ onOpenChangeAction(false); }, onError: (error) => { - toast.error(error.message || "Failed to redeem credits"); + toast.error(error.message || "Failed to redeem feedback credits"); }, }); @@ -53,7 +53,9 @@ export function RedeemDialog({
- Redeem credits + + Redeem feedback credits + Confirm this exchange before we update your balance. @@ -70,7 +72,7 @@ export function RedeemDialog({ {creditsRequired.toLocaleString()}

- credits + feedback credits
diff --git a/apps/dashboard/app/(main)/links/page.tsx b/apps/dashboard/app/(main)/links/page.tsx index b15749c767..29ddc7bec3 100644 --- a/apps/dashboard/app/(main)/links/page.tsx +++ b/apps/dashboard/app/(main)/links/page.tsx @@ -75,7 +75,7 @@ function LinksPageContent() { ); const { activeOrganization, isSwitchingOrganization } = useOrganizationsContext(); - const workspaceName = activeOrganization?.name ?? "this workspace"; + const organizationName = activeOrganization?.name ?? "this organization"; const { isOn } = useFlags(); const deepLinksEnabled = isOn("deeplinks"); @@ -193,10 +193,10 @@ function LinksPageContent() {
{isSwitchingOrganization - ? "Switching workspace…" + ? "Switching organization…" : emptyWorkspace - ? `${workspaceName} does not have any links yet. Create short links with workspace-scoped analytics.` - : `Short links for ${workspaceName} · Free while in beta`} + ? `${organizationName} does not have any links yet. Create short links with organization-wide analytics.` + : `Short links for ${organizationName} · Free while in beta`}
@@ -249,7 +249,7 @@ function LinksPageContent() { <> {isSwitchingOrganization && (

- Switching workspace… + Switching organization…

)} diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-explore.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-explore.tsx index f1a4ef136a..a69e6a2ca5 100644 --- a/apps/dashboard/app/(main)/onboarding/_components/step-explore.tsx +++ b/apps/dashboard/app/(main)/onboarding/_components/step-explore.tsx @@ -24,7 +24,7 @@ const FEATURES = [ icon: UsersIcon, title: "Live Visitors", description: "See who's on your site right now with real-time data.", - tab: "?tab=realtime", + tab: "/realtime", }, { icon: CursorClickIcon, @@ -101,7 +101,7 @@ export function StepExplore({
); diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.test.ts b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.test.ts new file mode 100644 index 0000000000..550459dd09 --- /dev/null +++ b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +describe("generateAgentPrompt", () => { + test("does not ask an AI assistant to send installation telemetry", () => { + const promptSource = readFileSync( + new URL("./step-install-tracking.tsx", import.meta.url), + "utf8" + ); + + expect(promptSource).not.toContain("agent-telemetry"); + expect(promptSource).not.toContain("Report Back"); + expect(promptSource).not.toContain("Always send this report"); + expect(promptSource).not.toContain("trackPerformance"); + expect(promptSource).not.toContain("trackScreenViews"); + expect(promptSource).not.toContain("trackSessions"); + }); +}); diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx index b551ad0d3d..0eb93fb368 100644 --- a/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx +++ b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx @@ -32,6 +32,7 @@ import { generateNpmCode, generateScriptTag, } from "../../websites/[id]/_components/utils/code-generators"; +import { RECOMMENDED_DEFAULTS } from "../../websites/[id]/_components/utils/tracking-defaults"; // TODO: Replace with published skill URL once available const SKILL_URL = "https://github.com/databuddy-cc/skill"; @@ -123,22 +124,21 @@ All options work as React/Vue props or \`data-*\` attributes on the script tag. | Option | Type | Default | What it does | |--------|------|---------|-------------| | trackWebVitals | bool | false | Core Web Vitals (LCP, CLS, INP, TTFB) | -| trackPerformance | bool | true | Page load timing | | trackErrors | bool | false | JavaScript errors and exceptions | | trackHashChanges | bool | false | URL hash changes (SPA routing) | | trackAttributes | bool | false | Auto-track elements with data-track attribute | | trackOutgoingLinks | bool | false | Clicks to external sites | | trackInteractions | bool | false | Button clicks and form submissions | -| trackSessions | bool | true | Session tracking (automatic) | -| trackScreenViews | bool | true | Page view tracking (automatic) | | disabled | bool | false | Master kill switch | | samplingRate | 0-1 | 1.0 | Fraction of events to capture | | enableBatching | bool | true | Batch events before sending | | batchSize | num | 10 | Events per batch | -| batchTimeout | num | 2000 | Max ms before flushing batch | +| batchTimeout | num | 5000 | Max ms before flushing batch | | enableRetries | bool | true | Retry failed requests | | maxRetries | num | 3 | Max retry attempts | +Page views and sessions are tracked automatically; they are not configuration options. + Enable what makes sense for this website. A good starting point: \`\`\`tsx @@ -176,40 +176,7 @@ Use snake_case event names. Track decisions and milestones (signup_completed, pu **Events not appearing in dashboard**: Data typically appears within 30 seconds. Check the Network tab for failed requests to basket.databuddy.cc. Verify the Client ID matches. Check for console errors. -**If another analytics tool is present**: Both can run in parallel. No conflicts. Optionally disable the other tool's page view tracking if Databuddy handles it. - -## Report Back — Required - -After installation (success or failure), send a POST to report what happened. This helps us improve the setup experience. - -\`\`\`bash -curl -X POST https://api.databuddy.cc/public/v1/agent-telemetry \\ - -H "Content-Type: application/json" \\ - -d '{ - "websiteId": "${websiteId}", - "agent": "claude", - "status": "success", - "framework": "nextjs", - "installMethod": "sdk", - "stepsCompleted": ["install", "mount", "env-var", "verify"], - "issues": [], - "metadata": {} - }' -\`\`\` - -Fields: -- **websiteId**: "${websiteId}" -- **agent**: Which tool ran this (claude, cursor, copilot, windsurf, other) -- **status**: "success" | "partial" | "failed" -- **framework**: What framework was detected (nextjs, react, vue, vanilla, etc.) -- **installMethod**: "sdk" or "script-tag" -- **stepsCompleted**: Array of steps that succeeded: "install", "mount", "env-var", "verify" -- **issues**: Array of problems encountered, e.g. [{"type": "csp", "detail": "blocked cdn.databuddy.cc", "resolved": true}] - - Common issue types: csp, adblocker, domain-mismatch, script-blocked, build-error, type-error, env-var-missing -- **errorMessage**: Final error message if status is "failed" -- **metadata**: Any extra context (package manager used, versions, etc.) - -Always send this report, even on failure. It's a single fire-and-forget POST.`; +**If another analytics tool is present**: Both can run in parallel. No conflicts. Optionally disable the other tool's page view tracking if Databuddy handles it.`; } function ClaudeLogo({ color }: { color: string }) { @@ -275,26 +242,6 @@ const AI_TOOLS = [ }, ]; -const DEFAULT_TRACKING_OPTIONS = { - disabled: false, - trackHashChanges: false, - trackAttributes: false, - trackOutgoingLinks: false, - trackInteractions: false, - trackPerformance: false, - trackWebVitals: false, - trackErrors: false, - trackSessions: true, - trackScreenViews: false, - enableBatching: true, - enableRetries: true, - batchSize: 10, - batchTimeout: 5000, - maxRetries: 3, - initialRetryDelay: 1000, - samplingRate: 1, -}; - const highlighter = createHighlighterCoreSync({ themes: [vesper], langs: [tsx, html, bash], @@ -378,8 +325,8 @@ export function StepInstallTracking({ const [copiedBlockId, setCopiedBlockId] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); - const trackingCode = generateScriptTag(websiteId, DEFAULT_TRACKING_OPTIONS); - const npmCode = generateNpmCode(websiteId, DEFAULT_TRACKING_OPTIONS); + const trackingCode = generateScriptTag(websiteId, RECOMMENDED_DEFAULTS); + const npmCode = generateNpmCode(websiteId, RECOMMENDED_DEFAULTS); const { data: trackingSetupData, refetch: refetchTrackingSetup } = useQuery({ ...orpc.websites.isTrackingSetup.queryOptions({ input: { websiteId } }), diff --git a/apps/dashboard/app/(main)/organizations/components/api-keys-section.tsx b/apps/dashboard/app/(main)/organizations/components/api-keys-section.tsx index 582754ba01..acde02dacc 100644 --- a/apps/dashboard/app/(main)/organizations/components/api-keys-section.tsx +++ b/apps/dashboard/app/(main)/organizations/components/api-keys-section.tsx @@ -139,7 +139,7 @@ export function ApiKeysSection({ API Keys {isSwitchingOrganization - ? "Switching workspace…" + ? "Switching organization…" : isEmpty ? `Create keys for programmatic access to ${organization.name}` : `${activeCount} active of ${items.length} key${items.length === 1 ? "" : "s"} for ${organization.name}`} @@ -220,7 +220,7 @@ export function ApiKeysSection({ <> {isSwitchingOrganization && (

- Switching workspace… + Switching organization…

)} @@ -236,7 +236,7 @@ export function ApiKeysSection({ } description={`API keys created here only authenticate requests for ${organization.name}. Keys are shown once at creation.`} icon={} - title="No API keys in this workspace" + title="No API keys in this organization" />
) : filtered.length === 0 ? ( diff --git a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx index 9a6b8b7c7a..7d08e58102 100644 --- a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx @@ -81,7 +81,7 @@ const SLACK_ITEM: IntegrationCatalogItem = { const GITHUB_ITEM: IntegrationCatalogItem = { accent: "#181717", - category: "Intelligence", + category: "Deployments", description: "Correlate deploys, commits, and PRs with traffic and error changes.", accentClassName: "bg-foreground/70", @@ -94,9 +94,9 @@ const GITHUB_SCOPES = ["repo:status", "read:org"]; const GSC_ITEM: IntegrationCatalogItem = { accent: "#4285F4", - category: "Intelligence", + category: "Search", description: - "Surface keyword ranking changes, impression drops, and CTR shifts in investigations.", + "Use keyword rankings, impression drops, and CTR shifts in findings.", iconPath: SIMPLE_ICONS.googlesearchconsole, id: "google-search-console", name: "Google Search Console", diff --git a/apps/dashboard/app/(main)/settings/account/page.tsx b/apps/dashboard/app/(main)/settings/account/page.tsx index e77759f925..28d904a09c 100644 --- a/apps/dashboard/app/(main)/settings/account/page.tsx +++ b/apps/dashboard/app/(main)/settings/account/page.tsx @@ -36,6 +36,9 @@ interface Account { createdAt: Date; id: string; providerId: string; + scopes: string[]; + updatedAt: Date; + userId: string; } type SocialProvider = "google" | "github"; @@ -52,6 +55,35 @@ const PROVIDER_CONFIG: Record = { credential: { icon: KeyIcon, name: "Password" }, }; +const SCOPE_LABELS: Record = { + email: "email address", + openid: "identity", + profile: "basic profile", + "read:user": "GitHub profile", + "user:email": "GitHub email addresses", +}; + +function formatAccountScopes(scopes: string[]): string { + if (scopes.length === 0) { + return "basic identity used for sign-in"; + } + + return [ + ...new Set( + scopes.map((scope) => { + const normalized = scope.trim(); + const knownLabel = SCOPE_LABELS[normalized]; + if (knownLabel) { + return knownLabel; + } + + const finalSegment = normalized.split("/").filter(Boolean).at(-1); + return (finalSegment || normalized).replaceAll(/[_:-]+/g, " "); + }) + ), + ].join(", "); +} + function getInitials(name: string): string { return name .split(" ") @@ -158,33 +190,46 @@ function ChangePasswordDialog({ } function UnlinkConfirmDialog({ - provider, + account, isPending, onConfirm, onClose, }: { - provider: SocialProvider | null; + account: Account | null; isPending: boolean; onConfirm: () => void; onClose: () => void; }) { + const provider = account?.providerId as SocialProvider | undefined; + const providerName = provider ? PROVIDER_CONFIG[provider]?.name : ""; + return ( - !open && onClose()} open={!!provider}> + !open && onClose()} open={!!account}> - Unlink Account + Disconnect {providerName} sign-in? - Are you sure you want to unlink your{" "} - {provider ? PROVIDER_CONFIG[provider]?.name : ""} account? You can - reconnect it later. + This removes {providerName} as a way to sign in to Databuddy. It + does not disconnect an organization data integration. You can + reconnect this identity later. + {account && ( + + + Provider account ID: {account.accountId} + + + Permissions: {formatAccountScopes(account.scopes)} + + + )} @@ -219,7 +264,7 @@ function DeleteAccountDialog({ const deleteAccount = useMutation({ mutationFn: async () => { const opts: { password?: string; callbackURL?: string } = { - callbackURL: "/auth/login", + callbackURL: "/login", }; if (hasPassword) { opts.password = password; @@ -233,7 +278,7 @@ function DeleteAccountDialog({ onSuccess: () => { if (hasPassword) { toast.success("Your account has been deleted"); - router.push("/auth/login"); + router.push("/login"); } else { setEmailSent(true); } @@ -346,9 +391,8 @@ export default function AccountSettingsPage() { const [isProfileInitialized, setIsProfileInitialized] = useState(false); const [showPasswordDialog, setShowPasswordDialog] = useState(false); const [showTwoFactorDialog, setShowTwoFactorDialog] = useState(false); - const [unlinkProvider, setUnlinkProvider] = useState( - null - ); + const [unlinkAccountTarget, setUnlinkAccountTarget] = + useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); useEffect(() => { @@ -401,15 +445,20 @@ export default function AccountSettingsPage() { }); const unlinkAccount = useMutation({ - mutationFn: async (providerId: string) => { - const result = await authClient.unlinkAccount({ providerId }); + mutationFn: async (accountToUnlink: Account) => { + const result = await authClient.unlinkAccount({ + providerId: accountToUnlink.providerId, + accountId: accountToUnlink.accountId, + }); if (result.error) { throw new Error(result.error.message); } return result; }, - onSuccess: () => { - toast.success("Account unlinked successfully"); + onSuccess: (_result, accountToUnlink) => { + const providerName = + PROVIDER_CONFIG[accountToUnlink.providerId]?.name ?? "Social"; + toast.success(`${providerName} sign-in disconnected`); queryClient.invalidateQueries({ queryKey: ["user-accounts"] }); }, }); @@ -594,7 +643,8 @@ export default function AccountSettingsPage() { Connected Identities - Link your accounts for easier sign-in + Personal sign-in methods for your Databuddy account. These are + not organization data integrations. @@ -613,39 +663,64 @@ export default function AccountSettingsPage() { (acc) => acc.providerId === provider ); const isOnlyAccount = - accounts.length === 1 && !!connectedAccount; + accounts.length <= 1 && !!connectedAccount; return (
{index > 0 && } -
-
+
+
-
+
{config.name} {connectedAccount - ? "Linked to your account" + ? `Provider account ID: ${connectedAccount.accountId}` : `Sign in with ${config.name}`} + {connectedAccount && ( + <> + + Permissions:{" "} + {formatAccountScopes( + connectedAccount.scopes + )} + + {isOnlyAccount && ( + + Add another sign-in method before + disconnecting this one. + + )} + + )}
{connectedAccount ? ( -
- {!isOnlyAccount && ( - - )} +
+ Connected
) : ( @@ -747,16 +822,16 @@ export default function AccountSettingsPage() { open={showTwoFactorDialog} /> setUnlinkProvider(null)} + onClose={() => setUnlinkAccountTarget(null)} onConfirm={() => { - if (unlinkProvider) { - unlinkAccount.mutate(unlinkProvider, { - onSuccess: () => setUnlinkProvider(null), + if (unlinkAccountTarget) { + unlinkAccount.mutate(unlinkAccountTarget, { + onSuccess: () => setUnlinkAccountTarget(null), }); } }} - provider={unlinkProvider} /> {user?.email && ( save( diff --git a/apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.test.ts b/apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.test.ts new file mode 100644 index 0000000000..a97ef1dd30 --- /dev/null +++ b/apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { summarizeTestDelivery } from "./notification-test-result"; + +describe("summarizeTestDelivery", () => { + it("does not claim success when every destination fails", () => { + expect( + summarizeTestDelivery([ + { channel: "email", success: false }, + { channel: "slack", success: false }, + ]) + ).toMatchObject({ + kind: "error", + title: "Test failed for Email, Slack", + }); + }); + + it("names partial delivery", () => { + expect( + summarizeTestDelivery([ + { channel: "email", success: true }, + { channel: "webhook", success: false }, + ]) + ).toMatchObject({ + kind: "warning", + title: "Test delivered via Email", + description: + "Delivery failed for Webhook. Check those destinations and try again.", + }); + }); +}); diff --git a/apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.ts b/apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.ts new file mode 100644 index 0000000000..931944a49e --- /dev/null +++ b/apps/dashboard/app/(main)/settings/notifications/_components/notification-test-result.ts @@ -0,0 +1,58 @@ +const CHANNEL_LABELS: Record = { + email: "Email", + slack: "Slack", + webhook: "Webhook", +}; + +interface DeliveryResult { + channel: string; + success: boolean; +} + +export interface DeliverySummary { + description?: string; + kind: "error" | "success" | "warning"; + title: string; +} + +function formatChannels(channels: string[]): string { + return [...new Set(channels)] + .map((channel) => CHANNEL_LABELS[channel] ?? channel) + .join(", "); +} + +export function summarizeTestDelivery( + results: DeliveryResult[] +): DeliverySummary { + const delivered = results.filter((item) => item.success); + const failed = results.filter((item) => !item.success); + + if (delivered.length === 0) { + const channels = formatChannels(failed.map((item) => item.channel)); + return { + kind: "error", + title: channels + ? `Test failed for ${channels}` + : "No test notification was sent", + description: + "Check the alert destinations and their credentials, then try again.", + }; + } + + const deliveredChannels = formatChannels( + delivered.map((item) => item.channel) + ); + if (failed.length > 0) { + const failedChannels = formatChannels(failed.map((item) => item.channel)); + return { + kind: "warning", + title: `Test delivered via ${deliveredChannels}`, + description: `Delivery failed for ${failedChannels}. Check those destinations and try again.`, + }; + } + + return { + kind: "success", + title: `Test delivered via ${deliveredChannels}`, + }; +} diff --git a/apps/dashboard/app/(main)/settings/notifications/page.tsx b/apps/dashboard/app/(main)/settings/notifications/page.tsx index de7939de23..d00033873d 100644 --- a/apps/dashboard/app/(main)/settings/notifications/page.tsx +++ b/apps/dashboard/app/(main)/settings/notifications/page.tsx @@ -14,6 +14,7 @@ import { toast } from "sonner"; import { orpc } from "@/lib/orpc"; import { AlarmSheet } from "./_components/alarm-sheet"; import { EmailPreferencesCard } from "./_components/email-preferences-card"; +import { summarizeTestDelivery } from "./_components/notification-test-result"; import { Dialog, DropdownMenu, Switch } from "@databuddy/ui/client"; import { Badge, @@ -182,13 +183,15 @@ export default function NotificationsSettingsPage() { const handleTest = async (alarm: Alarm) => { setTestingAlarmId(alarm.id); try { - await toast.promise(testMutation.mutateAsync({ alarmId: alarm.id }), { - loading: "Sending test…", - success: "Test sent", - error: "Test failed", + const result = await testMutation.mutateAsync({ alarmId: alarm.id }); + const summary = summarizeTestDelivery(result.results); + toast[summary.kind](summary.title, { + description: summary.description, }); } catch { - // toast.promise handles + toast.error("Test could not be sent", { + description: "Check the alert destinations and try again.", + }); } finally { setTestingAlarmId(null); } @@ -292,13 +295,13 @@ export default function NotificationsSettingsPage() {
- + diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts b/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts index 9de2a68609..3bd13dbb83 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/constants/settings-constants.ts @@ -101,17 +101,11 @@ export const BASIC_TRACKING_OPTIONS: TrackingOptionConfig[] = [ ]; export const ADVANCED_TRACKING_OPTIONS: TrackingOptionConfig[] = [ - { - key: "trackPerformance", - title: "Performance", - description: "Track page load and runtime performance", - data: ["Page load time", "DOM ready", "First paint"], - }, { key: "trackWebVitals", title: "Web Vitals", - description: "Track Core Web Vitals (LCP, FID, CLS, INP)", - data: ["LCP", "FID", "CLS", "INP", "TTFB"], + description: "Track page performance and Core Web Vitals", + data: ["FCP", "LCP", "CLS", "INP", "TTFB", "FPS"], }, { key: "trackErrors", diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.test.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.test.ts new file mode 100644 index 0000000000..3b02bc3e71 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "bun:test"; +import { generateNpmCode, generateScriptTag } from "./code-generators"; +import { RECOMMENDED_DEFAULTS } from "./tracking-defaults"; + +describe("recommended tracking snippets", () => { + it("keeps zero-config page views and performance tracking enabled", () => { + const script = generateScriptTag("example-client-id", RECOMMENDED_DEFAULTS); + const npm = generateNpmCode("example-client-id", RECOMMENDED_DEFAULTS); + + expect(script).toContain('data-track-web-vitals="true"'); + expect(script).not.toContain("track-performance"); + expect(npm).toContain("trackWebVitals={true}"); + expect(npm).not.toContain("trackPerformance"); + expect(npm).not.toContain("trackScreenViews"); + expect(npm).not.toContain("trackSessions"); + }); +}); diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-defaults.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-defaults.ts index 4a3dcc1689..97b7b56aba 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-defaults.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-defaults.ts @@ -3,15 +3,12 @@ import type { TrackingOptions } from "./types"; // Mirrors the SDK's zero-config behavior. Source of truth: packages/sdk/src/core/types.ts. export const ACTUAL_LIBRARY_DEFAULTS: TrackingOptions = { disabled: false, - trackScreenViews: true, trackHashChanges: false, - trackSessions: true, trackAttributes: false, trackOutgoingLinks: false, trackInteractions: false, - trackPerformance: true, trackWebVitals: false, trackErrors: false, @@ -22,9 +19,10 @@ export const ACTUAL_LIBRARY_DEFAULTS: TrackingOptions = { enableBatching: true, batchSize: 10, - batchTimeout: 2000, + batchTimeout: 5000, }; export const RECOMMENDED_DEFAULTS: TrackingOptions = { ...ACTUAL_LIBRARY_DEFAULTS, + trackWebVitals: true, }; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-helpers.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-helpers.ts index 579c6c1d3d..0c3aecb1ae 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-helpers.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tracking-helpers.ts @@ -22,8 +22,6 @@ export function enableAllBasicTracking( ): TrackingOptions { return { ...options, - trackScreenViews: true, - trackSessions: true, trackInteractions: true, trackOutgoingLinks: true, }; @@ -52,7 +50,6 @@ export function enableAllPerformanceTracking( ): TrackingOptions { return { ...options, - trackPerformance: true, trackWebVitals: true, trackErrors: true, }; @@ -68,7 +65,6 @@ export function enableAllAdvancedTracking( ...options, ...enableAllPerformanceTracking(options), trackErrors: true, - trackPerformance: true, trackWebVitals: true, }; } @@ -110,12 +106,9 @@ export function enablePrivacyMode(options: TrackingOptions): TrackingOptions { return { ...options, disabled: true, - trackScreenViews: false, - trackSessions: false, trackInteractions: false, trackOutgoingLinks: false, trackAttributes: false, - trackPerformance: false, trackWebVitals: false, trackErrors: false, }; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts index fdce3ec70e..31891590a6 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts @@ -46,9 +46,6 @@ export interface TrackingOptions { trackHashChanges: boolean; trackInteractions: boolean; trackOutgoingLinks: boolean; - trackPerformance: boolean; - trackScreenViews: boolean; - trackSessions: boolean; trackWebVitals: boolean; } diff --git a/apps/dashboard/app/(main)/websites/[id]/error.tsx b/apps/dashboard/app/(main)/websites/[id]/error.tsx index 99c152b57f..36a42fc57a 100644 --- a/apps/dashboard/app/(main)/websites/[id]/error.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/error.tsx @@ -25,7 +25,8 @@ export default function WebsiteError({

Something went wrong

- {error.message || "An error occurred while loading this page"} + We couldn't load this website. Try again or return to your + websites.

{error.digest && (

diff --git a/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-summary-stats.tsx b/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-summary-stats.tsx index e9c5b86f6b..d0e8e44459 100644 --- a/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-summary-stats.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-summary-stats.tsx @@ -100,7 +100,7 @@ export const ErrorSummaryStats = ({ /> diff --git a/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.test.ts b/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.test.ts new file mode 100644 index 0000000000..bcd2f4b657 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; +import { getErrorsPerAffectedUser } from "./utils"; + +describe("getErrorsPerAffectedUser", () => { + it("reports occurrences per affected user", () => { + expect(getErrorsPerAffectedUser(12, 3)).toBe(4); + }); + + it("does not divide by zero", () => { + expect(getErrorsPerAffectedUser(12, 0)).toBe(0); + }); +}); diff --git a/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.tsx b/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.tsx index 67b0808fc1..93c9aadc02 100644 --- a/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/errors/_components/error-table-columns.tsx @@ -1,5 +1,9 @@ import { getErrorTypeIcon } from "./error-icons"; -import { getErrorCategory, getSeverityColor } from "./utils"; +import { + getErrorCategory, + getErrorsPerAffectedUser, + getSeverityColor, +} from "./utils"; import { BugIcon } from "@databuddy/ui/icons"; import { Badge, formatLocalTime } from "@databuddy/ui"; @@ -26,7 +30,7 @@ export const errorColumns = [ cell: (info: CellInfo) => { const users = info.getValue() ?? 0; const errors = (info.row?.original?.errors as number) ?? 0; - const errorRate = errors > 0 ? ((users / errors) * 100).toFixed(1) : "0"; + const errorsPerUser = getErrorsPerAffectedUser(errors, users); return (

@@ -34,7 +38,8 @@ export const errorColumns = [ {users.toLocaleString()} - {errorRate}% error rate + {errorsPerUser.toFixed(1)} error + {errorsPerUser === 1 ? "" : "s"} per affected user
); diff --git a/apps/dashboard/app/(main)/websites/[id]/errors/_components/utils.ts b/apps/dashboard/app/(main)/websites/[id]/errors/_components/utils.ts index e53fe8adb1..a7e840807a 100644 --- a/apps/dashboard/app/(main)/websites/[id]/errors/_components/utils.ts +++ b/apps/dashboard/app/(main)/websites/[id]/errors/_components/utils.ts @@ -77,3 +77,10 @@ const SEVERITY_COLORS: Record<"high" | "medium" | "low", string> = { export const getSeverityColor = (severity: "high" | "medium" | "low"): string => SEVERITY_COLORS[severity]; + +export function getErrorsPerAffectedUser( + errors: number, + users: number +): number { + return users > 0 ? errors / users : 0; +} diff --git a/apps/dashboard/app/(main)/websites/[id]/events/error.tsx b/apps/dashboard/app/(main)/websites/[id]/events/error.tsx index 2b4a4c11ed..41079b3a9e 100644 --- a/apps/dashboard/app/(main)/websites/[id]/events/error.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/events/error.tsx @@ -27,7 +27,8 @@ export default function EventsError({

Error loading events

- {error.message || "An error occurred while loading events data"} + We couldn't load events for this website. Try again or return to + the overview.

{error.digest && (

diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx index 7654dea462..1b9f7bcbc0 100644 --- a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx @@ -233,7 +233,7 @@ export default function GoalsPage() { {!isDemoRoute && deletingGoalId && ( setDeletingGoalId(null)} onConfirm={() => { diff --git a/apps/dashboard/app/(main)/websites/page.tsx b/apps/dashboard/app/(main)/websites/page.tsx index 4e19506727..28d9069439 100644 --- a/apps/dashboard/app/(main)/websites/page.tsx +++ b/apps/dashboard/app/(main)/websites/page.tsx @@ -51,7 +51,7 @@ export default function WebsitesPage() { const [dialogOpen, setDialogOpen] = useState(false); const { activeOrganization, isSwitchingOrganization } = useOrganizationsContext(); - const workspaceName = activeOrganization?.name ?? "this workspace"; + const organizationName = activeOrganization?.name ?? "this organization"; const { websites, @@ -100,7 +100,7 @@ export default function WebsitesPage() { {isSwitchingOrganization && (

- Switching workspace… + Switching organization…

)} @@ -127,9 +127,9 @@ export default function WebsitesPage() { onClick: () => setDialogOpen(true), }} className="h-full" - description={`${workspaceName} does not have any websites yet. Add one to start collecting analytics for this workspace.`} + description={`${organizationName} does not have any websites yet. Add one to start collecting analytics for this organization.`} icon={} - title="No websites in this workspace" + title="No websites in this organization" variant="minimal" /> )} diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx index 3f30d8d9f6..f512e021f7 100644 --- a/apps/dashboard/app/layout.tsx +++ b/apps/dashboard/app/layout.tsx @@ -159,7 +159,6 @@ export default function RootLayout({ scriptUrl="https://cdn.databuddy.cc/databuddy-debug.js" trackAttributes={true} trackErrors={true} - trackPerformance={true} trackWebVitals={true} /> )} diff --git a/apps/dashboard/app/public/[id]/layout.tsx b/apps/dashboard/app/public/[id]/layout.tsx index a4071d79f8..7ceb5b6f7a 100644 --- a/apps/dashboard/app/public/[id]/layout.tsx +++ b/apps/dashboard/app/public/[id]/layout.tsx @@ -44,7 +44,10 @@ export default function PublicWebsiteLayout({ if (!id) { return ( - + ); } @@ -52,7 +55,7 @@ export default function PublicWebsiteLayout({ return ( ); diff --git a/apps/dashboard/autumn.config.ts b/apps/dashboard/autumn.config.ts index 04097bc558..f71d9447bd 100644 --- a/apps/dashboard/autumn.config.ts +++ b/apps/dashboard/autumn.config.ts @@ -1,4 +1,5 @@ import { AGENT_CREDIT_SCHEMA } from "./lib/credit-schema"; +import { DATABUNNY_USAGE_NAME } from "./lib/databunny-usage"; import { TOPUP_MAX_QUANTITY, TOPUP_TIERS } from "./lib/topup-math"; import { feature, item, plan } from "atmn"; @@ -40,7 +41,7 @@ export const agent_cache_write_tokens = feature({ export const agent_credits = feature({ id: "agent_credits", - name: "Agent Credits", + name: DATABUNNY_USAGE_NAME, type: "credit_system", creditSchema: [ { @@ -180,7 +181,7 @@ export const pro = plan({ */ export const scale = plan({ id: "scale", - name: "Scale", + name: "Enterprise", addOn: false, autoEnable: false, price: { @@ -265,7 +266,7 @@ export const pulse_pro = plan({ */ export const credits_booster = plan({ id: "credits_booster", - name: "Credit Booster", + name: "Databunny usage booster", addOn: true, autoEnable: false, price: { @@ -296,7 +297,7 @@ export const credits_booster = plan({ */ export const credits_topup = plan({ id: "credits_topup", - name: "Agent Credits", + name: DATABUNNY_USAGE_NAME, addOn: true, autoEnable: false, items: [ diff --git a/apps/dashboard/components/agent/agent-chat-surface.tsx b/apps/dashboard/components/agent/agent-chat-surface.tsx index 86dd40dec7..7e3dff90d2 100644 --- a/apps/dashboard/components/agent/agent-chat-surface.tsx +++ b/apps/dashboard/components/agent/agent-chat-surface.tsx @@ -262,7 +262,7 @@ function WelcomeState({ {item.source === "insight" - ? "From your insights" + ? "From your findings" : "Suggested"} diff --git a/apps/dashboard/components/agent/agent-commands.ts b/apps/dashboard/components/agent/agent-commands.ts index d90367bc8c..495408069c 100644 --- a/apps/dashboard/components/agent/agent-commands.ts +++ b/apps/dashboard/components/agent/agent-commands.ts @@ -85,7 +85,7 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [ id: "clear", command: "/clear", title: "Clear chat", - description: "Erase chat history and start fresh", + description: "Clear messages from this view and start fresh", action: "clear", prompt: "", keywords: ["clear", "reset", "erase", "new", "fresh"], diff --git a/apps/dashboard/components/agent/agent-credit-balance.tsx b/apps/dashboard/components/agent/agent-credit-balance.tsx index d3230d40d1..e00a010604 100644 --- a/apps/dashboard/components/agent/agent-credit-balance.tsx +++ b/apps/dashboard/components/agent/agent-credit-balance.tsx @@ -61,7 +61,7 @@ export function AgentCreditBalance({ return null; } return ( - + diff --git a/apps/dashboard/components/agent/agent-input.tsx b/apps/dashboard/components/agent/agent-input.tsx index 06eca60012..074848dffa 100644 --- a/apps/dashboard/components/agent/agent-input.tsx +++ b/apps/dashboard/components/agent/agent-input.tsx @@ -3,6 +3,8 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { BrainIcon, CaretDownIcon, @@ -53,6 +55,7 @@ import { DropdownMenu } from "@databuddy/ui/client"; import { Button, Skeleton, Textarea, Tooltip } from "@databuddy/ui"; export function AgentInput() { + const router = useRouter(); const { sendMessage, setMessages, stop, status } = useChat(); const { messages: pendingMessages, removeAction } = usePendingQueue(); const isLoading = status === "streaming" || status === "submitted"; @@ -153,6 +156,15 @@ export function AgentInput() { balance <= 0 ) { bumpCreditShake((n) => n + 1); + toast.error("Databunny can't answer another question yet", { + description: + "This organization's AI analysis allowance is empty. Top up Databunny usage or change the plan to continue.", + id: "databunny-usage-empty", + action: { + label: "View billing", + onClick: () => router.push("/billing#topup"), + }, + }); return; } sendMessage({ text: input.trim() }); @@ -166,6 +178,10 @@ export function AgentInput() { setMessages([]); setInput(""); setCommandsDismissed(true); + toast.info("Messages cleared from this view", { + description: + "The saved conversation remains available in chat history.", + }); return; } setInput(command.prompt); diff --git a/apps/dashboard/components/agent/chat-history.tsx b/apps/dashboard/components/agent/chat-history.tsx index 733250db1c..3e41fbcf89 100644 --- a/apps/dashboard/components/agent/chat-history.tsx +++ b/apps/dashboard/components/agent/chat-history.tsx @@ -117,7 +117,7 @@ export function ChatHistory({ if (!organizationId) { return (
- No active workspace + No active organization
); } diff --git a/apps/dashboard/components/autumn/attach-dialog.tsx b/apps/dashboard/components/autumn/attach-dialog.tsx index ca65298277..670651adb5 100644 --- a/apps/dashboard/components/autumn/attach-dialog.tsx +++ b/apps/dashboard/components/autumn/attach-dialog.tsx @@ -2,13 +2,15 @@ import type { PreviewAttachResponse } from "autumn-js"; import { useState } from "react"; +import { getAttachDialogCopy } from "@/lib/autumn/attach-dialog-copy"; import { Dialog } from "@databuddy/ui/client"; import { Badge, Button, Divider, Text, dayjs } from "@databuddy/ui"; export interface AttachDialogProps { + action?: string | null; onConfirm: () => Promise; open: boolean; - planId: string; + planName: string; preview: PreviewAttachResponse; setOpen: (open: boolean) => void; } @@ -123,9 +125,11 @@ function NextCycleSummary({ } export default function AttachDialog({ + action, open, setOpen, preview, + planName, onConfirm, }: AttachDialogProps) { const [loading, setLoading] = useState(false); @@ -133,15 +137,14 @@ export default function AttachDialog({ const { currency, lineItems, subtotal, total, nextCycle } = preview; const discountTotal = Math.max(0, subtotal - total); const discountBreakdown = aggregateDiscounts(lineItems); + const copy = getAttachDialogCopy(action, planName); return ( - Confirm purchase - - Review the charges before confirming. - + {copy.title} + {copy.description} @@ -256,12 +259,16 @@ export default function AttachDialog({ try { await onConfirm(); setOpen(false); + } catch { + // The caller owns the action-specific error message. Keep the dialog open. } finally { setLoading(false); } }} > - {total > 0 ? `Pay ${formatMoney(total, currency)}` : "Confirm"} + {total > 0 + ? `${copy.confirmLabel} · ${formatMoney(total, currency)} due today` + : copy.confirmLabel} diff --git a/apps/dashboard/components/autumn/pricing-table.tsx b/apps/dashboard/components/autumn/pricing-table.tsx index c55a526fdc..dade28a0c4 100644 --- a/apps/dashboard/components/autumn/pricing-table.tsx +++ b/apps/dashboard/components/autumn/pricing-table.tsx @@ -16,7 +16,9 @@ import { toast } from "sonner"; import { PricingTiersTooltip } from "@/app/(main)/billing/components/pricing-tiers-tooltip"; import { getStripeMetadata } from "@/app/(main)/billing/utils/stripe-metadata"; import AttachDialog from "@/components/autumn/attach-dialog"; +import { getCustomerPlanName } from "@/lib/autumn/customer-plan-name"; import { formatLocaleNumber } from "@/lib/format-locale-number"; +import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; import { cn } from "@/lib/utils"; import { ArrowsDownUpIcon as TreeIcon, @@ -111,15 +113,15 @@ function getButtonState( isRecommendedTier: boolean, isActivelySelected: boolean ): ButtonState { + if (eligibility?.canceling) { + return { text: "Resume plan", variant: "secondary", disabled: false }; + } if (eligibility?.status === "active") { return { text: "Current plan", variant: "secondary", disabled: true }; } if (eligibility?.status === "scheduled") { return { text: "Scheduled", variant: "secondary", disabled: true }; } - if (eligibility?.canceling) { - return { text: "Resume plan", variant: "secondary", disabled: false }; - } if (isActivelySelected) { return { text: "Complete purchase", variant: "primary", disabled: false }; } @@ -326,10 +328,12 @@ export default function PricingTable({ toast.success("Plan updated"); } catch (err) { toast.error( - err instanceof Error - ? err.message - : "Failed to attach plan." + getUserFacingErrorMessage( + err, + "We couldn't update this plan. Try again." + ) ); + throw err; } }} isSelected={selectedPlan === plan.id} @@ -393,6 +397,7 @@ function PricingCard({ if (!plan) { return null; } + const planDisplayName = getCustomerPlanName(plan.id, plan.name); const eligibility = plan.customerEligibility; const Icon = getPlanIcon(plan.id); @@ -405,6 +410,11 @@ function PricingCard({ isRecommended, isActivelySelected ); + const dialogAction = eligibility?.canceling + ? "resume" + : eligibility?.trialAvailable + ? "trial" + : eligibility?.attachAction; const handleUpgradeClick = async () => { if (!previewAction) { @@ -425,7 +435,10 @@ function PricingCard({ setDialogOpen(true); } catch (err) { toast.error( - err instanceof Error ? err.message : "Failed to preview plan." + getUserFacingErrorMessage( + err, + "We couldn't load the billing preview. Try again." + ) ); } finally { setIsAttaching(false); @@ -488,7 +501,7 @@ function PricingCard({
- {plan.name} + {planDisplayName} {isActive && ( @@ -592,11 +605,12 @@ function PricingCard({ {preview && ( { await attachAction?.(); }} open={dialogOpen} - planId={plan.id} + planName={planDisplayName} preview={preview} setOpen={setDialogOpen} /> diff --git a/apps/dashboard/components/charts/metrics-constants.ts b/apps/dashboard/components/charts/metrics-constants.ts index f66b7bfb3d..c0a1a5d6a4 100644 --- a/apps/dashboard/components/charts/metrics-constants.ts +++ b/apps/dashboard/components/charts/metrics-constants.ts @@ -305,7 +305,7 @@ export const CORE_WEB_VITALS_METRICS: MetricConfig[] = [ ), createMetric( "avg_fid", - "FID (Avg)", + "FID (Legacy Avg)", "avg_fid", CursorClickIcon, formatPerformanceTime, @@ -313,7 +313,7 @@ export const CORE_WEB_VITALS_METRICS: MetricConfig[] = [ ), createMetric( "p50_fid", - "FID (P50)", + "FID (Legacy P50)", "p50_fid", CursorClickIcon, formatPerformanceTime, diff --git a/apps/dashboard/components/error-boundary.tsx b/apps/dashboard/components/error-boundary.tsx index 3168a89c2a..a409c276da 100644 --- a/apps/dashboard/components/error-boundary.tsx +++ b/apps/dashboard/components/error-boundary.tsx @@ -14,7 +14,6 @@ interface ErrorBoundaryProps { export function ErrorBoundary({ children, fallback }: ErrorBoundaryProps) { const router = useRouter(); const [hasError, setHasError] = useState(false); - const [error, setError] = useState(null); useEffect(() => { const errorHandler = (event: ErrorEvent) => { @@ -26,7 +25,6 @@ export function ErrorBoundary({ children, fallback }: ErrorBoundaryProps) { lineno: event.lineno, colno: event.colno, }); - setError(err ?? new Error(event.message || "Unknown error")); setHasError(true); }; @@ -61,20 +59,13 @@ export function ErrorBoundary({ children, fallback }: ErrorBoundaryProps) {

- Something Went Wrong + Something went wrong

We encountered an error while trying to display this content. This could be due to a temporary issue or a problem with the data.

- {error && ( -
-

- {error.toString()} -

-
- )}
@@ -96,17 +87,16 @@ export function ErrorBoundary({ children, fallback }: ErrorBoundaryProps) { } onClick={() => { setHasError(false); - setError(null); }} > - Try Again + Try again
diff --git a/apps/dashboard/components/feature-gate.tsx b/apps/dashboard/components/feature-gate.tsx index 6e597dd326..52fad09400 100644 --- a/apps/dashboard/components/feature-gate.tsx +++ b/apps/dashboard/components/feature-gate.tsx @@ -36,7 +36,7 @@ const PLAN_CONFIG: Record< }, [PLAN_IDS.PRO]: { name: "Pro", icon: StarIcon, color: "text-primary" }, [PLAN_IDS.SCALE]: { - name: "Scale", + name: "Enterprise", icon: CrownIcon, color: "text-brand-amber", }, @@ -135,10 +135,16 @@ export function FeatureGate({ ) : ( -
+

- Contact your organization owner to upgrade + Ask an organization owner or billing admin to upgrade to{" "} + {planConfig.name}.

+
)} diff --git a/apps/dashboard/components/layout/mobile-sidebar.tsx b/apps/dashboard/components/layout/mobile-sidebar.tsx index 16a4760c3e..d724cf8e2b 100644 --- a/apps/dashboard/components/layout/mobile-sidebar.tsx +++ b/apps/dashboard/components/layout/mobile-sidebar.tsx @@ -120,9 +120,13 @@ function MobileNavItem({ if (isLocked) { return ( -
{item.name} @@ -132,7 +136,7 @@ function MobileNavItem({ {lockedPlanName} )} -
+ ); } diff --git a/apps/dashboard/components/layout/navigation/navigation-config.tsx b/apps/dashboard/components/layout/navigation/navigation-config.tsx index c09a7607ca..5afe3e5815 100644 --- a/apps/dashboard/components/layout/navigation/navigation-config.tsx +++ b/apps/dashboard/components/layout/navigation/navigation-config.tsx @@ -265,7 +265,7 @@ export const settingsNavigation: NavigationGroup[] = [ searchTags: ["workspace details", "organization id", "slug"], }, { - name: "Workspace Websites", + name: "Organization Websites", href: "#websites", icon: GlobeIcon, searchTags: ["organization websites", "workspace sites"], diff --git a/apps/dashboard/components/layout/organization-selector.tsx b/apps/dashboard/components/layout/organization-selector.tsx index 65ea84556b..f6cba3b296 100644 --- a/apps/dashboard/components/layout/organization-selector.tsx +++ b/apps/dashboard/components/layout/organization-selector.tsx @@ -182,7 +182,7 @@ export function OrganizationSelector({ const isSwitching = isSwitchingOrganization; const activeOrganizationName = activeOrganization?.name ?? "Organization"; const organizationTriggerLabel = isSwitching - ? `Switching workspace from ${activeOrganizationName}` + ? `Switching organization from ${activeOrganizationName}` : `Organization: ${activeOrganizationName}`; const avatarUrl = getDicebearUrl( activeOrganization?.logo || activeOrganization?.id @@ -296,7 +296,7 @@ export function OrganizationSelector({ /> {isSwitching - ? "Switching workspace…" + ? "Switching organization…" : (activeOrganization?.name ?? "Select organization")} {isSwitching ? ( diff --git a/apps/dashboard/components/layout/sidebar.tsx b/apps/dashboard/components/layout/sidebar.tsx index 9c83144a7b..a93311a25c 100644 --- a/apps/dashboard/components/layout/sidebar.tsx +++ b/apps/dashboard/components/layout/sidebar.tsx @@ -123,10 +123,18 @@ function SidebarNavItem({ if (isLocked) { const el = ( -
{!collapsed && ( @@ -140,10 +148,13 @@ function SidebarNavItem({ )} )} -
+ ); return collapsed ? ( - + {el} ) : ( diff --git a/apps/dashboard/components/organizations/api-key-sheet.tsx b/apps/dashboard/components/organizations/api-key-sheet.tsx index b985d891cd..6f0bed4104 100644 --- a/apps/dashboard/components/organizations/api-key-sheet.tsx +++ b/apps/dashboard/components/organizations/api-key-sheet.tsx @@ -10,6 +10,7 @@ import { z } from "zod"; import { ExpirationPicker } from "@/app/(main)/links/_components/expiration-picker"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { orpc } from "@/lib/orpc"; +import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; import { cn } from "@/lib/utils"; import { formatMaskedApiKey, @@ -356,7 +357,7 @@ export function ApiKeySheet({ toast.success("API key created"); }, onError: (err: Error) => { - toast.error(err.message || "Failed to create API key"); + toast.error(getUserFacingErrorMessage(err, "Failed to create API key.")); }, }); @@ -368,7 +369,7 @@ export function ApiKeySheet({ handleClose(); }, onError: (err: Error) => { - toast.error(err.message || "Failed to update API key"); + toast.error(getUserFacingErrorMessage(err, "Failed to update API key.")); }, }); @@ -380,7 +381,7 @@ export function ApiKeySheet({ toast.success("API key rotated"); }, onError: (err: Error) => { - toast.error(err.message || "Failed to rotate API key"); + toast.error(getUserFacingErrorMessage(err, "Failed to rotate API key.")); }, }); @@ -391,7 +392,7 @@ export function ApiKeySheet({ toast.success("API key revoked"); }, onError: (err: Error) => { - toast.error(err.message || "Failed to revoke API key"); + toast.error(getUserFacingErrorMessage(err, "Failed to revoke API key.")); }, }); @@ -404,7 +405,7 @@ export function ApiKeySheet({ handleClose(); }, onError: (err: Error) => { - toast.error(err.message || "Failed to delete API key"); + toast.error(getUserFacingErrorMessage(err, "Failed to delete API key.")); }, }); diff --git a/apps/dashboard/components/resource-unavailable-state.tsx b/apps/dashboard/components/resource-unavailable-state.tsx index 36e76841b8..1ea2ce1df9 100644 --- a/apps/dashboard/components/resource-unavailable-state.tsx +++ b/apps/dashboard/components/resource-unavailable-state.tsx @@ -25,7 +25,7 @@ export function ResourceUnavailableState({ variant: "secondary", }} className={className} - description="This resource is unavailable in the current workspace. Switch workspaces or check that you have access." + description="This resource is unavailable in the current organization. Switch organizations or check that you have access." icon={} isMainContent title="Resource unavailable" diff --git a/apps/dashboard/components/ui/command-search.tsx b/apps/dashboard/components/ui/command-search.tsx index 5700336b91..b09f5a687f 100644 --- a/apps/dashboard/components/ui/command-search.tsx +++ b/apps/dashboard/components/ui/command-search.tsx @@ -121,7 +121,7 @@ function toSearchItem( name: item.name, path: path || pathPrefix, icon: item.icon, - disabled: item.disabled || locked, + disabled: item.disabled, tag: item.tag, searchTags: item.searchTags, external: item.external, @@ -374,7 +374,7 @@ export function CommandSearchProvider({ children }: { children: ReactNode }) { name: "Create API Key", subtitle: activeOrganization ? `Create a key for ${activeOrganization.name}` - : "Create a workspace API key", + : "Create an organization API key", icon: KeyIcon, action: openCreateApiKey, disabled: !(organizationId && !isSwitchingOrganization), @@ -683,7 +683,9 @@ function SearchResultItem({ }) { const ItemIcon = item.icon; const subtitle = - item.subtitle ?? + (item.lockedPlanName + ? `Requires ${item.lockedPlanName} · open upgrade options` + : item.subtitle) ?? (item.path ? item.path.startsWith("http") ? "External link" diff --git a/apps/dashboard/components/ui/credit-arc-slider.tsx b/apps/dashboard/components/ui/credit-arc-slider.tsx index fe9d56e7e7..3810a963b8 100644 --- a/apps/dashboard/components/ui/credit-arc-slider.tsx +++ b/apps/dashboard/components/ui/credit-arc-slider.tsx @@ -22,6 +22,7 @@ import { } from "react"; interface CreditArcSliderProps { + ariaLabel?: string; className?: string; max?: number; min?: number; @@ -53,6 +54,7 @@ const ARC_LENGTH = Math.PI * ARC_RADIUS; const TRACK_PATH = `M ${TRACK_START.x} ${TRACK_START.y} A ${ARC_RADIUS} ${ARC_RADIUS} 0 0 1 ${TRACK_END.x} ${TRACK_END.y}`; export function CreditArcSlider({ + ariaLabel = "Credits to buy", value, onValueChange, min = TOPUP_MIN_QUANTITY, @@ -191,7 +193,7 @@ export function CreditArcSlider({ return (
{ switch (type) { @@ -174,32 +177,49 @@ export function WebsiteErrorState({ const getTitle = () => { switch (type) { case "not_found": - return "Website Not Found"; + return isPublicDashboard + ? "Dashboard not available" + : "Website not found"; case "unauthorized": - return isDemoRoute ? "Demo Not Available" : "Authentication Required"; + if (isPublicDashboard) { + return "Dashboard not available"; + } + return isDemoRoute ? "Demo not available" : "Authentication required"; case "forbidden": - return isDemoRoute ? "Demo Not Available" : "Access Denied"; + if (isPublicDashboard) { + return "Dashboard not available"; + } + return isDemoRoute ? "Demo not available" : "Access denied"; default: - return "Something Went Wrong"; + return "Something went wrong"; } }; const getDescription = () => { switch (type) { case "not_found": + if (isPublicDashboard) { + return "This shared dashboard does not exist or sharing has been turned off."; + } return isDemoRoute ? "This demo page doesn't exist or is no longer available." : "The website you're looking for doesn't exist or has been removed."; case "unauthorized": + if (isPublicDashboard) { + return "This dashboard is private or sharing has been turned off."; + } return isDemoRoute ? "This demo page is private and requires authentication." : "You need to sign in to view this website's analytics."; case "forbidden": + if (isPublicDashboard) { + return "This dashboard is private or sharing has been turned off."; + } return isDemoRoute ? "This demo page is private and requires authentication." : "You don't have permission to view this website's analytics."; default: - return message || "We encountered an error while loading this website."; + return "We couldn't load this website. Try again in a moment."; } }; @@ -227,7 +247,7 @@ export function WebsiteErrorState({ const canGoBack = typeof window !== "undefined" && window.history.length > 1; - if (!isDemoRoute && (type === "not_found" || type === "forbidden")) { + if (!isPublicView && (type === "not_found" || type === "forbidden")) { return ( )} - - - + {isPublicView ? "Go to homepage" : "Back to websites"} + + ); } @@ -267,11 +284,11 @@ export function WebsiteErrorState({ if (type === "unauthorized" || type === "forbidden") { return (
- {isDemoRoute ? ( + {isPublicView ? ( <>
); @@ -368,7 +385,7 @@ export function WebsiteErrorState({

- {type === "not_found" && ( + {type === "not_found" && !isPublicView && ( )} - {type === "not_found" && ( + {type === "not_found" && !isPublicView && ( diff --git a/apps/dashboard/error.tsx b/apps/dashboard/error.tsx index 59ebe6fc22..b01052760f 100644 --- a/apps/dashboard/error.tsx +++ b/apps/dashboard/error.tsx @@ -28,9 +28,11 @@ export default function ErrorPage({ error, reset }: ErrorPageProps) { We encountered an unexpected error. Please try again. If the problem persists, please contact support.

-
-						{error.message || "An unknown error occurred."}
-					
+ {error.digest && ( +

+ Reference: {error.digest} +

+ )}