diff --git a/codegen.yml b/codegen.yml index 09958a8fe..94a7e4d7f 100644 --- a/codegen.yml +++ b/codegen.yml @@ -169,6 +169,20 @@ generates: - typescript-operations - typed-document-node + './packages/ocom/ui-staff-route-tech-admin/src/generated.tsx': + documents: + - './packages/ocom/ui-staff-route-tech-admin/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + # Cellix core base type defs (static array for rolldown bundling) './packages/cellix/graphql-core/src/schema/base-type-defs.generated.ts': plugins: diff --git a/packages/cellix/service-blob-storage/src/index.ts b/packages/cellix/service-blob-storage/src/index.ts index 41b482bca..81f9f27b3 100644 --- a/packages/cellix/service-blob-storage/src/index.ts +++ b/packages/cellix/service-blob-storage/src/index.ts @@ -1,13 +1,20 @@ export type { BlobUploadCommonResponse } from '@azure/storage-blob'; export type { BlobAddress, + BlobContainerItem, + BlobExplorerBlobItem, + BlobExplorerHierarchyPage, + BlobExplorerMetadataFilter, + BlobExplorerTagFilter, BlobListItem, BlobStorage, BlobUploadAuthorizationHeader, ClientBlobStorage, CreateBlobAuthorizationHeaderRequest, CreateBlobSasUrlRequest, + ListBlobsHierarchyRequest, ListBlobsRequest, + ListContainersResult, ServiceBlobStorageOptions, ServiceClientBlobStorageOptions, UploadTextBlobRequest, diff --git a/packages/cellix/service-blob-storage/src/interfaces.ts b/packages/cellix/service-blob-storage/src/interfaces.ts index e9c2ddf85..5490f9e75 100644 --- a/packages/cellix/service-blob-storage/src/interfaces.ts +++ b/packages/cellix/service-blob-storage/src/interfaces.ts @@ -199,6 +199,209 @@ export interface BlobUploadAuthorizationHeader { headers: Record; } +/** + * A single Azure Blob Storage container returned by `listContainers`. + * + * @property name - The container name. + * @property url - Absolute URL to the container endpoint. + */ +export interface BlobContainerItem { + name: string; + url: string; +} + +/** + * Result of listing all blob containers in a storage account. + * + * @property containers - Array of container items in the account. + */ +export interface ListContainersResult { + containers: BlobContainerItem[]; +} + +/** + * Filter on blob metadata key/value pair for client-side filtering in `listBlobsHierarchy`. + * + * @property key - Metadata key to match. + * @property value - Metadata value to match. + */ +export interface BlobExplorerMetadataFilter { + key: string; + value: string; +} + +/** + * Filter on blob index tag key/value pair for client-side filtering in `listBlobsHierarchy`. + * + * @property key - Tag key to match. + * @property value - Tag value to match. + */ +export interface BlobExplorerTagFilter { + key: string; + value: string; +} + +/** + * Request contract for listing blobs with virtual folder hierarchy navigation. + * + * Use this with `BlobStorage.listBlobsHierarchy()` to enumerate blobs within a + * container using `/` as the hierarchy delimiter, supporting pagination and + * optional client-side filtering. + * + * @property containerName - Name of the Azure Blob container to enumerate. + * @property prefix - Optional blob-name prefix to scope the listing to a virtual folder. + * @property continuationToken - Optional token for fetching subsequent pages. + * @property maxResults - Optional maximum number of items per page. + * @property nameFilter - Optional string to match against blob names (client-side). + * @property metadataFilter - Optional metadata key/value filter (client-side). + * @property tagFilter - Optional tag key/value filter (client-side). + */ +export interface ListBlobsHierarchyRequest { + containerName: string; + prefix?: string; + continuationToken?: string; + maxResults?: number; + nameFilter?: string; + metadataFilter?: BlobExplorerMetadataFilter; + tagFilter?: BlobExplorerTagFilter; +} + +/** + * A single blob item returned by `listBlobsHierarchy`. + * + * @property name - Blob path relative to the container root. + * @property contentType - Optional MIME content type stored with the blob. + * @property contentLength - Optional size of the blob in bytes. + * @property lastModified - Optional last-modified timestamp. + * @property metadata - Optional blob metadata key/value pairs. + * @property tags - Optional blob index tag key/value pairs. + */ +export interface BlobExplorerBlobItem { + name: string; + contentType?: string; + contentLength?: number; + lastModified?: Date; + metadata?: Record; + tags?: Record; +} + +/** + * A page of results from `listBlobsHierarchy`. + * + * @property items - Blob items on this page. + * @property prefixes - Virtual folder prefixes (sub-directories) on this page. + * @property continuationToken - Optional token to fetch the next page. + */ +export interface BlobExplorerHierarchyPage { + items: BlobExplorerBlobItem[]; + prefixes: string[]; + continuationToken?: string; +} + +/** + * Identifies a single container in Azure Blob Storage. + * + * @property name - Name of the container. + * @property url - Absolute URL of the container. + */ +export interface BlobContainerItem { + name: string; + url: string; +} + +/** + * Filter to narrow blob listing results by a metadata key-value pair. + * + * @property key - Metadata key to match. + * @property value - Metadata value to match. + */ +export interface BlobExplorerMetadataFilter { + key: string; + value: string; +} + +/** + * Filter to narrow blob listing results by an index tag key-value pair. + * + * @property key - Tag key to match. + * @property value - Tag value to match. + */ +export interface BlobExplorerTagFilter { + key: string; + value: string; +} + +/** + * Request contract for listing blobs in a container using virtual-directory hierarchy. + * + * Use this with `BlobStorage.listBlobsHierarchy()` to enumerate blobs and + * virtual directories within a container, optionally scoped to a prefix and + * filtered by name, metadata, or tags. + * + * @property containerName - Name of the Azure Blob container to enumerate. + * @property prefix - Optional blob-name prefix to scope the listing to a virtual directory. + * @property continuationToken - Optional continuation token from a previous call for pagination. + * @property maxResults - Optional maximum number of results to return per page. + * @property nameFilter - Optional substring filter applied client-side to blob names. + * @property metadataFilter - Optional metadata key/value filter applied client-side. + * @property tagFilter - Optional tag key/value filter applied client-side. + */ +export interface ListBlobsHierarchyRequest { + containerName: string; + prefix?: string; + continuationToken?: string; + maxResults?: number; + nameFilter?: string; + metadataFilter?: BlobExplorerMetadataFilter; + tagFilter?: BlobExplorerTagFilter; +} + +/** + * Summary information for a single blob returned by the hierarchy listing. + * + * @property name - Blob path relative to the container root. + * @property contentType - MIME type of the blob, if available. + * @property contentLength - Size of the blob in bytes, if available. + * @property lastModified - Last-modified timestamp, if available. + * @property metadata - Blob metadata key-value pairs, if available. + * @property tags - Blob index tag key-value pairs, if available. + */ +export interface BlobExplorerBlobItem { + name: string; + contentType?: string; + contentLength?: number; + lastModified?: Date; + metadata?: Record; + tags?: Record; +} + +/** + * Page of results returned by `BlobStorage.listBlobsHierarchy()`. + * + * Contains blobs at the current virtual-directory level as well as virtual + * subdirectory prefixes. A non-null `continuationToken` indicates that more + * results are available and can be retrieved by passing the token back to the + * next call. + * + * @property items - Blob items at the current virtual-directory level. + * @property prefixes - Virtual subdirectory prefixes within the current prefix. + * @property continuationToken - Token to retrieve the next page, or undefined if no more pages. + */ +export interface BlobExplorerHierarchyPage { + items: BlobExplorerBlobItem[]; + prefixes: string[]; + continuationToken?: string; +} + +/** + * Result returned by `BlobStorage.listContainers()`. + * + * @property containers - Array of container summaries. + */ +export interface ListContainersResult { + containers: BlobContainerItem[]; +} + /** * Framework-level contract for server-side Azure Blob Storage operations. * @@ -270,6 +473,42 @@ export interface BlobStorage { * @returns A promise that resolves to a flat list of matching blobs. */ listBlobs(request: ListBlobsRequest): Promise; + + /** + * Lists all containers in the storage account. + * + * @returns A promise that resolves to the list of container summaries. + */ + listContainers(): Promise; + + /** + * Lists blobs at a virtual-directory level within a container. + * + * Results are paged and include both blob items and virtual-directory + * prefixes. Optional filters narrow results by name, metadata, or tags. + * + * @param request - Container, optional prefix, optional pagination token, + * and optional filters to apply. + * @returns A promise that resolves to a page of blob items and prefixes. + */ + listBlobsHierarchy(request: ListBlobsHierarchyRequest): Promise; + + /** + * Lists all containers in the storage account. + * + * @returns A promise that resolves to a list of all containers. + */ + listContainers(): Promise; + + /** + * Lists blobs in a container using a virtual-folder hierarchy with `/` as + * the delimiter. Supports pagination via continuation tokens and optional + * client-side filtering by name, metadata, or tags. + * + * @param request - Container, optional prefix, pagination options, and filters. + * @returns A promise that resolves to one page of blobs and virtual-folder prefixes. + */ + listBlobsHierarchy(request: ListBlobsHierarchyRequest): Promise; } /** diff --git a/packages/cellix/service-blob-storage/src/service-blob-storage.ts b/packages/cellix/service-blob-storage/src/service-blob-storage.ts index 62bd99e30..d01ce853d 100644 --- a/packages/cellix/service-blob-storage/src/service-blob-storage.ts +++ b/packages/cellix/service-blob-storage/src/service-blob-storage.ts @@ -1,7 +1,18 @@ import { DefaultAzureCredential, type TokenCredential } from '@azure/identity'; import { BlobServiceClient, type BlobUploadCommonResponse } from '@azure/storage-blob'; import type { ServiceBase } from '@cellix/api-services-spec'; -import type { BlobAddress, BlobListItem, BlobStorage, ListBlobsRequest, ServiceBlobStorageOptions, UploadTextBlobRequest } from './interfaces.ts'; +import type { + BlobAddress, + BlobExplorerBlobItem, + BlobExplorerHierarchyPage, + BlobListItem, + BlobStorage, + ListBlobsHierarchyRequest, + ListBlobsRequest, + ListContainersResult, + ServiceBlobStorageOptions, + UploadTextBlobRequest, +} from './interfaces.ts'; function validateOptions(options: ServiceBlobStorageOptions): void { if (!options.accountName?.trim()) { @@ -70,6 +81,95 @@ export class ServiceBlobStorage implements ServiceBase, BlobStorage return blobs; } + public async listContainers(): Promise { + const client = this.requireBlobServiceClient(); + const containers: ListContainersResult['containers'] = []; + + for await (const container of client.listContainers()) { + const containerClient = client.getContainerClient(container.name); + containers.push({ + name: container.name, + url: containerClient.url, + }); + } + + return { containers }; + } + + public async listBlobsHierarchy(request: ListBlobsHierarchyRequest): Promise { + const containerClient = this.getContainerClient(request.containerName); + const items: BlobExplorerBlobItem[] = []; + const prefixes: string[] = []; + const { prefix, continuationToken, maxResults, nameFilter, metadataFilter, tagFilter } = request; + + const listOptions = { + prefix: prefix ?? '', + includeTags: true, + includeMetadata: true, + }; + + const pageSettings = { + ...(continuationToken ? { continuationToken } : {}), + maxPageSize: maxResults ?? 100, + }; + + const pagedIterator = containerClient.listBlobsByHierarchy('/', listOptions).byPage(pageSettings); + const page = await pagedIterator.next(); + + let nextContinuationToken: string | undefined; + + if (!page.done && page.value) { + const pageValue = page.value; + nextContinuationToken = pageValue.continuationToken || undefined; + + for (const item of pageValue.segment.blobPrefixes ?? []) { + prefixes.push(item.name); + } + + for (const blob of pageValue.segment.blobItems ?? []) { + const blobItem: BlobExplorerBlobItem = { + name: blob.name, + ...(blob.properties.contentType !== undefined ? { contentType: blob.properties.contentType } : {}), + ...(blob.properties.contentLength !== undefined ? { contentLength: blob.properties.contentLength } : {}), + ...(blob.properties.lastModified !== undefined ? { lastModified: blob.properties.lastModified } : {}), + ...(blob.metadata ? { metadata: blob.metadata as Record } : {}), + ...(blob.tags ? { tags: blob.tags as Record } : {}), + }; + + // Apply client-side name filter + if (nameFilter && !blob.name.includes(nameFilter)) { + continue; + } + + // Apply client-side metadata filter + if (metadataFilter) { + const meta = blobItem.metadata ?? {}; + if (meta[metadataFilter.key] !== metadataFilter.value) { + continue; + } + } + + // Apply client-side tag filter + if (tagFilter) { + const tags = blobItem.tags ?? {}; + if (tags[tagFilter.key] !== tagFilter.value) { + continue; + } + } + + items.push(blobItem); + } + } + + const result: BlobExplorerHierarchyPage = { + items, + prefixes, + ...(nextContinuationToken ? { continuationToken: nextContinuationToken } : {}), + }; + + return result; + } + protected setBlobServiceClient(client: BlobServiceClient): void { this.blobServiceClientInternal = client; } diff --git a/packages/cellix/service-blob-storage/tests/index.test.ts b/packages/cellix/service-blob-storage/tests/index.test.ts index d1fb53236..67ad92491 100644 --- a/packages/cellix/service-blob-storage/tests/index.test.ts +++ b/packages/cellix/service-blob-storage/tests/index.test.ts @@ -2,30 +2,41 @@ import { createHash } from 'node:crypto'; import { ServiceBlobStorage, ServiceClientBlobStorage } from '@cellix/service-blob-storage'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { uploadMock, deleteBlobMock, listBlobsFlatMock, blobServiceFromConnectionStringMock, blobServiceConstructorMock, generateBlobSasQueryParametersMock, defaultAzureCredentialMock, MockStorageSharedKeyCredential } = vi.hoisted( - () => { - class HoistedStorageSharedKeyCredential { - public readonly accountName: string; - public readonly accountKey: string; - - constructor(accountName: string, accountKey: string) { - this.accountName = accountName; - this.accountKey = accountKey; - } +const { + uploadMock, + deleteBlobMock, + listBlobsFlatMock, + listBlobsByHierarchyMock, + listContainersMock, + blobServiceFromConnectionStringMock, + blobServiceConstructorMock, + generateBlobSasQueryParametersMock, + defaultAzureCredentialMock, + MockStorageSharedKeyCredential, +} = vi.hoisted(() => { + class HoistedStorageSharedKeyCredential { + public readonly accountName: string; + public readonly accountKey: string; + + constructor(accountName: string, accountKey: string) { + this.accountName = accountName; + this.accountKey = accountKey; } + } - return { - uploadMock: vi.fn(), - deleteBlobMock: vi.fn(), - listBlobsFlatMock: vi.fn(), - blobServiceFromConnectionStringMock: vi.fn(), - blobServiceConstructorMock: vi.fn(), - generateBlobSasQueryParametersMock: vi.fn(), - defaultAzureCredentialMock: vi.fn(), - MockStorageSharedKeyCredential: HoistedStorageSharedKeyCredential, - }; - }, -); + return { + uploadMock: vi.fn(), + deleteBlobMock: vi.fn(), + listBlobsFlatMock: vi.fn(), + listBlobsByHierarchyMock: vi.fn(), + listContainersMock: vi.fn(), + blobServiceFromConnectionStringMock: vi.fn(), + blobServiceConstructorMock: vi.fn(), + generateBlobSasQueryParametersMock: vi.fn(), + defaultAzureCredentialMock: vi.fn(), + MockStorageSharedKeyCredential: HoistedStorageSharedKeyCredential, + }; +}); vi.mock('@azure/identity', () => ({ DefaultAzureCredential: class MockDefaultAzureCredential { @@ -51,6 +62,7 @@ vi.mock('@azure/storage-blob', () => { } public getContainerClient = vi.fn(); + public listContainers = listContainersMock; static fromConnectionString(connectionString: string) { return blobServiceFromConnectionStringMock(connectionString); @@ -79,6 +91,7 @@ describe('@cellix/service-blob-storage public contract', () => { getBlockBlobClient: vi.fn(() => blockBlobClient), deleteBlob: deleteBlobMock, listBlobsFlat: listBlobsFlatMock, + listBlobsByHierarchy: listBlobsByHierarchyMock, }; beforeEach(() => { @@ -86,10 +99,12 @@ describe('@cellix/service-blob-storage public contract', () => { blobServiceFromConnectionStringMock.mockReturnValue({ url: 'https://127.0.0.1:10000/devstoreaccount1', getContainerClient: vi.fn(() => containerClient), + listContainers: listContainersMock, }); blobServiceConstructorMock.mockImplementation((url: string) => ({ url, getContainerClient: vi.fn(() => containerClient), + listContainers: listContainersMock, })); generateBlobSasQueryParametersMock.mockReturnValue({ toString: () => 'sig=token-123&se=2026-05-14T12%3A00%3A00Z&sr=b&sp=r', @@ -101,6 +116,34 @@ describe('@cellix/service-blob-storage public contract', () => { yield { name: 'b.txt' }; })(), ); + listContainersMock.mockReturnValue( + (async function* () { + await Promise.resolve(); + yield { name: 'container-a' }; + yield { name: 'container-b' }; + })(), + ); + listBlobsByHierarchyMock.mockReturnValue({ + byPage: vi.fn(() => ({ + next: vi.fn().mockResolvedValue({ + done: false, + value: { + continuationToken: undefined, + segment: { + blobItems: [ + { + name: 'file.txt', + properties: { contentType: 'text/plain', contentLength: 100, lastModified: new Date('2024-01-01') }, + metadata: { source: 'test' }, + tags: { env: 'prod' }, + }, + ], + blobPrefixes: [{ name: 'folder/' }], + }, + }, + }), + })), + }); }); describe('ServiceBlobStorage', () => { @@ -174,6 +217,113 @@ describe('@cellix/service-blob-storage public contract', () => { await expect(service.shutDown()).resolves.toBeUndefined(); }); + + it('listContainers returns all containers with names and URLs', async () => { + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + const result = await service.listContainers(); + + expect(result.containers).toHaveLength(2); + expect(result.containers[0]?.name).toBe('container-a'); + expect(result.containers[1]?.name).toBe('container-b'); + }); + + it('listContainers throws if not started', async () => { + const service = new ServiceBlobStorage({ accountName }); + + await expect(service.listContainers()).rejects.toThrow('ServiceBlobStorage is not started'); + }); + + it('listBlobsHierarchy returns blob items and prefixes', async () => { + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + const result = await service.listBlobsHierarchy({ containerName: 'my-container' }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]?.name).toBe('file.txt'); + expect(result.items[0]?.contentType).toBe('text/plain'); + expect(result.prefixes).toEqual(['folder/']); + expect(result.continuationToken).toBeUndefined(); + }); + + it('listBlobsHierarchy passes prefix and maxResults to the page iterator', async () => { + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + await service.listBlobsHierarchy({ containerName: 'my-container', prefix: 'docs/', maxResults: 10 }); + + expect(listBlobsByHierarchyMock).toHaveBeenCalledWith('/', expect.objectContaining({ prefix: 'docs/' })); + }); + + it('listBlobsHierarchy applies client-side name filter', async () => { + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + const result = await service.listBlobsHierarchy({ containerName: 'my-container', nameFilter: 'no-match' }); + + expect(result.items).toHaveLength(0); + }); + + it('listBlobsHierarchy applies client-side metadata filter', async () => { + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + const result = await service.listBlobsHierarchy({ containerName: 'my-container', metadataFilter: { key: 'source', value: 'wrong' } }); + + expect(result.items).toHaveLength(0); + }); + + it('listBlobsHierarchy applies client-side tag filter', async () => { + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + const result = await service.listBlobsHierarchy({ containerName: 'my-container', tagFilter: { key: 'env', value: 'prod' } }); + + expect(result.items).toHaveLength(1); + }); + + it('listBlobsHierarchy returns empty result when page is done', async () => { + listBlobsByHierarchyMock.mockReturnValueOnce({ + byPage: vi.fn(() => ({ + next: vi.fn().mockResolvedValue({ done: true, value: undefined }), + })), + }); + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + const result = await service.listBlobsHierarchy({ containerName: 'my-container' }); + + expect(result.items).toHaveLength(0); + expect(result.prefixes).toHaveLength(0); + }); + + it('listBlobsHierarchy returns continuation token when available', async () => { + listBlobsByHierarchyMock.mockReturnValueOnce({ + byPage: vi.fn(() => ({ + next: vi.fn().mockResolvedValue({ + done: false, + value: { + continuationToken: 'next-page-token', + segment: { blobItems: [], blobPrefixes: [] }, + }, + }), + })), + }); + const service = new ServiceBlobStorage({ accountName }); + await service.startUp(); + + const result = await service.listBlobsHierarchy({ containerName: 'my-container' }); + + expect(result.continuationToken).toBe('next-page-token'); + }); + + it('listBlobsHierarchy throws if not started', async () => { + const service = new ServiceBlobStorage({ accountName }); + + await expect(service.listBlobsHierarchy({ containerName: 'my-container' })).rejects.toThrow('ServiceBlobStorage is not started'); + }); }); describe('ServiceClientBlobStorage', () => { diff --git a/packages/ocom/application-services/src/contexts/tech-admin/blob-explorer/index.ts b/packages/ocom/application-services/src/contexts/tech-admin/blob-explorer/index.ts new file mode 100644 index 000000000..edc3b8c76 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/tech-admin/blob-explorer/index.ts @@ -0,0 +1,25 @@ +import type { + BlobExplorerHierarchyPage, + BlobStorageOperations, + BlobUploadAuthorizationHeader, + ClientUploadOperations, + CreateBlobAuthorizationHeaderRequest, + ListBlobsHierarchyRequest, + ListContainersResult, +} from '@ocom/service-blob-storage'; + +export type GetBlobDownloadAuthorizationRequest = CreateBlobAuthorizationHeaderRequest; + +export interface BlobExplorerApplicationService { + listContainers: () => Promise; + listBlobsHierarchy: (request: ListBlobsHierarchyRequest) => Promise; + getBlobDownloadAuthorization: (request: GetBlobDownloadAuthorizationRequest) => Promise; +} + +export const BlobExplorer = (blobStorageService: BlobStorageOperations, clientOperationsService: ClientUploadOperations): BlobExplorerApplicationService => { + return { + listContainers: () => blobStorageService.listContainers(), + listBlobsHierarchy: (request: ListBlobsHierarchyRequest) => blobStorageService.listBlobsHierarchy(request), + getBlobDownloadAuthorization: (request: GetBlobDownloadAuthorizationRequest) => clientOperationsService.createBlobReadAuthorizationHeader(request), + }; +}; diff --git a/packages/ocom/application-services/src/contexts/tech-admin/index.ts b/packages/ocom/application-services/src/contexts/tech-admin/index.ts new file mode 100644 index 000000000..822018e94 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/tech-admin/index.ts @@ -0,0 +1,12 @@ +import type { BlobStorageOperations, ClientUploadOperations } from '@ocom/service-blob-storage'; +import { BlobExplorer, type BlobExplorerApplicationService } from './blob-explorer/index.ts'; + +export interface TechAdminContextApplicationService { + BlobExplorer: BlobExplorerApplicationService; +} + +export const TechAdmin = (blobStorageService: BlobStorageOperations, clientOperationsService: ClientUploadOperations): TechAdminContextApplicationService => { + return { + BlobExplorer: BlobExplorer(blobStorageService, clientOperationsService), + }; +}; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index 9e5407886..6b1b6687b 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -2,6 +2,7 @@ import type { ApiContextSpec } from '@ocom/context-spec'; import { Domain } from '@ocom/domain'; import { Community, type CommunityContextApplicationService } from './contexts/community/index.ts'; import { Service, type ServiceContextApplicationService } from './contexts/service/index.ts'; +import { TechAdmin, type TechAdminContextApplicationService } from './contexts/tech-admin/index.ts'; import { User, type UserContextApplicationService } from './contexts/user/index.ts'; export type { CommunityUpdateSettingsCommand } from './contexts/community/index.ts'; @@ -9,6 +10,7 @@ export type { CommunityUpdateSettingsCommand } from './contexts/community/index. export interface ApplicationServices { Community: CommunityContextApplicationService; Service: ServiceContextApplicationService; + TechAdmin: TechAdminContextApplicationService; User: UserContextApplicationService; get verifiedUser(): VerifiedUser | null; } @@ -66,13 +68,14 @@ export const buildApplicationServicesFactory = (context: ApiContextSpec): Applic } } - const { dataSourcesFactory, blobStorageService, queueStorageService } = context; + const { dataSourcesFactory, blobStorageService, clientOperationsService, queueStorageService } = context; const dataSources = dataSourcesFactory.withPassport(passport); return { Community: Community(dataSources, blobStorageService, queueStorageService), Service: Service(dataSources), + TechAdmin: TechAdmin(blobStorageService, clientOperationsService), User: User(dataSources), get verifiedUser(): VerifiedUser | null { return { ...tokenValidationResult, hints: hints }; diff --git a/packages/ocom/graphql/src/schema/types/blob-explorer.graphql b/packages/ocom/graphql/src/schema/types/blob-explorer.graphql new file mode 100644 index 000000000..649c8c7ad --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/blob-explorer.graphql @@ -0,0 +1,61 @@ +type BlobExplorerContainer { + name: String! +} + +type BlobExplorerBlobItem { + name: String! + contentType: String + contentLength: Int + lastModified: String + metadata: JSON + tags: JSON +} + +type BlobExplorerHierarchyPage { + items: [BlobExplorerBlobItem!]! + prefixes: [String!]! + continuationToken: String +} + +type BlobExplorerContainerListResult { + containers: [BlobExplorerContainer!]! +} + +type BlobExplorerDownloadAuthorizationResult { + url: String! + authorizationHeader: String! + headers: JSON! +} + +input BlobExplorerMetadataFilterInput { + key: String! + value: String! +} + +input BlobExplorerTagFilterInput { + key: String! + value: String! +} + +input BlobExplorerListBlobsInput { + containerName: String! + prefix: String + continuationToken: String + maxResults: Int + nameFilter: String + metadataFilter: BlobExplorerMetadataFilterInput + tagFilter: BlobExplorerTagFilterInput +} + +input BlobExplorerDownloadAuthorizationInput { + containerName: String! + blobName: String! + contentLength: Int! + contentType: String! +} + +extend type Query { + blobExplorerListContainers: BlobExplorerContainerListResult! + blobExplorerListBlobs(input: BlobExplorerListBlobsInput!): BlobExplorerHierarchyPage! + blobExplorerGetDownloadAuthorization(input: BlobExplorerDownloadAuthorizationInput!): BlobExplorerDownloadAuthorizationResult! +} diff --git a/packages/ocom/graphql/src/schema/types/blob-explorer.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/blob-explorer.resolvers.test.ts new file mode 100644 index 000000000..dc04118b9 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/blob-explorer.resolvers.test.ts @@ -0,0 +1,368 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import { type FieldNode, type GraphQLObjectType, type GraphQLResolveInfo, type GraphQLSchema, Kind, type OperationDefinitionNode } from 'graphql'; +import { expect, vi } from 'vitest'; +import type { GraphContext } from '../context.ts'; +import blobExplorerResolvers from './blob-explorer.resolvers.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/blob-explorer.resolvers.feature')); + +// ─── Domain types ───────────────────────────────────────────────────────────── + +type StaffUserEntity = Domain.Contexts.User.StaffUser.StaffUserEntityReference; + +// ─── Mock factories ─────────────────────────────────────────────────────────── + +function createMockStaffUser(canViewBlobExplorer = false): StaffUserEntity { + return { + id: 'mock-staff-user-id', + externalId: 'mock-external-id', + firstName: 'Jane', + lastName: 'Smith', + displayName: 'Jane Smith', + email: 'jane@example.com', + accessBlocked: false, + tags: [], + userType: 'staff', + role: canViewBlobExplorer + ? ({ + id: 'mock-role-id', + roleName: 'TechAdmin', + permissions: { + techAdminPermissions: { + canViewBlobExplorer: true, + canManageTechAdmin: true, + canViewDatabaseExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + }, + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference) + : ({ + id: 'mock-role-id', + roleName: 'CaseManager', + permissions: { + techAdminPermissions: { + canViewBlobExplorer: false, + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + }, + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference), + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + activityLog: [], + } as unknown as StaffUserEntity; +} + +function makeMockInfo(fieldName: string): GraphQLResolveInfo { + const mockFieldNode: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: fieldName } }; + return { + fieldName, + fieldNodes: [mockFieldNode], + returnType: {} as GraphQLObjectType, + parentType: {} as GraphQLObjectType, + path: { key: fieldName, prev: undefined, typename: undefined }, + schema: {} as GraphQLSchema, + fragments: {}, + rootValue: {}, + operation: {} as OperationDefinitionNode, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +type JwtOverride = { + sub?: string; + given_name?: string; + family_name?: string; + email?: string; + roles?: string[]; +}; + +type MockedBlobExplorerService = { + listContainers: ReturnType; + listBlobsHierarchy: ReturnType; + getBlobDownloadAuthorization: ReturnType; +}; + +type MockedStaffUserService = GraphContext['applicationServices']['User']['StaffUser'] & { + queryByExternalId: ReturnType; +}; + +type TestGraphContext = Omit & { + applicationServices: Omit & { + User: Omit & { + StaffUser: MockedStaffUserService; + }; + TechAdmin: { + BlobExplorer: MockedBlobExplorerService; + }; + }; +}; + +function makeMockGraphContext(options: { jwt?: JwtOverride | null; canViewBlobExplorer?: boolean; blobExplorerServices?: Partial } = {}): TestGraphContext { + const { jwt = {}, canViewBlobExplorer = false, blobExplorerServices = {} } = options; + const mockStaffUser = createMockStaffUser(canViewBlobExplorer); + return { + applicationServices: { + User: { + StaffUser: { + queryByExternalId: vi.fn().mockResolvedValue(mockStaffUser), + } as unknown as MockedStaffUserService, + } as unknown as TestGraphContext['applicationServices']['User'], + TechAdmin: { + BlobExplorer: { + listContainers: vi.fn(), + listBlobsHierarchy: vi.fn(), + getBlobDownloadAuthorization: vi.fn(), + ...blobExplorerServices, + }, + }, + verifiedUser: + jwt === null + ? undefined + : { + verifiedJwt: + jwt === null + ? undefined + : { + sub: 'default-user-sub', + given_name: 'Jane', + family_name: 'Smith', + email: 'jane@example.com', + roles: [], + ...jwt, + }, + }, + } as unknown as TestGraphContext['applicationServices'], + } as unknown as TestGraphContext; +} + +// ─── Resolver references ────────────────────────────────────────────────────── + +// biome-ignore lint/style/noNonNullAssertion: test helper — key always exists +const Query = blobExplorerResolvers.Query!; + +const callQuery = (name: string, context: GraphContext, args: object = {}) => + // biome-ignore lint/style/noNonNullAssertion: test helper — key always exists + Query[name as keyof typeof Query]!({}, args, context, makeMockInfo(name)) as Promise; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let context: TestGraphContext; + let result: unknown; + let thrownError: unknown; + + BeforeEachScenario(() => { + context = makeMockGraphContext(); + result = undefined; + thrownError = undefined; + vi.clearAllMocks(); + }); + + // ─── blobExplorerListContainers ─────────────────────────────────────────── + + Scenario('Listing containers when authenticated with canViewBlobExplorer permission', ({ Given, When, Then }) => { + Given('a staff user with a verifiedJwt and canViewBlobExplorer permission', () => { + context = makeMockGraphContext({ + canViewBlobExplorer: true, + blobExplorerServices: { + listContainers: vi.fn().mockResolvedValue({ containers: [{ name: 'test-container', url: 'https://example.com/test-container' }] }), + }, + }); + }); + + When('the blobExplorerListContainers query is executed', async () => { + result = await callQuery('blobExplorerListContainers', context as unknown as GraphContext); + }); + + Then('it should return the list of containers', () => { + const res = result as { containers: { name: string }[] }; + expect(res.containers).toBeDefined(); + expect(res.containers.length).toBeGreaterThan(0); + }); + }); + + Scenario('Listing containers when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the blobExplorerListContainers query is executed', async () => { + try { + result = await callQuery('blobExplorerListContainers', context as unknown as GraphContext); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + Scenario('Listing containers when missing canViewBlobExplorer permission', ({ Given, When, Then }) => { + Given('a staff user with a verifiedJwt but without canViewBlobExplorer permission', () => { + context = makeMockGraphContext({ canViewBlobExplorer: false }); + }); + + When('the blobExplorerListContainers query is executed', async () => { + try { + result = await callQuery('blobExplorerListContainers', context as unknown as GraphContext); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── blobExplorerListBlobs ──────────────────────────────────────────────── + + Scenario('Listing blobs when authenticated with canViewBlobExplorer permission', ({ Given, When, Then }) => { + Given('a staff user with a verifiedJwt and canViewBlobExplorer permission', () => { + context = makeMockGraphContext({ + canViewBlobExplorer: true, + blobExplorerServices: { + listBlobsHierarchy: vi.fn().mockResolvedValue({ + items: [{ name: 'file.txt', contentType: 'text/plain', contentLength: 100 }], + prefixes: ['folder/'], + continuationToken: undefined, + }), + }, + }); + }); + + When('the blobExplorerListBlobs query is executed with containerName "my-container"', async () => { + result = await callQuery('blobExplorerListBlobs', context as unknown as GraphContext, { input: { containerName: 'my-container' } }); + }); + + Then('it should return the hierarchy page with items and prefixes', () => { + const res = result as { items: unknown[]; prefixes: string[] }; + expect(res.items).toBeDefined(); + expect(res.prefixes).toBeDefined(); + }); + }); + + Scenario('Listing blobs when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the blobExplorerListBlobs query is executed with containerName "my-container"', async () => { + try { + result = await callQuery('blobExplorerListBlobs', context as unknown as GraphContext, { input: { containerName: 'my-container' } }); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + Scenario('Listing blobs when missing canViewBlobExplorer permission', ({ Given, When, Then }) => { + Given('a staff user with a verifiedJwt but without canViewBlobExplorer permission', () => { + context = makeMockGraphContext({ canViewBlobExplorer: false }); + }); + + When('the blobExplorerListBlobs query is executed with containerName "my-container"', async () => { + try { + result = await callQuery('blobExplorerListBlobs', context as unknown as GraphContext, { input: { containerName: 'my-container' } }); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── blobExplorerGetDownloadAuthorization ───────────────────────────────── + + Scenario('Getting download authorization when authenticated with canViewBlobExplorer permission', ({ Given, When, Then }) => { + Given('a staff user with a verifiedJwt and canViewBlobExplorer permission', () => { + context = makeMockGraphContext({ + canViewBlobExplorer: true, + blobExplorerServices: { + getBlobDownloadAuthorization: vi.fn().mockResolvedValue({ + url: 'https://example.com/my-container/file.txt', + authorizationHeader: 'SharedKey devstoreaccount1:abc123', + headers: { 'Content-Type': 'text/plain', 'Content-Length': '100' }, + }), + }, + }); + }); + + When('the blobExplorerGetDownloadAuthorization query is executed with containerName "my-container" and blobName "file.txt"', async () => { + result = await callQuery('blobExplorerGetDownloadAuthorization', context as unknown as GraphContext, { + input: { containerName: 'my-container', blobName: 'file.txt', contentLength: 100, contentType: 'text/plain' }, + }); + }); + + Then('it should return the download authorization result', () => { + const res = result as { url: string; authorizationHeader: string; headers: Record }; + expect(res.url).toBeDefined(); + expect(res.authorizationHeader).toBeDefined(); + expect(res.headers).toBeDefined(); + }); + }); + + Scenario('Getting download authorization when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the blobExplorerGetDownloadAuthorization query is executed with containerName "my-container" and blobName "file.txt"', async () => { + try { + result = await callQuery('blobExplorerGetDownloadAuthorization', context as unknown as GraphContext, { + input: { containerName: 'my-container', blobName: 'file.txt', contentLength: 100, contentType: 'text/plain' }, + }); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + Scenario('Getting download authorization when missing canViewBlobExplorer permission', ({ Given, When, Then }) => { + Given('a staff user with a verifiedJwt but without canViewBlobExplorer permission', () => { + context = makeMockGraphContext({ canViewBlobExplorer: false }); + }); + + When('the blobExplorerGetDownloadAuthorization query is executed with containerName "my-container" and blobName "file.txt"', async () => { + try { + result = await callQuery('blobExplorerGetDownloadAuthorization', context as unknown as GraphContext, { + input: { containerName: 'my-container', blobName: 'file.txt', contentLength: 100, contentType: 'text/plain' }, + }); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); +}); diff --git a/packages/ocom/graphql/src/schema/types/blob-explorer.resolvers.ts b/packages/ocom/graphql/src/schema/types/blob-explorer.resolvers.ts new file mode 100644 index 000000000..24d8b6464 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/blob-explorer.resolvers.ts @@ -0,0 +1,80 @@ +import type { GraphQLResolveInfo } from 'graphql'; +import type { QueryBlobExplorerGetDownloadAuthorizationArgs, QueryBlobExplorerListBlobsArgs, Resolvers } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; + +const blobExplorer: Resolvers = { + Query: { + blobExplorerListContainers: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + + const jwt = context.applicationServices.verifiedUser.verifiedJwt; + const staffUser = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + if (!staffUser?.role?.permissions?.techAdminPermissions?.canViewBlobExplorer) { + throw new Error('Unauthorized'); + } + + const result = await context.applicationServices.TechAdmin.BlobExplorer.listContainers(); + return { containers: result.containers.map((c) => ({ name: c.name })) }; + }, + + blobExplorerListBlobs: async (_parent, args: QueryBlobExplorerListBlobsArgs, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + + const jwt = context.applicationServices.verifiedUser.verifiedJwt; + const staffUser = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + if (!staffUser?.role?.permissions?.techAdminPermissions?.canViewBlobExplorer) { + throw new Error('Unauthorized'); + } + + const { input } = args; + const request = { + containerName: input.containerName, + ...(input.prefix != null ? { prefix: input.prefix } : {}), + ...(input.continuationToken != null ? { continuationToken: input.continuationToken } : {}), + ...(input.maxResults != null ? { maxResults: input.maxResults } : {}), + ...(input.nameFilter != null ? { nameFilter: input.nameFilter } : {}), + ...(input.metadataFilter != null ? { metadataFilter: { key: input.metadataFilter.key, value: input.metadataFilter.value } } : {}), + ...(input.tagFilter != null ? { tagFilter: { key: input.tagFilter.key, value: input.tagFilter.value } } : {}), + }; + const result = await context.applicationServices.TechAdmin.BlobExplorer.listBlobsHierarchy(request); + return { + items: result.items.map((item) => ({ + name: item.name, + contentType: item.contentType ?? null, + contentLength: item.contentLength ?? null, + lastModified: item.lastModified ? item.lastModified.toISOString() : null, + metadata: item.metadata ?? null, + tags: item.tags ?? null, + })), + prefixes: result.prefixes, + continuationToken: result.continuationToken ?? null, + }; + }, + + blobExplorerGetDownloadAuthorization: async (_parent, args: QueryBlobExplorerGetDownloadAuthorizationArgs, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + + const jwt = context.applicationServices.verifiedUser.verifiedJwt; + const staffUser = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + if (!staffUser?.role?.permissions?.techAdminPermissions?.canViewBlobExplorer) { + throw new Error('Unauthorized'); + } + + const { input } = args; + return await context.applicationServices.TechAdmin.BlobExplorer.getBlobDownloadAuthorization({ + containerName: input.containerName, + blobName: input.blobName, + contentLength: input.contentLength, + contentType: input.contentType, + }); + }, + }, +}; + +export default blobExplorer; diff --git a/packages/ocom/graphql/src/schema/types/features/blob-explorer.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/blob-explorer.resolvers.feature new file mode 100644 index 000000000..ba4da8230 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/features/blob-explorer.resolvers.feature @@ -0,0 +1,56 @@ +Feature: Blob Explorer Resolvers + + As an API consumer + I want to query blob storage containers and blobs + So that I can browse Azure Blob Storage from the Staff Portal + + # ─── blobExplorerListContainers ───────────────────────────────────────────── + + Scenario: Listing containers when authenticated with canViewBlobExplorer permission + Given a staff user with a verifiedJwt and canViewBlobExplorer permission + When the blobExplorerListContainers query is executed + Then it should return the list of containers + + Scenario: Listing containers when unauthenticated + Given a user without a verifiedJwt in their context + When the blobExplorerListContainers query is executed + Then it should throw an "Unauthorized" error + + Scenario: Listing containers when missing canViewBlobExplorer permission + Given a staff user with a verifiedJwt but without canViewBlobExplorer permission + When the blobExplorerListContainers query is executed + Then it should throw an "Unauthorized" error + + # ─── blobExplorerListBlobs ─────────────────────────────────────────────────── + + Scenario: Listing blobs when authenticated with canViewBlobExplorer permission + Given a staff user with a verifiedJwt and canViewBlobExplorer permission + When the blobExplorerListBlobs query is executed with containerName "my-container" + Then it should return the hierarchy page with items and prefixes + + Scenario: Listing blobs when unauthenticated + Given a user without a verifiedJwt in their context + When the blobExplorerListBlobs query is executed with containerName "my-container" + Then it should throw an "Unauthorized" error + + Scenario: Listing blobs when missing canViewBlobExplorer permission + Given a staff user with a verifiedJwt but without canViewBlobExplorer permission + When the blobExplorerListBlobs query is executed with containerName "my-container" + Then it should throw an "Unauthorized" error + + # ─── blobExplorerGetDownloadAuthorization ──────────────────────────────────── + + Scenario: Getting download authorization when authenticated with canViewBlobExplorer permission + Given a staff user with a verifiedJwt and canViewBlobExplorer permission + When the blobExplorerGetDownloadAuthorization query is executed with containerName "my-container" and blobName "file.txt" + Then it should return the download authorization result + + Scenario: Getting download authorization when unauthenticated + Given a user without a verifiedJwt in their context + When the blobExplorerGetDownloadAuthorization query is executed with containerName "my-container" and blobName "file.txt" + Then it should throw an "Unauthorized" error + + Scenario: Getting download authorization when missing canViewBlobExplorer permission + Given a staff user with a verifiedJwt but without canViewBlobExplorer permission + When the blobExplorerGetDownloadAuthorization query is executed with containerName "my-container" and blobName "file.txt" + Then it should throw an "Unauthorized" error diff --git a/packages/ocom/service-blob-storage/src/blob-storage.contract.ts b/packages/ocom/service-blob-storage/src/blob-storage.contract.ts index 200c7f8b2..d015cfec6 100644 --- a/packages/ocom/service-blob-storage/src/blob-storage.contract.ts +++ b/packages/ocom/service-blob-storage/src/blob-storage.contract.ts @@ -9,7 +9,7 @@ export type CreateBlobAccessUrlRequest = CreateBlobAuthorizationHeaderRequest; * application can depend on only the backend blob methods without redefining * their documentation locally. */ -export type BlobStorageOperations = Pick; +export type BlobStorageOperations = Pick; /** * Client-side blob signing operations. diff --git a/packages/ocom/service-blob-storage/src/index.ts b/packages/ocom/service-blob-storage/src/index.ts index 7c09d629e..7d28c9397 100644 --- a/packages/ocom/service-blob-storage/src/index.ts +++ b/packages/ocom/service-blob-storage/src/index.ts @@ -1,9 +1,18 @@ export type { BlobAddress, + BlobContainerItem, + BlobExplorerBlobItem, + BlobExplorerHierarchyPage, + BlobExplorerMetadataFilter, + BlobExplorerTagFilter, BlobListItem, + BlobUploadAuthorizationHeader, ClientBlobStorage, + CreateBlobAuthorizationHeaderRequest, CreateBlobSasUrlRequest, + ListBlobsHierarchyRequest, ListBlobsRequest, + ListContainersResult, ServiceBlobStorageOptions, ServiceClientBlobStorageOptions, UploadTextBlobRequest, diff --git a/packages/ocom/ui-staff-route-tech-admin/package.json b/packages/ocom/ui-staff-route-tech-admin/package.json index a1622d8fb..5cd8a8967 100644 --- a/packages/ocom/ui-staff-route-tech-admin/package.json +++ b/packages/ocom/ui-staff-route-tech-admin/package.json @@ -10,12 +10,18 @@ "prebuild": "pnpm run lint", "build": "tsgo --noEmit", "lint": "biome lint", + "storybook": "storybook dev --port 6008", + "build-storybook": "storybook build", "test": "vitest run --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { "@ant-design/icons": "catalog:", + "@apollo/client": "^3.13.9", + "@graphql-typed-document-node/core": "^3.2.0", + "@ocom/ui-shared": "workspace:*", "@ocom/ui-staff-shared": "workspace:*", + "antd": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "catalog:" @@ -23,9 +29,16 @@ "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@chromatic-com/storybook": "^5.2.1", + "@storybook/react": "catalog:", + "@storybook/addon-a11y": "catalog:", + "@storybook/addon-docs": "catalog:", + "@storybook/addon-vitest": "catalog:", + "@storybook/react-vite": "catalog:", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", "jsdom": "catalog:", + "storybook": "catalog:", "vite": "catalog:", "vitest": "catalog:", "typescript": "catalog:" diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.container.graphql b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.container.graphql new file mode 100644 index 000000000..5b4cb4cd2 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.container.graphql @@ -0,0 +1,7 @@ +query BlobExplorerContainers { + blobExplorerListContainers { + containers { + name + } + } +} diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.container.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.container.tsx new file mode 100644 index 000000000..4e851fe0e --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.container.tsx @@ -0,0 +1,22 @@ +import { useQuery } from '@apollo/client'; +import type React from 'react'; +import { BlobExplorerContainersDocument } from '../generated.tsx'; +import { BlobContainerList } from './blob-container-list.tsx'; + +export interface BlobContainerListContainerProps { + onSelect: (containerName: string) => void; +} + +export const BlobContainerListContainer: React.FC = ({ onSelect }) => { + const { data, loading } = useQuery(BlobExplorerContainersDocument, { + fetchPolicy: 'cache-and-network', + }); + + return ( + ({ name: c.name }))} + onSelect={onSelect} + loading={loading} + /> + ); +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.stories.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.stories.tsx new file mode 100644 index 000000000..ec1156004 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BlobContainerList } from './blob-container-list.tsx'; + +const meta: Meta = { + title: 'TechAdmin/Components/BlobContainerList', + component: BlobContainerList, + parameters: { layout: 'padded' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + containers: [{ name: 'member-assets' }, { name: 'community-assets' }, { name: 'audit-logs' }], + onSelect: (name) => console.log('Selected container:', name), + loading: false, + }, +}; + +export const Loading: Story = { + args: { + containers: [], + onSelect: () => undefined, + loading: true, + }, +}; + +export const Empty: Story = { + args: { + containers: [], + onSelect: () => undefined, + loading: false, + }, +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.tsx new file mode 100644 index 000000000..38b0f62d8 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-container-list.tsx @@ -0,0 +1,29 @@ +import { List, Spin } from 'antd'; +import type React from 'react'; + +export interface BlobContainerListProps { + containers: { name: string }[]; + onSelect: (name: string) => void; + loading?: boolean; +} + +export const BlobContainerList: React.FC = ({ containers, onSelect, loading }) => { + if (loading) { + return ; + } + + return ( + ( + onSelect(container.name)} + > + {container.name} + + )} + /> + ); +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.container.graphql b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.container.graphql new file mode 100644 index 000000000..4e53dadd7 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.container.graphql @@ -0,0 +1,14 @@ +query BlobExplorerBlobs($input: BlobExplorerListBlobsInput!) { + blobExplorerListBlobs(input: $input) { + items { + name + contentType + contentLength + lastModified + metadata + tags + } + prefixes + continuationToken + } +} diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.container.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.container.tsx new file mode 100644 index 000000000..b2a98f859 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.container.tsx @@ -0,0 +1,105 @@ +import { useQuery } from '@apollo/client'; +import type React from 'react'; +import { useState } from 'react'; +import { BlobExplorerBlobsDocument } from '../generated.tsx'; +import type { BlobExplorerBlobItemProps } from './blob-list.tsx'; +import { BlobList } from './blob-list.tsx'; + +export interface BlobListContainerProps { + containerName: string; + onView?: (item: BlobExplorerBlobItemProps) => void; +} + +export const BlobListContainer: React.FC = ({ containerName, onView }) => { + const [prefix, setPrefix] = useState(''); + const [breadcrumbs, setBreadcrumbs] = useState([]); + const [allItems, setAllItems] = useState([]); + const [allPrefixes, setAllPrefixes] = useState([]); + const [continuationToken, setContinuationToken] = useState(undefined); + + const { data, loading, fetchMore } = useQuery(BlobExplorerBlobsDocument, { + variables: { input: { containerName, ...(prefix ? { prefix } : {}) } }, + fetchPolicy: 'cache-and-network', + onCompleted: (result) => { + const page = result.blobExplorerListBlobs; + setAllItems( + page.items.map((item) => ({ + name: item.name, + ...(item.contentType != null ? { contentType: item.contentType } : {}), + ...(item.contentLength != null ? { contentLength: item.contentLength } : {}), + ...(item.lastModified != null ? { lastModified: item.lastModified } : {}), + ...(item.metadata != null ? { metadata: item.metadata } : {}), + ...(item.tags != null ? { tags: item.tags } : {}), + })), + ); + setAllPrefixes([...page.prefixes]); + setContinuationToken(page.continuationToken ?? undefined); + }, + }); + + const handleNavigate = (newPrefix: string) => { + setAllItems([]); + setAllPrefixes([]); + setContinuationToken(undefined); + const segments = newPrefix.replace(/\/$/, '').split('/'); + setBreadcrumbs(segments); + setPrefix(newPrefix); + }; + + const handleBreadcrumb = (index: number) => { + if (index < 0) { + setPrefix(''); + setBreadcrumbs([]); + } else { + const newBreadcrumbs = breadcrumbs.slice(0, index + 1); + const newPrefix = `${newBreadcrumbs.join('/')}/`; + setBreadcrumbs(newBreadcrumbs); + setPrefix(newPrefix); + } + setAllItems([]); + setAllPrefixes([]); + setContinuationToken(undefined); + }; + + const handleLoadMore = async () => { + if (!continuationToken) return; + const result = await fetchMore({ + variables: { input: { containerName, ...(prefix ? { prefix } : {}), continuationToken } }, + }); + const page = result.data.blobExplorerListBlobs; + setAllItems((prev) => [ + ...prev, + ...page.items.map((item) => ({ + name: item.name, + ...(item.contentType != null ? { contentType: item.contentType } : {}), + ...(item.contentLength != null ? { contentLength: item.contentLength } : {}), + ...(item.lastModified != null ? { lastModified: item.lastModified } : {}), + ...(item.metadata != null ? { metadata: item.metadata } : {}), + ...(item.tags != null ? { tags: item.tags } : {}), + })), + ]); + setContinuationToken(page.continuationToken ?? undefined); + }; + + void data; + + return ( + onView?.(item)} + breadcrumbs={breadcrumbs} + onBreadcrumb={handleBreadcrumb} + {...(continuationToken ? { continuationToken } : {})} + {...(onView + ? { + onLoadMore: () => { + void handleLoadMore(); + }, + } + : {})} + loading={loading} + /> + ); +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.stories.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.stories.tsx new file mode 100644 index 000000000..5801acd7b --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BlobList } from './blob-list.tsx'; + +const meta: Meta = { + title: 'TechAdmin/Components/BlobList', + component: BlobList, + parameters: { layout: 'padded' }, +}; + +export default meta; +type Story = StoryObj; + +const mockItems = [ + { name: 'documents/report.pdf', contentType: 'application/pdf', contentLength: 204800, lastModified: '2024-01-15T10:00:00Z' }, + { name: 'images/logo.png', contentType: 'image/png', contentLength: 51200, lastModified: '2024-02-01T12:00:00Z' }, + { name: 'data.json', contentType: 'application/json', contentLength: 1024, lastModified: '2024-03-01T08:00:00Z' }, +]; + +export const Default: Story = { + args: { + items: mockItems, + prefixes: ['documents/', 'images/'], + onNavigate: (prefix) => console.log('Navigate to:', prefix), + onView: (item) => console.log('View:', item.name), + breadcrumbs: [], + onBreadcrumb: (index) => console.log('Breadcrumb:', index), + loading: false, + }, +}; + +export const Loading: Story = { + args: { + items: [], + prefixes: [], + onNavigate: () => undefined, + onView: () => undefined, + breadcrumbs: [], + onBreadcrumb: () => undefined, + loading: true, + }, +}; + +export const WithBreadcrumbs: Story = { + args: { + items: mockItems, + prefixes: [], + onNavigate: () => undefined, + onView: () => undefined, + breadcrumbs: ['documents', 'reports'], + onBreadcrumb: (index) => console.log('Breadcrumb:', index), + loading: false, + }, +}; + +export const WithLoadMore: Story = { + args: { + items: mockItems, + prefixes: [], + onNavigate: () => undefined, + onView: () => undefined, + breadcrumbs: [], + onBreadcrumb: () => undefined, + continuationToken: 'next-page-token', + onLoadMore: () => console.log('Load more'), + loading: false, + }, +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.tsx new file mode 100644 index 000000000..190e64b76 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-list.tsx @@ -0,0 +1,140 @@ +import type { TableColumnsType } from 'antd'; +import { Breadcrumb, Button, Space, Spin, Table, Typography } from 'antd'; +import type React from 'react'; + +const { Title } = Typography; + +export interface BlobExplorerBlobItemProps { + name: string; + contentType?: string | null; + contentLength?: number | null; + lastModified?: string | null; + metadata?: unknown; + tags?: unknown; +} + +export interface BlobListProps { + items: BlobExplorerBlobItemProps[]; + prefixes: string[]; + onNavigate: (prefix: string) => void; + onView: (item: BlobExplorerBlobItemProps) => void; + breadcrumbs: string[]; + onBreadcrumb: (index: number) => void; + continuationToken?: string | null; + onLoadMore?: () => void; + loading?: boolean; +} + +export const BlobList: React.FC = ({ items, prefixes, onNavigate, onView, breadcrumbs, onBreadcrumb, continuationToken, onLoadMore, loading }) => { + const columns: TableColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name' }, + { title: 'Content Type', dataIndex: 'contentType', key: 'contentType', render: (v: string | null) => v ?? '—' }, + { + title: 'Size', + dataIndex: 'contentLength', + key: 'contentLength', + render: (v: number | null) => (v != null ? `${v} bytes` : '—'), + }, + { + title: 'Last Modified', + dataIndex: 'lastModified', + key: 'lastModified', + render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—'), + }, + { + title: 'Action', + key: 'action', + render: (_: unknown, record: BlobExplorerBlobItemProps) => ( + + ), + }, + ]; + + const prefixItems = prefixes.map((prefix) => ({ + name: prefix, + contentType: undefined, + contentLength: undefined, + lastModified: undefined, + })); + + const prefixColumns: TableColumnsType<{ name: string }> = [ + { + title: 'Virtual Directory', + dataIndex: 'name', + key: 'name', + render: (name: string) => ( + + ), + }, + ]; + + return ( + + onBreadcrumb(-1)} + > + Root + + ), + }, + ...breadcrumbs.map((crumb, index) => ({ + title: ( + + ), + })), + ]} + /> + {loading ? ( + + ) : ( + <> + {prefixes.length > 0 && ( + <> + Virtual Directories + + + )} + Blobs +
+ {continuationToken && onLoadMore && } + + )} + + ); +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.container.graphql b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.container.graphql new file mode 100644 index 000000000..d242f2eb6 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.container.graphql @@ -0,0 +1,7 @@ +query BlobExplorerDownloadAuth($input: BlobExplorerDownloadAuthorizationInput!) { + blobExplorerGetDownloadAuthorization(input: $input) { + url + authorizationHeader + headers + } +} diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.container.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.container.tsx new file mode 100644 index 000000000..adfa16e19 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.container.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@apollo/client'; +import type React from 'react'; +import { BlobExplorerDownloadAuthDocument } from '../generated.tsx'; +import type { BlobExplorerBlobItemProps } from './blob-list.tsx'; +import { BlobPreviewModal } from './blob-preview-modal.tsx'; + +export interface BlobPreviewModalContainerProps { + open: boolean; + containerName: string; + blobItem: BlobExplorerBlobItemProps | null; + onClose: () => void; +} + +export const BlobPreviewModalContainer: React.FC = ({ open, containerName, blobItem, onClose }) => { + const { data, loading } = useQuery(BlobExplorerDownloadAuthDocument, { + variables: { + input: { + containerName, + blobName: blobItem?.name ?? '', + contentLength: blobItem?.contentLength ?? 0, + contentType: blobItem?.contentType ?? 'application/octet-stream', + }, + }, + skip: !open || !blobItem, + }); + + return ( + + ); +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.stories.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.stories.tsx new file mode 100644 index 000000000..8be334b12 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BlobPreviewModal } from './blob-preview-modal.tsx'; + +const meta: Meta = { + title: 'TechAdmin/Components/BlobPreviewModal', + component: BlobPreviewModal, + parameters: { layout: 'padded' }, +}; + +export default meta; +type Story = StoryObj; + +export const ImagePreview: Story = { + args: { + open: true, + blobName: 'images/logo.png', + contentType: 'image/png', + downloadUrl: 'https://via.placeholder.com/400', + authorizationHeader: undefined, + onClose: () => console.log('Close'), + loading: false, + }, +}; + +export const UnsupportedType: Story = { + args: { + open: true, + blobName: 'data/archive.zip', + contentType: 'application/zip', + downloadUrl: 'https://example.com/archive.zip', + onClose: () => console.log('Close'), + loading: false, + }, +}; + +export const Loading: Story = { + args: { + open: true, + blobName: 'documents/report.pdf', + contentType: 'application/pdf', + onClose: () => console.log('Close'), + loading: true, + }, +}; + +export const Closed: Story = { + args: { + open: false, + blobName: 'documents/report.pdf', + contentType: 'application/pdf', + onClose: () => console.log('Close'), + }, +}; diff --git a/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.tsx b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.tsx new file mode 100644 index 000000000..177fc2be3 --- /dev/null +++ b/packages/ocom/ui-staff-route-tech-admin/src/components/blob-preview-modal.tsx @@ -0,0 +1,112 @@ +import { Button, Modal, Spin, Typography } from 'antd'; +import type React from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +const { Text } = Typography; + +export interface BlobPreviewModalProps { + open: boolean; + blobName: string; + contentType?: string | null; + downloadUrl?: string; + authorizationHeader?: string; + onClose: () => void; + loading?: boolean; +} + +export const BlobPreviewModal: React.FC = ({ open, blobName, contentType, downloadUrl, authorizationHeader, onClose, loading }) => { + const [textContent, setTextContent] = useState(null); + const [fetchError, setFetchError] = useState(null); + + const isImage = contentType?.startsWith('image/'); + const isPdf = contentType === 'application/pdf'; + const isText = contentType?.startsWith('text/') || contentType === 'application/json'; + + const fetchText = useCallback(async () => { + if (!downloadUrl || !isText) return; + try { + const headersInit: HeadersInit = authorizationHeader ? { Authorization: authorizationHeader } : {}; + const response = await fetch(downloadUrl, { headers: headersInit }); + const text = await response.text(); + setTextContent(text); + } catch (err) { + setFetchError(String(err)); + } + }, [downloadUrl, authorizationHeader, isText]); + + useEffect(() => { + if (open && isText && downloadUrl) { + void fetchText(); + } else { + setTextContent(null); + setFetchError(null); + } + }, [open, isText, downloadUrl, fetchText]); + + const handleDownload = () => { + if (!downloadUrl) return; + const link = document.createElement('a'); + link.href = downloadUrl; + if (authorizationHeader) { + // For authenticated downloads we open in a new tab; the client handles auth + window.open(downloadUrl, '_blank'); + } else { + link.download = blobName.split('/').pop() ?? blobName; + link.click(); + } + }; + + const renderPreview = () => { + if (loading) return ; + if (!downloadUrl) return No preview available; + if (isImage) + return ( + {blobName} + ); + if (isPdf) + return ( +