Skip to content

Commit f10200e

Browse files
authored
Merge pull request #539 from databuddy-analytics/staging
Profile trait history: fixes + surface traits/history in UI
2 parents bd86a7f + 0f22db2 commit f10200e

11 files changed

Lines changed: 425 additions & 26 deletions

File tree

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"version": "1.0.1",
55
"type": "module",
66
"scripts": {
7-
"dev": "NODE_ENV=development dotenv -- bun --hot src/index.ts --port 3001",
7+
"dev": "NODE_ENV=development dotenv -- bun --hot src/index.ts",
88
"test": "TZ=UTC bunx --bun vitest run",
99
"test:integration": "TZ=UTC bunx --bun vitest run --config vitest.integration.config.ts",
1010
"test:watch": "TZ=UTC bunx --bun vitest"

apps/api/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ registerShutdownHooks();
109109

110110
export default {
111111
fetch: app.fetch,
112-
port: Number.parseInt(apiEnv.PORT, 10),
112+
// In development env validation is skipped, so zod defaults never apply and
113+
// apiEnv.PORT can be undefined — fall back explicitly or Bun binds a random port.
114+
port: Number.parseInt(apiEnv.PORT ?? "3001", 10) || 3001,
113115
idleTimeout: BUN_IDLE_TIMEOUT_SECONDS,
114116
};

apps/api/src/integration/profile-handlers.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import "@databuddy/test/env";
22

3-
import { profileAliases, profiles } from "@databuddy/db/schema";
3+
import {
4+
profileAliases,
5+
profiles,
6+
profileTraitChanges,
7+
} from "@databuddy/db/schema";
8+
import { eq } from "@databuddy/db";
49
import { appRouter, type Context } from "@databuddy/rpc";
10+
import { splitTraits, upsertProfile } from "@databuddy/services/identity";
511
import {
612
addToOrganization,
713
cleanup,
@@ -120,3 +126,123 @@ describe("profiles.get", () => {
120126
expect(result).toBeNull();
121127
});
122128
});
129+
130+
describe("profiles.getHistory", () => {
131+
iit("returns trait changes newest first", async () => {
132+
const user = await signUp();
133+
const org = await insertOrganization();
134+
await addToOrganization(user.id, org.id, "member");
135+
const website = await insertWebsite({ organizationId: org.id });
136+
await seedProfile(website.id, "user_1");
137+
await db()
138+
.insert(profileTraitChanges)
139+
.values([
140+
{
141+
id: "change_old",
142+
websiteId: website.id,
143+
profileId: "user_1",
144+
traits: { plan: "free" },
145+
changes: { plan: { old: null, new: "free" } },
146+
source: "identify",
147+
createdAt: new Date("2026-01-01T00:00:00Z"),
148+
},
149+
{
150+
id: "change_new",
151+
websiteId: website.id,
152+
profileId: "user_1",
153+
traits: { plan: "pro" },
154+
changes: { plan: { old: "free", new: "pro" } },
155+
source: "billing",
156+
createdAt: new Date("2026-02-01T00:00:00Z"),
157+
},
158+
]);
159+
160+
const result = await call(appRouter.profiles.getHistory, {
161+
...userContext(user, org.id),
162+
})({ websiteId: website.id, profileId: "user_1" });
163+
164+
expect(result.map((r) => r.source)).toEqual(["billing", "identify"]);
165+
expect(result[0]?.changes).toEqual({ plan: { old: "free", new: "pro" } });
166+
});
167+
168+
iit("does not leak history from another organization's website", async () => {
169+
const outsider = await signUp();
170+
const outsiderOrg = await insertOrganization();
171+
await addToOrganization(outsider.id, outsiderOrg.id, "member");
172+
173+
const org = await insertOrganization();
174+
const website = await insertWebsite({ organizationId: org.id });
175+
176+
await expectCode(
177+
call(appRouter.profiles.getHistory, {
178+
...userContext(outsider, outsiderOrg.id),
179+
})({ websiteId: website.id, profileId: "user_1" }),
180+
"FORBIDDEN"
181+
);
182+
});
183+
});
184+
185+
describe("upsertProfile trait history", () => {
186+
iit("records baseline on first identify, diff on the next", async () => {
187+
const org = await insertOrganization();
188+
const website = await insertWebsite({ organizationId: org.id });
189+
190+
await upsertProfile(website.id, "user_1", splitTraits({ plan: "free" }));
191+
await upsertProfile(
192+
website.id,
193+
"user_1",
194+
splitTraits({ plan: "pro" }),
195+
"billing"
196+
);
197+
198+
const rows = await db()
199+
.select()
200+
.from(profileTraitChanges)
201+
.where(eq(profileTraitChanges.profileId, "user_1"))
202+
.orderBy(profileTraitChanges.createdAt);
203+
204+
expect(rows).toHaveLength(2);
205+
expect(rows[0]?.changes).toEqual({ plan: { old: null, new: "free" } });
206+
expect(rows[0]?.source).toBe("identify");
207+
expect(rows[1]?.changes).toEqual({ plan: { old: "free", new: "pro" } });
208+
expect(rows[1]?.source).toBe("billing");
209+
expect(rows[1]?.traits).toEqual({ plan: "pro" });
210+
});
211+
212+
iit("writes no history row when traits are unchanged", async () => {
213+
const org = await insertOrganization();
214+
const website = await insertWebsite({ organizationId: org.id });
215+
216+
await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));
217+
await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));
218+
219+
const rows = await db()
220+
.select()
221+
.from(profileTraitChanges)
222+
.where(eq(profileTraitChanges.profileId, "user_1"));
223+
224+
expect(rows).toHaveLength(1);
225+
});
226+
227+
iit("cascades history deletion when the profile is deleted", async () => {
228+
const org = await insertOrganization();
229+
const website = await insertWebsite({ organizationId: org.id });
230+
231+
await upsertProfile(website.id, "user_1", splitTraits({ plan: "pro" }));
232+
expect(
233+
await db()
234+
.select()
235+
.from(profileTraitChanges)
236+
.where(eq(profileTraitChanges.profileId, "user_1"))
237+
).toHaveLength(1);
238+
239+
await db().delete(profiles).where(eq(profiles.profileId, "user_1"));
240+
241+
expect(
242+
await db()
243+
.select()
244+
.from(profileTraitChanges)
245+
.where(eq(profileTraitChanges.profileId, "user_1"))
246+
).toHaveLength(0);
247+
});
248+
});

0 commit comments

Comments
 (0)