Skip to content

Commit bd175b7

Browse files
committed
created plain customer cards post route
1 parent 59adbfb commit bd175b7

File tree

1 file changed

+393
-0
lines changed

1 file changed

+393
-0
lines changed
Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
2+
import { json } from "@remix-run/server-runtime";
3+
import { uiComponent } from "@team-plain/typescript-sdk";
4+
import { z } from "zod";
5+
import { prisma } from "~/db.server";
6+
import { env } from "~/env.server";
7+
import { logger } from "~/services/logger.server";
8+
9+
// Schema for the request body from Plain
10+
const PlainCustomerCardRequestSchema = z.object({
11+
cardKeys: z.array(z.string()),
12+
customer: z.object({
13+
id: z.string(),
14+
email: z.string().optional(),
15+
externalId: z.string().optional(),
16+
}),
17+
thread: z
18+
.object({
19+
id: z.string(),
20+
})
21+
.optional(),
22+
});
23+
24+
// Authenticate the request from Plain
25+
function authenticatePlainRequest(request: Request): boolean {
26+
const authHeader = request.headers.get("Authorization");
27+
const expectedSecret = env.PLAIN_CUSTOMER_CARDS_SECRET;
28+
29+
if (!expectedSecret) {
30+
logger.warn("PLAIN_CUSTOMER_CARDS_SECRET not configured");
31+
return false;
32+
}
33+
34+
if (!authHeader) {
35+
return false;
36+
}
37+
38+
// Support both "Bearer <token>" and plain token formats
39+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
40+
41+
return token === expectedSecret;
42+
}
43+
44+
export async function action({ request }: ActionFunctionArgs) {
45+
// Only accept POST requests
46+
if (request.method !== "POST") {
47+
return json({ error: "Method not allowed" }, { status: 405 });
48+
}
49+
50+
// Authenticate the request
51+
if (!authenticatePlainRequest(request)) {
52+
logger.warn("Unauthorized Plain customer card request", {
53+
headers: Object.fromEntries(request.headers.entries()),
54+
});
55+
return json({ error: "Unauthorized" }, { status: 401 });
56+
}
57+
58+
try {
59+
// Parse and validate the request body
60+
const body = await request.json();
61+
const parsed = PlainCustomerCardRequestSchema.safeParse(body);
62+
63+
if (!parsed.success) {
64+
logger.warn("Invalid Plain customer card request", {
65+
errors: parsed.error.errors,
66+
body,
67+
});
68+
return json({ error: "Invalid request body" }, { status: 400 });
69+
}
70+
71+
const { customer, cardKeys } = parsed.data;
72+
73+
// Look up the user by externalId (which is User.id)
74+
let user = null;
75+
if (customer.externalId) {
76+
user = await prisma.user.findUnique({
77+
where: { id: customer.externalId },
78+
include: {
79+
orgMemberships: {
80+
include: {
81+
organization: {
82+
include: {
83+
projects: {
84+
where: { deletedAt: null },
85+
take: 10, // Limit to recent projects
86+
orderBy: { createdAt: "desc" },
87+
},
88+
},
89+
},
90+
},
91+
},
92+
},
93+
});
94+
} else if (customer.email) {
95+
// Fallback to email lookup if externalId is not provided
96+
user = await prisma.user.findUnique({
97+
where: { email: customer.email },
98+
include: {
99+
orgMemberships: {
100+
include: {
101+
organization: {
102+
include: {
103+
projects: {
104+
where: { deletedAt: null },
105+
take: 10,
106+
orderBy: { createdAt: "desc" },
107+
},
108+
},
109+
},
110+
},
111+
},
112+
},
113+
});
114+
}
115+
116+
// If user not found, return empty cards
117+
if (!user) {
118+
logger.info("User not found for Plain customer card request", {
119+
customerId: customer.id,
120+
externalId: customer.externalId,
121+
email: customer.email,
122+
});
123+
return json({ cards: [] });
124+
}
125+
126+
// Build cards based on requested cardKeys
127+
const cards = [];
128+
129+
for (const cardKey of cardKeys) {
130+
switch (cardKey) {
131+
case "account-details": {
132+
// Build the impersonate URL
133+
const impersonateUrl = `${env.APP_ORIGIN || "https://cloud.trigger.dev"}/admin?impersonate=${user.id}`;
134+
135+
cards.push({
136+
key: "account-details",
137+
timeToLiveSeconds: 300, // Cache for 5 minutes
138+
components: [
139+
uiComponent.container({
140+
components: [
141+
uiComponent.text({
142+
text: "Account Details",
143+
textSize: "L",
144+
textColor: "NORMAL",
145+
}),
146+
uiComponent.spacer({ spacerSize: "M" }),
147+
uiComponent.row({
148+
left: uiComponent.text({
149+
text: "User ID",
150+
textSize: "S",
151+
textColor: "MUTED",
152+
}),
153+
right: uiComponent.copyButton({
154+
textToCopy: user.id,
155+
buttonLabel: "Copy",
156+
}),
157+
}),
158+
uiComponent.spacer({ spacerSize: "S" }),
159+
uiComponent.row({
160+
left: uiComponent.text({
161+
text: "Email",
162+
textSize: "S",
163+
textColor: "MUTED",
164+
}),
165+
right: uiComponent.text({
166+
text: user.email,
167+
textSize: "S",
168+
textColor: "NORMAL",
169+
}),
170+
}),
171+
uiComponent.spacer({ spacerSize: "S" }),
172+
uiComponent.row({
173+
left: uiComponent.text({
174+
text: "Name",
175+
textSize: "S",
176+
textColor: "MUTED",
177+
}),
178+
right: uiComponent.text({
179+
text: user.name || user.displayName || "N/A",
180+
textSize: "S",
181+
textColor: "NORMAL",
182+
}),
183+
}),
184+
uiComponent.spacer({ spacerSize: "S" }),
185+
uiComponent.row({
186+
left: uiComponent.text({
187+
text: "Admin",
188+
textSize: "S",
189+
textColor: "MUTED",
190+
}),
191+
right: uiComponent.badge({
192+
badgeLabel: user.admin ? "Yes" : "No",
193+
badgeColor: user.admin ? "BLUE" : "GRAY",
194+
}),
195+
}),
196+
uiComponent.spacer({ spacerSize: "S" }),
197+
uiComponent.row({
198+
left: uiComponent.text({
199+
text: "Member Since",
200+
textSize: "S",
201+
textColor: "MUTED",
202+
}),
203+
right: uiComponent.text({
204+
text: new Date(user.createdAt).toLocaleDateString(),
205+
textSize: "S",
206+
textColor: "NORMAL",
207+
}),
208+
}),
209+
uiComponent.spacer({ spacerSize: "M" }),
210+
uiComponent.divider(),
211+
uiComponent.spacer({ spacerSize: "M" }),
212+
uiComponent.linkButton({
213+
buttonLabel: "Impersonate User",
214+
buttonUrl: impersonateUrl,
215+
buttonStyle: "PRIMARY",
216+
}),
217+
],
218+
}),
219+
],
220+
});
221+
break;
222+
}
223+
224+
case "organizations": {
225+
if (user.orgMemberships.length === 0) {
226+
cards.push({
227+
key: "organizations",
228+
timeToLiveSeconds: 300,
229+
components: [
230+
uiComponent.container({
231+
components: [
232+
uiComponent.text({
233+
text: "Organizations",
234+
textSize: "L",
235+
textColor: "NORMAL",
236+
}),
237+
uiComponent.spacer({ spacerSize: "M" }),
238+
uiComponent.text({
239+
text: "No organizations found",
240+
textSize: "S",
241+
textColor: "MUTED",
242+
}),
243+
],
244+
}),
245+
],
246+
});
247+
break;
248+
}
249+
250+
const orgComponents = user.orgMemberships.flatMap((membership, index) => {
251+
const org = membership.organization;
252+
const projectCount = org.projects.length;
253+
254+
return [
255+
...(index > 0 ? [uiComponent.divider()] : []),
256+
uiComponent.text({
257+
text: org.title,
258+
textSize: "M",
259+
textColor: "NORMAL",
260+
}),
261+
uiComponent.spacer({ spacerSize: "XS" }),
262+
uiComponent.row({
263+
left: uiComponent.badge({
264+
badgeLabel: membership.role,
265+
badgeColor: membership.role === "ADMIN" ? "BLUE" : "GRAY",
266+
}),
267+
right: uiComponent.text({
268+
text: `${projectCount} project${projectCount !== 1 ? "s" : ""}`,
269+
textSize: "S",
270+
textColor: "MUTED",
271+
}),
272+
}),
273+
uiComponent.spacer({ spacerSize: "XS" }),
274+
uiComponent.linkButton({
275+
buttonLabel: "View in Dashboard",
276+
buttonUrl: `https://cloud.trigger.dev/orgs/${org.slug}`,
277+
buttonStyle: "SECONDARY",
278+
}),
279+
];
280+
});
281+
282+
cards.push({
283+
key: "organizations",
284+
timeToLiveSeconds: 300,
285+
components: [
286+
uiComponent.container({
287+
components: [
288+
uiComponent.text({
289+
text: "Organizations",
290+
textSize: "L",
291+
textColor: "NORMAL",
292+
}),
293+
uiComponent.spacer({ spacerSize: "M" }),
294+
...orgComponents,
295+
],
296+
}),
297+
],
298+
});
299+
break;
300+
}
301+
302+
case "projects": {
303+
const allProjects = user.orgMemberships.flatMap((membership) =>
304+
membership.organization.projects.map((project) => ({
305+
...project,
306+
orgSlug: membership.organization.slug,
307+
}))
308+
);
309+
310+
if (allProjects.length === 0) {
311+
cards.push({
312+
key: "projects",
313+
timeToLiveSeconds: 300,
314+
components: [
315+
uiComponent.container({
316+
components: [
317+
uiComponent.text({
318+
text: "Projects",
319+
textSize: "L",
320+
textColor: "NORMAL",
321+
}),
322+
uiComponent.spacer({ spacerSize: "M" }),
323+
uiComponent.text({
324+
text: "No projects found",
325+
textSize: "S",
326+
textColor: "MUTED",
327+
}),
328+
],
329+
}),
330+
],
331+
});
332+
break;
333+
}
334+
335+
const projectComponents = allProjects.slice(0, 10).flatMap((project, index) => {
336+
return [
337+
...(index > 0 ? [uiComponent.divider()] : []),
338+
uiComponent.text({
339+
text: project.name,
340+
textSize: "M",
341+
textColor: "NORMAL",
342+
}),
343+
uiComponent.spacer({ spacerSize: "XS" }),
344+
uiComponent.row({
345+
left: uiComponent.badge({
346+
badgeLabel: project.version,
347+
badgeColor: project.version === "V3" ? "GREEN" : "GRAY",
348+
}),
349+
right: uiComponent.linkButton({
350+
buttonLabel: "View",
351+
buttonUrl: `https://cloud.trigger.dev/orgs/${project.orgSlug}/projects/${project.slug}`,
352+
buttonStyle: "SECONDARY",
353+
}),
354+
}),
355+
];
356+
});
357+
358+
cards.push({
359+
key: "projects",
360+
timeToLiveSeconds: 300,
361+
components: [
362+
uiComponent.container({
363+
components: [
364+
uiComponent.text({
365+
text: "Projects",
366+
textSize: "L",
367+
textColor: "NORMAL",
368+
}),
369+
uiComponent.spacer({ spacerSize: "M" }),
370+
...projectComponents,
371+
],
372+
}),
373+
],
374+
});
375+
break;
376+
}
377+
378+
default:
379+
// Unknown card key - skip it
380+
logger.info("Unknown card key requested", { cardKey });
381+
break;
382+
}
383+
}
384+
385+
return json({ cards });
386+
} catch (error) {
387+
logger.error("Error processing Plain customer card request", {
388+
error: error instanceof Error ? error.message : String(error),
389+
stack: error instanceof Error ? error.stack : undefined,
390+
});
391+
return json({ error: "Internal server error" }, { status: 500 });
392+
}
393+
}

0 commit comments

Comments
 (0)