Soft-Delete & Trash System โ Developer Guide
Overview
The Soft-Delete / Trash system prevents accidental, irreversible data loss across the three core catalog entities: Products, Services, and Malets. Instead of permanently removing records from the database, the system marks them as "deleted" โ hiding them from public queries while preserving the data for restoration.
| Behavior | Description |
|---|---|
| Soft-delete | Sets isDeleted: true and deletedAt timestamp. Record remains in MongoDB. |
| Restore | Flips isDeleted back to false and unsets deletedAt. |
| List exclusion | All connection/list queries automatically exclude soft-deleted items. |
| Single-item lookup | Direct lookups by ID still resolve soft-deleted records (for Murchase history, order references, etc.). |
| Cascade | Soft-deleting a Malet cascades to its Products and Services via TCP events. |
Architecture
graph TD
A["deleteOne (Product/Service)"] -->|"findOneAndUpdate"| B["MongoDB: isDeleted=true"]
A -->|"emit"| C["SEARCH_SERVICE: item_soft_deleted"]
D["deleteMalet mutation"] -->|"save"| E["MongoDB: Malet.isDeleted=true"]
D -->|"emit"| F["SEARCH_SERVICE: malet_soft_deleted"]
C -->|"EventPattern"| G["SearchController: remove from index"]
F -->|"EventPattern"| H["SearchController: remove all Malet items"]
I["restoreOne (Product/Service)"] -->|"findOneAndUpdate"| J["MongoDB: isDeleted=false"]
I -->|"emit"| K["SEARCH_SERVICE: item_upserted"]
K -->|"EventPattern"| L["SearchController: re-index"]
M["restoreMalet mutation"] -->|"emit"| N["SEARCH_SERVICE: malet_restored"]
N -->|"EventPattern"| O["SearchController: re-index all Malet items"]
style B fill:#dc2626,color:#fff
style E fill:#dc2626,color:#fff
style G fill:#dc2626,color:#fff
style H fill:#dc2626,color:#fff
style J fill:#22c55e,color:#fff
style L fill:#22c55e,color:#fff
style O fill:#22c55e,color:#fff
Design Decisions
| Decision | Rationale |
|---|---|
isDeleted field |
Boolean flag is simple to query and index. No complex state machines needed. |
deletedAt timestamp |
Enables future "auto-purge after 30 days" policies and audit visibility. |
| Individual lookups still work | A Visitor's Murchase history may reference a product that was later trashed. Hard-deleting would break those references. |
| Cascade via TCP events | Malets, Products, and Services are separate subgraphs โ event-driven cascade avoids cross-service database coupling. |
| No hard purge yet | Only soft-delete is implemented. Permanent deletion can be added as an admin operation in the future. |
Schema
Item Entity (Products & Services)
Both Products and Services inherit from the shared Item base class in libs/common. The soft-delete fields are defined there:
// libs/common/src/entities/item.entity.ts
@ObjectType()
export class Item {
// ... existing fields (name, description, basePrice, etc.)
@Field(() => Boolean, { defaultValue: false })
@prop({ default: false })
isDeleted: boolean;
@Field(() => Date, { nullable: true })
@prop({ type: Date, default: null })
deletedAt?: Date;
}
Malet Entity
Since Malet does not inherit from Item, the fields are added directly:
// apps/malets/src/malet/malet.entity.ts
@Field(() => Boolean, { defaultValue: false })
@prop({ default: false })
isDeleted: boolean;
@Field(() => Date, { nullable: true })
@prop({ type: Date, default: null })
deletedAt?: Date;
GraphQL API
Products
# Standard delete (now performs soft-delete under the hood)
mutation {
deleteOneProduct(input: { id: "product-123" }) {
id
isDeleted
deletedAt
}
}
# Restore from trash
mutation {
restoreProduct(id: "product-123") {
id
name
isDeleted
}
}
Services
# Standard delete (soft-delete)
mutation {
deleteOneService(input: { id: "service-456" }) {
id
isDeleted
deletedAt
}
}
# Restore from trash
mutation {
restoreService(id: "service-456") {
id
name
isDeleted
}
}
Malets
# Soft-delete a Malet (cascades to its Products and Services)
mutation {
deleteMalet(id: "malet-789") {
id
name
isDeleted
deletedAt
}
}
# Restore a Malet (cascades restore to its Products and Services)
mutation {
restoreMalet(id: "malet-789") {
id
name
isDeleted
}
}
Important:
deleteMaletandrestoreMaletare owner-only operations. The mutations verify that the authenticated@CurrentActor()matches the Malet'sownerIdbefore proceeding.
List Query Exclusion
Products & Services (NestjsQuery)
Both ProductModule and ServiceModule configure a defaultFilter on their NestjsQuery resolver definition:
// apps/products/src/product/product.module.ts
NestjsQueryGraphQLModule.forFeature({
resolvers: [
{
DTOClass: Product,
EntityClass: Product,
Service: ProductSyncService,
read: {
one: { disabled: true },
defaultFilter: { isDeleted: { isNot: true } } // โ excludes trashed
},
referenceBy: { key: 'id' }
}
]
});
This means the auto-generated products connection query will never return soft-deleted items without any caller-side filtering. The manual product(id) single-item resolver is unaffected โ it returns soft-deleted items for historical reference.
Malets (Manual Queries)
Since Malet list queries are manually implemented, the isDeleted filter is applied directly:
// getMalets โ paginated connection
const mongoFilter: Record<string, any> = { isDeleted: { $ne: true } };
// myOwnedMalets โ personal context
this.maletModel.find({
ownerId: actor.id,
ownerType: OwnerType.USER,
isDeleted: { $ne: true }
});
// myOwnedMalets โ organization context
this.maletModel.find({
ownerId: orgId,
ownerType: OwnerType.ORGANIZATION,
isDeleted: { $ne: true }
});
Service Layer
Soft-Delete Override
Both ProductSyncService and ServiceSyncService override the NestjsQuery deleteOne method:
// apps/products/src/product/product.service.ts
async deleteOne(id: string): Promise<DocumentType<Product>> {
const product = await this.model
.findOneAndUpdate(
{ id },
{ $set: { isDeleted: true, deletedAt: new Date() } },
{ new: true },
)
.exec();
if (product) {
this.searchClient.emit('item_soft_deleted', {
id: product.id,
type: 'PRODUCT',
});
}
return product;
}
Key behavior:
- Uses
findOneAndUpdatefor an atomic soft-delete (no race conditions). - Emits
item_soft_deletedto the Search subgraph to remove the item from the Meilisearch index. - Returns the updated document with
isDeleted: true.
Restore
async restoreOne(id: string): Promise<DocumentType<Product>> {
const product = await this.model
.findOneAndUpdate(
{ id, isDeleted: true },
{ $set: { isDeleted: false }, $unset: { deletedAt: 1 } },
{ new: true },
)
.exec();
if (!product) {
throw new Error(`Product ${id} not found or not in trash`);
}
// Re-index in Meilisearch
this.searchClient.emit('item_upserted', {
id: product.id,
type: 'PRODUCT',
});
return product;
}
Cascade Operations
When a Malet is soft-deleted, all its Products and Services should also be soft-deleted. Both sync services expose bulk cascade methods:
// Cascade soft-delete all products for a Malet
async softDeleteByMaletId(maletId: string): Promise<number> {
const result = await this.model
.updateMany(
{ maletId, isDeleted: { $ne: true } },
{ $set: { isDeleted: true, deletedAt: new Date() } },
)
.exec();
return result.modifiedCount;
}
// Cascade restore all products for a Malet
async restoreByMaletId(maletId: string): Promise<number> {
const result = await this.model
.updateMany(
{ maletId, isDeleted: true },
{ $set: { isDeleted: false }, $unset: { deletedAt: 1 } },
)
.exec();
return result.modifiedCount;
}
Search Index Synchronization
The Search subgraph's SearchController handles three soft-delete-related TCP events to keep the Meilisearch index consistent:
| Event | Source | Handler | Action |
|---|---|---|---|
item_soft_deleted |
ProductSyncService / ServiceSyncService |
handleItemSoftDeleted |
Removes single item from Meilisearch |
malet_soft_deleted |
MaletCRUDResolver |
handleMaletSoftDeleted |
Removes all items belonging to the Malet from Meilisearch |
malet_restored |
MaletCRUDResolver |
handleMaletRestored |
Re-indexes all active (non-deleted) items belonging to the Malet |
sequenceDiagram
participant Malet as "MaletCRUDResolver"
participant TCP as "SEARCH_SERVICE (TCP)"
participant Search as "SearchController"
participant Meili as "Meilisearch"
participant DB as "MongoDB (Stubs)"
Note over Malet: deleteMalet(id)
Malet->>TCP: emit("malet_soft_deleted", { maletId })
TCP->>Search: @EventPattern("malet_soft_deleted")
Search->>Meili: search(filter: maletId)
loop Each hit
Search->>Meili: deleteItem(id)
end
Note over Malet: restoreMalet(id)
Malet->>TCP: emit("malet_restored", { maletId })
TCP->>Search: @EventPattern("malet_restored")
Search->>DB: find Products/Services(maletId, isDeleted: false)
loop Each active item
Search->>Meili: indexItem(enriched)
end
Affected File Map
libs/common/src/entities/
โโโ item.entity.ts # +isDeleted, +deletedAt fields
apps/products/src/product/
โโโ product.module.ts # defaultFilter: { isDeleted: { isNot: true } }
โโโ product.resolver.ts # +restoreProduct mutation
โโโ product.service.ts # deleteOne override, restoreOne, cascade methods
โโโ product.service.spec.ts # Updated + new tests
apps/services/src/service/
โโโ service.module.ts # defaultFilter: { isDeleted: { isNot: true } }
โโโ service-query.resolver.ts # +restoreService mutation
โโโ service.service.ts # deleteOne override, restoreOne, cascade methods
โโโ service.service.spec.ts # Updated + new tests
apps/malets/src/malet/
โโโ malet.entity.ts # +isDeleted, +deletedAt fields
โโโ malet-crud.resolver.ts # +deleteMalet, +restoreMalet, list filters
โโโ malet-crud.resolver.spec.ts # Updated + new tests
apps/search/src/
โโโ search.controller.ts # +item_soft_deleted, +malet_soft_deleted, +malet_restored
Testing
Unit Tests
# Products (57 tests)
npm run test -- apps/products
# Services (50 tests)
npm run test -- apps/services
# Malets (97 tests)
npm run test -- apps/malets
# All affected suites (309 tests across 34 suites)
npm run test -- apps/products apps/services apps/malets apps/search
Key Test Coverage
| Area | Tests |
|---|---|
ProductSyncService.deleteOne (soft-delete) |
Verifies findOneAndUpdate call, item_soft_deleted event emission, null handling |
ProductSyncService.restoreOne |
Verifies restore query, item_upserted re-index event, not-found error |
ProductSyncService.softDeleteByMaletId |
Verifies bulk updateMany with correct filter |
ProductSyncService.restoreByMaletId |
Verifies bulk restore with correct filter |
ProductResolver.restoreProduct |
Auth check, delegation to service |
MaletCRUDResolver.deleteMalet |
Owner check, isDeleted flag set, cascade event emission |
MaletCRUDResolver.restoreMalet |
Owner check, flag unset, cascade event emission |
MaletCRUDResolver.getMalets |
Filter includes isDeleted: { $ne: true } |
MaletCRUDResolver.myOwnedMalets |
Both personal and org context filter soft-deleted |
Frontend Implementation
The Admin Dashboard now includes a dedicated Trash tab with three collapsible sections (Malets, Products, Services) for browsing and restoring soft-deleted items.
Components
| Component | Purpose |
|---|---|
src/lib/components/admin/TrashBin.svelte |
Reusable trash bin with collapsible card grid, restore/delete actions |
src/lib/queries/adminTrash.ts |
GraphQL queries (GET_TRASHED_*), mutations (RESTORE_*), and types |
src/lib/utils/adminUtils.ts โ formatDeletedAt() |
Relative time formatting for deletedAt timestamps |
GraphQL Operations
# Fetch trashed Malets (new dedicated query)
query { trashedMalets { id name maletHandle isDeleted deletedAt } }
# Fetch trashed Products/Services (filter override)
query { products(filter: { isDeleted: { is: true } }) { edges { node { ... } } } }
# Restore mutations
mutation { restoreProduct(id: "...") { id name isDeleted } }
mutation { restoreService(id: "...") { id name isDeleted } }
mutation { restoreMalet(id: "...") { id name isDeleted } }
Backend Addition
A trashedMalets query was added to malet-crud.resolver.ts to support listing soft-deleted Malets. This was necessary because the main malets query hardcodes isDeleted: { $ne: true } in the resolver, making filter-based override impossible.
Design Decisions
| Decision | Rationale |
|---|---|
| Permanent delete disabled | No hard-delete backend exists yet. Button shown disabled with "coming soon" tooltip. |
| Lazy-load on tab switch | Trash data is fetched only when the user switches to the Trash tab. |
Promise.allSettled loading |
Individual entity queries can fail without blocking the others. |
| Card grid layout | Better visual density than table rows for items with metadata + actions. |
Future Enhancements
| Enhancement | Description |
|---|---|
| Auto-purge cron | Permanently delete items where deletedAt is older than 30 days |
โ
Implemented โ trashedMalets query + filter override for Products/Services |
|
| Bulk restore | Allow Malet Owners to restore multiple items at once |
| Hard delete admin | Platform admin mutation to permanently remove a trashed record |
| Undo toast | Frontend "Undo" notification after soft-delete with auto-restore timer |
Related
- Edit History Audit Trail โ Field-level change tracking for Products, Services, Malets, and more
- Search Engine Administration โ Index reconciliation ensures soft-deleted items are removed from search results
- Organizations & Permissions โ Permission control for who can delete/restore catalog items