Developer Docs

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: deleteMalet and restoreMalet are owner-only operations. The mutations verify that the authenticated @CurrentActor() matches the Malet's ownerId before 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 findOneAndUpdate for an atomic soft-delete (no race conditions).
  • Emits item_soft_deleted to 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
Trash listing โœ… 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