diff --git a/dbal/development/src/adapters/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter.ts index a5bfa02e5..9fbcd7d83 100644 --- a/dbal/development/src/adapters/acl-adapter.ts +++ b/dbal/development/src/adapters/acl-adapter.ts @@ -1,3 +1,3 @@ -export { ACLAdapter } from './acl-adapter/index' -export type { User, ACLRule } from './acl/types' +export { ACLAdapter } from './acl-adapter' +export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './acl-adapter/types' export { defaultACLRules } from './acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/acl-adapter.ts b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts new file mode 100644 index 000000000..9d2492451 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/acl-adapter.ts @@ -0,0 +1,86 @@ +import type { AdapterCapabilities, DBALAdapter } from '../adapter' +import type { ListOptions, ListResult } from '../../core/foundation/types' +import { createContext } from './context' +import { createReadStrategy } from './read-strategy' +import { createWriteStrategy } from './write-strategy' +import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' + +export class ACLAdapter implements DBALAdapter { + private readonly context: ACLContext + private readonly readStrategy: ReturnType + private readonly writeStrategy: ReturnType + + constructor(baseAdapter: DBALAdapter, user: User, options?: ACLAdapterOptions) { + this.context = createContext(baseAdapter, user, options) + this.readStrategy = createReadStrategy(this.context) + this.writeStrategy = createWriteStrategy(this.context) + } + + async create(entity: string, data: Record): Promise { + return this.writeStrategy.create(entity, data) + } + + async read(entity: string, id: string): Promise { + return this.readStrategy.read(entity, id) + } + + async update(entity: string, id: string, data: Record): Promise { + return this.writeStrategy.update(entity, id, data) + } + + async delete(entity: string, id: string): Promise { + return this.writeStrategy.delete(entity, id) + } + + async list(entity: string, options?: ListOptions): Promise> { + return this.readStrategy.list(entity, options) + } + + async findFirst(entity: string, filter?: Record): Promise { + return this.readStrategy.findFirst(entity, filter) + } + + async findByField(entity: string, field: string, value: unknown): Promise { + return this.readStrategy.findByField(entity, field, value) + } + + async upsert( + entity: string, + filter: Record, + createData: Record, + updateData: Record, + ): Promise { + return this.writeStrategy.upsert(entity, filter, createData, updateData) + } + + async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { + return this.writeStrategy.updateByField(entity, field, value, data) + } + + async deleteByField(entity: string, field: string, value: unknown): Promise { + return this.writeStrategy.deleteByField(entity, field, value) + } + + async createMany(entity: string, data: Record[]): Promise { + return this.writeStrategy.createMany(entity, data) + } + + async updateMany(entity: string, filter: Record, data: Record): Promise { + return this.writeStrategy.updateMany(entity, filter, data) + } + + async deleteMany(entity: string, filter?: Record): Promise { + return this.writeStrategy.deleteMany(entity, filter) + } + + async getCapabilities(): Promise { + return this.context.baseAdapter.getCapabilities() + } + + async close(): Promise { + await this.context.baseAdapter.close() + } +} + +export type { ACLAdapterOptions, ACLContext, ACLRule, User } +export { defaultACLRules } from '../acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/context.ts b/dbal/development/src/adapters/acl-adapter/context.ts index 9262dd64d..8213926b9 100644 --- a/dbal/development/src/adapters/acl-adapter/context.ts +++ b/dbal/development/src/adapters/acl-adapter/context.ts @@ -1,20 +1,12 @@ import type { DBALAdapter } from '../adapter' -import type { User, ACLRule } from '../acl/types' +import type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' import { logAudit } from '../acl/audit-logger' import { defaultACLRules } from '../acl/default-rules' -export interface ACLContext { - baseAdapter: DBALAdapter - user: User - rules: ACLRule[] - auditLog: boolean - logger: (entity: string, operation: string, success: boolean, message?: string) => void -} - export const createContext = ( baseAdapter: DBALAdapter, user: User, - options?: { rules?: ACLRule[]; auditLog?: boolean }, + options?: ACLAdapterOptions, ): ACLContext => { const auditLog = options?.auditLog ?? true const rules = options?.rules || defaultACLRules diff --git a/dbal/development/src/adapters/acl-adapter/guards.ts b/dbal/development/src/adapters/acl-adapter/guards.ts index 8da05a011..be5171354 100644 --- a/dbal/development/src/adapters/acl-adapter/guards.ts +++ b/dbal/development/src/adapters/acl-adapter/guards.ts @@ -1,7 +1,7 @@ import { checkPermission } from '../acl/check-permission' import { checkRowLevelAccess } from '../acl/check-row-level-access' import { resolvePermissionOperation } from '../acl/resolve-permission-operation' -import type { ACLContext } from './context' +import type { ACLContext } from './types' export const enforcePermission = (context: ACLContext, entity: string, operation: string) => { checkPermission(entity, operation, context.user, context.rules, context.logger) diff --git a/dbal/development/src/adapters/acl-adapter/index.ts b/dbal/development/src/adapters/acl-adapter/index.ts index 354fe2a58..b356927a7 100644 --- a/dbal/development/src/adapters/acl-adapter/index.ts +++ b/dbal/development/src/adapters/acl-adapter/index.ts @@ -1,92 +1,3 @@ -import type { AdapterCapabilities, DBALAdapter } from '../adapter' -import type { ListOptions, ListResult } from '../../core/foundation/types' -import type { User, ACLRule } from '../acl/types' -import type { ACLContext } from './context' -import { createContext } from './context' -import { createEntity, deleteEntity, listEntities, readEntity, updateEntity } from './crud' -import { - createMany, - deleteByField, - deleteMany, - findByField, - findFirst, - updateByField, - updateMany, - upsert, -} from './bulk' - -export class ACLAdapter implements DBALAdapter { - private readonly context: ACLContext - - constructor(baseAdapter: DBALAdapter, user: User, options?: { rules?: ACLRule[]; auditLog?: boolean }) { - this.context = createContext(baseAdapter, user, options) - } - - async create(entity: string, data: Record): Promise { - return createEntity(this.context)(entity, data) - } - - async read(entity: string, id: string): Promise { - return readEntity(this.context)(entity, id) - } - - async update(entity: string, id: string, data: Record): Promise { - return updateEntity(this.context)(entity, id, data) - } - - async delete(entity: string, id: string): Promise { - return deleteEntity(this.context)(entity, id) - } - - async list(entity: string, options?: ListOptions): Promise> { - return listEntities(this.context)(entity, options) - } - - async findFirst(entity: string, filter?: Record): Promise { - return findFirst(this.context)(entity, filter) - } - - async findByField(entity: string, field: string, value: unknown): Promise { - return findByField(this.context)(entity, field, value) - } - - async upsert( - entity: string, - filter: Record, - createData: Record, - updateData: Record, - ): Promise { - return upsert(this.context)(entity, filter, createData, updateData) - } - - async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { - return updateByField(this.context)(entity, field, value, data) - } - - async deleteByField(entity: string, field: string, value: unknown): Promise { - return deleteByField(this.context)(entity, field, value) - } - - async createMany(entity: string, data: Record[]): Promise { - return createMany(this.context)(entity, data) - } - - async updateMany(entity: string, filter: Record, data: Record): Promise { - return updateMany(this.context)(entity, filter, data) - } - - async deleteMany(entity: string, filter?: Record): Promise { - return deleteMany(this.context)(entity, filter) - } - - async getCapabilities(): Promise { - return this.context.baseAdapter.getCapabilities() - } - - async close(): Promise { - await this.context.baseAdapter.close() - } -} - -export type { User, ACLRule } from './acl/types' -export { defaultACLRules } from './acl/default-rules' +export { ACLAdapter } from './acl-adapter' +export type { ACLAdapterOptions, ACLContext, ACLRule, User } from './types' +export { defaultACLRules } from '../acl/default-rules' diff --git a/dbal/development/src/adapters/acl-adapter/read-strategy.ts b/dbal/development/src/adapters/acl-adapter/read-strategy.ts new file mode 100644 index 000000000..da2742e26 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/read-strategy.ts @@ -0,0 +1,48 @@ +import type { ListOptions, ListResult } from '../../core/foundation/types' +import { enforceRowAccess, resolveOperation, withAudit } from './guards' +import type { ACLContext } from './types' + +export const createReadStrategy = (context: ACLContext) => { + const read = async (entity: string, id: string): Promise => { + return withAudit(context, entity, 'read', async () => { + const result = await context.baseAdapter.read(entity, id) + if (result) { + enforceRowAccess(context, entity, 'read', result as Record) + } + return result + }) + } + + const list = async (entity: string, options?: ListOptions): Promise> => { + return withAudit(context, entity, 'list', () => context.baseAdapter.list(entity, options)) + } + + const findFirst = async (entity: string, filter?: Record): Promise => { + const operation = resolveOperation('findFirst') + return withAudit(context, entity, operation, async () => { + const result = await context.baseAdapter.findFirst(entity, filter) + if (result) { + enforceRowAccess(context, entity, operation, result as Record) + } + return result + }) + } + + const findByField = async (entity: string, field: string, value: unknown): Promise => { + const operation = resolveOperation('findByField') + return withAudit(context, entity, operation, async () => { + const result = await context.baseAdapter.findByField(entity, field, value) + if (result) { + enforceRowAccess(context, entity, operation, result as Record) + } + return result + }) + } + + return { + read, + list, + findFirst, + findByField, + } +} diff --git a/dbal/development/src/adapters/acl-adapter/types.ts b/dbal/development/src/adapters/acl-adapter/types.ts new file mode 100644 index 000000000..ea4cf1857 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/types.ts @@ -0,0 +1,27 @@ +import type { DBALAdapter } from '../adapter' + +export interface User { + id: string + username: string + role: 'user' | 'admin' | 'god' | 'supergod' +} + +export interface ACLRule { + entity: string + roles: string[] + operations: string[] + rowLevelFilter?: (user: User, data: Record) => boolean +} + +export interface ACLAdapterOptions { + rules?: ACLRule[] + auditLog?: boolean +} + +export interface ACLContext { + baseAdapter: DBALAdapter + user: User + rules: ACLRule[] + auditLog: boolean + logger: (entity: string, operation: string, success: boolean, message?: string) => void +} diff --git a/dbal/development/src/adapters/acl-adapter/write-strategy.ts b/dbal/development/src/adapters/acl-adapter/write-strategy.ts new file mode 100644 index 000000000..cf8b6aff5 --- /dev/null +++ b/dbal/development/src/adapters/acl-adapter/write-strategy.ts @@ -0,0 +1,83 @@ +import { enforceRowAccess, resolveOperation, withAudit } from './guards' +import type { ACLContext } from './types' + +export const createWriteStrategy = (context: ACLContext) => { + const create = async (entity: string, data: Record): Promise => { + return withAudit(context, entity, 'create', () => context.baseAdapter.create(entity, data)) + } + + const update = async (entity: string, id: string, data: Record): Promise => { + return withAudit(context, entity, 'update', async () => { + const existing = await context.baseAdapter.read(entity, id) + if (existing) { + enforceRowAccess(context, entity, 'update', existing as Record) + } + return context.baseAdapter.update(entity, id, data) + }) + } + + const remove = async (entity: string, id: string): Promise => { + return withAudit(context, entity, 'delete', async () => { + const existing = await context.baseAdapter.read(entity, id) + if (existing) { + enforceRowAccess(context, entity, 'delete', existing as Record) + } + return context.baseAdapter.delete(entity, id) + }) + } + + const upsert = async ( + entity: string, + filter: Record, + createData: Record, + updateData: Record, + ): Promise => { + return withAudit(context, entity, 'upsert', () => context.baseAdapter.upsert(entity, filter, createData, updateData)) + } + + const updateByField = async ( + entity: string, + field: string, + value: unknown, + data: Record, + ): Promise => { + const operation = resolveOperation('updateByField') + return withAudit(context, entity, operation, () => context.baseAdapter.updateByField(entity, field, value, data)) + } + + const deleteByField = async (entity: string, field: string, value: unknown): Promise => { + const operation = resolveOperation('deleteByField') + return withAudit(context, entity, operation, () => context.baseAdapter.deleteByField(entity, field, value)) + } + + const createMany = async (entity: string, data: Record[]): Promise => { + const operation = resolveOperation('createMany') + return withAudit(context, entity, operation, () => context.baseAdapter.createMany(entity, data)) + } + + const updateMany = async ( + entity: string, + filter: Record, + data: Record, + ): Promise => { + const operation = resolveOperation('updateMany') + return withAudit(context, entity, operation, () => context.baseAdapter.updateMany(entity, filter, data)) + } + + const deleteMany = async (entity: string, filter?: Record): Promise => { + const operation = resolveOperation('deleteMany') + return withAudit(context, entity, operation, () => context.baseAdapter.deleteMany(entity, filter)) + } + + return { + create, + update, + delete: remove, + upsert, + updateByField, + deleteByField, + createMany, + updateMany, + deleteMany, + } +} diff --git a/dbal/development/src/adapters/acl/audit-logger.ts b/dbal/development/src/adapters/acl/audit-logger.ts index f67a2736d..864c181e4 100644 --- a/dbal/development/src/adapters/acl/audit-logger.ts +++ b/dbal/development/src/adapters/acl/audit-logger.ts @@ -3,7 +3,7 @@ * @description Audit logging for ACL operations */ -import type { User } from './types' +import type { User } from '../acl-adapter/types' /** * Log audit entry for ACL operation diff --git a/dbal/development/src/adapters/acl/check-permission.ts b/dbal/development/src/adapters/acl/check-permission.ts index 3f1fd4a1b..b27a7b12d 100644 --- a/dbal/development/src/adapters/acl/check-permission.ts +++ b/dbal/development/src/adapters/acl/check-permission.ts @@ -4,7 +4,7 @@ */ import { DBALError } from '../../core/foundation/errors' -import type { User, ACLRule } from './types' +import type { ACLRule, User } from '../acl-adapter/types' /** * Check if user has permission to perform operation on entity diff --git a/dbal/development/src/adapters/acl/check-row-level-access.ts b/dbal/development/src/adapters/acl/check-row-level-access.ts index 3b3403205..70ea72fc7 100644 --- a/dbal/development/src/adapters/acl/check-row-level-access.ts +++ b/dbal/development/src/adapters/acl/check-row-level-access.ts @@ -4,7 +4,7 @@ */ import { DBALError } from '../../core/foundation/errors' -import type { User, ACLRule } from './types' +import type { ACLRule, User } from '../acl-adapter/types' /** * Check row-level access for specific data diff --git a/dbal/development/src/adapters/acl/default-rules.ts b/dbal/development/src/adapters/acl/default-rules.ts index a5ff3f3b0..25a6b803e 100644 --- a/dbal/development/src/adapters/acl/default-rules.ts +++ b/dbal/development/src/adapters/acl/default-rules.ts @@ -3,7 +3,7 @@ * @description Default ACL rules for entities */ -import type { ACLRule } from './types' +import type { ACLRule } from '../acl-adapter/types' export const defaultACLRules: ACLRule[] = [ { diff --git a/dbal/development/src/blob/providers/memory-storage/downloads.ts b/dbal/development/src/blob/providers/memory-storage/downloads.ts index eb82fac6a..6a722029f 100644 --- a/dbal/development/src/blob/providers/memory-storage/downloads.ts +++ b/dbal/development/src/blob/providers/memory-storage/downloads.ts @@ -1,17 +1,15 @@ import { DBALError } from '../../core/foundation/errors' import type { DownloadOptions } from '../blob-storage' import type { MemoryStore } from './store' +import { getBlobOrThrow, normalizeKey } from './utils' export const downloadBuffer = ( store: MemoryStore, key: string, options: DownloadOptions = {}, ): Buffer => { - const blob = store.get(key) - - if (!blob) { - throw DBALError.notFound(`Blob not found: ${key}`) - } + const normalizedKey = normalizeKey(key) + const blob = getBlobOrThrow(store, normalizedKey) let data = blob.data diff --git a/dbal/development/src/blob/providers/memory-storage/index.ts b/dbal/development/src/blob/providers/memory-storage/index.ts index 769285344..b1ed68b18 100644 --- a/dbal/development/src/blob/providers/memory-storage/index.ts +++ b/dbal/development/src/blob/providers/memory-storage/index.ts @@ -10,6 +10,7 @@ import { createStore } from './store' import { uploadBuffer, uploadFromStream } from './uploads' import { downloadBuffer, downloadStream } from './downloads' import { copyBlob, deleteBlob, getMetadata, listBlobs, getObjectCount, getTotalSize } from './management' +import { normalizeKey } from './utils' export class MemoryStorage implements BlobStorage { private store = createStore() @@ -43,7 +44,7 @@ export class MemoryStorage implements BlobStorage { } async exists(key: string): Promise { - return this.store.has(key) + return this.store.has(normalizeKey(key)) } async getMetadata(key: string): Promise { diff --git a/dbal/development/src/blob/providers/memory-storage/management.ts b/dbal/development/src/blob/providers/memory-storage/management.ts index 8d2ad4f8e..afff2801e 100644 --- a/dbal/development/src/blob/providers/memory-storage/management.ts +++ b/dbal/development/src/blob/providers/memory-storage/management.ts @@ -1,29 +1,29 @@ import { DBALError } from '../../core/foundation/errors' import type { BlobListOptions, BlobListResult, BlobMetadata } from '../blob-storage' -import { makeBlobMetadata } from './store' import type { MemoryStore } from './store' +import { toBlobMetadata } from './serialization' +import { cleanupStoreEntry, getBlobOrThrow, normalizeKey } from './utils' export const deleteBlob = async (store: MemoryStore, key: string): Promise => { - if (!store.has(key)) { - throw DBALError.notFound(`Blob not found: ${key}`) + const normalizedKey = normalizeKey(key) + + if (!store.has(normalizedKey)) { + throw DBALError.notFound(`Blob not found: ${normalizedKey}`) } - store.delete(key) + cleanupStoreEntry(store, normalizedKey) return true } export const getMetadata = (store: MemoryStore, key: string): BlobMetadata => { - const blob = store.get(key) + const normalizedKey = normalizeKey(key) + const blob = getBlobOrThrow(store, normalizedKey) - if (!blob) { - throw DBALError.notFound(`Blob not found: ${key}`) - } - - return makeBlobMetadata(key, blob) + return toBlobMetadata(normalizedKey, blob) } export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): BlobListResult => { - const prefix = options.prefix || '' + const prefix = options.prefix ? normalizeKey(options.prefix) : '' const maxKeys = options.maxKeys || 1000 const items: BlobMetadata[] = [] @@ -35,7 +35,7 @@ export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): Bl nextToken = key break } - items.push(makeBlobMetadata(key, blob)) + items.push(toBlobMetadata(key, blob)) } } @@ -47,11 +47,9 @@ export const listBlobs = (store: MemoryStore, options: BlobListOptions = {}): Bl } export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string): BlobMetadata => { - const sourceBlob = store.get(sourceKey) - - if (!sourceBlob) { - throw DBALError.notFound(`Source blob not found: ${sourceKey}`) - } + const normalizedSourceKey = normalizeKey(sourceKey) + const normalizedDestKey = normalizeKey(destKey) + const sourceBlob = getBlobOrThrow(store, normalizedSourceKey) const destBlob = { ...sourceBlob, @@ -59,8 +57,8 @@ export const copyBlob = (store: MemoryStore, sourceKey: string, destKey: string) lastModified: new Date(), } - store.set(destKey, destBlob) - return makeBlobMetadata(destKey, destBlob) + store.set(normalizedDestKey, destBlob) + return toBlobMetadata(normalizedDestKey, destBlob) } export const getTotalSize = (store: MemoryStore): number => { diff --git a/dbal/development/src/blob/providers/memory-storage/serialization.ts b/dbal/development/src/blob/providers/memory-storage/serialization.ts new file mode 100644 index 000000000..9b9bcc52c --- /dev/null +++ b/dbal/development/src/blob/providers/memory-storage/serialization.ts @@ -0,0 +1,43 @@ +import { createHash } from 'crypto' +import type { UploadOptions, BlobMetadata } from '../blob-storage' +import type { BlobData } from './store' + +export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"` + +export const toBlobData = (data: Buffer, options: UploadOptions = {}): BlobData => ({ + data, + contentType: options.contentType || 'application/octet-stream', + etag: generateEtag(data), + lastModified: new Date(), + metadata: options.metadata || {}, +}) + +export const toBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({ + key, + size: blob.data.length, + contentType: blob.contentType, + etag: blob.etag, + lastModified: blob.lastModified, + customMetadata: blob.metadata, +}) + +export const collectStream = async ( + stream: ReadableStream | NodeJS.ReadableStream, +): Promise => { + const chunks: Buffer[] = [] + + if ('getReader' in stream) { + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(Buffer.from(value)) + } + } else { + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)) + } + } + + return Buffer.concat(chunks) +} diff --git a/dbal/development/src/blob/providers/memory-storage/store.ts b/dbal/development/src/blob/providers/memory-storage/store.ts index d574a84d6..383249c93 100644 --- a/dbal/development/src/blob/providers/memory-storage/store.ts +++ b/dbal/development/src/blob/providers/memory-storage/store.ts @@ -1,6 +1,3 @@ -import type { BlobMetadata } from '../blob-storage' -import { createHash } from 'crypto' - export interface BlobData { data: Buffer contentType: string @@ -12,14 +9,3 @@ export interface BlobData { export type MemoryStore = Map export const createStore = (): MemoryStore => new Map() - -export const generateEtag = (data: Buffer): string => `"${createHash('md5').update(data).digest('hex')}"` - -export const makeBlobMetadata = (key: string, blob: BlobData): BlobMetadata => ({ - key, - size: blob.data.length, - contentType: blob.contentType, - etag: blob.etag, - lastModified: blob.lastModified, - customMetadata: blob.metadata, -}) diff --git a/dbal/development/src/blob/providers/memory-storage/uploads.ts b/dbal/development/src/blob/providers/memory-storage/uploads.ts index f282dc67f..356e37e85 100644 --- a/dbal/development/src/blob/providers/memory-storage/uploads.ts +++ b/dbal/development/src/blob/providers/memory-storage/uploads.ts @@ -1,7 +1,8 @@ import { DBALError } from '../../core/foundation/errors' import type { UploadOptions } from '../blob-storage' -import type { BlobData, MemoryStore } from './store' -import { generateEtag, makeBlobMetadata } from './store' +import type { MemoryStore } from './store' +import { collectStream, toBlobData, toBlobMetadata } from './serialization' +import { normalizeKey } from './utils' export const uploadBuffer = ( store: MemoryStore, @@ -9,43 +10,17 @@ export const uploadBuffer = ( data: Buffer | Uint8Array, options: UploadOptions = {}, ) => { + const normalizedKey = normalizeKey(key) const buffer = Buffer.from(data) - if (!options.overwrite && store.has(key)) { - throw DBALError.conflict(`Blob already exists: ${key}`) + if (!options.overwrite && store.has(normalizedKey)) { + throw DBALError.conflict(`Blob already exists: ${normalizedKey}`) } - const blob: BlobData = { - data: buffer, - contentType: options.contentType || 'application/octet-stream', - etag: generateEtag(buffer), - lastModified: new Date(), - metadata: options.metadata || {}, - } + const blob = toBlobData(buffer, options) - store.set(key, blob) - return makeBlobMetadata(key, blob) -} - -export const collectStream = async ( - stream: ReadableStream | NodeJS.ReadableStream, -): Promise => { - const chunks: Buffer[] = [] - - if ('getReader' in stream) { - const reader = stream.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks.push(Buffer.from(value)) - } - } else { - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk)) - } - } - - return Buffer.concat(chunks) + store.set(normalizedKey, blob) + return toBlobMetadata(normalizedKey, blob) } export const uploadFromStream = async ( diff --git a/dbal/development/src/blob/providers/memory-storage/utils.ts b/dbal/development/src/blob/providers/memory-storage/utils.ts new file mode 100644 index 000000000..51e2a8618 --- /dev/null +++ b/dbal/development/src/blob/providers/memory-storage/utils.ts @@ -0,0 +1,18 @@ +import { DBALError } from '../../core/foundation/errors' +import type { BlobData, MemoryStore } from './store' + +export const normalizeKey = (key: string): string => key.replace(/^\/+/, '').trim() + +export const getBlobOrThrow = (store: MemoryStore, key: string): BlobData => { + const blob = store.get(key) + + if (!blob) { + throw DBALError.notFound(`Blob not found: ${key}`) + } + + return blob +} + +export const cleanupStoreEntry = (store: MemoryStore, key: string): void => { + store.delete(key) +} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage.ts b/dbal/development/src/blob/providers/tenant-aware-storage.ts index 33fa59a9d..c8f14cee9 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage.ts @@ -1 +1,5 @@ export { TenantAwareBlobStorage } from './tenant-aware-storage/index' +export type { TenantAwareDeps } from './tenant-aware-storage/context' +export { scopeKey, unscopeKey } from './tenant-aware-storage/context' +export { ensurePermission, resolveTenantContext } from './tenant-aware-storage/tenant-context' +export { auditCopy, auditDeletion, auditUpload } from './tenant-aware-storage/audit-hooks' diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts b/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts new file mode 100644 index 000000000..8aeb80c80 --- /dev/null +++ b/dbal/development/src/blob/providers/tenant-aware-storage/audit-hooks.ts @@ -0,0 +1,17 @@ +import type { TenantAwareDeps } from './context' + +const recordUsageChange = async (deps: TenantAwareDeps, bytesChange: number, countChange: number): Promise => { + await deps.tenantManager.updateBlobUsage(deps.tenantId, bytesChange, countChange) +} + +export const auditUpload = async (deps: TenantAwareDeps, sizeBytes: number): Promise => { + await recordUsageChange(deps, sizeBytes, 1) +} + +export const auditDeletion = async (deps: TenantAwareDeps, sizeBytes: number): Promise => { + await recordUsageChange(deps, -sizeBytes, -1) +} + +export const auditCopy = async (deps: TenantAwareDeps, sizeBytes: number): Promise => { + await recordUsageChange(deps, sizeBytes, 1) +} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/context.ts b/dbal/development/src/blob/providers/tenant-aware-storage/context.ts index 234816666..067d7ff99 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/context.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/context.ts @@ -1,5 +1,4 @@ -import { DBALError } from '../../core/foundation/errors' -import type { TenantContext, TenantManager } from '../../core/foundation/tenant-context' +import type { TenantManager } from '../../core/foundation/tenant-context' import type { BlobStorage } from '../blob-storage' export interface TenantAwareDeps { @@ -9,10 +8,6 @@ export interface TenantAwareDeps { userId: string } -export const getContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise => { - return tenantManager.getTenantContext(tenantId, userId) -} - export const scopeKey = (key: string, namespace: string): string => { const cleanKey = key.startsWith('/') ? key.substring(1) : key return `${namespace}${cleanKey}` @@ -24,17 +19,3 @@ export const unscopeKey = (scopedKey: string, namespace: string): string => { } return scopedKey } - -export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => { - const accessCheck = - action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob') - - if (!accessCheck) { - const verbs: Record = { - read: 'read', - write: 'write', - delete: 'delete', - } - throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`) - } -} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts b/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts index 6ec400af4..b518eb1c0 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/mutations.ts @@ -1,10 +1,12 @@ import { DBALError } from '../../core/foundation/errors' import type { BlobMetadata } from '../blob-storage' -import { ensurePermission, getContext, scopeKey } from './context' +import { auditCopy, auditDeletion } from './audit-hooks' import type { TenantAwareDeps } from './context' +import { scopeKey } from './context' +import { ensurePermission, resolveTenantContext } from './tenant-context' export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'delete') const scopedKey = scopeKey(key, context.namespace) @@ -14,7 +16,7 @@ export const deleteBlob = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -36,7 +38,7 @@ export const copyBlob = async ( sourceKey: string, destKey: string, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') ensurePermission(context, 'write') @@ -50,7 +52,7 @@ export const copyBlob = async ( const destScoped = scopeKey(destKey, context.namespace) const metadata = await deps.baseStorage.copy(sourceScoped, destScoped) - await deps.tenantManager.updateBlobUsage(deps.tenantId, sourceMetadata.size, 1) + await auditCopy(deps, sourceMetadata.size) return { ...metadata, @@ -59,7 +61,7 @@ export const copyBlob = async ( } export const getStats = async (deps: TenantAwareDeps) => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) return { count: context.quota.currentBlobCount, totalSize: context.quota.currentBlobStorageBytes, diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts b/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts index 5ba718d0d..9fc52a58b 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/reads.ts @@ -1,9 +1,10 @@ import type { DownloadOptions, BlobMetadata, BlobListOptions, BlobListResult } from '../blob-storage' -import { ensurePermission, getContext, scopeKey, unscopeKey } from './context' import type { TenantAwareDeps } from './context' +import { scopeKey, unscopeKey } from './context' +import { ensurePermission, resolveTenantContext } from './tenant-context' export const downloadBuffer = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -15,7 +16,7 @@ export const downloadStream = async ( key: string, options?: DownloadOptions, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -26,7 +27,7 @@ export const listBlobs = async ( deps: TenantAwareDeps, options: BlobListOptions = {}, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedOptions: BlobListOptions = { @@ -46,7 +47,7 @@ export const listBlobs = async ( } export const getMetadata = async (deps: TenantAwareDeps, key: string): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) @@ -63,7 +64,7 @@ export const generatePresignedUrl = async ( key: string, expiresIn: number, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'read') const scopedKey = scopeKey(key, context.namespace) diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts b/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts new file mode 100644 index 000000000..acdd36720 --- /dev/null +++ b/dbal/development/src/blob/providers/tenant-aware-storage/tenant-context.ts @@ -0,0 +1,21 @@ +import { DBALError } from '../../core/foundation/errors' +import type { TenantContext } from '../../core/foundation/tenant-context' +import type { TenantAwareDeps } from './context' + +export const resolveTenantContext = async ({ tenantManager, tenantId, userId }: TenantAwareDeps): Promise => { + return tenantManager.getTenantContext(tenantId, userId) +} + +export const ensurePermission = (context: TenantContext, action: 'read' | 'write' | 'delete'): void => { + const accessCheck = + action === 'read' ? context.canRead('blob') : action === 'write' ? context.canWrite('blob') : context.canDelete('blob') + + if (!accessCheck) { + const verbs: Record = { + read: 'read', + write: 'write', + delete: 'delete', + } + throw DBALError.forbidden(`Permission denied: cannot ${verbs[action]} blobs`) + } +} diff --git a/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts b/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts index cd787a533..382fc4881 100644 --- a/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts +++ b/dbal/development/src/blob/providers/tenant-aware-storage/uploads.ts @@ -1,7 +1,9 @@ import { DBALError } from '../../core/foundation/errors' -import type { UploadOptions, BlobMetadata } from '../blob-storage' +import { auditUpload } from './audit-hooks' import type { TenantAwareDeps } from './context' -import { ensurePermission, getContext, scopeKey } from './context' +import { scopeKey } from './context' +import { ensurePermission, resolveTenantContext } from './tenant-context' +import type { UploadOptions, BlobMetadata } from '../blob-storage' export const uploadBuffer = async ( deps: TenantAwareDeps, @@ -9,7 +11,7 @@ export const uploadBuffer = async ( data: Buffer, options?: UploadOptions, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'write') if (!context.canUploadBlob(data.length)) { @@ -18,7 +20,7 @@ export const uploadBuffer = async ( const scopedKey = scopeKey(key, context.namespace) const metadata = await deps.baseStorage.upload(scopedKey, data, options) - await deps.tenantManager.updateBlobUsage(deps.tenantId, data.length, 1) + await auditUpload(deps, data.length) return { ...metadata, @@ -33,7 +35,7 @@ export const uploadStream = async ( size: number, options?: UploadOptions, ): Promise => { - const context = await getContext(deps) + const context = await resolveTenantContext(deps) ensurePermission(context, 'write') if (!context.canUploadBlob(size)) { @@ -42,7 +44,7 @@ export const uploadStream = async ( const scopedKey = scopeKey(key, context.namespace) const metadata = await deps.baseStorage.uploadStream(scopedKey, stream, size, options) - await deps.tenantManager.updateBlobUsage(deps.tenantId, size, 1) + await auditUpload(deps, size) return { ...metadata, diff --git a/dbal/development/src/bridges/websocket-bridge/connection-manager.ts b/dbal/development/src/bridges/websocket-bridge/connection-manager.ts new file mode 100644 index 000000000..2e39d36a4 --- /dev/null +++ b/dbal/development/src/bridges/websocket-bridge/connection-manager.ts @@ -0,0 +1,90 @@ +import { DBALError } from '../../core/foundation/errors' +import type { RPCMessage } from '../utils/rpc-types' +import type { BridgeState } from './state' +import type { MessageRouter } from './message-router' + +export interface ConnectionManager { + ensureConnection: () => Promise + send: (message: RPCMessage) => Promise + close: () => Promise +} + +export const createConnectionManager = ( + state: BridgeState, + messageRouter: MessageRouter, +): ConnectionManager => { + let connectionPromise: Promise | null = null + + const resetConnection = () => { + connectionPromise = null + state.ws = null + } + + const rejectPendingRequests = (error: DBALError) => { + state.pendingRequests.forEach(({ reject }) => reject(error)) + state.pendingRequests.clear() + } + + const ensureConnection = async (): Promise => { + if (state.ws?.readyState === WebSocket.OPEN) { + return + } + + if (connectionPromise) { + return connectionPromise + } + + connectionPromise = new Promise((resolve, reject) => { + try { + const ws = new WebSocket(state.endpoint) + state.ws = ws + + ws.onopen = () => resolve() + ws.onerror = error => { + const connectionError = DBALError.internal(`WebSocket connection failed: ${error}`) + rejectPendingRequests(connectionError) + resetConnection() + reject(connectionError) + } + ws.onclose = () => { + rejectPendingRequests(DBALError.internal('WebSocket connection closed')) + resetConnection() + } + ws.onmessage = event => messageRouter.handle(event.data) + } catch (error) { + resetConnection() + const connectionError = + error instanceof DBALError ? error : DBALError.internal('Failed to establish WebSocket connection') + reject(connectionError) + } + }) + + return connectionPromise + } + + const send = async (message: RPCMessage): Promise => { + await ensureConnection() + + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { + throw DBALError.internal('WebSocket connection not open') + } + + state.ws.send(JSON.stringify(message)) + } + + const close = async (): Promise => { + rejectPendingRequests(DBALError.internal('WebSocket connection closed')) + + if (state.ws) { + state.ws.close() + } + + resetConnection() + } + + return { + ensureConnection, + send, + close, + } +} diff --git a/dbal/development/src/bridges/websocket-bridge/connection.ts b/dbal/development/src/bridges/websocket-bridge/connection.ts deleted file mode 100644 index 9f348f18a..000000000 --- a/dbal/development/src/bridges/websocket-bridge/connection.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { DBALError } from '../../core/foundation/errors' -import { handleMessage } from './message-handler' -import type { BridgeState } from './state' - -export const connect = async (state: BridgeState): Promise => { - if (state.ws?.readyState === WebSocket.OPEN) { - return - } - - return new Promise((resolve, reject) => { - state.ws = new WebSocket(state.endpoint) - - state.ws.onopen = () => resolve() - state.ws.onerror = error => reject(DBALError.internal(`WebSocket connection failed: ${error}`)) - state.ws.onmessage = event => handleMessage(state, event.data) - state.ws.onclose = () => { - state.ws = null - } - }) -} - -export const closeConnection = async (state: BridgeState): Promise => { - if (state.ws) { - state.ws.close() - state.ws = null - } - state.pendingRequests.clear() -} diff --git a/dbal/development/src/bridges/websocket-bridge/index.ts b/dbal/development/src/bridges/websocket-bridge/index.ts index f4ecedcdd..b6f27cbad 100644 --- a/dbal/development/src/bridges/websocket-bridge/index.ts +++ b/dbal/development/src/bridges/websocket-bridge/index.ts @@ -1,16 +1,20 @@ import type { DBALAdapter, AdapterCapabilities } from '../../adapters/adapter' import type { ListOptions, ListResult } from '../../core/types' -import { closeConnection } from './connection' +import { createConnectionManager } from './connection-manager' +import { createMessageRouter } from './message-router' import { createOperations } from './operations' import { createBridgeState } from './state' export class WebSocketBridge implements DBALAdapter { private readonly state: ReturnType + private readonly connectionManager: ReturnType private readonly operations: ReturnType constructor(endpoint: string, auth?: { user: unknown; session: unknown }) { this.state = createBridgeState(endpoint, auth) - this.operations = createOperations(this.state) + const messageRouter = createMessageRouter(this.state) + this.connectionManager = createConnectionManager(this.state, messageRouter) + this.operations = createOperations(this.state, this.connectionManager) } create(entity: string, data: Record): Promise { @@ -75,6 +79,6 @@ export class WebSocketBridge implements DBALAdapter { } async close(): Promise { - await closeConnection(this.state) + await this.connectionManager.close() } } diff --git a/dbal/development/src/bridges/websocket-bridge/message-handler.ts b/dbal/development/src/bridges/websocket-bridge/message-handler.ts deleted file mode 100644 index 78db23362..000000000 --- a/dbal/development/src/bridges/websocket-bridge/message-handler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RPCResponse } from '../utils/rpc-types' -import type { BridgeState } from './state' -import { DBALError } from '../../core/foundation/errors' - -export const handleMessage = (state: BridgeState, data: string): void => { - try { - const response: RPCResponse = JSON.parse(data) - const pending = state.pendingRequests.get(response.id) - - if (!pending) { - return - } - - state.pendingRequests.delete(response.id) - - if (response.error) { - const error = new DBALError(response.error.message, response.error.code, response.error.details) - pending.reject(error) - } else { - pending.resolve(response.result) - } - } catch (error) { - console.error('Failed to parse WebSocket message:', error) - } -} diff --git a/dbal/development/src/bridges/websocket-bridge/message-router.ts b/dbal/development/src/bridges/websocket-bridge/message-router.ts new file mode 100644 index 000000000..0603f2a2a --- /dev/null +++ b/dbal/development/src/bridges/websocket-bridge/message-router.ts @@ -0,0 +1,68 @@ +import { DBALError } from '../../core/foundation/errors' +import type { RPCResponse } from '../utils/rpc-types' +import type { BridgeState } from './state' + +export interface MessageRouter { + handle: (rawMessage: unknown) => void +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isRPCError = (value: unknown): value is NonNullable => + isRecord(value) && + typeof value.code === 'number' && + typeof value.message === 'string' && + (value.details === undefined || isRecord(value.details)) + +const isRPCResponse = (value: unknown): value is RPCResponse => { + if (!isRecord(value)) { + return false + } + + const hasId = typeof value.id === 'string' + const hasResult = Object.prototype.hasOwnProperty.call(value, 'result') + const hasError = isRPCError(value.error) || value.error === undefined + + return hasId && (hasResult || isRPCError(value.error)) && hasError +} + +const parseResponse = (rawMessage: string): RPCResponse => { + const parsed = JSON.parse(rawMessage) as unknown + + if (!isRPCResponse(parsed)) { + throw new Error('Invalid RPC response shape') + } + + return parsed +} + +export const createMessageRouter = (state: BridgeState): MessageRouter => ({ + handle: (rawMessage: unknown) => { + if (typeof rawMessage !== 'string') { + console.warn('Ignoring non-string WebSocket message') + return + } + + try { + const response = parseResponse(rawMessage) + const pending = state.pendingRequests.get(response.id) + + if (!pending) { + console.warn(`No pending request for response ${response.id}`) + return + } + + state.pendingRequests.delete(response.id) + + if (response.error) { + const error = new DBALError(response.error.message, response.error.code, response.error.details) + pending.reject(error) + } else { + pending.resolve(response.result) + } + } catch (error) { + console.error('Failed to process WebSocket message', error) + } + }, +}) diff --git a/dbal/development/src/bridges/websocket-bridge/operations.ts b/dbal/development/src/bridges/websocket-bridge/operations.ts index 05c9a866b..8519082fe 100644 --- a/dbal/development/src/bridges/websocket-bridge/operations.ts +++ b/dbal/development/src/bridges/websocket-bridge/operations.ts @@ -1,31 +1,36 @@ import type { AdapterCapabilities } from '../../adapters/adapter' import type { ListOptions, ListResult } from '../../core/types' +import type { ConnectionManager } from './connection-manager' import type { BridgeState } from './state' import { rpcCall } from './rpc' -export const createOperations = (state: BridgeState) => ({ - create: (entity: string, data: Record) => rpcCall(state, 'create', entity, data), - read: (entity: string, id: string) => rpcCall(state, 'read', entity, id), - update: (entity: string, id: string, data: Record) => rpcCall(state, 'update', entity, id, data), - delete: (entity: string, id: string) => rpcCall(state, 'delete', entity, id) as Promise, - list: (entity: string, options?: ListOptions) => rpcCall(state, 'list', entity, options) as Promise>, - findFirst: (entity: string, filter?: Record) => rpcCall(state, 'findFirst', entity, filter), - findByField: (entity: string, field: string, value: unknown) => rpcCall(state, 'findByField', entity, field, value), +export const createOperations = (state: BridgeState, connectionManager: ConnectionManager) => ({ + create: (entity: string, data: Record) => rpcCall(state, connectionManager, 'create', entity, data), + read: (entity: string, id: string) => rpcCall(state, connectionManager, 'read', entity, id), + update: (entity: string, id: string, data: Record) => + rpcCall(state, connectionManager, 'update', entity, id, data), + delete: (entity: string, id: string) => rpcCall(state, connectionManager, 'delete', entity, id) as Promise, + list: (entity: string, options?: ListOptions) => + rpcCall(state, connectionManager, 'list', entity, options) as Promise>, + findFirst: (entity: string, filter?: Record) => + rpcCall(state, connectionManager, 'findFirst', entity, filter), + findByField: (entity: string, field: string, value: unknown) => + rpcCall(state, connectionManager, 'findByField', entity, field, value), upsert: ( entity: string, filter: Record, createData: Record, updateData: Record, - ) => rpcCall(state, 'upsert', entity, filter, createData, updateData), + ) => rpcCall(state, connectionManager, 'upsert', entity, filter, createData, updateData), updateByField: (entity: string, field: string, value: unknown, data: Record) => - rpcCall(state, 'updateByField', entity, field, value, data), + rpcCall(state, connectionManager, 'updateByField', entity, field, value, data), deleteByField: (entity: string, field: string, value: unknown) => - rpcCall(state, 'deleteByField', entity, field, value) as Promise, + rpcCall(state, connectionManager, 'deleteByField', entity, field, value) as Promise, deleteMany: (entity: string, filter?: Record) => - rpcCall(state, 'deleteMany', entity, filter) as Promise, + rpcCall(state, connectionManager, 'deleteMany', entity, filter) as Promise, createMany: (entity: string, data: Record[]) => - rpcCall(state, 'createMany', entity, data) as Promise, + rpcCall(state, connectionManager, 'createMany', entity, data) as Promise, updateMany: (entity: string, filter: Record, data: Record) => - rpcCall(state, 'updateMany', entity, filter, data) as Promise, - getCapabilities: () => rpcCall(state, 'getCapabilities') as Promise, + rpcCall(state, connectionManager, 'updateMany', entity, filter, data) as Promise, + getCapabilities: () => rpcCall(state, connectionManager, 'getCapabilities') as Promise, }) diff --git a/dbal/development/src/bridges/websocket-bridge/rpc.ts b/dbal/development/src/bridges/websocket-bridge/rpc.ts index 2462558b4..3de06a550 100644 --- a/dbal/development/src/bridges/websocket-bridge/rpc.ts +++ b/dbal/development/src/bridges/websocket-bridge/rpc.ts @@ -1,25 +1,28 @@ import { DBALError } from '../../core/foundation/errors' import { generateRequestId } from '../utils/generate-request-id' import type { RPCMessage } from '../utils/rpc-types' -import { connect } from './connection' +import type { ConnectionManager } from './connection-manager' import type { BridgeState } from './state' -export const rpcCall = async (state: BridgeState, method: string, ...params: unknown[]): Promise => { - await connect(state) - +export const rpcCall = async ( + state: BridgeState, + connectionManager: ConnectionManager, + method: string, + ...params: unknown[] +): Promise => { const id = generateRequestId() const message: RPCMessage = { id, method, params } return new Promise((resolve, reject) => { state.pendingRequests.set(id, { resolve, reject }) - if (state.ws?.readyState === WebSocket.OPEN) { - state.ws.send(JSON.stringify(message)) - } else { - state.pendingRequests.delete(id) - reject(DBALError.internal('WebSocket connection not open')) - return - } + connectionManager + .send(message) + .catch(error => { + state.pendingRequests.delete(id) + reject(error) + return + }) setTimeout(() => { if (state.pendingRequests.has(id)) { diff --git a/dbal/development/src/core/entities/operations/core/user-operations.ts b/dbal/development/src/core/entities/operations/core/user-operations.ts index d5e29f59c..5d1da503e 100644 --- a/dbal/development/src/core/entities/operations/core/user-operations.ts +++ b/dbal/development/src/core/entities/operations/core/user-operations.ts @@ -1,2 +1,11 @@ export { createUserOperations } from './user' export type { UserOperations } from './user' + +export { createUser } from './user/create' +export { deleteUser } from './user/delete' +export { updateUser } from './user/update' +export { + assertValidUserCreate, + assertValidUserId, + assertValidUserUpdate, +} from './user/validation' diff --git a/dbal/development/src/core/entities/operations/core/user/create.ts b/dbal/development/src/core/entities/operations/core/user/create.ts new file mode 100644 index 000000000..4543fe4e2 --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/create.ts @@ -0,0 +1,20 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import { DBALError } from '../../../../foundation/errors' +import type { User } from '../../../../foundation/types' +import { assertValidUserCreate } from './validation' + +export const createUser = async ( + adapter: DBALAdapter, + data: Omit, +): Promise => { + assertValidUserCreate(data) + + try { + return adapter.create('User', data) as Promise + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('User with username or email already exists') + } + throw error + } +} diff --git a/dbal/development/src/core/entities/operations/core/user/delete.ts b/dbal/development/src/core/entities/operations/core/user/delete.ts new file mode 100644 index 000000000..07484d1a6 --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/delete.ts @@ -0,0 +1,13 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import { DBALError } from '../../../../foundation/errors' +import { assertValidUserId } from './validation' + +export const deleteUser = async (adapter: DBALAdapter, id: string): Promise => { + assertValidUserId(id) + + const result = await adapter.delete('User', id) + if (!result) { + throw DBALError.notFound(`User not found: ${id}`) + } + return result +} diff --git a/dbal/development/src/core/entities/operations/core/user/index.ts b/dbal/development/src/core/entities/operations/core/user/index.ts index 200efa017..a5f410c72 100644 --- a/dbal/development/src/core/entities/operations/core/user/index.ts +++ b/dbal/development/src/core/entities/operations/core/user/index.ts @@ -1,6 +1,8 @@ import type { DBALAdapter } from '../../../../adapters/adapter' import type { User, ListOptions, ListResult } from '../../../../foundation/types' -import { createUser, deleteUser, updateUser } from './mutations' +import { createUser } from './create' +import { deleteUser } from './delete' +import { updateUser } from './update' import { createManyUsers, deleteManyUsers, updateManyUsers } from './batch' import { listUsers, readUser } from './reads' diff --git a/dbal/development/src/core/entities/operations/core/user/mutations.ts b/dbal/development/src/core/entities/operations/core/user/mutations.ts deleted file mode 100644 index 8e80c7be8..000000000 --- a/dbal/development/src/core/entities/operations/core/user/mutations.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { DBALAdapter } from '../../../../adapters/adapter' -import type { User } from '../../../../foundation/types' -import { DBALError } from '../../../../foundation/errors' -import { validateUserCreate, validateUserUpdate, validateId } from '../../../../foundation/validation' - -export const createUser = async ( - adapter: DBALAdapter, - data: Omit, -): Promise => { - const validationErrors = validateUserCreate(data) - if (validationErrors.length > 0) { - throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error }))) - } - - try { - return adapter.create('User', data) as Promise - } catch (error) { - if (error instanceof DBALError && error.code === 409) { - throw DBALError.conflict('User with username or email already exists') - } - throw error - } -} - -export const updateUser = async (adapter: DBALAdapter, id: string, data: Partial): Promise => { - const idErrors = validateId(id) - if (idErrors.length > 0) { - throw DBALError.validationError('Invalid user ID', idErrors.map(error => ({ field: 'id', error }))) - } - - const validationErrors = validateUserUpdate(data) - if (validationErrors.length > 0) { - throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error }))) - } - - try { - return adapter.update('User', id, data) as Promise - } catch (error) { - if (error instanceof DBALError && error.code === 409) { - throw DBALError.conflict('Username or email already exists') - } - throw error - } -} - -export const deleteUser = async (adapter: DBALAdapter, id: string): Promise => { - const validationErrors = validateId(id) - if (validationErrors.length > 0) { - throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error }))) - } - - const result = await adapter.delete('User', id) - if (!result) { - throw DBALError.notFound(`User not found: ${id}`) - } - return result -} diff --git a/dbal/development/src/core/entities/operations/core/user/update.ts b/dbal/development/src/core/entities/operations/core/user/update.ts new file mode 100644 index 000000000..ca0ae185d --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/update.ts @@ -0,0 +1,22 @@ +import type { DBALAdapter } from '../../../../adapters/adapter' +import { DBALError } from '../../../../foundation/errors' +import type { User } from '../../../../foundation/types' +import { assertValidUserId, assertValidUserUpdate } from './validation' + +export const updateUser = async ( + adapter: DBALAdapter, + id: string, + data: Partial, +): Promise => { + assertValidUserId(id) + assertValidUserUpdate(data) + + try { + return adapter.update('User', id, data) as Promise + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('Username or email already exists') + } + throw error + } +} diff --git a/dbal/development/src/core/entities/operations/core/user/validation.ts b/dbal/development/src/core/entities/operations/core/user/validation.ts new file mode 100644 index 000000000..0b57322d5 --- /dev/null +++ b/dbal/development/src/core/entities/operations/core/user/validation.ts @@ -0,0 +1,24 @@ +import { DBALError } from '../../../../foundation/errors' +import type { User } from '../../../../foundation/types' +import { validateId, validateUserCreate, validateUserUpdate } from '../../../../foundation/validation' + +export const assertValidUserId = (id: string): void => { + const validationErrors = validateId(id) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid user ID', validationErrors.map(error => ({ field: 'id', error }))) + } +} + +export const assertValidUserCreate = (data: Omit): void => { + const validationErrors = validateUserCreate(data) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid user data', validationErrors.map(error => ({ field: 'user', error }))) + } +} + +export const assertValidUserUpdate = (data: Partial): void => { + const validationErrors = validateUserUpdate(data) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid user update data', validationErrors.map(error => ({ field: 'user', error }))) + } +} diff --git a/dbal/development/src/core/foundation/types/entities.ts b/dbal/development/src/core/foundation/types/entities.ts new file mode 100644 index 000000000..dcd20b271 --- /dev/null +++ b/dbal/development/src/core/foundation/types/entities.ts @@ -0,0 +1,19 @@ +export type EntityId = string + +export interface BaseEntity { + id: EntityId + createdAt: Date + updatedAt: Date +} + +export interface SoftDeletableEntity extends BaseEntity { + deletedAt?: Date +} + +export interface TenantScopedEntity extends BaseEntity { + tenantId: string +} + +export interface EntityMetadata { + metadata?: Record +} diff --git a/dbal/development/src/core/foundation/types/events.ts b/dbal/development/src/core/foundation/types/events.ts new file mode 100644 index 000000000..5679fb156 --- /dev/null +++ b/dbal/development/src/core/foundation/types/events.ts @@ -0,0 +1,13 @@ +import type { OperationContext } from './operations' + +export interface DomainEvent> { + id: string + name: string + occurredAt: Date + payload: TPayload + context?: OperationContext +} + +export interface EventHandler> { + (event: DomainEvent): void | Promise +} diff --git a/dbal/development/src/core/foundation/types/index.ts b/dbal/development/src/core/foundation/types/index.ts index 4c2264d64..293944c9a 100644 --- a/dbal/development/src/core/foundation/types/index.ts +++ b/dbal/development/src/core/foundation/types/index.ts @@ -4,3 +4,6 @@ export * from './content' export * from './automation' export * from './packages' export * from './shared' +export * from './entities' +export * from './operations' +export * from './events' diff --git a/dbal/development/src/core/foundation/types/operations.ts b/dbal/development/src/core/foundation/types/operations.ts new file mode 100644 index 000000000..c411a9ea9 --- /dev/null +++ b/dbal/development/src/core/foundation/types/operations.ts @@ -0,0 +1,19 @@ +export interface OperationContext { + tenantId?: string + userId?: string + correlationId?: string + traceId?: string + metadata?: Record +} + +export interface OperationOptions { + timeoutMs?: number + retryCount?: number + dryRun?: boolean +} + +export interface OperationAuditTrail { + performedAt: Date + performedBy?: string + context?: OperationContext +} diff --git a/dbal/shared/tools/cpp-build-assistant.ts b/dbal/shared/tools/cpp-build-assistant.ts index fcd23a64b..7becf72bc 100644 --- a/dbal/shared/tools/cpp-build-assistant.ts +++ b/dbal/shared/tools/cpp-build-assistant.ts @@ -1,12 +1,15 @@ import path from 'path' -import { CppBuildAssistant, runCppBuildAssistant } from './cpp-build-assistant/index' +import { runCppBuildAssistant } from './cpp-build-assistant/runner' -export { CppBuildAssistant, runCppBuildAssistant } +export { CppBuildAssistant, createAssistant } from './cpp-build-assistant' +export { createCppBuildAssistantConfig } from './cpp-build-assistant/config' +export { runCppBuildAssistant } from './cpp-build-assistant/runner' if (require.main === module) { const args = process.argv.slice(2) + const projectRoot = path.join(__dirname, '..') - runCppBuildAssistant(args, path.join(__dirname, '..')) + runCppBuildAssistant(args, projectRoot) .then(success => { process.exit(success ? 0 : 1) }) diff --git a/dbal/shared/tools/cpp-build-assistant/cli.ts b/dbal/shared/tools/cpp-build-assistant/cli.ts new file mode 100644 index 000000000..c29c525a8 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/cli.ts @@ -0,0 +1,125 @@ +import os from 'os' +import { BuildType } from './config' +import { COLORS, log } from './logging' +import { CppBuildAssistant } from './index' + +export type CliCommand = + | 'check' + | 'init' + | 'install' + | 'configure' + | 'build' + | 'test' + | 'clean' + | 'rebuild' + | 'full' + | 'help' + +export interface ParsedCliArgs { + command: CliCommand + buildType: BuildType + jobs: number + target?: string + options: string[] +} + +const parseBuildType = (options: string[]): BuildType => (options.includes('--debug') ? 'Debug' : 'Release') + +const parseJobs = (options: string[]): number => { + const jobsArg = options.find(option => option.startsWith('--jobs=')) + const parsedJobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : Number.NaN + + return Number.isNaN(parsedJobs) ? os.cpus().length : parsedJobs +} + +const parseTarget = (command: CliCommand, options: string[]): string | undefined => { + if (command !== 'build') return undefined + + return options.find(option => !option.startsWith('--')) || 'all' +} + +export const parseCliArgs = (args: string[]): ParsedCliArgs => { + const command = (args[0] as CliCommand | undefined) || 'help' + const options = args.slice(1) + + return { + command, + buildType: parseBuildType(options), + jobs: parseJobs(options), + target: parseTarget(command, options), + options, + } +} + +export const showHelp = (): void => { + console.log(` +${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper + +${COLORS.cyan}USAGE:${COLORS.reset} + npm run cpp:build [command] [options] + +${COLORS.cyan}COMMANDS:${COLORS.reset} + ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed + ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing) + ${COLORS.green}install${COLORS.reset} Install Conan dependencies + ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator + ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all) + ${COLORS.green}test${COLORS.reset} Run tests with CTest + ${COLORS.green}clean${COLORS.reset} Remove build artifacts + ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild + ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build + ${COLORS.green}help${COLORS.reset} Show this help message + +${COLORS.cyan}OPTIONS:${COLORS.reset} + --debug Use Debug build type + --release Use Release build type (default) + --jobs=N Number of parallel build jobs (default: CPU count) + +${COLORS.cyan}EXAMPLES:${COLORS.reset} + npm run cpp:build check + npm run cpp:build full + npm run cpp:build build dbal_daemon + npm run cpp:build build -- --debug + npm run cpp:build test +`) +} + +export const runCli = async (args: string[], assistant: CppBuildAssistant): Promise => { + const parsed = parseCliArgs(args) + + switch (parsed.command) { + case 'check': + return assistant.checkDependencies() + case 'init': + return assistant.createConanfile() + case 'install': + if (!assistant.checkDependencies()) return false + return assistant.installConanDeps() + case 'configure': + if (!assistant.checkDependencies()) return false + return assistant.configureCMake(parsed.buildType) + case 'build': + if (!assistant.checkDependencies()) return false + return assistant.build(parsed.target, parsed.jobs) + case 'test': + return assistant.test() + case 'clean': + return assistant.clean() + case 'rebuild': + assistant.clean() + if (!assistant.checkDependencies()) return false + if (!assistant.configureCMake(parsed.buildType)) return false + return assistant.build('all', parsed.jobs) + case 'full': + log.section('Full Build Workflow') + if (!assistant.checkDependencies()) return false + if (!assistant.createConanfile()) return false + if (!assistant.installConanDeps()) return false + if (!assistant.configureCMake(parsed.buildType)) return false + return assistant.build('all', parsed.jobs) + case 'help': + default: + showHelp() + return true + } +} diff --git a/dbal/shared/tools/cpp-build-assistant/config.ts b/dbal/shared/tools/cpp-build-assistant/config.ts new file mode 100644 index 000000000..f160b8cc4 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/config.ts @@ -0,0 +1,20 @@ +import path from 'path' + +export type BuildType = 'Debug' | 'Release' + +export interface CppBuildAssistantConfig { + projectRoot: string + cppDir: string + buildDir: string +} + +export const createCppBuildAssistantConfig = (projectRoot?: string): CppBuildAssistantConfig => { + const resolvedProjectRoot = projectRoot || path.join(__dirname, '..') + const cppDir = path.join(resolvedProjectRoot, 'cpp') + + return { + projectRoot: resolvedProjectRoot, + cppDir, + buildDir: path.join(cppDir, 'build'), + } +} diff --git a/dbal/shared/tools/cpp-build-assistant/index.ts b/dbal/shared/tools/cpp-build-assistant/index.ts index 6810db714..a4f7500b8 100644 --- a/dbal/shared/tools/cpp-build-assistant/index.ts +++ b/dbal/shared/tools/cpp-build-assistant/index.ts @@ -1,18 +1,27 @@ import os from 'os' import path from 'path' +import { CppBuildAssistantConfig, BuildType, createCppBuildAssistantConfig } from './config' import { COLORS, log } from './logging' import { checkDependencies } from './dependencies' import { cleanBuild, configureCMake, ensureConanFile, execCommand, installConanDeps, buildTarget, runTests } from './workflow' export class CppBuildAssistant { - private projectRoot: string - private cppDir: string - private buildDir: string + private config: CppBuildAssistantConfig - constructor(projectRoot?: string) { - this.projectRoot = projectRoot || path.join(__dirname, '..') - this.cppDir = path.join(this.projectRoot, 'cpp') - this.buildDir = path.join(this.cppDir, 'build') + constructor(config?: CppBuildAssistantConfig) { + this.config = config || createCppBuildAssistantConfig() + } + + get projectRoot(): string { + return this.config.projectRoot + } + + get cppDir(): string { + return this.config.cppDir + } + + get buildDir(): string { + return this.config.buildDir } checkDependencies(): boolean { @@ -27,7 +36,7 @@ export class CppBuildAssistant { return installConanDeps(this.cppDir, execCommand) } - configureCMake(buildType: 'Debug' | 'Release' = 'Release'): boolean { + configureCMake(buildType: BuildType = 'Release'): boolean { return configureCMake(this.cppDir, buildType, execCommand) } @@ -42,88 +51,11 @@ export class CppBuildAssistant { clean(): boolean { return cleanBuild(this.buildDir) } - - async run(args: string[]): Promise { - const command = args[0] || 'help' - const options = args.slice(1) - - const buildType = options.includes('--debug') ? 'Debug' : 'Release' - const jobsArg = options.find(option => option.startsWith('--jobs=')) - const jobs = jobsArg ? parseInt(jobsArg.split('=')[1]) : os.cpus().length - - switch (command) { - case 'check': - return this.checkDependencies() - case 'init': - return this.createConanfile() - case 'install': - if (!this.checkDependencies()) return false - return this.installConanDeps() - case 'configure': - if (!this.checkDependencies()) return false - return this.configureCMake(buildType as 'Debug' | 'Release') - case 'build': - if (!this.checkDependencies()) return false - const target = options.find(option => !option.startsWith('--')) || 'all' - return this.build(target, jobs) - case 'test': - return this.test() - case 'clean': - return this.clean() - case 'rebuild': - this.clean() - if (!this.checkDependencies()) return false - if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false - return this.build('all', jobs) - case 'full': - log.section('Full Build Workflow') - if (!this.checkDependencies()) return false - if (!this.createConanfile()) return false - if (!this.installConanDeps()) return false - if (!this.configureCMake(buildType as 'Debug' | 'Release')) return false - return this.build('all', jobs) - case 'help': - default: - this.showHelp() - return true - } - } - - private showHelp(): void { - console.log(` -${COLORS.bright}C++ Build Assistant${COLORS.reset} - Conan + Ninja Build Helper - -${COLORS.cyan}USAGE:${COLORS.reset} - npm run cpp:build [command] [options] - -${COLORS.cyan}COMMANDS:${COLORS.reset} - ${COLORS.green}check${COLORS.reset} Check if all dependencies are installed - ${COLORS.green}init${COLORS.reset} Initialize project (create conanfile if missing) - ${COLORS.green}install${COLORS.reset} Install Conan dependencies - ${COLORS.green}configure${COLORS.reset} Configure CMake with Ninja generator - ${COLORS.green}build${COLORS.reset} [target] Build the project (default: all) - ${COLORS.green}test${COLORS.reset} Run tests with CTest - ${COLORS.green}clean${COLORS.reset} Remove build artifacts - ${COLORS.green}rebuild${COLORS.reset} Clean and rebuild - ${COLORS.green}full${COLORS.reset} Full workflow: check → install → configure → build - ${COLORS.green}help${COLORS.reset} Show this help message - -${COLORS.cyan}OPTIONS:${COLORS.reset} - --debug Use Debug build type - --release Use Release build type (default) - --jobs=N Number of parallel build jobs (default: CPU count) - -${COLORS.cyan}EXAMPLES:${COLORS.reset} - npm run cpp:build check - npm run cpp:build full - npm run cpp:build build dbal_daemon - npm run cpp:build build -- --debug - npm run cpp:build test -`) - } } -export const runCppBuildAssistant = async (args: string[], projectRoot?: string) => { - const assistant = new CppBuildAssistant(projectRoot || path.join(__dirname, '..')) - return assistant.run(args) +export const createAssistant = (projectRoot?: string): CppBuildAssistant => { + const config = createCppBuildAssistantConfig(projectRoot || path.join(__dirname, '..')) + return new CppBuildAssistant(config) } + +export { BuildType, CppBuildAssistantConfig, COLORS, log } diff --git a/dbal/shared/tools/cpp-build-assistant/runner.ts b/dbal/shared/tools/cpp-build-assistant/runner.ts new file mode 100644 index 000000000..5d84827d2 --- /dev/null +++ b/dbal/shared/tools/cpp-build-assistant/runner.ts @@ -0,0 +1,10 @@ +import { CppBuildAssistant } from './index' +import { createCppBuildAssistantConfig } from './config' +import { runCli } from './cli' + +export const runCppBuildAssistant = async (args: string[], projectRoot?: string): Promise => { + const config = createCppBuildAssistantConfig(projectRoot) + const assistant = new CppBuildAssistant(config) + + return runCli(args, assistant) +} diff --git a/frontends/nextjs/src/components/editors/JsonEditor.tsx b/frontends/nextjs/src/components/editors/JsonEditor.tsx index 7b6beb59a..f6ebcf593 100644 --- a/frontends/nextjs/src/components/editors/JsonEditor.tsx +++ b/frontends/nextjs/src/components/editors/JsonEditor.tsx @@ -1,12 +1,13 @@ -import { useState, useEffect } from 'react' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui' -import { Button } from '@/components/ui' -import { Alert, AlertDescription } from '@/components/ui' -import { FloppyDisk, X, Warning, ShieldCheck } from '@phosphor-icons/react' +import { useEffect, useState } from 'react' +import { Alert, AlertDescription, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui' +import { Warning } from '@phosphor-icons/react' import Editor from '@monaco-editor/react' +import { toast } from 'sonner' + +import { SchemaSection } from './json/SchemaSection' +import { Toolbar } from './json/Toolbar' import { securityScanner, type SecurityScanResult } from '@/lib/security-scanner' import { SecurityWarningDialog } from '@/components/organisms/security/SecurityWarningDialog' -import { toast } from 'sonner' interface JsonEditorProps { open: boolean @@ -32,10 +33,12 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json } }, [open, value]) + const parseJson = () => JSON.parse(jsonText) + const handleSave = () => { try { - const parsed = JSON.parse(jsonText) - + const parsed = parseJson() + const scanResult = securityScanner.scanJSON(jsonText) setSecurityScanResult(scanResult) @@ -66,8 +69,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json const handleForceSave = () => { try { - const parsed = JSON.parse(jsonText) - onSave(parsed) + onSave(parseJson()) setError(null) setPendingSave(false) setShowSecurityDialog(false) @@ -81,7 +83,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json const scanResult = securityScanner.scanJSON(jsonText) setSecurityScanResult(scanResult) setShowSecurityDialog(true) - + if (scanResult.safe) { toast.success('No security issues detected') } else { @@ -91,8 +93,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json const handleFormat = () => { try { - const parsed = JSON.parse(jsonText) - setJsonText(JSON.stringify(parsed, null, 2)) + setJsonText(JSON.stringify(parseJson(), null, 2)) setError(null) } catch (err) { setError(err instanceof Error ? err.message : 'Invalid JSON - cannot format') @@ -106,7 +107,7 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json {title} - +
{error && ( @@ -115,16 +116,21 @@ export function JsonEditor({ open, onClose, title, value, onSave, schema }: Json )} - {securityScanResult && securityScanResult.severity !== 'safe' && securityScanResult.severity !== 'low' && !showSecurityDialog && ( - - - - {securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} detected. - Click Security Scan to review. - - - )} - + {securityScanResult && + securityScanResult.severity !== 'safe' && + securityScanResult.severity !== 'low' && + !showSecurityDialog && ( + + + + {securityScanResult.issues.length} security {securityScanResult.issues.length === 1 ? 'issue' : 'issues'} +  detected. Click Security Scan to review. + + + )} + + +
- - - - - - + diff --git a/frontends/nextjs/src/components/editors/ThemeEditor.tsx b/frontends/nextjs/src/components/editors/ThemeEditor.tsx index 03c56b878..9800bdb50 100644 --- a/frontends/nextjs/src/components/editors/ThemeEditor.tsx +++ b/frontends/nextjs/src/components/editors/ThemeEditor.tsx @@ -1,79 +1,15 @@ -import { useState, useEffect } from 'react' +import { useEffect, useState } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Label } from '@/components/ui' -import { Input } from '@/components/ui' import { Button } from '@/components/ui' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui' import { Switch } from '@/components/ui' import { Palette, Sun, Moon, FloppyDisk, ArrowCounterClockwise } from '@phosphor-icons/react' import { toast } from 'sonner' import { useKV } from '@github/spark/hooks' - -interface ThemeColors { - background: string - foreground: string - card: string - cardForeground: string - primary: string - primaryForeground: string - secondary: string - secondaryForeground: string - muted: string - mutedForeground: string - accent: string - accentForeground: string - destructive: string - destructiveForeground: string - border: string - input: string - ring: string -} - -interface ThemeConfig { - light: ThemeColors - dark: ThemeColors - radius: string -} - -const DEFAULT_LIGHT_THEME: ThemeColors = { - background: 'oklch(0.92 0.03 290)', - foreground: 'oklch(0.25 0.02 260)', - card: 'oklch(1 0 0)', - cardForeground: 'oklch(0.25 0.02 260)', - primary: 'oklch(0.55 0.18 290)', - primaryForeground: 'oklch(0.98 0 0)', - secondary: 'oklch(0.35 0.02 260)', - secondaryForeground: 'oklch(0.90 0.01 260)', - muted: 'oklch(0.95 0.02 290)', - mutedForeground: 'oklch(0.50 0.02 260)', - accent: 'oklch(0.70 0.17 195)', - accentForeground: 'oklch(0.2 0.02 260)', - destructive: 'oklch(0.55 0.22 25)', - destructiveForeground: 'oklch(0.98 0 0)', - border: 'oklch(0.85 0.02 290)', - input: 'oklch(0.85 0.02 290)', - ring: 'oklch(0.70 0.17 195)', -} - -const DEFAULT_DARK_THEME: ThemeColors = { - background: 'oklch(0.145 0 0)', - foreground: 'oklch(0.985 0 0)', - card: 'oklch(0.205 0 0)', - cardForeground: 'oklch(0.985 0 0)', - primary: 'oklch(0.922 0 0)', - primaryForeground: 'oklch(0.205 0 0)', - secondary: 'oklch(0.269 0 0)', - secondaryForeground: 'oklch(0.985 0 0)', - muted: 'oklch(0.269 0 0)', - mutedForeground: 'oklch(0.708 0 0)', - accent: 'oklch(0.269 0 0)', - accentForeground: 'oklch(0.985 0 0)', - destructive: 'oklch(0.704 0.191 22.216)', - destructiveForeground: 'oklch(0.98 0 0)', - border: 'oklch(1 0 0 / 10%)', - input: 'oklch(1 0 0 / 15%)', - ring: 'oklch(0.556 0 0)', -} +import { PaletteEditor } from './theme/PaletteEditor' +import { PreviewPane } from './theme/PreviewPane' +import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from './theme/constants' +import { ThemeColors, ThemeConfig } from './theme/types' export function ThemeEditor() { const [themeConfig, setThemeConfig] = useKV('theme_config', { @@ -81,7 +17,7 @@ export function ThemeEditor() { dark: DEFAULT_DARK_THEME, radius: '0.5rem', }) - + const [isDarkMode, setIsDarkMode] = useKV('dark_mode_enabled', false) const [editingTheme, setEditingTheme] = useState<'light' | 'dark'>('light') const [localColors, setLocalColors] = useState(DEFAULT_LIGHT_THEME) @@ -95,30 +31,19 @@ export function ThemeEditor() { }, [editingTheme, themeConfig]) useEffect(() => { - if (themeConfig) { - applyTheme() - } - }, [themeConfig, isDarkMode]) - - const applyTheme = () => { if (!themeConfig) return - + const root = document.documentElement const colors = isDarkMode ? themeConfig.dark : themeConfig.light - + Object.entries(colors).forEach(([key, value]) => { const cssVarName = key.replace(/([A-Z])/g, '-$1').toLowerCase() root.style.setProperty(`--${cssVarName}`, value) }) - + root.style.setProperty('--radius', themeConfig.radius) - - if (isDarkMode) { - root.classList.add('dark') - } else { - root.classList.remove('dark') - } - } + root.classList.toggle('dark', isDarkMode) + }, [isDarkMode, themeConfig]) const handleColorChange = (colorKey: keyof ThemeColors, value: string) => { setLocalColors((current) => ({ @@ -130,12 +55,14 @@ export function ThemeEditor() { const handleSave = () => { setThemeConfig((current) => { if (!current) return { light: localColors, dark: DEFAULT_DARK_THEME, radius: localRadius } + return { ...current, [editingTheme]: localColors, radius: localRadius, } }) + toast.success('Theme saved successfully') } @@ -151,41 +78,6 @@ export function ThemeEditor() { toast.success(checked ? 'Dark mode enabled' : 'Light mode enabled') } - const colorGroups = [ - { - title: 'Base Colors', - colors: [ - { key: 'background' as const, label: 'Background' }, - { key: 'foreground' as const, label: 'Foreground' }, - { key: 'card' as const, label: 'Card' }, - { key: 'cardForeground' as const, label: 'Card Foreground' }, - ], - }, - { - title: 'Action Colors', - colors: [ - { key: 'primary' as const, label: 'Primary' }, - { key: 'primaryForeground' as const, label: 'Primary Foreground' }, - { key: 'secondary' as const, label: 'Secondary' }, - { key: 'secondaryForeground' as const, label: 'Secondary Foreground' }, - { key: 'accent' as const, label: 'Accent' }, - { key: 'accentForeground' as const, label: 'Accent Foreground' }, - { key: 'destructive' as const, label: 'Destructive' }, - { key: 'destructiveForeground' as const, label: 'Destructive Foreground' }, - ], - }, - { - title: 'Supporting Colors', - colors: [ - { key: 'muted' as const, label: 'Muted' }, - { key: 'mutedForeground' as const, label: 'Muted Foreground' }, - { key: 'border' as const, label: 'Border' }, - { key: 'input' as const, label: 'Input' }, - { key: 'ring' as const, label: 'Ring' }, - ], - }, - ] - return (
@@ -196,9 +88,7 @@ export function ThemeEditor() { Theme Editor - - Customize the application theme colors and appearance - + Customize the application theme colors and appearance
@@ -207,52 +97,21 @@ export function ThemeEditor() {
- - setEditingTheme(v as 'light' | 'dark')}> + + + setEditingTheme(value as 'light' | 'dark')}> Light Theme Dark Theme - - -
-
- - setLocalRadius(e.target.value)} - placeholder="e.g., 0.5rem" - className="mt-1.5" - /> -
-
- {colorGroups.map((group) => ( -
-

{group.title}

-
- {group.colors.map(({ key, label }) => ( -
- -
-
- handleColorChange(key, e.target.value)} - placeholder="oklch(...)" - className="font-mono text-sm" - /> -
-
- ))} -
-
- ))} + +
- - - -
- - - Card Example - This is a card description - - -

Card content with muted text

-
-
-
- +
diff --git a/frontends/nextjs/src/components/editors/json/SchemaSection.tsx b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx new file mode 100644 index 000000000..b77ca8486 --- /dev/null +++ b/frontends/nextjs/src/components/editors/json/SchemaSection.tsx @@ -0,0 +1,26 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' + +interface SchemaSectionProps { + schema?: unknown +} + +export function SchemaSection({ schema }: SchemaSectionProps) { + if (!schema) return null + + const formattedSchema = + typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) + + return ( + + + Schema + Reference for the expected JSON structure + + +
+          {formattedSchema}
+        
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/json/Toolbar.tsx b/frontends/nextjs/src/components/editors/json/Toolbar.tsx new file mode 100644 index 000000000..a642ce6fc --- /dev/null +++ b/frontends/nextjs/src/components/editors/json/Toolbar.tsx @@ -0,0 +1,31 @@ +import { Button, DialogFooter } from '@/components/ui' +import { FloppyDisk, ShieldCheck, X } from '@phosphor-icons/react' + +interface ToolbarProps { + onScan: () => void + onFormat: () => void + onCancel: () => void + onSave: () => void +} + +export function Toolbar({ onScan, onFormat, onCancel, onSave }: ToolbarProps) { + return ( + + + + + + + ) +} diff --git a/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx b/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx new file mode 100644 index 000000000..2950b135b --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/PaletteEditor.tsx @@ -0,0 +1,86 @@ +import { Input, Label } from '@/components/ui' +import { ThemeColors } from './types' + +const colorGroups = [ + { + title: 'Base Colors', + colors: [ + { key: 'background' as const, label: 'Background' }, + { key: 'foreground' as const, label: 'Foreground' }, + { key: 'card' as const, label: 'Card' }, + { key: 'cardForeground' as const, label: 'Card Foreground' }, + ], + }, + { + title: 'Action Colors', + colors: [ + { key: 'primary' as const, label: 'Primary' }, + { key: 'primaryForeground' as const, label: 'Primary Foreground' }, + { key: 'secondary' as const, label: 'Secondary' }, + { key: 'secondaryForeground' as const, label: 'Secondary Foreground' }, + { key: 'accent' as const, label: 'Accent' }, + { key: 'accentForeground' as const, label: 'Accent Foreground' }, + { key: 'destructive' as const, label: 'Destructive' }, + { key: 'destructiveForeground' as const, label: 'Destructive Foreground' }, + ], + }, + { + title: 'Supporting Colors', + colors: [ + { key: 'muted' as const, label: 'Muted' }, + { key: 'mutedForeground' as const, label: 'Muted Foreground' }, + { key: 'border' as const, label: 'Border' }, + { key: 'input' as const, label: 'Input' }, + { key: 'ring' as const, label: 'Ring' }, + ], + }, +] + +interface PaletteEditorProps { + colors: ThemeColors + radius: string + onColorChange: (colorKey: keyof ThemeColors, value: string) => void + onRadiusChange: (value: string) => void +} + +export function PaletteEditor({ colors, radius, onColorChange, onRadiusChange }: PaletteEditorProps) { + return ( +
+
+
+ + onRadiusChange(e.target.value)} + placeholder="e.g., 0.5rem" + className="mt-1.5" + /> +
+
+ + {colorGroups.map((group) => ( +
+

{group.title}

+
+ {group.colors.map(({ key, label }) => ( +
+ +
+
+ onColorChange(key, e.target.value)} + placeholder="oklch(...)" + className="font-mono text-sm" + /> +
+
+ ))} +
+
+ ))} +
+ ) +} diff --git a/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx b/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx new file mode 100644 index 000000000..795ccf45d --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/PreviewPane.tsx @@ -0,0 +1,33 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' +import { Button } from '@/components/ui' + +export function PreviewPane() { + return ( +
+

