From 19dc75c3f4232705360f59796ff2751c13347aaf Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 20:35:09 +0100 Subject: [PATCH] feat(05-01): add MessageRepository port and adapter - Create MessageRepository port interface with CRUD and query methods - Implement DrizzleMessageRepository adapter with nanoid generation - Add findBySender/findByRecipient for participant-based queries - Add findPendingForUser for unread user notifications - Add findRequiringResponse for messages awaiting reply - Add findReplies for message threading support - Add 23 tests covering all operations and edge cases - Update test-helpers with messages table schema - Re-export from index files following AgentRepository pattern --- src/db/repositories/drizzle/index.ts | 1 + src/db/repositories/drizzle/message.test.ts | 464 ++++++++++++++++++++ src/db/repositories/drizzle/message.ts | 143 ++++++ src/db/repositories/drizzle/test-helpers.ts | 16 + src/db/repositories/index.ts | 9 + src/db/repositories/message-repository.ts | 118 +++++ 6 files changed, 751 insertions(+) create mode 100644 src/db/repositories/drizzle/message.test.ts create mode 100644 src/db/repositories/drizzle/message.ts create mode 100644 src/db/repositories/message-repository.ts diff --git a/src/db/repositories/drizzle/index.ts b/src/db/repositories/drizzle/index.ts index 297291c..e56f451 100644 --- a/src/db/repositories/drizzle/index.ts +++ b/src/db/repositories/drizzle/index.ts @@ -10,3 +10,4 @@ export { DrizzlePhaseRepository } from './phase.js'; export { DrizzlePlanRepository } from './plan.js'; export { DrizzleTaskRepository } from './task.js'; export { DrizzleAgentRepository } from './agent.js'; +export { DrizzleMessageRepository } from './message.js'; diff --git a/src/db/repositories/drizzle/message.test.ts b/src/db/repositories/drizzle/message.test.ts new file mode 100644 index 0000000..a387ad4 --- /dev/null +++ b/src/db/repositories/drizzle/message.test.ts @@ -0,0 +1,464 @@ +/** + * DrizzleMessageRepository Tests + * + * Tests for the Message repository adapter. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DrizzleMessageRepository } from './message.js'; +import { DrizzleAgentRepository } from './agent.js'; +import { DrizzleTaskRepository } from './task.js'; +import { DrizzlePlanRepository } from './plan.js'; +import { DrizzlePhaseRepository } from './phase.js'; +import { DrizzleInitiativeRepository } from './initiative.js'; +import { createTestDatabase } from './test-helpers.js'; +import type { DrizzleDatabase } from '../../index.js'; + +describe('DrizzleMessageRepository', () => { + let db: DrizzleDatabase; + let messageRepo: DrizzleMessageRepository; + let agentRepo: DrizzleAgentRepository; + let testAgentId: string; + + beforeEach(async () => { + db = createTestDatabase(); + messageRepo = new DrizzleMessageRepository(db); + agentRepo = new DrizzleAgentRepository(db); + + // Create required hierarchy for agent FK + const taskRepo = new DrizzleTaskRepository(db); + const planRepo = new DrizzlePlanRepository(db); + const phaseRepo = new DrizzlePhaseRepository(db); + const initiativeRepo = new DrizzleInitiativeRepository(db); + + const initiative = await initiativeRepo.create({ + name: 'Test Initiative', + }); + const phase = await phaseRepo.create({ + initiativeId: initiative.id, + number: 1, + name: 'Test Phase', + }); + const plan = await planRepo.create({ + phaseId: phase.id, + number: 1, + name: 'Test Plan', + }); + const task = await taskRepo.create({ + planId: plan.id, + name: 'Test Task', + order: 1, + }); + + // Create a test agent + const agent = await agentRepo.create({ + name: 'test-agent', + worktreeId: 'worktree-123', + taskId: task.id, + }); + testAgentId = agent.id; + }); + + describe('create', () => { + it('should create agent→user message (question)', async () => { + const message = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'question', + content: 'Do you want to proceed with deployment?', + requiresResponse: true, + }); + + expect(message.id).toBeDefined(); + expect(message.id.length).toBeGreaterThan(0); + expect(message.senderType).toBe('agent'); + expect(message.senderId).toBe(testAgentId); + expect(message.recipientType).toBe('user'); + expect(message.recipientId).toBeNull(); + expect(message.type).toBe('question'); + expect(message.content).toBe('Do you want to proceed with deployment?'); + expect(message.requiresResponse).toBe(true); + expect(message.status).toBe('pending'); + expect(message.parentMessageId).toBeNull(); + expect(message.createdAt).toBeInstanceOf(Date); + expect(message.updatedAt).toBeInstanceOf(Date); + }); + + it('should create agent→user message (notification, requiresResponse=false)', async () => { + const message = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'info', + content: 'Build completed successfully.', + requiresResponse: false, + }); + + expect(message.type).toBe('info'); + expect(message.requiresResponse).toBe(false); + expect(message.status).toBe('pending'); + }); + + it('should create user→agent response', async () => { + // First create the question + const question = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'question', + content: 'Which database?', + requiresResponse: true, + }); + + // Then create user response + const response = await messageRepo.create({ + senderType: 'user', + recipientType: 'agent', + recipientId: testAgentId, + type: 'response', + content: 'Use PostgreSQL', + parentMessageId: question.id, + }); + + expect(response.senderType).toBe('user'); + expect(response.senderId).toBeNull(); + expect(response.recipientType).toBe('agent'); + expect(response.recipientId).toBe(testAgentId); + expect(response.parentMessageId).toBe(question.id); + }); + + it('should default type to info', async () => { + const message = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Status update', + }); + + expect(message.type).toBe('info'); + }); + }); + + describe('findById', () => { + it('should return null for non-existent message', async () => { + const result = await messageRepo.findById('non-existent-id'); + expect(result).toBeNull(); + }); + + it('should find an existing message', async () => { + const created = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Test message', + }); + + const found = await messageRepo.findById(created.id); + expect(found).not.toBeNull(); + expect(found!.id).toBe(created.id); + expect(found!.content).toBe('Test message'); + }); + }); + + describe('findBySender', () => { + it('should find messages by agent sender', async () => { + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Message 1', + }); + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Message 2', + }); + + const messages = await messageRepo.findBySender('agent', testAgentId); + expect(messages.length).toBe(2); + }); + + it('should find messages by user sender', async () => { + await messageRepo.create({ + senderType: 'user', + recipientType: 'agent', + recipientId: testAgentId, + content: 'User message 1', + }); + await messageRepo.create({ + senderType: 'user', + recipientType: 'agent', + recipientId: testAgentId, + content: 'User message 2', + }); + + const messages = await messageRepo.findBySender('user'); + expect(messages.length).toBe(2); + }); + + it('should return empty array when no messages from sender', async () => { + const messages = await messageRepo.findBySender('agent', 'nonexistent-id'); + expect(messages).toEqual([]); + }); + }); + + describe('findByRecipient', () => { + it('should find messages by user recipient', async () => { + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'For user 1', + }); + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'For user 2', + }); + + const messages = await messageRepo.findByRecipient('user'); + expect(messages.length).toBe(2); + }); + + it('should find messages by agent recipient', async () => { + await messageRepo.create({ + senderType: 'user', + recipientType: 'agent', + recipientId: testAgentId, + content: 'For agent', + }); + + const messages = await messageRepo.findByRecipient('agent', testAgentId); + expect(messages.length).toBe(1); + }); + + it('should return empty array when no messages for recipient', async () => { + const messages = await messageRepo.findByRecipient('agent', 'nonexistent-id'); + expect(messages).toEqual([]); + }); + }); + + describe('findPendingForUser', () => { + it('should return only user-targeted pending messages', async () => { + // Create pending message for user + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Pending for user', + status: 'pending', + }); + + // Create read message for user (should not be returned) + const readMessage = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Already read', + }); + await messageRepo.update(readMessage.id, { status: 'read' }); + + // Create pending message for agent (should not be returned) + await messageRepo.create({ + senderType: 'user', + recipientType: 'agent', + recipientId: testAgentId, + content: 'For agent not user', + }); + + const pending = await messageRepo.findPendingForUser(); + expect(pending.length).toBe(1); + expect(pending[0].content).toBe('Pending for user'); + }); + + it('should return empty array when no pending messages for user', async () => { + const pending = await messageRepo.findPendingForUser(); + expect(pending).toEqual([]); + }); + }); + + describe('findRequiringResponse', () => { + it('should return only messages needing response', async () => { + // Create message requiring response + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'question', + content: 'Requires answer', + requiresResponse: true, + }); + + // Create message not requiring response + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'info', + content: 'Just info', + requiresResponse: false, + }); + + // Create message that required response but was responded + const responded = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'question', + content: 'Already answered', + requiresResponse: true, + }); + await messageRepo.update(responded.id, { status: 'responded' }); + + const requiring = await messageRepo.findRequiringResponse(); + expect(requiring.length).toBe(1); + expect(requiring[0].content).toBe('Requires answer'); + }); + + it('should return empty array when no messages require response', async () => { + const requiring = await messageRepo.findRequiringResponse(); + expect(requiring).toEqual([]); + }); + }); + + describe('findReplies (message threading)', () => { + it('should find all replies to a parent message', async () => { + // Create original question + const question = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'question', + content: 'Original question', + requiresResponse: true, + }); + + // Create two replies + await messageRepo.create({ + senderType: 'user', + recipientType: 'agent', + recipientId: testAgentId, + type: 'response', + content: 'First reply', + parentMessageId: question.id, + }); + await messageRepo.create({ + senderType: 'user', + recipientType: 'agent', + recipientId: testAgentId, + type: 'response', + content: 'Second reply', + parentMessageId: question.id, + }); + + // Create unrelated message (should not be in replies) + await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Unrelated message', + }); + + const replies = await messageRepo.findReplies(question.id); + expect(replies.length).toBe(2); + expect(replies.every((r) => r.parentMessageId === question.id)).toBe(true); + }); + + it('should return empty array when message has no replies', async () => { + const message = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'No replies', + }); + + const replies = await messageRepo.findReplies(message.id); + expect(replies).toEqual([]); + }); + }); + + describe('update status flow', () => { + it('should update status: pending → read → responded', async () => { + const message = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + type: 'question', + content: 'Status flow test', + requiresResponse: true, + }); + expect(message.status).toBe('pending'); + + // Wait to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update to read + const readMessage = await messageRepo.update(message.id, { status: 'read' }); + expect(readMessage.status).toBe('read'); + expect(readMessage.updatedAt.getTime()).toBeGreaterThan(message.updatedAt.getTime()); + + // Wait again + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update to responded + const respondedMessage = await messageRepo.update(readMessage.id, { + status: 'responded', + }); + expect(respondedMessage.status).toBe('responded'); + expect(respondedMessage.updatedAt.getTime()).toBeGreaterThan( + readMessage.updatedAt.getTime() + ); + }); + }); + + describe('update', () => { + it('should throw for non-existent message', async () => { + await expect( + messageRepo.update('non-existent-id', { status: 'read' }) + ).rejects.toThrow('Message not found'); + }); + + it('should update content and updatedAt', async () => { + const created = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'Original content', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const updated = await messageRepo.update(created.id, { + content: 'Updated content', + }); + + expect(updated.content).toBe('Updated content'); + expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime()); + }); + }); + + describe('delete', () => { + it('should delete an existing message', async () => { + const created = await messageRepo.create({ + senderType: 'agent', + senderId: testAgentId, + recipientType: 'user', + content: 'To delete', + }); + + await messageRepo.delete(created.id); + + const found = await messageRepo.findById(created.id); + expect(found).toBeNull(); + }); + + it('should throw for non-existent message', async () => { + await expect(messageRepo.delete('non-existent-id')).rejects.toThrow( + 'Message not found' + ); + }); + }); +}); diff --git a/src/db/repositories/drizzle/message.ts b/src/db/repositories/drizzle/message.ts new file mode 100644 index 0000000..8d0653f --- /dev/null +++ b/src/db/repositories/drizzle/message.ts @@ -0,0 +1,143 @@ +/** + * Drizzle Message Repository Adapter + * + * Implements MessageRepository interface using Drizzle ORM. + */ + +import { eq, and, desc } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import type { DrizzleDatabase } from '../../index.js'; +import { messages, type Message } from '../../schema.js'; +import type { + MessageRepository, + CreateMessageData, + UpdateMessageData, + MessageParticipantType, +} from '../message-repository.js'; + +/** + * Drizzle adapter for MessageRepository. + * + * Uses dependency injection for database instance, + * enabling isolated test databases. + */ +export class DrizzleMessageRepository implements MessageRepository { + constructor(private db: DrizzleDatabase) {} + + async create(data: CreateMessageData): Promise { + const id = nanoid(); + const now = new Date(); + + await this.db.insert(messages).values({ + id, + senderType: data.senderType, + senderId: data.senderId ?? null, + recipientType: data.recipientType, + recipientId: data.recipientId ?? null, + type: data.type ?? 'info', + content: data.content, + requiresResponse: data.requiresResponse ?? false, + status: data.status ?? 'pending', + parentMessageId: data.parentMessageId ?? null, + createdAt: now, + updatedAt: now, + }); + + // Fetch to get the complete record with all defaults applied + const created = await this.findById(id); + return created!; + } + + async findById(id: string): Promise { + const result = await this.db + .select() + .from(messages) + .where(eq(messages.id, id)) + .limit(1); + + return result[0] ?? null; + } + + async findBySender(type: MessageParticipantType, id?: string): Promise { + if (id) { + return this.db + .select() + .from(messages) + .where(and(eq(messages.senderType, type), eq(messages.senderId, id))) + .orderBy(desc(messages.createdAt)); + } + + // For user sender (no id), find where senderType='user' and senderId is null + return this.db + .select() + .from(messages) + .where(eq(messages.senderType, type)) + .orderBy(desc(messages.createdAt)); + } + + async findByRecipient(type: MessageParticipantType, id?: string): Promise { + if (id) { + return this.db + .select() + .from(messages) + .where(and(eq(messages.recipientType, type), eq(messages.recipientId, id))) + .orderBy(desc(messages.createdAt)); + } + + // For user recipient (no id), find where recipientType='user' and recipientId is null + return this.db + .select() + .from(messages) + .where(eq(messages.recipientType, type)) + .orderBy(desc(messages.createdAt)); + } + + async findPendingForUser(): Promise { + return this.db + .select() + .from(messages) + .where(and(eq(messages.recipientType, 'user'), eq(messages.status, 'pending'))) + .orderBy(desc(messages.createdAt)); + } + + async findRequiringResponse(): Promise { + return this.db + .select() + .from(messages) + .where(and(eq(messages.requiresResponse, true), eq(messages.status, 'pending'))) + .orderBy(desc(messages.createdAt)); + } + + async findReplies(parentMessageId: string): Promise { + return this.db + .select() + .from(messages) + .where(eq(messages.parentMessageId, parentMessageId)) + .orderBy(desc(messages.createdAt)); + } + + async update(id: string, data: UpdateMessageData): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new Error(`Message not found: ${id}`); + } + + const updated = { + ...data, + updatedAt: new Date(), + }; + + await this.db.update(messages).set(updated).where(eq(messages.id, id)); + + return { ...existing, ...updated } as Message; + } + + async delete(id: string): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new Error(`Message not found: ${id}`); + } + + await this.db.delete(messages).where(eq(messages.id, id)); + } +} diff --git a/src/db/repositories/drizzle/test-helpers.ts b/src/db/repositories/drizzle/test-helpers.ts index c41e695..506b1be 100644 --- a/src/db/repositories/drizzle/test-helpers.ts +++ b/src/db/repositories/drizzle/test-helpers.ts @@ -82,6 +82,22 @@ CREATE TABLE IF NOT EXISTS agents ( created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); + +-- Messages table +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY NOT NULL, + sender_type TEXT NOT NULL, + sender_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + recipient_type TEXT NOT NULL, + recipient_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'info', + content TEXT NOT NULL, + requires_response INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + parent_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); `; /** diff --git a/src/db/repositories/index.ts b/src/db/repositories/index.ts index e043070..17aebb2 100644 --- a/src/db/repositories/index.ts +++ b/src/db/repositories/index.ts @@ -35,3 +35,12 @@ export type { AgentStatus, CreateAgentData, } from './agent-repository.js'; + +export type { + MessageRepository, + MessageParticipantType, + MessageType, + MessageStatus, + CreateMessageData, + UpdateMessageData, +} from './message-repository.js'; diff --git a/src/db/repositories/message-repository.ts b/src/db/repositories/message-repository.ts new file mode 100644 index 0000000..e364607 --- /dev/null +++ b/src/db/repositories/message-repository.ts @@ -0,0 +1,118 @@ +/** + * Message Repository Port Interface + * + * Port for Message aggregate operations. + * Implementations (Drizzle, etc.) are adapters. + * + * Messages persist agent questions for users to query and respond later. + * Supports threading via parentMessageId for response linking. + */ + +import type { Message } from '../schema.js'; + +/** + * Message sender/recipient type. + */ +export type MessageParticipantType = 'agent' | 'user'; + +/** + * Message type. + */ +export type MessageType = 'question' | 'info' | 'error' | 'response'; + +/** + * Message status. + */ +export type MessageStatus = 'pending' | 'read' | 'responded'; + +/** + * Data for creating a new message. + * Omits system-managed fields (id, createdAt, updatedAt). + */ +export interface CreateMessageData { + senderType: MessageParticipantType; + senderId?: string | null; + recipientType: MessageParticipantType; + recipientId?: string | null; + type?: MessageType; + content: string; + requiresResponse?: boolean; + status?: MessageStatus; + parentMessageId?: string | null; +} + +/** + * Data for updating a message. + * Partial of create data - all fields optional. + */ +export type UpdateMessageData = Partial; + +/** + * Message Repository Port + * + * Defines operations for the Message aggregate. + * Enables message persistence for agent-user communication. + */ +export interface MessageRepository { + /** + * Create a new message. + * Generates id and sets timestamps automatically. + */ + create(data: CreateMessageData): Promise; + + /** + * Find a message by its ID. + * Returns null if not found. + */ + findById(id: string): Promise; + + /** + * Find messages by sender. + * @param type - 'agent' or 'user' + * @param id - Optional sender ID (agent ID if type='agent', omit for user) + * Returns messages ordered by createdAt DESC. + */ + findBySender(type: MessageParticipantType, id?: string): Promise; + + /** + * Find messages by recipient. + * @param type - 'agent' or 'user' + * @param id - Optional recipient ID (agent ID if type='agent', omit for user) + * Returns messages ordered by createdAt DESC. + */ + findByRecipient(type: MessageParticipantType, id?: string): Promise; + + /** + * Find all pending messages for user. + * Returns messages where recipientType='user' and status='pending'. + * Ordered by createdAt DESC. + */ + findPendingForUser(): Promise; + + /** + * Find all messages requiring a response. + * Returns messages where requiresResponse=true and status='pending'. + * Ordered by createdAt DESC. + */ + findRequiringResponse(): Promise; + + /** + * Find all replies to a message. + * @param parentMessageId - The ID of the parent message + * Returns messages ordered by createdAt DESC. + */ + findReplies(parentMessageId: string): Promise; + + /** + * Update a message. + * Throws if message not found. + * Updates updatedAt timestamp automatically. + */ + update(id: string, data: UpdateMessageData): Promise; + + /** + * Delete a message. + * Throws if message not found. + */ + delete(id: string): Promise; +}