Skip to content

Commit 0fb818b

Browse files
committed
internal support via envvar team id
1 parent d6dc85b commit 0fb818b

8 files changed

Lines changed: 2176 additions & 0 deletions

File tree

apps/backend/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
5454
STACK_OPENAI_API_KEY=mock_openai_api_key
5555
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
5656
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
57+
STACK_INTERNAL_SUPPORT_TEAM_ID=mock_internal_support_team_id
5758

5859
# S3 Configuration for local development using s3mock
5960
STACK_S3_ENDPOINT=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
2+
import { globalPrismaClient } from "@/prisma-client";
3+
import { NextRequest, NextResponse } from "next/server";
4+
import { validateSupportAuth } from "../../../support-auth";
5+
6+
// Internal support endpoint for listing events in a project
7+
// Protected by Stack Auth session - requires @stack-auth.com email
8+
9+
export async function GET(
10+
request: NextRequest,
11+
{ params }: { params: Promise<{ projectId: string }> }
12+
) {
13+
const auth = await validateSupportAuth(request);
14+
if (!auth.success) {
15+
return auth.response;
16+
}
17+
18+
const { projectId } = await params;
19+
const searchParams = request.nextUrl.searchParams;
20+
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "30", 10));
21+
const offset = parseInt(searchParams.get("offset") ?? "0", 10);
22+
23+
try {
24+
const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID);
25+
26+
// Events are stored in the global prisma client with a data filter by tenancy
27+
// We need to query by the data field's tenancyId
28+
const events = await globalPrismaClient.event.findMany({
29+
where: {
30+
data: {
31+
path: ["tenancyId"],
32+
equals: tenancy.id,
33+
},
34+
},
35+
orderBy: { eventStartedAt: "desc" },
36+
take: limit,
37+
skip: offset,
38+
include: {
39+
endUserIpInfoGuess: true,
40+
},
41+
});
42+
43+
const total = await globalPrismaClient.event.count({
44+
where: {
45+
data: {
46+
path: ["tenancyId"],
47+
equals: tenancy.id,
48+
},
49+
},
50+
});
51+
52+
const items = events.map((event) => ({
53+
id: event.id,
54+
eventTypes: event.systemEventTypeIds,
55+
eventStartedAt: event.eventStartedAt.toISOString(),
56+
eventEndedAt: event.eventEndedAt.toISOString(),
57+
isWide: event.isWide,
58+
data: event.data as Record<string, unknown>,
59+
ipInfo: event.endUserIpInfoGuess ? {
60+
ip: event.endUserIpInfoGuess.ip,
61+
countryCode: event.endUserIpInfoGuess.countryCode,
62+
cityName: event.endUserIpInfoGuess.cityName,
63+
isTrusted: event.isEndUserIpInfoGuessTrusted,
64+
} : null,
65+
}));
66+
67+
return NextResponse.json({ items, total });
68+
} catch (error) {
69+
console.error("[Support API] Error fetching events:", error);
70+
return NextResponse.json(
71+
{ error: `Internal server error: ${error instanceof Error ? error.message : String(error)}` },
72+
{ status: 500 }
73+
);
74+
}
75+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
2+
import { getPrismaClientForTenancy } from "@/prisma-client";
3+
import { NextRequest, NextResponse } from "next/server";
4+
import { validateSupportAuth } from "../../../support-auth";
5+
6+
// Internal support endpoint for listing teams in a project
7+
// Protected by Stack Auth session - requires @stack-auth.com email
8+
9+
export async function GET(
10+
request: NextRequest,
11+
{ params }: { params: Promise<{ projectId: string }> }
12+
) {
13+
const auth = await validateSupportAuth(request);
14+
if (!auth.success) {
15+
return auth.response;
16+
}
17+
18+
const { projectId } = await params;
19+
const searchParams = request.nextUrl.searchParams;
20+
const search = searchParams.get("search") ?? undefined;
21+
const teamId = searchParams.get("teamId") ?? undefined; // Exact team ID lookup
22+
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "25", 10));
23+
const offset = parseInt(searchParams.get("offset") ?? "0", 10);
24+
25+
try {
26+
const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID);
27+
const prisma = await getPrismaClientForTenancy(tenancy);
28+
29+
// Build search filter - exact teamId takes priority
30+
const searchFilter = teamId
31+
? { teamId: teamId }
32+
: search ? {
33+
OR: [
34+
{ displayName: { contains: search, mode: "insensitive" as const } },
35+
{ teamId: { contains: search, mode: "insensitive" as const } },
36+
],
37+
} : {};
38+
39+
const whereClause = {
40+
tenancyId: tenancy.id,
41+
...searchFilter,
42+
};
43+
44+
const [teams, total] = await Promise.all([
45+
prisma.team.findMany({
46+
where: whereClause,
47+
orderBy: { createdAt: "desc" },
48+
take: limit,
49+
skip: offset,
50+
include: {
51+
teamMembers: {
52+
take: 5,
53+
include: {
54+
projectUser: {
55+
include: {
56+
contactChannels: {
57+
where: {
58+
type: "EMAIL",
59+
isPrimary: "TRUE", // This is a special enum value in Prisma
60+
},
61+
},
62+
},
63+
},
64+
},
65+
},
66+
_count: {
67+
select: { teamMembers: true },
68+
},
69+
},
70+
}),
71+
prisma.team.count({ where: whereClause }),
72+
]);
73+
74+
const items = teams.map((team) => ({
75+
id: team.teamId,
76+
displayName: team.displayName,
77+
createdAt: team.createdAt.toISOString(),
78+
profileImageUrl: team.profileImageUrl,
79+
memberCount: team._count.teamMembers,
80+
members: team.teamMembers.map((tm: typeof team.teamMembers[number]) => ({
81+
userId: tm.projectUser.projectUserId,
82+
displayName: tm.projectUser.displayName,
83+
email: tm.projectUser.contactChannels[0]?.value ?? null,
84+
})),
85+
clientMetadata: team.clientMetadata,
86+
serverMetadata: team.serverMetadata,
87+
}));
88+
89+
return NextResponse.json({ items, total });
90+
} catch (error) {
91+
console.error("Support API error:", error);
92+
return NextResponse.json(
93+
{ error: "Internal server error" },
94+
{ status: 500 }
95+
);
96+
}
97+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
2+
import { getPrismaClientForTenancy } from "@/prisma-client";
3+
import { NextRequest, NextResponse } from "next/server";
4+
import { validateSupportAuth } from "../../../support-auth";
5+
6+
// Internal support endpoint for listing users in a project
7+
// Protected by Stack Auth session - requires @stack-auth.com email
8+
9+
export async function GET(
10+
request: NextRequest,
11+
{ params }: { params: Promise<{ projectId: string }> }
12+
) {
13+
const auth = await validateSupportAuth(request);
14+
if (!auth.success) {
15+
return auth.response;
16+
}
17+
18+
const { projectId } = await params;
19+
const searchParams = request.nextUrl.searchParams;
20+
const search = searchParams.get("search") ?? undefined;
21+
const userId = searchParams.get("userId") ?? undefined; // Exact user ID lookup
22+
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "25", 10));
23+
const offset = parseInt(searchParams.get("offset") ?? "0", 10);
24+
25+
try {
26+
console.log(`[Support API] Fetching users for project: ${projectId}, userId: ${userId ?? 'none'}, search: ${search ?? 'none'}`);
27+
const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID);
28+
const prisma = await getPrismaClientForTenancy(tenancy);
29+
30+
// Build search filter - exact userId takes priority
31+
const searchFilter = userId
32+
? { projectUserId: userId }
33+
: search ? {
34+
OR: [
35+
{ displayName: { contains: search, mode: "insensitive" as const } },
36+
{ projectUserId: { contains: search, mode: "insensitive" as const } },
37+
{
38+
contactChannels: {
39+
some: {
40+
value: { contains: search, mode: "insensitive" as const },
41+
},
42+
},
43+
},
44+
],
45+
} : {};
46+
47+
const [users, total] = await Promise.all([
48+
prisma.projectUser.findMany({
49+
where: {
50+
tenancyId: tenancy.id,
51+
...searchFilter,
52+
},
53+
orderBy: { createdAt: "desc" },
54+
take: limit,
55+
skip: offset,
56+
include: {
57+
teamMembers: {
58+
include: {
59+
team: true,
60+
},
61+
},
62+
authMethods: true,
63+
contactChannels: {
64+
where: {
65+
type: "EMAIL",
66+
isPrimary: "TRUE", // This is a special enum value in Prisma
67+
},
68+
},
69+
},
70+
}),
71+
prisma.projectUser.count({
72+
where: {
73+
tenancyId: tenancy.id,
74+
...searchFilter,
75+
},
76+
}),
77+
]);
78+
79+
const items = users.map((user) => {
80+
const primaryEmailChannel = user.contactChannels[0];
81+
return {
82+
id: user.projectUserId,
83+
displayName: user.displayName,
84+
primaryEmail: primaryEmailChannel?.value ?? null,
85+
primaryEmailVerified: primaryEmailChannel?.isVerified ?? false,
86+
isAnonymous: user.isAnonymous ?? false,
87+
createdAt: user.createdAt.toISOString(),
88+
profileImageUrl: user.profileImageUrl,
89+
teams: user.teamMembers.map((tm) => ({
90+
id: tm.team.teamId,
91+
displayName: tm.team.displayName,
92+
})),
93+
authMethods: user.authMethods.map((am) => am.authMethodIdentifier),
94+
clientMetadata: user.clientMetadata,
95+
serverMetadata: user.serverMetadata,
96+
};
97+
});
98+
99+
console.log(`[Support API] Found ${total} users`);
100+
return NextResponse.json({ items, total });
101+
} catch (error) {
102+
console.error("[Support API] Error fetching users:", error);
103+
return NextResponse.json(
104+
{ error: `Internal server error: ${error instanceof Error ? error.message : String(error)}` },
105+
{ status: 500 }
106+
);
107+
}
108+
}

0 commit comments

Comments
 (0)