From 8b22e76331b8e06c04c32f5a4043b311a038d877 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 09:20:28 +1000 Subject: [PATCH 01/19] wip --- apps/web/actions/folders/updateFolder.ts | 1 + .../web-backend/src/Folders/FoldersRpcs.ts | 9 +++ packages/web-backend/src/Folders/index.ts | 68 +++++++++++++++++++ packages/web-domain/src/Folder.ts | 12 ++++ 4 files changed, 90 insertions(+) diff --git a/apps/web/actions/folders/updateFolder.ts b/apps/web/actions/folders/updateFolder.ts index ff1548f57b..1f4262194e 100644 --- a/apps/web/actions/folders/updateFolder.ts +++ b/apps/web/actions/folders/updateFolder.ts @@ -7,6 +7,7 @@ import type { Folder } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +// TODO: Replace this export async function updateFolder({ folderId, name, diff --git a/packages/web-backend/src/Folders/FoldersRpcs.ts b/packages/web-backend/src/Folders/FoldersRpcs.ts index 078d6f10fc..d0de39a906 100644 --- a/packages/web-backend/src/Folders/FoldersRpcs.ts +++ b/packages/web-backend/src/Folders/FoldersRpcs.ts @@ -26,6 +26,15 @@ 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..8f2e19d696 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -107,6 +107,7 @@ export class Folders extends Effect.Service()("Folders", { 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,6 +123,73 @@ export class Folders extends Effect.Service()("Folders", { yield* deleteFolder(folder); }), + + update: Effect.fn("Folders.update")(function* ( + id: Folder.FolderId, + data: Folder.FolderUpdate, + ) { + const user = yield* CurrentUser; + + // const [folder] = yield* db + // .execute((db) => + // db.select().from(Db.folders).where(Dz.eq(Db.folders.id, id)), + // ) + // .pipe(Policy.withPolicy(policy.canEdit(id))); + // if (!folder) return yield* new Folder.NotFoundError(); + // yield* deleteFolder(folder); + + // // If parentId is provided and not null, verify it exists and belongs to the same organization + if (data.parentId) { + // Check that we're not creating a circular reference + if (data.parentId === Option.some(id)) + throw new Error("A folder cannot be its own parent"); // TODO: Effect error + + // 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; + // } + } + + yield* db + .execute((db) => + db.update(Db.folders) + .set({ + ...(Option.isSome(data.name) ? { name: data.name.value } : {}), + ...(Option.isSome(data.color) ? { color: data.color.value } : {}), + ...(Option.isSome(data.parentId) ? { parentId: data.parentId.value } : {}), + }) + .where(Dz.eq(Db.folders.id, id)); + ) + .pipe(Policy.withPolicy(policy.canEdit(id))); + + revalidatePath(`/dashboard/caps`); + revalidatePath(`/dashboard/folder/${folderId}`); + }), }; }), dependencies: [FoldersPolicy.Default, Database.Default], diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index 3a1e34b65e..2a88aaa85c 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -26,6 +26,13 @@ export class Folder extends Schema.Class("Folder")({ parentId: Schema.OptionFromNullOr(FolderId), }) {} +export class FolderUpdate extends Schema.Class("FolderPatch")({ + id: FolderId, + name: Schema.OptionFromUndefinedOr(Schema.String), + color: Schema.OptionFromUndefinedOr(FolderColor), + parentId: Schema.OptionFromUndefinedOr(FolderId), +}) {} + export class FolderRpcs extends RpcGroup.make( Rpc.make("FolderDelete", { payload: FolderId, @@ -41,4 +48,9 @@ export class FolderRpcs extends RpcGroup.make( success: Folder, error: Schema.Union(NotFoundError, InternalError), }).middleware(RpcAuthMiddleware), + Rpc.make("FolderUpdate", { + payload: FolderUpdate, + success: Folder, + error: Schema.Union(NotFoundError, InternalError), + }).middleware(RpcAuthMiddleware), ) {} From 889d4df1332e612e9a8f2a7914c746611d5be24d Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 09:32:11 +1000 Subject: [PATCH 02/19] wip --- apps/web/actions/folders/updateFolder.ts | 74 ------------------- .../dashboard/caps/components/Folder.tsx | 50 ++++++------- packages/web-backend/src/Folders/index.ts | 26 ++++--- packages/web-domain/src/Folder.ts | 6 +- 4 files changed, 43 insertions(+), 113 deletions(-) delete mode 100644 apps/web/actions/folders/updateFolder.ts diff --git a/apps/web/actions/folders/updateFolder.ts b/apps/web/actions/folders/updateFolder.ts deleted file mode 100644 index 1f4262194e..0000000000 --- a/apps/web/actions/folders/updateFolder.ts +++ /dev/null @@ -1,74 +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"; - -// TODO: Replace this -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..2aae9edb66 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"; @@ -37,7 +36,6 @@ const FolderCard = ({ const router = useRouter(); const { theme } = useTheme(); const [confirmDeleteFolderOpen, setConfirmDeleteFolderOpen] = useState(false); - const [isRenaming, setIsRenaming] = useState(false); const [updateName, setUpdateName] = useState(name); const nameRef = useRef(null); const folderRef = useRef(null); @@ -83,12 +81,22 @@ 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"), + }); + useEffect(() => { - if (isRenaming && nameRef.current) { + if (updateFolder.isPending && nameRef.current) { nameRef.current.focus(); nameRef.current.select(); } - }, [isRenaming]); + }, [updateFolder.isPending]); // Register this folder as a drop target for mobile drag and drop useEffect(() => { @@ -176,17 +184,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 +339,20 @@ const FolderCard = ({ rows={1} value={updateName} onChange={(e) => setUpdateName(e.target.value)} - onBlur={async () => { - setIsRenaming(false); - if (updateName.trim() !== name) { - await updateFolderNameHandler(); - } + onBlur={() => { + 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 @@ -364,7 +363,6 @@ const FolderCard = ({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setIsRenaming(true); }} >

diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index 8f2e19d696..09796e55a5 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -5,6 +5,7 @@ import * as Dz from "drizzle-orm"; import { Effect, Option } from "effect"; import { Database, type DatabaseError } from "../Database.ts"; import { FoldersPolicy } from "./FoldersPolicy.ts"; +import { revalidatePath } from "next/cache"; // @effect-diagnostics-next-line leakingRequirements:off export class Folders extends Effect.Service()("Folders", { @@ -125,7 +126,7 @@ export class Folders extends Effect.Service()("Folders", { }), update: Effect.fn("Folders.update")(function* ( - id: Folder.FolderId, + folderId: Folder.FolderId, data: Folder.FolderUpdate, ) { const user = yield* CurrentUser; @@ -141,7 +142,7 @@ export class Folders extends Effect.Service()("Folders", { // // If parentId is provided and not null, verify it exists and belongs to the same organization if (data.parentId) { // Check that we're not creating a circular reference - if (data.parentId === Option.some(id)) + if (data.parentId === Option.some(folderId)) throw new Error("A folder cannot be its own parent"); // TODO: Effect error // const [parentFolder] = await db() @@ -177,15 +178,20 @@ export class Folders extends Effect.Service()("Folders", { yield* db .execute((db) => - db.update(Db.folders) - .set({ - ...(Option.isSome(data.name) ? { name: data.name.value } : {}), - ...(Option.isSome(data.color) ? { color: data.color.value } : {}), - ...(Option.isSome(data.parentId) ? { parentId: data.parentId.value } : {}), - }) - .where(Dz.eq(Db.folders.id, id)); + db + .update(Db.folders) + .set({ + ...(Option.isSome(data.name) ? { name: data.name.value } : {}), + ...(Option.isSome(data.color) + ? { color: data.color.value } + : {}), + ...(Option.isSome(data.parentId) + ? { parentId: data.parentId.value } + : {}), + }) + .where(Dz.eq(Db.folders.id, folderId)), ) - .pipe(Policy.withPolicy(policy.canEdit(id))); + .pipe(Policy.withPolicy(policy.canEdit(folderId))); revalidatePath(`/dashboard/caps`); revalidatePath(`/dashboard/folder/${folderId}`); diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index 2a88aaa85c..efb987ccc7 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -28,9 +28,9 @@ export class Folder extends Schema.Class("Folder")({ export class FolderUpdate extends Schema.Class("FolderPatch")({ id: FolderId, - name: Schema.OptionFromUndefinedOr(Schema.String), - color: Schema.OptionFromUndefinedOr(FolderColor), - parentId: Schema.OptionFromUndefinedOr(FolderId), + name: Schema.optional(Schema.String), + color: Schema.optional(FolderColor), + parentId: Schema.optional(FolderId), }) {} export class FolderRpcs extends RpcGroup.make( From ff8026c25689151ad47127a10ab98c1af0f32142 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 09:52:00 +1000 Subject: [PATCH 03/19] more wip --- .../dashboard/caps/components/Folder.tsx | 9 ++- .../web-backend/src/Folders/FoldersRepo.ts | 74 +++++++++++++++++++ packages/web-backend/src/Folders/index.ts | 27 ++++--- packages/web-domain/src/Folder.ts | 17 ++++- 4 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 packages/web-backend/src/Folders/FoldersRepo.ts diff --git a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx index 2aae9edb66..c70681a0f4 100644 --- a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx @@ -36,6 +36,7 @@ const FolderCard = ({ const router = useRouter(); const { theme } = useTheme(); const [confirmDeleteFolderOpen, setConfirmDeleteFolderOpen] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); const [updateName, setUpdateName] = useState(name); const nameRef = useRef(null); const folderRef = useRef(null); @@ -89,14 +90,15 @@ const FolderCard = ({ router.refresh(); }, onError: () => toast.error("Failed to update folder name"), + onSettled: () => setIsRenaming(false), }); useEffect(() => { - if (updateFolder.isPending && nameRef.current) { + if (isRenaming && nameRef.current) { nameRef.current.focus(); nameRef.current.select(); } - }, [updateFolder.isPending]); + }, [isRenaming]); // Register this folder as a drop target for mobile drag and drop useEffect(() => { @@ -340,6 +342,7 @@ const FolderCard = ({ value={updateName} onChange={(e) => setUpdateName(e.target.value)} onBlur={() => { + setIsRenaming(false); if (updateName.trim() !== name) updateFolder.mutate({ id, @@ -348,6 +351,7 @@ const FolderCard = ({ }} onKeyDown={(e) => { if (e.key === "Enter") { + setIsRenaming(false); if (updateName.trim() !== name) updateFolder.mutate({ id, @@ -363,6 +367,7 @@ const FolderCard = ({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); + setIsRenaming(false); }} >

