Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 0 additions & 73 deletions apps/web/actions/folders/updateFolder.ts

This file was deleted.

45 changes: 24 additions & 21 deletions apps/web/app/(org)/dashboard/caps/components/Folder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
});
Comment thread
oscartbeaumont marked this conversation as resolved.

useEffect(() => {
if (isRenaming && nameRef.current) {
nameRef.current.focus();
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
Expand Down Expand Up @@ -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(),
});
}
}}
Comment thread
oscartbeaumont marked this conversation as resolved.
className="w-full resize-none bg-transparent border-none focus:outline-none
Expand All @@ -364,7 +367,7 @@ const FolderCard = ({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsRenaming(true);
setIsRenaming(false);
}}
>
<p className="text-[15px] truncate text-gray-12 w-full max-w-[116px] m-0 p-0 h-[22px] leading-[22px] font-normal tracking-normal">
Expand Down
74 changes: 74 additions & 0 deletions packages/web-backend/src/Folders/FoldersRepo.ts
Original file line number Diff line number Diff line change
@@ -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<Schema.Type<typeof Folder.Folder>, "id">;

export class FoldersRepo extends Effect.Service<FoldersRepo>()("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<CreateFolderInput>) =>
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(),
Comment thread
oscartbeaumont marked this conversation as resolved.
})
.where(Dz.eq(Db.folders.id, id)),
);

return yield* getById(id);
});

return { getById, delete: delete_, create, update };
}),
dependencies: [Database.Default],
}) {}
9 changes: 9 additions & 0 deletions packages/web-backend/src/Folders/FoldersRpcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { Folders } from "./index.ts";

export const FolderRpcsLive = Folder.FolderRpcs.toLayer(
Effect.gen(function* () {

Check failure on line 7 in packages/web-backend/src/Folders/FoldersRpcs.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Argument of type 'Effect<{ FolderDelete: (folderId: string & Brand<"FolderId">) => Effect<undefined, InternalError | PolicyDeniedError | NotFoundError, CurrentUser | Database>; FolderCreate: (data: { ...; }) => Effect<...>; FolderUpdate: (data: FolderUpdate) => Effect<...>; }, never, Folders>' is not assignable to parameter of type 'HandlersFrom<Rpc<"FolderDelete", brand<typeof String$, "FolderId">, typeof Void, Union<[typeof NotFoundError, typeof InternalError, typeof PolicyDeniedError]>, typeof RpcAuthMiddleware> | Rpc<...> | Rpc<...>> | Effect<...>'.
const folders = yield* Folders;

return {
Expand All @@ -26,6 +26,15 @@
() => new InternalError({ type: "database" }),
),
),
FolderUpdate: (data) =>
folders
.update(data.id, data)
.pipe(
Effect.catchTag(
"DatabaseError",
() => new InternalError({ type: "database" }),
),
),
};
}),
);
71 changes: 71 additions & 0 deletions packages/web-backend/src/Folders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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>()("Folders", {
Expand Down Expand Up @@ -107,6 +108,7 @@

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
Expand All @@ -122,6 +124,75 @@

yield* deleteFolder(folder);
}),

update: Effect.fn("Folders.update")(function* (
folderId: Folder.FolderId,
data: Folder.FolderUpdate,
) {
const user = yield* CurrentUser;

const [folder] = yield* db
Comment thread
oscartbeaumont marked this conversation as resolved.
Outdated
.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 (data.parentId) {
// Check that we're not creating a circular reference
if (data.parentId === folderId)
return yield* new Folder.RecursiveDefinitionError();

const [parentFolder] = yield* db.execute((db) =>
Comment thread
oscartbeaumont marked this conversation as resolved.
Outdated
db
.select()
.from(Db.folders)
.where(
Dz.and(
Dz.eq(Db.folders.id, data.parentId),

Check failure on line 153 in packages/web-backend/src/Folders/index.ts

View workflow job for this annotation

GitHub Actions / Typecheck

No overload matches this call.
Dz.eq(Db.folders.organizationId, user.activeOrganizationId),
),
),
);

if (!parentFolder) return yield* new Folder.NotFoundError();
Comment thread
oscartbeaumont marked this conversation as resolved.
Outdated

// 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)),

Check failure on line 172 in packages/web-backend/src/Folders/index.ts

View workflow job for this annotation

GitHub Actions / Typecheck

No overload matches this call.
);

if (!nextParent) break;
currentParentId = nextParent.parentId;
}
}

yield* db
Comment thread
oscartbeaumont marked this conversation as resolved.
Outdated
.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)),
)
.pipe(Policy.withPolicy(policy.canEdit(folderId)));

revalidatePath(`/dashboard/caps`);
revalidatePath(`/dashboard/folder/${folderId}`);
}),
};
}),
dependencies: [FoldersPolicy.Default, Database.Default],
Expand Down
24 changes: 23 additions & 1 deletion packages/web-domain/src/Folder.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +16,12 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()(
{},
) {}

// A folder can't be declared within itself.
export class RecursiveDefinitionError extends Schema.TaggedError<RecursiveDefinitionError>()(
"RecursiveDefinitionError",
{},
) {}

export class Folder extends Schema.Class<Folder>("Folder")({
id: FolderId,
name: Schema.String,
Expand All @@ -24,6 +30,18 @@ export class Folder extends Schema.Class<Folder>("Folder")({
createdById: Schema.String,
spaceId: Schema.OptionFromNullOr(Schema.String),
parentId: Schema.OptionFromNullOr(FolderId),
Comment thread
Brendonovich marked this conversation as resolved.
}) {
static decodeSync = Schema.decodeSync(Folder);

static toJS = (self: Folder) =>
Schema.encode(Folder)(self).pipe(Effect.orDie);
}

export class FolderUpdate extends Schema.Class<FolderUpdate>("FolderPatch")({
id: FolderId,
name: Schema.optional(Schema.String),
color: Schema.optional(FolderColor),
parentId: Schema.optional(FolderId),
}) {}

export class FolderRpcs extends RpcGroup.make(
Expand All @@ -41,4 +59,8 @@ export class FolderRpcs extends RpcGroup.make(
success: Folder,
error: Schema.Union(NotFoundError, InternalError),
}).middleware(RpcAuthMiddleware),
Rpc.make("FolderUpdate", {
payload: FolderUpdate,
error: Schema.Union(NotFoundError, RecursiveDefinitionError, InternalError),
}).middleware(RpcAuthMiddleware),
) {}
Loading