-
-
- {breadcrumb.map((folder, index) => (
-
- ))}
+ const [childFolders, breadcrumb, videosData] = yield* Effect.all([
+ getChildFolders(
+ params.folderId,
+ spaceOrOrg.variant === "space"
+ ? { variant: "space", spaceId: spaceOrOrg.space.id }
+ : { variant: "org", organizationId: spaceOrOrg.organization.id },
+ ),
+ getFolderBreadcrumb(params.folderId),
+ getVideosByFolderId(params.folderId),
+ ]);
+
+ return (
+
- {/* Display Child Folders */}
- {childFolders.length > 0 && (
- <>
-
Subfolders
-
- {childFolders.map((folder) => (
-
+
+
+
+ {breadcrumb.map((folder, index) => (
+
))}
- >
- )}
- {/* Display Videos */}
-
-
+
+ {/* Display Child Folders */}
+ {childFolders.length > 0 && (
+ <>
+
+ Subfolders
+
+
+ {childFolders.map((folder) => (
+
+ ))}
+
+ >
+ )}
+ {/* Display Videos */}
+
+
+ );
+ }).pipe(
+ Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())),
+ Effect.provideService(CurrentUser, user),
+ runPromise,
);
};
diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx
index aa94d6dbe1..507ba02580 100644
--- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx
+++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx
@@ -4,39 +4,27 @@ import {
comments,
folders,
organizationMembers,
- organizations,
sharedVideos,
spaceMembers,
- spaces,
spaceVideos,
users,
videos,
videoUploads,
} from "@cap/database/schema";
import { serverEnv } from "@cap/env";
-import { Video } from "@cap/web-domain";
+import { CurrentUser, Video } from "@cap/web-domain";
import { and, count, desc, eq, isNull, sql } from "drizzle-orm";
+import { Effect } from "effect";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
+import { runPromise } from "@/lib/server";
import { SharedCaps } from "./SharedCaps";
+import { getSpaceOrOrg } from "./utils";
export const metadata: Metadata = {
title: "Shared Caps — Cap",
};
-type SpaceData = {
- id: string;
- name: string;
- organizationId: string;
- createdById: string;
-};
-
-type OrganizationData = {
- id: string;
- name: string;
- ownerId: string;
-};
-
export type SpaceMemberData = {
id: string;
userId: string;
@@ -47,31 +35,6 @@ export type SpaceMemberData = {
};
// --- Helper functions ---
-async function fetchSpaceData(id: string) {
- return db()
- .select({
- id: spaces.id,
- name: spaces.name,
- organizationId: spaces.organizationId,
- createdById: spaces.createdById,
- })
- .from(spaces)
- .where(eq(spaces.id, id))
- .limit(1);
-}
-
-async function fetchOrganizationData(id: string) {
- return db()
- .select({
- id: organizations.id,
- name: organizations.name,
- ownerId: organizations.ownerId,
- })
- .from(organizations)
- .where(eq(organizations.id, id))
- .limit(1);
-}
-
async function fetchFolders(spaceId: string) {
return db()
.select({
@@ -127,60 +90,25 @@ export default async function SharedCapsPage(props: {
const page = Number(searchParams.page) || 1;
const limit = Number(searchParams.limit) || 15;
const user = await getCurrentUser();
- const userId = user?.id as string;
- // this is just how it work atm
- const spaceOrOrgId = params.spaceId;
-
- // Parallelize fetching space and org data
- const [spaceData, organizationData] = await Promise.all([
- fetchSpaceData(spaceOrOrgId),
- fetchOrganizationData(spaceOrOrgId),
- ]);
+ if (!user) notFound();
- // organizationData assignment handled above
- if (spaceData.length === 0 && organizationData.length === 0) {
- notFound();
- }
+ const spaceOrOrg = await getSpaceOrOrg(params.spaceId).pipe(
+ Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())),
+ Effect.provideService(CurrentUser, user),
+ runPromise,
+ );
- const isSpace = spaceData.length > 0;
+ if (!spaceOrOrg) notFound();
- if (isSpace) {
- const space = spaceData[0] as SpaceData;
- const isSpaceCreator = space.createdById === userId;
- let hasAccess = isSpaceCreator;
- if (!isSpaceCreator) {
- const [spaceMembership, orgMembership] = await Promise.all([
- db()
- .select({ id: spaceMembers.id })
- .from(spaceMembers)
- .where(
- and(
- eq(spaceMembers.userId, userId),
- eq(spaceMembers.spaceId, spaceOrOrgId),
- ),
- )
- .limit(1),
- db()
- .select({ id: organizationMembers.id })
- .from(organizationMembers)
- .where(
- and(
- eq(organizationMembers.userId, userId),
- eq(organizationMembers.organizationId, space.organizationId),
- ),
- )
- .limit(1),
- ]);
- hasAccess = spaceMembership.length > 0 || orgMembership.length > 0;
- }
- if (!hasAccess) notFound();
+ if (spaceOrOrg.variant === "space") {
+ const { space } = spaceOrOrg;
// Fetch members in parallel
const [spaceMembersData, organizationMembersData, foldersData] =
await Promise.all([
- fetchSpaceMembers(spaceOrOrgId),
+ fetchSpaceMembers(space.id),
fetchOrganizationMembers(space.organizationId),
- fetchFolders(spaceOrOrgId),
+ fetchFolders(space.id),
]);
async function fetchSpaceVideos(
@@ -244,7 +172,7 @@ export default async function SharedCapsPage(props: {
// Fetch videos and count in parallel
const { videos: spaceVideoData, totalCount } = await fetchSpaceVideos(
- spaceOrOrgId,
+ space.id,
page,
limit,
);
@@ -268,30 +196,14 @@ export default async function SharedCapsPage(props: {
dubApiKeyEnabled={!!serverEnv().DUB_API_KEY}
spaceMembers={spaceMembersData}
organizationMembers={organizationMembersData}
- currentUserId={userId}
+ currentUserId={user.id}
folders={foldersData}
/>
);
- } else {
- const organization = organizationData[0] as OrganizationData;
- const isOrgOwner = organization.ownerId === userId;
-
- if (!isOrgOwner) {
- const orgMembership = await db()
- .select({ id: organizationMembers.id })
- .from(organizationMembers)
- .where(
- and(
- eq(organizationMembers.userId, userId),
- eq(organizationMembers.organizationId, spaceOrOrgId),
- ),
- )
- .limit(1);
+ }
- if (orgMembership.length === 0) {
- notFound();
- }
- }
+ if (spaceOrOrg.variant === "organization") {
+ const { organization } = spaceOrOrg;
async function fetchOrganizationVideos(
orgId: string,
@@ -364,9 +276,9 @@ export default async function SharedCapsPage(props: {
const [organizationVideos, organizationMembersData, foldersData] =
await Promise.all([
- fetchOrganizationVideos(spaceOrOrgId, page, limit),
- fetchOrganizationMembers(spaceOrOrgId),
- fetchFolders(spaceOrOrgId),
+ fetchOrganizationVideos(organization.id, page, limit),
+ fetchOrganizationMembers(organization.id),
+ fetchFolders(organization.id),
]);
const { videos: orgVideoData, totalCount } = organizationVideos;
@@ -390,7 +302,7 @@ export default async function SharedCapsPage(props: {
organizationData={organization}
dubApiKeyEnabled={!!serverEnv().DUB_API_KEY}
organizationMembers={organizationMembersData}
- currentUserId={userId}
+ currentUserId={user.id}
folders={foldersData}
/>
);
diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts
new file mode 100644
index 0000000000..df6205f9af
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts
@@ -0,0 +1,48 @@
+import { organizations, spaces } from "@cap/database/schema";
+import { Database, OrganisationsPolicy, SpacesPolicy } from "@cap/web-backend";
+import { Policy } from "@cap/web-domain";
+import { eq } from "drizzle-orm";
+import { Effect } from "effect";
+
+export const getSpaceOrOrg = Effect.fn(function* (spaceOrOrgId: string) {
+ const db = yield* Database;
+ const spacesPolicy = yield* SpacesPolicy;
+ const orgsPolicy = yield* OrganisationsPolicy;
+
+ const [[space], [organization]] = yield* Effect.all([
+ db.execute((db) =>
+ db
+ .select({
+ id: spaces.id,
+ name: spaces.name,
+ organizationId: spaces.organizationId,
+ createdById: spaces.createdById,
+ })
+ .from(spaces)
+ .where(eq(spaces.id, spaceOrOrgId))
+ .limit(1),
+ ),
+ db.execute((db) =>
+ db
+ .select({
+ id: organizations.id,
+ name: organizations.name,
+ ownerId: organizations.ownerId,
+ })
+ .from(organizations)
+ .where(eq(organizations.id, spaceOrOrgId))
+ .limit(1),
+ ),
+ ]);
+
+ if (space)
+ return yield* Effect.succeed({ variant: "space" as const, space }).pipe(
+ Policy.withPolicy(spacesPolicy.isMember(space.id)),
+ );
+
+ if (organization)
+ return yield* Effect.succeed({
+ variant: "organization" as const,
+ organization,
+ }).pipe(Policy.withPolicy(orgsPolicy.isMember(organization.id)));
+});
diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts
index c2eb8d01c8..ef6d7c260e 100644
--- a/apps/web/lib/folder.ts
+++ b/apps/web/lib/folder.ts
@@ -13,26 +13,33 @@ import {
videos,
videoUploads,
} from "@cap/database/schema";
+import { Database } from "@cap/web-backend";
import type { Video } from "@cap/web-domain";
-import { Folder } from "@cap/web-domain";
+import { CurrentUser, Folder } from "@cap/web-domain";
import { and, desc, eq } from "drizzle-orm";
import { sql } from "drizzle-orm/sql";
+import { Effect } from "effect";
import { revalidatePath } from "next/cache";
-export async function getFolderById(folderId: string | undefined) {
+export const getFolderById = Effect.fn(function* (folderId: string) {
if (!folderId) throw new Error("Folder ID is required");
+ const db = yield* Database;
- const [folder] = await db()
- .select()
- .from(folders)
- .where(eq(folders.id, Folder.FolderId.make(folderId)));
+ const [folder] = yield* db.execute((db) =>
+ db
+ .select()
+ .from(folders)
+ .where(eq(folders.id, Folder.FolderId.make(folderId))),
+ );
if (!folder) throw new Error("Folder not found");
return folder;
-}
+});
-export async function getFolderBreadcrumb(folderId: Folder.FolderId) {
+export const getFolderBreadcrumb = Effect.fn(function* (
+ folderId: Folder.FolderId,
+) {
const breadcrumb: Array<{
id: Folder.FolderId;
name: string;
@@ -41,7 +48,7 @@ export async function getFolderBreadcrumb(folderId: Folder.FolderId) {
let currentFolderId = folderId;
while (currentFolderId) {
- const folder = await getFolderById(currentFolderId);
+ const folder = yield* getFolderById(currentFolderId);
if (!folder) break;
breadcrumb.unshift({
@@ -55,48 +62,58 @@ export async function getFolderBreadcrumb(folderId: Folder.FolderId) {
}
return breadcrumb;
-}
+});
// Helper function to fetch shared spaces data for videos
-async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) {
+const getSharedSpacesForVideos = Effect.fn(function* (
+ videoIds: Video.VideoId[],
+) {
if (videoIds.length === 0) return {};
+ const db = yield* Database;
// Fetch space-level sharing
- const spaceSharing = await db()
- .select({
- videoId: spaceVideos.videoId,
- id: spaces.id,
- name: spaces.name,
- organizationId: spaces.organizationId,
- iconUrl: organizations.iconUrl,
- })
- .from(spaceVideos)
- .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id))
- .innerJoin(organizations, eq(spaces.organizationId, organizations.id))
- .where(
- sql`${spaceVideos.videoId} IN (${sql.join(
- videoIds.map((id) => sql`${id}`),
- sql`, `,
- )})`,
- );
+ const spaceSharing = yield* db.execute((db) =>
+ db
+ .select({
+ videoId: spaceVideos.videoId,
+ id: spaces.id,
+ name: spaces.name,
+ organizationId: spaces.organizationId,
+ iconUrl: organizations.iconUrl,
+ })
+ .from(spaceVideos)
+ .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id))
+ .innerJoin(organizations, eq(spaces.organizationId, organizations.id))
+ .where(
+ sql`${spaceVideos.videoId} IN (${sql.join(
+ videoIds.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ ),
+ );
// Fetch organization-level sharing
- const orgSharing = await db()
- .select({
- videoId: sharedVideos.videoId,
- id: organizations.id,
- name: organizations.name,
- organizationId: organizations.id,
- iconUrl: organizations.iconUrl,
- })
- .from(sharedVideos)
- .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id))
- .where(
- sql`${sharedVideos.videoId} IN (${sql.join(
- videoIds.map((id) => sql`${id}`),
- sql`, `,
- )})`,
- );
+ const orgSharing = yield* db.execute((db) =>
+ db
+ .select({
+ videoId: sharedVideos.videoId,
+ id: organizations.id,
+ name: organizations.name,
+ organizationId: organizations.id,
+ iconUrl: organizations.iconUrl,
+ })
+ .from(sharedVideos)
+ .innerJoin(
+ organizations,
+ eq(sharedVideos.organizationId, organizations.id),
+ )
+ .where(
+ sql`${sharedVideos.videoId} IN (${sql.join(
+ videoIds.map((id) => sql`${id}`),
+ sql`, `,
+ )})`,
+ ),
+ );
// Combine and group by videoId
const sharedSpacesMap: Record<
@@ -138,23 +155,29 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) {
});
return sharedSpacesMap;
-}
+});
-export async function getVideosByFolderId(folderId: Folder.FolderId) {
+export const getVideosByFolderId = Effect.fn(function* (
+ folderId: Folder.FolderId,
+) {
if (!folderId) throw new Error("Folder ID is required");
+ const db = yield* Database;
- const videoData = await db()
- .select({
- id: videos.id,
- ownerId: videos.ownerId,
- name: videos.name,
- createdAt: videos.createdAt,
- public: videos.public,
- metadata: videos.metadata,
- duration: videos.duration,
- totalComments: sql
`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`,
- totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`,
- sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>`
+ const videoData = yield* db.execute((db) =>
+ db
+ .select({
+ id: videos.id,
+ ownerId: videos.ownerId,
+ name: videos.name,
+ createdAt: videos.createdAt,
+ public: videos.public,
+ metadata: videos.metadata,
+ duration: videos.duration,
+ totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`,
+ totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`,
+ sharedOrganizations: sql<
+ { id: string; name: string; iconUrl: string }[]
+ >`
COALESCE(
JSON_ARRAYAGG(
JSON_OBJECT(
@@ -167,44 +190,48 @@ export async function getVideosByFolderId(folderId: Folder.FolderId) {
)
`,
- ownerName: users.name,
- effectiveDate: sql`
+ ownerName: users.name,
+ effectiveDate: sql`
COALESCE(
JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')),
${videos.createdAt}
)
`,
- hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean),
- hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith(
- Boolean,
- ),
- })
- .from(videos)
- .leftJoin(comments, eq(videos.id, comments.videoId))
- .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId))
- .leftJoin(organizations, eq(sharedVideos.organizationId, organizations.id))
- .leftJoin(users, eq(videos.ownerId, users.id))
- .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId))
- .where(eq(videos.folderId, folderId))
- .groupBy(
- videos.id,
- videos.ownerId,
- videos.name,
- videos.createdAt,
- videos.public,
- videos.metadata,
- users.name,
- )
- .orderBy(
- desc(sql`COALESCE(
+ hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean),
+ hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith(
+ Boolean,
+ ),
+ })
+ .from(videos)
+ .leftJoin(comments, eq(videos.id, comments.videoId))
+ .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId))
+ .leftJoin(
+ organizations,
+ eq(sharedVideos.organizationId, organizations.id),
+ )
+ .leftJoin(users, eq(videos.ownerId, users.id))
+ .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId))
+ .where(eq(videos.folderId, folderId))
+ .groupBy(
+ videos.id,
+ videos.ownerId,
+ videos.name,
+ videos.createdAt,
+ videos.public,
+ videos.metadata,
+ users.name,
+ )
+ .orderBy(
+ desc(sql`COALESCE(
JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')),
${videos.createdAt}
)`),
- );
+ ),
+ );
// Fetch shared spaces data for all videos
const videoIds = videoData.map((video) => video.id);
- const sharedSpacesMap = await getSharedSpacesForVideos(videoIds);
+ const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds);
// Process the video data to match the expected format
const processedVideoData = videoData.map((video) => {
@@ -238,31 +265,42 @@ export async function getVideosByFolderId(folderId: Folder.FolderId) {
});
return processedVideoData;
-}
+});
+
+export const getChildFolders = Effect.fn(function* (
+ folderId: Folder.FolderId,
+ root:
+ | { variant: "user" }
+ | { variant: "space"; spaceId: string }
+ | { variant: "org"; organizationId: string },
+) {
+ const db = yield* Database;
-export async function getChildFolders(folderId: Folder.FolderId) {
- const user = await getCurrentUser();
- if (!user || !user.activeOrganizationId)
- throw new Error("Unauthorized or no active organization");
+ const user = yield* CurrentUser;
+ if (!user.activeOrganizationId) throw new Error("No active organization");
- const childFolders = await db()
- .select({
- id: folders.id,
- name: folders.name,
- color: folders.color,
- parentId: folders.parentId,
- organizationId: folders.organizationId,
- videoCount: sql`(
- SELECT COUNT(*) FROM videos WHERE videos.folderId = folders.id
- )`,
- })
- .from(folders)
- .where(
- and(
- eq(folders.parentId, folderId),
- eq(folders.organizationId, user.activeOrganizationId),
+ const childFolders = yield* db.execute((db) =>
+ db
+ .select({
+ id: folders.id,
+ name: folders.name,
+ color: folders.color,
+ parentId: folders.parentId,
+ organizationId: folders.organizationId,
+ videoCount: sql`(
+ SELECT COUNT(*) FROM videos WHERE videos.folderId = folders.id
+ )`,
+ })
+ .from(folders)
+ .where(
+ and(
+ eq(folders.parentId, folderId),
+ root.variant === "space"
+ ? eq(folders.spaceId, root.spaceId)
+ : undefined,
+ ),
),
- );
+ );
return childFolders;
-}
+});
diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts
index a6a3b9a856..cee4927f44 100644
--- a/apps/web/lib/server.ts
+++ b/apps/web/lib/server.ts
@@ -5,7 +5,9 @@ import {
Database,
Folders,
HttpAuthMiddlewareLive,
+ OrganisationsPolicy,
S3Buckets,
+ SpacesPolicy,
Videos,
VideosPolicy,
} from "@cap/web-backend";
@@ -43,6 +45,8 @@ export const Dependencies = Layer.mergeAll(
Videos.Default,
VideosPolicy.Default,
Folders.Default,
+ SpacesPolicy.Default,
+ OrganisationsPolicy.Default,
).pipe(
Layer.provideMerge(
Layer.mergeAll(Database.Default, TracingLayer, FetchHttpClient.layer),
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index ccf63a2cce..c4e4e6d87d 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -168,13 +168,16 @@ export const organizations = mysqlTable(
}),
);
+export type OrganisationMemberRole = "owner" | "member";
export const organizationMembers = mysqlTable(
"organization_members",
{
id: nanoId("id").notNull().primaryKey().unique(),
userId: nanoId("userId").notNull(),
organizationId: nanoId("organizationId").notNull(),
- role: varchar("role", { length: 255 }).notNull(),
+ role: varchar("role", { length: 255 })
+ .notNull()
+ .$type(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(),
},
@@ -195,7 +198,9 @@ export const organizationInvites = mysqlTable(
organizationId: nanoId("organizationId").notNull(),
invitedEmail: varchar("invitedEmail", { length: 255 }).notNull(),
invitedByUserId: nanoId("invitedByUserId").notNull(),
- role: varchar("role", { length: 255 }).notNull(),
+ role: varchar("role", { length: 255 })
+ .notNull()
+ .$type(),
status: varchar("status", { length: 255 }).notNull().default("pending"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(),
@@ -552,7 +557,10 @@ export const spaceMembers = mysqlTable(
id: nanoId("id").notNull().primaryKey().unique(),
spaceId: nanoId("spaceId").notNull(),
userId: nanoId("userId").notNull(),
- role: varchar("role", { length: 255 }).notNull().default("member"),
+ role: varchar("role", { length: 255 })
+ .notNull()
+ .default("member")
+ .$type<"member" | "Admin">(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(),
},
diff --git a/packages/web-backend/src/Auth.ts b/packages/web-backend/src/Auth.ts
index 46e0b39bac..e2b008b861 100644
--- a/packages/web-backend/src/Auth.ts
+++ b/packages/web-backend/src/Auth.ts
@@ -1,6 +1,6 @@
import { getServerSession } from "@cap/database/auth/auth-options";
import * as Db from "@cap/database/schema";
-import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain";
+import { CurrentUser, HttpAuthMiddleware, Policy } from "@cap/web-domain";
import { HttpApiError, HttpServerRequest } from "@effect/platform";
import * as Dz from "drizzle-orm";
import { type Cause, Effect, Layer, Option, Schema } from "effect";
@@ -65,7 +65,7 @@ export const HttpAuthMiddlewareLive = Layer.effect(
Option.map((user) => ({
id: user.id,
email: user.email,
- activeOrgId: user.activeOrganizationId,
+ activeOrganizationId: user.activeOrganizationId,
})),
Effect.catchTag(
"NoSuchElementException",
@@ -98,7 +98,7 @@ export const provideOptionalAuth = (
CurrentUser.context({
id: user.id,
email: user.email,
- activeOrgId: user.activeOrganizationId,
+ activeOrganizationId: user.activeOrganizationId,
}),
),
Option.match({
diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts
index bf4b2c10f9..d402ef2154 100644
--- a/packages/web-backend/src/Folders/index.ts
+++ b/packages/web-backend/src/Folders/index.ts
@@ -79,7 +79,7 @@ export class Folders extends Effect.Service()("Folders", {
.where(
Dz.and(
Dz.eq(Db.folders.id, parentId),
- Dz.eq(Db.folders.organizationId, user.activeOrgId),
+ Dz.eq(Db.folders.organizationId, user.activeOrganizationId),
),
),
);
@@ -91,7 +91,7 @@ export class Folders extends Effect.Service()("Folders", {
id: Folder.FolderId.make(nanoId()),
name: data.name,
color: data.color,
- organizationId: user.activeOrgId,
+ organizationId: user.activeOrganizationId,
createdById: user.id,
spaceId: data.spaceId,
parentId: data.parentId,
diff --git a/packages/web-backend/src/Organisations/OrganisationsPolicy.ts b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts
new file mode 100644
index 0000000000..4d1e414b8c
--- /dev/null
+++ b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts
@@ -0,0 +1,29 @@
+import { Policy } from "@cap/web-domain";
+import { Effect, Option } from "effect";
+
+import { Database } from "../Database.ts";
+import { OrganisationsRepo } from "../Organisations/OrganisationsRepo.ts";
+import { SpacesRepo } from "../Spaces/SpacesRepo.ts";
+
+export class OrganisationsPolicy extends Effect.Service()(
+ "OrganisationsPolicy",
+ {
+ effect: Effect.gen(function* () {
+ const repo = yield* OrganisationsRepo;
+
+ const isMember = (orgId: string) =>
+ Policy.policy(
+ Effect.fn(function* (user) {
+ return Option.isSome(yield* repo.membership(user.id, orgId));
+ }),
+ );
+
+ return { isMember };
+ }),
+ dependencies: [
+ OrganisationsRepo.Default,
+ SpacesRepo.Default,
+ Database.Default,
+ ],
+ },
+) {}
diff --git a/packages/web-backend/src/Organisations/OrganisationsRepo.ts b/packages/web-backend/src/Organisations/OrganisationsRepo.ts
index 73a8c02fe4..3626864ae6 100644
--- a/packages/web-backend/src/Organisations/OrganisationsRepo.ts
+++ b/packages/web-backend/src/Organisations/OrganisationsRepo.ts
@@ -1,7 +1,7 @@
import * as Db from "@cap/database/schema";
import type { Video } from "@cap/web-domain";
import * as Dz from "drizzle-orm";
-import { Effect } from "effect";
+import { Array, Effect } from "effect";
import { Database } from "../Database.ts";
@@ -31,6 +31,24 @@ export class OrganisationsRepo extends Effect.Service()(
),
),
),
+
+ membership: (userId: string, orgId: string) =>
+ db
+ .execute((db) =>
+ db
+ .select({
+ membershipId: Db.organizationMembers.id,
+ role: Db.organizationMembers.role,
+ })
+ .from(Db.organizationMembers)
+ .where(
+ Dz.and(
+ Dz.eq(Db.organizationMembers.userId, userId),
+ Dz.eq(Db.organizationMembers.organizationId, orgId),
+ ),
+ ),
+ )
+ .pipe(Effect.map(Array.get(0))),
};
}),
dependencies: [Database.Default],
diff --git a/packages/web-backend/src/Rpcs.ts b/packages/web-backend/src/Rpcs.ts
index 0cced04caf..5f45ea0c67 100644
--- a/packages/web-backend/src/Rpcs.ts
+++ b/packages/web-backend/src/Rpcs.ts
@@ -28,7 +28,7 @@ export const RpcAuthMiddlewareLive = Layer.effect(
Effect.succeed({
id: user.id,
email: user.email,
- activeOrgId: user.activeOrganizationId,
+ activeOrganizationId: user.activeOrganizationId,
}),
}),
),
diff --git a/packages/web-backend/src/Spaces/SpacesPolicy.ts b/packages/web-backend/src/Spaces/SpacesPolicy.ts
new file mode 100644
index 0000000000..910a106862
--- /dev/null
+++ b/packages/web-backend/src/Spaces/SpacesPolicy.ts
@@ -0,0 +1,29 @@
+import { Policy } from "@cap/web-domain";
+import { Effect, Option } from "effect";
+
+import { Database } from "../Database.ts";
+import { OrganisationsRepo } from "../Organisations/OrganisationsRepo.ts";
+import { SpacesRepo } from "../Spaces/SpacesRepo.ts";
+
+export class SpacesPolicy extends Effect.Service()(
+ "SpacesPolicy",
+ {
+ effect: Effect.gen(function* () {
+ const repo = yield* SpacesRepo;
+
+ const isMember = (spaceId: string) =>
+ Policy.policy(
+ Effect.fn(function* (user) {
+ return Option.isSome(yield* repo.membership(user.id, spaceId));
+ }),
+ );
+
+ return { isMember };
+ }),
+ dependencies: [
+ OrganisationsRepo.Default,
+ SpacesRepo.Default,
+ Database.Default,
+ ],
+ },
+) {}
diff --git a/packages/web-backend/src/Spaces/SpacesRepo.ts b/packages/web-backend/src/Spaces/SpacesRepo.ts
index bb77e42f5a..886ec1f3b8 100644
--- a/packages/web-backend/src/Spaces/SpacesRepo.ts
+++ b/packages/web-backend/src/Spaces/SpacesRepo.ts
@@ -1,7 +1,7 @@
import * as Db from "@cap/database/schema";
import type { Video } from "@cap/web-domain";
import * as Dz from "drizzle-orm";
-import { Effect } from "effect";
+import { Array, Effect } from "effect";
import { Database } from "../Database.ts";
@@ -11,21 +11,40 @@ export class SpacesRepo extends Effect.Service()("SpacesRepo", {
return {
membershipForVideo: (userId: string, videoId: Video.VideoId) =>
- db.execute((db) =>
- db
- .select({ membershipId: Db.spaceMembers.id })
- .from(Db.spaceMembers)
- .leftJoin(
- Db.spaceVideos,
- Dz.eq(Db.spaceMembers.spaceId, Db.spaceVideos.spaceId),
- )
- .where(
- Dz.and(
- Dz.eq(Db.spaceMembers.userId, userId),
- Dz.eq(Db.spaceVideos.videoId, videoId),
+ db
+ .execute((db) =>
+ db
+ .select({ membershipId: Db.spaceMembers.id })
+ .from(Db.spaceMembers)
+ .leftJoin(
+ Db.spaceVideos,
+ Dz.eq(Db.spaceMembers.spaceId, Db.spaceVideos.spaceId),
+ )
+ .where(
+ Dz.and(
+ Dz.eq(Db.spaceMembers.userId, userId),
+ Dz.eq(Db.spaceVideos.videoId, videoId),
+ ),
),
- ),
- ),
+ )
+ .pipe(Effect.map(Array.get(0))),
+ membership: (userId: string, spaceId: string) =>
+ db
+ .execute((db) =>
+ db
+ .select({
+ membershipId: Db.spaceMembers.id,
+ role: Db.spaceMembers.role,
+ })
+ .from(Db.spaceMembers)
+ .where(
+ Dz.and(
+ Dz.eq(Db.spaceMembers.userId, userId),
+ Dz.eq(Db.spaceMembers.spaceId, spaceId),
+ ),
+ ),
+ )
+ .pipe(Effect.map(Array.get(0))),
};
}),
dependencies: [Database.Default],
diff --git a/packages/web-backend/src/Spaces/index.ts b/packages/web-backend/src/Spaces/index.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/web-backend/src/Videos/VideosPolicy.ts b/packages/web-backend/src/Videos/VideosPolicy.ts
index 9e3aa6f6f4..17bf7886f0 100644
--- a/packages/web-backend/src/Videos/VideosPolicy.ts
+++ b/packages/web-backend/src/Videos/VideosPolicy.ts
@@ -36,9 +36,7 @@ export class VideosPolicy extends Effect.Service()(
orgsRepo
.membershipForVideo(userId, video.id)
.pipe(Effect.map(Array.get(0))),
- spacesRepo
- .membershipForVideo(userId, video.id)
- .pipe(Effect.map(Array.get(0))),
+ spacesRepo.membershipForVideo(userId, video.id),
]);
if (
diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts
index fc77017804..d06a7422b8 100644
--- a/packages/web-backend/src/Videos/index.ts
+++ b/packages/web-backend/src/Videos/index.ts
@@ -11,6 +11,7 @@ import { VideosRepo } from "./VideosRepo.ts";
export class Videos extends Effect.Service()("Videos", {
effect: Effect.gen(function* () {
+ const db = yield* Database;
const repo = yield* VideosRepo;
const policy = yield* VideosPolicy;
const s3Buckets = yield* S3Buckets;
@@ -107,8 +108,6 @@ export class Videos extends Effect.Service()("Videos", {
getUploadProgress: Effect.fn("Videos.getUploadProgress")(function* (
videoId: Video.VideoId,
) {
- const db = yield* Database;
-
const [result] = yield* db
.execute((db) =>
db
@@ -135,14 +134,17 @@ export class Videos extends Effect.Service()("Videos", {
getDownloadInfo: Effect.fn("Videos.getDownloadInfo")(function* (
videoId: Video.VideoId,
) {
- const [video] = yield* getById(videoId).pipe(
- Effect.flatMap(
- Effect.catchTag(
- "NoSuchElementException",
- () => new Video.NotFoundError(),
+ const [video] = yield* repo
+ .getById(videoId)
+ .pipe(
+ Effect.flatMap(
+ Effect.catchTag(
+ "NoSuchElementException",
+ () => new Video.NotFoundError(),
+ ),
),
- ),
- );
+ Policy.withPublicPolicy(policy.canView(videoId)),
+ );
const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts
index 4ad253f168..badfc188db 100644
--- a/packages/web-backend/src/index.ts
+++ b/packages/web-backend/src/index.ts
@@ -2,8 +2,10 @@ export * from "./Auth.ts";
export * from "./Database.ts";
export { Folders } from "./Folders/index.ts";
export * from "./Loom/index.ts";
+export { OrganisationsPolicy } from "./Organisations/OrganisationsPolicy.ts";
export * from "./Rpcs.ts";
export { S3Buckets } from "./S3Buckets/index.ts";
+export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts";
export { Videos } from "./Videos/index.ts";
export { VideosPolicy } from "./Videos/VideosPolicy.ts";
export * as Workflows from "./Workflows.ts";
diff --git a/packages/web-domain/src/Authentication.ts b/packages/web-domain/src/Authentication.ts
index b4ef466d3e..d69ed5d5c6 100644
--- a/packages/web-domain/src/Authentication.ts
+++ b/packages/web-domain/src/Authentication.ts
@@ -6,7 +6,7 @@ import { InternalError } from "./Errors.ts";
export class CurrentUser extends Context.Tag("CurrentUser")<
CurrentUser,
- { id: string; email: string; activeOrgId: string }
+ { id: string; email: string; activeOrganizationId: string }
>() {}
export class HttpAuthMiddleware extends HttpApiMiddleware.Tag()(
diff --git a/packages/web-domain/src/Policy.ts b/packages/web-domain/src/Policy.ts
index d159eafd14..cbdcf6cfe0 100644
--- a/packages/web-domain/src/Policy.ts
+++ b/packages/web-domain/src/Policy.ts
@@ -1,19 +1,17 @@
// shoutout https://lucas-barake.github.io/building-a-composable-policy-system/
-import { Context, Data, Effect, type Option, Schema } from "effect";
+import { type Brand, Context, Data, Effect, type Option, Schema } from "effect";
import { CurrentUser } from "./Authentication.ts";
-export type Policy = Effect.Effect<
- void,
- PolicyDeniedError | E,
- CurrentUser | R
+export type Policy = Brand.Branded<
+ Effect.Effect,
+ "Private"
>;
-export type PublicPolicy = Effect.Effect<
- void,
- PolicyDeniedError | E,
- R
+export type PublicPolicy = Brand.Branded<
+ Effect.Effect,
+ "Public"
>;
export class PolicyDeniedError extends Schema.TaggedError()(
@@ -36,7 +34,7 @@ export const policy = (
),
(result) => (result ? Effect.void : Effect.fail(new PolicyDeniedError())),
),
- );
+ ) as Policy;
/**
* Creates a policy from a predicate function that may evaluate the current user,
@@ -54,7 +52,7 @@ export const publicPolicy = (
return yield* Effect.flatMap(predicate(user), (result) =>
result ? Effect.void : Effect.fail(new PolicyDeniedError()),
);
- });
+ }) as PublicPolicy;
export class DenyAccess extends Data.TaggedError("DenyAccess")<{}> {}