From 8b723cd1691326eafab3bf276e53683cd8061a6a Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 26 Dec 2024 18:13:14 -0800 Subject: [PATCH 1/9] Raw SQL query for fetching users --- .../app/api/v1/team-member-profiles/crud.tsx | 6 +- apps/backend/src/app/api/v1/users/crud.tsx | 304 ++++++++++++++++-- apps/backend/src/prisma-client.tsx | 49 +++ .../src/route-handlers/smart-request.tsx | 24 +- apps/backend/src/utils/telemetry.tsx | 4 +- eslint-configs/defaults.js | 1 + packages/stack-shared/src/utils/objects.tsx | 4 + 7 files changed, 357 insertions(+), 35 deletions(-) diff --git a/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx b/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx index dba4e1a58..754b533ff 100644 --- a/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx +++ b/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx @@ -76,7 +76,7 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa include: fullInclude, }); - const lastActiveAtMillis = await getUsersLastActiveAtMillis(db.map(user => user.projectUserId), db.map(user => user.createdAt)); + const lastActiveAtMillis = await getUsersLastActiveAtMillis(auth.project.id, db.map(user => user.projectUserId), db.map(user => user.createdAt)); return { items: db.map((user, index) => prismaToCrud(user, lastActiveAtMillis[index])), @@ -118,7 +118,7 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa throw new KnownErrors.TeamMembershipNotFound(params.team_id, params.user_id); } - return prismaToCrud(db, await getUserLastActiveAtMillis(db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime()); + return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime()); }); }, onUpdate: async ({ auth, data, params }) => { @@ -151,7 +151,7 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa include: fullInclude, }); - return prismaToCrud(db, await getUserLastActiveAtMillis(db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime()); + return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime()); }); }, })); diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index f600dcedc..ec4ca31bc 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -1,7 +1,7 @@ import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; import { PrismaTransaction } from "@/lib/types"; import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; -import { prismaClient, retryTransaction } from "@/prisma-client"; +import { RawQuery, prismaClient, rawQuery, retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { BooleanTrue, Prisma } from "@prisma/client"; @@ -11,8 +11,10 @@ import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/cr import { userIdOrMeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64"; import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { hashPassword, isPasswordHashValid } from "@stackframe/stack-shared/dist/utils/hashes"; +import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings"; import { teamPrismaToCrud, teamsCrudHandlers } from "../teams/crud"; @@ -203,45 +205,299 @@ async function getOtpConfig(tx: PrismaTransaction, projectConfigId: string) { return otpConfig.length === 0 ? null : otpConfig[0]; } -export const getUserLastActiveAtMillis = async (userId: string): Promise => { - const event = await prismaClient.event.findFirst({ - where: { - data: { - path: ["$.userId"], - equals: userId, - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - - return event?.createdAt.getTime() ?? null; +export const getUserLastActiveAtMillis = async (projectId: string, userId: string): Promise => { + const res = (await getUsersLastActiveAtMillis(projectId, [userId], [new Date()]))[0]; + if (res === 0) { + return null; + } + return res; }; -// same as userIds.map(userId => getUserLastActiveAtMillis(userId, fallbackTo)), but uses a single query -export const getUsersLastActiveAtMillis = async (userIds: string[], fallbackTo: (number | Date)[]): Promise => { +// same as userIds.map(userId => getUserLastActiveAtMillis(projectId, userId)), but uses a single query +export const getUsersLastActiveAtMillis = async (projectId: string, userIds: string[], userSignedUpAtMillis: (number | Date)[]): Promise => { if (userIds.length === 0) { // Prisma.join throws an error if the array is empty, so we need to handle that case return []; } const events = await prismaClient.$queryRaw>` - SELECT data->>'userId' as "userId", MAX("createdAt") as "lastActiveAt" + SELECT data->>'userId' as "userId", MAX("eventStartedAt") as "lastActiveAt" FROM "Event" - WHERE data->>'userId' = ANY(${Prisma.sql`ARRAY[${Prisma.join(userIds)}]`}) + WHERE data->>'userId' = ANY(${Prisma.sql`ARRAY[${Prisma.join(userIds)}]`}) AND data->>'projectId' = ${projectId} GROUP BY data->>'userId' `; return userIds.map((userId, index) => { const event = events.find(e => e.userId === userId); return event ? event.lastActiveAt.getTime() : ( - typeof fallbackTo[index] === "number" ? (fallbackTo[index] as number) : (fallbackTo[index] as Date).getTime() + typeof userSignedUpAtMillis[index] === "number" ? (userSignedUpAtMillis[index] as number) : (userSignedUpAtMillis[index] as Date).getTime() ); }); }; +export function getUserQuery(projectId: string, userId: string): RawQuery { + return { + sql: Prisma.sql` + SELECT to_json( + ( + SELECT ( + to_jsonb("ProjectUser".*) || + jsonb_build_object( + 'lastActiveAt', ( + SELECT MAX("eventStartedAt") as "lastActiveAt" + FROM "Event" + WHERE data->>'projectId' = "ProjectUser"."projectId" AND ("data"->>'userId')::UUID = "ProjectUser"."projectUserId" AND "systemEventTypeIds" @> '{"$user-activity"}' + ), + 'UserActivityEvents', ( + SELECT COALESCE(ARRAY_AGG( + to_jsonb("Event") || + jsonb_build_object() + ), '{}') + FROM "Event" + WHERE "Event".data->>'projectId' = "ProjectUser"."projectId" AND ("Event".data->>'userId')::UUID = "ProjectUser"."projectUserId" AND "Event"."systemEventTypeIds" @> '{"$user-activity"}' + ), + 'ContactChannels', ( + SELECT COALESCE(ARRAY_AGG( + to_jsonb("ContactChannel") || + jsonb_build_object() + ), '{}') + FROM "ContactChannel" + WHERE "ContactChannel"."projectId" = "ProjectUser"."projectId" AND "ContactChannel"."projectUserId" = "ProjectUser"."projectUserId" AND "ContactChannel"."isPrimary" = 'TRUE' + ), + 'ProjectUserOAuthAccounts', ( + SELECT COALESCE(ARRAY_AGG( + to_jsonb("ProjectUserOAuthAccount") || + jsonb_build_object( + 'ProviderConfig', ( + SELECT to_jsonb("OAuthProviderConfig") + FROM "OAuthProviderConfig" + WHERE "OAuthProviderConfig"."id" = "ProjectUserOAuthAccount"."oauthProviderConfigId" + ) + ) + ), '{}') + FROM "ProjectUserOAuthAccount" + WHERE "ProjectUserOAuthAccount"."projectId" = "ProjectUser"."projectId" AND "ProjectUserOAuthAccount"."projectUserId" = "ProjectUser"."projectUserId" + ), + 'AuthMethods', ( + SELECT COALESCE(ARRAY_AGG( + to_jsonb("AuthMethod") || + jsonb_build_object( + 'PasswordAuthMethod', ( + SELECT to_jsonb("PasswordAuthMethod") + FROM "PasswordAuthMethod" + WHERE "PasswordAuthMethod"."projectId" = "ProjectUser"."projectId" AND "PasswordAuthMethod"."projectUserId" = "ProjectUser"."projectUserId" AND "PasswordAuthMethod"."authMethodId" = "AuthMethod"."id" + ), + 'OtpAuthMethod', ( + SELECT to_jsonb("OtpAuthMethod") + FROM "OtpAuthMethod" + WHERE "OtpAuthMethod"."projectId" = "ProjectUser"."projectId" AND "OtpAuthMethod"."projectUserId" = "ProjectUser"."projectUserId" AND "OtpAuthMethod"."authMethodId" = "AuthMethod"."id" + ), + 'PasskeyAuthMethod', ( + SELECT to_jsonb("PasskeyAuthMethod") + FROM "PasskeyAuthMethod" + WHERE "PasskeyAuthMethod"."projectId" = "ProjectUser"."projectId" AND "PasskeyAuthMethod"."projectUserId" = "ProjectUser"."projectUserId" AND "PasskeyAuthMethod"."authMethodId" = "AuthMethod"."id" + ), + 'OAuthAuthMethod', ( + SELECT to_jsonb("OAuthAuthMethod") + FROM "OAuthAuthMethod" + WHERE "OAuthAuthMethod"."projectId" = "ProjectUser"."projectId" AND "OAuthAuthMethod"."projectUserId" = "ProjectUser"."projectUserId" AND "OAuthAuthMethod"."authMethodId" = "AuthMethod"."id" + ) + ) + ), '{}') + FROM "AuthMethod" + WHERE "AuthMethod"."projectId" = "ProjectUser"."projectId" AND "AuthMethod"."projectUserId" = "ProjectUser"."projectUserId" + ), + 'SelectedTeamMember', ( + SELECT + to_jsonb("TeamMember") || + jsonb_build_object( + 'Team', ( + SELECT + to_jsonb("Team") + FROM "Team" + WHERE "Team"."projectId" = "ProjectUser"."projectId" AND "Team"."teamId" = "TeamMember"."teamId" + ) + ) + FROM "TeamMember" + WHERE "TeamMember"."projectId" = "ProjectUser"."projectId" AND "TeamMember"."projectUserId" = "ProjectUser"."projectUserId" AND "TeamMember"."isSelected" = 'TRUE' + ) + ) + ) + FROM "ProjectUser" + WHERE "ProjectUser"."projectId" = ${projectId} AND "ProjectUser"."projectUserId" = ${userId}::UUID + ) + ) AS "row_data_json" + `, + postProcess: async (queryResult) => { + /* + const selectedTeamMembers = prisma.teamMembers; + if (selectedTeamMembers.length > 1) { + throw new StackAssertionError("User cannot have more than one selected team; this should never happen"); + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const primaryEmailContactChannel = prisma.contactChannels.find((c) => c.type === 'EMAIL' && c.isPrimary); + const passwordAuth = prisma.authMethods.find((m) => m.passwordAuthMethod); + const otpAuth = prisma.authMethods.find((m) => m.otpAuthMethod); + const passkeyAuth = prisma.authMethods.find((m) => m.passkeyAuthMethod); + + return { + id: prisma.projectUserId, + display_name: prisma.displayName || null, + primary_email: primaryEmailContactChannel?.value || null, + primary_email_verified: !!primaryEmailContactChannel?.isVerified, + primary_email_auth_enabled: !!primaryEmailContactChannel?.usedForAuth, + profile_image_url: prisma.profileImageUrl, + signed_up_at_millis: prisma.createdAt.getTime(), + client_metadata: prisma.clientMetadata, + client_read_only_metadata: prisma.clientReadOnlyMetadata, + server_metadata: prisma.serverMetadata, + has_password: !!passwordAuth, + otp_auth_enabled: !!otpAuth, + auth_with_email: !!passwordAuth || !!otpAuth, + requires_totp_mfa: prisma.requiresTotpMfa, + passkey_auth_enabled: !!passkeyAuth, + oauth_providers: prisma.projectUserOAuthAccounts.map((a) => ({ + id: a.oauthProviderConfigId, + account_id: a.providerAccountId, + email: a.email, + })), + selected_team_id: selectedTeamMembers[0]?.teamId ?? null, + selected_team: selectedTeamMembers[0] ? teamPrismaToCrud(selectedTeamMembers[0]?.team) : null, + last_active_at_millis: lastActiveAtMillis, + }; + + +[ + { + row_data_json: [ + { + createdAt: '2024-12-27T01:18:14.979', + projectId: 'internal', + updatedAt: '2024-12-27T01:18:14.979', + totpSecret: null, + AuthMethods: [ + { + id: '61d03adb-4121-4315-a930-ecfd99148811', + createdAt: '2024-12-27T01:18:15.17', + projectId: 'internal', + updatedAt: '2024-12-27T01:18:15.17', + OtpAuthMethod: [ + { + createdAt: '2024-12-27T01:18:15.17', + projectId: 'internal', + updatedAt: '2024-12-27T01:18:15.17', + authMethodId: '61d03adb-4121-4315-a930-ecfd99148811', + projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35' + } + ], + projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35', + OAuthAuthMethod: null, + projectConfigId: 'f1d1732c-3061-41b0-bce4-09e0c5c009fc', + PasskeyAuthMethod: null, + PasswordAuthMethod: null, + authMethodConfigId: '27aa9ab1-664f-4910-97a0-a50c382c7e4f' + } + ], + displayName: null, + projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35', + clientMetadata: null, + serverMetadata: null, + ContactChannels: [ + { + id: '9a191986-97f9-42b8-99d0-db5a684ae88a', + type: 'EMAIL', + value: 'default-mailbox--28598bbe-79ed-44e6-b476-e6ffaa3a2dd5@stack-generated.example.com', + createdAt: '2024-12-27T01:18:15.071', + isPrimary: 'TRUE', + projectId: 'internal', + updatedAt: '2024-12-27T01:18:15.071', + isVerified: true, + usedForAuth: 'TRUE', + projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35' + } + ], + profileImageUrl: null, + requiresTotpMfa: false, + SelectedTeamMember: null, + clientReadOnlyMetadata: null, + ProjectUserOAuthAccounts: null + } + ] + } +] + */ + + if (queryResult.length === 0) { + return null; + } + if (queryResult.length !== 1) { + throw new StackAssertionError("Expected 1 result, got " + queryResult.length, queryResult); + } + + const row = queryResult[0].row_data_json; + + const primaryEmailContactChannel = row.ContactChannels.find((c: any) => c.type === 'EMAIL' && c.isPrimary); + const passwordAuth = row.AuthMethods.find((m: any) => m.PasswordAuthMethod); + const otpAuth = row.AuthMethods.find((m: any) => m.OtpAuthMethod); + const passkeyAuth = row.AuthMethods.find((m: any) => m.PasskeyAuthMethod); + + return { + id: row.projectUserId, + display_name: row.displayName, + primary_email: primaryEmailContactChannel?.value || null, + primary_email_verified: primaryEmailContactChannel?.isVerified || false, + primary_email_auth_enabled: primaryEmailContactChannel?.usedForAuth === 'TRUE' ? true : false, + profile_image_url: row.profileImageUrl, + signed_up_at_millis: new Date(row.createdAt + "Z").getTime(), + client_metadata: row.clientMetadata, + client_read_only_metadata: row.clientReadOnlyMetadata, + server_metadata: row.serverMetadata, + has_password: !!passwordAuth, + otp_auth_enabled: !!otpAuth, + auth_with_email: !!passwordAuth || !!otpAuth, + requires_totp_mfa: row.requiresTotpMfa, + passkey_auth_enabled: !!passkeyAuth, + oauth_providers: row.ProjectUserOAuthAccounts.map((a: any) => ({ + id: a.oauthProviderConfigId, + account_id: a.providerAccountId, + email: a.email, + })), + selected_team_id: row.SelectedTeamMember?.teamId ?? null, + selected_team: row.SelectedTeamMember ? { + id: row.SelectedTeamMember.team.teamId, + display_name: row.SelectedTeamMember.team.displayName, + profile_image_url: row.SelectedTeamMember.team.profileImageUrl, + created_at_millis: row.SelectedTeamMember.team.createdAt.getTime(), + client_metadata: row.SelectedTeamMember.team.clientMetadata, + client_read_only_metadata: row.SelectedTeamMember.team.clientReadOnlyMetadata, + server_metadata: row.SelectedTeamMember.team.serverMetadata, + } : null, + last_active_at_millis: row.lastActiveAt ? new Date(row.lastActiveAt + "Z").getTime() : new Date(row.createdAt + "Z").getTime(), + }; + }, + }; +} + export async function getUser(options: { projectId: string, userId: string }) { + const result = await rawQuery(getUserQuery(options.projectId, options.userId)); + + // In non-prod environments, let's also call the legacy function and ensure the result is the same + // TODO next-release: remove this + if (!getNodeEnvironment().includes("prod")) { + const legacyResult = await getUserLegacy(options); + if (!deepPlainEquals(result, legacyResult)) { + throw new StackAssertionError("User result mismatch", { + result, + legacyResult, + }); + } + } + + return result; +} + +async function getUserLegacy(options: { projectId: string, userId: string }) { const [db, lastActiveAtMillis] = await Promise.all([ prismaClient.projectUser.findUnique({ where: { @@ -252,7 +508,7 @@ export async function getUser(options: { projectId: string, userId: string }) { }, include: userFullInclude, }), - getUserLastActiveAtMillis(options.userId), + getUserLastActiveAtMillis(options.projectId, options.userId), ]); if (!db) { @@ -333,7 +589,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC } : {}, }); - const lastActiveAtMillis = await getUsersLastActiveAtMillis(db.map(user => user.projectUserId), db.map(user => user.createdAt)); + const lastActiveAtMillis = await getUsersLastActiveAtMillis(auth.project.id, db.map(user => user.projectUserId), db.map(user => user.createdAt)); return { // remove the last item because it's the next cursor items: db.map((user, index) => userPrismaToCrud(user, lastActiveAtMillis[index])).slice(0, query.limit), @@ -514,7 +770,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC throw new StackAssertionError("User was created but not found", newUser); } - return userPrismaToCrud(user, await getUserLastActiveAtMillis(user.projectUserId) ?? user.createdAt.getTime()); + return userPrismaToCrud(user, await getUserLastActiveAtMillis(auth.project.id, user.projectUserId) ?? user.createdAt.getTime()); }); if (auth.project.config.create_team_on_sign_up) { @@ -826,7 +1082,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }); } - return userPrismaToCrud(db, await getUserLastActiveAtMillis(params.user_id) ?? db.createdAt.getTime()); + return userPrismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, params.user_id) ?? db.createdAt.getTime()); }); diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 102cbaae7..3f9319914 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -1,6 +1,7 @@ import { Prisma, PrismaClient } from "@prisma/client"; import { withAccelerate } from "@prisma/extension-accelerate"; import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; +import { isNotNull, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { traceSpan } from "./utils/telemetry"; @@ -39,3 +40,51 @@ export async function retryTransaction(fn: (...args: Parameters = { + sql: Prisma.Sql, + postProcess: (rows: any[]) => Promise, +}; + +export async function rawQuery>(query: Q): Promise>> { + const result = await rawQueryArray([query]); + return result[0]; +} + +export async function rawQueryAll>>(queries: Q): Promise<{ [K in keyof Q]: Awaited["postProcess"]>> }> { + const keys = typedKeys(queries); + const result = await rawQueryArray(keys.map(key => queries[key]).filter(isNotNull)); + return typedFromEntries(keys.map((key, index) => [key, result[index]])); +} + +async function rawQueryArray[]>(queries: Q): Promise<[] & { [K in keyof Q]: Awaited> }> { + if (queries.length === 0) return [] as any; + + const query = Prisma.sql` + WITH ${Prisma.join(queries.map((q, index) => { + return Prisma.sql`${Prisma.raw("q" + index)} AS ( + ${q.sql} + )`; + }), ",\n")} + + ${Prisma.join(queries.map((q, index) => { + return Prisma.sql` + SELECT + ${"q" + index} AS type, + row_to_json(c) AS json + FROM (SELECT * FROM ${Prisma.raw("q" + index)}) c + `; + }), "\nUNION ALL\n")} + `; + const rawResult = await prismaClient.$queryRaw(query) as { type: string, json: any }[]; + const unprocessed = new Array(queries.length).fill(null).map(() => [] as any[]); + for (const row of rawResult) { + const type = row.type; + const index = +type.slice(1); + unprocessed[index].push(row.json); + } + const postProcessed = await Promise.all( + queries.map((q, index) => q.postProcess(unprocessed[index])) + ); + return postProcessed as any; +} diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index ed63d6218..a9a297a00 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -1,11 +1,11 @@ import "../polyfills"; -import { getUser } from "@/app/api/v1/users/crud"; +import { getUser, getUserQuery } from "@/app/api/v1/users/crud"; import { checkApiKeySet } from "@/lib/api-keys"; import { getProject, listManagedProjectIds } from "@/lib/projects"; import { decodeAccessToken } from "@/lib/tokens"; +import { rawQueryAll } from "@/prisma-client"; import { traceSpan, withTraceSpan } from "@/utils/telemetry"; -import { Span } from "@opentelemetry/api"; import { KnownErrors } from "@stackframe/stack-shared"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; @@ -139,7 +139,7 @@ async function parseBody(req: NextRequest, bodyBuffer: ArrayBuffer): Promise => { +const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextRequest): Promise => { const projectId = req.headers.get("x-stack-project-id"); let requestType = req.headers.get("x-stack-access-type"); const publishableClientKey = req.headers.get("x-stack-publishable-client-key"); @@ -150,7 +150,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque const refreshToken = req.headers.get("x-stack-refresh-token"); const developmentKeyOverride = req.headers.get("x-stack-development-override-key"); // in development, the internal project's API key can optionally be used to access any project - const extractUserFromAccessToken = async (options: { token: string, projectId: string }) => { + const extractUserIdFromAccessToken = async (options: { token: string, projectId: string }) => { const result = await decodeAccessToken(options.token); if (result.status === "error") { throw result.error; @@ -160,7 +160,12 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque throw new KnownErrors.InvalidProjectForAccessToken(); } - const user = await getUser({ projectId: options.projectId, userId: result.data.userId }); + return result.data.userId; + }; + + const extractUserFromAccessToken = async (options: { token: string, projectId: string }) => { + const userId = await extractUserIdFromAccessToken(options); + const user = await getUser({ projectId: options.projectId, userId }); if (!user) { // this is the case when access token is still valid, but the user is deleted from the database throw new KnownErrors.AccessTokenExpired(); @@ -197,7 +202,14 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque return user; }; - // Do all the requests in parallel + + // Prisma does a query for every function call by default, even if we batch them with transactions + // Because smart route handlers are always called, we instead send over a single raw query that fetches all the + // data at the same time, saving us a lot of requests + const bundledQueries = { + user: projectId && accessToken ? getUserQuery(projectId, await extractUserIdFromAccessToken({ token: accessToken, projectId })) : undefined, + }; + const queriesResults = await rawQueryAll(bundledQueries); const queryFuncs = { project: () => projectId ? getProject(projectId) : Promise.resolve(null), diff --git a/apps/backend/src/utils/telemetry.tsx b/apps/backend/src/utils/telemetry.tsx index 0660c0b83..d1649ea10 100644 --- a/apps/backend/src/utils/telemetry.tsx +++ b/apps/backend/src/utils/telemetry.tsx @@ -2,9 +2,9 @@ import { AttributeValue, Span, trace } from "@opentelemetry/api"; const tracer = trace.getTracer('stack-backend'); -export function withTraceSpan

(optionsOrDescription: string | { description: string, attributes?: Record }, fn: (...args: readonly [...P, Span]) => Promise): (...args: P) => Promise { +export function withTraceSpan

(optionsOrDescription: string | { description: string, attributes?: Record }, fn: (...args: P) => Promise): (...args: P) => Promise { return async (...args: P) => { - return await traceSpan(optionsOrDescription, (span) => fn(...args, span)); + return await traceSpan(optionsOrDescription, (span) => fn(...args)); }; } diff --git a/eslint-configs/defaults.js b/eslint-configs/defaults.js index d573bd642..993c4389c 100644 --- a/eslint-configs/defaults.js +++ b/eslint-configs/defaults.js @@ -28,6 +28,7 @@ module.exports = { "TSConditionalType", "FunctionDeclaration", "CallExpression", + "TemplateLiteral *", ], }, ], diff --git a/packages/stack-shared/src/utils/objects.tsx b/packages/stack-shared/src/utils/objects.tsx index 952950b58..f86199941 100644 --- a/packages/stack-shared/src/utils/objects.tsx +++ b/packages/stack-shared/src/utils/objects.tsx @@ -1,5 +1,9 @@ import { StackAssertionError } from "./errors"; +export function isNotNull(value: T): value is NonNullable { + return value !== null && value !== undefined; +} + export type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T; /** From c98ab27847592f36b2da315b10477eec1557e8df Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 26 Dec 2024 18:24:23 -0800 Subject: [PATCH 2/9] Fixes --- apps/backend/src/app/api/v1/users/crud.tsx | 109 +----------------- apps/backend/src/prisma-client.tsx | 7 +- .../src/route-handlers/smart-request.tsx | 3 +- 3 files changed, 5 insertions(+), 114 deletions(-) diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index ec4ca31bc..677ba1a31 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -248,14 +248,6 @@ export function getUserQuery(projectId: string, userId: string): RawQuery>'projectId' = "ProjectUser"."projectId" AND ("data"->>'userId')::UUID = "ProjectUser"."projectUserId" AND "systemEventTypeIds" @> '{"$user-activity"}' ), - 'UserActivityEvents', ( - SELECT COALESCE(ARRAY_AGG( - to_jsonb("Event") || - jsonb_build_object() - ), '{}') - FROM "Event" - WHERE "Event".data->>'projectId' = "ProjectUser"."projectId" AND ("Event".data->>'userId')::UUID = "ProjectUser"."projectUserId" AND "Event"."systemEventTypeIds" @> '{"$user-activity"}' - ), 'ContactChannels', ( SELECT COALESCE(ARRAY_AGG( to_jsonb("ContactChannel") || @@ -328,106 +320,7 @@ export function getUserQuery(projectId: string, userId: string): RawQuery { - /* - const selectedTeamMembers = prisma.teamMembers; - if (selectedTeamMembers.length > 1) { - throw new StackAssertionError("User cannot have more than one selected team; this should never happen"); - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const primaryEmailContactChannel = prisma.contactChannels.find((c) => c.type === 'EMAIL' && c.isPrimary); - const passwordAuth = prisma.authMethods.find((m) => m.passwordAuthMethod); - const otpAuth = prisma.authMethods.find((m) => m.otpAuthMethod); - const passkeyAuth = prisma.authMethods.find((m) => m.passkeyAuthMethod); - - return { - id: prisma.projectUserId, - display_name: prisma.displayName || null, - primary_email: primaryEmailContactChannel?.value || null, - primary_email_verified: !!primaryEmailContactChannel?.isVerified, - primary_email_auth_enabled: !!primaryEmailContactChannel?.usedForAuth, - profile_image_url: prisma.profileImageUrl, - signed_up_at_millis: prisma.createdAt.getTime(), - client_metadata: prisma.clientMetadata, - client_read_only_metadata: prisma.clientReadOnlyMetadata, - server_metadata: prisma.serverMetadata, - has_password: !!passwordAuth, - otp_auth_enabled: !!otpAuth, - auth_with_email: !!passwordAuth || !!otpAuth, - requires_totp_mfa: prisma.requiresTotpMfa, - passkey_auth_enabled: !!passkeyAuth, - oauth_providers: prisma.projectUserOAuthAccounts.map((a) => ({ - id: a.oauthProviderConfigId, - account_id: a.providerAccountId, - email: a.email, - })), - selected_team_id: selectedTeamMembers[0]?.teamId ?? null, - selected_team: selectedTeamMembers[0] ? teamPrismaToCrud(selectedTeamMembers[0]?.team) : null, - last_active_at_millis: lastActiveAtMillis, - }; - - -[ - { - row_data_json: [ - { - createdAt: '2024-12-27T01:18:14.979', - projectId: 'internal', - updatedAt: '2024-12-27T01:18:14.979', - totpSecret: null, - AuthMethods: [ - { - id: '61d03adb-4121-4315-a930-ecfd99148811', - createdAt: '2024-12-27T01:18:15.17', - projectId: 'internal', - updatedAt: '2024-12-27T01:18:15.17', - OtpAuthMethod: [ - { - createdAt: '2024-12-27T01:18:15.17', - projectId: 'internal', - updatedAt: '2024-12-27T01:18:15.17', - authMethodId: '61d03adb-4121-4315-a930-ecfd99148811', - projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35' - } - ], - projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35', - OAuthAuthMethod: null, - projectConfigId: 'f1d1732c-3061-41b0-bce4-09e0c5c009fc', - PasskeyAuthMethod: null, - PasswordAuthMethod: null, - authMethodConfigId: '27aa9ab1-664f-4910-97a0-a50c382c7e4f' - } - ], - displayName: null, - projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35', - clientMetadata: null, - serverMetadata: null, - ContactChannels: [ - { - id: '9a191986-97f9-42b8-99d0-db5a684ae88a', - type: 'EMAIL', - value: 'default-mailbox--28598bbe-79ed-44e6-b476-e6ffaa3a2dd5@stack-generated.example.com', - createdAt: '2024-12-27T01:18:15.071', - isPrimary: 'TRUE', - projectId: 'internal', - updatedAt: '2024-12-27T01:18:15.071', - isVerified: true, - usedForAuth: 'TRUE', - projectUserId: 'be796835-b849-478f-93c8-a0827c4eff35' - } - ], - profileImageUrl: null, - requiresTotpMfa: false, - SelectedTeamMember: null, - clientReadOnlyMetadata: null, - ProjectUserOAuthAccounts: null - } - ] - } -] - */ - + postProcess: (queryResult) => { if (queryResult.length === 0) { return null; } diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 3f9319914..f4ef16ff0 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -43,7 +43,7 @@ export async function retryTransaction(fn: (...args: Parameters = { sql: Prisma.Sql, - postProcess: (rows: any[]) => Promise, + postProcess: (rows: any[]) => T, // Tip: If your postProcess is async, just set T = Promise (compared to doing Promise.all in rawQuery, this ensures that there are no accidental timing attacks) }; export async function rawQuery>(query: Q): Promise>> { @@ -83,8 +83,7 @@ async function rawQueryArray[]>(queries: Q): Promise<[] const index = +type.slice(1); unprocessed[index].push(row.json); } - const postProcessed = await Promise.all( - queries.map((q, index) => q.postProcess(unprocessed[index])) - ); + const postProcessed = queries.map((q, index) => q.postProcess(unprocessed[index])); return postProcessed as any; } + diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index a9a297a00..27203ffa6 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -216,7 +216,6 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque isClientKeyValid: () => projectId && publishableClientKey && requestType === "client" ? checkApiKeySet(projectId, { publishableClientKey }) : Promise.resolve(false), isServerKeyValid: () => projectId && secretServerKey && requestType === "server" ? checkApiKeySet(projectId, { secretServerKey }) : Promise.resolve(false), isAdminKeyValid: () => projectId && superSecretAdminKey && requestType === "admin" ? checkApiKeySet(projectId, { superSecretAdminKey }) : Promise.resolve(false), - user: () => projectId && accessToken ? extractUserFromAccessToken({ token: accessToken, projectId }) : Promise.resolve(null), internalUser: () => projectId && adminAccessToken ? extractUserFromAdminAccessToken({ token: adminAccessToken, projectId }) : Promise.resolve(null), } as const; const results: [string, Promise][] = []; @@ -285,7 +284,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque return { project, - user: await queries.user ?? undefined, + user: queriesResults.user ?? undefined, type: requestType, }; }); From c649d9380d2c237f77470748cf8cddefdef6a386 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 26 Dec 2024 18:26:42 -0800 Subject: [PATCH 3/9] Don't catch StackAssertionError in CRUD handlers --- apps/backend/src/route-handlers/crud-handler.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/route-handlers/crud-handler.tsx b/apps/backend/src/route-handlers/crud-handler.tsx index 5ff7302dd..fc24bdc8d 100644 --- a/apps/backend/src/route-handlers/crud-handler.tsx +++ b/apps/backend/src/route-handlers/crud-handler.tsx @@ -257,7 +257,7 @@ export function createCrudHandlers< }, }); } catch (error) { - if (allowedErrorTypes?.some((a) => error instanceof a)) { + if (allowedErrorTypes?.some((a) => error instanceof a) || error instanceof StackAssertionError) { throw error; } throw new CrudHandlerInvocationError(error); From 526c50729a026ac45e166c81404e3c32562b32a7 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 26 Dec 2024 18:34:39 -0800 Subject: [PATCH 4/9] Fix --- apps/backend/src/app/api/v1/users/crud.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index 677ba1a31..79b588fea 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -206,7 +206,7 @@ async function getOtpConfig(tx: PrismaTransaction, projectConfigId: string) { } export const getUserLastActiveAtMillis = async (projectId: string, userId: string): Promise => { - const res = (await getUsersLastActiveAtMillis(projectId, [userId], [new Date()]))[0]; + const res = (await getUsersLastActiveAtMillis(projectId, [userId], [0]))[0]; if (res === 0) { return null; } @@ -223,7 +223,7 @@ export const getUsersLastActiveAtMillis = async (projectId: string, userIds: str const events = await prismaClient.$queryRaw>` SELECT data->>'userId' as "userId", MAX("eventStartedAt") as "lastActiveAt" FROM "Event" - WHERE data->>'userId' = ANY(${Prisma.sql`ARRAY[${Prisma.join(userIds)}]`}) AND data->>'projectId' = ${projectId} + WHERE data->>'userId' = ANY(${Prisma.sql`ARRAY[${Prisma.join(userIds)}]`}) AND data->>'projectId' = ${projectId} AND "systemEventTypeIds" @> '{"$user-activity"}' GROUP BY data->>'userId' `; @@ -248,6 +248,13 @@ export function getUserQuery(projectId: string, userId: string): RawQuery>'projectId' = "ProjectUser"."projectId" AND ("data"->>'userId')::UUID = "ProjectUser"."projectUserId" AND "systemEventTypeIds" @> '{"$user-activity"}' ), + 'UserActivityEvents', ( + SELECT COALESCE(ARRAY_AGG( + to_jsonb("Event") + ), '{}') + FROM "Event" + WHERE data->>'projectId' = "ProjectUser"."projectId" AND ("data"->>'userId')::UUID = "ProjectUser"."projectUserId" AND "systemEventTypeIds" @> '{"$user-activity"}' + ), 'ContactChannels', ( SELECT COALESCE(ARRAY_AGG( to_jsonb("ContactChannel") || @@ -328,7 +335,7 @@ export function getUserQuery(projectId: string, userId: string): RawQuery c.type === 'EMAIL' && c.isPrimary); const passwordAuth = row.AuthMethods.find((m: any) => m.PasswordAuthMethod); From 40a66094fd297cd0815950d8a4943e28d908916b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 26 Dec 2024 18:55:19 -0800 Subject: [PATCH 5/9] Fix? --- apps/backend/src/app/api/v1/users/crud.tsx | 36 +++-- .../endpoints/api/v1/team-memberships.test.ts | 137 ++++++++++++++++++ 2 files changed, 159 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index 79b588fea..e3261f1c0 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -248,13 +248,6 @@ export function getUserQuery(projectId: string, userId: string): RawQuery>'projectId' = "ProjectUser"."projectId" AND ("data"->>'userId')::UUID = "ProjectUser"."projectUserId" AND "systemEventTypeIds" @> '{"$user-activity"}' ), - 'UserActivityEvents', ( - SELECT COALESCE(ARRAY_AGG( - to_jsonb("Event") - ), '{}') - FROM "Event" - WHERE data->>'projectId' = "ProjectUser"."projectId" AND ("data"->>'userId')::UUID = "ProjectUser"."projectUserId" AND "systemEventTypeIds" @> '{"$user-activity"}' - ), 'ContactChannels', ( SELECT COALESCE(ARRAY_AGG( to_jsonb("ContactChannel") || @@ -270,7 +263,7 @@ export function getUserQuery(projectId: string, userId: string): RawQuery c.type === 'EMAIL' && c.isPrimary); const passwordAuth = row.AuthMethods.find((m: any) => m.PasswordAuthMethod); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts index a9587c9d1..754581889 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts @@ -167,6 +167,143 @@ it("creates a team and allows managing users on the server", async ({ expect }) `); }); +it("lets users be on multiple teams", async ({ expect }) => { + const { userId: creatorUserId } = await Auth.Otp.signIn(); + const { teamId: teamId1 } = await Team.createAndAddCurrent(); + const { teamId: teamId2 } = await Team.createAndAddCurrent(); + + await bumpEmailAddress(); + const { userId } = await Auth.Otp.signIn(); + await niceBackendFetch(`/api/v1/team-memberships/${teamId1}/${userId}`, { + accessType: "server", + method: "POST", + body: {}, + }); + await niceBackendFetch(`/api/v1/team-memberships/${teamId2}/${userId}`, { + accessType: "server", + method: "POST", + body: {}, + }); + + const response = await niceBackendFetch(`/api/v1/users?team_id=${teamId1}`, { + accessType: "server", + method: "GET", + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "is_paginated": true, + "items": [ + { + "auth_with_email": true, + "client_metadata": null, + "client_read_only_metadata": null, + "display_name": null, + "has_password": false, + "id": "", + "last_active_at_millis": , + "oauth_providers": [], + "otp_auth_enabled": true, + "passkey_auth_enabled": false, + "primary_email": "default-mailbox--@stack-generated.example.com", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "profile_image_url": null, + "requires_totp_mfa": false, + "selected_team": null, + "selected_team_id": null, + "server_metadata": null, + "signed_up_at_millis": , + }, + { + "auth_with_email": true, + "client_metadata": null, + "client_read_only_metadata": null, + "display_name": null, + "has_password": false, + "id": "", + "last_active_at_millis": , + "oauth_providers": [], + "otp_auth_enabled": true, + "passkey_auth_enabled": false, + "primary_email": "mailbox-1--@stack-generated.example.com", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "profile_image_url": null, + "requires_totp_mfa": false, + "selected_team": null, + "selected_team_id": null, + "server_metadata": null, + "signed_up_at_millis": , + }, + ], + "pagination": { "next_cursor": null }, + }, + "headers": Headers {