Skip to content

Commit 832004a

Browse files
authored
Merge pull request #66 from AdaWorldAPI/claude/beautiful-gates-dJo0u
feat(op-work-packages): write-model + CreateService over a store
2 parents d716204 + 5e9bba5 commit 832004a

4 files changed

Lines changed: 335 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/op-work-packages/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ op-core = { path = "../op-core" }
99

1010
serde.workspace = true
1111
chrono.workspace = true
12+
async-trait.workspace = true
13+
thiserror.workspace = true
1214

1315
[dev-dependencies]
1416
serde_json.workspace = true
17+
tokio.workspace = true

crates/op-work-packages/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,10 @@
99
//! concept `project_work_item` (codebook `0x0102`); see `op-canon`.
1010
1111
pub mod work_package;
12+
pub mod work_package_service;
1213

1314
pub use work_package::{DoneRatio, WorkPackage};
15+
pub use work_package_service::{
16+
MemoryWorkPackageStore, NewWorkPackage, WorkPackageError, WorkPackageResult,
17+
WorkPackageService, WorkPackageStore,
18+
};
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
//! Work package service
2+
//!
3+
//! Mirrors: `app/services/work_packages/create_service.rb` (+ the set-attributes
4+
//! write path). A thin `CreateService` over a pluggable [`WorkPackageStore`]:
5+
//! deserialize a [`NewWorkPackage`] write-model, validate, build the domain
6+
//! [`WorkPackage`], persist, return it with its assigned id.
7+
//!
8+
//! Structure follows the `op-journals` house pattern: an error enum +
9+
//! `Result` alias, an async `Store` trait, the service, and an in-memory
10+
//! store for tests.
11+
12+
use std::sync::atomic::{AtomicI64, Ordering};
13+
use std::sync::{Arc, RwLock};
14+
15+
use async_trait::async_trait;
16+
use op_core::traits::Id;
17+
use op_core::types::Formattable;
18+
use serde::Deserialize;
19+
use thiserror::Error;
20+
21+
use crate::work_package::WorkPackage;
22+
23+
/// Work package service errors.
24+
#[derive(Debug, Error)]
25+
pub enum WorkPackageError {
26+
/// No work package with the given id.
27+
#[error("work package not found: {0}")]
28+
NotFound(Id),
29+
/// The write-model failed validation (e.g. blank subject).
30+
#[error("validation failed: {0}")]
31+
Validation(String),
32+
/// The underlying store failed.
33+
#[error("database error: {0}")]
34+
Database(String),
35+
}
36+
37+
/// Result alias for the service surface.
38+
pub type WorkPackageResult<T> = Result<T, WorkPackageError>;
39+
40+
/// Create write-model — the input DTO for `POST /work_packages`.
41+
///
42+
/// Only the NOT NULL columns are required (`subject`, `project_id`,
43+
/// `type_id`, `status_id`, `author_id`); everything else is optional,
44+
/// matching the persisted shape (`op-db` carries the rest as `Option`).
45+
/// Deserializable so an API layer can parse a request body straight into it.
46+
#[derive(Debug, Clone, Deserialize)]
47+
pub struct NewWorkPackage {
48+
/// One-line headline. Required (non-blank — see [`WorkPackageService::create`]).
49+
pub subject: String,
50+
/// Owning project FK.
51+
pub project_id: Id,
52+
/// Work-package type FK.
53+
pub type_id: Id,
54+
/// Status FK.
55+
pub status_id: Id,
56+
/// Author FK.
57+
pub author_id: Id,
58+
/// Priority FK (optional — nullable column).
59+
#[serde(default)]
60+
pub priority_id: Option<Id>,
61+
/// Assignee FK (optional).
62+
#[serde(default)]
63+
pub assigned_to_id: Option<Id>,
64+
/// Parent WP FK for the hierarchy (optional).
65+
#[serde(default)]
66+
pub parent_id: Option<Id>,
67+
/// Description body as markdown source (optional).
68+
#[serde(default)]
69+
pub description: Option<String>,
70+
}
71+
72+
impl NewWorkPackage {
73+
/// The minimal required create input; optionals default to `None`.
74+
pub fn new(
75+
subject: impl Into<String>,
76+
project_id: Id,
77+
type_id: Id,
78+
status_id: Id,
79+
author_id: Id,
80+
) -> Self {
81+
Self {
82+
subject: subject.into(),
83+
project_id,
84+
type_id,
85+
status_id,
86+
author_id,
87+
priority_id: None,
88+
assigned_to_id: None,
89+
parent_id: None,
90+
description: None,
91+
}
92+
}
93+
94+
/// Map the write-model onto a fresh (unpersisted) domain entity.
95+
pub fn to_work_package(&self) -> WorkPackage {
96+
let mut wp = WorkPackage::new(
97+
self.subject.clone(),
98+
self.project_id,
99+
self.type_id,
100+
self.status_id,
101+
self.author_id,
102+
);
103+
wp.priority_id = self.priority_id;
104+
wp.assigned_to_id = self.assigned_to_id;
105+
wp.parent_id = self.parent_id;
106+
if let Some(body) = &self.description {
107+
wp.description = Formattable::markdown(body.clone());
108+
}
109+
wp
110+
}
111+
}
112+
113+
/// Persistence port for work packages (mirrors OpenProject's repository).
114+
#[async_trait]
115+
pub trait WorkPackageStore: Send + Sync {
116+
/// Insert a new work package; returns the assigned id.
117+
async fn insert(&self, wp: &WorkPackage) -> WorkPackageResult<Id>;
118+
/// Fetch by id.
119+
async fn get(&self, id: Id) -> WorkPackageResult<Option<WorkPackage>>;
120+
/// All work packages in a project.
121+
async fn list_for_project(&self, project_id: Id) -> WorkPackageResult<Vec<WorkPackage>>;
122+
}
123+
124+
/// Create/read service over a [`WorkPackageStore`].
125+
pub struct WorkPackageService {
126+
store: Arc<dyn WorkPackageStore>,
127+
}
128+
129+
impl WorkPackageService {
130+
/// Build the service over a store.
131+
pub fn new(store: Arc<dyn WorkPackageStore>) -> Self {
132+
Self { store }
133+
}
134+
135+
/// Create a work package from the write-model: validate, build, persist.
136+
/// Returns the persisted entity with its assigned id.
137+
///
138+
/// # Errors
139+
///
140+
/// [`WorkPackageError::Validation`] if `subject` is blank;
141+
/// [`WorkPackageError::Database`] if the store fails.
142+
pub async fn create(&self, input: NewWorkPackage) -> WorkPackageResult<WorkPackage> {
143+
if input.subject.trim().is_empty() {
144+
return Err(WorkPackageError::Validation(
145+
"subject can't be blank".to_string(),
146+
));
147+
}
148+
let wp = input.to_work_package();
149+
let id = self.store.insert(&wp).await?;
150+
let mut created = wp;
151+
created.id = Some(id);
152+
Ok(created)
153+
}
154+
155+
/// Fetch a work package by id, erroring [`WorkPackageError::NotFound`] if
156+
/// absent.
157+
///
158+
/// # Errors
159+
///
160+
/// [`WorkPackageError::NotFound`] / [`WorkPackageError::Database`].
161+
pub async fn find(&self, id: Id) -> WorkPackageResult<WorkPackage> {
162+
self.store
163+
.get(id)
164+
.await?
165+
.ok_or(WorkPackageError::NotFound(id))
166+
}
167+
168+
/// All work packages in a project.
169+
///
170+
/// # Errors
171+
///
172+
/// [`WorkPackageError::Database`] on store failure.
173+
pub async fn list_for_project(&self, project_id: Id) -> WorkPackageResult<Vec<WorkPackage>> {
174+
self.store.list_for_project(project_id).await
175+
}
176+
}
177+
178+
/// In-memory [`WorkPackageStore`] for tests and prototyping.
179+
pub struct MemoryWorkPackageStore {
180+
rows: RwLock<Vec<WorkPackage>>,
181+
next_id: AtomicI64,
182+
}
183+
184+
impl Default for MemoryWorkPackageStore {
185+
fn default() -> Self {
186+
Self::new()
187+
}
188+
}
189+
190+
impl MemoryWorkPackageStore {
191+
/// Empty store, ids starting at 1.
192+
pub fn new() -> Self {
193+
Self {
194+
rows: RwLock::new(Vec::new()),
195+
next_id: AtomicI64::new(1),
196+
}
197+
}
198+
}
199+
200+
#[async_trait]
201+
impl WorkPackageStore for MemoryWorkPackageStore {
202+
async fn insert(&self, wp: &WorkPackage) -> WorkPackageResult<Id> {
203+
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
204+
let mut stored = wp.clone();
205+
stored.id = Some(id);
206+
self.rows
207+
.write()
208+
.map_err(|e| WorkPackageError::Database(e.to_string()))?
209+
.push(stored);
210+
Ok(id)
211+
}
212+
213+
async fn get(&self, id: Id) -> WorkPackageResult<Option<WorkPackage>> {
214+
let rows = self
215+
.rows
216+
.read()
217+
.map_err(|e| WorkPackageError::Database(e.to_string()))?;
218+
Ok(rows.iter().find(|wp| wp.id == Some(id)).cloned())
219+
}
220+
221+
async fn list_for_project(&self, project_id: Id) -> WorkPackageResult<Vec<WorkPackage>> {
222+
let rows = self
223+
.rows
224+
.read()
225+
.map_err(|e| WorkPackageError::Database(e.to_string()))?;
226+
Ok(rows
227+
.iter()
228+
.filter(|wp| wp.project_id == project_id)
229+
.cloned()
230+
.collect())
231+
}
232+
}
233+
234+
#[cfg(test)]
235+
mod tests {
236+
use super::*;
237+
238+
fn service() -> WorkPackageService {
239+
WorkPackageService::new(Arc::new(MemoryWorkPackageStore::new()))
240+
}
241+
242+
// subject, project, type, status, author
243+
fn input() -> NewWorkPackage {
244+
NewWorkPackage::new("Fix the thing", 1, 2, 3, 10)
245+
}
246+
247+
#[tokio::test]
248+
async fn create_persists_and_assigns_id() {
249+
let svc = service();
250+
let created = svc.create(input()).await.unwrap();
251+
assert!(created.id.is_some(), "persisted row gets an id");
252+
assert_eq!(created.subject, "Fix the thing");
253+
// Round-trips through the store.
254+
let found = svc.find(created.id.unwrap()).await.unwrap();
255+
assert_eq!(found.subject, "Fix the thing");
256+
assert_eq!(found.project_id, 1);
257+
}
258+
259+
#[tokio::test]
260+
async fn create_rejects_blank_subject() {
261+
let svc = service();
262+
let mut bad = input();
263+
bad.subject = " ".to_string();
264+
let err = svc.create(bad).await.unwrap_err();
265+
assert!(
266+
matches!(err, WorkPackageError::Validation(_)),
267+
"blank subject → Validation, got {err:?}"
268+
);
269+
}
270+
271+
#[tokio::test]
272+
async fn create_maps_optional_write_model_fields() {
273+
let svc = service();
274+
let mut inp = input();
275+
inp.priority_id = Some(5);
276+
inp.assigned_to_id = Some(20);
277+
inp.parent_id = Some(7);
278+
inp.description = Some("**body**".to_string());
279+
let wp = svc.create(inp).await.unwrap();
280+
assert_eq!(wp.priority_id, Some(5));
281+
assert_eq!(wp.assigned_to_id, Some(20));
282+
assert_eq!(wp.parent_id, Some(7));
283+
assert_eq!(wp.description.raw, "**body**");
284+
}
285+
286+
#[tokio::test]
287+
async fn find_unknown_id_is_not_found() {
288+
let svc = service();
289+
let err = svc.find(999).await.unwrap_err();
290+
assert!(matches!(err, WorkPackageError::NotFound(999)), "{err:?}");
291+
}
292+
293+
#[tokio::test]
294+
async fn list_for_project_filters_by_project() {
295+
let svc = service();
296+
// Two in project 1, one in project 2.
297+
svc.create(NewWorkPackage::new("a", 1, 2, 3, 10))
298+
.await
299+
.unwrap();
300+
svc.create(NewWorkPackage::new("b", 1, 2, 3, 10))
301+
.await
302+
.unwrap();
303+
svc.create(NewWorkPackage::new("c", 2, 2, 3, 10))
304+
.await
305+
.unwrap();
306+
assert_eq!(svc.list_for_project(1).await.unwrap().len(), 2);
307+
assert_eq!(svc.list_for_project(2).await.unwrap().len(), 1);
308+
assert_eq!(svc.list_for_project(99).await.unwrap().len(), 0);
309+
}
310+
311+
#[test]
312+
fn write_model_deserializes_with_optional_fields_defaulting() {
313+
// An API body with only the required fields parses; optionals default.
314+
let json = r#"{"subject":"S","project_id":1,"type_id":2,"status_id":3,"author_id":10}"#;
315+
let nwp: NewWorkPackage = serde_json::from_str(json).unwrap();
316+
assert_eq!(nwp.subject, "S");
317+
assert!(nwp.priority_id.is_none());
318+
assert!(nwp.description.is_none());
319+
// And it maps to a fresh, unpersisted entity.
320+
let wp = nwp.to_work_package();
321+
assert!(wp.id.is_none());
322+
assert_eq!(wp.status_id, 3);
323+
}
324+
}

0 commit comments

Comments
 (0)