UNPKG

create-sekiban-app

Version:

Create a new Sekiban application with Dapr

612 lines (525 loc) 20.7 kB
import { Router, Request, Response, NextFunction } from 'express'; import type { Router as ExpressRouter } from 'express'; import { z } from 'zod'; import { CreateTask, AssignTask, CompleteTask, UpdateTask, DeleteTask, RevertTaskCompletion, GetTaskById, TaskListQuery, ActiveTaskListQuery, TasksByAssigneeQuery } from '@dapr-sample/domain'; import { PartitionKeys, CommandValidationError, SekibanError, AggregateNotFoundError } from '@sekiban/core'; import { getExecutor } from '../setup/executor.js'; const router: ExpressRouter = Router(); // Test actor directly router.get( '/test-actor', async (req: Request, res: Response, next: NextFunction) => { try { console.log('[TEST] Testing direct actor invocation...'); const { DaprClient, ActorProxyBuilder, ActorId } = await import('@dapr/dapr'); const { AggregateActorFactory } = await import('@sekiban/dapr'); const daprClient = new DaprClient({ daprHost: "127.0.0.1", daprPort: "3500" }); const AggregateActorClass = AggregateActorFactory.createActorClass(); const builder = new ActorProxyBuilder(AggregateActorClass, daprClient); const actorId = 'test-actor-123'; const actor = builder.build(new ActorId(actorId)); console.log('[TEST] Calling actor testMethod...'); const testResult = await (actor as any).testMethod(); console.log('[TEST] Test method result:', testResult); res.json({ success: true, actorId, testResult, message: 'Actor test completed' }); } catch (error) { console.error('[TEST] Actor test error:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : undefined }); } } ); // Helper to create HTTP errors class HttpError extends Error { constructor(message: string, public statusCode: number, public code: string) { super(message); this.name = 'HttpError'; } } // Validation middleware function validateBody<T>(schema: z.ZodSchema<T>) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body); if (!result.success) { const validationErrors = result.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}` ); const error = new CommandValidationError('Request', validationErrors); return next(error); } req.body = result.data; next(); }; } // Create task router.post( '/tasks', validateBody((CreateTask as any).schema), async (req: Request, res: Response, next: NextFunction) => { try { const executor = await getExecutor(); console.log('Executing command:', JSON.stringify(req.body, null, 2)); const command = CreateTask.create(req.body); const result = await executor.executeCommandAsync(command); console.log('Command result:', result); if (result.isErr()) { console.error('Command failed:', result.error); return next(result.error); } console.log('Command succeeded:', result.value); res.status(201).json({ data: { taskId: result.value.aggregateId }, message: 'Task created successfully' }); } catch (error) { next(error); } } ); // Get task by ID router.get( '/tasks/:taskId', async (req: Request, res: Response, next: NextFunction) => { try { const { taskId } = req.params; // Validate UUID const uuidResult = z.string().uuid().safeParse(taskId); if (!uuidResult.success) { const error = new CommandValidationError('TaskId', ['Invalid task ID format']); return next(error); } const executor = await getExecutor(); // Get the aggregate state directly from the actor (like in create task) const { DaprClient, ActorProxyBuilder, ActorId } = await import('@dapr/dapr'); const { AggregateActorFactory } = await import('@sekiban/dapr'); const daprClient = new DaprClient({ daprHost: "127.0.0.1", daprPort: "3500" }); const AggregateActorClass = AggregateActorFactory.createActorClass(); const builder = new ActorProxyBuilder(AggregateActorClass, daprClient); // Use the same actor ID pattern as in command execution const actorId = `default@Task@${taskId}=TaskProjector`; const actor = builder.build(new ActorId(actorId)) as any; console.log(`[GET] Getting aggregate state for actor: ${actorId}`); let result: { value: any } | undefined; try { const aggregateState = await actor.getAggregateStateAsync(); console.log('[GET] Raw aggregate state result:', aggregateState); console.log('[GET] Aggregate state type:', typeof aggregateState); console.log('[GET] Aggregate state keys:', aggregateState ? Object.keys(aggregateState) : 'null'); if (!aggregateState) { console.log(`[GET] No aggregate found for task: ${taskId}, trying direct event retrieval`); // Try direct approach: load events from EventHandler and project them manually const { TaskProjector } = await import('@dapr-sample/domain'); const projector = new TaskProjector(); // Get events from event handler directly const eventHandlerActorId = `Task-${taskId}-default`; const eventHandlerActor = builder.build(new ActorId(eventHandlerActorId)); console.log(`[GET] Trying to get events directly from EventHandler: ${eventHandlerActorId}`); // This won't work with ActorProxyBuilder since it's wrong actor type, let me use direct HTTP call const eventsUrl = `http://127.0.0.1:3501/v1.0/actors/AggregateEventHandlerActor/${eventHandlerActorId}/method/getAllEventsAsync`; console.log(`[GET] Calling EventHandler directly: ${eventsUrl}`); const eventsResponse = await fetch(eventsUrl, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); console.log(`[GET] Events response status: ${eventsResponse.status}`); const eventsText = await eventsResponse.text(); console.log(`[GET] Events response body: ${eventsText}`); if (eventsResponse.ok && eventsText && eventsText !== '{}') { const events = JSON.parse(eventsText); console.log(`[GET] Retrieved ${events.length} events directly`); if (events.length > 0) { // Project the events manually let projectedState: any = null; for (const eventDoc of events) { console.log(`[GET] Projecting event: ${eventDoc.eventType}`); // Create a simple projection for TaskCreated if (eventDoc.eventType === 'TaskCreated') { projectedState = { aggregateType: 'Task', taskId: eventDoc.payload.taskId, title: eventDoc.payload.title, description: eventDoc.payload.description, priority: eventDoc.payload.priority, status: 'active', createdAt: eventDoc.payload.createdAt, updatedAt: eventDoc.payload.createdAt }; console.log(`[GET] Manually projected state: ${JSON.stringify(projectedState, null, 2)}`); break; } } if (projectedState) { console.log('[GET] Using manually projected state'); // Handle different possible result structures let payload: any = projectedState; console.log('Final payload for response:', JSON.stringify(payload, null, 2)); const isCompleted = payload.aggregateType === 'CompletedTask' || payload.status === 'completed'; res.json({ id: payload.taskId, title: payload.title, description: payload.description, assignedTo: payload.assignedTo, dueDate: payload.dueDate, priority: payload.priority, status: isCompleted ? 'completed' : payload.status, createdAt: payload.createdAt, updatedAt: payload.updatedAt, completedAt: isCompleted ? payload.completedAt : undefined, completedBy: isCompleted ? payload.completedBy : undefined, completionNotes: isCompleted ? payload.completionNotes : undefined }); return; } } } const error = new AggregateNotFoundError(taskId, 'Task'); return next(error); } console.log('[GET] Aggregate state loaded:', JSON.stringify(aggregateState, null, 2)); result = { value: aggregateState }; } catch (actorError) { console.error('[GET] Error calling getAggregateStateAsync:', actorError); const error = new AggregateNotFoundError(taskId, 'Task'); return next(error); } if (!result.value) { const error = new AggregateNotFoundError(taskId, 'Task'); return next(error); } // Transform aggregate to response console.log('Query result structure:', JSON.stringify(result.value, null, 2)); // Handle different possible result structures let payload: any; if (result.value && result.value.payload) { payload = result.value.payload; } else if (result.value && typeof result.value === 'object') { // The result might be the payload directly payload = result.value; } else { console.error('Unexpected result structure:', result.value); const error = new AggregateNotFoundError(taskId, 'Task'); return next(error); } console.log('Extracted payload:', JSON.stringify(payload, null, 2)); // Check if payload has the expected properties if (!payload || typeof payload !== 'object') { console.error('Invalid payload structure:', payload); const error = new AggregateNotFoundError(taskId, 'Task'); return next(error); } const isCompleted = payload.aggregateType === 'CompletedTask' || payload.status === 'completed'; res.json({ id: payload.taskId, title: payload.title, description: payload.description, assignedTo: payload.assignedTo, dueDate: payload.dueDate, priority: payload.priority, status: isCompleted ? 'completed' : payload.status, createdAt: payload.createdAt, updatedAt: payload.updatedAt, completedAt: isCompleted ? payload.completedAt : undefined, completedBy: isCompleted ? payload.completedBy : undefined, completionNotes: isCompleted ? payload.completionNotes : undefined }); } catch (error) { next(error); } } ); // Assign task router.post( '/tasks/:taskId/assign', validateBody(z.object({ assignedTo: z.string().email() })), async (req: Request, res: Response, next: NextFunction) => { try { const { taskId } = req.params; const { assignedTo } = req.body; const executor = await getExecutor(); const command = AssignTask.create({ taskId, assignedTo }); console.log(`[ASSIGN] Executing AssignTask for taskId: ${taskId}, assignedTo: ${assignedTo}`); console.log(`[ASSIGN] Command created:`, JSON.stringify(command, null, 2)); const result = await executor.executeCommandAsync(command); console.log(`[ASSIGN] Command execution result success: ${result.isOk()}`); if (result.isErr()) { console.error(`[ASSIGN] Command execution failed:`, result.error); console.error(`[ASSIGN] Error details:`, { message: result.error.message, stack: result.error.stack, code: (result.error as any).code }); return next(result.error); } console.log(`[ASSIGN] Command execution successful:`, result.value); res.json({ message: 'Task assigned successfully' }); } catch (error) { next(error); } } ); // Complete task router.post( '/tasks/:taskId/complete', validateBody(z.object({ completedBy: z.string().email(), notes: z.string().max(500).optional() })), async (req: Request, res: Response, next: NextFunction) => { try { const { taskId } = req.params; const executor = await getExecutor(); const command = CompleteTask.create({ ...req.body, taskId }); const result = await executor.executeCommandAsync(command); if (result.isErr()) { return next(result.error); } res.json({ message: 'Task completed successfully' }); } catch (error) { next(error); } } ); // Update task router.patch( '/tasks/:taskId', validateBody(z.object({ title: z.string().min(1).max(200).optional(), description: z.string().max(1000).optional(), dueDate: z.string().datetime().optional(), priority: z.enum(['low', 'medium', 'high']).optional() })), async (req: Request, res: Response, next: NextFunction) => { try { const { taskId } = req.params; const executor = await getExecutor(); const command = UpdateTask.create({ ...req.body, taskId }); const result = await executor.executeCommandAsync(command); if (result.isErr()) { return next(result.error); } res.json({ message: 'Task updated successfully' }); } catch (error) { next(error); } } ); // Delete task router.delete( '/tasks/:taskId', validateBody(z.object({ deletedBy: z.string().email(), reason: z.string().max(200).optional() })), async (req: Request, res: Response, next: NextFunction) => { try { const { taskId } = req.params; const executor = await getExecutor(); const command = DeleteTask.create({ ...req.body, taskId }); const result = await executor.executeCommandAsync(command); if (result.isErr()) { return next(result.error); } res.json({ message: 'Task deleted successfully' }); } catch (error) { next(error); } } ); // Revert task completion router.post( '/tasks/:taskId/revert-completion', validateBody(z.object({ revertedBy: z.string().email(), reason: z.string().max(500).optional() })), async (req: Request, res: Response, next: NextFunction) => { try { const { taskId } = req.params; const executor = await getExecutor(); const command = RevertTaskCompletion.create({ ...req.body, taskId }); const result = await executor.executeCommandAsync(command); if (result.isErr()) { return next(result.error); } res.json({ message: 'Task completion reverted successfully' }); } catch (error) { next(error); } } ); // Workflow: Create and assign task router.post( '/workflows/task-assignment', validateBody(z.object({ title: z.string(), description: z.string().optional(), assignedTo: z.string().email(), priority: z.enum(['low', 'medium', 'high']).default('medium'), dueDate: z.string().datetime().optional() })), async (req: Request, res: Response, next: NextFunction) => { try { const executor = await getExecutor(); // Create task with assignedTo already set const command = CreateTask.create({ title: req.body.title, description: req.body.description, assignedTo: req.body.assignedTo, priority: req.body.priority, dueDate: req.body.dueDate }); const result = await executor.executeCommandAsync(command); if (result.isErr()) { return next(result.error); } res.status(201).json({ taskId: result.value.aggregateId, message: 'Task created and assigned successfully' }); } catch (error) { next(error); } } ); // Test loadAggregateAsync directly router.get( '/tasks/:taskId/aggregate-state', async (req: Request, res: Response, next: NextFunction) => { try { const { taskId } = req.params; console.log('[TEST] Testing loadAggregateAsync for task:', taskId); // Create the actor ID in the same format as the executor const aggregateId = `default@Task@${taskId}=TaskProjector`; const { DaprClient, ActorProxyBuilder, ActorId } = await import('@dapr/dapr'); const { AggregateActorFactory } = await import('@sekiban/dapr'); const daprClient = new DaprClient({ daprHost: "127.0.0.1", daprPort: String(process.env.DAPR_HTTP_PORT || "3500") }); const AggregateActorClass = AggregateActorFactory.createActorClass(); const builder = new ActorProxyBuilder(AggregateActorClass, daprClient); const actor = builder.build(new ActorId(aggregateId)); console.log('[TEST] Calling loadAggregateAsync...'); const partitionKeys = { aggregateId: taskId, group: 'Task', rootPartitionKey: 'default' }; const aggregateState = await (actor as any).loadAggregateAsync(partitionKeys); console.log('[TEST] Aggregate state:', aggregateState); res.json({ success: true, aggregateId, aggregateState, message: 'Aggregate state loaded successfully' }); } catch (error) { console.error('[TEST] Load aggregate error:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); } } ); // Get all tasks with filtering and pagination router.get( '/tasks', async (req: Request, res: Response, next: NextFunction) => { try { const { status, assignee, limit, offset } = req.query; const executor = await getExecutor(); // Choose the appropriate query based on parameters let query; if (assignee) { // Use TasksByAssigneeQuery if assignee is specified query = new TasksByAssigneeQuery(assignee as string); } else if (status === 'active') { // Use ActiveTaskListQuery for active tasks query = new ActiveTaskListQuery(); } else { // Use general TaskListQuery query = new TaskListQuery(); } // Note: New query implementation handles pagination internally // The multi-projection system will handle limit/offset automatically // Execute multi-projection query let result; try { result = await executor.queryAsync(query); } catch (error) { return res.status(500).json({ error: 'Failed to execute query', details: error instanceof Error ? error.message : String(error) }); } if (result.isErr()) { return res.status(500).json({ error: 'Failed to fetch tasks', details: result.error.message }); } const queryResult = result.value || { items: [] }; const tasks = queryResult.items || []; // Transform results to consistent API response format const transformedTasks = tasks.map((item: any) => { const payload = item.payload || item; const isCompleted = payload.aggregateType === 'CompletedTask' || payload.status === 'completed'; return { id: payload.taskId, title: payload.title, description: payload.description, assignedTo: payload.assignedTo, dueDate: payload.dueDate, priority: payload.priority, status: isCompleted ? 'completed' : payload.status, createdAt: payload.createdAt, updatedAt: payload.updatedAt, completedAt: isCompleted ? payload.completedAt : undefined, completedBy: isCompleted ? payload.completedBy : undefined, completionNotes: isCompleted ? payload.completionNotes : undefined }; }); res.json(transformedTasks); } catch (error) { next(error); } } ); export { router as taskRoutes };