Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion packages/stack/registry/btst-media.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
{
"path": "btst/media/types.ts",
"type": "registry:lib",
"content": "export type Asset = {\n\tid: string;\n\tfilename: string;\n\toriginalName: string;\n\tmimeType: string;\n\tsize: number;\n\turl: string;\n\tfolderId?: string;\n\talt?: string;\n\tcreatedAt: Date;\n};\n\nexport type Folder = {\n\tid: string;\n\tname: string;\n\tparentId?: string;\n\tcreatedAt: Date;\n};\n\nexport interface SerializedAsset extends Omit<Asset, \"createdAt\"> {\n\tcreatedAt: string;\n}\n\nexport interface SerializedFolder extends Omit<Folder, \"createdAt\"> {\n\tcreatedAt: string;\n}\n",
"content": "export type Asset = {\n\tid: string;\n\tfilename: string;\n\toriginalName: string;\n\tmimeType: string;\n\tsize: number;\n\turl: string;\n\tfolderId?: string;\n\talt?: string;\n\ttenantId?: string | null;\n\tcreatedAt: Date;\n};\n\nexport type Folder = {\n\tid: string;\n\tname: string;\n\tparentId?: string;\n\ttenantId?: string | null;\n\tcreatedAt: Date;\n};\n\nexport interface SerializedAsset extends Omit<Asset, \"createdAt\"> {\n\tcreatedAt: string;\n}\n\nexport interface SerializedFolder extends Omit<Folder, \"createdAt\"> {\n\tcreatedAt: string;\n}\n",
"target": "src/components/btst/media/types.ts"
},
{
Expand Down
155 changes: 155 additions & 0 deletions packages/stack/src/plugins/media/__tests__/getters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getAssetById,
listFolders,
getFolderById,
getFolderByName,
} from "../api/getters";

const createTestAdapter = (): Adapter => {
Expand Down Expand Up @@ -162,6 +163,30 @@ describe("media getters", () => {
expect(photoResult.items[0]!.filename).toBe("holiday-photo.jpg");
});

it("filters assets by tenantId", async () => {
await adapter.create({
model: "mediaAsset",
data: makeAsset({
filename: "tenant-a.jpg",
url: "https://example.com/a.jpg",
}),
});
const assetB = await adapter.create<{ id: string }>({
model: "mediaAsset",
data: {
...makeAsset({
filename: "tenant-b.jpg",
url: "https://example.com/b.jpg",
}),
tenantId: "tenant-b",
},
});

const result = await listAssets(adapter, { tenantId: "tenant-b" });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.id).toBe(assetB.id);
});

it("paginates results with limit and offset", async () => {
for (let i = 0; i < 5; i++) {
await adapter.create({
Expand Down Expand Up @@ -231,6 +256,24 @@ describe("media getters", () => {
expect(result[1]!.name).toBe("Zeta");
});

it("filters folders by tenantId", async () => {
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "No Tenant" }),
});
const tenantFolder = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: {
...makeFolder({ name: "Tenant Folder" }),
tenantId: "tenant-x",
},
});

const result = await listFolders(adapter, { tenantId: "tenant-x" });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe(tenantFolder.id);
});

it("filters folders by parentId", async () => {
const root = await adapter.create<{ id: string }>({
model: "mediaFolder",
Expand Down Expand Up @@ -271,4 +314,116 @@ describe("media getters", () => {
expect(result!.name).toBe("Test Folder");
});
});

// ── getFolderByName ───────────────────────────────────────────────────────

describe("getFolderByName", () => {
it("returns null when no folder exists", async () => {
const result = await getFolderByName(adapter, "nonexistent");
expect(result).toBeNull();
});

it("finds a root-level folder by name with no parentId constraint", async () => {
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "blog-gen-profile-1" }),
});

const result = await getFolderByName(adapter, "blog-gen-profile-1");
expect(result).not.toBeNull();
expect(result!.name).toBe("blog-gen-profile-1");
});

it("returns null when name matches but parentId does not", async () => {
const parent = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Parent" }),
});
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "Child", parentId: parent.id }),
});

// Search for "Child" scoped to a different (non-existent) parent.
const result = await getFolderByName(adapter, "Child", "wrong-parent-id");
expect(result).toBeNull();
});

it("scopes lookup to root-level folders when parentId is null", async () => {
// Explicitly store parentId as null (mirrors how SQL adapters persist root folders).
const rootFolder = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: { ...makeFolder({ name: "Images" }), parentId: null },
});
// A nested folder with the same name.
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "Images", parentId: rootFolder.id }),
});

// Only the root-level "Images" (parentId = null) should be returned.
const result = await getFolderByName(adapter, "Images", null);
expect(result).not.toBeNull();
expect(result!.id).toBe(rootFolder.id);
});

it("finds a child folder scoped to the correct parentId", async () => {
const parentA = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Parent A" }),
});
const parentB = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Parent B" }),
});
const childOfA = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Child", parentId: parentA.id }),
});
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "Child", parentId: parentB.id }),
});

const result = await getFolderByName(adapter, "Child", parentA.id);
expect(result).not.toBeNull();
expect(result!.id).toBe(childOfA.id);
});

it("filters by tenantId when provided", async () => {
await adapter.create({
model: "mediaFolder",
data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-1" },
});
const folderT2 = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-2" },
});

// Same name, different tenant — should only return the matching one.
const result = await getFolderByName(
adapter,
"blog-gen-abc",
undefined,
"tenant-2",
);
expect(result).not.toBeNull();
expect(result!.id).toBe(folderT2.id);
});

