This guide explains how to add activity history tracking to any entity in the VerifyWise system.
The change history system is a generic, reusable framework that tracks all changes to entities (Model Inventory, Vendors, Use Cases, etc.). It consists of:
- Backend: Generic utilities for recording and retrieving change history
- Frontend: Reusable
HistorySidebarcomponent and hooks - Configuration: Entity-specific settings for tracking and display
Backend:
├── /Servers/config/changeHistory.config.ts # Entity configurations
├── /Servers/utils/changeHistory.base.utils.ts # Generic utilities
├── /Servers/utils/{entity}ChangeHistory.utils.ts # Entity-specific wrappers
└── /Servers/routes/{entity}ChangeHistory.route.ts # API routes
Frontend:
├── /Clients/src/config/changeHistory.config.ts # UI configurations
├── /Clients/src/application/hooks/useEntityChangeHistory.ts # Generic hook
├── /Clients/src/application/repository/changeHistory.repository.ts
└── /Clients/src/presentation/components/Common/HistorySidebar/ # Reusable UI
Follow these steps to add change history tracking to a new entity (e.g., "vendor"):
Create a migration file: /Servers/migrations/YYYYMMDDHHMMSS-create-{entity}-change-history.ts
import { QueryInterface, DataTypes } from "sequelize";
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS "{{TENANT_SCHEMA}}".vendor_change_history (
id SERIAL PRIMARY KEY,
vendor_id INTEGER NOT NULL REFERENCES "{{TENANT_SCHEMA}}".vendors(id) ON DELETE CASCADE,
action VARCHAR(10) NOT NULL CHECK (action IN ('created', 'updated', 'deleted')),
field_name VARCHAR(255),
old_value TEXT,
new_value TEXT,
changed_by_user_id INTEGER REFERENCES public.users(id) ON DELETE SET NULL,
changed_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vendor_change_history_vendor_id
ON "{{TENANT_SCHEMA}}".vendor_change_history(vendor_id);
CREATE INDEX idx_vendor_change_history_changed_at
ON "{{TENANT_SCHEMA}}".vendor_change_history(changed_at DESC);
`);
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.query(
'DROP TABLE IF EXISTS "{{TENANT_SCHEMA}}".vendor_change_history CASCADE;'
);
},
};Key Points:
- Replace
vendorwith your entity name - Foreign key should reference your entity table
- Always include indexes on foreign key and
changed_atfor performance - Important:
changed_by_user_idusesON DELETE SET NULLto preserve history when users are deleted- Allows users to be removed without losing change history
- UI displays "Deleted User" for NULL user IDs
- History remains intact for audit purposes
Edit /Servers/config/changeHistory.config.ts:
export type EntityType =
| "model_inventory"
| "vendor" // Add your entity here
| "use_case"
// ... other entities
export const ENTITY_CONFIGS: { [key in EntityType]: EntityConfig } = {
// ... existing configs
vendor: {
tableName: "vendor_change_history",
foreignKeyField: "vendor_id",
fieldsToTrack: [
"name",
"website",
"poc_name",
"poc_email",
"reviewer",
"review_date",
// Add all fields you want to track
],
fieldLabels: {
name: "Name",
website: "Website",
poc_name: "POC name",
poc_email: "POC email",
reviewer: "Reviewer",
review_date: "Review date",
// Map field names to display labels (sentence case)
},
fieldFormatters: {
// Optional: Add custom formatters for specific fields
review_date: GENERIC_FORMATTERS.date,
reviewer: GENERIC_FORMATTERS.user,
// Use GENERIC_FORMATTERS.boolean, .array, .user, .date, or .text
},
},
};Field Formatters:
GENERIC_FORMATTERS.boolean: For true/false fields (displays "Yes"/"No")GENERIC_FORMATTERS.date: For date fields (formats consistently)GENERIC_FORMATTERS.user: For user ID fields (looks up and displays user name)GENERIC_FORMATTERS.array: For array fields (sorts and joins)GENERIC_FORMATTERS.text: Default text formatter
Create /Servers/utils/vendorChangeHistory.utils.ts:
import { VendorModel } from "../domain.layer/models/vendor/vendor.model";
import { Transaction } from "sequelize";
import {
recordEntityChange,
recordMultipleFieldChanges as recordMultipleFieldChangesGeneric,
getEntityChangeHistory,
trackEntityChanges,
recordEntityCreation,
recordEntityDeletion,
} from "./changeHistory.base.utils";
export const recordVendorChange = async (
vendorId: number,
action: "created" | "updated" | "deleted",
changedByUserId: number,
tenant: string,
fieldName?: string,
oldValue?: string,
newValue?: string,
transaction?: Transaction
): Promise<void> => {
return recordEntityChange(
"vendor",
vendorId,
action,
changedByUserId,
tenant,
fieldName,
oldValue,
newValue,
transaction
);
};
export const getVendorChangeHistory = async (
vendorId: number,
tenant: string
): Promise<any[]> => {
return getEntityChangeHistory("vendor", vendorId, tenant);
};
export const trackVendorChanges = async (
oldVendor: VendorModel,
newVendor: Partial<VendorModel>
): Promise<Array<{ fieldName: string; oldValue: string; newValue: string }>> => {
return trackEntityChanges("vendor", oldVendor, newVendor);
};
export const recordVendorCreation = async (
vendorId: number,
changedByUserId: number,
tenant: string,
vendorData: Partial<VendorModel>,
transaction?: Transaction
): Promise<void> => {
return recordEntityCreation(
"vendor",
vendorId,
changedByUserId,
tenant,
vendorData,
transaction
);
};
export const recordVendorDeletion = async (
vendorId: number,
changedByUserId: number,
tenant: string,
transaction?: Transaction
): Promise<void> => {
return recordEntityDeletion(
"vendor",
vendorId,
changedByUserId,
tenant,
transaction
);
};Note: This step is optional but recommended for better code organization and type safety.
Create /Servers/routes/vendorChangeHistory.route.ts:
import { Router } from "express";
import { getVendorChangeHistory } from "../utils/vendorChangeHistory.utils";
const router = Router();
router.get("/:vendorId", async (req, res) => {
try {
const vendorId = parseInt(req.params.vendorId);
const tenant = req.tenant; // From auth middleware
const history = await getVendorChangeHistory(vendorId, tenant);
res.json({
message: "OK",
data: history,
});
} catch (error) {
console.error("Error fetching vendor change history:", error);
res.status(500).json({
message: "Error fetching change history",
error: error.message,
});
}
});
export default router;Edit /Servers/routes/index.ts (or your main router file):
import vendorChangeHistoryRoutes from "./vendorChangeHistory.route";
// ... other imports
app.use("/api/vendor-change-history", vendorChangeHistoryRoutes);In your vendor controller (e.g., /Servers/controllers/vendor.ctrl.ts):
On Create:
import { recordVendorCreation } from "../utils/vendorChangeHistory.utils";
export const createVendor = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const vendor = await VendorModel.create(req.body, { transaction });
// Record creation in history
await recordVendorCreation(
vendor.id,
req.userId, // From auth middleware
req.tenant,
req.body,
transaction
);
await transaction.commit();
res.json({ message: "OK", data: vendor });
} catch (error) {
await transaction.rollback();
res.status(500).json({ message: "Error creating vendor" });
}
};On Update:
import { trackVendorChanges, recordMultipleFieldChanges } from "../utils/vendorChangeHistory.utils";
export const updateVendor = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const vendor = await VendorModel.findByPk(req.params.id);
// Track what changed
const changes = await trackVendorChanges(vendor, req.body);
// Update vendor
await vendor.update(req.body, { transaction });
// Record changes in history
if (changes.length > 0) {
await recordMultipleFieldChanges(
vendor.id,
req.userId,
req.tenant,
changes,
transaction
);
}
await transaction.commit();
res.json({ message: "OK", data: vendor });
} catch (error) {
await transaction.rollback();
res.status(500).json({ message: "Error updating vendor" });
}
};On Delete:
import { recordVendorDeletion } from "../utils/vendorChangeHistory.utils";
export const deleteVendor = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const vendor = await VendorModel.findByPk(req.params.id);
// Record deletion in history
await recordVendorDeletion(
vendor.id,
req.userId,
req.tenant,
transaction
);
await vendor.destroy({ transaction });
await transaction.commit();
res.json({ message: "OK" });
} catch (error) {
await transaction.rollback();
res.status(500).json({ message: "Error deleting vendor" });
}
};Edit /Clients/src/config/changeHistory.config.ts:
export type EntityType =
| "model_inventory"
| "vendor" // Add your entity here
// ... other entities
export const ENTITY_HISTORY_CONFIGS: {
[key in EntityType]: EntityHistoryConfig;
} = {
// ... existing configs
vendor: {
entityName: "Vendor",
emptyStateTitle: "Activity history",
emptyStateMessage:
"Automatically tracks every change to this vendor. See what your team is working on and what updates they've made, in real time.",
},
};In your vendor modal component (e.g., /Clients/src/presentation/components/Modals/NewVendor/index.tsx):
import HistorySidebar from "../../Common/HistorySidebar";
import { useEntityChangeHistory } from "../../../../application/hooks/useEntityChangeHistory";
import { useQueryClient } from "@tanstack/react-query";
import { History as HistoryIcon } from "lucide-react";
// Inside your component:
const queryClient = useQueryClient();
const [isHistorySidebarOpen, setIsHistorySidebarOpen] = useState(false);
// Prefetch history data when modal opens (optional but recommended)
useEntityChangeHistory(
isOpen && isEdit ? "vendor" : undefined,
isOpen && isEdit ? selectedVendorId : undefined
);
// IMPORTANT: Invalidate cache when modal closes to ensure fresh data on reopen
const handleClose = () => {
setIsOpen(false);
// Invalidate change history cache when modal closes
queryClient.invalidateQueries({
queryKey: ["changeHistory", "vendor", selectedVendorId]
});
};
// Add history button to modal header
<IconButton
onClick={() => setIsHistorySidebarOpen(!isHistorySidebarOpen)}
sx={{
color: isHistorySidebarOpen
? theme.palette.primary.main
: theme.palette.text.secondary,
}}
>
<HistoryIcon size={20} />
</IconButton>
// Add HistorySidebar to modal content
<Stack direction="row" spacing={2}>
<Box flex={1}>
{/* Your modal content */}
</Box>
{isEdit && (
<HistorySidebar
isOpen={isHistorySidebarOpen}
entityType="vendor"
entityId={selectedVendorId}
/>
)}
</Stack>Why cache invalidation is important:
- React Query caches history data for 30 seconds
- Without invalidation, reopening the modal shows stale cached data
- Invalidating on close ensures fresh data is fetched when the modal reopens
- This shows updates made in the previous session
After implementation, test the following:
- Create Entity: Verify history records are created with initial field values
- Update Entity: Verify only changed fields are tracked
- Delete Entity: Verify deletion is recorded
- Frontend Display:
- History sidebar opens/closes correctly
- History entries display properly grouped
- User avatars load correctly
- Timestamps display in relative format
- Field changes show old → new values
- Empty state displays when no history exists
- Fade overlay appears when content overflows
The change history system is multi-tenant aware:
- Each tenant has their own
{entity}_change_historytable in their schema - User data is stored in the global
public.userstable - API routes use
req.tenantto query the correct tenant schema
The change history system gracefully handles deleted users:
changed_by_user_idusesON DELETE SET NULLconstraint- When a user is deleted, their ID in all history records becomes
NULL - History entries are preserved for audit purposes
- History entries: Shows "Deleted User" as the username
- Creation header: Shows "Created by a deleted user"
- Avatar: Shows default avatar with "D" initial
For existing model_inventory_change_history table, run:
cd Servers
npm run migrate-dbThis applies the migration that:
- Removes NOT NULL constraint from
changed_by_user_id - Drops existing foreign key
- Adds new foreign key with
ON DELETE SET NULL
✅ Preserves complete audit trail ✅ Allows user cleanup/GDPR compliance ✅ No data loss when users are removed ✅ Clear indication in UI when user no longer exists
When an entity (e.g., a model, vendor) is deleted:
- Behavior:
ON DELETE CASCADEon the entity foreign key - Result: ALL change history for that entity is deleted
- Reasoning: History without the entity is meaningless
If you need permanent audit trail:
- Implement soft deletes on your entities (add
deleted_atfield) - Don't hard delete entities, just mark them as deleted
- History will be preserved
When a user is deleted:
- Behavior:
ON DELETE SET NULLonchanged_by_user_id - Result: User ID becomes
NULL, history preserved - UI Display: Shows "Deleted User"
- Field removed from tracking: Old history entries remain, just no new entries created
- Field renamed: Old and new field names both appear in history (shows schema evolution)
- Field values: Stored as formatted text snapshots, not foreign keys
- Example: "John Doe" stored, not user ID 123
- Preserved even if referenced data is deleted
- Indexes: Always create indexes on
{entity}_idandchanged_atcolumns - Batch Operations: Use transactions when recording multiple changes
- Caching: The frontend hook caches data for 30 seconds
- Prefetching: Prefetch history when modal opens to avoid loading spinners
See the Model Inventory implementation as a reference:
- Backend:
/Servers/utils/modelInventoryChangeHistory.utils.ts - Frontend:
/Clients/src/presentation/components/Modals/NewModelInventory/index.tsx - Route:
/Servers/routes/modelInventoryChangeHistory.route.ts
Issue: History not showing up
- Check API route is registered correctly
- Verify tenant schema exists
- Check browser network tab for API errors
Issue: Field names not formatted
- Check
fieldLabelsin entity configuration - Field labels should use sentence case (e.g., "Security assessment")
Issue: User names showing as "User #123"
- Verify user exists in
public.userstable - Check
GENERIC_FORMATTERS.useris used for user ID fields
Issue: Fade overlay always visible
- Check if content actually overflows the container
- Verify
scrollContainerRefis attached to scroll container - Check
showFadestate is being updated correctly
Issue: Updated fields don't show in history after closing/reopening modal
- You forgot to invalidate the cache in
handleClose - Add
queryClient.invalidateQueries({ queryKey: ["changeHistory", entityType, entityId] }) - See Step 8 for the complete pattern
The change history system includes several built-in features to handle edge cases:
Problem: Loading thousands of history entries at once causes performance issues.
Solution:
- Backend returns first 100 entries by default
- Frontend uses
useInfiniteQueryfor automatic pagination - "Load more" button appears when more entries exist
- Additional entries load on demand
Implementation:
// Backend automatically supports pagination
const result = await getEntityChangeHistory(
entityType,
entityId,
tenant,
limit, // default: 100
offset // default: 0
);
// Returns: { data: [...], hasMore: boolean, total: number }
// Frontend automatically shows "Load more" button
// Users can click to load next 100 entriesProblem: Very long field values (descriptions, JSON data) break UI layout.
Solution:
- Values longer than 200 characters are automatically truncated
- Shows "..." and a "Show more" link
- Click to expand/collapse individual values
- Preserves formatting (colors, strikethrough) in both states
Example:
Original: "This is a very long description that goes on and on..."
Truncated: "This is a very long description that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on..."
Display: "This is a very long description that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on... [Show more]"
Problem: Arrays that represent ordered lists (priorities, sequences) were sorted alphabetically, making reordering undetectable.
Solution:
- Array formatter does not sort values
- Preserves original order exactly as stored
- Detects reordering as a change
- Critical for priority lists, task ordering, etc.
Example:
Old: ["High", "Medium", "Low"]
New: ["Low", "High", "Medium"]
Result: Detected as change (was being ignored before fix)
Problem: If a field formatter throws an error, the entire history crashes.
Solution:
- All formatters wrapped in try-catch
- Falls back to
String(value)if formatter fails - Logs error to console for debugging
- UI degrades gracefully, never crashes
Code:
try {
return await formatter(value);
} catch (error) {
console.error(`Error formatting field "${fieldName}":`, error);
return String(value); // Safe fallback
}Problem: When an entity is deleted while the modal is open, the API returns an error.
Solution:
- Error state displays user-friendly message
- Shows "Unable to load history" with explanation
- Red clock icon indicates error condition
- Message: "This {entity} may have been deleted, or there was an error loading the activity history."
Potential improvements to the system:
- Bulk Change Summary: Group multiple related changes into a single event
- Revert Changes: Add ability to revert to previous values
- Search/Filter: Add search within history entries
- Export History: Export history as CSV or PDF
- Real-time Updates: Use WebSockets to show live changes from other users
- Field-Level Permissions: Hide certain field changes based on user role