Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
STACK_OPENAI_API_KEY=mock_openai_api_key
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
STACK_INTERNAL_SUPPORT_TEAM_ID=mock_internal_support_team_id

# S3 Configuration for local development using s3mock
STACK_S3_ENDPOINT=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { DEFAULT_BRANCH_ID } from "@/lib/tenancies";
import { globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { supportAuthSchema, validateSupportTeamMembership } from "../../../support-auth";

export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
summary: "List events in a project (Support)",
description: "Internal support endpoint for listing events in a project. Requires support team membership.",
tags: ["Internal", "Support"],
},
request: yupObject({
auth: supportAuthSchema,
params: yupObject({
projectId: yupString().defined(),
}).defined(),
query: yupObject({
limit: yupString().optional(),
offset: yupString().optional(),
}),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
items: yupMixed().defined(),
total: yupNumber().defined(),
}).defined(),
}),
handler: async (req, fullReq) => {
const auth = fullReq.auth ?? throwErr("Missing auth in support events route");
await validateSupportTeamMembership(auth);

Comment thread
madster456 marked this conversation as resolved.
const { projectId } = req.params;

// Parse and validate limit: must be finite, positive, capped at 100, default 30
const parsedLimit = parseInt(req.query.limit ?? "", 10);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.min(parsedLimit, 100)
: 30;

// Parse and validate offset: must be finite, non-negative, default 0
const parsedOffset = parseInt(req.query.offset ?? "", 10);
const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0
? parsedOffset
: 0;

// Events are stored with projectId in the data field
const whereClause = {
AND: [
{
data: {
path: ["projectId"],
equals: projectId,
},
},
{
data: {
path: ["branchId"],
equals: DEFAULT_BRANCH_ID,
},
},
],
};

const events = await globalPrismaClient.event.findMany({
where: whereClause,
orderBy: { eventStartedAt: "desc" },
take: limit,
skip: offset,
include: {
endUserIpInfoGuess: true,
},
});

const total = await globalPrismaClient.event.count({
where: whereClause,
});

const items = events.map((event) => ({
id: event.id,
eventTypes: event.systemEventTypeIds,
eventStartedAt: event.eventStartedAt.toISOString(),
eventEndedAt: event.eventEndedAt.toISOString(),
isWide: event.isWide,
data: event.data as Record<string, unknown>,
ipInfo: event.endUserIpInfoGuess ? {
ip: event.endUserIpInfoGuess.ip,
countryCode: event.endUserIpInfoGuess.countryCode,
cityName: event.endUserIpInfoGuess.cityName,
isTrusted: event.isEndUserIpInfoGuessTrusted,
} : null,
}));

return {
statusCode: 200,
bodyType: "json",
body: { items, total },
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { supportAuthSchema, validateSupportTeamMembership } from "../../../support-auth";

export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
summary: "List teams in a project (Support)",
description: "Internal support endpoint for listing teams in a project. Requires support team membership.",
tags: ["Internal", "Support"],
},
request: yupObject({
auth: supportAuthSchema,
params: yupObject({
projectId: yupString().defined(),
}).defined(),
query: yupObject({
search: yupString().optional(),
teamId: yupString().optional(),
limit: yupString().optional(),
offset: yupString().optional(),
}),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
items: yupMixed().defined(),
total: yupNumber().defined(),
}).defined(),
}),
handler: async (req, fullReq) => {
const auth = fullReq.auth ?? throwErr("Missing auth in support teams route");
await validateSupportTeamMembership(auth);

Comment thread
madster456 marked this conversation as resolved.
const { projectId } = req.params;
const search = req.query.search;
const teamId = req.query.teamId;

// Parse and validate limit: must be finite, positive, capped at 100, default 25
const parsedLimit = parseInt(req.query.limit ?? "", 10);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.min(parsedLimit, 100)
: 25;

// Parse and validate offset: must be finite, non-negative, default 0
const parsedOffset = parseInt(req.query.offset ?? "", 10);
const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0
? parsedOffset
: 0;

const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID);
const prisma = await getPrismaClientForTenancy(tenancy);

// Build search filter - exact teamId takes priority
const searchFilter = teamId
? { teamId: teamId }
: search ? {
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ teamId: { contains: search, mode: "insensitive" as const } },
],
} : {};

const whereClause = {
tenancyId: tenancy.id,
...searchFilter,
};

const [teams, total] = await Promise.all([
prisma.team.findMany({
where: whereClause,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
include: {
teamMembers: {
take: 5,
include: {
projectUser: {
include: {
contactChannels: {
where: {
type: "EMAIL",
isPrimary: "TRUE",
},
},
},
},
},
},
_count: {
select: { teamMembers: true },
},
},
}),
prisma.team.count({ where: whereClause }),
]);

const items = teams.map((team) => ({
id: team.teamId,
displayName: team.displayName,
createdAt: team.createdAt.toISOString(),
profileImageUrl: team.profileImageUrl,
memberCount: team._count.teamMembers,
members: team.teamMembers.map((tm: typeof team.teamMembers[number]) => ({
userId: tm.projectUser.projectUserId,
displayName: tm.projectUser.displayName,
email: tm.projectUser.contactChannels[0]?.value ?? null,
})),
clientMetadata: team.clientMetadata,
serverMetadata: team.serverMetadata,
}));

return {
statusCode: 200,
bodyType: "json",
body: { items, total },
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { supportAuthSchema, validateSupportTeamMembership } from "../../../support-auth";

export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
summary: "List users in a project (Support)",
description: "Internal support endpoint for listing users in a project. Requires support team membership.",
tags: ["Internal", "Support"],
},
request: yupObject({
auth: supportAuthSchema,
params: yupObject({
projectId: yupString().defined(),
}).defined(),
query: yupObject({
search: yupString().optional(),
userId: yupString().optional(),
limit: yupString().optional(),
offset: yupString().optional(),
}),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
items: yupMixed().defined(),
total: yupNumber().defined(),
}).defined(),
}),
handler: async (req, fullReq) => {
const auth = fullReq.auth ?? throwErr("Missing auth in support users route");
await validateSupportTeamMembership(auth);

Comment thread
madster456 marked this conversation as resolved.
const { projectId } = req.params;
const search = req.query.search;
const userId = req.query.userId;

// Parse and validate limit: must be finite, positive, capped at 100, default 25
const parsedLimit = parseInt(req.query.limit ?? "", 10);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.min(parsedLimit, 100)
: 25;

// Parse and validate offset: must be finite, non-negative, default 0
const parsedOffset = parseInt(req.query.offset ?? "", 10);
const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0
? parsedOffset
: 0;

const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID);
const prisma = await getPrismaClientForTenancy(tenancy);

// Build search filter - exact userId takes priority
const searchFilter = userId
? { projectUserId: userId }
: search ? {
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ projectUserId: { contains: search, mode: "insensitive" as const } },
{
contactChannels: {
some: {
value: { contains: search, mode: "insensitive" as const },
},
},
},
],
} : {};

const [users, total] = await Promise.all([
prisma.projectUser.findMany({
where: {
tenancyId: tenancy.id,
...searchFilter,
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
include: {
teamMembers: {
include: {
team: true,
},
},
authMethods: {
include: {
otpAuthMethod: true,
passwordAuthMethod: true,
passkeyAuthMethod: true,
oauthAuthMethod: true,
},
},
contactChannels: {
where: {
type: "EMAIL",
isPrimary: "TRUE",
},
},
},
}),
prisma.projectUser.count({
where: {
tenancyId: tenancy.id,
...searchFilter,
},
}),
]);

const items = users.map((user) => {
const primaryEmailChannel = user.contactChannels.at(0);
return {
id: user.projectUserId,
displayName: user.displayName,
primaryEmail: primaryEmailChannel?.value ?? null,
primaryEmailVerified: primaryEmailChannel?.isVerified ?? false,
isAnonymous: user.isAnonymous,
createdAt: user.createdAt.toISOString(),
profileImageUrl: user.profileImageUrl,
teams: user.teamMembers.map((tm) => ({
id: tm.team.teamId,
displayName: tm.team.displayName,
})),
authMethods: user.authMethods.map((am) => {
if (am.oauthAuthMethod) return `oauth:${am.oauthAuthMethod.configOAuthProviderId}`;
if (am.passwordAuthMethod) return 'password';
if (am.passkeyAuthMethod) return 'passkey';
if (am.otpAuthMethod) return 'otp';
return 'unknown';
}),
clientMetadata: user.clientMetadata,
serverMetadata: user.serverMetadata,
};
});

return {
statusCode: 200,
bodyType: "json",
body: { items, total },
};
},
});
Loading
Loading