Merge dev into update-oauth-docs

This commit is contained in:
Konsti Wohlwend 2025-09-01 04:31:29 -07:00 committed by GitHub
commit e540c93c58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 46 additions and 23 deletions

View File

@ -62,6 +62,9 @@ export const sessionsCrudHandlers = createLazyProxy(() => createCrudHandlers(ses
JOIN latest_events le ON e.data->>'sessionId' = le."sessionId" AND e."eventStartedAt" = le."lastActiveAt"
LEFT JOIN ${sqlQuoteIdent(schema)}."EventIpInfo" geo ON geo.id = e."endUserIpInfoGuessId"
WHERE e."systemEventTypeIds" @> '{"$session-activity"}'
AND e.data->>'userId' = ${query.user_id}
AND e.data->>'projectId' = ${auth.tenancy.project.id}
AND COALESCE(e.data->>'branchId', 'main') = ${auth.tenancy.branchId}
`;
const sessionsWithLastActiveAt = refreshTokenObjs.map(s => {

View File

@ -15,7 +15,7 @@ const DataPointsSchema = yupArray(yupObject({
}).defined()).defined();
async function loadUsersByCountry(tenancy: Tenancy): Promise<Record<string, number>> {
async function loadUsersByCountry(tenancy: Tenancy, includeAnonymous: boolean = false): Promise<Record<string, number>> {
const a = await globalPrismaClient.$queryRaw<{countryCode: string|null, userCount: bigint}[]>`
WITH LatestEventWithCountryCode AS (
SELECT DISTINCT ON ("userId")
@ -27,7 +27,7 @@ async function loadUsersByCountry(tenancy: Tenancy): Promise<Record<string, numb
ON "Event"."endUserIpInfoGuessId" = eip.id
WHERE '$user-activity' = ANY("systemEventTypeIds"::text[])
AND "data"->>'projectId' = ${tenancy.project.id}
AND "data"->>'isAnonymous' != 'true'
AND (${includeAnonymous} OR "data"->>'isAnonymous' != 'true')
AND COALESCE("data"->>'branchId', 'main') = ${tenancy.branchId}
AND "countryCode" IS NOT NULL
ORDER BY "userId", "eventStartedAt" DESC
@ -45,7 +45,7 @@ async function loadUsersByCountry(tenancy: Tenancy): Promise<Record<string, numb
return rec;
}
async function loadTotalUsers(tenancy: Tenancy, now: Date): Promise<DataPoints> {
async function loadTotalUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise<DataPoints> {
const schema = getPrismaSchemaForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
return (await prisma.$queryRaw<{date: Date, dailyUsers: bigint, cumUsers: bigint}[]>`
@ -65,7 +65,7 @@ async function loadTotalUsers(tenancy: Tenancy, now: Date): Promise<DataPoints>
LEFT JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu
ON DATE(pu."createdAt") = ds.registration_day
AND pu."tenancyId" = ${tenancy.id}::UUID
AND pu."isAnonymous" = false
AND (${includeAnonymous} OR pu."isAnonymous" = false)
GROUP BY ds.registration_day
ORDER BY ds.registration_day
`).map((x) => ({
@ -74,7 +74,7 @@ async function loadTotalUsers(tenancy: Tenancy, now: Date): Promise<DataPoints>
}));
}
async function loadDailyActiveUsers(tenancy: Tenancy, now: Date) {
async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false) {
const res = await globalPrismaClient.$queryRaw<{day: Date, dau: bigint}[]>`
WITH date_series AS (
SELECT GENERATE_SERIES(
@ -87,7 +87,7 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date) {
daily_users AS (
SELECT
DATE_TRUNC('day', "eventStartedAt") AS "day",
COUNT(DISTINCT CASE WHEN "data"->>'isAnonymous' = 'false' THEN "data"->'userId' ELSE NULL END) AS "dau"
COUNT(DISTINCT CASE WHEN (${includeAnonymous} OR "data"->>'isAnonymous' = 'false') THEN "data"->'userId' ELSE NULL END) AS "dau"
FROM "Event"
WHERE "eventStartedAt" >= ${now}::date - INTERVAL '30 days'
AND '$user-activity' = ANY("systemEventTypeIds"::text[])
@ -134,7 +134,7 @@ async function loadLoginMethods(tenancy: Tenancy): Promise<{method: string, coun
`;
}
async function loadRecentlyActiveUsers(tenancy: Tenancy): Promise<UsersCrud["Admin"]["Read"][]> {
async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolean = false): Promise<UsersCrud["Admin"]["Read"][]> {
// use the Events table to get the most recent activity
const events = await globalPrismaClient.$queryRaw<{ data: any, eventStartedAt: Date }[]>`
WITH RankedEvents AS (
@ -146,7 +146,7 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy): Promise<UsersCrud["Adm
) as rn
FROM "Event"
WHERE "data"->>'projectId' = ${tenancy.project.id}
AND "data"->>'isAnonymous' != 'true'
AND (${includeAnonymous} OR "data"->>'isAnonymous' != 'true')
AND COALESCE("data"->>'branchId', 'main') = ${tenancy.branchId}
AND '$user-activity' = ANY("systemEventTypeIds"::text[])
)
@ -188,6 +188,9 @@ export const GET = createSmartRouteHandler({
type: adminAuthTypeSchema.defined(),
tenancy: adaptSchema.defined(),
}),
query: yupObject({
include_anonymous: yupString().oneOf(["true", "false"]).optional(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
@ -205,6 +208,7 @@ export const GET = createSmartRouteHandler({
}),
handler: async (req) => {
const now = new Date();
const includeAnonymous = req.query?.include_anonymous === "true";
const prisma = await getPrismaClientForTenancy(req.auth.tenancy);
@ -218,24 +222,24 @@ export const GET = createSmartRouteHandler({
loginMethods
] = await Promise.all([
prisma.projectUser.count({
where: { tenancyId: req.auth.tenancy.id, isAnonymous: false },
where: { tenancyId: req.auth.tenancy.id, ...(includeAnonymous ? {} : { isAnonymous: false }) },
}),
loadTotalUsers(req.auth.tenancy, now),
loadDailyActiveUsers(req.auth.tenancy, now),
loadUsersByCountry(req.auth.tenancy),
loadTotalUsers(req.auth.tenancy, now, includeAnonymous),
loadDailyActiveUsers(req.auth.tenancy, now, includeAnonymous),
loadUsersByCountry(req.auth.tenancy, includeAnonymous),
usersCrudHandlers.adminList({
tenancy: req.auth.tenancy,
query: {
order_by: 'signed_up_at',
desc: "true",
limit: 5,
include_anonymous: "false",
include_anonymous: includeAnonymous ? "true" : "false",
},
allowedErrorTypes: [
KnownErrors.UserNotFound,
],
}).then(res => res.items),
loadRecentlyActiveUsers(req.auth.tenancy),
loadRecentlyActiveUsers(req.auth.tenancy, includeAnonymous),
loadLoginMethods(req.auth.tenancy),
] as const);

View File

@ -49,7 +49,15 @@ export default function PageClient(props: { inviteUser: (origin: string, teamId:
};
const grouped = groupBy(newProjects, (project) => project.ownerTeamId);
return Array.from(grouped.entries()).map(([teamId, projects]) => {
return [...grouped.entries()].sort((a, b) => {
if (a[0] === null) return -1;
if (b[0] === null) return 1;
if (sort === "recency") {
return a[1][0].createdAt > b[1][0].createdAt ? -1 : 1;
} else {
return stringCompare(a[1][0].displayName, b[1][0].displayName);
}
}).map(([teamId, projects]) => {
return {
teamId,
projects: projects.sort(projectSort),

View File

@ -5,6 +5,7 @@ import { ErrorBoundary } from '@sentry/nextjs';
import { UserAvatar } from '@stackframe/stack';
import { fromNow } from '@stackframe/stack-shared/dist/utils/dates';
import { Card, CardContent, CardHeader, CardTitle, Table, TableBody, TableCell, TableRow, Typography } from '@stackframe/stack-ui';
import { useState } from 'react';
import { PageLayout } from "../page-layout";
import { useAdminApp } from '../use-admin-app';
import { GlobeSection } from './globe';
@ -38,8 +39,9 @@ const dauConfig = {
export default function MetricsPage(props: { toSetup: () => void }) {
const adminApp = useAdminApp();
const router = useRouter();
const [includeAnonymous, setIncludeAnonymous] = useState(false);
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics();
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);
return (
<PageLayout fillWidth>

View File

@ -8,7 +8,7 @@ import SetupPage from "./setup-page";
export default function PageClient() {
const adminApp = useAdminApp();
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics();
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(false);
const [page, setPage] = useState<'setup' | 'metrics'>(data.total_users === 0 ? 'setup' : 'metrics');
switch (page) {

View File

@ -297,9 +297,14 @@ export class StackAdminInterface extends StackServerInterface {
);
}
async getMetrics(): Promise<any> {
async getMetrics(includeAnonymous: boolean = false): Promise<any> {
const params = new URLSearchParams();
if (includeAnonymous) {
params.append('include_anonymous', 'true');
}
const queryString = params.toString();
const response = await this.sendAdminRequest(
"/internal/metrics",
`/internal/metrics${queryString ? `?${queryString}` : ''}`,
{
method: "GET",
},

View File

@ -51,8 +51,8 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
private readonly _svixTokenCache = createCache(async () => {
return await this._interface.getSvixToken();
});
private readonly _metricsCache = createCache(async () => {
return await this._interface.getMetrics();
private readonly _metricsCache = createCache(async ([includeAnonymous]: [boolean]) => {
return await this._interface.getMetrics(includeAnonymous);
});
private readonly _emailPreviewCache = createCache(async ([themeId, themeTsxSource, templateId, templateTsxSource]: [string | null | false | undefined, string | undefined, string | undefined, string | undefined]) => {
return await this._interface.renderEmailPreview({ themeId, themeTsxSource, templateId, templateTsxSource });
@ -415,8 +415,8 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
return {
...super[stackAppInternalsSymbol],
// IF_PLATFORM react-like
useMetrics: (): any => {
return useAsyncCache(this._metricsCache, [], "useMetrics()");
useMetrics: (includeAnonymous: boolean = false): any => {
return useAsyncCache(this._metricsCache, [includeAnonymous] as const, "useMetrics()");
}
// END_PLATFORM
};

View File

@ -2,6 +2,7 @@
"$schema": "https://turbo.build/schema.json",
"globalEnv": [
"STACK_*",
"CRON_SECRET",
"NEXT_PUBLIC_*",
"NEXT_PUBLIC_SENTRY_*",
"SENTRY_*",