Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development dotenv -- bun --hot src/index.ts --port 3001",
"dev": "NODE_ENV=development dotenv -- bun --hot src/index.ts",
"test": "TZ=UTC bunx --bun vitest run",
"test:integration": "TZ=UTC bunx --bun vitest run --config vitest.integration.config.ts",
"test:watch": "TZ=UTC bunx --bun vitest"
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ registerShutdownHooks();

export default {
fetch: app.fetch,
port: Number.parseInt(apiEnv.PORT, 10),
// In development env validation is skipped, so zod defaults never apply and
// apiEnv.PORT can be undefined — fall back explicitly or Bun binds a random port.
port: Number.parseInt(apiEnv.PORT ?? "3001", 10) || 3001,
idleTimeout: BUN_IDLE_TIMEOUT_SECONDS,
};
128 changes: 127 additions & 1 deletion apps/api/src/integration/profile-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import "@databuddy/test/env";

import { profileAliases, profiles } from "@databuddy/db/schema";
import {
profileAliases,
profiles,
profileTraitChanges,
} from "@databuddy/db/schema";
import { eq } from "@databuddy/db";
import { appRouter, type Context } from "@databuddy/rpc";
import { splitTraits, upsertProfile } from "@databuddy/services/identity";
import {
addToOrganization,
cleanup,
Expand Down Expand Up @@ -120,3 +126,123 @@ describe("profiles.get", () => {
expect(result).toBeNull();
});
});

describe("profiles.getHistory", () => {
iit("returns trait changes newest first", async () => {
const user = await signUp();
const org = await insertOrganization();
await addToOrganization(user.id, org.id, "member");
const website = await insertWebsite({ organizationId: org.id });
await seedProfile(website.id, "user_1");
await db()
.insert(profileTraitChanges)
.values([
{
id: "change_old",
websiteId: website.id,
profileId: "user_1",
traits: { plan: "free" },
changes: { plan: { old: null, new: "free" } },
source: "identify",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
{
id: "change_new",
websiteId: website.id,
profileId: "user_1",
traits: { plan: "pro" },
changes: { plan: { old: "free", new: "pro" } },
source: "billing",
createdAt: new Date("2026-02-01T00:00:00Z"),
},
]);

const result = await call(appRouter.profiles.getHistory, {
...userContext(user, org.id),
})({ websiteId: website.id, profileId: "user_1" });

expect(result.map((r) => r.source)).toEqual(["billing", "identify"]);
expect(result[0]?.changes).toEqual({ plan: { old: "free", new: "pro" } });
});

iit("does not leak history from another organization's website", async () => {
const outsider = await signUp();
const outsiderOrg = await insertOrganization();
await addToOrganization(outsider.id, outsiderOrg.id, "member");

const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await expectCode(
call(appRouter.profiles.getHistory, {
...userContext(outsider, outsiderOrg.id),
})({ websiteId: website.id, profileId: "user_1" }),
"FORBIDDEN"
);
});
});

describe("upsertProfile trait history", () => {
iit("records baseline on first identify, diff on the next", async () => {
const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await upsertProfile(website.id, "user_1", splitTraits({ plan: "free" }));
await upsertProfile(
website.id,
"user_1",
splitTraits({ plan: "pro" }),
"billing"
);

const rows = await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"))
.orderBy(profileTraitChanges.createdAt);

expect(rows).toHaveLength(2);
expect(rows[0]?.changes).toEqual({ plan: { old: null, new: "free" } });
expect(rows[0]?.source).toBe("identify");
expect(rows[1]?.changes).toEqual({ plan: { old: "free", new: "pro" } });
expect(rows[1]?.source).toBe("billing");
expect(rows[1]?.traits).toEqual({ plan: "pro" });
});

iit("writes no history row when traits are unchanged", async () => {
const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));
await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));

const rows = await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"));

expect(rows).toHaveLength(1);
});

iit("cascades history deletion when the profile is deleted", async () => {
const org = await insertOrganization();
const website = await insertWebsite({ organizationId: org.id });

await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));
expect(
await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"))
).toHaveLength(1);

await db().delete(profiles).where(eq(profiles.profileId, "user_1"));

expect(
await db()
.select()
.from(profileTraitChanges)
.where(eq(profileTraitChanges.profileId, "user_1"))
).toHaveLength(0);
});
});
148 changes: 142 additions & 6 deletions apps/dashboard/app/(main)/websites/[id]/users/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,29 @@ import { type ElementType, type ReactNode, useCallback, useState } from "react";
import { BrowserIcon, CountryFlag, OSIcon } from "@/components/icon";
import { useDateFilters } from "@/hooks/use-date-filters";
import { getDeviceIcon } from "@/components/device-icon";
import { useProfileIdentity } from "@/hooks/use-profiles";
import { useProfileHistory, useProfileIdentity } from "@/hooks/use-profiles";
import { cn } from "@/lib/utils";
import { generateProfileName } from "./_components/generate-profile-name";
import { SessionRow } from "./_components/session-row";
import { useUserProfile } from "./use-user-profile";
import {
ArrowLeftIcon,
ChartLineIcon,
ClockCounterClockwiseIcon,
ClockIcon,
DevicesIcon,
GaugeIcon,
GlobeIcon,
LockSimpleIcon,
TagIcon,
UserIcon,
} from "@databuddy/ui/icons";
import {
Button,
EmptyState,
Spinner,
StatusDot,
Tooltip,
formatDateOnly,
formatLocalTime,
} from "@databuddy/ui";
Expand Down Expand Up @@ -317,6 +321,108 @@ function WebVitalsSection({
);
}

