Skip to content

Commit 1657371

Browse files
authored
fix: identify org-wide features per user (#17010)
1 parent 97b9207 commit 1657371

File tree

11 files changed

+148
-45
lines changed

11 files changed

+148
-45
lines changed

packages/emails/templates/organizer-request-email.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
1+
import { checkIfUserHasFeatureController } from "@calcom/features/flags/operations/check-if-user-has-feature.controller";
22
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
33

44
import { renderEmail } from "../";
@@ -7,22 +7,15 @@ import OrganizerScheduledEmail from "./organizer-scheduled-email";
77
/**
88
* TODO: Remove once fully migrated to V2
99
*/
10-
async function getOrganizerRequestTemplate(args: { teamId?: number; userId?: number }) {
11-
const featuresRepository = new FeaturesRepository();
12-
const hasNewTemplate = await featuresRepository.checkIfTeamOrUserHasFeature(
13-
args,
14-
"organizer-request-email-v2"
15-
);
10+
async function getOrganizerRequestTemplate(userId?: number) {
11+
const hasNewTemplate = await checkIfUserHasFeatureController(userId, "organizer-request-email-v2");
1612
return hasNewTemplate ? ("OrganizerRequestEmailV2" as const) : ("OrganizerRequestEmail" as const);
1713
}
1814

1915
export default class OrganizerRequestEmail extends OrganizerScheduledEmail {
2016
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
2117
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
22-
const template = await getOrganizerRequestTemplate({
23-
userId: this.calEvent.organizer.id,
24-
teamId: this.calEvent.team?.id,
25-
});
18+
const template = await getOrganizerRequestTemplate(this.calEvent.organizer.id);
2619

2720
return {
2821
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,

packages/features/flags/features.repository.interface.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,4 @@ import type { AppFlags } from "./config";
33
export interface IFeaturesRepository {
44
checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise<boolean>;
55
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
6-
checkIfTeamHasFeature(teamId: number, slug: string): Promise<boolean>;
7-
checkIfTeamOrUserHasFeature(args: { teamId?: number; userId?: number }, slug: string): Promise<boolean>;
86
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { IFeaturesRepository } from "./features.repository.interface";
2+
3+
export class MockFeaturesRepository implements IFeaturesRepository {
4+
async checkIfUserHasFeature(userId: number, slug: string) {
5+
return slug === "mock-feature";
6+
}
7+
async checkIfFeatureIsEnabledGlobally() {
8+
return true;
9+
}
10+
}

packages/features/flags/features.repository.ts

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,51 +15,50 @@ export class FeaturesRepository implements IFeaturesRepository {
1515
throw err;
1616
}
1717
}
18-
async checkIfTeamHasFeature(teamId: number, slug: string) {
18+
async checkIfUserHasFeature(userId: number, slug: string) {
1919
try {
20-
const teamFeature = await db.teamFeatures.findUnique({
20+
/**
21+
* findUnique was failing in prismock tests, so I'm using findFirst instead
22+
* FIXME refactor when upgrading prismock
23+
* https://github.com/morintd/prismock/issues/592
24+
*/
25+
const userHasFeature = await db.userFeatures.findFirst({
2126
where: {
22-
teamId_featureId: { teamId, featureId: slug },
27+
userId,
28+
featureId: slug,
2329
},
2430
});
25-
return !!teamFeature;
31+
if (userHasFeature) return true;
32+
// If the user doesn't have the feature, check if they belong to a team with the feature.
33+
// This also covers organizations, which are teams.
34+
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeature(userId, slug);
35+
if (userBelongsToTeamWithFeature) return true;
36+
return false;
2637
} catch (err) {
2738
captureException(err);
2839
throw err;
2940
}
3041
}
31-
32-
async checkIfUserHasFeature(userId: number, slug: string) {
42+
private async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string) {
3343
try {
34-
const userFeature = await db.userFeatures.findUnique({
44+
const user = await db.user.findUnique({
3545
where: {
36-
userId_featureId: { userId, featureId: slug },
46+
id: userId,
47+
teams: {
48+
some: {
49+
team: {
50+
features: {
51+
some: {
52+
featureId: slug,
53+
},
54+
},
55+
},
56+
},
57+
},
3758
},
59+
select: { id: true },
3860
});
39-
return !!userFeature;
40-
} catch (err) {
41-
captureException(err);
42-
throw err;
43-
}
44-
}
45-
46-
async checkIfTeamOrUserHasFeature(
47-
args: {
48-
teamId?: number;
49-
userId?: number;
50-
},
51-
slug: string
52-
) {
53-
const { teamId, userId } = args;
54-
try {
55-
if (teamId) {
56-
const teamHasFeature = await this.checkIfTeamHasFeature(teamId, slug);
57-
if (teamHasFeature) return true;
58-
}
59-
if (userId) {
60-
const userHasFeature = await this.checkIfUserHasFeature(userId, slug);
61-
if (userHasFeature) return true;
62-
}
61+
if (user) return true;
6362
return false;
6463
} catch (err) {
6564
captureException(err);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import prismock from "../../../../tests/libs/__mocks__/prisma";
2+
3+
import { expect, it } from "vitest";
4+
5+
import { checkIfUserHasFeatureController } from "./check-if-user-has-feature.controller";
6+
7+
/**
8+
* Since our current controller doesn't run any authentication checks or input validation,
9+
* this test is identical to the test in the use case.
10+
*/
11+
it("checks if user has access to feature", async () => {
12+
const userId = 1;
13+
await prismock.userFeatures.create({
14+
data: {
15+
userId,
16+
featureId: "mock-feature",
17+
assignedBy: "1",
18+
updatedAt: new Date(),
19+
},
20+
});
21+
await expect(checkIfUserHasFeatureController(userId, "nonexistent-feature")).resolves.toBe(false);
22+
await expect(checkIfUserHasFeatureController(userId, "mock-feature")).resolves.toBe(true);
23+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { startSpan } from "@sentry/nextjs";
2+
3+
import { checkIfUserHasFeatureUseCase } from "./check-if-user-has-feature.use-case";
4+
5+
/**
6+
* Controllers use Presenters to convert the data to a UI-friendly format just before
7+
* returning it to the "consumer". This helps us ship less JavaScript to the client (logic
8+
* and libraries to convert the data), helps prevent leaking any sensitive properties, like
9+
* emails or hashed passwords, and also helps us slim down the amount of data we're sending
10+
* back to the client.
11+
*/
12+
function presenter(userHasFeature: boolean) {
13+
return startSpan({ name: "checkIfUserHasFeature Presenter", op: "serialize" }, () => {
14+
return userHasFeature;
15+
});
16+
}
17+
18+
/**
19+
* Controllers perform authentication checks and input validation before passing the input
20+
* to the specific use cases. Controllers orchestrate Use Cases. They don't implement any
21+
* logic, but define the whole operations using use cases.
22+
*/
23+
export async function checkIfUserHasFeatureController(
24+
userId: number | undefined,
25+
slug: string
26+
): Promise<ReturnType<typeof presenter>> {
27+
return await startSpan({ name: "checkIfUserHasFeature Controller" }, async () => {
28+
if (!userId) throw new Error("Missing userId in checkIfUserHasFeatureController");
29+
const userHasFeature = await checkIfUserHasFeatureUseCase(userId, slug);
30+
return presenter(userHasFeature);
31+
});
32+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** We export the controller since it's the main entry point for this operation in the app */
2+
export { checkIfUserHasFeatureController } from "./check-if-user-has-feature.controller";
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import prismock from "../../../../tests/libs/__mocks__/prisma";
2+
3+
import { expect, it } from "vitest";
4+
5+
import { checkIfUserHasFeatureUseCase } from "./check-if-user-has-feature.use-case";
6+
7+
// This is identical to the test in the controller since the controller currently
8+
// doesn't run any authentication checks or input validation.
9+
it("returns if user has access to feature", async () => {
10+
const userId = 1;
11+
await prismock.userFeatures.create({
12+
data: {
13+
userId,
14+
featureId: "mock-feature",
15+
assignedBy: "1",
16+
updatedAt: new Date(),
17+
},
18+
});
19+
await expect(checkIfUserHasFeatureUseCase(userId, "nonexistent-feature")).resolves.toBe(false);
20+
await expect(checkIfUserHasFeatureUseCase(userId, "mock-feature")).resolves.toBe(true);
21+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { startSpan } from "@sentry/nextjs";
2+
3+
import { FeaturesRepository } from "../features.repository";
4+
5+
/**
6+
* Use Cases represent individual operations, like "Create Feature" or "Sign In" or "Toggle Feature".
7+
* Accept pre-validated input (from controllers) and handle authorization checks.
8+
* Use Repositories and Services to access data sources and communicate with external systems.
9+
* Use cases should not use other use cases. That's a code smell. It means the use case
10+
* does multiple things and should be broken down into multiple use cases.
11+
*/
12+
export function checkIfUserHasFeatureUseCase(userId: number, slug: string): Promise<boolean> {
13+
return startSpan({ name: "checkIfUserHasFeature UseCase", op: "function" }, async () => {
14+
const featuresRepository = new FeaturesRepository();
15+
16+
return await featuresRepository.checkIfUserHasFeature(userId, slug);
17+
});
18+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- CreateIndex
2+
CREATE INDEX "TeamFeatures_teamId_featureId_idx" ON "TeamFeatures"("teamId", "featureId");
3+
4+
-- CreateIndex
5+
CREATE INDEX "UserFeatures_userId_featureId_idx" ON "UserFeatures"("userId", "featureId");

0 commit comments

Comments
 (0)