Skip to content

Commit d2ed5c0

Browse files
isshaddadTrigger.dev RepoOps
authored andcommitted
fix(webapp): attribute feedback-form Plain threads to the current org
Support threads sent from the dashboard contact form now carry the organization they were sent from: the org page in the submitted path, or the user's only org when sent from outside an org page. The lookup is scoped to the user's own memberships, and a failed lookup sends the message without an org instead of dropping it. Mono-RevId: 7e276dffb2951e420a26f437bffa29a6a8e9e909
1 parent 7bbcf00 commit d2ed5c0

5 files changed

Lines changed: 162 additions & 0 deletions

File tree

‎apps/webapp/app/routes/resources.feedback.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { parseWithZod } from "@conform-to/zod/v4";
22
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
33
import { uiComponent } from "@team-plain/ui-components";
44
import { z } from "zod";
5+
import { $replica, prisma } from "~/db.server";
56
import { redirectWithSuccessMessage } from "~/models/message.server";
7+
import { logger } from "~/services/logger.server";
68
import { requireUser } from "~/services/session.server";
79
import { sanitizeRedirectPath } from "~/utils";
10+
import { feedbackOrganization } from "~/utils/feedbackOrganization.server";
811
import { sendToPlain } from "~/utils/plain.server";
912

1013
export const feedbackTypes = {
@@ -80,12 +83,24 @@ export async function action({ request }: ActionFunctionArgs) {
8083
const inquiry = feedbackTypes[submission.value.feedbackType as FeedbackType];
8184
const redirectPath = sanitizeRedirectPath(submission.value.path);
8285
try {
86+
// Best-effort: a failed lookup sends the message without an org rather than losing it.
87+
const organization = await feedbackOrganization(
88+
user.id,
89+
submission.value.path,
90+
$replica,
91+
prisma
92+
).catch((error) => {
93+
logger.error("Failed to resolve feedback organization", { error });
94+
return undefined;
95+
});
8396
await sendToPlain({
8497
userId: user.id,
8598
email: user.email,
8699
name: user.name ?? user.displayName ?? user.email,
87100
title: inquiry.threadTitle,
88101
labelTypeIds: inquiry.labelTypeId ? [inquiry.labelTypeId] : undefined,
102+
organizationId: organization?.id,
103+
organizationName: organization?.title,
89104
components: [
90105
uiComponent.text({
91106
text: `New ${inquiry.label} reported by ${user.name} (${user.email})`,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import type { PrismaClient } from "@trigger.dev/database";
3+
import { describe, expect } from "vitest";
4+
import { feedbackOrganization } from "./feedbackOrganization.server";
5+
6+
async function createUser(prisma: PrismaClient, name: string) {
7+
return prisma.user.create({
8+
data: { email: `${name}@example.com`, authenticationMethod: "MAGIC_LINK" },
9+
});
10+
}
11+
12+
async function createOrg(prisma: PrismaClient, slug: string, memberIds: string[]) {
13+
return prisma.organization.create({
14+
data: {
15+
title: `Org ${slug}`,
16+
slug,
17+
members: { create: memberIds.map((userId) => ({ userId })) },
18+
},
19+
});
20+
}
21+
22+
describe("feedbackOrganization", () => {
23+
postgresTest("uses the org page the message was sent from", async ({ prisma }) => {
24+
const user = await createUser(prisma, "multi");
25+
const a = await createOrg(prisma, "org-a", [user.id]);
26+
await createOrg(prisma, "org-b", [user.id]);
27+
28+
const org = await feedbackOrganization(
29+
user.id,
30+
"/orgs/org-a/projects/p/env/dev/runs",
31+
prisma,
32+
prisma
33+
);
34+
expect(org).toEqual({ id: a.id, title: a.title });
35+
});
36+
37+
postgresTest("never resolves an org the user isn't a member of", async ({ prisma }) => {
38+
const user = await createUser(prisma, "member");
39+
const other = await createUser(prisma, "other");
40+
await createOrg(prisma, "mine", [user.id]);
41+
await createOrg(prisma, "theirs", [other.id]);
42+
43+
expect(await feedbackOrganization(user.id, "/orgs/theirs", prisma, prisma)).toBeUndefined();
44+
});
45+
46+
postgresTest("falls back to the user's only org outside an org page", async ({ prisma }) => {
47+
const user = await createUser(prisma, "single");
48+
const only = await createOrg(prisma, "only", [user.id]);
49+
50+
expect(await feedbackOrganization(user.id, "/account", prisma, prisma)).toEqual({
51+
id: only.id,
52+
title: only.title,
53+
});
54+
});
55+
56+
postgresTest("leaves it unattributed when the org is ambiguous", async ({ prisma }) => {
57+
const user = await createUser(prisma, "ambiguous");
58+
await createOrg(prisma, "first", [user.id]);
59+
await createOrg(prisma, "second", [user.id]);
60+
61+
expect(await feedbackOrganization(user.id, "/account", prisma, prisma)).toBeUndefined();
62+
});
63+
64+
postgresTest("finds a just-created org the replica hasn't caught up on", async ({ prisma }) => {
65+
const user = await createUser(prisma, "lagged");
66+
67+
// The org exists only inside an uncommitted transaction: the transaction stands in for the
68+
// primary, and the plain client for a replica that can't see it yet.
69+
await prisma.$transaction(async (tx) => {
70+
const fresh = await tx.organization.create({
71+
data: { title: "Org fresh", slug: "fresh", members: { create: [{ userId: user.id }] } },
72+
});
73+
expect(await feedbackOrganization(user.id, "/orgs/fresh", prisma, prisma)).toBeUndefined();
74+
expect(await feedbackOrganization(user.id, "/orgs/fresh", prisma, tx)).toEqual({
75+
id: fresh.id,
76+
title: fresh.title,
77+
});
78+
});
79+
});
80+
81+
postgresTest("ignores deleted orgs", async ({ prisma }) => {
82+
const user = await createUser(prisma, "deleted");
83+
const gone = await createOrg(prisma, "gone", [user.id]);
84+
const live = await createOrg(prisma, "live", [user.id]);
85+
await prisma.organization.update({ where: { id: gone.id }, data: { deletedAt: new Date() } });
86+
87+
expect(await feedbackOrganization(user.id, "/orgs/gone", prisma, prisma)).toBeUndefined();
88+
expect(await feedbackOrganization(user.id, "/account", prisma, prisma)).toEqual({
89+
id: live.id,
90+
title: live.title,
91+
});
92+
});
93+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { PrismaReplicaClient } from "@trigger.dev/database";
2+
import { organizationSlugFromPath } from "./organizationSlugFromPath";
3+
4+
type OrgReader = Pick<PrismaReplicaClient, "organization">;
5+
6+
// The org a feedback message is about: the org page it was sent from, else the user's only org.
7+
// Membership-scoped, so a forged path can't attribute a thread to someone else's org.
8+
export async function feedbackOrganization(
9+
userId: string,
10+
path: string,
11+
replica: OrgReader,
12+
primary: OrgReader
13+
): Promise<{ id: string; title: string } | undefined> {
14+
const slug = organizationSlugFromPath(path);
15+
const query = (client: OrgReader) =>
16+
client.organization.findMany({
17+
where: { members: { some: { userId } }, deletedAt: null, ...(slug ? { slug } : {}) },
18+
select: { id: true, title: true },
19+
take: 2,
20+
});
21+
// Replica first; a miss may be replication lag on a just-created org, so retry the primary.
22+
let orgs = await query(replica);
23+
if (orgs.length === 0) orgs = await query(primary);
24+
return orgs.length === 1 ? orgs[0] : undefined;
25+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "vitest";
2+
import { organizationSlugFromPath } from "./organizationSlugFromPath";
3+
4+
describe("organizationSlugFromPath", () => {
5+
it("returns the slug of an org page", () => {
6+
expect(organizationSlugFromPath("/orgs/acme-1a2b")).toBe("acme-1a2b");
7+
expect(organizationSlugFromPath("/orgs/acme-1a2b/projects/web-x9/env/dev/runs")).toBe(
8+
"acme-1a2b"
9+
);
10+
expect(organizationSlugFromPath("/orgs/acme-1a2b?tab=usage")).toBe("acme-1a2b");
11+
});
12+
13+
it("returns undefined outside an org", () => {
14+
expect(organizationSlugFromPath("/")).toBeUndefined();
15+
expect(organizationSlugFromPath("/account")).toBeUndefined();
16+
expect(organizationSlugFromPath("/orgs")).toBeUndefined();
17+
expect(organizationSlugFromPath("/orgs/")).toBeUndefined();
18+
expect(organizationSlugFromPath("/orgs/new")).toBeUndefined();
19+
expect(organizationSlugFromPath("/admin/orgs/acme-1a2b")).toBeUndefined();
20+
});
21+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// The org slug of a dashboard path like `/orgs/<slug>/...`, or undefined outside an org.
2+
export function organizationSlugFromPath(path: string): string | undefined {
3+
const match = /^\/orgs\/([^/?#]+)/.exec(path);
4+
if (!match || match[1] === "new") {
5+
return undefined;
6+
}
7+
return decodeURIComponent(match[1]);
8+
}

0 commit comments

Comments
 (0)