Skip to content

Commit

Permalink
Fix stuff
Browse files Browse the repository at this point in the history
  • Loading branch information
N2D4 committed Feb 7, 2025
1 parent d059aa2 commit b216d56
Show file tree
Hide file tree
Showing 11 changed files with 44 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- It's very common to query by userId, projectId, branchId, and eventStartedAt at the same time.
-- We can use a composite index to speed up the query.
-- Sadly we can't add this to the Prisma schema itself because Prisma does not understand composite indexes of JSONB fields.
-- So we have to add it manually.
CREATE INDEX idx_event_userid_projectid_branchid_eventstartedat ON "Event" ((data->>'projectId'), (data->>'branchId'), (data->>'userId'), "eventStartedAt");
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { checkApiKeySet } from "@/lib/api-keys";
import { getProject } from "@/lib/projects";
import { Tenancy, getSoleTenancyFromProject } from "@/lib/tenancies";
import { decodeAccessToken, oauthCookieSchema } from "@/lib/tokens";
import { getProvider } from "@/oauth";
import { prismaClient } from "@/prisma-client";
Expand Down Expand Up @@ -55,7 +55,7 @@ export const GET = createSmartRouteHandler({
bodyType: yupString().oneOf(["empty"]).defined(),
}),
async handler({ params, query }, fullReq) {
const tenancy = await getProject(query.client_id);
const tenancy = await getSoleTenancyFromProject(query.client_id) as Tenancy | null;

if (!tenancy) {
throw new KnownErrors.InvalidOAuthClientIdOrSecret(query.client_id);
Expand All @@ -77,10 +77,13 @@ export const GET = createSmartRouteHandler({
if (result.status === "error") {
throw result.error;
}
const { userId, tenancyId: accessTokenProjectId } = result.data;
const { userId, projectId: accessTokenProjectId, branchId: accessTokenBranchId } = result.data;

if (accessTokenProjectId !== query.client_id) {
throw new StatusError(StatusError.Forbidden, "The access token is not valid for this tenancy");
throw new StatusError(StatusError.Forbidden, "The access token is not valid for this project");
}
if (accessTokenBranchId !== tenancy.branchId) {
throw new StatusError(StatusError.Forbidden, "The access token is not valid for this branch");
}

if (query.provider_scope && provider.type === "shared") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ const handler = createSmartRouteHandler({
body: {},
method: "GET",
query: {
client_id: outerInfo.tenancyId,
client_id: tenancy.project.id,
client_secret: outerInfo.publishableClientKey,
redirect_uri: outerInfo.redirectUri,
state: outerInfo.state,
Expand Down
10 changes: 5 additions & 5 deletions apps/backend/src/app/api/latest/internal/api-keys/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export const apiKeyCrudHandlers = createLazyProxy(() => createPrismaCrudHandlers
baseFields: async () => ({}),
where: async ({ auth }) => {
return {
tenancyId: auth.tenancy.id,
projectId: auth.project.id,
};
},
whereUnique: async ({ params, auth }) => {
return {
tenancyId_id: {
tenancyId: auth.tenancy.id,
projectId_id: {
projectId: auth.project.id,
id: params.api_key_id,
},
};
Expand All @@ -38,8 +38,8 @@ export const apiKeyCrudHandlers = createLazyProxy(() => createPrismaCrudHandlers
if (type === 'create') {
old = await prismaClient.apiKeySet.findUnique({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
projectId_id: {
projectId: auth.project.id,
id: params.api_key_id ?? throwErr('params.apiKeyId is required for update')
},
},
Expand Down
22 changes: 12 additions & 10 deletions apps/backend/src/app/api/latest/internal/metrics/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const DataPointsSchema = yupArray(yupObject({
}).defined()).defined();


async function loadUsersByCountry(tenancyId: string): Promise<Record<string, number>> {
async function loadUsersByCountry(tenancy: Tenancy): Promise<Record<string, number>> {
const a = await prismaClient.$queryRaw<{countryCode: string|null, userCount: bigint}[]>`
WITH LatestEventWithCountryCode AS (
SELECT DISTINCT ON ("userId")
Expand All @@ -26,7 +26,8 @@ async function loadUsersByCountry(tenancyId: string): Promise<Record<string, num
LEFT JOIN "EventIpInfo" eip
ON "Event"."endUserIpInfoGuessId" = eip.id
WHERE '$user-activity' = ANY("systemEventTypeIds"::text[])
AND "data"->>'tenancyId' = ${tenancyId}
AND "data"->>'projectId' = ${tenancy.project.id}
AND "data"->>'branchId' = ${tenancy.branchId}
AND "countryCode" IS NOT NULL
ORDER BY "userId", "eventStartedAt" DESC
)
Expand All @@ -43,7 +44,7 @@ async function loadUsersByCountry(tenancyId: string): Promise<Record<string, num
return rec;
}

async function loadTotalUsers(tenancyId: string, now: Date): Promise<DataPoints> {
async function loadTotalUsers(tenancy: Tenancy, now: Date): Promise<DataPoints> {
return (await prismaClient.$queryRaw<{date: Date, dailyUsers: bigint, cumUsers: bigint}[]>`
WITH date_series AS (
SELECT GENERATE_SERIES(
Expand All @@ -59,7 +60,7 @@ async function loadTotalUsers(tenancyId: string, now: Date): Promise<DataPoints>
SUM(COALESCE(COUNT(pu."projectUserId"), 0)) OVER (ORDER BY ds.registration_day) AS "cumUsers"
FROM date_series ds
LEFT JOIN "ProjectUser" pu
ON DATE(pu."createdAt") = ds.registration_day AND pu."tenancyId" = ${tenancyId}
ON DATE(pu."createdAt") = ds.registration_day AND pu."tenancyId" = ${tenancy.id}::UUID
GROUP BY ds.registration_day
ORDER BY ds.registration_day
`).map((x) => ({
Expand All @@ -68,7 +69,7 @@ async function loadTotalUsers(tenancyId: string, now: Date): Promise<DataPoints>
}));
}

async function loadDailyActiveUsers(tenancyId: string, now: Date) {
async function loadDailyActiveUsers(tenancy: Tenancy, now: Date) {
const res = await prismaClient.$queryRaw<{day: Date, dau: bigint}[]>`
WITH date_series AS (
SELECT GENERATE_SERIES(
Expand All @@ -86,7 +87,8 @@ async function loadDailyActiveUsers(tenancyId: string, now: Date) {
WHERE "eventStartedAt" >= ${now} - INTERVAL '30 days'
AND "eventStartedAt" < ${now}
AND '$user-activity' = ANY("systemEventTypeIds"::text[])
AND "data"->>'tenancyId' = ${tenancyId}
AND "data"->>'projectId' = ${tenancy.project.id}
AND "data"->>'branchId' = ${tenancy.branchId}
GROUP BY DATE_TRUNC('day', "eventStartedAt")
)
SELECT ds."day", COALESCE(du.dau, 0) AS dau
Expand Down Expand Up @@ -127,7 +129,7 @@ async function loadLoginMethods(tenancyId: string): Promise<{method: string, cou
LEFT JOIN "PasswordAuthMethod" pam ON method.id = pam."authMethodId"
LEFT JOIN "PasskeyAuthMethod" pkm ON method.id = pkm."authMethodId"
LEFT JOIN "OtpAuthMethod" oam ON method.id = oam."authMethodId"
WHERE method."tenancyId" = ${tenancyId})
WHERE method."tenancyId" = ${tenancyId}::UUID)
SELECT LOWER("method") AS method, COUNT(id)::int AS "count" FROM tab
GROUP BY "method"
`;
Expand Down Expand Up @@ -216,9 +218,9 @@ export const GET = createSmartRouteHandler({
prismaClient.projectUser.count({
where: { tenancyId: req.auth.tenancy.id, },
}),
loadTotalUsers(req.auth.tenancy.id, now),
loadDailyActiveUsers(req.auth.tenancy.id, now),
loadUsersByCountry(req.auth.tenancy.id),
loadTotalUsers(req.auth.tenancy, now),
loadDailyActiveUsers(req.auth.tenancy, now),
loadUsersByCountry(req.auth.tenancy),
(await usersCrudHandlers.adminList({
tenancy: req.auth.tenancy,
query: {
Expand Down
14 changes: 7 additions & 7 deletions apps/backend/src/app/api/latest/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export const getUsersLastActiveAtMillis = async (tenancyId: string, userIds: str
});
};

export function getUserQuery(tenancyId: string, userId: string): RawQuery<UsersCrud["Admin"]["Read"] | null> {
export function getUserQuery(projectId: string, branchId: string | null, userId: string): RawQuery<UsersCrud["Admin"]["Read"] | null> {
return {
sql: Prisma.sql`
SELECT to_json(
Expand All @@ -248,7 +248,7 @@ export function getUserQuery(tenancyId: string, userId: string): RawQuery<UsersC
'lastActiveAt', (
SELECT MAX("eventStartedAt") as "lastActiveAt"
FROM "Event"
WHERE data->>'tenancyId' = "ProjectUser"."tenancyId" AND "data"->>'userId' = ("ProjectUser"."projectUserId")::text AND "systemEventTypeIds" @> '{"$user-activity"}'
WHERE data->>'tenancyId' = ("ProjectUser"."tenancyId")::text AND "data"->>'userId' = ("ProjectUser"."projectUserId")::text AND "systemEventTypeIds" @> '{"$user-activity"}'
),
'ContactChannels', (
SELECT COALESCE(ARRAY_AGG(
Expand Down Expand Up @@ -333,15 +333,16 @@ export function getUserQuery(tenancyId: string, userId: string): RawQuery<UsersC
)
)
FROM "ProjectUser"
LEFT JOIN "Project" ON "Project"."id" = "ProjectUser"."tenancyId"
LEFT JOIN "Tenancy" ON "Tenancy"."id" = "ProjectUser"."tenancyId"
LEFT JOIN "Project" ON "Project"."id" = "Tenancy"."projectId"
LEFT JOIN "ProjectConfig" ON "ProjectConfig"."id" = "Project"."configId"
WHERE "ProjectUser"."tenancyId" = ${tenancyId} AND "ProjectUser"."projectUserId" = ${userId}::UUID
WHERE "Tenancy"."projectId" = ${projectId} AND "Tenancy"."branchId" = ${branchId ?? "main"} AND "ProjectUser"."projectUserId" = ${userId}::UUID
)
) AS "row_data_json"
`,
postProcess: (queryResult) => {
if (queryResult.length !== 1) {
throw new StackAssertionError(`Expected 1 user with id ${userId} in tenancy ${tenancyId}, got ${queryResult.length}`, { queryResult });
throw new StackAssertionError(`Expected 1 user with id ${userId} in project ${projectId}, got ${queryResult.length}`, { queryResult });
}

const row = queryResult[0].row_data_json;
Expand Down Expand Up @@ -405,10 +406,9 @@ export async function getUser(options: { userId: string } & ({ projectId: string
tenancy = await getTenancy(options.tenancyId) ?? throwErr("Tenancy not found", { tenancyId: options.tenancyId });
}

const result = await rawQuery(getUserQuery(tenancy.id, options.userId));
const result = await rawQuery(getUserQuery(tenancy.project.id, tenancy.branchId, 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({ tenancyId: tenancy.id, userId: options.userId });
if (!deepPlainEquals(result, legacyResult)) {
Expand Down
1 change: 0 additions & 1 deletion apps/backend/src/lib/api-keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export async function checkApiKeySet(projectId: string, key: KeyType): Promise<b
const result = await rawQuery(checkApiKeySetQuery(projectId, key));

// 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 legacy = await checkApiKeySetLegacy(projectId, key);
if (legacy !== result) {
Expand Down
3 changes: 1 addition & 2 deletions apps/backend/src/lib/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ export function getProjectQuery(projectId: string): RawQuery<ProjectsCrud["Admin
'userCount', (
SELECT count(*)
FROM "ProjectUser"
WHERE "ProjectUser"."projectId" = "Project"."id"
WHERE "ProjectUser"."mirroredProjectId" = "Project"."id"
)
)
)
Expand Down Expand Up @@ -474,7 +474,6 @@ export async function getProject(projectId: string): Promise<ProjectsCrud["Admin
const result = await rawQuery(getProjectQuery(projectId));

// 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 getProjectLegacy(projectId);
if (!deepPlainEquals(omit(result ?? {}, ["user_count"] as any), omit(legacyResult ?? {}, ["user_count"] as any))) {
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/lib/tenancies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export async function getSoleTenancyFromProject(projectId: string) {
}

export async function getTenancy(tenancyId: string) {
if (tenancyId === "internal") {
throw new StackAssertionError("Tried to get tenancy with ID `internal`. This is a mistake because `internal` is only a valid identifier for projects.");
}
const prisma = await prismaClient.tenancy.findUnique({
where: { id: tenancyId },
include: fullTenancyInclude,
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/lib/tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]
const accessTokenSchema = yupObject({
projectId: yupString().defined(),
userId: yupString().defined(),
branchId: yupString().defined(),
exp: yupNumber().defined(),
});

Expand Down Expand Up @@ -64,7 +65,7 @@ export async function decodeAccessToken(accessToken: string) {
const result = await accessTokenSchema.validate({
projectId: payload.aud || payload.projectId,
userId: payload.sub,
branchId: payload.branchId ?? "main", // TODO remove this once old tokens have expired
branchId: payload.branchId ?? "main", // TODO remove the main fallback once old tokens have expired
refreshTokenId: payload.refreshTokenId,
exp: payload.exp,
});
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/route-handlers/smart-request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque
// 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,
user: projectId && accessToken ? getUserQuery(projectId, null, await extractUserIdFromAccessToken({ token: accessToken, projectId })) : undefined,
isClientKeyValid: projectId && publishableClientKey && requestType === "client" ? checkApiKeySetQuery(projectId, { publishableClientKey }) : undefined,
isServerKeyValid: projectId && secretServerKey && requestType === "server" ? checkApiKeySetQuery(projectId, { secretServerKey }) : undefined,
isAdminKeyValid: projectId && superSecretAdminKey && requestType === "admin" ? checkApiKeySetQuery(projectId, { superSecretAdminKey }) : undefined,
Expand Down

0 comments on commit b216d56

Please sign in to comment.