diff --git a/apps/server/src/core/base/services/__tests__/strip-property-from-view-config.spec.ts b/apps/server/src/core/base/services/__tests__/strip-property-from-view-config.spec.ts new file mode 100644 index 000000000..87bf85b22 --- /dev/null +++ b/apps/server/src/core/base/services/__tests__/strip-property-from-view-config.spec.ts @@ -0,0 +1,77 @@ +import { stripPropertyFromViewConfig } from '../strip-property-from-view-config'; + +describe('stripPropertyFromViewConfig', () => { + it('returns the config unchanged when no references exist', () => { + const config = { + sorts: [{ propertyId: 'p-other', direction: 'asc' as const }], + filter: { + op: 'and' as const, + children: [{ propertyId: 'p-other', op: 'eq' as const, value: 1 }], + }, + }; + expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual(config); + }); + + it('drops sort entries pointing at the deleted property', () => { + const config = { + sorts: [ + { propertyId: 'p-deleted', direction: 'asc' as const }, + { propertyId: 'p-keep', direction: 'desc' as const }, + ], + }; + expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({ + sorts: [{ propertyId: 'p-keep', direction: 'desc' as const }], + }); + }); + + it('prunes filter clauses pointing at the deleted property', () => { + const config = { + filter: { + op: 'and' as const, + children: [ + { propertyId: 'p-deleted', op: 'eq' as const, value: 1 }, + { propertyId: 'p-keep', op: 'eq' as const, value: 2 }, + ], + }, + }; + expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({ + filter: { + op: 'and' as const, + children: [{ propertyId: 'p-keep', op: 'eq' as const, value: 2 }], + }, + }); + }); + + it('drops nested groups that become empty after pruning', () => { + const config = { + filter: { + op: 'and' as const, + children: [ + { + op: 'or' as const, + children: [{ propertyId: 'p-deleted', op: 'eq' as const, value: 1 }], + }, + { propertyId: 'p-keep', op: 'eq' as const, value: 2 }, + ], + }, + }; + expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({ + filter: { + op: 'and' as const, + children: [{ propertyId: 'p-keep', op: 'eq' as const, value: 2 }], + }, + }); + }); + + it('clears kanban fields when groupByPropertyId matches', () => { + const config = { + groupByPropertyId: 'p-deleted', + hiddenChoiceIds: ['c1'], + choiceOrder: ['c1', 'c2'], + sorts: [{ propertyId: 'p-keep', direction: 'asc' as const }], + }; + expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({ + sorts: [{ propertyId: 'p-keep', direction: 'asc' as const }], + }); + }); +}); diff --git a/apps/server/src/core/base/services/base-property.service.ts b/apps/server/src/core/base/services/base-property.service.ts index 875dcccb9..54aec0bb2 100644 --- a/apps/server/src/core/base/services/base-property.service.ts +++ b/apps/server/src/core/base/services/base-property.service.ts @@ -16,6 +16,8 @@ import { executeTx } from '@docmost/db/utils'; import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo'; import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo'; import { BaseRepo } from '@docmost/db/repos/base/base.repo'; +import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo'; +import { stripPropertyFromViewConfig } from './strip-property-from-view-config'; import { CreatePropertyDto } from '../dto/create-property.dto'; import { UpdatePropertyDto, @@ -42,6 +44,7 @@ import { BasePropertyReorderedEvent, BasePropertyUpdatedEvent, BaseSchemaBumpedEvent, + BaseViewUpdatedEvent, } from '../events/base-events'; import { processBaseTypeConversion } from '../tasks/base-type-conversion.task'; import { FormulaService } from '../formula/formula.service'; @@ -67,6 +70,7 @@ export class BasePropertyService { private readonly basePropertyRepo: BasePropertyRepo, private readonly baseRowRepo: BaseRowRepo, private readonly baseRepo: BaseRepo, + private readonly baseViewRepo: BaseViewRepo, @InjectQueue(QueueName.BASE_QUEUE) private readonly baseQueue: Queue, private readonly eventEmitter: EventEmitter2, private readonly formulaService: FormulaService, @@ -517,11 +521,55 @@ export class BasePropertyService { // Soft-delete so queries filter the property out immediately, then // enqueue cell-gc to scrub cell keys and hard-delete. If the enqueue // fails, revert the soft-delete so the property isn't orphaned. + // In the same transaction, strip the deleted property's id out of + // every view's config (sorts, filters, groupBy) so views don't render + // dangling references after the delete commits. + const updatedViewIds: string[] = []; await executeTx(this.db, async (trx) => { await this.basePropertyRepo.softDelete(dto.propertyId, trx); await this.baseRepo.bumpSchemaVersion(dto.pageId, trx); + + const views = await this.baseViewRepo.findByPageId(dto.pageId, { + workspaceId, + trx, + }); + for (const view of views) { + const before = (view.config ?? {}) as Record; + const next = stripPropertyFromViewConfig( + view.config as any, + dto.propertyId, + ); + const after = next as Record; + if ( + Object.keys(before).length === Object.keys(after).length && + Object.keys(before).every((k) => k in after) && + JSON.stringify(before) === JSON.stringify(after) + ) { + continue; + } + await this.baseViewRepo.updateView( + view.id, + { config: next as any }, + { workspaceId, trx }, + ); + updatedViewIds.push(view.id); + } }); + for (const viewId of updatedViewIds) { + const fresh = await this.baseViewRepo.findById(viewId, { workspaceId }); + if (fresh) { + const event: BaseViewUpdatedEvent = { + pageId: dto.pageId, + workspaceId, + actorId: actorId ?? null, + requestId: dto.requestId ?? null, + view: fresh, + }; + this.eventEmitter.emit(EventName.BASE_VIEW_UPDATED, event); + } + } + const payload: IBaseCellGcJob = { pageId: dto.pageId, propertyId: dto.propertyId, diff --git a/apps/server/src/core/base/services/strip-property-from-view-config.ts b/apps/server/src/core/base/services/strip-property-from-view-config.ts new file mode 100644 index 000000000..0fe304f60 --- /dev/null +++ b/apps/server/src/core/base/services/strip-property-from-view-config.ts @@ -0,0 +1,57 @@ +import { ViewConfig } from '../base.schemas'; + +type FilterCondition = { + propertyId: string; + op: string; + value?: unknown; +}; +type FilterGroup = { + op: 'and' | 'or'; + children: Array; +}; +type FilterNode = FilterCondition | FilterGroup; + +function isGroup(node: FilterNode): node is FilterGroup { + return 'children' in node; +} + +function pruneFilter( + node: FilterNode, + propertyId: string, +): FilterNode | null { + if (isGroup(node)) { + const kept = node.children + .map((c) => pruneFilter(c, propertyId)) + .filter((c): c is FilterNode => c !== null); + return kept.length === 0 ? null : { op: node.op, children: kept }; + } + return node.propertyId === propertyId ? null : node; +} + +export function stripPropertyFromViewConfig( + config: ViewConfig | undefined | null, + propertyId: string, +): ViewConfig { + if (!config) return {}; + const next: Record = { ...config }; + + if (config.sorts) { + const sorts = config.sorts.filter((s) => s.propertyId !== propertyId); + if (sorts.length > 0) next.sorts = sorts; + else delete next.sorts; + } + + if (config.filter) { + const pruned = pruneFilter(config.filter, propertyId); + if (pruned) next.filter = pruned; + else delete next.filter; + } + + if (config.groupByPropertyId === propertyId) { + delete next.groupByPropertyId; + delete next.hiddenChoiceIds; + delete next.choiceOrder; + } + + return next as ViewConfig; +}