History Implementation Guide for SciCat Backend¶
This guide provides developers with comprehensive instructions for implementing history tracking in new entities/modules within the SciCat backend.
Overview¶
The SciCat backend includes a centralized history system that tracks changes to critical data entities. Currently implemented for:
- Dataset
- Proposal
- Sample
- Instrument
- PublishedData
- Policy
- Datablock
- Attachment
Architecture¶
The history system consists of:
- Generic History Schema (
src/common/schemas/generic-history.schema.ts) - History Plugin (
src/common/mongoose/plugins/history.plugin.ts) - History Service (
src/history/history.service.ts) - History Controller (
src/history/history.controller.ts) - CASL Authorization for history access control
Authorization Model¶
The history module implements a two-layer authorization model consistent with the rest of the application:
1. Endpoint-Level Access¶
Controlled by historyEndpointAccess() in the CASL factory, this determines if a user has permission to use the history API endpoints at all. This is checked using:
@CheckPolicies("history", (ability: AppAbility) =>
ability.can(Action.HistoryReadEndpoint, "GenericHistory"),
)
2. Instance-Level Access¶
Controlled by historyInstanceAccess() in the CASL factory, this determines if a user has permission to access specific subsystem histories (Dataset, Proposal, etc.). This is checked for each specific history request:
// Example from controller
const ability = this.caslFactory.historyInstanceAccess(request.user as JWTUser);
const requiredAction = this.subsystemActionMap[subsystem];
if (!requiredAction || !ability.can(requiredAction, "GenericHistory")) {
throw new ForbiddenException(`No access to ${subsystem} history`);
}
Each subsystem has its own specific action permission (e.g., Action.HistoryReadDataset, Action.HistoryReadProposal).
Step-by-Step Implementation Guide¶
1. Module Configuration¶
To add history tracking to a new entity, modify your module's MongooseModule.forFeatureAsync configuration:
import { historyPlugin } from "../common/mongoose/plugins/history.plugin";
import {
GenericHistory,
GenericHistorySchema,
} from "../common/schemas/generic-history.schema";
import { getCurrentUsername } from "../common/utils/request-context.util";
@Module({
imports: [
CaslModule,
ConfigModule,
// Import the GenericHistory schema
MongooseModule.forFeature([
{
name: GenericHistory.name,
schema: GenericHistorySchema,
},
]),
// Configure your entity with history plugin
MongooseModule.forFeatureAsync([
{
name: YourEntity.name,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const schema = YourEntitySchema;
// existing code...
// Apply history plugin once if schema name matches TRACKABLES config
// Pass both schema and configService to applyHistoryPluginOnce
applyHistoryPluginOnce(schema, configService);
return schema;
},
},
]),
],
controllers: [YourEntityController],
providers: [YourEntityService],
exports: [YourEntityService],
})
export class YourEntityModule {}
2. Configuration Setup¶
Environment Variables¶
Add your entity to the TRACKABLES environment variable in .env:
TRACKABLES=Dataset,Proposal,Sample,Instrument,PublishedData,Policy,Datablock,Attachment,YourEntity
Configuration File¶
Add history access groups to src/config/configuration.ts:
// Add environment variable extraction
const historyYourEntityGroups = process.env.HISTORY_ACCESS_YOUR_ENTITY_GROUPS || "";
// Add to accessGroups section
accessGroups: {
// ... existing groups
//History
historyYourEntity: historyYourEntityGroups
? historyYourEntityGroups.split(",").map((v) => v.trim())
: [],
}
Environment Variable Definition¶
Add to your environment configuration:
HISTORY_ACCESS_YOUR_ENTITY_GROUPS=yourEntityHistoryGroup,anotherGroup
3. CASL Authorization Configuration¶
Update src/casl/action.enum.ts to add a new action for your entity:
export enum Action {
// ... existing actions
// History actions
HistoryReadEndpoint = "history_read_endpoint",
// ... existing history read actions
HistoryReadYourEntity = "history_read_yourentity",
}
Update both authorization functions in src/casl/casl-ability.factory.ts:
// 1. Update endpoint-level access
historyEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);
if (user) {
if (user.currentGroups && Array.isArray(user.currentGroups)) {
// ... existing code
// Users with access to your entity history also get endpoint access
if (
user.currentGroups.some((g) =>
this.accessGroups?.historyYourEntity.includes(g),
)
) {
can(Action.HistoryReadEndpoint, "GenericHistory");
}
}
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
// 2. Update instance-level access
historyInstanceAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);
if (user && user.currentGroups && Array.isArray(user.currentGroups)) {
if (
user.currentGroups.some((g) =>
this.accessGroups?.admin.includes(g),
)
) {
// Admin gets access to all subsystems
can(Action.HistoryReadYourEntity, "GenericHistory");
// ... other existing permissions
} else {
// Specific permissions for your entity
if (
user.currentGroups.some((g) =>
this.accessGroups?.historyYourEntity.includes(g),
)
) {
can(Action.HistoryReadYourEntity, "GenericHistory");
}
}
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
4. Update the History Controller¶
Update the subsystem action map in the history controller:
private readonly subsystemActionMap = {
// ... existing mappings
YourEntity: Action.HistoryReadYourEntity,
};
5. Service Implementation¶
Your service methods should properly handle metadata fields when performing updates:
import { HistoryService } from "../history/history.service";
import { getCurrentUsername } from "../common/utils/request-context.util";
@Injectable()
export class YourEntityService {
constructor(
@InjectModel(YourEntity.name)
private yourEntityModel: Model<YourEntityDocument>,
private historyService: HistoryService, // Inject if needed for custom queries
) {}
// The history plugin automatically handles tracking of create, update, delete operations
// But you need to properly set updatedBy and updatedAt in your update methods:
async update(id: string, updateDto: UpdateYourEntityDto) {
const username = getCurrentUsername();
return this.yourEntityModel.findByIdAndUpdate(
id,
{
$set: {
...updateDto,
updatedBy: username,
updatedAt: new Date(),
},
},
{ new: true, runValidators: true },
);
}
}
History Plugin Options¶
The history plugin accepts several configuration options:
interface HistoryPluginOptions {
historyModelName?: string; // Default: GenericHistory.name
modelName?: string; // Required: Must match TRACKABLES
getActiveUser?: () => string; // Function to get current user
configService?: ConfigService; // For accessing environment variables
trackableStrategy?: "delta" | "document"; // Default: "document"
trackables?: string[]; // Override TRACKABLES config
}
Tracking Strategies¶
- Document Strategy (default): Stores complete before/after document states
- Delta Strategy: Stores only changed fields with their before/after values
Configure via environment variable:
TRACKABLE_STRATEGY=delta # or "document"
History Data Structure¶
Each history record contains:
{
subsystem: string; // Entity name (e.g., "YourEntity")
documentId: string; // The _id of the changed document
before?: object; // Document state before change
after?: object; // Document state after change (null for deletes)
operation: "update" | "delete";
timestamp: Date; // Auto-generated
user?: string; // Username who made the change
}
Testing¶
Unit Tests¶
Test your history implementation:
describe("YourEntity History", () => {
it("should record history on update", async () => {
const entity = await yourEntityService.create(testData);
await yourEntityService.update(entity._id, { field: "newValue" });
const history = await historyService.find({
subsystem: "YourEntity",
documentId: entity._id,
});
expect(history).toHaveLength(1);
expect(history[0].operation).toBe("update");
expect(history[0].before.field).toBe("oldValue");
expect(history[0].after.field).toBe("newValue");
});
});
Integration Tests¶
Add tests similar to existing ones in /test/ directory:
describe("YourEntity History API", () => {
it("should get history for entity", async () => {
return request(appUrl)
.get("/api/v3/history")
.query({
filter: JSON.stringify({
subsystem: "YourEntity",
documentId: entityId,
}),
})
.set("Authorization", `Bearer ${accessToken}`)
.expect(200)
.then((res) => {
res.body.should.have.property("items");
res.body.items.should.be.an("array");
});
});
});
Best Practices¶
- Model Name Consistency: Ensure the
modelNamein the plugin matches the name inTRACKABLES - Access Control: Always implement proper CASL authorization for history access
- Testing: Include both unit and integration tests for history functionality
- Performance: Consider the volume of changes when choosing tracking strategy
- Documentation: Update API documentation to include history endpoints
Troubleshooting¶
Common Issues¶
- History not recording: Check that entity name is in
TRACKABLESenvironment variable - Permission denied: Verify CASL configuration includes your entity
- Plugin not loading: Ensure
ConfigServiceis properly injected - Missing user info: Verify
getCurrentUsername()is working correctly
Debug Logging¶
Enable debug logging to troubleshoot:
// The plugin logs when history tracking is enabled
console.log(`History tracking enabled for model: ${modelName}`);
Migration Notes¶
When adding history to existing entities:
- Existing records won't have history until first modification
- Consider backfilling critical historical data if needed
- Test thoroughly with existing data before deployment
Related Files¶
/src/common/schemas/generic-history.schema.ts- History data model/src/common/mongoose/plugins/history.plugin.ts- Core plugin implementation/src/history/- History service and controller/src/casl/casl-ability.factory.ts- Authorization configuration/test/Datablock.js- Example of history testing
This guide should provide everything needed to implement history tracking for new entities in the SciCat backend system.