From 52102ed8abc9f72a33ba284904e71f269422ea12 Mon Sep 17 00:00:00 2001 From: JustinMissmahl Date: Sat, 11 Apr 2026 22:22:21 +0200 Subject: [PATCH 1/6] self_host1 --- .env.selfhost.example | 44 +++++++ .gitignore | 1 + README.md | 29 ++++- apps/api/src/index.ts | 8 +- apps/api/src/lib/cors-origins.ts | 39 ++++++ apps/api/src/routes/public/index.ts | 3 +- apps/api/src/routes/webhooks/autumn.ts | 20 ++- .../src/lib/request-validation-logic.test.ts | 30 +++++ apps/basket/src/lib/request-validation.ts | 9 +- .../dashboard/app/(dby)/dby/l/[slug]/page.tsx | 3 +- .../(main)/billing/utils/stripe-metadata.ts | 10 +- .../app/(main)/home/hooks/use-pulse-status.ts | 9 +- apps/dashboard/app/(main)/monitors/page.tsx | 11 +- .../_components/step-create-website.tsx | 7 +- .../_components/step-install-tracking.tsx | 30 +++-- .../_components/step-invite-team.tsx | 5 +- .../notifications/_components/alarm-sheet.tsx | 7 +- .../(main)/settings/notifications/page.tsx | 8 +- .../[id]/_components/utils/code-generators.ts | 33 +++-- apps/dashboard/app/databuddy.js/route.ts | 15 +++ apps/dashboard/app/errors.js/route.ts | 15 +++ apps/dashboard/app/layout.tsx | 16 +-- apps/dashboard/app/providers.tsx | 7 +- apps/dashboard/app/sitemap.ts | 6 +- apps/dashboard/app/vitals.js/route.ts | 15 +++ .../providers/organizations-provider.tsx | 55 ++++++++- apps/dashboard/components/website-dialog.tsx | 7 +- apps/dashboard/hooks/use-links.ts | 34 ++++-- apps/dashboard/hooks/use-monitors.ts | 12 +- apps/dashboard/hooks/use-websites.ts | 46 +++++-- apps/dashboard/lib/tracker-script.ts | 69 +++++++++++ apps/dashboard/next.config.ts | 31 ++++- apps/dashboard/proxy.ts | 2 +- apps/links/src/index.ts | 5 +- apps/links/src/routes/redirect.ts | 10 +- apps/uptime/src/uptime-transition-emails.ts | 5 +- dashboard.Dockerfile | 60 +++++++++ docker-compose.selfhost.yml | 115 +++++++++++++++++- packages/auth/src/auth.ts | 88 ++++++++++++-- packages/db/package.json | 2 +- packages/env/src/base.ts | 1 + packages/redis/redis.ts | 12 +- packages/rpc/src/orpc.ts | 3 +- packages/shared/package.json | 1 + packages/shared/src/utils/index.ts | 1 + packages/shared/src/utils/origins.ts | 87 +++++++++++++ turbo.json | 1 + 47 files changed, 910 insertions(+), 117 deletions(-) create mode 100644 .env.selfhost.example create mode 100644 apps/api/src/lib/cors-origins.ts create mode 100644 apps/dashboard/app/databuddy.js/route.ts create mode 100644 apps/dashboard/app/errors.js/route.ts create mode 100644 apps/dashboard/app/vitals.js/route.ts create mode 100644 apps/dashboard/lib/tracker-script.ts create mode 100644 dashboard.Dockerfile create mode 100644 packages/shared/src/utils/origins.ts diff --git a/.env.selfhost.example b/.env.selfhost.example new file mode 100644 index 000000000..457e39f5f --- /dev/null +++ b/.env.selfhost.example @@ -0,0 +1,44 @@ +BETTER_AUTH_SECRET="" +POSTGRES_PASSWORD="" +CLICKHOUSE_PASSWORD="" +REDIS_PASSWORD="" +DATABASE_URL="postgres://databuddy:@postgres:5432/databuddy" +REDIS_URL="redis://:@redis:6379" +CLICKHOUSE_URL="http://default:@clickhouse:8123" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +RESEND_API_KEY="" + +POSTGRES_DB=databuddy +POSTGRES_USER=databuddy +POSTGRES_PORT=5432 + +CLICKHOUSE_DB=databuddy_analytics +CLICKHOUSE_USER=default +CLICKHOUSE_PORT=8123 + +REDIS_PORT=6379 + +BETTER_AUTH_URL=http://localhost:3100 +DASHBOARD_URL=http://localhost:3100 +NEXT_PUBLIC_API_URL=http://localhost:3001 +NEXT_PUBLIC_APP_URL=http://localhost:3100 +NEXT_PUBLIC_BASKET_URL=http://localhost:4000 +NEXT_PUBLIC_SCRIPT_URL=http://localhost:3100/databuddy.js +NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3100 +AUTH_TRUSTED_ORIGINS=http://localhost:3100,http://localhost:3000,http://localhost:3001 +CLIENT_APP_ALLOWED_ORIGINS= +AUTH_COOKIE_DOMAIN= +APP_URL=http://localhost:3100 + +DASHBOARD_PORT=3100 +API_PORT=3001 +BASKET_PORT=4000 +LINKS_PORT=2500 + +AI_API_KEY=placeholder +LINKS_ROOT_REDIRECT_URL=http://localhost:3100 +IMAGE_TAG=edge +NODE_ENV=production diff --git a/.gitignore b/.gitignore index 6746e7bf3..c246395f2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ admin # Local env files .env +.env.prod .mcp.json .env.local .env.development.local diff --git a/README.md b/README.md index fc27674b5..1bcdefe7a 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,33 @@ Services started: All ports are configurable via env vars (`API_PORT`, `BASKET_PORT`, etc.). See the compose file comments for the full env var reference. +### Troubleshooting + +If the `init` job fails and Postgres logs show `password authentication failed for user "databuddy"`, the Postgres volume was likely initialized with an older password. + +Changing `POSTGRES_PASSWORD` or `DATABASE_URL` in your environment does not update the password stored inside an existing Postgres data volume. + +To keep the existing data, update the password inside the running Postgres container to match your current environment and redeploy: + +```bash +docker exec -it databuddy-postgres psql -U databuddy -d databuddy +``` + +Then inside `psql`: + +```sql +ALTER USER databuddy WITH PASSWORD 'your-current-postgres-password'; +``` + +Make sure the password in `DATABASE_URL` matches the same value. + +If you do not need to preserve the database, remove the existing volume and start fresh so Postgres initializes with the current environment values: + +```bash +docker compose -f docker-compose.selfhost.yml down -v +docker compose -f docker-compose.selfhost.yml up -d +``` + ## 🤝 Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. @@ -137,4 +164,4 @@ See [SECURITY.md](SECURITY.md) for reporting vulnerabilities. This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file for details. -Copyright (c) 2025 Databuddy Analytics, Inc. +Copyright (c) 2025 Databuddy Analytics, Inc. \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0b38f15d3..bf97e408f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,6 +19,7 @@ import { initLogger, log, parseError } from "evlog"; import { evlog, useLogger } from "evlog/elysia"; import { applyAuthWideEvent } from "@/lib/auth-wide-event"; import { AUTUMN_API_PREFIX, withAutumnApiPath } from "@/lib/autumn-mount"; +import { getAllowedCorsOrigins } from "@/lib/cors-origins"; import { apiLoggerDrain, enrichApiWideEvent, @@ -260,12 +261,7 @@ const app = new Elysia({ precompile: true }) .use( cors({ credentials: true, - origin: [ - /(?:^|\.)databuddy\.cc$/, - ...(process.env.NODE_ENV === "development" - ? ["http://localhost:3000"] - : []), - ], + origin: getAllowedCorsOrigins(), }) ) .use(publicApi) diff --git a/apps/api/src/lib/cors-origins.ts b/apps/api/src/lib/cors-origins.ts new file mode 100644 index 000000000..89415f17b --- /dev/null +++ b/apps/api/src/lib/cors-origins.ts @@ -0,0 +1,39 @@ +import { + getClientAppAllowedOrigins, + normalizeOrigin, +} from "@databuddy/shared/utils/origins"; + +const DATABUDDY_DOMAIN_REGEX = /(?:^|\.)databuddy\.cc$/; + +export function getAllowedCorsOrigins(): Array { + const defaults = [ + process.env.BETTER_AUTH_URL, + process.env.NEXT_PUBLIC_APP_URL, + process.env.NEXT_PUBLIC_API_URL, + process.env.DASHBOARD_URL, + process.env.APP_URL, + process.env.NODE_ENV === "development" + ? "http://localhost:3000" + : undefined, + process.env.NODE_ENV === "development" + ? "http://localhost:3100" + : undefined, + process.env.NODE_ENV === "development" + ? "http://localhost:3001" + : undefined, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => normalizeOrigin(value)) + .filter((value): value is string => Boolean(value)); + + const extraOrigins = getClientAppAllowedOrigins(); + const authTrustedOrigins = (process.env.AUTH_TRUSTED_ORIGINS ?? "") + .split(",") + .map((origin) => normalizeOrigin(origin)) + .filter((origin): origin is string => Boolean(origin)); + + return [ + DATABUDDY_DOMAIN_REGEX, + ...new Set([...defaults, ...authTrustedOrigins, ...extraOrigins]), + ]; +} diff --git a/apps/api/src/routes/public/index.ts b/apps/api/src/routes/public/index.ts index b56972b15..795d72fde 100644 --- a/apps/api/src/routes/public/index.ts +++ b/apps/api/src/routes/public/index.ts @@ -2,6 +2,7 @@ import cors from "@elysiajs/cors"; import { serverTiming } from "@elysiajs/server-timing"; import { Elysia } from "elysia"; import { parseError } from "evlog"; +import { getAllowedCorsOrigins } from "@/lib/cors-origins"; import { captureError, mergeWideEvent } from "@/lib/tracing"; import { agentTelemetryRoute } from "./agent-telemetry"; import { flagsRoute } from "./flags"; @@ -22,7 +23,7 @@ export const publicApi = new Elysia({ prefix: "/public" }) .use( cors({ credentials: false, - origin: true, + origin: getAllowedCorsOrigins(), }) ) .options("*", () => new Response(null, { status: 204 })) diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index 7f7a36a5b..86c7a6ffd 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -10,11 +10,18 @@ import { Resend } from "resend"; import { Webhook } from "svix"; import { mergeWideEvent } from "../../lib/tracing"; -const resend = new Resend(process.env.RESEND_API_KEY); const SVIX_SECRET = process.env.AUTUMN_WEBHOOK_SECRET; const SLACK_URL = process.env.SLACK_WEBHOOK_URL ?? ""; const COOLDOWN_DAYS = 7; +function getResendClient(): Resend | null { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + return null; + } + return new Resend(apiKey); +} + interface LimitReachedData { customer_id: string; entity_id?: string; @@ -145,6 +152,17 @@ async function sendAlertEmailAction(opts: { return { success: false, message: "No email found" }; } + const resend = getResendClient(); + if (!resend) { + useLogger().warn( + "Skipping alert email - RESEND_API_KEY is not configured", + { + autumn: { customerId, cooldownKey }, + } + ); + return { success: true, message: "Email provider not configured" }; + } + const result = await resend.emails.send({ from: "Databuddy ", to: userData.email, diff --git a/apps/basket/src/lib/request-validation-logic.test.ts b/apps/basket/src/lib/request-validation-logic.test.ts index 7404dda2c..cc736b49e 100644 --- a/apps/basket/src/lib/request-validation-logic.test.ts +++ b/apps/basket/src/lib/request-validation-logic.test.ts @@ -83,9 +83,16 @@ vi.mock("@lib/tracing", () => ({ record: (_name: string, fn: Function) => Promise.resolve().then(() => fn()), captureError: vi.fn(), })); +mock.module("@databuddy/shared/utils/origins", () => ({ + getClientAppAllowedOrigins: mock(() => []), + isOriginInList: mock(() => false), +})); // Import after mocks const { validateRequest, checkForBot } = await import("./request-validation"); +const { getClientAppAllowedOrigins, isOriginInList } = await import( + "@databuddy/shared/utils/origins" +); function makeReq( url = "https://example.com?client_id=ws_1", @@ -112,6 +119,8 @@ describe("validateRequest", () => { mockIsValidIpFromSettings.mockReset(); mockLogBlockedTraffic.mockReset(); mockLoggerSet.mockReset(); + (getClientAppAllowedOrigins as any).mockReset(); + (isOriginInList as any).mockReset(); // Defaults: everything passes mockGetWebsiteByIdV2.mockResolvedValue({ @@ -127,6 +136,8 @@ describe("validateRequest", () => { mockIsValidOrigin.mockReturnValue(true); mockIsValidOriginFromSettings.mockReturnValue(true); mockIsValidIpFromSettings.mockReturnValue(true); + (getClientAppAllowedOrigins as any).mockReturnValue([]); + (isOriginInList as any).mockReturnValue(false); }); test("happy path → returns ValidatedRequest", async () => { @@ -270,6 +281,25 @@ describe("validateRequest", () => { } }); + test("globally allowed client app origin bypasses website origin checks", async () => { + (getClientAppAllowedOrigins as any).mockReturnValue([ + "https://starter1.emeruslabs.com", + ]); + (isOriginInList as any).mockReturnValue(true); + + const result = await validateRequest( + {}, + { client_id: "ws_1" }, + makeReq("https://example.com", { + origin: "https://starter1.emeruslabs.com", + }) + ); + + expect(result.clientId).toBe("ws_1"); + expect(mockIsValidOrigin).not.toHaveBeenCalled(); + expect(mockIsValidOriginFromSettings).not.toHaveBeenCalled(); + }); + test("IP not authorized → throws 403", async () => { mockGetWebsiteByIdV2.mockResolvedValue({ id: "ws_1", diff --git a/apps/basket/src/lib/request-validation.ts b/apps/basket/src/lib/request-validation.ts index 531cac6ee..a850f48f7 100644 --- a/apps/basket/src/lib/request-validation.ts +++ b/apps/basket/src/lib/request-validation.ts @@ -16,6 +16,10 @@ import { VALIDATION_LIMITS, validatePayloadSize, } from "@utils/validation"; +import { + getClientAppAllowedOrigins, + isOriginInList, +} from "@databuddy/shared/utils/origins"; import { useLogger } from "evlog/elysia"; export interface ValidatedRequest { @@ -141,12 +145,15 @@ export function validateRequest( const origin = request.headers.get("origin"); const ip = extractIpFromRequest(request); + const clientAppAllowedOrigins = getClientAppAllowedOrigins(); const securitySettings = getWebsiteSecuritySettings(website.settings); const allowedOrigins = securitySettings?.allowedOrigins; const allowedIps = securitySettings?.allowedIps; - if (origin && allowedOrigins && allowedOrigins.length > 0) { + if (origin && isOriginInList(origin, clientAppAllowedOrigins)) { + log.set({ validation: { clientAppOriginAllowed: true, origin } }); + } else if (origin && allowedOrigins && allowedOrigins.length > 0) { if ( !(await record("isValidOriginFromSettings", () => isValidOriginFromSettings(origin, allowedOrigins) diff --git a/apps/dashboard/app/(dby)/dby/l/[slug]/page.tsx b/apps/dashboard/app/(dby)/dby/l/[slug]/page.tsx index 0c40b38c0..af1535830 100644 --- a/apps/dashboard/app/(dby)/dby/l/[slug]/page.tsx +++ b/apps/dashboard/app/(dby)/dby/l/[slug]/page.tsx @@ -1,4 +1,3 @@ -import { db } from "@databuddy/db"; import { type CachedLink, getCachedLink, @@ -15,6 +14,8 @@ async function getLinkBySlug(slug: string): Promise { return cached; } + const { db } = await import("@databuddy/db"); + const dbLink = await db.query.links.findFirst({ where: (links, { and, eq, isNull }) => and(eq(links.slug, slug), isNull(links.deletedAt)), diff --git a/apps/dashboard/app/(main)/billing/utils/stripe-metadata.ts b/apps/dashboard/app/(main)/billing/utils/stripe-metadata.ts index b8f2d0d26..415dc428d 100644 --- a/apps/dashboard/app/(main)/billing/utils/stripe-metadata.ts +++ b/apps/dashboard/app/(main)/billing/utils/stripe-metadata.ts @@ -1,13 +1,13 @@ import { getTrackingIds } from "@databuddy/sdk"; -const DATABUDDY_CLIENT_ID = - process.env.NEXT_PUBLIC_DATABUDDY_CLIENT_ID ?? "OXmNQsViBT-FOS_wZCTHc"; +const DATABUDDY_CLIENT_ID = process.env.NEXT_PUBLIC_DATABUDDY_CLIENT_ID; export function getStripeMetadata(): Record { const { anonId, sessionId } = getTrackingIds(); - const metadata: Record = { - databuddy_client_id: DATABUDDY_CLIENT_ID, - }; + const metadata: Record = {}; + if (DATABUDDY_CLIENT_ID) { + metadata.databuddy_client_id = DATABUDDY_CLIENT_ID; + } if (sessionId) { metadata.databuddy_session_id = sessionId; } diff --git a/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts b/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts index f1089154a..378dceb2e 100644 --- a/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts +++ b/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; import { useFeatureAccess } from "@/hooks/use-feature-access"; import { orpc } from "@/lib/orpc"; @@ -23,12 +24,16 @@ export interface PulseStatus { export function usePulseStatus() { const { hasAccess, isLoading: isAccessLoading } = useFeatureAccess("monitors"); + const { activeOrganization, activeOrganizationId } = + useOrganizationsContext(); + const organizationId = + activeOrganization?.id ?? activeOrganizationId ?? undefined; const query = useQuery({ ...orpc.uptime.listSchedules.queryOptions({ - input: {}, + input: organizationId ? { organizationId } : {}, }), - enabled: hasAccess, + enabled: hasAccess && !!organizationId, }); type ScheduleRow = PulseStatus["monitors"][number]; diff --git a/apps/dashboard/app/(main)/monitors/page.tsx b/apps/dashboard/app/(main)/monitors/page.tsx index 81a6cde57..7cbc29afb 100644 --- a/apps/dashboard/app/(main)/monitors/page.tsx +++ b/apps/dashboard/app/(main)/monitors/page.tsx @@ -7,6 +7,7 @@ import { UserPlusIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { Suspense, useState } from "react"; import { PageHeader } from "@/app/(main)/websites/_components/page-header"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; import { ErrorBoundary } from "@/components/error-boundary"; import { FeatureAccessGate } from "@/components/feature-access-gate"; import { MonitorRow } from "@/components/monitors/monitor-row"; @@ -45,6 +46,10 @@ export interface Monitor { export default function MonitorsPage() { const { hasAccess, isLoading: isAccessLoading } = useFeatureAccess("monitors"); + const { activeOrganization, activeOrganizationId } = + useOrganizationsContext(); + const organizationId = + activeOrganization?.id ?? activeOrganizationId ?? undefined; const [isSheetOpen, setIsSheetOpen] = useState(false); const [showInviteDialog, setShowInviteDialog] = useState(false); const [editingSchedule, setEditingSchedule] = useState<{ @@ -60,8 +65,10 @@ export default function MonitorsPage() { } | null>(null); const schedulesQuery = useQuery({ - ...orpc.uptime.listSchedules.queryOptions({ input: {} }), - enabled: hasAccess, + ...orpc.uptime.listSchedules.queryOptions({ + input: organizationId ? { organizationId } : {}, + }), + enabled: hasAccess && !!organizationId, }); const handleCreate = () => { diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx index d8cdda4d0..8a90bec8e 100644 --- a/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx +++ b/apps/dashboard/app/(main)/onboarding/_components/step-create-website.tsx @@ -42,7 +42,10 @@ interface StepCreateWebsiteProps { } export function StepCreateWebsite({ onComplete }: StepCreateWebsiteProps) { - const { activeOrganization } = useOrganizationsContext(); + const { activeOrganization, activeOrganizationId } = + useOrganizationsContext(); + const organizationId = + activeOrganization?.id ?? activeOrganizationId ?? undefined; const createWebsiteMutation = useCreateWebsite(); const form = useForm({ @@ -59,7 +62,7 @@ export function StepCreateWebsite({ onComplete }: StepCreateWebsiteProps) { const result = await createWebsiteMutation.mutateAsync({ name: formData.name, domain: formData.domain, - organizationId: activeOrganization?.id, + organizationId, }); toast.success("Website created!"); try { 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 89a6058ac..4e40ec0ed 100644 --- a/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx +++ b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx @@ -37,6 +37,20 @@ import { // TODO: Replace with published skill URL once available const SKILL_URL = "https://github.com/databuddy-cc/skill"; +const TRACKER_SCRIPT_URL = + process.env.NEXT_PUBLIC_SCRIPT_URL || "https://cdn.databuddy.cc/databuddy.js"; +const TRACKER_API_URL = + process.env.NEXT_PUBLIC_BASKET_URL || "https://basket.databuddy.cc"; +const PUBLIC_API_URL = + process.env.NEXT_PUBLIC_API_URL || "https://api.databuddy.cc"; + +function toOrigin(value: string) { + try { + return new URL(value).origin; + } catch { + return value; + } +} function generateAgentPrompt(websiteId: string): string { return `Add Databuddy analytics to this repository. Client ID: ${websiteId} @@ -70,7 +84,7 @@ import { Databuddy } from "@databuddy/sdk/vue"; **Vanilla JS / HTML** — CDN script in \`\`: \`\`\`html - + \`\`\` Store the Client ID in an env var — never hardcode it. @@ -116,8 +130,8 @@ Use snake_case event names. Track decisions and milestones (signup_completed, pu ## Verification — How to Confirm It Works 1. Open DevTools → Network tab, reload the page -2. Look for a request to cdn.databuddy.cc/databuddy.js (script loading) -3. Look for requests to basket.databuddy.cc (events being sent) +2. Look for a request to ${TRACKER_SCRIPT_URL} (script loading) +3. Look for requests to ${TRACKER_API_URL} (events being sent) 4. Both should return 200. If events show the correct Client ID in the payload, tracking is working. ## Common Issues & Fixes @@ -125,8 +139,8 @@ Use snake_case event names. Track decisions and milestones (signup_completed, pu **Domain mismatch**: Events are rejected if sent from a domain that doesn't match the website configured in Databuddy. The domain in settings must match the domain the script runs on. **Content Security Policy (CSP)**: If the site has strict CSP headers, add these directives: -- script-src: https://cdn.databuddy.cc -- connect-src: https://basket.databuddy.cc +- script-src: ${toOrigin(TRACKER_SCRIPT_URL)} +- connect-src: ${toOrigin(TRACKER_API_URL)} **Ad blockers**: uBlock Origin, Privacy Badger, and similar extensions may block analytics scripts. Test with extensions disabled. For production, consider a custom tracking domain (proxy through your own domain). @@ -134,7 +148,7 @@ Use snake_case event names. Track decisions and milestones (signup_completed, pu **Script not loading**: Verify the script tag is in (not ), the src URL is correct, and no CSP or network error appears in the console. -**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. +**Events not appearing in dashboard**: Data typically appears within 30 seconds. Check the Network tab for failed requests to ${TRACKER_API_URL}. 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. @@ -143,7 +157,7 @@ Use snake_case event names. Track decisions and milestones (signup_completed, pu 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 \\ +curl -X POST ${PUBLIC_API_URL}/public/v1/agent-telemetry \\ -H "Content-Type: application/json" \\ -d '{ "websiteId": "${websiteId}", @@ -164,7 +178,7 @@ Fields: - **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}] +- **issues**: Array of problems encountered, e.g. [{"type": "csp", "detail": "blocked ${TRACKER_SCRIPT_URL}", "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.) diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-invite-team.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-invite-team.tsx index b481166bc..1694d852f 100644 --- a/apps/dashboard/app/(main)/onboarding/_components/step-invite-team.tsx +++ b/apps/dashboard/app/(main)/onboarding/_components/step-invite-team.tsx @@ -44,8 +44,9 @@ interface SentInvite { } export function StepInviteTeam() { - const { activeOrganization } = useOrganizationsContext(); - const organizationId = activeOrganization?.id ?? ""; + const { activeOrganization, activeOrganizationId } = + useOrganizationsContext(); + const organizationId = activeOrganization?.id ?? activeOrganizationId ?? ""; const { inviteMember, isInviting } = useOrganizationInvitations(organizationId); const [sentInvites, setSentInvites] = useState([]); diff --git a/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx index 799ce07d7..0e87e702f 100644 --- a/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx +++ b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx @@ -143,13 +143,14 @@ export function AlarmSheet({ const isEditing = !!alarm; const { activeOrganization, activeOrganizationId } = useOrganizationsContext(); + const organizationId = activeOrganization?.id ?? activeOrganizationId ?? null; const queryClient = useQueryClient(); const { data: websites } = useQuery({ ...orpc.websites.list.queryOptions({ - input: {}, + input: organizationId ? { organizationId } : {}, }), - enabled: open, + enabled: open && !!organizationId, }); const form = useForm({ @@ -178,8 +179,6 @@ export function AlarmSheet({ const isPending = createMutation.isPending || updateMutation.isPending; const handleSubmit = async (data: AlarmFormData) => { - const organizationId = - activeOrganization?.id ?? activeOrganizationId ?? null; if (!organizationId) { return; } diff --git a/apps/dashboard/app/(main)/settings/notifications/page.tsx b/apps/dashboard/app/(main)/settings/notifications/page.tsx index 8e7ef459e..58b1807e3 100644 --- a/apps/dashboard/app/(main)/settings/notifications/page.tsx +++ b/apps/dashboard/app/(main)/settings/notifications/page.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; import { orpc } from "@/lib/orpc"; import { AlarmSheet } from "./_components/alarm-sheet"; @@ -101,6 +102,10 @@ function parseAlarms(rows: readonly Record[]): Alarm[] { export default function NotificationsSettingsPage() { const queryClient = useQueryClient(); + const { activeOrganization, activeOrganizationId } = + useOrganizationsContext(); + const organizationId = + activeOrganization?.id ?? activeOrganizationId ?? undefined; const [sheetOpen, setSheetOpen] = useState(false); const [editingAlarm, setEditingAlarm] = useState(null); const [deletingAlarm, setDeletingAlarm] = useState(null); @@ -108,8 +113,9 @@ export default function NotificationsSettingsPage() { const { data: alarms, isLoading } = useQuery({ ...orpc.alarms.list.queryOptions({ - input: {}, + input: organizationId ? { organizationId } : {}, }), + enabled: !!organizationId, }); const deleteMutation = useMutation({ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts index 922133799..1d3b0dbd4 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts @@ -1,6 +1,21 @@ import { ACTUAL_LIBRARY_DEFAULTS } from "./tracking-defaults"; import type { TrackingOptions } from "./types"; +function getTrackerEndpoints() { + const isLocalhost = process.env.NODE_ENV === "development"; + + return { + scriptUrl: + process.env.NEXT_PUBLIC_SCRIPT_URL ?? + (isLocalhost + ? "http://localhost:3000/databuddy.js" + : "https://cdn.databuddy.cc/databuddy.js"), + apiUrl: + process.env.NEXT_PUBLIC_BASKET_URL ?? + (isLocalhost ? "http://localhost:4000" : "https://basket.databuddy.cc"), + }; +} + /** * Generate HTML script tag for tracking */ @@ -8,13 +23,7 @@ export function generateScriptTag( websiteId: string, trackingOptions: TrackingOptions ): string { - const isLocalhost = process.env.NODE_ENV === "development"; - const scriptUrl = isLocalhost - ? "http://localhost:3000/databuddy.js" - : "https://cdn.databuddy.cc/databuddy.js"; - const _apiUrl = isLocalhost - ? "http://localhost:4000" - : "https://basket.databuddy.cc"; + const { apiUrl, scriptUrl } = getTrackerEndpoints(); const options = Object.entries(trackingOptions) .filter(([key, value]) => { @@ -38,6 +47,7 @@ export function generateScriptTag( return `