@@ -13,12 +13,13 @@ use std::sync::atomic::{AtomicI64, Ordering};
1313use std:: sync:: { Arc , RwLock } ;
1414
1515use async_trait:: async_trait;
16+ use chrono:: Utc ;
1617use op_core:: traits:: Id ;
1718use op_core:: types:: Formattable ;
1819use serde:: Deserialize ;
1920use thiserror:: Error ;
2021
21- use crate :: work_package:: WorkPackage ;
22+ use crate :: work_package:: { DoneRatio , WorkPackage } ;
2223
2324/// Work package service errors.
2425#[ derive( Debug , Error ) ]
@@ -29,6 +30,15 @@ pub enum WorkPackageError {
2930 /// The write-model failed validation (e.g. blank subject).
3031 #[ error( "validation failed: {0}" ) ]
3132 Validation ( String ) ,
33+ /// Optimistic-lock conflict — the caller's expected `lock_version` is
34+ /// stale (someone else updated the row first).
35+ #[ error( "version conflict: expected {expected}, found {actual}" ) ]
36+ Conflict {
37+ /// The `lock_version` the caller believed was current.
38+ expected : i32 ,
39+ /// The `lock_version` actually stored.
40+ actual : i32 ,
41+ } ,
3242 /// The underlying store failed.
3343 #[ error( "database error: {0}" ) ]
3444 Database ( String ) ,
@@ -110,13 +120,75 @@ impl NewWorkPackage {
110120 }
111121}
112122
123+ /// Update write-model — a sparse patch of the mutable attributes. Each `Some`
124+ /// field is applied; `None` leaves the current value untouched.
125+ ///
126+ /// Un-setting a nullable FK back to `NULL` (e.g. clearing an assignee) is a
127+ /// deliberate follow-up — this shape only *sets* values, matching the common
128+ /// PATCH case; the `None`-means-"no change" convention can't also express
129+ /// "change to null" without an `Option<Option<_>>`, which lands later.
130+ #[ derive( Debug , Clone , Default , Deserialize ) ]
131+ pub struct UpdateWorkPackage {
132+ /// New subject (must stay non-blank — validated on apply).
133+ #[ serde( default ) ]
134+ pub subject : Option < String > ,
135+ /// New description (markdown source).
136+ #[ serde( default ) ]
137+ pub description : Option < String > ,
138+ /// New type FK.
139+ #[ serde( default ) ]
140+ pub type_id : Option < Id > ,
141+ /// New status FK.
142+ #[ serde( default ) ]
143+ pub status_id : Option < Id > ,
144+ /// New priority FK.
145+ #[ serde( default ) ]
146+ pub priority_id : Option < Id > ,
147+ /// New assignee FK.
148+ #[ serde( default ) ]
149+ pub assigned_to_id : Option < Id > ,
150+ /// New progress `0..=100` (clamped).
151+ #[ serde( default ) ]
152+ pub done_ratio : Option < u8 > ,
153+ }
154+
155+ impl UpdateWorkPackage {
156+ /// Apply the set (`Some`) fields onto `wp`, leaving `None` fields as-is.
157+ /// Does not touch `lock_version` / timestamps — the service owns those.
158+ fn apply_to ( & self , wp : & mut WorkPackage ) {
159+ if let Some ( subject) = & self . subject {
160+ wp. subject = subject. clone ( ) ;
161+ }
162+ if let Some ( body) = & self . description {
163+ wp. description = Formattable :: markdown ( body. clone ( ) ) ;
164+ }
165+ if let Some ( type_id) = self . type_id {
166+ wp. type_id = type_id;
167+ }
168+ if let Some ( status_id) = self . status_id {
169+ wp. status_id = status_id;
170+ }
171+ if let Some ( priority_id) = self . priority_id {
172+ wp. priority_id = Some ( priority_id) ;
173+ }
174+ if let Some ( assigned_to_id) = self . assigned_to_id {
175+ wp. assigned_to_id = Some ( assigned_to_id) ;
176+ }
177+ if let Some ( done_ratio) = self . done_ratio {
178+ wp. done_ratio = DoneRatio :: new ( done_ratio) ;
179+ }
180+ }
181+ }
182+
113183/// Persistence port for work packages (mirrors OpenProject's repository).
114184#[ async_trait]
115185pub trait WorkPackageStore : Send + Sync {
116186 /// Insert a new work package; returns the assigned id.
117187 async fn insert ( & self , wp : & WorkPackage ) -> WorkPackageResult < Id > ;
118188 /// Fetch by id.
119189 async fn get ( & self , id : Id ) -> WorkPackageResult < Option < WorkPackage > > ;
190+ /// Persist changes to an existing work package (matched by `wp.id`).
191+ async fn update ( & self , wp : & WorkPackage ) -> WorkPackageResult < ( ) > ;
120192 /// All work packages in a project.
121193 async fn list_for_project ( & self , project_id : Id ) -> WorkPackageResult < Vec < WorkPackage > > ;
122194}
@@ -165,6 +237,43 @@ impl WorkPackageService {
165237 . ok_or ( WorkPackageError :: NotFound ( id) )
166238 }
167239
240+ /// Apply a sparse update with **optimistic locking**.
241+ /// `expected_lock_version` must equal the stored row's `lock_version`,
242+ /// otherwise [`WorkPackageError::Conflict`] (someone else wrote first). On
243+ /// success the patch is applied, `lock_version` is bumped, `updated_at`
244+ /// refreshed, and the row persisted; the updated entity is returned.
245+ ///
246+ /// # Errors
247+ ///
248+ /// [`WorkPackageError::NotFound`] if `id` is absent;
249+ /// [`WorkPackageError::Conflict`] on a stale `expected_lock_version`;
250+ /// [`WorkPackageError::Validation`] if the patch would blank the subject;
251+ /// [`WorkPackageError::Database`] on store failure.
252+ pub async fn update (
253+ & self ,
254+ id : Id ,
255+ expected_lock_version : i32 ,
256+ changes : UpdateWorkPackage ,
257+ ) -> WorkPackageResult < WorkPackage > {
258+ let mut wp = self . find ( id) . await ?;
259+ if wp. lock_version != expected_lock_version {
260+ return Err ( WorkPackageError :: Conflict {
261+ expected : expected_lock_version,
262+ actual : wp. lock_version ,
263+ } ) ;
264+ }
265+ changes. apply_to ( & mut wp) ;
266+ if wp. subject . trim ( ) . is_empty ( ) {
267+ return Err ( WorkPackageError :: Validation (
268+ "subject can't be blank" . to_string ( ) ,
269+ ) ) ;
270+ }
271+ wp. lock_version += 1 ;
272+ wp. updated_at = Utc :: now ( ) ;
273+ self . store . update ( & wp) . await ?;
274+ Ok ( wp)
275+ }
276+
168277 /// All work packages in a project.
169278 ///
170279 /// # Errors
@@ -218,6 +327,19 @@ impl WorkPackageStore for MemoryWorkPackageStore {
218327 Ok ( rows. iter ( ) . find ( |wp| wp. id == Some ( id) ) . cloned ( ) )
219328 }
220329
330+ async fn update ( & self , wp : & WorkPackage ) -> WorkPackageResult < ( ) > {
331+ let mut rows = self
332+ . rows
333+ . write ( )
334+ . map_err ( |e| WorkPackageError :: Database ( e. to_string ( ) ) ) ?;
335+ let slot = rows
336+ . iter_mut ( )
337+ . find ( |existing| existing. id == wp. id )
338+ . ok_or ( WorkPackageError :: NotFound ( wp. id . unwrap_or ( 0 ) ) ) ?;
339+ * slot = wp. clone ( ) ;
340+ Ok ( ( ) )
341+ }
342+
221343 async fn list_for_project ( & self , project_id : Id ) -> WorkPackageResult < Vec < WorkPackage > > {
222344 let rows = self
223345 . rows
@@ -321,4 +443,95 @@ mod tests {
321443 assert ! ( wp. id. is_none( ) ) ;
322444 assert_eq ! ( wp. status_id, 3 ) ;
323445 }
446+
447+ #[ tokio:: test]
448+ async fn update_applies_changes_and_bumps_lock_version ( ) {
449+ let svc = service ( ) ;
450+ let created = svc. create ( input ( ) ) . await . unwrap ( ) ;
451+ let id = created. id . unwrap ( ) ;
452+ assert_eq ! ( created. lock_version, 0 ) ;
453+
454+ let changes = UpdateWorkPackage {
455+ subject : Some ( "Renamed" . to_string ( ) ) ,
456+ status_id : Some ( 9 ) ,
457+ done_ratio : Some ( 150 ) , // clamps to 100
458+ ..Default :: default ( )
459+ } ;
460+ let updated = svc. update ( id, 0 , changes) . await . unwrap ( ) ;
461+ assert_eq ! ( updated. subject, "Renamed" ) ;
462+ assert_eq ! ( updated. status_id, 9 ) ;
463+ assert_eq ! ( updated. done_ratio. value( ) , 100 ) ;
464+ assert_eq ! ( updated. lock_version, 1 , "lock_version bumps on update" ) ;
465+ // Persisted: a re-read sees the new state + version.
466+ let reread = svc. find ( id) . await . unwrap ( ) ;
467+ assert_eq ! ( reread. subject, "Renamed" ) ;
468+ assert_eq ! ( reread. lock_version, 1 ) ;
469+ }
470+
471+ #[ tokio:: test]
472+ async fn update_rejects_stale_lock_version_as_conflict ( ) {
473+ let svc = service ( ) ;
474+ let id = svc. create ( input ( ) ) . await . unwrap ( ) . id . unwrap ( ) ;
475+ // First update at version 0 → now version 1.
476+ svc. update (
477+ id,
478+ 0 ,
479+ UpdateWorkPackage {
480+ subject : Some ( "v1" . into ( ) ) ,
481+ ..Default :: default ( )
482+ } ,
483+ )
484+ . await
485+ . unwrap ( ) ;
486+ // Second update still claiming version 0 → conflict (actual is 1).
487+ let err = svc
488+ . update (
489+ id,
490+ 0 ,
491+ UpdateWorkPackage {
492+ subject : Some ( "v2" . into ( ) ) ,
493+ ..Default :: default ( )
494+ } ,
495+ )
496+ . await
497+ . unwrap_err ( ) ;
498+ assert ! (
499+ matches!(
500+ err,
501+ WorkPackageError :: Conflict {
502+ expected: 0 ,
503+ actual: 1
504+ }
505+ ) ,
506+ "stale version → Conflict, got {err:?}"
507+ ) ;
508+ }
509+
510+ #[ tokio:: test]
511+ async fn update_unknown_id_is_not_found ( ) {
512+ let svc = service ( ) ;
513+ let err = svc
514+ . update ( 999 , 0 , UpdateWorkPackage :: default ( ) )
515+ . await
516+ . unwrap_err ( ) ;
517+ assert ! ( matches!( err, WorkPackageError :: NotFound ( 999 ) ) , "{err:?}" ) ;
518+ }
519+
520+ #[ tokio:: test]
521+ async fn update_rejects_blanking_the_subject ( ) {
522+ let svc = service ( ) ;
523+ let id = svc. create ( input ( ) ) . await . unwrap ( ) . id . unwrap ( ) ;
524+ let err = svc
525+ . update (
526+ id,
527+ 0 ,
528+ UpdateWorkPackage {
529+ subject : Some ( " " . into ( ) ) ,
530+ ..Default :: default ( )
531+ } ,
532+ )
533+ . await
534+ . unwrap_err ( ) ;
535+ assert ! ( matches!( err, WorkPackageError :: Validation ( _) ) , "{err:?}" ) ;
536+ }
324537}
0 commit comments