Folder
This commit is contained in:
parent
f32a0a19a7
commit
4347adedd9
10 changed files with 1949 additions and 401 deletions
|
|
@ -5,17 +5,26 @@ import {
|
|||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import type { TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { sdkStreamMixin } from "@smithy/util-stream";
|
||||
import { mockClient } from "aws-sdk-client-mock";
|
||||
import { testClient } from "hono/testing";
|
||||
import { Readable } from "stream";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createConfig } from "../../config.js";
|
||||
import { MiddlewareManager } from "../../middlewares/middleware.js";
|
||||
import { getMainRouter } from "../../routers/index.js";
|
||||
import type { TestUserData } from "../helpers/dbSetup.js";
|
||||
import { getTestUser } from "../helpers/dbSetup.js";
|
||||
|
||||
// Helper to create S3 stream from content
|
||||
const createS3Stream = (content: string) => {
|
||||
const stream = new Readable();
|
||||
stream.push(content);
|
||||
stream.push(null);
|
||||
return sdkStreamMixin(stream);
|
||||
};
|
||||
|
||||
// Create S3 mock
|
||||
const s3Mock = mockClient(S3Client);
|
||||
|
||||
|
|
@ -43,6 +52,9 @@ describe("TabloData Endpoint", () => {
|
|||
return res;
|
||||
};
|
||||
|
||||
// Store folder metadata state for tests
|
||||
let mockFolderMetadata: TabloFoldersMetadata = { folders: [], version: 1 };
|
||||
|
||||
beforeAll(() => {
|
||||
// Reset mocks before all tests
|
||||
s3Mock.reset();
|
||||
|
|
@ -56,26 +68,48 @@ describe("TabloData Endpoint", () => {
|
|||
],
|
||||
});
|
||||
|
||||
// Mock GetObjectCommand (used by getTabloFile)
|
||||
// Create a proper SDK stream from Readable
|
||||
const stream = new Readable();
|
||||
stream.push("test file content");
|
||||
stream.push(null);
|
||||
const sdkStream = sdkStreamMixin(stream);
|
||||
|
||||
s3Mock.on(GetObjectCommand).resolves({
|
||||
Body: sdkStream,
|
||||
ContentType: "text/plain",
|
||||
LastModified: new Date("2025-11-12"),
|
||||
// Mock GetObjectCommand (used by getTabloFile and getFolderMetadata)
|
||||
s3Mock.on(GetObjectCommand).callsFake((input) => {
|
||||
// Handle folder metadata requests
|
||||
if (input.Key?.endsWith(".tablo-folders.json")) {
|
||||
if (mockFolderMetadata.folders.length === 0 && mockFolderMetadata.version === 1) {
|
||||
// Return empty metadata (simulates file not found)
|
||||
return Promise.resolve({
|
||||
Body: createS3Stream(JSON.stringify(mockFolderMetadata)),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
Body: createS3Stream(JSON.stringify(mockFolderMetadata)),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
}
|
||||
// Handle regular file requests
|
||||
return Promise.resolve({
|
||||
Body: createS3Stream("test file content"),
|
||||
ContentType: "text/plain",
|
||||
LastModified: new Date("2025-11-12"),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock PutObjectCommand (used by postTabloFile)
|
||||
s3Mock.on(PutObjectCommand).resolves({});
|
||||
// Mock PutObjectCommand (used by postTabloFile and saveFolderMetadata)
|
||||
s3Mock.on(PutObjectCommand).callsFake((input) => {
|
||||
// Handle folder metadata saves
|
||||
if (input.Key?.endsWith(".tablo-folders.json") && input.Body) {
|
||||
mockFolderMetadata = JSON.parse(input.Body as string);
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
// Mock DeleteObjectCommand (used by deleteTabloFile)
|
||||
s3Mock.on(DeleteObjectCommand).resolves({});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset folder metadata before each test
|
||||
mockFolderMetadata = { folders: [], version: 1 };
|
||||
});
|
||||
|
||||
describe("GET /tablo-data/:tabloId/filenames - Owner Access", () => {
|
||||
it("should allow owner to access their private tablo", async () => {
|
||||
const res = await getTabloFileNamesResponse(ownerUser, client, "test_tablo_owner_private");
|
||||
|
|
@ -491,4 +525,632 @@ describe("TabloData Endpoint", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// FOLDER ENDPOINT TESTS
|
||||
// ============================================
|
||||
|
||||
describe("GET /tablo-data/:tabloId/folders - Get Folders (Member Access)", () => {
|
||||
// Helper function to get folders
|
||||
const getFoldersRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"].$get(
|
||||
{ param: { tabloId } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
describe("Owner Access", () => {
|
||||
it("should allow owner to get folders from their own tablo", async () => {
|
||||
const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_private");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folders).toEqual([]);
|
||||
expect(data.version).toBe(1);
|
||||
});
|
||||
|
||||
it("should allow owner to get folders from their shared tablo", async () => {
|
||||
const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_shared");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User Access", () => {
|
||||
it("should allow temp user to get folders from owner's shared tablo", async () => {
|
||||
const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_shared");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny temp user access to owner's private tablo folders", async () => {
|
||||
const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_private");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated access to folders", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$get({
|
||||
param: { tabloId: "test_tablo_owner_private" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tablo-data/:tabloId/folders - Create Folder (Admin Only)", () => {
|
||||
// Helper function to create folder
|
||||
const createFolderRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
name: string,
|
||||
description?: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId }, json: { name, description } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
describe("Owner with Admin Access", () => {
|
||||
it("should allow owner to create folder in their own tablo", async () => {
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"My Folder",
|
||||
"Test description"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("Folder created successfully");
|
||||
expect(data.folder.name).toBe("My Folder");
|
||||
expect(data.folder.description).toBe("Test description");
|
||||
expect(data.folder.id).toMatch(/^folder-/);
|
||||
});
|
||||
|
||||
it("should allow owner to create folder without description", async () => {
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"Folder Without Description"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folder.name).toBe("Folder Without Description");
|
||||
expect(data.folder.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 400 if folder name is missing", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId: "test_tablo_owner_private" }, json: {} },
|
||||
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder name is required");
|
||||
});
|
||||
|
||||
it("should return 400 if folder name is empty string", async () => {
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
" "
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder name is required");
|
||||
});
|
||||
|
||||
it("should return 400 for duplicate folder names", async () => {
|
||||
// Create first folder
|
||||
await createFolderRequest(ownerUser, client, "test_tablo_owner_private", "Duplicate Name");
|
||||
|
||||
// Try to create folder with same name
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"duplicate name" // Case insensitive check
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("A folder with this name already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User - Blocked by regularUserCheck", () => {
|
||||
it("should deny temp user from creating folder (regularUserCheck)", async () => {
|
||||
const res = await createFolderRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"Temp Folder"
|
||||
);
|
||||
|
||||
// Temporary users are blocked by regularUserCheck middleware
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated folder creation", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post({
|
||||
param: { tabloId: "test_tablo_owner_private" },
|
||||
json: { name: "Test Folder" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /tablo-data/:tabloId/folders/:folderId - Update Folder (Admin Only)", () => {
|
||||
// Helper function to update folder
|
||||
const updateFolderRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
folderId: string,
|
||||
name?: string,
|
||||
description?: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put(
|
||||
{ param: { tabloId, folderId }, json: { name, description } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create a folder and return its ID
|
||||
const createTestFolder = async (tabloId: string, name: string): Promise<string> => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId }, json: { name } },
|
||||
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
return data.folder.id;
|
||||
};
|
||||
|
||||
describe("Owner with Admin Access", () => {
|
||||
it("should allow owner to update folder name", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "Original Name");
|
||||
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
"Updated Name"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("Folder updated successfully");
|
||||
expect(data.folder.name).toBe("Updated Name");
|
||||
});
|
||||
|
||||
it("should allow owner to update folder description", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder");
|
||||
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
undefined,
|
||||
"New description"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folder.description).toBe("New description");
|
||||
});
|
||||
|
||||
it("should allow owner to clear folder description", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder");
|
||||
|
||||
// First set a description
|
||||
await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
undefined,
|
||||
"Some description"
|
||||
);
|
||||
|
||||
// Then clear it
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId,
|
||||
undefined,
|
||||
""
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.folder.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 404 for non-existent folder", async () => {
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"non-existent-folder-id",
|
||||
"New Name"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder not found");
|
||||
});
|
||||
|
||||
it("should return 400 for duplicate folder names on update", async () => {
|
||||
const folderId1 = await createTestFolder("test_tablo_owner_private", "Folder One");
|
||||
await createTestFolder("test_tablo_owner_private", "Folder Two");
|
||||
|
||||
// Try to rename folder 1 to folder 2's name
|
||||
const res = await updateFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId1,
|
||||
"Folder Two"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("A folder with this name already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User - Blocked by regularUserCheck", () => {
|
||||
it("should deny temp user from updating folder (regularUserCheck)", async () => {
|
||||
const res = await updateFolderRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"some-folder-id",
|
||||
"New Name"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated folder update", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put({
|
||||
param: { tabloId: "test_tablo_owner_private", folderId: "some-id" },
|
||||
json: { name: "New Name" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /tablo-data/:tabloId/folders/:folderId - Delete Folder (Admin Only)", () => {
|
||||
// Helper function to delete folder
|
||||
const deleteFolderRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
folderId: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete(
|
||||
{ param: { tabloId, folderId } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to create a folder and return its ID
|
||||
const createTestFolder = async (tabloId: string, name: string): Promise<string> => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"].$post(
|
||||
{ param: { tabloId }, json: { name } },
|
||||
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
|
||||
);
|
||||
const data = await res.json();
|
||||
return data.folder.id;
|
||||
};
|
||||
|
||||
describe("Owner with Admin Access", () => {
|
||||
it("should allow owner to delete folder from their own tablo", async () => {
|
||||
const folderId = await createTestFolder("test_tablo_owner_private", "To Delete");
|
||||
|
||||
const res = await deleteFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
folderId
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("Folder deleted successfully");
|
||||
expect(data.folder.name).toBe("To Delete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 404 for non-existent folder", async () => {
|
||||
const res = await deleteFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"non-existent-folder-id"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Folder not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Temp User - Blocked by regularUserCheck", () => {
|
||||
it("should deny temp user from deleting folder (regularUserCheck)", async () => {
|
||||
const res = await deleteFolderRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"some-folder-id"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unauthenticated Access", () => {
|
||||
it("should deny unauthenticated folder deletion", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete({
|
||||
param: { tabloId: "test_tablo_owner_private", folderId: "some-id" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// NESTED FILE PATH TESTS (/:tabloId/file/:path)
|
||||
// ============================================
|
||||
|
||||
describe("Nested File Path Routes (/file/:path)", () => {
|
||||
// Helper function to get file with nested path
|
||||
const getNestedFileRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
filePath: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["file"][":path"].$get(
|
||||
{ param: { tabloId, path: filePath } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to post file with nested path
|
||||
const postNestedFileRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
filePath: string,
|
||||
content: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["file"][":path"].$post(
|
||||
{ param: { tabloId, path: filePath }, json: { content, contentType: "text/plain" } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to delete file with nested path
|
||||
const deleteNestedFileRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
filePath: string
|
||||
) => {
|
||||
return await client["tablo-data"][":tabloId"]["file"][":path"].$delete(
|
||||
{ param: { tabloId, path: filePath } },
|
||||
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
|
||||
);
|
||||
};
|
||||
|
||||
describe("GET /tablo-data/:tabloId/file/:path - Get File with Nested Path", () => {
|
||||
it("should allow owner to get file with nested path", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/document.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.fileName).toBe("folder-123/document.pdf");
|
||||
expect(data.content).toBe("test file content");
|
||||
});
|
||||
|
||||
it("should allow owner to get file with deeply nested path", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/subfolder/deep/file.txt"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.fileName).toBe("folder-123/subfolder/deep/file.txt");
|
||||
});
|
||||
|
||||
it("should allow member to get nested file from shared tablo", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_shared",
|
||||
"folder-456/report.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny access to nested file in private tablo", async () => {
|
||||
const res = await getNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/secret.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should deny unauthenticated access to nested file", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$get({
|
||||
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tablo-data/:tabloId/file/:path - Upload File with Nested Path", () => {
|
||||
it("should allow owner to upload file with nested path", async () => {
|
||||
const res = await postNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/new-document.pdf",
|
||||
"Document content"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("File uploaded successfully");
|
||||
expect(data.fileName).toBe("folder-123/new-document.pdf");
|
||||
});
|
||||
|
||||
it("should allow member to upload file with nested path to shared tablo", async () => {
|
||||
const res = await postNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_shared",
|
||||
"folder-789/upload.txt",
|
||||
"Uploaded content"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny member from uploading nested file to private tablo", async () => {
|
||||
const res = await postNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/unauthorized.txt",
|
||||
"Should fail"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should deny unauthenticated nested file upload", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$post({
|
||||
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
|
||||
json: { content: "test" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin Only)", () => {
|
||||
it("should allow admin to delete file with nested path", async () => {
|
||||
const res = await deleteNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/to-delete.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toBe("File deleted successfully");
|
||||
expect(data.fileName).toBe("folder-123/to-delete.pdf");
|
||||
});
|
||||
|
||||
it("should allow admin to delete deeply nested file", async () => {
|
||||
const res = await deleteNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
"folder-123/level2/level3/deep-file.txt"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should deny temp user from deleting nested file (regularUserCheck)", async () => {
|
||||
const res = await deleteNestedFileRequest(
|
||||
temporaryUser,
|
||||
client,
|
||||
"test_tablo_temp_private",
|
||||
"folder-123/file.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should deny non-admin from deleting nested file", async () => {
|
||||
// Owner has admin access to test_tablo_temp_shared_admin
|
||||
// BUT regularUserCheck blocks access to tablos owned by temporary users
|
||||
const res = await deleteNestedFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_temp_shared_admin",
|
||||
"folder/file.pdf"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should deny unauthenticated nested file deletion", async () => {
|
||||
const res = await client["tablo-data"][":tabloId"]["file"][":path"].$delete({
|
||||
param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,11 +116,13 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) =>
|
|||
const { Contents } = await s3_client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
Prefix: tabloId,
|
||||
Prefix: `${tabloId}/`,
|
||||
})
|
||||
);
|
||||
|
||||
return Contents?.map((content) => content.Key?.split("/")[1]).filter(
|
||||
// Return full file paths relative to tabloId (e.g., "file.pdf" or "folder-123/file.pdf")
|
||||
const prefix = `${tabloId}/`;
|
||||
return Contents?.map((content) => content.Key?.substring(prefix.length)).filter(
|
||||
(content) => content?.length && content.length > 0
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async function startServer(secrets: Secrets) {
|
|||
"Access-Control-Allow-Credentials",
|
||||
"Access-Control-Expose-Headers",
|
||||
],
|
||||
allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "OPTIONS", "DELETE"],
|
||||
exposeHeaders: ["set-cookie"],
|
||||
credentials: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "../helpers/helpers.js";
|
||||
|
|
@ -7,6 +13,16 @@ import type { AuthEnv } from "../types/app.types.js";
|
|||
|
||||
const factory = createFactory<AuthEnv>();
|
||||
|
||||
// Metadata file name for folders
|
||||
const FOLDERS_METADATA_FILE = ".tablo-folders.json";
|
||||
|
||||
// Helper to generate unique folder IDs
|
||||
const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// ============================================
|
||||
// FILE ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
|
@ -22,17 +38,20 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) =>
|
|||
|
||||
const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const fileName = c.req.param("fileName");
|
||||
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
|
||||
const filePath = c.req.param("path") || c.req.param("fileName");
|
||||
|
||||
if (!filePath) {
|
||||
return c.json({ error: "File path is required" }, 400);
|
||||
}
|
||||
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
const response = await s3_client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${fileName}`,
|
||||
Key: `${tabloId}/${filePath}`,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -43,7 +62,7 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
const content = await response.Body.transformToString();
|
||||
|
||||
return c.json({
|
||||
fileName,
|
||||
fileName: filePath,
|
||||
content,
|
||||
contentType: response.ContentType,
|
||||
lastModified: response.LastModified,
|
||||
|
|
@ -56,7 +75,12 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
|
||||
const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const fileName = c.req.param("fileName");
|
||||
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
|
||||
const filePath = c.req.param("path") || c.req.param("fileName");
|
||||
|
||||
if (!filePath) {
|
||||
return c.json({ error: "File path is required" }, 400);
|
||||
}
|
||||
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
|
|
@ -71,7 +95,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
await s3_client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${fileName}`,
|
||||
Key: `${tabloId}/${filePath}`,
|
||||
Body: content,
|
||||
ContentType: contentType,
|
||||
})
|
||||
|
|
@ -79,7 +103,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
|
||||
return c.json({
|
||||
message: "File uploaded successfully",
|
||||
fileName,
|
||||
fileName: filePath,
|
||||
tabloId,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -88,61 +112,29 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// // PUT /tablo-data/:tabloId/:fileName - Update a file
|
||||
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
|
||||
// const tabloId = c.req.param("tabloId");
|
||||
// const fileName = c.req.param("fileName");
|
||||
// const s3_client = c.get("s3_client");
|
||||
|
||||
// try {
|
||||
// const body = await c.req.json();
|
||||
// const { content, contentType = "text/plain" } = body;
|
||||
|
||||
// if (!content) {
|
||||
// return c.json({ error: "Content is required" }, 400);
|
||||
// }
|
||||
|
||||
// const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
// await s3_client.send(
|
||||
// new PutObjectCommand({
|
||||
// Bucket: "tablo-data",
|
||||
// Key: `${tabloId}/${fileName}`,
|
||||
// Body: content,
|
||||
// ContentType: contentType,
|
||||
// })
|
||||
// );
|
||||
|
||||
// return c.json({
|
||||
// message: "File updated successfully",
|
||||
// fileName,
|
||||
// tabloId,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error updating file:", error);
|
||||
// return c.json({ error: "Failed to update file" }, 500);
|
||||
// }
|
||||
// });
|
||||
|
||||
const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const fileName = c.req.param("fileName");
|
||||
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
|
||||
const filePath = c.req.param("path") || c.req.param("fileName");
|
||||
|
||||
if (!filePath) {
|
||||
return c.json({ error: "File path is required" }, 400);
|
||||
}
|
||||
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
|
||||
await s3_client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${fileName}`,
|
||||
Key: `${tabloId}/${filePath}`,
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({
|
||||
message: "File deleted successfully",
|
||||
fileName,
|
||||
fileName: filePath,
|
||||
tabloId,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -151,6 +143,193 @@ const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.
|
|||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// FOLDER ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
// Helper to get or create folder metadata
|
||||
const getFolderMetadata = async (
|
||||
s3_client: S3Client,
|
||||
tabloId: string
|
||||
): Promise<TabloFoldersMetadata> => {
|
||||
try {
|
||||
const response = await s3_client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${FOLDERS_METADATA_FILE}`,
|
||||
})
|
||||
);
|
||||
|
||||
if (response.Body) {
|
||||
const content = await response.Body.transformToString();
|
||||
return JSON.parse(content);
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, return default
|
||||
}
|
||||
return { folders: [], version: 1 };
|
||||
};
|
||||
|
||||
// Helper to save folder metadata
|
||||
const saveFolderMetadata = async (
|
||||
s3_client: S3Client,
|
||||
tabloId: string,
|
||||
metadata: TabloFoldersMetadata
|
||||
) => {
|
||||
await s3_client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "tablo-data",
|
||||
Key: `${tabloId}/${FOLDERS_METADATA_FILE}`,
|
||||
Body: JSON.stringify(metadata, null, 2),
|
||||
ContentType: "application/json",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// GET /tablo-data/:tabloId/folders - Get all folders for a tablo
|
||||
const getTabloFolders = factory.createHandlers(checkTabloMember, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
return c.json(metadata);
|
||||
} catch (error) {
|
||||
console.error("Error fetching folders:", error);
|
||||
return c.json({ error: "Failed to fetch folders" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /tablo-data/:tabloId/folders - Create a new folder (admin only)
|
||||
const createTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const s3_client = c.get("s3_client");
|
||||
const user = c.get("user");
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { name, description } = body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return c.json({ error: "Folder name is required" }, 400);
|
||||
}
|
||||
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
|
||||
// Check for duplicate folder names
|
||||
if (metadata.folders.some((f) => f.name.toLowerCase() === name.trim().toLowerCase())) {
|
||||
return c.json({ error: "A folder with this name already exists" }, 400);
|
||||
}
|
||||
|
||||
const newFolder: TabloFolder = {
|
||||
id: generateFolderId(),
|
||||
name: name.trim(),
|
||||
description: description?.trim() || undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: user.id,
|
||||
};
|
||||
|
||||
metadata.folders.push(newFolder);
|
||||
metadata.version += 1;
|
||||
|
||||
await saveFolderMetadata(s3_client, tabloId, metadata);
|
||||
|
||||
return c.json({
|
||||
message: "Folder created successfully",
|
||||
folder: newFolder,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating folder:", error);
|
||||
return c.json({ error: "Failed to create folder" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /tablo-data/:tabloId/folders/:folderId - Update a folder (admin only)
|
||||
const updateTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const folderId = c.req.param("folderId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { name, description } = body;
|
||||
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
const folderIndex = metadata.folders.findIndex((f) => f.id === folderId);
|
||||
|
||||
if (folderIndex === -1) {
|
||||
return c.json({ error: "Folder not found" }, 404);
|
||||
}
|
||||
|
||||
// Check for duplicate folder names (excluding current folder)
|
||||
if (
|
||||
name &&
|
||||
metadata.folders.some(
|
||||
(f, idx) => idx !== folderIndex && f.name.toLowerCase() === name.trim().toLowerCase()
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "A folder with this name already exists" }, 400);
|
||||
}
|
||||
|
||||
// Update folder
|
||||
if (name) {
|
||||
metadata.folders[folderIndex].name = name.trim();
|
||||
}
|
||||
if (description !== undefined) {
|
||||
metadata.folders[folderIndex].description = description?.trim() || undefined;
|
||||
}
|
||||
metadata.version += 1;
|
||||
|
||||
await saveFolderMetadata(s3_client, tabloId, metadata);
|
||||
|
||||
return c.json({
|
||||
message: "Folder updated successfully",
|
||||
folder: metadata.folders[folderIndex],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating folder:", error);
|
||||
return c.json({ error: "Failed to update folder" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /tablo-data/:tabloId/folders/:folderId - Delete a folder (admin only)
|
||||
const deleteTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
|
||||
const tabloId = c.req.param("tabloId");
|
||||
const folderId = c.req.param("folderId");
|
||||
const s3_client = c.get("s3_client");
|
||||
|
||||
try {
|
||||
const metadata = await getFolderMetadata(s3_client, tabloId);
|
||||
const folderIndex = metadata.folders.findIndex((f) => f.id === folderId);
|
||||
|
||||
if (folderIndex === -1) {
|
||||
return c.json({ error: "Folder not found" }, 404);
|
||||
}
|
||||
|
||||
const deletedFolder = metadata.folders[folderIndex];
|
||||
metadata.folders.splice(folderIndex, 1);
|
||||
metadata.version += 1;
|
||||
|
||||
await saveFolderMetadata(s3_client, tabloId, metadata);
|
||||
|
||||
// Note: Files in the folder are NOT deleted, they become "orphaned" / unorganized
|
||||
return c.json({
|
||||
message: "Folder deleted successfully",
|
||||
folder: deletedFolder,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting folder:", error);
|
||||
return c.json({ error: "Failed to delete folder" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTER SETUP
|
||||
// ============================================
|
||||
|
||||
export const getTabloDataRouter = () => {
|
||||
const tabloDataRouter = new Hono();
|
||||
const middlewareManager = MiddlewareManager.getInstance();
|
||||
|
|
@ -159,7 +338,22 @@ export const getTabloDataRouter = () => {
|
|||
tabloDataRouter.use(middlewareManager.streamChat);
|
||||
tabloDataRouter.use(middlewareManager.r2);
|
||||
|
||||
// File endpoints
|
||||
tabloDataRouter.get("/:tabloId/filenames", ...getTabloFilenames);
|
||||
|
||||
// Folder endpoints (must be defined before wildcard file routes)
|
||||
tabloDataRouter.get("/:tabloId/folders", ...getTabloFolders);
|
||||
tabloDataRouter.post("/:tabloId/folders", ...createTabloFolder(middlewareManager));
|
||||
tabloDataRouter.put("/:tabloId/folders/:folderId", ...updateTabloFolder(middlewareManager));
|
||||
tabloDataRouter.delete("/:tabloId/folders/:folderId", ...deleteTabloFolder(middlewareManager));
|
||||
|
||||
// File routes using wildcard to support nested paths (e.g., "folder-123/file.pdf")
|
||||
// These must be defined after the specific routes above
|
||||
tabloDataRouter.get("/:tabloId/file/:path{.+}", ...getTabloFile);
|
||||
tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile);
|
||||
tabloDataRouter.delete("/:tabloId/file/:path{.+}", ...deleteTabloFile(middlewareManager));
|
||||
|
||||
// Legacy routes for backward compatibility (single-level file names only)
|
||||
tabloDataRouter.get("/:tabloId/:fileName", ...getTabloFile);
|
||||
tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile);
|
||||
tabloDataRouter.delete("/:tabloId/:fileName", ...deleteTabloFile(middlewareManager));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -140,7 +140,7 @@ export function useCreateTabloFile() {
|
|||
{ tabloId: string; fileName: string; data: FileUploadRequest }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, fileName, data }) => {
|
||||
const response = await api.post(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
|
||||
const response = await api.post(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data);
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to create file");
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ export function useUpdateTabloFile() {
|
|||
{ tabloId: string; fileName: string; data: FileUploadRequest }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, fileName, data }) => {
|
||||
const response = await api.put(`/api/v1/tablo-data/${tabloId}/${fileName}`, data);
|
||||
const response = await api.put(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data);
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to update file");
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ export function useDeleteTabloFile() {
|
|||
|
||||
return useMutation<FileOperationResponse, Error, { tabloId: string; fileName: string }>({
|
||||
mutationFn: async ({ tabloId, fileName }) => {
|
||||
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/${fileName}`);
|
||||
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/file/${fileName}`);
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to delete file");
|
||||
}
|
||||
|
|
|
|||
191
apps/main/src/hooks/tablo_folders.ts
Normal file
191
apps/main/src/hooks/tablo_folders.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@xtablo/shared";
|
||||
import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { useAuthedApi } from "./auth";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
|
||||
const toastOptions = { timeout: 5000 };
|
||||
|
||||
// Hook to get folders for a tablo
|
||||
export function useTabloFolders(tabloId: string) {
|
||||
const api = useAuthedApi();
|
||||
|
||||
return useQuery<TabloFoldersMetadata>({
|
||||
queryKey: ["tablo-folders", tabloId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/api/v1/tablo-data/${tabloId}/folders`);
|
||||
if (response.status === 200) {
|
||||
return response.data;
|
||||
}
|
||||
return { folders: [], version: 1 };
|
||||
},
|
||||
enabled: !!tabloId,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to create a new folder
|
||||
export function useCreateTabloFolder() {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TabloFolder,
|
||||
Error,
|
||||
{ tabloId: string; name: string; description?: string; createdBy: string }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, name, description }) => {
|
||||
const response = await api.post(`/api/v1/tablo-data/${tabloId}/folders`, {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.data?.error || "Failed to create folder");
|
||||
}
|
||||
|
||||
return response.data.folder;
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Livrable créé",
|
||||
description: `Le livrable "${folder.name}" a été créé avec succès`,
|
||||
type: "success",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: `Échec de la création du livrable: ${error.message}`,
|
||||
type: "error",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onSettled: (_, _err, variables) => {
|
||||
if (variables) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tablo-folders", variables.tabloId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update a folder
|
||||
export function useUpdateTabloFolder() {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TabloFolder,
|
||||
Error,
|
||||
{ tabloId: string; folderId: string; name?: string; description?: string }
|
||||
>({
|
||||
mutationFn: async ({ tabloId, folderId, name, description }) => {
|
||||
const response = await api.put(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`, {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.data?.error || "Failed to update folder");
|
||||
}
|
||||
|
||||
return response.data.folder;
|
||||
},
|
||||
onSuccess: (folder) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Livrable mis à jour",
|
||||
description: `Le livrable "${folder.name}" a été mis à jour`,
|
||||
type: "success",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: `Échec de la mise à jour du livrable: ${error.message}`,
|
||||
type: "error",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onSettled: (_, _err, variables) => {
|
||||
if (variables) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tablo-folders", variables.tabloId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to delete a folder
|
||||
export function useDeleteTabloFolder() {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, { tabloId: string; folderId: string; folderName: string }>({
|
||||
mutationFn: async ({ tabloId, folderId }) => {
|
||||
const response = await api.delete(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.data?.error || "Failed to delete folder");
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Livrable supprimé",
|
||||
description: `Le livrable "${variables.folderName}" a été supprimé`,
|
||||
type: "success",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: `Échec de la suppression du livrable: ${error.message}`,
|
||||
type: "error",
|
||||
},
|
||||
toastOptions
|
||||
);
|
||||
},
|
||||
onSettled: (_, _err, variables) => {
|
||||
if (variables) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tablo-folders", variables.tabloId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get folder path prefix for a file
|
||||
export const getFolderFilePrefix = (folderId: string) => `${folderId}/`;
|
||||
|
||||
// Helper to extract folder ID from a file name
|
||||
export const extractFolderIdFromFileName = (fileName: string): string | null => {
|
||||
const match = fileName.match(/^(folder-[^/]+)\//);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
// Helper to get the actual file name without folder prefix
|
||||
export const getFileNameWithoutFolder = (fileName: string): string => {
|
||||
const folderId = extractFolderIdFromFileName(fileName);
|
||||
if (folderId) {
|
||||
return fileName.substring(folderId.length + 1);
|
||||
}
|
||||
return fileName;
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"./database": "./src/database.types.ts",
|
||||
"./events": "./src/events.types.ts",
|
||||
"./tablos": "./src/tablos.types.ts",
|
||||
"./tablo-data": "./src/tablo-data.types.ts",
|
||||
"./stripe": "./src/stripe.types.ts",
|
||||
"./kanban": "./src/kanban.types.ts",
|
||||
"./utils": "./src/utils.ts"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export type {
|
|||
// ============================================================================
|
||||
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
|
||||
// ============================================================================
|
||||
// Tablo Data Types (Files and Folders)
|
||||
// ============================================================================
|
||||
export type { TabloFolder, TabloFoldersMetadata } from "./tablo-data.types.js";
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
export type {
|
||||
|
|
|
|||
23
packages/shared-types/src/tablo-data.types.ts
Normal file
23
packages/shared-types/src/tablo-data.types.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// ============================================================================
|
||||
// Tablo Data Types (Files and Folders)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Represents a folder within a tablo for organizing files/deliverables
|
||||
*/
|
||||
export interface TabloFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata file structure for storing folder information in S3
|
||||
*/
|
||||
export interface TabloFoldersMetadata {
|
||||
folders: TabloFolder[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue