Skip to content

Commit 83dd4cb

Browse files
authored
"Last active at" column on users and sessions (#1081)
1 parent d39b5c0 commit 83dd4cb

9 files changed

Lines changed: 179 additions & 628 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
-- AlterTable
2+
-- Add lastActiveAt column to ProjectUser table as nullable first (for backfill)
3+
ALTER TABLE "ProjectUser" ADD COLUMN "lastActiveAt" TIMESTAMP(3);
4+
5+
-- AlterTable
6+
-- Add lastActiveAt and lastActiveAtIpInfo columns to ProjectUserRefreshToken table
7+
ALTER TABLE "ProjectUserRefreshToken" ADD COLUMN "lastActiveAt" TIMESTAMP(3);
8+
ALTER TABLE "ProjectUserRefreshToken" ADD COLUMN "lastActiveAtIpInfo" JSONB;
9+
10+
-- SPLIT_STATEMENT_SENTINEL
11+
-- SINGLE_STATEMENT_SENTINEL
12+
-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL
13+
-- Backfill ProjectUser.lastActiveAt from Events table (using $user-activity events)
14+
WITH to_update AS (
15+
SELECT pu."tenancyId", pu."projectUserId"
16+
FROM "ProjectUser" pu
17+
JOIN "Tenancy" t ON t."id" = pu."tenancyId"
18+
WHERE pu."lastActiveAt" IS NULL
19+
LIMIT 1000
20+
),
21+
event_activity AS (
22+
SELECT
23+
t."id" AS "tenancyId",
24+
e."data"->>'userId' AS "userId",
25+
MAX(e."eventStartedAt") AS "lastActiveAt"
26+
FROM "Event" e
27+
JOIN "Tenancy" t ON t."projectId" = e."data"->>'projectId'
28+
AND t."branchId" = COALESCE(e."data"->>'branchId', 'main')
29+
WHERE '$user-activity' = ANY(e."systemEventTypeIds"::text[])
30+
AND e."data"->>'userId' IS NOT NULL
31+
AND EXISTS (SELECT 1 FROM to_update tu WHERE tu."tenancyId" = t."id" AND tu."projectUserId"::text = e."data"->>'userId')
32+
GROUP BY t."id", e."data"->>'userId'
33+
),
34+
updated AS (
35+
UPDATE "ProjectUser" pu
36+
SET "lastActiveAt" = COALESCE(ea."lastActiveAt", pu."createdAt")
37+
FROM to_update tu
38+
LEFT JOIN event_activity ea ON ea."tenancyId" = tu."tenancyId" AND ea."userId"::uuid = tu."projectUserId"
39+
WHERE pu."tenancyId" = tu."tenancyId" AND pu."projectUserId" = tu."projectUserId"
40+
RETURNING 1
41+
)
42+
SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated;
43+
44+
-- SPLIT_STATEMENT_SENTINEL
45+
-- SINGLE_STATEMENT_SENTINEL
46+
-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL
47+
-- Backfill ProjectUserRefreshToken.lastActiveAt and lastActiveAtIpInfo from Events table (using $session-activity events)
48+
WITH to_update AS (
49+
SELECT rt."tenancyId", rt."id"
50+
FROM "ProjectUserRefreshToken" rt
51+
JOIN "Tenancy" t ON t."id" = rt."tenancyId"
52+
WHERE rt."lastActiveAt" IS NULL
53+
LIMIT 1000
54+
),
55+
-- Get the most recent session activity event for each session, along with its IP info
56+
event_activity AS (
57+
SELECT DISTINCT ON (t."id", e."data"->>'sessionId')
58+
t."id" AS "tenancyId",
59+
e."data"->>'sessionId' AS "sessionId",
60+
e."eventStartedAt" AS "lastActiveAt",
61+
CASE
62+
WHEN eip."id" IS NOT NULL THEN jsonb_build_object(
63+
'ip', eip."ip",
64+
'countryCode', eip."countryCode",
65+
'regionCode', eip."regionCode",
66+
'cityName', eip."cityName",
67+
'latitude', eip."latitude",
68+
'longitude', eip."longitude",
69+
'tzIdentifier', eip."tzIdentifier"
70+
)
71+
ELSE NULL
72+
END AS "ipInfo"
73+
FROM "Event" e
74+
JOIN "Tenancy" t ON t."projectId" = e."data"->>'projectId'
75+
AND t."branchId" = COALESCE(e."data"->>'branchId', 'main')
76+
LEFT JOIN "EventIpInfo" eip ON eip."id" = e."endUserIpInfoGuessId"
77+
WHERE '$session-activity' = ANY(e."systemEventTypeIds"::text[])
78+
AND e."data"->>'sessionId' IS NOT NULL
79+
AND EXISTS (SELECT 1 FROM to_update tu WHERE tu."tenancyId" = t."id" AND tu."id"::text = e."data"->>'sessionId')
80+
ORDER BY t."id", e."data"->>'sessionId', e."eventStartedAt" DESC
81+
),
82+
updated AS (
83+
UPDATE "ProjectUserRefreshToken" rt
84+
SET "lastActiveAt" = COALESCE(ea."lastActiveAt", rt."createdAt"),
85+
"lastActiveAtIpInfo" = ea."ipInfo"
86+
FROM to_update tu
87+
LEFT JOIN event_activity ea ON ea."tenancyId" = tu."tenancyId" AND ea."sessionId"::uuid = tu."id"
88+
WHERE rt."tenancyId" = tu."tenancyId" AND rt."id" = tu."id"
89+
RETURNING 1
90+
)
91+
SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated;
92+
93+
-- SPLIT_STATEMENT_SENTINEL
94+
-- Make columns NOT NULL with default NOW() for new rows
95+
ALTER TABLE "ProjectUser" ALTER COLUMN "lastActiveAt" SET NOT NULL;
96+
ALTER TABLE "ProjectUser" ALTER COLUMN "lastActiveAt" SET DEFAULT NOW();
97+
98+
ALTER TABLE "ProjectUserRefreshToken" ALTER COLUMN "lastActiveAt" SET NOT NULL;
99+
ALTER TABLE "ProjectUserRefreshToken" ALTER COLUMN "lastActiveAt" SET DEFAULT NOW();

apps/backend/prisma/schema.prisma

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ model ProjectUser {
170170
mirroredProjectId String
171171
mirroredBranchId String
172172
173-
createdAt DateTime @default(now())
174-
updatedAt DateTime @updatedAt
173+
createdAt DateTime @default(now())
174+
updatedAt DateTime @updatedAt
175+
lastActiveAt DateTime @default(now())
175176
176177
displayName String?
177178
serverMetadata Json?
@@ -430,8 +431,10 @@ model ProjectUserRefreshToken {
430431
tenancyId String @db.Uuid
431432
projectUserId String @db.Uuid
432433
433-
createdAt DateTime @default(now())
434-
updatedAt DateTime @updatedAt
434+
createdAt DateTime @default(now())
435+
updatedAt DateTime @updatedAt
436+
lastActiveAt DateTime @default(now())
437+
lastActiveAtIpInfo Json?
435438
436439
refreshToken String @unique
437440
expiresAt DateTime?

apps/backend/src/app/api/latest/auth/sessions/crud.tsx

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, sqlQuoteIdent } from "@/prisma-client";
1+
import { globalPrismaClient } from "@/prisma-client";
22
import { createCrudHandlers } from "@/route-handlers/crud-handler";
33
import { SmartRequestAuth } from "@/route-handlers/smart-request";
4-
import { Prisma } from "@/generated/prisma/client";
54
import { KnownErrors } from "@stackframe/stack-shared";
65
import { sessionsCrud } from "@stackframe/stack-shared/dist/interface/crud/sessions";
76
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
87
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
9-
import { GeoInfo } from "@stackframe/stack-shared/dist/utils/geo";
8+
import { geoInfoSchema } from "@stackframe/stack-shared/dist/utils/geo";
109
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
1110

1211
export const sessionsCrudHandlers = createLazyProxy(() => createCrudHandlers(sessionsCrud, {
@@ -17,8 +16,6 @@ export const sessionsCrudHandlers = createLazyProxy(() => createCrudHandlers(ses
1716
user_id: userIdOrMeSchema.defined(),
1817
}).defined(),
1918
onList: async ({ auth, query }) => {
20-
const prisma = await getPrismaClientForTenancy(auth.tenancy);
21-
const schema = await getPrismaSchemaForTenancy(auth.tenancy);
2219
const listImpersonations = auth.type === 'admin';
2320

2421
if (auth.type === 'client') {
@@ -39,60 +36,25 @@ export const sessionsCrudHandlers = createLazyProxy(() => createCrudHandlers(ses
3936
},
4037
});
4138

42-
// Get the latest event for each session
43-
const events = await prisma.$queryRaw<Array<{ sessionId: string, lastActiveAt: Date, geo: GeoInfo | null, isEndUserIpInfoGuessTrusted: boolean }>>`
44-
WITH latest_events AS (
45-
SELECT data->>'sessionId' as "sessionId",
46-
MAX("eventStartedAt") as "lastActiveAt"
47-
FROM ${sqlQuoteIdent(schema)}."Event"
48-
WHERE ${refreshTokenObjs.length > 0
49-
? Prisma.sql`data->>'sessionId' = ANY(${Prisma.sql`ARRAY[${Prisma.join(refreshTokenObjs.map(s => s.id))}]`})`
50-
: Prisma.sql`FALSE`}
51-
AND "systemEventTypeIds" @> '{"$session-activity"}'
52-
AND data->>'userId' = ${query.user_id}
53-
AND data->>'projectId' = ${auth.tenancy.project.id}
54-
AND COALESCE(data->>'branchId', 'main') = ${auth.tenancy.branchId}
55-
GROUP BY data->>'sessionId'
56-
)
57-
SELECT e.data->>'sessionId' as "sessionId",
58-
le."lastActiveAt",
59-
row_to_json(geo.*) as "geo",
60-
e.data->>'isEndUserIpInfoGuessTrusted' as "isEndUserIpInfoGuessTrusted"
61-
FROM ${sqlQuoteIdent(schema)}."Event" e
62-
JOIN latest_events le ON e.data->>'sessionId' = le."sessionId" AND e."eventStartedAt" = le."lastActiveAt"
63-
LEFT JOIN ${sqlQuoteIdent(schema)}."EventIpInfo" geo ON geo.id = e."endUserIpInfoGuessId"
64-
WHERE e."systemEventTypeIds" @> '{"$session-activity"}'
65-
AND e.data->>'userId' = ${query.user_id}
66-
AND e.data->>'projectId' = ${auth.tenancy.project.id}
67-
AND COALESCE(e.data->>'branchId', 'main') = ${auth.tenancy.branchId}
68-
`;
69-
70-
const sessionsWithLastActiveAt = refreshTokenObjs.map(s => {
71-
const event = events.find(e => e.sessionId === s.id);
72-
return {
73-
...s,
74-
last_active_at: event?.lastActiveAt.getTime(),
75-
last_active_at_end_user_ip_info: event?.geo,
76-
};
77-
});
78-
7939
const result = {
80-
items: sessionsWithLastActiveAt.map(s => ({
81-
id: s.id,
82-
user_id: s.projectUserId,
83-
created_at: s.createdAt.getTime(),
84-
last_used_at: s.last_active_at,
85-
is_impersonation: s.isImpersonation,
86-
last_used_at_end_user_ip_info: s.last_active_at_end_user_ip_info ?? undefined,
87-
is_current_session: s.id === auth.refreshTokenId,
88-
})),
40+
items: refreshTokenObjs.map(s => {
41+
const ipInfo = s.lastActiveAtIpInfo ? geoInfoSchema.validateSync(s.lastActiveAtIpInfo) : undefined;
42+
return {
43+
id: s.id,
44+
user_id: s.projectUserId,
45+
created_at: s.createdAt.getTime(),
46+
last_used_at: s.lastActiveAt.getTime(),
47+
is_impersonation: s.isImpersonation,
48+
is_current_session: s.id === auth.refreshTokenId,
49+
last_used_at_end_user_ip_info: ipInfo,
50+
};
51+
}),
8952
is_paginated: false,
9053
};
9154

9255
return result;
9356
},
9457
onDelete: async ({ auth, params }: { auth: SmartRequestAuth, params: { id: string }, query: { user_id?: string } }) => {
95-
const prisma = await getPrismaClientForTenancy(auth.tenancy);
9658
const session = await globalPrismaClient.projectUserRefreshToken.findFirst({
9759
where: {
9860
tenancyId: auth.tenancy.id,

apps/backend/src/app/api/latest/internal/metrics/route.tsx

Lines changed: 7 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import { Prisma } from "@/generated/prisma/client";
12
import { getOrSetCacheValue } from "@/lib/cache";
23
import { Tenancy } from "@/lib/tenancies";
34
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, PrismaClientTransaction, sqlQuoteIdent } from "@/prisma-client";
45
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
5-
import { Prisma } from "@/generated/prisma/client";
66
import { KnownErrors } from "@stackframe/stack-shared";
77
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
88
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
@@ -193,52 +193,20 @@ async function loadLoginMethods(tenancy: Tenancy): Promise<{ method: string, cou
193193
}
194194

195195
async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolean = false): Promise<UsersCrud["Admin"]["Read"][]> {
196-
const events = await globalPrismaClient.$queryRaw<{ userId: string, lastActiveAt: Date }[]>`
197-
WITH ordered_events AS (
198-
SELECT
199-
"data"->>'userId' AS "userId",
200-
"eventStartedAt" AS "lastActiveAt"
201-
FROM "Event"
202-
WHERE "data"->>'projectId' = ${tenancy.project.id}
203-
AND COALESCE("data"->>'branchId', 'main') = ${tenancy.branchId}
204-
AND (${includeAnonymous} OR COALESCE("data"->>'isAnonymous', 'false') != 'true')
205-
AND '$user-activity' = ANY("systemEventTypeIds"::text[])
206-
AND "data"->>'userId' IS NOT NULL
207-
ORDER BY "eventStartedAt" DESC
208-
LIMIT 4000
209-
),
210-
latest_events AS (
211-
SELECT DISTINCT ON ("userId")
212-
"userId",
213-
"lastActiveAt"
214-
FROM ordered_events
215-
ORDER BY "userId", "lastActiveAt" DESC
216-
)
217-
SELECT "userId", "lastActiveAt"
218-
FROM latest_events
219-
ORDER BY "lastActiveAt" DESC
220-
LIMIT 5
221-
`;
222-
if (events.length === 0) {
223-
return [];
224-
}
225-
226196
const prisma = await getPrismaClientForTenancy(tenancy);
227197
const dbUsers = await prisma.projectUser.findMany({
228198
where: {
229199
tenancyId: tenancy.id,
230-
projectUserId: {
231-
in: events.map((event) => event.userId),
232-
},
200+
...(!includeAnonymous ? { isAnonymous: false } : {}),
201+
},
202+
orderBy: {
203+
lastActiveAt: 'desc',
233204
},
205+
take: 5,
234206
include: userFullInclude,
235207
});
236208

237-
const userObjects = events.map((event) => {
238-
const user = dbUsers.find((user) => user.projectUserId === event.userId);
239-
return user ? userPrismaToCrud(user, event.lastActiveAt.getTime()) : null;
240-
});
241-
return userObjects.filter((user): user is UsersCrud["Admin"]["Read"] => user !== null);
209+
return dbUsers.map((user) => userPrismaToCrud(user));
242210
}
243211

244212
export const GET = createSmartRouteHandler({

apps/backend/src/app/api/latest/team-member-profiles/crud.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ import { teamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/
88
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
99
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1010
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
11-
import { getUserLastActiveAtMillis, getUsersLastActiveAtMillis, userFullInclude, userPrismaToCrud } from "../users/crud";
11+
import { userFullInclude, userPrismaToCrud } from "../users/crud";
1212

1313
const fullInclude = { projectUser: { include: userFullInclude } };
1414

15-
function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof fullInclude }>, lastActiveAtMillis: number) {
15+
function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof fullInclude }>) {
1616
return {
1717
team_id: prisma.teamId,
1818
user_id: prisma.projectUserId,
1919
display_name: prisma.displayName ?? prisma.projectUser.displayName,
2020
profile_image_url: prisma.profileImageUrl ?? prisma.projectUser.profileImageUrl,
21-
user: userPrismaToCrud(prisma.projectUser, lastActiveAtMillis),
21+
user: userPrismaToCrud(prisma.projectUser),
2222
};
2323
}
2424

@@ -78,10 +78,8 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
7878
include: fullInclude,
7979
});
8080

81-
const lastActiveAtMillis = await getUsersLastActiveAtMillis(auth.project.id, auth.branchId, db.map(user => user.projectUserId), db.map(user => user.createdAt));
82-
8381
return {
84-
items: db.map((user, index) => prismaToCrud(user, lastActiveAtMillis[index])),
82+
items: db.map((user) => prismaToCrud(user)),
8583
is_paginated: false,
8684
};
8785
});
@@ -121,7 +119,7 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
121119
throw new KnownErrors.TeamMembershipNotFound(params.team_id, params.user_id);
122120
}
123121

124-
return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime());
122+
return prismaToCrud(db);
125123
});
126124
},
127125
onUpdate: async ({ auth, data, params }) => {
@@ -155,7 +153,7 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
155153
include: fullInclude,
156154
});
157155

158-
return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime());
156+
return prismaToCrud(db);
159157
});
160158
},
161159
}));

0 commit comments

Comments
 (0)