Theme Preview

+
+
+ + + + +
+ + + Card Example + This is a card description + + +

Card content with muted text

+
+
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/editors/theme/constants.ts b/frontends/nextjs/src/components/editors/theme/constants.ts new file mode 100644 index 000000000..7e7b8f8b2 --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/constants.ts @@ -0,0 +1,41 @@ +import { ThemeColors } from './types' + +export const DEFAULT_LIGHT_THEME: ThemeColors = { + background: 'oklch(0.92 0.03 290)', + foreground: 'oklch(0.25 0.02 260)', + card: 'oklch(1 0 0)', + cardForeground: 'oklch(0.25 0.02 260)', + primary: 'oklch(0.55 0.18 290)', + primaryForeground: 'oklch(0.98 0 0)', + secondary: 'oklch(0.35 0.02 260)', + secondaryForeground: 'oklch(0.90 0.01 260)', + muted: 'oklch(0.95 0.02 290)', + mutedForeground: 'oklch(0.50 0.02 260)', + accent: 'oklch(0.70 0.17 195)', + accentForeground: 'oklch(0.2 0.02 260)', + destructive: 'oklch(0.55 0.22 25)', + destructiveForeground: 'oklch(0.98 0 0)', + border: 'oklch(0.85 0.02 290)', + input: 'oklch(0.85 0.02 290)', + ring: 'oklch(0.70 0.17 195)', +} + +export const DEFAULT_DARK_THEME: ThemeColors = { + background: 'oklch(0.145 0 0)', + foreground: 'oklch(0.985 0 0)', + card: 'oklch(0.205 0 0)', + cardForeground: 'oklch(0.985 0 0)', + primary: 'oklch(0.922 0 0)', + primaryForeground: 'oklch(0.205 0 0)', + secondary: 'oklch(0.269 0 0)', + secondaryForeground: 'oklch(0.985 0 0)', + muted: 'oklch(0.269 0 0)', + mutedForeground: 'oklch(0.708 0 0)', + accent: 'oklch(0.269 0 0)', + accentForeground: 'oklch(0.985 0 0)', + destructive: 'oklch(0.704 0.191 22.216)', + destructiveForeground: 'oklch(0.98 0 0)', + border: 'oklch(1 0 0 / 10%)', + input: 'oklch(1 0 0 / 15%)', + ring: 'oklch(0.556 0 0)', +} diff --git a/frontends/nextjs/src/components/editors/theme/types.ts b/frontends/nextjs/src/components/editors/theme/types.ts new file mode 100644 index 000000000..8440c06d8 --- /dev/null +++ b/frontends/nextjs/src/components/editors/theme/types.ts @@ -0,0 +1,25 @@ +export interface ThemeColors { + background: string + foreground: string + card: string + cardForeground: string + primary: string + primaryForeground: string + secondary: string + secondaryForeground: string + muted: string + mutedForeground: string + accent: string + accentForeground: string + destructive: string + destructiveForeground: string + border: string + input: string + ring: string +} + +export interface ThemeConfig { + light: ThemeColors + dark: ThemeColors + radius: string +} diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx index 514ccdb25..f811601a0 100644 --- a/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel.tsx @@ -1,22 +1,13 @@ "use client" import { useEffect, useMemo, useState } from 'react' -import { Button } from '@/components/ui' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Badge } from '@/components/ui' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui' -import { Stack, Typography } from '@/components/ui' import { toast } from 'sonner' +import { AppHeader } from '@/components/shared/AppHeader' import { Database } from '@/lib/database' import type { Comment, User } from '@/lib/level-types' -import { AppHeader } from '@/components/shared/AppHeader' +import { ModeratorActions } from './ModeratorPanel/Actions' +import { ModeratorHeader } from './ModeratorPanel/Header' +import { ModeratorLogList } from './ModeratorPanel/LogList' const FLAGGED_TERMS = ['spam', 'error', 'abuse', 'illegal', 'urgent', 'offensive'] @@ -70,8 +61,6 @@ export function ModeratorPanel({ user, onLogout, onNavigate }: ModeratorPanelPro toast.success('Flag resolved and archived from the queue') } - const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1) - return (
-
- Moderation queue - - Keep the community healthy by resolving flags, reviewing reports, and guiding the tone. - -
- -
- - - Flagged content - Automated signal based on keywords - - - {flaggedComments.length} - - Pending items in the moderation queue - - - - - - - Resolved this session - - - {resolvedIds.length} - - Items you flagged as handled - - - - - - - Community signals - - - - {FLAGGED_TERMS.map((term) => ( - {highlightLabel(term)} - ))} - - - Track the keywords that pulled items into the queue - - - -
- - - -
-
- Flagged comments - A curated view of the comments that triggered a signal -
- -
-
- - {isLoading ? ( - Loading flagged comments… - ) : flaggedComments.length === 0 ? ( - - No flagged comments at the moment. Enjoy the calm. - - ) : ( - - - - User - Comment - Matched terms - Actions - - - - {flaggedComments.map((comment) => { - const matches = FLAGGED_TERMS.filter((term) => - comment.content.toLowerCase().includes(term) - ) - return ( - - {comment.userId} - {comment.content} - - - {matches.map((match) => ( - - {match} - - ))} - - - - - - - ) - })} - -
- )} -
-
+ + +
) diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx new file mode 100644 index 000000000..8bfa78c49 --- /dev/null +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Actions.tsx @@ -0,0 +1,56 @@ +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack, Typography } from '@/components/ui' + +interface ModeratorActionsProps { + flaggedCount: number + resolvedCount: number + flaggedTerms: string[] +} + +export function ModeratorActions({ flaggedCount, resolvedCount, flaggedTerms }: ModeratorActionsProps) { + const highlightLabel = (term: string) => term.charAt(0).toUpperCase() + term.slice(1) + + return ( +
+ + + Flagged content + Automated signal based on keywords + + + {flaggedCount} + + Pending items in the moderation queue + + + + + + + Resolved this session + + + {resolvedCount} + + Items you flagged as handled + + + + + + + Community signals + + + + {flaggedTerms.map((term) => ( + {highlightLabel(term)} + ))} + + + Track the keywords that pulled items into the queue + + + +
+ ) +} diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx new file mode 100644 index 000000000..cb86c4a0a --- /dev/null +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/Header.tsx @@ -0,0 +1,12 @@ +import { Typography } from '@/components/ui' + +export function ModeratorHeader() { + return ( +
+ Moderation queue + + Keep the community healthy by resolving flags, reviewing reports, and guiding the tone. + +
+ ) +} diff --git a/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx b/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx new file mode 100644 index 000000000..b133c6b0b --- /dev/null +++ b/frontends/nextjs/src/components/level/panels/ModeratorPanel/LogList.tsx @@ -0,0 +1,83 @@ +import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Stack } from '@/components/ui' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from '@/components/ui' +import type { Comment } from '@/lib/level-types' + +interface ModeratorLogListProps { + flaggedComments: Comment[] + flaggedTerms: string[] + isLoading: boolean + onNavigate: (level: number) => void + onResolve: (commentId: string) => void +} + +export function ModeratorLogList({ + flaggedComments, + flaggedTerms, + isLoading, + onNavigate, + onResolve, +}: ModeratorLogListProps) { + return ( + + +
+
+ Flagged comments + A curated view of the comments that triggered a signal +
+ +
+
+ + {isLoading ? ( + Loading flagged comments… + ) : flaggedComments.length === 0 ? ( + + No flagged comments at the moment. Enjoy the calm. + + ) : ( + + + + User + Comment + Matched terms + Actions + + + + {flaggedComments.map((comment) => { + const matches = flaggedTerms.filter((term) => + comment.content.toLowerCase().includes(term) + ) + + return ( + + {comment.userId} + {comment.content} + + + {matches.map((match) => ( + + {match} + + ))} + + + + + + + ) + })} + +
+ )} +
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx b/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx index 254d0132e..dbd2edc00 100644 --- a/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx +++ b/frontends/nextjs/src/components/managers/DropdownConfigManager.tsx @@ -1,26 +1,16 @@ -import { useState, useEffect } from 'react' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui' -import { Button } from '@/components/ui' -import { Input } from '@/components/ui' -import { Label } from '@/components/ui' -import { ScrollArea } from '@/components/ui' -import { Card } from '@/components/ui' -import { Badge } from '@/components/ui' -import { Separator } from '@/components/ui' +import { useEffect, useState } from 'react' +import { Button, Card } from '@/components/ui' import { Database } from '@/lib/database' -import { Plus, X, FloppyDisk, Trash, Pencil } from '@phosphor-icons/react' +import { Plus } from '@phosphor-icons/react' import { toast } from 'sonner' import type { DropdownConfig } from '@/lib/database' +import { DropdownConfigForm } from './dropdown/DropdownConfigForm' +import { PreviewPane } from './dropdown/PreviewPane' export function DropdownConfigManager() { const [dropdowns, setDropdowns] = useState([]) const [isEditing, setIsEditing] = useState(false) const [editingDropdown, setEditingDropdown] = useState(null) - const [dropdownName, setDropdownName] = useState('') - const [dropdownLabel, setDropdownLabel] = useState('') - const [options, setOptions] = useState>([]) - const [newOptionValue, setNewOptionValue] = useState('') - const [newOptionLabel, setNewOptionLabel] = useState('') useEffect(() => { loadDropdowns() @@ -31,63 +21,34 @@ export function DropdownConfigManager() { setDropdowns(configs) } - const startEdit = (dropdown?: DropdownConfig) => { - if (dropdown) { - setEditingDropdown(dropdown) - setDropdownName(dropdown.name) - setDropdownLabel(dropdown.label) - setOptions(dropdown.options) - } else { - setEditingDropdown(null) - setDropdownName('') - setDropdownLabel('') - setOptions([]) - } + const openEditor = (dropdown?: DropdownConfig) => { + setEditingDropdown(dropdown ?? null) setIsEditing(true) } - const addOption = () => { - if (newOptionValue && newOptionLabel) { - setOptions(current => [...current, { value: newOptionValue, label: newOptionLabel }]) - setNewOptionValue('') - setNewOptionLabel('') - } - } - - const removeOption = (index: number) => { - setOptions(current => current.filter((_, i) => i !== index)) - } - - const handleSave = async () => { - if (!dropdownName || !dropdownLabel || options.length === 0) { - toast.error('Please fill all fields and add at least one option') - return - } - - const newDropdown: DropdownConfig = { - id: editingDropdown?.id || `dropdown_${Date.now()}`, - name: dropdownName, - label: dropdownLabel, - options, - } - - if (editingDropdown) { - await Database.updateDropdownConfig(newDropdown.id, newDropdown) + const handleSave = async (config: DropdownConfig, isEdit: boolean) => { + if (isEdit) { + await Database.updateDropdownConfig(config.id, config) toast.success('Dropdown updated successfully') } else { - await Database.addDropdownConfig(newDropdown) + await Database.addDropdownConfig(config) toast.success('Dropdown created successfully') } setIsEditing(false) - loadDropdowns() + await loadDropdowns() } const handleDelete = async (id: string) => { - if (confirm('Are you sure you want to delete this dropdown configuration?')) { - await Database.deleteDropdownConfig(id) - toast.success('Dropdown deleted') - loadDropdowns() + await Database.deleteDropdownConfig(id) + toast.success('Dropdown deleted') + await loadDropdowns() + } + + const handleDialogChange = (open: boolean) => { + setIsEditing(open) + if (!open) { + setEditingDropdown(null) } } @@ -98,7 +59,7 @@ export function DropdownConfigManager() {

Dropdown Configurations

Manage dynamic dropdown options for properties

- @@ -106,30 +67,12 @@ export function DropdownConfigManager() {
{dropdowns.map(dropdown => ( - -
-
-

{dropdown.label}

-

{dropdown.name}

-
-
- - -
-
- -
- {dropdown.options.map((opt, i) => ( - - {opt.label} - - ))} -
-
+ ))}
@@ -139,88 +82,12 @@ export function DropdownConfigManager() { )} - - - - {editingDropdown ? 'Edit' : 'Create'} Dropdown Configuration - - -
-
- - setDropdownName(e.target.value)} - /> -

Unique identifier for this dropdown

-
- -
- - setDropdownLabel(e.target.value)} - /> -
- - - -
- -
- setNewOptionValue(e.target.value)} - /> - setNewOptionLabel(e.target.value)} - /> - -
-
- - {options.length > 0 && ( - -
- {options.map((opt, i) => ( -
-
- {opt.value} - - {opt.label} -
- -
- ))} -
-
- )} -
- - - - - -
-
+ ) } diff --git a/frontends/nextjs/src/components/managers/PageRoutesManager.tsx b/frontends/nextjs/src/components/managers/PageRoutesManager.tsx index e6ed6411f..e11958f24 100644 --- a/frontends/nextjs/src/components/managers/PageRoutesManager.tsx +++ b/frontends/nextjs/src/components/managers/PageRoutesManager.tsx @@ -1,29 +1,39 @@ -import { useState, useEffect } from 'react' -import { Button } from '@/components/ui' -import { Input } from '@/components/ui' -import { Label } from '@/components/ui' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui' -import { Badge } from '@/components/ui' -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui' -import { Plus, Pencil, Trash, Eye, LockKey } from '@phosphor-icons/react' +import { useEffect, useState } from 'react' +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui' +import { Plus } from '@phosphor-icons/react' import { Database } from '@/lib/database' +import type { PageConfig } from '@/lib/level-types' import { toast } from 'sonner' -import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types' -import { Switch } from '@/components/ui' +import { RoutesTable } from './page-routes/RoutesTable' +import { Preview } from './page-routes/Preview' +import { RouteEditor, RouteFormData } from './page-routes/RouteEditor' + +const defaultFormData: RouteFormData = { + path: '/', + title: '', + level: 1, + requiresAuth: false, + componentTree: [], +} export function PageRoutesManager() { const [pages, setPages] = useState([]) const [isDialogOpen, setIsDialogOpen] = useState(false) const [editingPage, setEditingPage] = useState(null) - const [formData, setFormData] = useState>({ - path: '/', - title: '', - level: 1, - requiresAuth: false, - componentTree: [], - }) + const [formData, setFormData] = useState({ ...defaultFormData }) useEffect(() => { loadPages() @@ -40,13 +50,7 @@ export function PageRoutesManager() { setFormData(page) } else { setEditingPage(null) - setFormData({ - path: '/', - title: '', - level: 1, - requiresAuth: false, - componentTree: [], - }) + setFormData({ ...defaultFormData }) } setIsDialogOpen(true) } @@ -54,13 +58,7 @@ export function PageRoutesManager() { const handleCloseDialog = () => { setIsDialogOpen(false) setEditingPage(null) - setFormData({ - path: '/', - title: '', - level: 1, - requiresAuth: false, - componentTree: [], - }) + setFormData({ ...defaultFormData }) } const handleSavePage = async () => { @@ -98,18 +96,6 @@ export function PageRoutesManager() { } } - const getLevelBadgeColor = (level: AppLevel) => { - switch (level) { - case 1: return 'bg-blue-500' - case 2: return 'bg-green-500' - case 3: return 'bg-orange-500' - case 4: return 'bg-sky-500' - case 5: return 'bg-purple-500' - case 6: return 'bg-rose-500' - default: return 'bg-gray-500' - } - } - return (
@@ -124,94 +110,23 @@ export function PageRoutesManager() { New Page Route - + {editingPage ? 'Edit Page Route' : 'Create New Page Route'} Configure the route path, access level, and authentication requirements - -
-
-
- - setFormData({ ...formData, path: e.target.value })} - /> -
-
- - setFormData({ ...formData, title: e.target.value })} - /> -
-
- -
-
- - -
- -
- - -
-
- -
- setFormData({ ...formData, requiresAuth: checked })} - /> - -
+
+ +
- - - - -
@@ -222,67 +137,11 @@ export function PageRoutesManager() { All page routes in your application - {pages.length === 0 ? ( -
-

No pages configured yet. Create your first page route!

-
- ) : ( - - - - Path - Title - Level - Auth - Required Role - Actions - - - - {pages.map((page) => ( - - {page.path} - {page.title} - - - Level {page.level} - - - - {page.requiresAuth ? ( - - ) : ( - - )} - - - - {page.requiredRole || 'public'} - - - -
- - -
-
-
- ))} -
-
- )} +
diff --git a/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx b/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx new file mode 100644 index 000000000..754d4cf73 --- /dev/null +++ b/frontends/nextjs/src/components/managers/dropdown/DropdownConfigForm.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useState } from 'react' +import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, ScrollArea, Separator } from '@/components/ui' +import { FloppyDisk, Plus, X } from '@phosphor-icons/react' +import { toast } from 'sonner' +import type { DropdownConfig } from '@/lib/database' + +interface DropdownConfigFormProps { + open: boolean + editingDropdown: DropdownConfig | null + onOpenChange: (open: boolean) => void + onSave: (config: DropdownConfig, isEdit: boolean) => Promise | void +} + +const getDefaultOptions = (dropdown?: DropdownConfig | null) => dropdown?.options ?? [] + +const buildDropdownConfig = ( + dropdown: DropdownConfig | null, + name: string, + label: string, + options: Array<{ value: string; label: string }> +): DropdownConfig => ({ + id: dropdown?.id ?? `dropdown_${Date.now()}`, + name: name.trim(), + label: label.trim(), + options, +}) + +export function DropdownConfigForm({ open, editingDropdown, onOpenChange, onSave }: DropdownConfigFormProps) { + const [dropdownName, setDropdownName] = useState('') + const [dropdownLabel, setDropdownLabel] = useState('') + const [options, setOptions] = useState>([]) + const [newOptionValue, setNewOptionValue] = useState('') + const [newOptionLabel, setNewOptionLabel] = useState('') + + const isEditMode = useMemo(() => Boolean(editingDropdown), [editingDropdown]) + + useEffect(() => { + if (open) { + setDropdownName(editingDropdown?.name ?? '') + setDropdownLabel(editingDropdown?.label ?? '') + setOptions(getDefaultOptions(editingDropdown)) + } else { + setDropdownName('') + setDropdownLabel('') + setOptions([]) + setNewOptionValue('') + setNewOptionLabel('') + } + }, [open, editingDropdown]) + + const addOption = () => { + if (!newOptionValue.trim() || !newOptionLabel.trim()) { + toast.error('Please provide both a value and label for the option') + return + } + + const duplicate = options.some( + (opt) => opt.value.toLowerCase() === newOptionValue.trim().toLowerCase() + ) + + if (duplicate) { + toast.error('An option with this value already exists') + return + } + + setOptions((current) => [ + ...current, + { value: newOptionValue.trim(), label: newOptionLabel.trim() }, + ]) + setNewOptionValue('') + setNewOptionLabel('') + } + + const removeOption = (index: number) => { + setOptions((current) => current.filter((_, i) => i !== index)) + } + + const handleSave = async () => { + if (!dropdownName.trim() || !dropdownLabel.trim() || options.length === 0) { + toast.error('Please fill all fields and add at least one option') + return + } + + const config = buildDropdownConfig(editingDropdown, dropdownName, dropdownLabel, options) + await onSave(config, isEditMode) + onOpenChange(false) + } + + return ( + + + + {isEditMode ? 'Edit' : 'Create'} Dropdown Configuration + + +
+
+ + setDropdownName(e.target.value)} + /> +

Unique identifier for this dropdown

+
+ +
+ + setDropdownLabel(e.target.value)} + /> +
+ + + +
+ +
+ setNewOptionValue(e.target.value)} + /> + setNewOptionLabel(e.target.value)} + /> + +
+
+ + {options.length > 0 && ( + +
+ {options.map((opt, i) => ( +
+
+ {opt.value} + + {opt.label} +
+ +
+ ))} +
+
+ )} +
+ + {options.length === 0 && ( +
+ Tip + Add at least one option to save this dropdown configuration. +
+ )} + + + + + +
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx b/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx new file mode 100644 index 000000000..bbc144249 --- /dev/null +++ b/frontends/nextjs/src/components/managers/dropdown/PreviewPane.tsx @@ -0,0 +1,44 @@ +import { Badge, Button, Card, Separator } from '@/components/ui' +import { Pencil, Trash } from '@phosphor-icons/react' +import type { DropdownConfig } from '@/lib/database' + +interface PreviewPaneProps { + dropdown: DropdownConfig + onEdit: (dropdown: DropdownConfig) => void + onDelete: (id: string) => void +} + +export function PreviewPane({ dropdown, onEdit, onDelete }: PreviewPaneProps) { + const handleDelete = () => { + if (confirm('Are you sure you want to delete this dropdown configuration?')) { + onDelete(dropdown.id) + } + } + + return ( + +
+
+

{dropdown.label}

+

{dropdown.name}

+
+
+ + +
+
+ +
+ {dropdown.options.map((opt, i) => ( + + {opt.label} + + ))} +
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/page-routes/Preview.tsx b/frontends/nextjs/src/components/managers/page-routes/Preview.tsx new file mode 100644 index 000000000..2bfd2e207 --- /dev/null +++ b/frontends/nextjs/src/components/managers/page-routes/Preview.tsx @@ -0,0 +1,37 @@ +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' +import { Eye, LockKey } from '@phosphor-icons/react' +import type { PageConfig } from '@/lib/level-types' + +interface PreviewProps { + formData: Partial +} + +export function Preview({ formData }: PreviewProps) { + return ( + + + Route Preview + Quick glance at the route details + + +
+

Path

+

{formData.path || '/your-path'}

+
+
+

Title

+

{formData.title || 'Untitled Page'}

+
+
+ Level {formData.level || 1} + {formData.requiredRole || 'public'} + {formData.requiresAuth ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx b/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx new file mode 100644 index 000000000..e772c97ff --- /dev/null +++ b/frontends/nextjs/src/components/managers/page-routes/RouteEditor.tsx @@ -0,0 +1,107 @@ +import { + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from '@/components/ui' +import type { PageConfig, UserRole, AppLevel } from '@/lib/level-types' + +export type RouteFormData = Partial + +interface RouteEditorProps { + formData: RouteFormData + onChange: (value: RouteFormData) => void + onSave: () => void + onCancel: () => void + isEdit: boolean +} + +export function RouteEditor({ formData, onChange, onSave, onCancel, isEdit }: RouteEditorProps) { + return ( +
+
+
+ + onChange({ ...formData, path: e.target.value })} + /> +
+
+ + onChange({ ...formData, title: e.target.value })} + /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ onChange({ ...formData, requiresAuth: checked })} + /> + +
+ +
+ + +
+
+ ) +} diff --git a/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx b/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx new file mode 100644 index 000000000..be7f98038 --- /dev/null +++ b/frontends/nextjs/src/components/managers/page-routes/RoutesTable.tsx @@ -0,0 +1,98 @@ +import { + Badge, + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui' +import { Eye, LockKey, Pencil, Trash } from '@phosphor-icons/react' +import type { PageConfig, AppLevel } from '@/lib/level-types' + +interface RoutesTableProps { + pages: PageConfig[] + onEdit: (page: PageConfig) => void + onDelete: (pageId: string) => void +} + +const getLevelBadgeColor = (level: AppLevel) => { + switch (level) { + case 1: return 'bg-blue-500' + case 2: return 'bg-green-500' + case 3: return 'bg-orange-500' + case 4: return 'bg-sky-500' + case 5: return 'bg-purple-500' + case 6: return 'bg-rose-500' + default: return 'bg-gray-500' + } +} + +export function RoutesTable({ pages, onEdit, onDelete }: RoutesTableProps) { + if (pages.length === 0) { + return ( +
+

No pages configured yet. Create your first page route!

+
+ ) + } + + return ( + + + + Path + Title + Level + Auth + Required Role + Actions + + + + {pages.map((page) => ( + + {page.path} + {page.title} + + + Level {page.level} + + + + {page.requiresAuth ? ( + + ) : ( + + )} + + + + {page.requiredRole || 'public'} + + + +
+ + +
+
+
+ ))} +
+
+ ) +}