diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py
index 71262a8..3c8ab17 100644
--- a/backend/app/api/v1/api.py
+++ b/backend/app/api/v1/api.py
@@ -1,7 +1,9 @@
from fastapi import APIRouter
-from app.api.v1.endpoints import states
+from app.api.v1.endpoints import states, workflows
api_router = APIRouter()
# Include all endpoint routers
api_router.include_router(states.router, prefix="/states", tags=["states"])
+api_router.include_router(
+ workflows.router, prefix="/workflows", tags=["workflows"])
diff --git a/backend/app/api/v1/endpoints/states.py b/backend/app/api/v1/endpoints/states.py
index c4b8b80..ef95b5f 100644
--- a/backend/app/api/v1/endpoints/states.py
+++ b/backend/app/api/v1/endpoints/states.py
@@ -1,6 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
-from typing import List
from app.database import get_db
from app.schemas.state import StateCreate, StateUpdate, StateResponse
from app.services.state_service import StateService
@@ -8,15 +7,16 @@
router = APIRouter()
-@router.get("/", response_model=List[StateResponse], summary="Get all states")
+@router.get("/", response_model=list[StateResponse], summary="Get all states")
async def get_states(
skip: int = Query(0, ge=0, description="Number of records to skip"),
- limit: int = Query(100,
- ge=1,
- le=1000,
- description="Maximum number of records to return"
- ),
- db: Session = Depends(get_db)
+ limit: int = Query(
+ 100,
+ ge=1,
+ le=1000,
+ description="Maximum number of records to return",
+ ),
+ db: Session = Depends(get_db),
):
"""
Retrieve all states with pagination support, ordered by sort_order.
@@ -27,10 +27,11 @@ async def get_states(
return states
-@router.get("/active",
- response_model=List[StateResponse],
- summary="Get active states"
- )
+@router.get(
+ "/active",
+ response_model=list[StateResponse],
+ summary="Get active states",
+)
async def get_active_states(db: Session = Depends(get_db)):
"""
Retrieve all active states, ordered by sort_order.
@@ -39,13 +40,14 @@ async def get_active_states(db: Session = Depends(get_db)):
return states
-@router.get("/{state_id}",
- response_model=StateResponse,
- summary="Get state by ID"
- )
+@router.get(
+ "/{state_id}",
+ response_model=StateResponse,
+ summary="Get state by ID",
+)
async def get_state(
state_id: int,
- db: Session = Depends(get_db)
+ db: Session = Depends(get_db),
):
"""
Retrieve a specific state by its ID.
@@ -57,14 +59,15 @@ async def get_state(
return state
-@router.post("/",
- response_model=StateResponse,
- status_code=201,
- summary="Create new state"
- )
+@router.post(
+ "/",
+ response_model=StateResponse,
+ status_code=201,
+ summary="Create new state",
+)
async def create_state(
state: StateCreate,
- db: Session = Depends(get_db)
+ db: Session = Depends(get_db),
):
"""
Create a new state.
@@ -77,14 +80,15 @@ async def create_state(
return StateService.create_state(db, state)
-@router.put("/{state_id}",
- response_model=StateResponse,
- summary="Update state"
- )
+@router.put(
+ "/{state_id}",
+ response_model=StateResponse,
+ summary="Update state",
+)
async def update_state(
state_id: int,
state_update: StateUpdate,
- db: Session = Depends(get_db)
+ db: Session = Depends(get_db),
):
"""
Update an existing state.
@@ -98,7 +102,7 @@ async def update_state(
@router.delete("/{state_id}", summary="Delete state")
async def delete_state(
state_id: int,
- db: Session = Depends(get_db)
+ db: Session = Depends(get_db),
):
"""
Delete a state.
@@ -108,10 +112,11 @@ async def delete_state(
return {"message": f"State {state_id} deleted successfully"}
-@router.post("/initialize",
- response_model=List[StateResponse],
- summary="Initialize default states"
- )
+@router.post(
+ "/initialize",
+ response_model=list[StateResponse],
+ summary="Initialize default states",
+)
async def initialize_default_states(db: Session = Depends(get_db)):
"""
Initialize default states (New, In Progress, Done) if they don't exist.
diff --git a/backend/app/api/v1/endpoints/workflows.py b/backend/app/api/v1/endpoints/workflows.py
new file mode 100644
index 0000000..99f2f60
--- /dev/null
+++ b/backend/app/api/v1/endpoints/workflows.py
@@ -0,0 +1,52 @@
+from fastapi import APIRouter, HTTPException
+
+from app.services.workflow_service import WorkflowService
+from app.schemas.workflow import StartWorkflowRequest, CompleteTaskRequest
+
+
+router = APIRouter()
+
+
+
+
+
+@router.post("/start", summary="Start a new workflow instance")
+async def start_workflow(payload: StartWorkflowRequest):
+ try:
+ return WorkflowService.start_workflow(
+ name=payload.name,
+ bpmn_filename=payload.bpmn_filename,
+ process_id=payload.process_id,
+ )
+ except FileNotFoundError:
+ raise HTTPException(status_code=404, detail="BPMN file not found")
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc))
+
+
+@router.get("/", summary="List workflow instances")
+async def list_workflows():
+ return WorkflowService.list_workflows()
+
+
+@router.post(
+ "/{instance_id}/tasks/{task_id}/complete",
+ summary="Complete a user task",
+)
+async def complete_task(
+ instance_id: str,
+ task_id: str,
+ payload: CompleteTaskRequest,
+):
+ try:
+ return WorkflowService.complete_user_task(
+ instance_id,
+ task_id,
+ payload.data.model_dump(),
+ )
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc))
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index 9f2f091..8d1c8b6 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -1 +1 @@
-# Schemas Package
+
diff --git a/backend/app/schemas/state.py b/backend/app/schemas/state.py
index 64a08cb..c3a1ded 100644
--- a/backend/app/schemas/state.py
+++ b/backend/app/schemas/state.py
@@ -1,5 +1,4 @@
from pydantic import BaseModel, Field, ConfigDict, field_serializer
-from typing import Optional
from datetime import datetime
@@ -13,10 +12,10 @@ class StateBase(CustomBaseModel):
max_length=50,
description="State name"
)
- description: Optional[str] = Field(None,
- max_length=200,
- description="State description"
- )
+ description: str | None = Field(None,
+ max_length=200,
+ description="State description"
+ )
is_active: bool = Field(True, description="Whether the state is active")
sort_order: int = Field(0, ge=0, description="Sort order for display")
@@ -26,17 +25,17 @@ class StateCreate(StateBase):
class StateUpdate(CustomBaseModel):
- name: Optional[str] = Field(None, min_length=1, max_length=50)
- description: Optional[str] = Field(None, max_length=200)
- is_active: Optional[bool] = None
- sort_order: Optional[int] = Field(None, ge=0)
+ name: str | None = Field(None, min_length=1, max_length=50)
+ description: str | None = Field(None, max_length=200)
+ is_active: bool | None = None
+ sort_order: int | None = Field(None, ge=0)
class StateResponse(StateBase):
id: int
created_at: datetime
- updated_at: Optional[datetime] = None
+ updated_at: datetime | None = None
@field_serializer("created_at", "updated_at", when_used="json")
- def serialize_dt(self, dt: Optional[datetime]) -> Optional[str]:
+ def serialize_dt(self, dt: datetime | None) -> str | None:
return dt.isoformat() if dt else None
diff --git a/backend/app/schemas/workflow.py b/backend/app/schemas/workflow.py
new file mode 100644
index 0000000..ecfdbfa
--- /dev/null
+++ b/backend/app/schemas/workflow.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel, Field
+
+
+class StartWorkflowRequest(BaseModel):
+ name: str = Field(default="Simple Approval")
+ bpmn_filename: str = Field(default="simple_approval.bpmn")
+ process_id: str = Field(default="SimpleApproval")
+
+
+class CompleteTaskRequest(BaseModel):
+ class ApprovalData(BaseModel):
+ approved: bool
+
+ data: ApprovalData
+
diff --git a/backend/app/services/state_service.py b/backend/app/services/state_service.py
index 3e15078..918e931 100644
--- a/backend/app/services/state_service.py
+++ b/backend/app/services/state_service.py
@@ -1,6 +1,5 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
-from typing import List, Optional
from app.models.state import State
from app.schemas.state import StateCreate, StateUpdate
from fastapi import HTTPException
@@ -8,10 +7,11 @@
class StateService:
@staticmethod
- def get_states(db: Session,
- skip: int = 0,
- limit: int = 100
- ) -> List[State]:
+ def get_states(
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
+ ) -> list[State]:
"""Get all states with pagination, ordered by sort_order"""
return (
db.query(State)
@@ -22,17 +22,17 @@ def get_states(db: Session,
)
@staticmethod
- def get_state_by_id(db: Session, state_id: int) -> Optional[State]:
+ def get_state_by_id(db: Session, state_id: int) -> State | None:
"""Get state by ID"""
return db.query(State).filter(State.id == state_id).first()
@staticmethod
- def get_state_by_name(db: Session, name: str) -> Optional[State]:
+ def get_state_by_name(db: Session, name: str) -> State | None:
"""Get state by name"""
return db.query(State).filter(State.name == name).first()
@staticmethod
- def get_active_states(db: Session) -> List[State]:
+ def get_active_states(db: Session) -> list[State]:
"""Get all active states, ordered by sort_order"""
return (
db.query(State)
@@ -65,10 +65,11 @@ def create_state(db: Session, state: StateCreate) -> State:
)
@staticmethod
- def update_state(db: Session,
- state_id: int,
- state_update: StateUpdate
- ) -> State:
+ def update_state(
+ db: Session,
+ state_id: int,
+ state_update: StateUpdate,
+ ) -> State:
"""Update an existing state"""
db_state = StateService.get_state_by_id(db, state_id)
if not db_state:
@@ -117,7 +118,7 @@ def delete_state(db: Session, state_id: int) -> bool:
)
@staticmethod
- def initialize_default_states(db: Session) -> List[State]:
+ def initialize_default_states(db: Session) -> list[State]:
"""Initialize default states if they don't exist"""
default_states = [
{"name": "New",
diff --git a/backend/app/services/workflow_service.py b/backend/app/services/workflow_service.py
new file mode 100644
index 0000000..50f8238
--- /dev/null
+++ b/backend/app/services/workflow_service.py
@@ -0,0 +1,157 @@
+import threading
+import uuid
+from pathlib import Path
+
+from SpiffWorkflow.bpmn.parser import BpmnParser
+from SpiffWorkflow.bpmn.workflow import BpmnWorkflow
+from SpiffWorkflow.task import Task, TaskState
+
+
+class WorkflowInstance:
+ def __init__(self, instance_id: str, name: str, workflow: BpmnWorkflow):
+ self.instance_id = instance_id
+ self.name = name
+ self.workflow = workflow
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.instance_id,
+ "name": self.name,
+ "status": self._status(),
+ "ready_user_tasks": [
+ {
+ "id": str(task.id),
+ "name": task.task_spec.name,
+ "spec_name": task.task_spec.name,
+ }
+ for task in WorkflowService.get_ready_user_tasks(self.workflow)
+ ],
+ }
+
+ def _status(self) -> str:
+ if self.workflow.is_completed():
+ return "COMPLETED"
+ # SpiffWorkflow 3.x uses American spelling
+ is_canceled = getattr(self.workflow, "is_canceled", None)
+ if callable(is_canceled) and is_canceled():
+ return "CANCELLED"
+ return "RUNNING"
+
+
+class WorkflowService:
+ _lock = threading.Lock()
+ _instances: dict[str, WorkflowInstance] = {}
+
+ @staticmethod
+ def _get_parser() -> BpmnParser:
+ # Always use a fresh parser so BPMN edits are picked up in dev
+ return BpmnParser()
+
+ @staticmethod
+ def _get_bpmn_path(filename: str) -> Path:
+ base = Path(__file__).resolve().parent.parent / "workflows"
+ return base / filename
+
+ @staticmethod
+ def load_spec(bpmn_filename: str, process_id: str):
+ parser = WorkflowService._get_parser()
+ path = WorkflowService._get_bpmn_path(bpmn_filename)
+ parser.add_bpmn_file(str(path))
+ return parser.get_spec(process_id)
+
+ @staticmethod
+ def start_workflow(
+ name: str,
+ bpmn_filename: str,
+ process_id: str,
+ ) -> dict:
+ spec = WorkflowService.load_spec(bpmn_filename, process_id)
+ workflow = BpmnWorkflow(spec)
+ # Initialize defaults so expressions have values
+ workflow.data["approved"] = False
+ workflow.do_engine_steps()
+ instance_id = str(uuid.uuid4())
+ instance = WorkflowInstance(instance_id, name, workflow)
+ with WorkflowService._lock:
+ WorkflowService._instances[instance_id] = instance
+ return instance.to_dict()
+
+ @staticmethod
+ def list_workflows() -> list[dict]:
+ with WorkflowService._lock:
+ return [
+ instance.to_dict()
+ for instance in WorkflowService._instances.values()
+ ]
+
+ @staticmethod
+ def get_instance(instance_id: str) -> WorkflowInstance:
+ with WorkflowService._lock:
+ instance = WorkflowService._instances.get(instance_id)
+ if instance is None:
+ raise KeyError("Workflow instance not found")
+ return instance
+
+ @staticmethod
+ def get_ready_user_tasks(workflow: BpmnWorkflow) -> list[Task]:
+ # Ensure waiting tasks are refreshed before we query states
+ if hasattr(workflow, "refresh_waiting_tasks"):
+ workflow.refresh_waiting_tasks()
+ if hasattr(workflow, "update_waiting_tasks"):
+ workflow.update_waiting_tasks()
+ # Include tasks that are waiting or ready for user action
+ tasks_ready: list[Task] = workflow.get_tasks(state=TaskState.READY)
+ tasks_waiting: list[Task] = workflow.get_tasks(state=TaskState.WAITING)
+ tasks: list[Task] = tasks_ready + [
+ t for t in tasks_waiting if t not in tasks_ready
+ ]
+ # Identify BPMN User Tasks by bpmn_name or class name heuristic
+
+ def is_user_task(t: Task) -> bool:
+ name = getattr(t.task_spec, "bpmn_name", "")
+ if isinstance(name, str) and name.lower() == "usertask":
+ return True
+ return "usertask" in t.task_spec.__class__.__name__.lower()
+ return [t for t in tasks if is_user_task(t)]
+
+ @staticmethod
+ def complete_user_task(instance_id: str, task_id: str, data: dict) -> dict:
+ instance = WorkflowService.get_instance(instance_id)
+ workflow = instance.workflow
+ # Locate task: prefer direct lookup by id, fallback to string match
+ task: Task | None = None
+ try:
+ task = workflow.get_task_from_id(int(task_id))
+ except Exception:
+ task = next(
+ (t for t in workflow.get_tasks() if str(t.id) == str(task_id)),
+ None,
+ )
+ if task is None:
+ raise KeyError("Task not found")
+ if task.state != TaskState.READY:
+ # Attempt to move the task to READY state, then re-check
+ try:
+ workflow.run_task_from_id(task.id)
+ # Some engines require refreshing waiting tasks
+ if hasattr(workflow, "refresh_waiting_tasks"):
+ workflow.refresh_waiting_tasks()
+ except Exception:
+ pass
+ # Re-fetch task and verify state
+ try:
+ task = workflow.get_task_from_id(task.id)
+ except Exception:
+ pass
+ if task.state != TaskState.READY:
+ raise ValueError("Task is not ready")
+ # Normalize approved flag to boolean for expression
+ normalized: dict = dict(data or {})
+ if "approved" in normalized:
+ normalized["approved"] = bool(normalized["approved"])
+ task.data.update(normalized)
+ # Ensure gateway conditions can see the variables
+ workflow.data.update(normalized)
+ task.complete()
+ workflow.do_engine_steps()
+ return instance.to_dict()
diff --git a/backend/app/workflows/simple_approval.bpmn b/backend/app/workflows/simple_approval.bpmn
new file mode 100644
index 0000000..ebae62a
--- /dev/null
+++ b/backend/app/workflows/simple_approval.bpmn
@@ -0,0 +1,73 @@
+
+
+
+
+ Flow_Start_Task
+
+
+ Flow_Start_Task
+ Flow_Task_GW
+
+
+ Flow_Task_GW
+ Flow_GW_Approved
+ Flow_GW_Rejected
+
+
+ Flow_GW_Approved
+
+
+ Flow_GW_Rejected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 57004d2..e7db10e 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -7,4 +7,5 @@ sqlalchemy==2.0.23
psycopg2-binary==2.9.9
alembic==1.12.1
pytest==7.4.2
-flake8==7.0.0
\ No newline at end of file
+flake8==7.0.0
+SpiffWorkflow==3.1.1
\ No newline at end of file
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf3..261136a 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+.eslintcache
\ No newline at end of file
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
index 8c82344..7e75c66 100644
--- a/frontend/eslint.config.mjs
+++ b/frontend/eslint.config.mjs
@@ -8,6 +8,7 @@ import prettier from 'eslint-plugin-prettier';
export default [
{
files: ['**/*.ts', '**/*.vue', '**/*.js'],
+ ignores:['**/node_modules/**', '**/dist/**', '**/build/**'],
languageOptions: {
parser: vueParser,
parserOptions: {
diff --git a/frontend/package.json b/frontend/package.json
index 1e79136..48d66b7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host",
- "lint": "eslint . --ext .ts,.vue,.js",
+ "lint": "eslint . --ext .ts,.vue,.js --cache",
"lint:fix": "eslint . --ext .ts,.vue,.js --fix",
"format": "prettier --write .",
"test": "vitest",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 8c9655e..a7ee05a 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,5 +1,5 @@
@@ -11,7 +11,12 @@ import HelloWorld from './components/HelloWorld.vue';
-
+
+
diff --git a/frontend/tests/HelloWorld.spec.ts b/frontend/tests/HelloWorld.spec.ts
index f4db323..048e74e 100644
--- a/frontend/tests/HelloWorld.spec.ts
+++ b/frontend/tests/HelloWorld.spec.ts
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/vue';
import { describe, it, expect } from 'vitest';
import HelloWorld from '../src/components/HelloWorld.vue';
+import '@testing-library/jest-dom';
describe('HelloWorld.vue', () => {
it('renders the correct message', () => {