diff --git a/apps/web/__tests__/unit/comment-disabled-settings.test.ts b/apps/web/__tests__/unit/comment-disabled-settings.test.ts new file mode 100644 index 00000000000..22e92c1570e --- /dev/null +++ b/apps/web/__tests__/unit/comment-disabled-settings.test.ts @@ -0,0 +1,203 @@ +import * as Db from "@cap/database/schema"; +import type { MySqlTable } from "drizzle-orm/mysql-core"; +import { Effect } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record; + +const videoId = "accessible-video"; +const organizationId = "organization"; + +const mocks = vi.hoisted(() => ({ + notify: vi.fn(async () => undefined), + state: { + videoSettings: null as Row | null, + organizationSettings: null as Row | null, + spaces: [] as Row[], + inserted: [] as Row[], + }, +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: (table: MySqlTable) => { + const rows = () => + table === Db.spaceVideos + ? mocks.state.spaces + : [ + { + id: videoId, + videoSettings: mocks.state.videoSettings, + organizationSettings: mocks.state.organizationSettings, + }, + ]; + const query = { + leftJoin: () => query, + innerJoin: () => query, + where: () => + Object.assign(Promise.resolve(rows()), { + limit: async () => rows(), + }), + }; + return query; + }, + }), + insert: () => ({ + values: async (row: Row) => { + mocks.state.inserted.push(row); + }, + }), + }), +})); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ id: "viewer", name: "Viewer" }), +})); +vi.mock("@cap/web-domain", () => ({ + Comment: { CommentId: { make: (id: string) => id } }, + Policy: { withPublicPolicy: () => (effect: unknown) => effect }, +})); +vi.mock("@cap/web-backend", async () => { + const { resolveEffectiveVideoRules } = await import( + "../../../../packages/web-backend/src/Videos/EffectiveVideoRules" + ); + return { + provideOptionalAuth: (effect: unknown) => effect, + resolveEffectiveVideoRules, + VideosPolicy: Effect.succeed({ canView: () => Effect.void }), + }; +}); +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/comment-reply-parent", () => ({ + isReplyParentOnVideo: async () => true, +})); +vi.mock("@/lib/org-pro", () => ({ isVideoOwnerPro: async () => true })); +vi.mock("@/lib/Notification", () => ({ createNotification: mocks.notify })); +vi.mock("@/lib/server", () => ({ runPromiseExit: Effect.runPromiseExit })); + +import { newComment } from "@/actions/videos/new-comment"; +import { canUseMediaComments } from "@/lib/media-comments"; + +function post(type: "text" | "emoji", parentCommentId = "") { + return newComment({ + content: type === "emoji" ? "👍" : "A comment", + videoId: videoId as never, + type, + authorImage: null, + parentCommentId: parentCommentId as never, + timestamp: null, + }); +} + +const canRecord = () => + canUseMediaComments( + { id: "viewer" } as never, + { + id: videoId, + ownerId: "owner", + } as never, + ); + +beforeEach(() => { + mocks.state.videoSettings = null; + mocks.state.organizationSettings = null; + mocks.state.spaces = []; + mocks.state.inserted = []; + mocks.notify.mockClear(); +}); + +describe("comment and reaction settings", () => { + it("posts when nothing is disabled", async () => { + await post("text"); + await post("emoji"); + expect(mocks.state.inserted).toHaveLength(2); + }); + + it("rejects comments and replies when the video disables comments", async () => { + mocks.state.videoSettings = { disableComments: true }; + + await expect(post("text")).rejects.toThrow("disabled"); + await expect(post("text", "parent")).rejects.toThrow("disabled"); + await expect(post("emoji", "parent")).rejects.toThrow("disabled"); + + expect(mocks.state.inserted).toEqual([]); + expect(mocks.notify).not.toHaveBeenCalled(); + }); + + it("rejects reactions when the video disables reactions", async () => { + mocks.state.videoSettings = { disableReactions: true }; + + await expect(post("emoji")).rejects.toThrow("disabled"); + + expect(mocks.state.inserted).toEqual([]); + }); + + it("keeps comments and reactions independent", async () => { + mocks.state.videoSettings = { disableComments: true }; + await post("emoji"); + + mocks.state.videoSettings = { disableReactions: true }; + await post("text"); + + expect(mocks.state.inserted.map((row) => row.type)).toEqual([ + "emoji", + "text", + ]); + }); + + it("inherits the organization setting unless the video overrides it", async () => { + mocks.state.organizationSettings = { disableComments: true }; + await expect(post("text")).rejects.toThrow("disabled"); + + mocks.state.videoSettings = { disableComments: false }; + await post("text"); + + expect(mocks.state.inserted).toHaveLength(1); + }); + + it("applies a space's setting over the video's", async () => { + mocks.state.videoSettings = { disableComments: false }; + mocks.state.spaces = [ + { + id: "space", + name: "Space", + organizationId, + settings: { disableComments: true }, + }, + ]; + + await expect(post("text")).rejects.toThrow("disabled"); + + expect(mocks.state.inserted).toEqual([]); + }); + + it("ignores the organization's own all-members space like the share page", async () => { + mocks.state.spaces = [ + { + id: organizationId, + name: "Everyone", + organizationId, + settings: { disableComments: true }, + }, + ]; + + await post("text"); + + expect(mocks.state.inserted).toHaveLength(1); + }); + + it("stops media comments when comments are disabled", async () => { + expect(await canRecord()).toBe(true); + + mocks.state.videoSettings = { disableComments: true }; + + expect(await canRecord()).toBe(false); + }); + + it("keeps media comments available when only reactions are disabled", async () => { + mocks.state.videoSettings = { disableReactions: true }; + + expect(await canRecord()).toBe(true); + }); +}); diff --git a/apps/web/__tests__/unit/comment-reply-parent.test.ts b/apps/web/__tests__/unit/comment-reply-parent.test.ts index 793464700ab..e05ac14db11 100644 --- a/apps/web/__tests__/unit/comment-reply-parent.test.ts +++ b/apps/web/__tests__/unit/comment-reply-parent.test.ts @@ -94,6 +94,9 @@ vi.mock("@/lib/media-comments", () => ({ })); vi.mock("@/lib/Notification", () => ({ createNotification: mocks.notify })); vi.mock("@/lib/server", () => ({ runPromiseExit: Effect.runPromiseExit })); +vi.mock("@/lib/video-feedback-rules", () => ({ + isVideoFeedbackDisabled: async () => false, +})); import { newComment } from "@/actions/videos/new-comment"; import { newMediaComment } from "@/actions/videos/new-media-comment"; diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 616ac95a87a..b0d5cf802a5 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -13,6 +13,7 @@ import { revalidatePath } from "next/cache"; import { isReplyParentOnVideo } from "@/lib/comment-reply-parent"; import { createNotification } from "@/lib/Notification"; import * as EffectRuntime from "@/lib/server"; +import { isVideoFeedbackDisabled } from "@/lib/video-feedback-rules"; export async function newComment(data: { content: string; @@ -69,6 +70,14 @@ export async function newComment(data: { throw new Error("Video not found"); } + if ( + await isVideoFeedbackDisabled( + videoId, + conditionalType === "reaction" ? "disableReactions" : "disableComments", + ) + ) + throw new Error("Feedback is disabled on this video"); + if ( parentCommentId && !(await isReplyParentOnVideo(parentCommentId, videoId)) diff --git a/apps/web/lib/media-comments.ts b/apps/web/lib/media-comments.ts index ac4055b73e3..81bce73a742 100644 --- a/apps/web/lib/media-comments.ts +++ b/apps/web/lib/media-comments.ts @@ -4,17 +4,19 @@ import type { users } from "@cap/database/schema"; import type { User, Video } from "@cap/web-domain"; import type { InferSelectModel } from "drizzle-orm"; import { isVideoOwnerPro } from "@/lib/org-pro"; +import { isVideoFeedbackDisabled } from "@/lib/video-feedback-rules"; /** * Recording media comments (video/voice replies on the share-page timeline) is * part of the VIDEO OWNER's Pro plan: any signed-in viewer can record on a Pro - * owner's video. Viewing/playing media comments is never gated here — that - * follows the video's normal view policy. + * owner's video, unless comments are disabled on it. Viewing/playing media + * comments is never gated here — that follows the video's normal view policy. */ export async function canUseMediaComments( user: Pick, "id"> | null, video: { ownerId: User.UserId; id: Video.VideoId }, ): Promise { if (!user) return false; + if (await isVideoFeedbackDisabled(video.id, "disableComments")) return false; return isVideoOwnerPro(video.ownerId); } diff --git a/apps/web/lib/video-feedback-rules.ts b/apps/web/lib/video-feedback-rules.ts new file mode 100644 index 00000000000..d64801ae248 --- /dev/null +++ b/apps/web/lib/video-feedback-rules.ts @@ -0,0 +1,43 @@ +import { db } from "@cap/database"; +import { + organizations, + spaces, + spaceVideos, + videos, +} from "@cap/database/schema"; +import { resolveEffectiveVideoRules } from "@cap/web-backend"; +import type { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; + +export async function isVideoFeedbackDisabled( + videoId: Video.VideoId, + setting: "disableComments" | "disableReactions", +) { + const [[video], sharedSpaces] = await Promise.all([ + db() + .select({ + videoSettings: videos.settings, + organizationSettings: organizations.settings, + }) + .from(videos) + .leftJoin(organizations, eq(organizations.id, videos.orgId)) + .where(eq(videos.id, videoId)) + .limit(1), + db() + .select({ + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + settings: spaces.settings, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .where(eq(spaceVideos.videoId, videoId)), + ]); + + return resolveEffectiveVideoRules({ + videoSettings: video?.videoSettings, + organizationSettings: video?.organizationSettings, + spaces: sharedSpaces.filter((space) => space.id !== space.organizationId), + }).settings[setting]; +}