function formatTraitValue(value: unknown): string {
if (value === null || value === undefined) {
return "—";
}
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}

function TraitsSection({
className,
traits,
}: {
className?: string;
traits: Record<string, unknown> | undefined;
}) {
const entries = Object.entries(traits ?? {}).sort(([a], [b]) =>
a.localeCompare(b)
);
if (entries.length === 0) {
return null;
}

return (
<SidebarSection className={className} icon={TagIcon} title="Traits">
{entries.map(([key, value]) => (
<div
className="flex min-h-10 items-center justify-between gap-3 py-2.5"
key={key}
>
<span className="shrink-0 text-muted-foreground text-xs">{key}</span>
<span className="min-w-0 truncate text-right font-medium text-foreground text-sm">
{formatTraitValue(value)}
</span>
</div>
))}
</SidebarSection>
);
}

interface TraitHistoryEntry {
changes: Record<string, { old: unknown; new: unknown }>;
createdAt: string | Date;
source: string;
}

function TraitHistorySection({
className,
history,
}: {
className?: string;
history: TraitHistoryEntry[];
}) {
if (history.length === 0) {
return null;
}

return (
<SidebarSection
className={className}
icon={ClockCounterClockwiseIcon}
title="Trait history"
>
<div className="flex flex-col gap-3">
{history.map((entry, index) => (
<div
className="border-border/60 border-l-2 pl-3"
key={`${entry.createdAt}-${index}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
{formatDateOnly(entry.createdAt)} ·{" "}
{formatLocalTime(entry.createdAt, "h:mm A")}
</span>
<span className="text-muted-foreground/60 text-xs">
{entry.source}
</span>
</div>
<div className="mt-1 flex flex-col gap-0.5">
{Object.entries(entry.changes).map(([key, change]) => (
<p className="text-sm" key={key}>
<span className="font-medium text-foreground">{key}</span>{" "}
<span className="text-muted-foreground/70 line-through">
{formatTraitValue(change.old)}
</span>{" "}
<span className="text-foreground">
→ {formatTraitValue(change.new)}
</span>
</p>
))}
</div>
</div>
))}
</div>
</SidebarSection>
);
}

function Header({
onBack,
userProfile,
Expand Down Expand Up @@ -352,11 +458,23 @@ function Header({
<div className="flex items-center gap-2.5">
<CountryFlag country={countryCode} size="sm" />
<div>
<h1 className="font-medium text-foreground text-sm">
{identity?.displayName ||
identity?.email ||
generateProfileName(userProfile.visitor_id)}
</h1>
<div className="flex items-center gap-1.5">
<h1 className="font-medium text-foreground text-sm">
{identity?.displayName ||
identity?.email ||
generateProfileName(userProfile.visitor_id)}
</h1>
{identity?.displayName || identity?.email ? (
<Tooltip
content="Name and email are encrypted at rest"
side="bottom"
>
<span className="flex items-center text-muted-foreground">
<LockSimpleIcon className="size-3" weight="duotone" />
</span>
</Tooltip>
) : null}
</div>
<p className="text-muted-foreground text-xs">
{identity?.displayName && identity?.email
? `${identity.email} · `
Expand Down Expand Up @@ -407,6 +525,10 @@ export default function UserDetailPage() {
websiteId,
userProfile?.profile_id ?? ""
);
const { data: traitHistory } = useProfileHistory(
websiteId,
userProfile?.profile_id ?? ""
);

const handleToggleSession = useCallback((sessionId: string) => {
setExpandedSessions((prev) => {
Expand Down Expand Up @@ -496,6 +618,8 @@ export default function UserDetailPage() {
))}
</div>

<TraitsSection traits={identity?.traits} />

<WebVitalsSection vitals={webVitals} />

<SidebarSection icon={GlobeIcon} title="Location">
Expand Down Expand Up @@ -572,6 +696,8 @@ export default function UserDetailPage() {
value={userProfile.total_duration_formatted || "0s"}
/>
</SidebarSection>

<TraitHistorySection history={traitHistory ?? []} />
</aside>

<main className="min-w-0 flex-1 overflow-y-auto">
Expand All @@ -586,11 +712,21 @@ export default function UserDetailPage() {
))}
</div>

<TraitsSection
className="bg-background lg:hidden"
traits={identity?.traits}
/>

<WebVitalsSection
className="bg-background lg:hidden"
vitals={webVitals}
/>

<TraitHistorySection
className="bg-background lg:hidden"
history={traitHistory ?? []}
/>

<div className="sticky top-0 z-10 grid h-[39px] grid-cols-[24px_1fr_120px_80px_60px_60px_70px_80px] items-center gap-2 border-b bg-accent px-3 font-medium text-muted-foreground text-xs shadow-[0_0_0_0.5px_var(--border)] lg:grid-cols-[24px_1fr_120px_80px_100px_60px_60px_70px_80px]">
<div />
<span>Session</span>
Expand Down
Loading
Loading