feat(#37-41): Add domains, ideas, relationships, agents, widgets schema

Schema additions for issues #37-41:

New models:
- Domain (#37): Life domains (work, marriage, homelab, etc.)
- Idea (#38): Brain dumps with pgvector embeddings
- Relationship (#39): Generic entity linking (blocks, depends_on)
- Agent (#40): ClawdBot agent tracking with metrics
- AgentSession (#40): Conversation session tracking
- WidgetDefinition (#41): HUD widget registry
- UserLayout (#41): Per-user dashboard configuration

Updated models:
- Task, Event, Project: Added domainId foreign key
- User, Workspace: Added new relations

New enums:
- IdeaStatus: CAPTURED, PROCESSING, ACTIONABLE, ARCHIVED, DISCARDED
- RelationshipType: BLOCKS, BLOCKED_BY, DEPENDS_ON, etc.
- AgentStatus: IDLE, WORKING, WAITING, ERROR, TERMINATED
- EntityType: Added IDEA, DOMAIN

Migration: 20260129182803_add_domains_ideas_agents_widgets
This commit is contained in:
Jason Woltje
2026-01-29 12:29:21 -06:00
parent a220c2dc0a
commit 973502f26e
308 changed files with 18374 additions and 113 deletions

View File

@@ -0,0 +1,772 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ActivityLoggingInterceptor } from "./activity-logging.interceptor";
import { ActivityService } from "../activity.service";
import { ExecutionContext, CallHandler } from "@nestjs/common";
import { of } from "rxjs";
import { ActivityAction, EntityType } from "@prisma/client";
describe("ActivityLoggingInterceptor", () => {
let interceptor: ActivityLoggingInterceptor;
let activityService: ActivityService;
const mockActivityService = {
logActivity: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ActivityLoggingInterceptor,
{
provide: ActivityService,
useValue: mockActivityService,
},
],
}).compile();
interceptor = module.get<ActivityLoggingInterceptor>(
ActivityLoggingInterceptor
);
activityService = module.get<ActivityService>(ActivityService);
vi.clearAllMocks();
});
const createMockExecutionContext = (
method: string,
params: any = {},
body: any = {},
user: any = null,
ip = "127.0.0.1",
userAgent = "test-agent"
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => ({
method,
params,
body,
user,
ip,
headers: {
"user-agent": userAgent,
},
}),
}),
getClass: () => ({ name: "TestController" }),
getHandler: () => ({ name: "testMethod" }),
} as any;
};
const createMockCallHandler = (result: any = {}): CallHandler => {
return {
handle: () => of(result),
} as any;
};
describe("intercept", () => {
it("should not log if user is not authenticated", async () => {
const context = createMockExecutionContext("POST", {}, {}, null);
const next = createMockCallHandler();
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should log POST request as CREATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
workspaceId: "workspace-123",
title: "New Task",
};
const context = createMockExecutionContext(
"POST",
{},
body,
user,
"127.0.0.1",
"Mozilla/5.0"
);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: expect.any(String),
entityId: "task-123",
details: expect.objectContaining({
method: "POST",
controller: "TestController",
handler: "testMethod",
}),
ipAddress: "127.0.0.1",
userAgent: "Mozilla/5.0",
});
resolve();
});
});
});
it("should log PATCH request as UPDATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "task-456",
};
const body = {
status: "IN_PROGRESS",
};
const result = {
id: "task-456",
workspaceId: "workspace-123",
status: "IN_PROGRESS",
};
const context = createMockExecutionContext("PATCH", params, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-124",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.UPDATED,
entityType: expect.any(String),
entityId: "task-456",
details: expect.objectContaining({
method: "PATCH",
changes: body,
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should log PUT request as UPDATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "event-789",
};
const body = {
title: "Updated Event",
};
const result = {
id: "event-789",
workspaceId: "workspace-123",
title: "Updated Event",
};
const context = createMockExecutionContext("PUT", params, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-125",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.UPDATED,
entityType: expect.any(String),
entityId: "event-789",
details: expect.objectContaining({
method: "PUT",
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should log DELETE request as DELETE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "project-999",
};
const result = {
id: "project-999",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("DELETE", params, {}, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-126",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.DELETED,
entityType: expect.any(String),
entityId: "project-999",
details: expect.objectContaining({
method: "DELETE",
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should not log GET requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("GET", {}, {}, user);
const next = createMockCallHandler({ data: [] });
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should extract entity ID from result if not in params", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
id: "task-new-123",
workspaceId: "workspace-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-127",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
entityId: "task-new-123",
})
);
resolve();
});
});
});
it("should handle errors gracefully", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, {}, user);
const next = createMockCallHandler({ id: "test-123" });
mockActivityService.logActivity.mockRejectedValue(
new Error("Logging failed")
);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not throw error, just log it
resolve();
});
});
});
});
describe("edge cases", () => {
it("should handle POST request with no id field in response", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
workspaceId: "workspace-123",
title: "New Task",
// No 'id' field in response
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when entityId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle user object missing workspaceId", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle body missing workspaceId when user also missing workspaceId", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
// No workspaceId
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should extract workspaceId from body when not in user object", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
workspaceId: "workspace-from-body",
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "workspace-from-body",
})
);
resolve();
});
});
});
it("should handle null result from handler", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("DELETE", { id: "task-123" }, {}, user);
const next = createMockCallHandler(null);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should still log activity with entityId from params
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
entityId: "task-123",
workspaceId: "workspace-123",
})
);
resolve();
});
});
});
it("should handle undefined result from handler", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, { title: "New Task" }, user);
const next = createMockCallHandler(undefined);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log when entityId cannot be determined
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should log warning when entityId is missing", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
workspaceId: "workspace-123",
title: "New Task",
// No 'id' field
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
resolve();
});
});
consoleSpy.mockRestore();
});
it("should log warning when workspaceId is missing", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
resolve();
});
});
consoleSpy.mockRestore();
});
it("should handle activity service throwing an error", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, {}, user);
const next = createMockCallHandler({ id: "test-123" });
const activityError = new Error("Activity logging failed");
mockActivityService.logActivity.mockRejectedValue(activityError);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not throw error, just log it
resolve();
});
});
});
it("should handle OPTIONS requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("OPTIONS", {}, {}, user);
const next = createMockCallHandler({});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log OPTIONS requests
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle HEAD requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("HEAD", {}, {}, user);
const next = createMockCallHandler({});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log HEAD requests
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
});
describe("sensitive data sanitization", () => {
it("should redact password field", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
username: "testuser",
password: "secret123",
email: "test@example.com",
};
const result = {
id: "user-456",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.password).toBe("[REDACTED]");
expect(logCall.details.data.username).toBe("testuser");
expect(logCall.details.data.email).toBe("test@example.com");
resolve();
});
});
});
it("should redact token field", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Integration",
apiToken: "sk_test_1234567890",
};
const result = {
id: "integration-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-124",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.apiToken).toBe("[REDACTED]");
expect(logCall.details.data.title).toBe("Integration");
resolve();
});
});
});
it("should redact sensitive fields in nested objects", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Config",
settings: {
apiKey: "secret_key",
public: "visible_data",
auth: {
token: "auth_token_123",
refreshToken: "refresh_token_456",
},
},
};
const result = {
id: "config-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-128",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.title).toBe("Config");
expect(logCall.details.data.settings.apiKey).toBe("[REDACTED]");
expect(logCall.details.data.settings.public).toBe("visible_data");
expect(logCall.details.data.settings.auth.token).toBe("[REDACTED]");
expect(logCall.details.data.settings.auth.refreshToken).toBe(
"[REDACTED]"
);
resolve();
});
});
});
it("should not modify non-sensitive fields", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Safe Data",
description: "This is public",
count: 42,
active: true,
};
const result = {
id: "item-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-130",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data).toEqual(body);
resolve();
});
});
});
});
});

View File

@@ -0,0 +1,195 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
import { ActivityService } from "../activity.service";
import { ActivityAction, EntityType } from "@prisma/client";
/**
* Interceptor for automatic activity logging
* Logs CREATE, UPDATE, DELETE actions based on HTTP methods
*/
@Injectable()
export class ActivityLoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(ActivityLoggingInterceptor.name);
constructor(private readonly activityService: ActivityService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, params, body, user, ip, headers } = request;
// Only log for authenticated requests
if (!user) {
return next.handle();
}
// Skip GET requests (read-only)
if (method === "GET") {
return next.handle();
}
return next.handle().pipe(
tap(async (result) => {
try {
const action = this.mapMethodToAction(method);
if (!action) {
return;
}
// Extract entity information
const entityId = params.id || result?.id;
const workspaceId = user.workspaceId || body.workspaceId;
if (!entityId || !workspaceId) {
this.logger.warn(
"Cannot log activity: missing entityId or workspaceId"
);
return;
}
// Determine entity type from controller/handler
const controllerName = context.getClass().name;
const handlerName = context.getHandler().name;
const entityType = this.inferEntityType(controllerName, handlerName);
// Build activity details with sanitized body
const sanitizedBody = this.sanitizeSensitiveData(body);
const details: Record<string, any> = {
method,
controller: controllerName,
handler: handlerName,
};
if (method === "POST") {
details.data = sanitizedBody;
} else if (method === "PATCH" || method === "PUT") {
details.changes = sanitizedBody;
}
// Log the activity
await this.activityService.logActivity({
workspaceId,
userId: user.id,
action,
entityType,
entityId,
details,
ipAddress: ip,
userAgent: headers["user-agent"],
});
} catch (error) {
// Don't fail the request if activity logging fails
this.logger.error(
"Failed to log activity",
error instanceof Error ? error.message : "Unknown error"
);
}
})
);
}
/**
* Map HTTP method to ActivityAction
*/
private mapMethodToAction(method: string): ActivityAction | null {
switch (method) {
case "POST":
return ActivityAction.CREATED;
case "PATCH":
case "PUT":
return ActivityAction.UPDATED;
case "DELETE":
return ActivityAction.DELETED;
default:
return null;
}
}
/**
* Infer entity type from controller/handler names
*/
private inferEntityType(
controllerName: string,
handlerName: string
): EntityType {
const combined = `${controllerName} ${handlerName}`.toLowerCase();
if (combined.includes("task")) {
return EntityType.TASK;
} else if (combined.includes("event")) {
return EntityType.EVENT;
} else if (combined.includes("project")) {
return EntityType.PROJECT;
} else if (combined.includes("workspace")) {
return EntityType.WORKSPACE;
} else if (combined.includes("user")) {
return EntityType.USER;
}
// Default to TASK if cannot determine
return EntityType.TASK;
}
/**
* Sanitize sensitive data from objects before logging
* Redacts common sensitive field names
*/
private sanitizeSensitiveData(data: any): any {
if (!data || typeof data !== "object") {
return data;
}
// List of sensitive field names (case-insensitive)
const sensitiveFields = [
"password",
"token",
"secret",
"apikey",
"api_key",
"authorization",
"creditcard",
"credit_card",
"cvv",
"ssn",
"privatekey",
"private_key",
];
const sanitize = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map((item) => sanitize(item));
}
if (obj && typeof obj === "object") {
const sanitized: Record<string, any> = {};
for (const key in obj) {
const lowerKey = key.toLowerCase();
const isSensitive = sensitiveFields.some((field) =>
lowerKey.includes(field)
);
if (isSensitive) {
sanitized[key] = "[REDACTED]";
} else if (typeof obj[key] === "object") {
sanitized[key] = sanitize(obj[key]);
} else {
sanitized[key] = obj[key];
}
}
return sanitized;
}
return obj;
};
return sanitize(data);
}
}