2025-10-18 19:32:05 +00:00
|
|
|
import type { Doc, Id } from "@fileone/convex/dataModel"
|
2025-09-16 22:36:26 +00:00
|
|
|
import type {
|
|
|
|
|
AuthenticatedMutationCtx,
|
|
|
|
|
AuthenticatedQueryCtx,
|
|
|
|
|
} from "../functions"
|
2025-10-16 21:43:23 +00:00
|
|
|
import { authorizedGet } from "../functions"
|
2025-11-08 18:03:10 +00:00
|
|
|
import type { ApplicationErrorData } from "../shared/error"
|
|
|
|
|
import { createErrorData, ErrorCode, error } from "../shared/error"
|
2025-09-28 15:45:49 +00:00
|
|
|
import {
|
|
|
|
|
type DirectoryHandle,
|
2025-10-05 15:01:55 +00:00
|
|
|
type DirectoryPath,
|
2025-09-28 15:45:49 +00:00
|
|
|
type FileSystemItem,
|
|
|
|
|
FileType,
|
|
|
|
|
newDirectoryHandle,
|
2025-10-18 14:02:20 +00:00
|
|
|
} from "../shared/filesystem"
|
2025-09-13 22:02:27 +01:00
|
|
|
|
2025-10-05 15:01:55 +00:00
|
|
|
export type DirectoryInfo = Doc<"directories"> & { path: DirectoryPath }
|
2025-09-20 23:23:28 +00:00
|
|
|
|
2025-09-19 23:01:44 +00:00
|
|
|
export async function fetchRoot(ctx: AuthenticatedQueryCtx) {
|
|
|
|
|
return await ctx.db
|
|
|
|
|
.query("directories")
|
|
|
|
|
.withIndex("byParentId", (q) =>
|
|
|
|
|
q.eq("userId", ctx.user._id).eq("parentId", undefined),
|
|
|
|
|
)
|
|
|
|
|
.first()
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 17:03:50 +00:00
|
|
|
export async function fetchHandle(
|
|
|
|
|
ctx: AuthenticatedQueryCtx,
|
|
|
|
|
handle: DirectoryHandle,
|
|
|
|
|
): Promise<Doc<"directories">> {
|
2025-10-16 21:43:23 +00:00
|
|
|
const directory = await authorizedGet(ctx, handle.id)
|
|
|
|
|
if (!directory) {
|
2025-11-08 18:03:10 +00:00
|
|
|
error({
|
|
|
|
|
code: ErrorCode.NotFound,
|
|
|
|
|
message: `Directory ${handle.id} not found`,
|
|
|
|
|
})
|
2025-09-21 17:03:50 +00:00
|
|
|
}
|
|
|
|
|
return directory
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-19 23:01:44 +00:00
|
|
|
export async function fetch(
|
|
|
|
|
ctx: AuthenticatedQueryCtx,
|
|
|
|
|
{ directoryId }: { directoryId: Id<"directories"> },
|
2025-09-20 23:23:28 +00:00
|
|
|
): Promise<DirectoryInfo> {
|
2025-10-16 21:43:23 +00:00
|
|
|
const directory = await authorizedGet(ctx, directoryId)
|
2025-09-20 23:23:28 +00:00
|
|
|
if (!directory) {
|
2025-11-08 18:03:10 +00:00
|
|
|
error({
|
|
|
|
|
code: ErrorCode.NotFound,
|
|
|
|
|
message: `Directory ${directoryId} not found`,
|
|
|
|
|
})
|
2025-09-20 23:23:28 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-05 15:01:55 +00:00
|
|
|
const path: DirectoryPath = [
|
2025-09-20 23:54:27 +00:00
|
|
|
{
|
|
|
|
|
handle: newDirectoryHandle(directoryId),
|
|
|
|
|
name: directory.name,
|
|
|
|
|
},
|
|
|
|
|
]
|
2025-09-20 23:23:28 +00:00
|
|
|
let parentDirId = directory.parentId
|
|
|
|
|
while (parentDirId) {
|
2025-10-16 21:43:23 +00:00
|
|
|
const parentDir = await authorizedGet(ctx, parentDirId)
|
2025-09-20 23:23:28 +00:00
|
|
|
if (parentDir) {
|
2025-09-20 23:54:27 +00:00
|
|
|
path.push({
|
|
|
|
|
handle: newDirectoryHandle(parentDir._id),
|
|
|
|
|
name: parentDir.name,
|
|
|
|
|
})
|
2025-09-20 23:23:28 +00:00
|
|
|
parentDirId = parentDir.parentId
|
|
|
|
|
} else {
|
2025-11-08 18:03:10 +00:00
|
|
|
error({
|
|
|
|
|
code: ErrorCode.NotFound,
|
|
|
|
|
message: "Parent directory not found",
|
|
|
|
|
})
|
2025-09-20 23:23:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-05 15:01:55 +00:00
|
|
|
return { ...directory, path: path.reverse() as DirectoryPath }
|
2025-09-19 23:01:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-09-13 22:02:27 +01:00
|
|
|
export async function fetchContent(
|
2025-09-16 22:36:26 +00:00
|
|
|
ctx: AuthenticatedQueryCtx,
|
2025-10-03 23:23:05 +00:00
|
|
|
{
|
|
|
|
|
directoryId,
|
|
|
|
|
trashed,
|
|
|
|
|
}: { directoryId?: Id<"directories">; trashed: boolean },
|
2025-09-28 15:45:49 +00:00
|
|
|
): Promise<FileSystemItem[]> {
|
2025-09-17 00:04:12 +00:00
|
|
|
let dirId: Id<"directories"> | undefined
|
2025-09-20 23:23:28 +00:00
|
|
|
if (directoryId) {
|
2025-09-17 00:04:12 +00:00
|
|
|
dirId = directoryId
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-13 22:02:27 +01:00
|
|
|
const [files, directories] = await Promise.all([
|
|
|
|
|
ctx.db
|
|
|
|
|
.query("files")
|
2025-10-03 23:23:05 +00:00
|
|
|
.withIndex("byDirectoryId", (q) => {
|
|
|
|
|
if (trashed) {
|
|
|
|
|
return q
|
|
|
|
|
.eq("userId", ctx.user._id)
|
|
|
|
|
.eq("directoryId", dirId)
|
|
|
|
|
.gte("deletedAt", 0)
|
|
|
|
|
}
|
|
|
|
|
return q
|
2025-09-16 22:36:26 +00:00
|
|
|
.eq("userId", ctx.user._id)
|
2025-09-17 00:04:12 +00:00
|
|
|
.eq("directoryId", dirId)
|
2025-10-03 23:23:05 +00:00
|
|
|
.eq("deletedAt", undefined)
|
|
|
|
|
})
|
2025-09-13 22:02:27 +01:00
|
|
|
.collect(),
|
|
|
|
|
ctx.db
|
|
|
|
|
.query("directories")
|
2025-10-03 23:23:05 +00:00
|
|
|
.withIndex("byParentId", (q) => {
|
|
|
|
|
if (trashed) {
|
|
|
|
|
return q
|
|
|
|
|
.eq("userId", ctx.user._id)
|
|
|
|
|
.eq("parentId", dirId)
|
|
|
|
|
.gte("deletedAt", 0)
|
|
|
|
|
}
|
|
|
|
|
return q
|
2025-09-16 22:36:26 +00:00
|
|
|
.eq("userId", ctx.user._id)
|
2025-09-17 00:04:12 +00:00
|
|
|
.eq("parentId", dirId)
|
2025-10-03 23:23:05 +00:00
|
|
|
.eq("deletedAt", undefined)
|
|
|
|
|
})
|
2025-09-13 22:02:27 +01:00
|
|
|
.collect(),
|
|
|
|
|
])
|
|
|
|
|
|
2025-09-28 15:45:49 +00:00
|
|
|
const items: FileSystemItem[] = []
|
2025-09-13 22:02:27 +01:00
|
|
|
for (const directory of directories) {
|
2025-09-28 15:45:49 +00:00
|
|
|
items.push({ kind: FileType.Directory, doc: directory })
|
2025-09-13 22:02:27 +01:00
|
|
|
}
|
|
|
|
|
for (const file of files) {
|
2025-09-28 15:45:49 +00:00
|
|
|
items.push({ kind: FileType.File, doc: file })
|
2025-09-13 22:02:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function create(
|
2025-09-16 22:36:26 +00:00
|
|
|
ctx: AuthenticatedMutationCtx,
|
2025-09-19 23:01:44 +00:00
|
|
|
{ name, parentId }: { name: string; parentId: Id<"directories"> },
|
2025-09-13 22:02:27 +01:00
|
|
|
): Promise<Id<"directories">> {
|
2025-10-16 21:43:23 +00:00
|
|
|
const parentDir = await authorizedGet(ctx, parentId)
|
2025-09-19 23:01:44 +00:00
|
|
|
if (!parentDir) {
|
2025-11-08 18:03:10 +00:00
|
|
|
error({
|
|
|
|
|
code: ErrorCode.NotFound,
|
|
|
|
|
message: `Parent directory ${parentId} not found`,
|
|
|
|
|
})
|
2025-09-15 21:44:41 +00:00
|
|
|
}
|
|
|
|
|
|
2025-09-14 18:12:29 +00:00
|
|
|
const existing = await ctx.db
|
|
|
|
|
.query("directories")
|
|
|
|
|
.withIndex("uniqueDirectoryInDirectory", (q) =>
|
2025-09-16 22:36:26 +00:00
|
|
|
q
|
|
|
|
|
.eq("userId", ctx.user._id)
|
|
|
|
|
.eq("parentId", parentId)
|
|
|
|
|
.eq("name", name)
|
|
|
|
|
.eq("deletedAt", undefined),
|
2025-09-14 18:12:29 +00:00
|
|
|
)
|
|
|
|
|
.first()
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
2025-11-08 18:03:10 +00:00
|
|
|
error({
|
|
|
|
|
code: ErrorCode.DirectoryExists,
|
|
|
|
|
message: `Directory with name ${name} already exists in ${parentId ? `directory ${parentId}` : "root"}`,
|
|
|
|
|
})
|
2025-09-14 18:12:29 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-03 21:23:51 +00:00
|
|
|
const now = Date.now()
|
2025-09-13 22:02:27 +01:00
|
|
|
return await ctx.db.insert("directories", {
|
|
|
|
|
name,
|
|
|
|
|
parentId,
|
2025-09-16 22:36:26 +00:00
|
|
|
userId: ctx.user._id,
|
2025-09-13 22:02:27 +01:00
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-09-14 18:12:29 +00:00
|
|
|
|
2025-09-21 17:03:50 +00:00
|
|
|
export async function move(
|
|
|
|
|
ctx: AuthenticatedMutationCtx,
|
|
|
|
|
{
|
|
|
|
|
targetDirectory,
|
|
|
|
|
sourceDirectories,
|
|
|
|
|
}: {
|
|
|
|
|
targetDirectory: DirectoryHandle
|
|
|
|
|
sourceDirectories: DirectoryHandle[]
|
|
|
|
|
},
|
2025-09-25 23:12:13 +00:00
|
|
|
) {
|
2025-09-21 22:24:24 +00:00
|
|
|
const conflictCheckResults = await Promise.allSettled(
|
|
|
|
|
sourceDirectories.map((directory) =>
|
2025-10-16 21:43:23 +00:00
|
|
|
authorizedGet(ctx, directory.id).then((d) => {
|
2025-09-21 22:24:24 +00:00
|
|
|
if (!d) {
|
2025-11-08 18:03:10 +00:00
|
|
|
error({
|
|
|
|
|
code: ErrorCode.NotFound,
|
|
|
|
|
message: `Directory ${directory.id} not found`,
|
|
|
|
|
})
|
2025-09-21 22:24:24 +00:00
|
|
|
}
|
|
|
|
|
return ctx.db
|
|
|
|
|
.query("directories")
|
|
|
|
|
.withIndex("uniqueDirectoryInDirectory", (q) =>
|
|
|
|
|
q
|
|
|
|
|
.eq("userId", ctx.user._id)
|
|
|
|
|
.eq("parentId", targetDirectory.id)
|
|
|
|
|
.eq("name", d.name)
|
|
|
|
|
.eq("deletedAt", undefined),
|
|
|
|
|
)
|
|
|
|
|
.first()
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-08 18:03:10 +00:00
|
|
|
const errors: ApplicationErrorData[] = []
|
2025-09-25 23:12:13 +00:00
|
|
|
const okDirectories: DirectoryHandle[] = []
|
|
|
|
|
conflictCheckResults.forEach((result, i) => {
|
|
|
|
|
if (result.status === "fulfilled") {
|
|
|
|
|
if (result.value) {
|
|
|
|
|
errors.push(
|
2025-11-08 18:03:10 +00:00
|
|
|
createErrorData(
|
|
|
|
|
ErrorCode.Conflict,
|
2025-09-25 23:12:13 +00:00
|
|
|
`Directory ${targetDirectory.id} already contains a directory with name ${result.value.name}`,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2025-09-28 15:45:49 +00:00
|
|
|
okDirectories.push(sourceDirectories[i]!)
|
2025-09-25 23:12:13 +00:00
|
|
|
}
|
2025-09-21 22:24:24 +00:00
|
|
|
} else if (result.status === "rejected") {
|
2025-11-08 18:03:10 +00:00
|
|
|
errors.push(createErrorData(ErrorCode.Internal))
|
2025-09-21 22:24:24 +00:00
|
|
|
}
|
2025-09-25 23:12:13 +00:00
|
|
|
})
|
2025-09-21 22:24:24 +00:00
|
|
|
|
2025-09-26 22:20:30 +00:00
|
|
|
const ignoredHandles = new Set<DirectoryHandle>()
|
|
|
|
|
|
|
|
|
|
const promises: Promise<void>[] = []
|
|
|
|
|
for (const handle of okDirectories) {
|
|
|
|
|
if (handle.id === targetDirectory.id) {
|
|
|
|
|
// if the directory that needs to be moved is the same as the dest directory
|
|
|
|
|
// it is silently ignored
|
|
|
|
|
ignoredHandles.add(handle)
|
|
|
|
|
} else {
|
|
|
|
|
promises.push(
|
2025-10-03 23:23:05 +00:00
|
|
|
ctx.db.patch(handle.id, {
|
|
|
|
|
parentId: targetDirectory.id,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
}),
|
2025-09-26 22:20:30 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results = await Promise.allSettled(promises)
|
2025-09-25 23:12:13 +00:00
|
|
|
|
|
|
|
|
for (const updateResult of results) {
|
|
|
|
|
if (updateResult.status === "rejected") {
|
2025-11-08 18:03:10 +00:00
|
|
|
errors.push(createErrorData(ErrorCode.Internal))
|
2025-09-25 23:12:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 22:20:30 +00:00
|
|
|
return {
|
|
|
|
|
moved: okDirectories.filter((handle) => !ignoredHandles.has(handle)),
|
|
|
|
|
errors,
|
|
|
|
|
}
|
2025-09-21 17:03:50 +00:00
|
|
|
}
|
|
|
|
|
|
2025-09-14 18:12:29 +00:00
|
|
|
export async function moveToTrashRecursive(
|
2025-09-16 22:36:26 +00:00
|
|
|
ctx: AuthenticatedMutationCtx,
|
2025-09-28 15:45:49 +00:00
|
|
|
handle: DirectoryHandle,
|
2025-09-14 18:12:29 +00:00
|
|
|
): Promise<void> {
|
2025-10-03 21:23:51 +00:00
|
|
|
const now = Date.now()
|
2025-09-14 18:12:29 +00:00
|
|
|
|
|
|
|
|
const filesToDelete: Id<"files">[] = []
|
|
|
|
|
const directoriesToDelete: Id<"directories">[] = []
|
|
|
|
|
|
2025-09-28 15:45:49 +00:00
|
|
|
const directoryQueue: Id<"directories">[] = [handle.id]
|
2025-09-14 18:12:29 +00:00
|
|
|
|
|
|
|
|
while (directoryQueue.length > 0) {
|
|
|
|
|
const currentDirectoryId = directoryQueue.shift()!
|
|
|
|
|
directoriesToDelete.push(currentDirectoryId)
|
|
|
|
|
|
|
|
|
|
const files = await ctx.db
|
|
|
|
|
.query("files")
|
|
|
|
|
.withIndex("byDirectoryId", (q) =>
|
|
|
|
|
q
|
2025-09-16 22:36:26 +00:00
|
|
|
.eq("userId", ctx.user._id)
|
2025-09-14 18:12:29 +00:00
|
|
|
.eq("directoryId", currentDirectoryId)
|
|
|
|
|
.eq("deletedAt", undefined),
|
|
|
|
|
)
|
|
|
|
|
.collect()
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
filesToDelete.push(file._id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const subdirectories = await ctx.db
|
|
|
|
|
.query("directories")
|
|
|
|
|
.withIndex("byParentId", (q) =>
|
2025-09-16 22:36:26 +00:00
|
|
|
q
|
|
|
|
|
.eq("userId", ctx.user._id)
|
|
|
|
|
.eq("parentId", currentDirectoryId)
|
|
|
|
|
.eq("deletedAt", undefined),
|
2025-09-14 18:12:29 +00:00
|
|
|
)
|
|
|
|
|
.collect()
|
|
|
|
|
|
|
|
|
|
for (const subdirectory of subdirectories) {
|
|
|
|
|
directoryQueue.push(subdirectory._id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filePatches = filesToDelete.map((fileId) =>
|
|
|
|
|
ctx.db.patch(fileId, { deletedAt: now }),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const directoryPatches = directoriesToDelete.map((dirId) =>
|
|
|
|
|
ctx.db.patch(dirId, { deletedAt: now }),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await Promise.all([...filePatches, ...directoryPatches])
|
|
|
|
|
}
|
2025-10-05 00:41:59 +00:00
|
|
|
|
|
|
|
|
export async function deletePermanently(
|
|
|
|
|
ctx: AuthenticatedMutationCtx,
|
|
|
|
|
{
|
|
|
|
|
items,
|
|
|
|
|
}: {
|
|
|
|
|
items: DirectoryHandle[]
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const itemsToBeDeleted = await Promise.allSettled(
|
|
|
|
|
items.map((item) => ctx.db.get(item.id)),
|
|
|
|
|
).then((results) =>
|
|
|
|
|
results.filter(
|
|
|
|
|
(result): result is PromiseFulfilledResult<Doc<"directories">> =>
|
|
|
|
|
result.status === "fulfilled" && result.value !== null,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const deleteDirectoryPromises = itemsToBeDeleted.map((item) =>
|
|
|
|
|
ctx.db.delete(item.value._id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const deleteResults = await Promise.allSettled(deleteDirectoryPromises)
|
|
|
|
|
|
2025-11-08 18:03:10 +00:00
|
|
|
const errors: ApplicationErrorData[] = []
|
2025-10-05 00:41:59 +00:00
|
|
|
let successfulDeletions = 0
|
|
|
|
|
for (const result of deleteResults) {
|
|
|
|
|
if (result.status === "rejected") {
|
2025-11-08 18:03:10 +00:00
|
|
|
errors.push(createErrorData(ErrorCode.Internal))
|
2025-10-05 00:41:59 +00:00
|
|
|
} else {
|
|
|
|
|
successfulDeletions += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { deleted: successfulDeletions, errors }
|
|
|
|
|
}
|
2025-10-05 14:29:45 +00:00
|
|
|
|
|
|
|
|
export async function restore(
|
|
|
|
|
ctx: AuthenticatedMutationCtx,
|
|
|
|
|
{
|
|
|
|
|
items,
|
|
|
|
|
}: {
|
|
|
|
|
items: DirectoryHandle[]
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const itemsToBeRestored = await Promise.allSettled(
|
|
|
|
|
items.map((item) => ctx.db.get(item.id)),
|
|
|
|
|
).then((results) =>
|
|
|
|
|
results.filter(
|
|
|
|
|
(result): result is PromiseFulfilledResult<Doc<"directories">> =>
|
|
|
|
|
result.status === "fulfilled" && result.value !== null,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const restoreDirectoryPromises = itemsToBeRestored.map((item) =>
|
|
|
|
|
ctx.db.patch(item.value._id, {
|
|
|
|
|
deletedAt: undefined,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const restoreResults = await Promise.allSettled(restoreDirectoryPromises)
|
|
|
|
|
|
2025-11-08 18:03:10 +00:00
|
|
|
const errors: ApplicationErrorData[] = []
|
2025-10-05 14:29:45 +00:00
|
|
|
let successfulRestorations = 0
|
|
|
|
|
for (const result of restoreResults) {
|
|
|
|
|
if (result.status === "rejected") {
|
2025-11-08 18:03:10 +00:00
|
|
|
errors.push(createErrorData(ErrorCode.Internal))
|
2025-10-05 14:29:45 +00:00
|
|
|
} else {
|
|
|
|
|
successfulRestorations += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { restored: successfulRestorations, errors }
|
|
|
|
|
}
|