it("returns null when tenantId does not match", async () => {
await adapter.create({
model: "mediaFolder",
data: { ...makeFolder({ name: "blog-gen-xyz" }), tenantId: "tenant-1" },
});

const result = await getFolderByName(
adapter,
"blog-gen-xyz",
undefined,
"tenant-999",
);
expect(result).toBeNull();
});
});
});
30 changes: 30 additions & 0 deletions packages/stack/src/plugins/media/__tests__/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ describe("media mutations", () => {
expect(asset.alt).toBe("A beautiful photo");
});

it("persists tenantId when provided", async () => {
const asset = await createAsset(adapter, {
...assetInput,
tenantId: "profile-abc",
});

expect(asset.tenantId).toBe("profile-abc");
});

it("leaves tenantId absent when not provided", async () => {
const asset = await createAsset(adapter, assetInput);

expect(asset.tenantId == null).toBe(true);
});

it("creates multiple independent assets", async () => {
await createAsset(adapter, {
...assetInput,
Expand Down Expand Up @@ -194,6 +209,21 @@ describe("media mutations", () => {
expect(folder.createdAt).toBeInstanceOf(Date);
});

it("persists tenantId when provided", async () => {
const folder = await createFolder(adapter, {
name: "Tenant Folder",
tenantId: "profile-xyz",
});

expect(folder.tenantId).toBe("profile-xyz");
});

it("leaves tenantId absent when not provided", async () => {
const folder = await createFolder(adapter, { name: "No Tenant" });

expect(folder.tenantId == null).toBe(true);
});

it("creates a nested folder with parentId", async () => {
const parent = await createFolder(adapter, { name: "Root" });
const child = await createFolder(adapter, {
Expand Down
77 changes: 64 additions & 13 deletions packages/stack/src/plugins/media/api/getters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface AssetListParams {
query?: string;
offset?: number;
limit?: number;
tenantId?: string;
}

/**
Expand All @@ -27,6 +28,7 @@ export interface AssetListResult {
*/
export interface FolderListParams {
parentId?: string | null;
tenantId?: string;
}

/**
Expand Down Expand Up @@ -64,6 +66,14 @@ export async function listAssets(
});
}

if (query.tenantId !== undefined) {
whereConditions.push({
field: "tenantId",
value: query.tenantId,
operator: "eq" as const,
});
}

const needsInMemoryFilter = !!query.query;
const dbWhere = whereConditions.length > 0 ? whereConditions : undefined;

Expand Down Expand Up @@ -136,22 +146,31 @@ export async function listFolders(
adapter: Adapter,
params?: FolderListParams,
): Promise<Folder[]> {
// Only add a where clause when parentId is explicitly provided as a string.
// Passing undefined (or no params) returns all folders unfiltered.
const where =
params?.parentId !== undefined
? [
{
field: "parentId",
value: params.parentId,
operator: "eq" as const,
},
]
: undefined;
const whereConditions: Array<{
field: string;
value: string | null;
operator: "eq";
}> = [];

if (params?.parentId !== undefined) {
whereConditions.push({
field: "parentId",
value: params.parentId,
operator: "eq" as const,
});
}

if (params?.tenantId !== undefined) {
whereConditions.push({
field: "tenantId",
value: params.tenantId,
operator: "eq" as const,
});
}

return adapter.findMany<Folder>({
model: "mediaFolder",
where,
where: whereConditions.length > 0 ? whereConditions : undefined,
sortBy: { field: "name", direction: "asc" },
});
}
Expand All @@ -172,3 +191,35 @@ export async function getFolderById(
where: [{ field: "id", value: id, operator: "eq" as const }],
});
}

/**
* Find a single folder by name, optionally scoped to a specific parent and/or tenant.
* Pass `null` for `parentId` to search only root-level folders.
* Pass `undefined` for `parentId` to search regardless of parent.
* Returns `null` if no matching folder is found.
*
* Pure DB function — no hooks, no HTTP context.
* The caller is responsible for any access-control checks.
*/
export async function getFolderByName(
adapter: Adapter,
name: string,
parentId?: string | null,
tenantId?: string,
): Promise<Folder | null> {
const where: Array<{
field: string;
value: string | null;
operator: "eq";
}> = [{ field: "name", value: name, operator: "eq" as const }];

if (parentId !== undefined) {
where.push({ field: "parentId", value: parentId, operator: "eq" as const });
}

if (tenantId !== undefined) {
where.push({ field: "tenantId", value: tenantId, operator: "eq" as const });
}

return adapter.findOne<Folder>({ model: "mediaFolder", where });
}
1 change: 1 addition & 0 deletions packages/stack/src/plugins/media/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
getAssetById,
listFolders,
getFolderById,
getFolderByName,
type AssetListParams,
type AssetListResult,
type FolderListParams,
Expand Down
4 changes: 4 additions & 0 deletions packages/stack/src/plugins/media/api/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface CreateAssetInput {
url: string;
folderId?: string;
alt?: string;
tenantId?: string;
}

/**
Expand All @@ -28,6 +29,7 @@ export interface UpdateAssetInput {
export interface CreateFolderInput {
name: string;
parentId?: string;
tenantId?: string;
}

/**
Expand All @@ -51,6 +53,7 @@ export async function createAsset(
url: input.url,
folderId: input.folderId,
alt: input.alt,
tenantId: input.tenantId,
createdAt: new Date(),
},
});
Expand Down Expand Up @@ -114,6 +117,7 @@ export async function createFolder(
data: {
name: input.name,
parentId: input.parentId,
tenantId: input.tenantId,
createdAt: new Date(),
},
});
Expand Down
Loading
Loading