diff --git a/apps/web/actions/folders/updateFolder.ts b/apps/web/actions/folders/updateFolder.ts deleted file mode 100644 index ff1548f57b..0000000000 --- a/apps/web/actions/folders/updateFolder.ts +++ /dev/null @@ -1,73 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { folders } from "@cap/database/schema"; -import type { Folder } from "@cap/web-domain"; -import { and, eq } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; - -export async function updateFolder({ - folderId, - name, - color, - parentId, -}: { - folderId: Folder.FolderId; - name?: string; - color?: "normal" | "blue" | "red" | "yellow"; - parentId?: Folder.FolderId | null; -}) { - const user = await getCurrentUser(); - if (!user || !user.activeOrganizationId) - throw new Error("Unauthorized or no active organization"); - - // If parentId is provided and not null, verify it exists and belongs to the same organization - if (parentId) { - // Check that we're not creating a circular reference - if (parentId === folderId) { - throw new Error("A folder cannot be its own parent"); - } - - const [parentFolder] = await db() - .select() - .from(folders) - .where( - and( - eq(folders.id, parentId), - eq(folders.organizationId, user.activeOrganizationId), - ), - ); - - if (!parentFolder) { - throw new Error("Parent folder not found or not accessible"); - } - - // Check for circular references in the folder hierarchy - let currentParentId = parentFolder.parentId; - while (currentParentId) { - if (currentParentId === folderId) { - throw new Error("Cannot create circular folder references"); - } - - const [nextParent] = await db() - .select() - .from(folders) - .where(eq(folders.id, currentParentId)); - - if (!nextParent) break; - currentParentId = nextParent.parentId; - } - } - - await db() - .update(folders) - .set({ - ...(name !== undefined ? { name } : {}), - ...(color !== undefined ? { color } : {}), - ...(parentId !== undefined ? { parentId } : {}), - }) - .where(eq(folders.id, folderId)); - revalidatePath(`/dashboard/caps`); - revalidatePath(`/dashboard/folder/${folderId}`); -} diff --git a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx index b70e62fcfb..51b2b846d1 100644 --- a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx @@ -9,7 +9,6 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; -import { updateFolder } from "@/actions/folders/updateFolder"; import { useEffectMutation } from "@/lib/EffectRuntime"; import { withRpc } from "@/lib/Rpcs"; import { ConfirmationDialog } from "../../_components/ConfirmationDialog"; @@ -83,6 +82,17 @@ const FolderCard = ({ }, }); + const updateFolder = useEffectMutation({ + mutationFn: (data: Folder.FolderUpdate) => + withRpc((r) => r.FolderUpdate(data)), + onSuccess: () => { + toast.success("Folder name updated successfully"); + router.refresh(); + }, + onError: () => toast.error("Failed to update folder name"), + onSettled: () => setIsRenaming(false), + }); + useEffect(() => { if (isRenaming && nameRef.current) { nameRef.current.focus(); @@ -176,17 +186,6 @@ const FolderCard = ({ }; }, [id, name, rive, isDragOver]); - const updateFolderNameHandler = async () => { - try { - await updateFolder({ folderId: id, name: updateName }); - toast.success("Folder name updated successfully"); - } catch (error) { - toast.error("Failed to update folder name"); - } finally { - setIsRenaming(false); - } - }; - const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -342,18 +341,22 @@ const FolderCard = ({ rows={1} value={updateName} onChange={(e) => setUpdateName(e.target.value)} - onBlur={async () => { + onBlur={() => { setIsRenaming(false); - if (updateName.trim() !== name) { - await updateFolderNameHandler(); - } + if (updateName.trim() !== name) + updateFolder.mutate({ + id, + name: updateName.trim(), + }); }} - onKeyDown={async (e) => { + onKeyDown={(e) => { if (e.key === "Enter") { setIsRenaming(false); - if (updateName.trim() !== name) { - await updateFolderNameHandler(); - } + if (updateName.trim() !== name) + updateFolder.mutate({ + id, + name: updateName.trim(), + }); } }} className="w-full resize-none bg-transparent border-none focus:outline-none diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx index 0bf4d07871..bb013899c6 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx @@ -1,5 +1,6 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { serverEnv } from "@cap/env"; +import { Spaces } from "@cap/web-backend"; import { CurrentUser, type Folder } from "@cap/web-domain"; import { Effect } from "effect"; import { notFound } from "next/navigation"; @@ -16,7 +17,6 @@ import { NewSubfolderButton, } from "../../../../folder/[id]/components"; import FolderVideosSection from "../../../../folder/[id]/components/FolderVideosSection"; -import { getSpaceOrOrg } from "../../utils"; const FolderPage = async (props: { params: Promise<{ spaceId: string; folderId: Folder.FolderId }>; @@ -26,7 +26,8 @@ const FolderPage = async (props: { if (!user) return notFound(); return await Effect.gen(function* () { - const spaceOrOrg = yield* getSpaceOrOrg(params.spaceId); + const spaces = yield* Spaces; + const spaceOrOrg = yield* spaces.getSpaceOrOrg(params.spaceId); if (!spaceOrOrg) notFound(); const [childFolders, breadcrumb, videosData] = yield* Effect.all([ diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 507ba02580..f35a74fa9e 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -12,6 +12,7 @@ import { videoUploads, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; +import { Spaces } from "@cap/web-backend"; import { CurrentUser, Video } from "@cap/web-domain"; import { and, count, desc, eq, isNull, sql } from "drizzle-orm"; import { Effect } from "effect"; @@ -19,7 +20,6 @@ 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", @@ -92,7 +92,9 @@ export default async function SharedCapsPage(props: { const user = await getCurrentUser(); if (!user) notFound(); - const spaceOrOrg = await getSpaceOrOrg(params.spaceId).pipe( + const spaceOrOrg = await Effect.flatMap(Spaces, (s) => + s.getSpaceOrOrg(params.spaceId), + ).pipe( Effect.catchTag("PolicyDenied", () => Effect.sync(() => notFound())), Effect.provideService(CurrentUser, user), runPromise, diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts b/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts deleted file mode 100644 index df6205f9af..0000000000 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -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/app/Layout/devtoolsServer.ts b/apps/web/app/Layout/devtoolsServer.ts index ceeeee87e3..e43137035d 100644 --- a/apps/web/app/Layout/devtoolsServer.ts +++ b/apps/web/app/Layout/devtoolsServer.ts @@ -6,8 +6,6 @@ import { users } from "@cap/database/schema"; import { eq } from "drizzle-orm"; export async function promoteToPro() { - "use server"; - if (process.env.NODE_ENV !== "development") throw new Error("promoteToPro can only be used in development"); @@ -24,8 +22,6 @@ export async function promoteToPro() { } export async function demoteFromPro() { - "use server"; - if (process.env.NODE_ENV !== "development") throw new Error("demoteFromPro can only be used in development"); diff --git a/apps/web/app/embed/page.tsx b/apps/web/app/embed/page.tsx index 9382e9cbc0..a099e96fa6 100644 --- a/apps/web/app/embed/page.tsx +++ b/apps/web/app/embed/page.tsx @@ -1,4 +1,3 @@ -"use server"; import { redirect } from "next/navigation"; export default async function EmbedPage() { diff --git a/apps/web/app/s/page.tsx b/apps/web/app/s/page.tsx index 5dfed3800e..ff7f537882 100644 --- a/apps/web/app/s/page.tsx +++ b/apps/web/app/s/page.tsx @@ -1,5 +1,3 @@ -"use server"; - import { redirect } from "next/navigation"; export default async function SharePage() { diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index cee4927f44..f24a657a74 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -7,6 +7,7 @@ import { HttpAuthMiddlewareLive, OrganisationsPolicy, S3Buckets, + Spaces, SpacesPolicy, Videos, VideosPolicy, @@ -47,6 +48,7 @@ export const Dependencies = Layer.mergeAll( Folders.Default, SpacesPolicy.Default, OrganisationsPolicy.Default, + Spaces.Default, ).pipe( Layer.provideMerge( Layer.mergeAll(Database.Default, TracingLayer, FetchHttpClient.layer), diff --git a/packages/web-backend/src/Folders/FoldersPolicy.ts b/packages/web-backend/src/Folders/FoldersPolicy.ts index 42b677ec6f..df5a8b8caa 100644 --- a/packages/web-backend/src/Folders/FoldersPolicy.ts +++ b/packages/web-backend/src/Folders/FoldersPolicy.ts @@ -1,47 +1,52 @@ -import * as Db from "@cap/database/schema"; import { type Folder, Policy } from "@cap/web-domain"; -import * as Dz from "drizzle-orm"; import { Effect } from "effect"; import { Database } from "../Database.ts"; +import { OrganisationsPolicy } from "../Organisations/OrganisationsPolicy.ts"; +import { Spaces } from "../Spaces/index.ts"; +import { SpacesPolicy } from "../Spaces/SpacesPolicy.ts"; +import { FoldersRepo } from "./FoldersRepo.ts"; export class FoldersPolicy extends Effect.Service()( "FoldersPolicy", { effect: Effect.gen(function* () { - const db = yield* Database; + const repo = yield* FoldersRepo; + const spacesPolicy = yield* SpacesPolicy; + const orgsPolicy = yield* OrganisationsPolicy; + const spaces = yield* Spaces; const canEdit = (id: Folder.FolderId) => Policy.policy((user) => Effect.gen(function* () { - const [folder] = yield* db.execute((db) => - db.select().from(Db.folders).where(Dz.eq(Db.folders.id, id)), + const folder = yield* (yield* repo.getById(id)).pipe( + Effect.catchTag( + "NoSuchElementException", + () => new Policy.PolicyDeniedError(), + ), ); - // All space members can edit space properties - if (!folder?.spaceId) { - return folder?.createdById === user.id; - } - - const { spaceId } = folder; - const [spaceMember] = yield* db.execute((db) => - db - .select() - .from(Db.spaceMembers) - .where( - Dz.and( - Dz.eq(Db.spaceMembers.userId, user.id), - Dz.eq(Db.spaceMembers.spaceId, spaceId), - ), - ), - ); + if (folder.spaceId === null) return folder.createdById === user.id; + + const spaceOrOrg = yield* spaces.getSpaceOrOrg(folder.spaceId); + if (!spaceOrOrg) return false; + + if (spaceOrOrg.variant === "space") + yield* spacesPolicy.isMember(spaceOrOrg.space.id); + else yield* orgsPolicy.isOwner(spaceOrOrg.organization.id); - return spaceMember !== undefined; + return true; }), ); return { canEdit }; }), - dependencies: [Database.Default], + dependencies: [ + FoldersRepo.Default, + Database.Default, + Spaces.Default, + SpacesPolicy.Default, + OrganisationsPolicy.Default, + ], }, ) {} diff --git a/packages/web-backend/src/Folders/FoldersRepo.ts b/packages/web-backend/src/Folders/FoldersRepo.ts new file mode 100644 index 0000000000..d940006806 --- /dev/null +++ b/packages/web-backend/src/Folders/FoldersRepo.ts @@ -0,0 +1,88 @@ +import { nanoId } from "@cap/database/helpers"; +import * as Db from "@cap/database/schema"; +import { Folder, type Organisation, type User } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; +import { Array, Effect, Option } from "effect"; +import type { Schema } from "effect/Schema"; +import { Database } from "../Database.ts"; + +export type CreateFolderInput = Omit< + Schema.Type, + "id" | "createdAt" | "updatedAt" +> & { + organizationId: Organisation.OrganisationId; + createdById: User.UserId; +}; + +export class FoldersRepo extends Effect.Service()("FoldersRepo", { + effect: Effect.gen(function* () { + const db = yield* Database; + + /** + * Gets a `Folder` by its ID. + */ + const getById = ( + id: Folder.FolderId, + filters?: { organizationId?: Organisation.OrganisationId }, + ) => + db + .execute((db) => + db + .select() + .from(Db.folders) + .where( + Dz.and( + Dz.eq(Db.folders.id, id), + filters?.organizationId && + Dz.eq(Db.folders.organizationId, filters.organizationId), + ), + ), + ) + .pipe(Effect.map(Array.get(0))); + + const delete_ = (id: Folder.FolderId) => + db.execute((db) => db.delete(Db.folders).where(Dz.eq(Db.folders.id, id))); + + const create = (data: CreateFolderInput) => + Effect.gen(function* () { + const id = Folder.FolderId.make(nanoId()); + + yield* db.execute((db) => + db.insert(Db.folders).values([ + { + ...data, + id, + parentId: Option.getOrNull(data.parentId ?? Option.none()), + spaceId: Option.getOrNull(data.spaceId ?? Option.none()), + }, + ]), + ); + + return id; + }); + + const update = (id: Folder.FolderId, data: Partial) => + Effect.gen(function* () { + yield* db.execute((db) => + db + .update(Db.folders) + .set({ + ...data, + parentId: data.parentId + ? Option.getOrNull(data.parentId) + : undefined, + spaceId: data.spaceId + ? Option.getOrNull(data.spaceId) + : undefined, + updatedAt: new Date(), + }) + .where(Dz.eq(Db.folders.id, id)), + ); + + return yield* getById(id); + }); + + return { getById, delete: delete_, create, update }; + }), + dependencies: [Database.Default], +}) {} diff --git a/packages/web-backend/src/Folders/FoldersRpcs.ts b/packages/web-backend/src/Folders/FoldersRpcs.ts index 078d6f10fc..46ed375299 100644 --- a/packages/web-backend/src/Folders/FoldersRpcs.ts +++ b/packages/web-backend/src/Folders/FoldersRpcs.ts @@ -17,6 +17,7 @@ export const FolderRpcsLive = Folder.FolderRpcs.toLayer( () => new InternalError({ type: "database" }), ), ), + FolderCreate: (data) => folders .create(data) @@ -26,6 +27,16 @@ export const FolderRpcsLive = Folder.FolderRpcs.toLayer( () => new InternalError({ type: "database" }), ), ), + + FolderUpdate: (data) => + folders + .update(data.id, data) + .pipe( + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + ), }; }), ); diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index d402ef2154..e8e488b3c0 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -1,16 +1,24 @@ -import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; -import { CurrentUser, Folder, Policy } from "@cap/web-domain"; +import { + CurrentUser, + Folder, + Organisation, + Policy, + User, +} from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Option } from "effect"; + import { Database, type DatabaseError } from "../Database.ts"; import { FoldersPolicy } from "./FoldersPolicy.ts"; +import { FoldersRepo } from "./FoldersRepo.ts"; // @effect-diagnostics-next-line leakingRequirements:off export class Folders extends Effect.Service()("Folders", { effect: Effect.gen(function* () { const db = yield* Database; const policy = yield* FoldersPolicy; + const repo = yield* FoldersRepo; const deleteFolder = (folder: { id: Folder.FolderId; @@ -72,41 +80,36 @@ export class Folders extends Effect.Service()("Folders", { if (Option.isSome(data.parentId)) { const parentId = data.parentId.value; - const [parentFolder] = yield* db.execute((db) => - db - .select() - .from(Db.folders) - .where( - Dz.and( - Dz.eq(Db.folders.id, parentId), - Dz.eq(Db.folders.organizationId, user.activeOrganizationId), + + yield* repo + .getById(parentId, { + organizationId: Organisation.OrganisationId.make( + user.activeOrganizationId, + ), + }) + .pipe( + Policy.withPolicy(policy.canEdit(parentId)), + Effect.flatMap( + Effect.catchTag( + "NoSuchElementException", + () => new Folder.NotFoundError(), ), ), - ); - - if (!parentFolder) return yield* new Folder.NotFoundError(); + ); } - const folder = { - id: Folder.FolderId.make(nanoId()), + yield* repo.create({ name: data.name, color: data.color, - organizationId: user.activeOrganizationId, - createdById: user.id, + organizationId: Organisation.OrganisationId.make( + user.activeOrganizationId, + ), + createdById: User.UserId.make(user.id), spaceId: data.spaceId, parentId: data.parentId, - }; - - yield* db.execute((db) => - db.insert(Db.folders).values({ - ...folder, - spaceId: Option.getOrNull(folder.spaceId), - parentId: Option.getOrNull(folder.parentId), - }), - ); - - return new Folder.Folder(folder); + }); }), + /** * Deletes a folder and all its subfolders. Videos inside the folders will be * relocated to the root of the collection (space or My Caps) they're in @@ -122,7 +125,75 @@ export class Folders extends Effect.Service()("Folders", { yield* deleteFolder(folder); }), + + update: Effect.fn("Folders.update")(function* ( + folderId: Folder.FolderId, + data: Folder.FolderUpdate, + ) { + const folder = yield* (yield* repo + .getById(folderId) + .pipe(Policy.withPolicy(policy.canEdit(folderId)))).pipe( + Effect.catchTag( + "NoSuchElementException", + () => new Folder.NotFoundError(), + ), + ); + + // If parentId is provided and not null, verify it exists and belongs to the same organization + if (data.parentId && Option.isSome(data.parentId)) { + const parentId = data.parentId.value; + // Check that we're not creating an immediate circular reference + if (parentId === folderId) + return yield* new Folder.RecursiveDefinitionError(); + + const parentFolder = yield* repo + .getById(parentId, { + organizationId: Organisation.OrganisationId.make( + folder.organizationId, + ), + }) + .pipe( + Policy.withPolicy(policy.canEdit(parentId)), + Effect.flatMap( + Effect.catchTag( + "NoSuchElementException", + () => new Folder.ParentNotFoundError(), + ), + ), + ); + + // Check for circular references in the folder hierarchy + let currentParentId = parentFolder.parentId; + while (currentParentId) { + if (currentParentId === folderId) + return yield* new Folder.RecursiveDefinitionError(); + + const parentId = currentParentId; + const nextParent = yield* repo.getById(parentId, { + organizationId: Organisation.OrganisationId.make( + folder.organizationId, + ), + }); + + if (Option.isNone(nextParent)) break; + currentParentId = nextParent.value.parentId; + } + } + + yield* db.execute((db) => + db + .update(Db.folders) + .set({ + name: data.name, + color: data.color, + parentId: data.parentId + ? Option.getOrNull(data.parentId) + : undefined, + }) + .where(Dz.eq(Db.folders.id, folderId)), + ); + }), }; }), - dependencies: [FoldersPolicy.Default, Database.Default], + dependencies: [FoldersPolicy.Default, FoldersRepo.Default, Database.Default], }) {} diff --git a/packages/web-backend/src/Loom/ImportVideo.ts b/packages/web-backend/src/Loom/ImportVideo.ts index 506e45f6f7..4800f759f2 100644 --- a/packages/web-backend/src/Loom/ImportVideo.ts +++ b/packages/web-backend/src/Loom/ImportVideo.ts @@ -1,4 +1,4 @@ -import { S3Bucket, Video } from "@cap/web-domain"; +import { Organisation, S3Bucket, User, Video } from "@cap/web-domain"; import { Headers, HttpClient } from "@effect/platform"; import { Activity, Workflow } from "@effect/workflow"; import { Effect, Option, Schedule, Schema, Stream } from "effect"; @@ -23,14 +23,14 @@ export const LoomImportVideo = Workflow.make({ name: "LoomImportVideo", payload: { cap: Schema.Struct({ - userId: Schema.String, - orgId: Schema.String, + userId: User.UserId, + orgId: Organisation.OrganisationId, }), loom: Schema.Struct({ - userId: Schema.String, - orgId: Schema.String, + userId: User.UserId, + orgId: Organisation.OrganisationId, video: Schema.Struct({ - id: Schema.String, + id: Video.VideoId, name: Schema.String, downloadUrl: Schema.String, width: Schema.OptionFromNullOr(Schema.Number), diff --git a/packages/web-backend/src/Organisations/OrganisationsPolicy.ts b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts index 4d1e414b8c..1d0f5475e2 100644 --- a/packages/web-backend/src/Organisations/OrganisationsPolicy.ts +++ b/packages/web-backend/src/Organisations/OrganisationsPolicy.ts @@ -12,13 +12,23 @@ export class OrganisationsPolicy extends Effect.Service()( const repo = yield* OrganisationsRepo; const isMember = (orgId: string) => - Policy.policy( - Effect.fn(function* (user) { - return Option.isSome(yield* repo.membership(user.id, orgId)); - }), + Policy.policy((user) => + repo.membership(user.id, orgId).pipe(Effect.map(Option.isSome)), ); - return { isMember }; + const isOwner = (orgId: string) => + Policy.policy((user) => + repo.membership(user.id, orgId).pipe( + Effect.map((v) => + v.pipe( + Option.filter((v) => v.role === "owner"), + Option.isSome, + ), + ), + ), + ); + + return { isMember, isOwner }; }), dependencies: [ OrganisationsRepo.Default, diff --git a/packages/web-backend/src/Organisations/OrganisationsRepo.ts b/packages/web-backend/src/Organisations/OrganisationsRepo.ts index 3626864ae6..488f352a38 100644 --- a/packages/web-backend/src/Organisations/OrganisationsRepo.ts +++ b/packages/web-backend/src/Organisations/OrganisationsRepo.ts @@ -31,7 +31,6 @@ export class OrganisationsRepo extends Effect.Service()( ), ), ), - membership: (userId: string, orgId: string) => db .execute((db) => diff --git a/packages/web-backend/src/Spaces/SpacesPolicy.ts b/packages/web-backend/src/Spaces/SpacesPolicy.ts index 066fd0e6c7..4e06726fe9 100644 --- a/packages/web-backend/src/Spaces/SpacesPolicy.ts +++ b/packages/web-backend/src/Spaces/SpacesPolicy.ts @@ -12,10 +12,8 @@ export class SpacesPolicy extends Effect.Service()( const repo = yield* SpacesRepo; const hasMembership = (spaceId: string) => - Policy.policy( - Effect.fn(function* (user) { - return Option.isSome(yield* repo.membership(user.id, spaceId)); - }), + Policy.policy((user) => + repo.membership(user.id, spaceId).pipe(Effect.map(Option.isSome)), ); const isOwner = (spaceId: string) => diff --git a/packages/web-backend/src/Spaces/index.ts b/packages/web-backend/src/Spaces/index.ts index e69de29bb2..caf6643856 100644 --- a/packages/web-backend/src/Spaces/index.ts +++ b/packages/web-backend/src/Spaces/index.ts @@ -0,0 +1,62 @@ +import * as Db from "@cap/database/schema"; +import { Policy } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; +import { Effect } from "effect"; + +import { Database } from "../Database"; +import { OrganisationsPolicy } from "../Organisations/OrganisationsPolicy"; +import { SpacesPolicy } from "./SpacesPolicy"; + +export class Spaces extends Effect.Service()("Spaces", { + effect: Effect.gen(function* () { + const db = yield* Database; + const spacesPolicy = yield* SpacesPolicy; + const orgsPolicy = yield* OrganisationsPolicy; + + // this sucks but right now org ids are also valid space ids, + // since the whole-org space is just the org id + const getSpaceOrOrg = Effect.fn(function* (spaceOrOrgId: string) { + const [[space], [org]] = yield* Effect.all([ + db.execute((db) => + db + .select({ + id: Db.spaces.id, + name: Db.spaces.name, + organizationId: Db.spaces.organizationId, + createdById: Db.spaces.createdById, + }) + .from(Db.spaces) + .where(Dz.eq(Db.spaces.id, spaceOrOrgId)) + .limit(1), + ), + db.execute((db) => + db + .select({ + id: Db.organizations.id, + name: Db.organizations.name, + ownerId: Db.organizations.ownerId, + }) + .from(Db.organizations) + .where(Dz.eq(Db.organizations.id, spaceOrOrgId)) + .limit(1), + ), + ]); + if (space) + return yield* Effect.succeed({ variant: "space" as const, space }).pipe( + Policy.withPolicy(spacesPolicy.isMember(space.id)), + ); + if (org) + return yield* Effect.succeed({ + variant: "organization" as const, + organization: org, + }).pipe(Policy.withPolicy(orgsPolicy.isMember(org.id))); + }); + + return { getSpaceOrOrg }; + }), + dependencies: [ + SpacesPolicy.Default, + OrganisationsPolicy.Default, + Database.Default, + ], +}) {} diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts index ce5dc3fbea..c54a3a53cb 100644 --- a/packages/web-backend/src/Videos/VideosRpcs.ts +++ b/packages/web-backend/src/Videos/VideosRpcs.ts @@ -16,6 +16,7 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( S3Error: () => new InternalError({ type: "s3" }), }), ), + VideoDuplicate: (videoId) => videos.duplicate(videoId).pipe( Effect.catchTags({ @@ -23,6 +24,7 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( S3Error: () => new InternalError({ type: "s3" }), }), ), + GetUploadProgress: (videoId) => videos.getUploadProgress(videoId).pipe( provideOptionalAuth, @@ -31,6 +33,7 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( UnknownException: () => new InternalError({ type: "unknown" }), }), ), + VideoGetDownloadInfo: (videoId) => videos.getDownloadInfo(videoId).pipe( provideOptionalAuth, diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index badfc188db..7a1b9449af 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -5,6 +5,7 @@ export * from "./Loom/index.ts"; export { OrganisationsPolicy } from "./Organisations/OrganisationsPolicy.ts"; export * from "./Rpcs.ts"; export { S3Buckets } from "./S3Buckets/index.ts"; +export { Spaces } from "./Spaces/index.ts"; export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts"; export { Videos } from "./Videos/index.ts"; export { VideosPolicy } from "./Videos/VideosPolicy.ts"; diff --git a/packages/web-domain/src/Comment.ts b/packages/web-domain/src/Comment.ts new file mode 100644 index 0000000000..067d656319 --- /dev/null +++ b/packages/web-domain/src/Comment.ts @@ -0,0 +1,4 @@ +import { Schema } from "effect"; + +export const CommentId = Schema.String.pipe(Schema.brand("CommentId")); +export type CommentId = typeof CommentId.Type; diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index 3a1e34b65e..94f864d03e 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -1,9 +1,12 @@ +import { HttpApiSchema } from "@effect/platform"; import { Rpc, RpcGroup } from "@effect/rpc"; -import { Schema } from "effect"; - +import { Effect, Schema } from "effect"; import { RpcAuthMiddleware } from "./Authentication.ts"; import { InternalError } from "./Errors.ts"; +import { OrganisationId } from "./Organisation.ts"; import { PolicyDeniedError } from "./Policy.ts"; +import { SpaceId } from "./Space.ts"; +import { UserId } from "./User.ts"; export const FolderId = Schema.String.pipe(Schema.brand("FolderId")); export type FolderId = typeof FolderId.Type; @@ -14,18 +17,41 @@ export type FolderColor = (typeof FolderColor)["Type"]; export class NotFoundError extends Schema.TaggedError()( "FolderNotFoundError", {}, + HttpApiSchema.annotations({ status: 404 }), +) {} + +// A folder can't be declared within itself. +export class RecursiveDefinitionError extends Schema.TaggedError()( + "RecursiveDefinitionError", + {}, + HttpApiSchema.annotations({ status: 409 }), +) {} + +// Attempted to assign a parent to a folder which doesn't exist. +export class ParentNotFoundError extends Schema.TaggedError()( + "ParentNotFoundError", + {}, + HttpApiSchema.annotations({ status: 404 }), ) {} export class Folder extends Schema.Class("Folder")({ id: FolderId, name: Schema.String, color: FolderColor, - organizationId: Schema.String, - createdById: Schema.String, + organizationId: OrganisationId, + createdById: UserId, spaceId: Schema.OptionFromNullOr(Schema.String), parentId: Schema.OptionFromNullOr(FolderId), }) {} +export const FolderUpdate = Schema.Struct({ + id: FolderId, + name: Schema.optional(Schema.String), + color: Schema.optional(FolderColor), + parentId: Schema.optional(Schema.Option(FolderId)), +}); +export type FolderUpdate = Schema.Schema.Type; + export class FolderRpcs extends RpcGroup.make( Rpc.make("FolderDelete", { payload: FolderId, @@ -35,10 +61,19 @@ export class FolderRpcs extends RpcGroup.make( payload: Schema.Struct({ name: Schema.String, color: FolderColor, - spaceId: Schema.OptionFromUndefinedOr(Schema.String), + spaceId: Schema.OptionFromUndefinedOr(SpaceId), parentId: Schema.OptionFromUndefinedOr(FolderId), }), - success: Folder, - error: Schema.Union(NotFoundError, InternalError), + error: Schema.Union(NotFoundError, InternalError, PolicyDeniedError), + }).middleware(RpcAuthMiddleware), + Rpc.make("FolderUpdate", { + payload: FolderUpdate, + error: Schema.Union( + RecursiveDefinitionError, + ParentNotFoundError, + PolicyDeniedError, + NotFoundError, + InternalError, + ), }).middleware(RpcAuthMiddleware), ) {} diff --git a/packages/web-domain/src/Loom.ts b/packages/web-domain/src/Loom.ts index 8f1c0776ae..3fe3105b46 100644 --- a/packages/web-domain/src/Loom.ts +++ b/packages/web-domain/src/Loom.ts @@ -1,6 +1,7 @@ import { Workflow } from "@effect/workflow"; import { Schema } from "effect"; - +import { OrganisationId } from "./Organisation.ts"; +import { UserId } from "./User.ts"; import * as Video from "./Video.ts"; class LoomApiError extends Schema.TaggedError("LoomApiError")( @@ -19,12 +20,12 @@ export const LoomImportVideo = Workflow.make({ name: "LoomImportVideo", payload: { cap: Schema.Struct({ - userId: Schema.String, - orgId: Schema.String, + userId: UserId, + orgId: OrganisationId, }), loom: Schema.Struct({ - userId: Schema.String, - orgId: Schema.String, + userId: UserId, + orgId: OrganisationId, video: Schema.Struct({ id: Schema.String, name: Schema.String, diff --git a/packages/web-domain/src/Organisation.ts b/packages/web-domain/src/Organisation.ts index 41be3531d5..c2c74e8257 100644 --- a/packages/web-domain/src/Organisation.ts +++ b/packages/web-domain/src/Organisation.ts @@ -1,6 +1,11 @@ import { Schema } from "effect"; +export const OrganisationId = Schema.String.pipe( + Schema.brand("OrganisationId"), +); +export type OrganisationId = typeof OrganisationId.Type; + export class Organisation extends Schema.Class("Organisation")({ - id: Schema.String.pipe(Schema.brand("OrganisationId")), + id: OrganisationId, name: Schema.String, }) {} diff --git a/packages/web-domain/src/S3Bucket.ts b/packages/web-domain/src/S3Bucket.ts index 327826e882..93b06fad4c 100644 --- a/packages/web-domain/src/S3Bucket.ts +++ b/packages/web-domain/src/S3Bucket.ts @@ -1,11 +1,12 @@ import { Schema } from "effect"; +import { UserId } from "./User"; export const S3BucketId = Schema.String.pipe(Schema.brand("S3BucketId")); export type S3BucketId = typeof S3BucketId.Type; export class S3Bucket extends Schema.Class("S3Bucket")({ id: S3BucketId, - ownerId: Schema.String, + ownerId: UserId, region: Schema.String, endpoint: Schema.OptionFromNullOr(Schema.String), name: Schema.String, diff --git a/packages/web-domain/src/Space.ts b/packages/web-domain/src/Space.ts new file mode 100644 index 0000000000..4091861708 --- /dev/null +++ b/packages/web-domain/src/Space.ts @@ -0,0 +1,4 @@ +import { Schema } from "effect"; + +export const SpaceId = Schema.String; // TODO: .pipe(Schema.brand("SpaceId")); +export type SpaceId = typeof SpaceId.Type; diff --git a/packages/web-domain/src/User.ts b/packages/web-domain/src/User.ts new file mode 100644 index 0000000000..2ef26ac3da --- /dev/null +++ b/packages/web-domain/src/User.ts @@ -0,0 +1,4 @@ +import { Schema } from "effect"; + +export const UserId = Schema.String.pipe(Schema.brand("UserId")); +export type UserId = typeof UserId.Type; diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 232efbd0f1..5dc1b4cc7f 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -4,8 +4,10 @@ import { Context, Effect, Option, Schema } from "effect"; import { RpcAuthMiddleware } from "./Authentication.ts"; import { InternalError } from "./Errors.ts"; import { FolderId } from "./Folder.ts"; +import { OrganisationId } from "./Organisation.ts"; import { PolicyDeniedError } from "./Policy.ts"; import { S3BucketId } from "./S3Bucket.ts"; +import { UserId } from "./User.ts"; export const VideoId = Schema.String.pipe(Schema.brand("VideoId")); export type VideoId = typeof VideoId.Type; @@ -13,8 +15,8 @@ export type VideoId = typeof VideoId.Type; // Purposefully doesn't include password as this is a public class export class Video extends Schema.Class