diff --git a/packages/web-backend/src/Folders/FoldersRepo.ts b/packages/web-backend/src/Folders/FoldersRepo.ts new file mode 100644 index 0000000000..49fda47ae2 --- /dev/null +++ b/packages/web-backend/src/Folders/FoldersRepo.ts @@ -0,0 +1,74 @@ +import { nanoId } from "@cap/database/helpers"; +import * as Db from "@cap/database/schema"; +import { Folder } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; +import { Effect, Option } from "effect"; +import type { Schema } from "effect/Schema"; +import { Database } from "../Database.ts"; + +export type CreateFolderInput = Omit, "id">; + +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) => + Effect.gen(function* () { + const [folder] = yield* db.execute((db) => + db.select().from(Db.folders).where(Dz.eq(Db.folders.id, id)), + ); + + return Option.fromNullable(folder).pipe( + Option.map((f) => Folder.Folder.decodeSync(f)), + ); + }); + + 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/index.ts b/packages/web-backend/src/Folders/index.ts index 09796e55a5..da18fdd638 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -129,22 +129,31 @@ export class Folders extends Effect.Service()("Folders", { folderId: Folder.FolderId, data: Folder.FolderUpdate, ) { - const user = yield* CurrentUser; + // const [video] = yield* repo + // .getById(videoId) + // .pipe( + // Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())), + // Policy.withPolicy(policy.isOwner(videoId)), + // ); + + // const user = yield* CurrentUser; // const [folder] = yield* db // .execute((db) => // db.select().from(Db.folders).where(Dz.eq(Db.folders.id, id)), // ) // .pipe(Policy.withPolicy(policy.canEdit(id))); + + // TODO: Hook up a return like this // if (!folder) return yield* new Folder.NotFoundError(); - // yield* deleteFolder(folder); // // If parentId is provided and not null, verify it exists and belongs to the same organization if (data.parentId) { // Check that we're not creating a circular reference - if (data.parentId === Option.some(folderId)) - throw new Error("A folder cannot be its own parent"); // TODO: Effect error + if (data.parentId === folderId) + return yield* new Folder.RecursiveDefinitionError(); + // TODO: Should this have a `policy` assigned to it??? // const [parentFolder] = await db() // .select() // .from(folders) @@ -181,13 +190,9 @@ export class Folders extends Effect.Service()("Folders", { db .update(Db.folders) .set({ - ...(Option.isSome(data.name) ? { name: data.name.value } : {}), - ...(Option.isSome(data.color) - ? { color: data.color.value } - : {}), - ...(Option.isSome(data.parentId) - ? { parentId: data.parentId.value } - : {}), + ...(data.name ? { name: data.name } : {}), + ...(data.color ? { color: data.color } : {}), + ...(data.parentId ? { parentId: data.parentId } : {}), }) .where(Dz.eq(Db.folders.id, folderId)), ) diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index efb987ccc7..cac359fae5 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -1,5 +1,5 @@ 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"; @@ -16,6 +16,12 @@ export class NotFoundError extends Schema.TaggedError()( {}, ) {} +// A folder can't be declared within itself. +export class RecursiveDefinitionError extends Schema.TaggedError()( + "RecursiveDefinitionError", + {}, +) {} + export class Folder extends Schema.Class("Folder")({ id: FolderId, name: Schema.String, @@ -24,7 +30,12 @@ export class Folder extends Schema.Class("Folder")({ createdById: Schema.String, spaceId: Schema.OptionFromNullOr(Schema.String), parentId: Schema.OptionFromNullOr(FolderId), -}) {} +}) { + static decodeSync = Schema.decodeSync(Folder); + + static toJS = (self: Folder) => + Schema.encode(Folder)(self).pipe(Effect.orDie); +} export class FolderUpdate extends Schema.Class("FolderPatch")({ id: FolderId, @@ -51,6 +62,6 @@ export class FolderRpcs extends RpcGroup.make( Rpc.make("FolderUpdate", { payload: FolderUpdate, success: Folder, - error: Schema.Union(NotFoundError, InternalError), + error: Schema.Union(NotFoundError, RecursiveDefinitionError, InternalError), }).middleware(RpcAuthMiddleware), ) {} From bb4b418f89d370ac57114cbd0ba6e2d0b02eb12a Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 10:07:36 +1000 Subject: [PATCH 04/19] wip --- packages/web-backend/src/Folders/index.ts | 86 ++++++++++------------- packages/web-domain/src/Folder.ts | 1 - 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index da18fdd638..3a51ae9112 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -129,60 +129,52 @@ export class Folders extends Effect.Service()("Folders", { folderId: Folder.FolderId, data: Folder.FolderUpdate, ) { - // const [video] = yield* repo - // .getById(videoId) - // .pipe( - // Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())), - // Policy.withPolicy(policy.isOwner(videoId)), - // ); - - // const user = yield* CurrentUser; - - // const [folder] = yield* db - // .execute((db) => - // db.select().from(Db.folders).where(Dz.eq(Db.folders.id, id)), - // ) - // .pipe(Policy.withPolicy(policy.canEdit(id))); + const user = yield* CurrentUser; - // TODO: Hook up a return like this - // if (!folder) return yield* new Folder.NotFoundError(); + const [folder] = yield* db + .execute((db) => + db.select().from(Db.folders).where(Dz.eq(Db.folders.id, folderId)), + ) + .pipe(Policy.withPolicy(policy.canEdit(folderId))); + if (!folder) return yield* new Folder.NotFoundError(); - // // If parentId is provided and not null, verify it exists and belongs to the same organization + // If parentId is provided and not null, verify it exists and belongs to the same organization if (data.parentId) { // Check that we're not creating a circular reference if (data.parentId === folderId) return yield* new Folder.RecursiveDefinitionError(); - // TODO: Should this have a `policy` assigned to it??? - // 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; - // } + const [parentFolder] = yield* db.execute((db) => + db + .select() + .from(Db.folders) + .where( + Dz.and( + Dz.eq(Db.folders.id, data.parentId), + Dz.eq(Db.folders.organizationId, user.activeOrganizationId), + ), + ), + ); + + if (!parentFolder) return yield* new Folder.NotFoundError(); + + // Check for circular references in the folder hierarchy + let currentParentId = parentFolder.parentId; + while (currentParentId) { + if (currentParentId === folderId) { + return yield* new Folder.RecursiveDefinitionError(); + } + + const [nextParent] = yield* db.execute((db) => + db + .select() + .from(Db.folders) + .where(Dz.eq(Db.folders.id, currentParentId)), + ); + + if (!nextParent) break; + currentParentId = nextParent.parentId; + } } yield* db diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index cac359fae5..aa0ab7579d 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -61,7 +61,6 @@ export class FolderRpcs extends RpcGroup.make( }).middleware(RpcAuthMiddleware), Rpc.make("FolderUpdate", { payload: FolderUpdate, - success: Folder, error: Schema.Union(NotFoundError, RecursiveDefinitionError, InternalError), }).middleware(RpcAuthMiddleware), ) {} From d87ff5de7ffff6783d0af383ecf0cd17c4acd05f Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 10:11:49 +1000 Subject: [PATCH 05/19] fix error --- packages/web-backend/src/Folders/index.ts | 5 ++--- packages/web-domain/src/Folder.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index 3a51ae9112..cfc2ec6559 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -156,14 +156,13 @@ export class Folders extends Effect.Service()("Folders", { ), ); - if (!parentFolder) return yield* new Folder.NotFoundError(); + if (!parentFolder) return yield* new Folder.ParentNotFoundError(); // Check for circular references in the folder hierarchy let currentParentId = parentFolder.parentId; while (currentParentId) { - if (currentParentId === folderId) { + if (currentParentId === folderId) return yield* new Folder.RecursiveDefinitionError(); - } const [nextParent] = yield* db.execute((db) => db diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index aa0ab7579d..83a0912bb9 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -22,6 +22,12 @@ export class RecursiveDefinitionError extends Schema.TaggedError()( + "ParentNotFoundError", + {}, +) {} + export class Folder extends Schema.Class("Folder")({ id: FolderId, name: Schema.String, @@ -61,6 +67,11 @@ export class FolderRpcs extends RpcGroup.make( }).middleware(RpcAuthMiddleware), Rpc.make("FolderUpdate", { payload: FolderUpdate, - error: Schema.Union(NotFoundError, RecursiveDefinitionError, InternalError), + error: Schema.Union( + NotFoundError, + RecursiveDefinitionError, + ParentNotFoundError, + InternalError, + ), }).middleware(RpcAuthMiddleware), ) {} From bdeb26986a8f6b0f3df8c615865d09fd616bcdba Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 11:08:02 +1000 Subject: [PATCH 06/19] cleanup `"use server"`'s --- apps/web/app/Layout/devtoolsServer.ts | 4 ---- apps/web/app/embed/page.tsx | 1 - apps/web/app/s/page.tsx | 2 -- 3 files changed, 7 deletions(-) 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() { From d7a648565111d6d765aba95aa0ca5f09ed8b1434 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 13:41:42 +1000 Subject: [PATCH 07/19] add policy for parent folder --- packages/web-backend/src/Folders/index.ts | 26 ++++++++++------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index cfc2ec6559..abf55e72b1 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -129,8 +129,6 @@ export class Folders extends Effect.Service()("Folders", { folderId: Folder.FolderId, data: Folder.FolderUpdate, ) { - const user = yield* CurrentUser; - const [folder] = yield* db .execute((db) => db.select().from(Db.folders).where(Dz.eq(Db.folders.id, folderId)), @@ -140,22 +138,20 @@ export class Folders extends Effect.Service()("Folders", { // If parentId is provided and not null, verify it exists and belongs to the same organization if (data.parentId) { + const parentId = data.parentId; + // Check that we're not creating a circular reference - if (data.parentId === folderId) + if (parentId === folderId) return yield* new Folder.RecursiveDefinitionError(); - const [parentFolder] = yield* db.execute((db) => - db - .select() - .from(Db.folders) - .where( - Dz.and( - Dz.eq(Db.folders.id, data.parentId), - Dz.eq(Db.folders.organizationId, user.activeOrganizationId), - ), - ), - ); - + const [parentFolder] = yield* db + .execute((db) => + db + .select() + .from(Db.folders) + .where(Dz.eq(Db.folders.id, parentId)), + ) + .pipe(Policy.withPolicy(policy.canEdit(parentId))); if (!parentFolder) return yield* new Folder.ParentNotFoundError(); // Check for circular references in the folder hierarchy From 479398290db04b55ac5ab193b4682a0a27231a48 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 14:09:52 +1000 Subject: [PATCH 08/19] improve logic --- packages/web-backend/src/Folders/index.ts | 93 +++++++++++++---------- packages/web-domain/src/Folder.ts | 4 + 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index abf55e72b1..a2084038d5 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -137,53 +137,62 @@ export class Folders extends Effect.Service()("Folders", { if (!folder) return yield* new Folder.NotFoundError(); // If parentId is provided and not null, verify it exists and belongs to the same organization - if (data.parentId) { - const parentId = data.parentId; + if (!data.parentId) return; + const parentId = data.parentId; - // Check that we're not creating a circular reference - if (parentId === folderId) - return yield* new Folder.RecursiveDefinitionError(); - - const [parentFolder] = yield* db - .execute((db) => - db - .select() - .from(Db.folders) - .where(Dz.eq(Db.folders.id, parentId)), - ) - .pipe(Policy.withPolicy(policy.canEdit(parentId))); - if (!parentFolder) return yield* 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 [nextParent] = yield* db.execute((db) => - db - .select() - .from(Db.folders) - .where(Dz.eq(Db.folders.id, currentParentId)), - ); - - if (!nextParent) break; - currentParentId = nextParent.parentId; - } - } + // Check that we're not creating an immediate circular reference + if (parentId === folderId) + return yield* new Folder.RecursiveDefinitionError(); - yield* db + const [parentFolder] = yield* db .execute((db) => db - .update(Db.folders) - .set({ - ...(data.name ? { name: data.name } : {}), - ...(data.color ? { color: data.color } : {}), - ...(data.parentId ? { parentId: data.parentId } : {}), - }) - .where(Dz.eq(Db.folders.id, folderId)), + .select() + .from(Db.folders) + .where( + Dz.and( + Dz.eq(Db.folders.id, parentId), + Dz.eq(Db.folders.organizationId, folder.organizationId), + ), + ), ) - .pipe(Policy.withPolicy(policy.canEdit(folderId))); + .pipe(Policy.withPolicy(policy.canEdit(parentId))); + if (!parentFolder) return yield* 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* db.execute((db) => + db + .select() + .from(Db.folders) + .where( + Dz.and( + Dz.eq(Db.folders.id, parentId), + // This should be implied but extra tenant isolation can't hurt + Dz.eq(Db.folders.organizationId, folder.organizationId), + ), + ), + ); + + if (!nextParent) break; + currentParentId = nextParent.parentId; + } + + yield* db.execute((db) => + db + .update(Db.folders) + .set({ + ...(data.name ? { name: data.name } : {}), + ...(data.color ? { color: data.color } : {}), + ...(data.parentId ? { parentId: data.parentId } : {}), + }) + .where(Dz.eq(Db.folders.id, folderId)), + ); revalidatePath(`/dashboard/caps`); revalidatePath(`/dashboard/folder/${folderId}`); diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index 83a0912bb9..969ca41bfb 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -4,6 +4,7 @@ import { Effect, Schema } from "effect"; import { RpcAuthMiddleware } from "./Authentication.ts"; import { InternalError } from "./Errors.ts"; import { PolicyDeniedError } from "./Policy.ts"; +import { HttpApiSchema } from "@effect/platform"; export const FolderId = Schema.String.pipe(Schema.brand("FolderId")); export type FolderId = typeof FolderId.Type; @@ -14,18 +15,21 @@ 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")({ From ba5bf6ba206fbc1c346d811250daea138b3028ef Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 14:17:02 +1000 Subject: [PATCH 09/19] do it goodly --- packages/web-backend/src/Folders/index.ts | 2 +- packages/web-domain/src/Folder.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index a2084038d5..8c8fc9b9be 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -3,9 +3,9 @@ import * as Db from "@cap/database/schema"; import { CurrentUser, Folder, Policy } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Option } from "effect"; +import { revalidatePath } from "next/cache"; import { Database, type DatabaseError } from "../Database.ts"; import { FoldersPolicy } from "./FoldersPolicy.ts"; -import { revalidatePath } from "next/cache"; // @effect-diagnostics-next-line leakingRequirements:off export class Folders extends Effect.Service()("Folders", { diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index 969ca41bfb..c7ae821340 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -1,10 +1,9 @@ +import { HttpApiSchema } from "@effect/platform"; import { Rpc, RpcGroup } from "@effect/rpc"; import { Effect, Schema } from "effect"; - import { RpcAuthMiddleware } from "./Authentication.ts"; import { InternalError } from "./Errors.ts"; import { PolicyDeniedError } from "./Policy.ts"; -import { HttpApiSchema } from "@effect/platform"; export const FolderId = Schema.String.pipe(Schema.brand("FolderId")); export type FolderId = typeof FolderId.Type; @@ -72,9 +71,10 @@ export class FolderRpcs extends RpcGroup.make( Rpc.make("FolderUpdate", { payload: FolderUpdate, error: Schema.Union( - NotFoundError, RecursiveDefinitionError, ParentNotFoundError, + PolicyDeniedError, + NotFoundError, InternalError, ), }).middleware(RpcAuthMiddleware), From 40a756f57539972f2a749cd310c655cea6b8857d Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 14:19:33 +1000 Subject: [PATCH 10/19] drop em --- packages/web-domain/src/Folder.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/web-domain/src/Folder.ts b/packages/web-domain/src/Folder.ts index c7ae821340..f53646bf47 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -39,12 +39,7 @@ export class Folder extends Schema.Class("Folder")({ createdById: Schema.String, spaceId: Schema.OptionFromNullOr(Schema.String), parentId: Schema.OptionFromNullOr(FolderId), -}) { - static decodeSync = Schema.decodeSync(Folder); - - static toJS = (self: Folder) => - Schema.encode(Folder)(self).pipe(Effect.orDie); -} +}) {} export class FolderUpdate extends Schema.Class("FolderPatch")({ id: FolderId, From 5d0f267b4342f34243b211cb1030ee06a2ec6dba Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 7 Oct 2025 14:25:07 +1000 Subject: [PATCH 11/19] cleanup --- packages/web-domain/src/Video.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 3480f8cdbf..018c8984a7 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -36,8 +36,6 @@ export class Video extends Schema.Class

diff --git a/packages/web-backend/src/Folders/FoldersRepo.ts b/packages/web-backend/src/Folders/FoldersRepo.ts index 49fda47ae2..1e04a68259 100644 --- a/packages/web-backend/src/Folders/FoldersRepo.ts +++ b/packages/web-backend/src/Folders/FoldersRepo.ts @@ -1,12 +1,18 @@ import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; -import { Folder } from "@cap/web-domain"; +import { Folder, type Organisation, type User } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Option } from "effect"; import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; -export type CreateFolderInput = Omit, "id">; +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* () { @@ -21,9 +27,7 @@ export class FoldersRepo extends Effect.Service()("FoldersRepo", { db.select().from(Db.folders).where(Dz.eq(Db.folders.id, id)), ); - return Option.fromNullable(folder).pipe( - Option.map((f) => Folder.Folder.decodeSync(f)), - ); + return Option.fromNullable(folder); }); const delete_ = (id: Folder.FolderId) => diff --git a/packages/web-backend/src/Folders/FoldersRpcs.ts b/packages/web-backend/src/Folders/FoldersRpcs.ts index d0de39a906..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,7 @@ export const FolderRpcsLive = Folder.FolderRpcs.toLayer( () => new InternalError({ type: "database" }), ), ), + FolderUpdate: (data) => folders .update(data.id, data) diff --git a/packages/web-backend/src/Folders/index.ts b/packages/web-backend/src/Folders/index.ts index 8c8fc9b9be..b0bab16654 100644 --- a/packages/web-backend/src/Folders/index.ts +++ b/packages/web-backend/src/Folders/index.ts @@ -1,6 +1,12 @@ 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 { revalidatePath } from "next/cache"; @@ -106,7 +112,13 @@ export class Folders extends Effect.Service()("Folders", { }), ); - return new Folder.Folder(folder); + return new Folder.Folder({ + ...folder, + organizationId: Organisation.OrganisationId.make( + user.activeOrganizationId, + ), + createdById: User.UserId.make(user.id), + }); }), /** diff --git a/packages/web-backend/src/Loom/ImportVideo.ts b/packages/web-backend/src/Loom/ImportVideo.ts index 0c5cf0891c..992ae1f2fd 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/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-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 f53646bf47..5519e86e70 100644 --- a/packages/web-domain/src/Folder.ts +++ b/packages/web-domain/src/Folder.ts @@ -4,6 +4,9 @@ import { Effect, Schema } from "effect"; import { RpcAuthMiddleware } from "./Authentication.ts"; import { InternalError } from "./Errors.ts"; import { PolicyDeniedError } from "./Policy.ts"; +import { OrganisationId } from "./Organisation.ts"; +import { UserId } from "./User.ts"; +import { SpaceId } from "./Space.ts"; export const FolderId = Schema.String.pipe(Schema.brand("FolderId")); export type FolderId = typeof FolderId.Type; @@ -35,8 +38,8 @@ 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), }) {} @@ -57,7 +60,7 @@ 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, diff --git a/packages/web-domain/src/Loom.ts b/packages/web-domain/src/Loom.ts index 8f1c0776ae..18de4cd2d3 100644 --- a/packages/web-domain/src/Loom.ts +++ b/packages/web-domain/src/Loom.ts @@ -2,6 +2,8 @@ import { Workflow } from "@effect/workflow"; import { Schema } from "effect"; import * as Video from "./Video.ts"; +import { UserId } from "./User.ts"; +import { OrganisationId } from "./Organisation.ts"; class LoomApiError extends Schema.TaggedError("LoomApiError")( "LoomApiError", @@ -19,12 +21,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 018c8984a7..c3edb7bd91 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -4,6 +4,8 @@ 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 { UserId } from "./User.ts"; import { PolicyDeniedError } from "./Policy.ts"; import { S3BucketId } from "./S3Bucket.ts"; @@ -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