1use chrono::Utc;
12use serde::Serialize;
13use serde_json::json;
14use sqlx::{PgConnection, PgPool};
15use uuid::Uuid;
16
17use crate::{
18 auth::Actor,
19 domain::{self, ItemType, ProjectVisibility, Role},
20 error::{AppError, AppResult},
21 models::{Project, ProjectMember},
22 services::{access, audit, users},
23};
24
25const COLUMNS: &str = "id, code, name, description, active, created_at, oidc_group, created_by,
30 number_scheme, visibility,
31 (SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = projects.id) AS member_count";
32
33pub async fn list(pool: &PgPool, actor: &Actor, only_active: bool) -> AppResult<Vec<Project>> {
36 Ok(sqlx::query_as::<_, Project>(&format!(
37 "SELECT {COLUMNS} FROM projects WHERE (active OR NOT $1) AND {}
38 ORDER BY active DESC, name",
39 access::project_visible("projects.id", 2)
40 ))
41 .bind(only_active)
42 .bind(&actor.hidden_projects)
43 .fetch_all(pool)
44 .await?)
45}
46
47pub async fn list_managed(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Project>> {
51 if !actor.is_admin() {
52 return list(pool, actor, false).await;
53 }
54 Ok(sqlx::query_as::<_, Project>(&format!(
55 "SELECT {COLUMNS} FROM projects ORDER BY active DESC, name"
56 ))
57 .fetch_all(pool)
58 .await?)
59}
60
61pub async fn restricted_memberships(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Uuid>> {
63 Ok(sqlx::query_scalar::<_, Uuid>(
64 "SELECT id FROM projects WHERE visibility = 'restricted' AND id = ANY($1) ORDER BY code",
65 )
66 .bind(&actor.projects)
67 .fetch_all(pool)
68 .await?)
69}
70
71pub async fn editable_by(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Project>> {
73 Ok(list(pool, actor, true)
74 .await?
75 .into_iter()
76 .filter(|p| actor.can_edit_project(Some(p.id)))
77 .collect())
78}
79
80pub async fn memberships_of(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<Project>> {
82 Ok(sqlx::query_as::<_, Project>(&format!(
83 "SELECT {COLUMNS} FROM projects
84 WHERE id IN (SELECT project_id FROM project_members WHERE user_id = $1)
85 ORDER BY name"
86 ))
87 .bind(user_id)
88 .fetch_all(pool)
89 .await?)
90}
91
92pub(crate) async fn get_unchecked(pool: &PgPool, id: Uuid) -> AppResult<Project> {
93 sqlx::query_as::<_, Project>(&format!("SELECT {COLUMNS} FROM projects WHERE id = $1"))
94 .bind(id)
95 .fetch_optional(pool)
96 .await?
97 .ok_or_else(access::not_found_project)
98}
99
100pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Project> {
103 let project = get_unchecked(pool, id).await?;
104 access::ensure_visible(actor, Some(project.id), access::not_found_project())?;
105 Ok(project)
106}
107
108pub async fn get_for_management(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Project> {
112 if actor.is_admin() {
113 return get_unchecked(pool, id).await;
114 }
115 get(pool, actor, id).await
116}
117
118pub async fn get_by_code_for_management(
120 pool: &PgPool,
121 actor: &Actor,
122 code: &str,
123) -> AppResult<Project> {
124 if actor.is_admin() {
125 return get_by_code_unchecked(pool, code).await;
126 }
127 get_by_code(pool, actor, code).await
128}
129
130pub(crate) async fn get_by_code_unchecked(pool: &PgPool, code: &str) -> AppResult<Project> {
131 let code = domain::normalize_project_code(code)?;
132 sqlx::query_as::<_, Project>(&format!("SELECT {COLUMNS} FROM projects WHERE code = $1"))
133 .bind(&code)
134 .fetch_optional(pool)
135 .await?
136 .ok_or_else(|| AppError::not_found(format!("Project '{code}' not found.")))
137}
138
139pub async fn get_by_code(pool: &PgPool, actor: &Actor, code: &str) -> AppResult<Project> {
142 let project = get_by_code_unchecked(pool, code).await?;
143 access::ensure_visible(
144 actor,
145 Some(project.id),
146 AppError::not_found(format!("Project '{}' not found.", project.code)),
147 )?;
148 Ok(project)
149}
150
151pub async fn set_visibility(
166 pool: &PgPool,
167 actor: &Actor,
168 id: Uuid,
169 visibility: ProjectVisibility,
170) -> AppResult<Project> {
171 let mut tx = pool.begin().await?;
172 let current = sqlx::query_as::<_, Project>(&format!(
173 "SELECT {COLUMNS} FROM projects WHERE id = $1 FOR UPDATE"
174 ))
175 .bind(id)
176 .fetch_optional(&mut *tx)
177 .await?
178 .ok_or_else(access::not_found_project)?;
179 require_manager(&mut tx, actor, id).await?;
180 if current.visibility == visibility.as_str() {
181 tx.rollback().await?;
182 return Ok(current);
183 }
184 if visibility == ProjectVisibility::Internal {
185 super::project_policy::require_member_manager_under_policy(
186 &mut tx,
187 actor,
188 id,
189 "Opening the group to all users",
190 )
191 .await?;
192 }
193 let project = sqlx::query_as::<_, Project>(&format!(
194 "UPDATE projects SET visibility = $2 WHERE id = $1 RETURNING {COLUMNS}"
195 ))
196 .bind(id)
197 .bind(visibility.as_str())
198 .fetch_one(&mut *tx)
199 .await?;
200 if visibility == ProjectVisibility::Internal {
201 let prefix = private_prefix(id);
204 sqlx::query(
205 "INSERT INTO counters (name, value)
206 SELECT 'num:' || substr(name, length($1) + 1), value FROM counters
207 WHERE left(name, length($1)) = $1
208 ON CONFLICT (name) DO UPDATE SET value = GREATEST(counters.value, EXCLUDED.value)",
209 )
210 .bind(&prefix)
211 .execute(&mut *tx)
212 .await?;
213 sqlx::query("DELETE FROM counters WHERE left(name, length($1)) = $1")
214 .bind(&prefix)
215 .execute(&mut *tx)
216 .await?;
217 }
218 let summary = match visibility {
219 ProjectVisibility::Restricted => format!("Restricted group {}", project.code),
220 ProjectVisibility::Internal => format!("Opened group {} to all users", project.code),
221 };
222 audit::record(
223 &mut tx,
224 Some(actor),
225 "project.visibility",
226 "project",
227 Some(id),
228 summary,
229 json!({ "visibility": visibility.as_str() }),
230 )
231 .await?;
232 tx.commit().await?;
233 Ok(project)
234}
235
236fn clean_group(group: Option<&str>) -> AppResult<Option<String>> {
237 match group.map(str::trim).filter(|g| !g.is_empty()) {
238 None => Ok(None),
239 Some(g) => Ok(Some(domain::limit_text("IdP group", g, 200)?)),
240 }
241}
242
243pub async fn can_manage(
246 conn: &mut PgConnection,
247 actor: &Actor,
248 project_id: Uuid,
249) -> AppResult<bool> {
250 if actor.is_admin() {
251 return Ok(true);
252 }
253 if !actor.can_edit() {
254 return Ok(false);
255 }
256 Ok(sqlx::query_scalar::<_, bool>(
257 "SELECT EXISTS (SELECT 1 FROM project_members
258 WHERE project_id = $1 AND user_id = $2 AND manager)",
259 )
260 .bind(project_id)
261 .bind(actor.id)
262 .fetch_one(&mut *conn)
263 .await?)
264}
265
266pub async fn is_manager(pool: &PgPool, actor: &Actor, project_id: Uuid) -> AppResult<bool> {
268 let mut conn = pool.acquire().await?;
269 can_manage(&mut conn, actor, project_id).await
270}
271
272pub(crate) async fn require_manager(
273 conn: &mut PgConnection,
274 actor: &Actor,
275 project_id: Uuid,
276) -> AppResult<()> {
277 if !actor.is_admin() {
281 access::ensure_visible(actor, Some(project_id), access::not_found_project())?;
282 }
283 if can_manage(conn, actor, project_id).await? {
284 Ok(())
285 } else {
286 Err(AppError::Forbidden(
287 "Only the managers of this project group (and administrators) can change it.".into(),
288 ))
289 }
290}
291
292pub async fn create(
306 pool: &PgPool,
307 actor: &Actor,
308 code: &str,
309 name: &str,
310 description: &str,
311 oidc_group: Option<&str>,
312 visibility: ProjectVisibility,
313) -> AppResult<Project> {
314 actor.require(Role::Editor)?;
315 let code = domain::normalize_project_code(code)?;
316 let name = domain::require_text("Project name", name, 200)?;
317 let description = domain::limit_text("Description", description, 4000)?;
318 let oidc_group = clean_group(oidc_group)?;
319 if oidc_group.is_some() && !actor.is_admin() {
320 return Err(AppError::Forbidden(
321 "Only administrators link project groups to groups of the identity provider.".into(),
322 ));
323 }
324 let mut tx = pool.begin().await?;
325 let id = Uuid::new_v4();
326 sqlx::query(
327 "INSERT INTO projects (id, code, name, description, oidc_group, created_by, visibility)
328 VALUES ($1, $2, $3, $4, $5, $6, $7)",
329 )
330 .bind(id)
331 .bind(&code)
332 .bind(&name)
333 .bind(&description)
334 .bind(&oidc_group)
335 .bind(actor.id)
336 .bind(visibility.as_str())
337 .execute(&mut *tx)
338 .await
339 .map_err(|e| {
340 AppError::unique_violation(e, format!("A project with code '{code}' already exists."))
341 })?;
342 if !actor.is_admin() {
345 sqlx::query(
346 "INSERT INTO project_members (project_id, user_id, source, manager)
347 VALUES ($1, $2, 'manual', true)",
348 )
349 .bind(id)
350 .bind(actor.id)
351 .execute(&mut *tx)
352 .await?;
353 }
354 audit::record(
355 &mut tx,
356 Some(actor),
357 "project.create",
358 "project",
359 Some(id),
360 format!("Created project {code} – {name}"),
361 json!({ "oidc_group": oidc_group, "visibility": visibility.as_str() }),
362 )
363 .await?;
364 let project =
365 sqlx::query_as::<_, Project>(&format!("SELECT {COLUMNS} FROM projects WHERE id = $1"))
366 .bind(id)
367 .fetch_one(&mut *tx)
368 .await?;
369 tx.commit().await?;
370 Ok(project)
371}
372
373pub struct ProjectUpdate<'a> {
374 pub name: &'a str,
375 pub description: &'a str,
376 pub active: bool,
377 pub oidc_group: Option<&'a str>,
379}
380
381pub async fn update(
394 pool: &PgPool,
395 actor: &Actor,
396 id: Uuid,
397 upd: ProjectUpdate<'_>,
398) -> AppResult<Project> {
399 let name = domain::require_text("Project name", upd.name, 200)?;
400 let description = domain::limit_text("Description", upd.description, 4000)?;
401 let mut tx = pool.begin().await?;
402 let current = sqlx::query_as::<_, Project>(&format!(
403 "SELECT {COLUMNS} FROM projects WHERE id = $1 FOR UPDATE"
404 ))
405 .bind(id)
406 .fetch_optional(&mut *tx)
407 .await?
408 .ok_or_else(|| AppError::not_found("Project not found."))?;
409 require_manager(&mut tx, actor, id).await?;
410 let oidc_group = if actor.is_admin() {
411 let wanted = clean_group(upd.oidc_group)?;
412 if wanted != current.oidc_group {
413 super::project_policy::require_member_manager_under_policy(
414 &mut tx,
415 actor,
416 id,
417 "Linking another identity-provider group",
418 )
419 .await?;
420 }
421 wanted
422 } else {
423 current.oidc_group.clone()
424 };
425 let project = sqlx::query_as::<_, Project>(&format!(
426 "UPDATE projects SET name = $2, description = $3, active = $4, oidc_group = $5
427 WHERE id = $1 RETURNING {COLUMNS}"
428 ))
429 .bind(id)
430 .bind(&name)
431 .bind(&description)
432 .bind(upd.active)
433 .bind(&oidc_group)
434 .fetch_one(&mut *tx)
435 .await?;
436 audit::record(
437 &mut tx,
438 Some(actor),
439 "project.update",
440 "project",
441 Some(id),
442 format!("Updated project {}", project.code),
443 json!({ "active": upd.active, "oidc_group": oidc_group }),
444 )
445 .await?;
446 tx.commit().await?;
447 Ok(project)
448}
449
450pub async fn set_number_scheme(
463 pool: &PgPool,
464 actor: &Actor,
465 id: Uuid,
466 raw: &str,
467) -> AppResult<Project> {
468 let scheme = match raw.trim() {
469 "" => None,
470 s => Some(domain::NumberScheme::parse(s)?.as_string()),
471 };
472 let mut tx = pool.begin().await?;
473 let current = sqlx::query_as::<_, Project>(&format!(
474 "SELECT {COLUMNS} FROM projects WHERE id = $1 FOR UPDATE"
475 ))
476 .bind(id)
477 .fetch_optional(&mut *tx)
478 .await?
479 .ok_or_else(|| AppError::not_found("Project not found."))?;
480 require_manager(&mut tx, actor, id).await?;
481 let project = sqlx::query_as::<_, Project>(&format!(
482 "UPDATE projects SET number_scheme = $2 WHERE id = $1 RETURNING {COLUMNS}"
483 ))
484 .bind(id)
485 .bind(&scheme)
486 .fetch_one(&mut *tx)
487 .await?;
488 audit::record(
489 &mut tx,
490 Some(actor),
491 "project.numbering",
492 "project",
493 Some(id),
494 format!(
495 "Numbering scheme of project {}: {}",
496 project.code,
497 scheme.as_deref().unwrap_or(domain::DEFAULT_NUMBER_SCHEME)
498 ),
499 json!({ "before": current.number_scheme, "after": scheme }),
500 )
501 .await?;
502 tx.commit().await?;
503 Ok(project)
504}
505
506#[derive(Clone, Debug, Serialize)]
508pub struct NumberingPreview {
509 pub scheme: String,
511 pub is_default: bool,
513 pub next: Vec<NextNumber>,
515}
516
517#[derive(Clone, Debug, Serialize)]
518pub struct NextNumber {
519 pub item_type: ItemType,
520 pub label: &'static str,
521 pub number: String,
522}
523
524fn private_prefix(project_id: Uuid) -> String {
526 format!("num@{project_id}:")
527}
528
529pub(crate) fn number_counter(
542 project_id: Uuid,
543 visibility: &str,
544 scheme: &domain::NumberScheme,
545 ctx: &domain::NumberContext<'_>,
546) -> (String, Option<String>) {
547 let shared = scheme.counter_key(ctx);
548 if visibility == ProjectVisibility::Restricted.as_str()
549 && *scheme != domain::NumberScheme::default_scheme()
550 {
551 let rest = shared.strip_prefix("num:").unwrap_or(&shared);
552 (
553 format!("{}{rest}", private_prefix(project_id)),
554 Some(shared),
555 )
556 } else {
557 (shared, None)
558 }
559}
560
561pub(crate) async fn next_number(
563 conn: &mut PgConnection,
564 counter: &str,
565 seed: Option<&str>,
566) -> AppResult<i64> {
567 if let Some(seed) = seed {
568 sqlx::query(
569 "INSERT INTO counters (name, value)
570 VALUES ($1, COALESCE((SELECT value FROM counters WHERE name = $2), 0))
571 ON CONFLICT (name) DO NOTHING",
572 )
573 .bind(counter)
574 .bind(seed)
575 .execute(&mut *conn)
576 .await?;
577 }
578 crate::services::next_counter(conn, counter).await
579}
580
581pub async fn numbering_preview(
590 pool: &PgPool,
591 project: &Project,
592 scheme: Option<&str>,
593) -> AppResult<NumberingPreview> {
594 let scheme = match scheme.map(str::trim).filter(|s| !s.is_empty()) {
595 Some(s) => domain::NumberScheme::parse(s)?,
596 None => domain::NumberScheme::of_project(project.number_scheme.as_deref())?,
597 };
598 let now = Utc::now();
599 let mut next = Vec::new();
600 for item_type in ItemType::ALL {
601 let ctx = domain::number_context(&project.code, item_type, now);
602 let (counter, seed) = number_counter(project.id, &project.visibility, &scheme, &ctx);
603 let used = sqlx::query_scalar::<_, Option<i64>>(
604 "SELECT COALESCE((SELECT value FROM counters WHERE name = $1),
605 (SELECT value FROM counters WHERE name = $2))",
606 )
607 .bind(&counter)
608 .bind(&seed)
609 .fetch_one(pool)
610 .await?
611 .unwrap_or(0);
612 next.push(NextNumber {
613 item_type,
614 label: item_type.label(),
615 number: scheme.format(&ctx, used + 1),
616 });
617 }
618 Ok(NumberingPreview {
619 is_default: scheme == domain::NumberScheme::default_scheme(),
620 scheme: scheme.as_string(),
621 next,
622 })
623}
624
625pub async fn members(pool: &PgPool, project_id: Uuid) -> AppResult<Vec<ProjectMember>> {
626 Ok(sqlx::query_as::<_, ProjectMember>(
627 "SELECT u.id AS user_id, u.username, u.display_name, u.role, u.active, pm.source,
628 pm.added_at, pm.manager,
629 (SELECT COUNT(*) FROM revisions r JOIN items i ON i.id = r.item_id
630 WHERE i.project_id = pm.project_id AND r.checked_out_by = u.id) AS checkouts
631 FROM project_members pm JOIN users u ON u.id = pm.user_id
632 WHERE pm.project_id = $1 ORDER BY pm.manager DESC, u.display_name",
633 )
634 .bind(project_id)
635 .fetch_all(pool)
636 .await?)
637}
638
639#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
642pub struct FormerHolder {
643 pub user_id: Uuid,
644 pub username: String,
645 pub display_name: String,
646 pub active: bool,
647 pub checkouts: i64,
648}
649
650pub async fn former_holders(
654 pool: &PgPool,
655 actor: &Actor,
656 project_id: Uuid,
657) -> AppResult<Vec<FormerHolder>> {
658 let mut conn = pool.acquire().await?;
659 require_manager(&mut conn, actor, project_id).await?;
660 Ok(sqlx::query_as::<_, FormerHolder>(
661 "SELECT u.id AS user_id, u.username, u.display_name, u.active, COUNT(*) AS checkouts
662 FROM revisions r JOIN items i ON i.id = r.item_id JOIN users u ON u.id = r.checked_out_by
663 WHERE i.project_id = $1
664 AND NOT EXISTS (SELECT 1 FROM project_members pm
665 WHERE pm.project_id = $1 AND pm.user_id = u.id)
666 GROUP BY u.id, u.username, u.display_name, u.active
667 ORDER BY u.display_name LIMIT 200",
668 )
669 .bind(project_id)
670 .fetch_all(&mut *conn)
671 .await?)
672}
673
674async fn other_active_managers(
678 conn: &mut PgConnection,
679 project_id: Uuid,
680 besides: Uuid,
681) -> AppResult<i64> {
682 Ok(sqlx::query_scalar::<_, i64>(
683 "SELECT COUNT(*) FROM project_members pm JOIN users u ON u.id = pm.user_id
684 WHERE pm.project_id = $1 AND pm.manager AND u.active AND pm.user_id <> $2",
685 )
686 .bind(project_id)
687 .bind(besides)
688 .fetch_one(&mut *conn)
689 .await?)
690}
691
692pub async fn add_member(
711 pool: &PgPool,
712 actor: &Actor,
713 project_id: Uuid,
714 username: &str,
715 manager: bool,
716) -> AppResult<ProjectMember> {
717 let project = get_for_management(pool, actor, project_id).await?;
718 let user = users::find_by_username(pool, username)
719 .await?
720 .ok_or_else(|| AppError::BadRequest(format!("Unknown user '{}'.", username.trim())))?;
721 let mut tx = pool.begin().await?;
722 sqlx::query("SELECT id FROM projects WHERE id = $1 FOR UPDATE")
724 .bind(project_id)
725 .execute(&mut *tx)
726 .await?;
727 require_manager(&mut tx, actor, project_id).await?;
728 if user.id == actor.id {
729 super::project_policy::forbid_admin_self_add(&mut tx, actor, project_id, manager).await?;
730 }
731 let changed = sqlx::query(
735 "INSERT INTO project_members (project_id, user_id, source, manager) VALUES ($1, $2, 'manual', $3)
736 ON CONFLICT (project_id, user_id)
737 DO UPDATE SET manager = project_members.manager OR EXCLUDED.manager",
738 )
739 .bind(project_id)
740 .bind(user.id)
741 .bind(manager)
742 .execute(&mut *tx)
743 .await?
744 .rows_affected();
745 if changed > 0 {
746 audit::record(
747 &mut tx,
748 Some(actor),
749 "project.add_member",
750 "project",
751 Some(project_id),
752 format!(
753 "Added {} to {}{}",
754 user.username,
755 project.code,
756 if manager { " as manager" } else { "" }
757 ),
758 json!({ "user_id": user.id, "manager": manager }),
759 )
760 .await?;
761 if user.id == actor.id {
762 super::project_policy::note_admin_self_add(&mut tx, actor, &project).await?;
763 }
764 }
765 tx.commit().await?;
766 members(pool, project_id)
767 .await?
768 .into_iter()
769 .find(|m| m.user_id == user.id)
770 .ok_or_else(|| AppError::not_found("Membership not found."))
771}
772
773pub async fn remove_member(
794 pool: &PgPool,
795 actor: &Actor,
796 project_id: Uuid,
797 user_id: Uuid,
798) -> AppResult<i64> {
799 let project = get_for_management(pool, actor, project_id).await?;
800 let mut tx = pool.begin().await?;
801 sqlx::query("SELECT id FROM projects WHERE id = $1 FOR UPDATE")
803 .bind(project_id)
804 .execute(&mut *tx)
805 .await?;
806 if user_id != actor.id {
807 require_manager(&mut tx, actor, project_id).await?;
808 }
809 let (is_manager, from_idp, active) = sqlx::query_as::<_, (bool, bool, bool)>(
810 "SELECT pm.manager, pm.source = 'oidc' AND p.oidc_group IS NOT NULL, u.active
811 FROM project_members pm JOIN projects p ON p.id = pm.project_id
812 JOIN users u ON u.id = pm.user_id
813 WHERE pm.project_id = $1 AND pm.user_id = $2",
814 )
815 .bind(project_id)
816 .bind(user_id)
817 .fetch_optional(&mut *tx)
818 .await?
819 .ok_or_else(|| AppError::not_found("This user is not a member of the project group."))?;
820 if !actor.is_admin()
821 && is_manager
822 && other_active_managers(&mut tx, project_id, user_id).await? == 0
823 {
824 return Err(AppError::Conflict(format!(
825 "{} needs at least one active manager – make someone else a manager first.",
826 project.code
827 )));
828 }
829 if from_idp && active {
833 return Err(AppError::Conflict(format!(
834 "This membership comes from the identity-provider group {} and would return at the next SSO login. \
835 Remove the user from that group in the identity provider, or ask an administrator to unlink \
836 the group or deactivate the user.",
837 project.oidc_group.as_deref().unwrap_or("")
838 )));
839 }
840 let username = sqlx::query_scalar::<_, String>(
841 "DELETE FROM project_members pm USING users u
842 WHERE pm.project_id = $1 AND pm.user_id = $2 AND u.id = pm.user_id
843 RETURNING u.username",
844 )
845 .bind(project_id)
846 .bind(user_id)
847 .fetch_optional(&mut *tx)
848 .await?
849 .ok_or_else(|| AppError::not_found("This user is not a member of the project group."))?;
850 let held = held_checkouts(&mut tx, project_id, user_id).await?;
853 let still = if held > 0 {
854 format!(
855 " ({username} still holds {held} check-outs in {})",
856 project.code
857 )
858 } else {
859 String::new()
860 };
861 audit::record(
862 &mut tx,
863 Some(actor),
864 "project.remove_member",
865 "project",
866 Some(project_id),
867 format!("Removed {username} from {}{still}", project.code),
868 json!({ "user_id": user_id, "checkouts": held }),
869 )
870 .await?;
871 tx.commit().await?;
872 Ok(held)
873}
874
875pub async fn demote_manager(
893 pool: &PgPool,
894 actor: &Actor,
895 project_id: Uuid,
896 user_id: Uuid,
897) -> AppResult<()> {
898 let project = get_for_management(pool, actor, project_id).await?;
899 let mut tx = pool.begin().await?;
900 sqlx::query("SELECT id FROM projects WHERE id = $1 FOR UPDATE")
901 .bind(project_id)
902 .execute(&mut *tx)
903 .await?;
904 require_manager(&mut tx, actor, project_id).await?;
905 let (manager, username) = sqlx::query_as::<_, (bool, String)>(
906 "SELECT pm.manager, u.username FROM project_members pm JOIN users u ON u.id = pm.user_id
907 WHERE pm.project_id = $1 AND pm.user_id = $2",
908 )
909 .bind(project_id)
910 .bind(user_id)
911 .fetch_optional(&mut *tx)
912 .await?
913 .ok_or_else(|| AppError::not_found("This user is not a member of the project group."))?;
914 if !manager {
915 return Err(AppError::Conflict(format!(
916 "{username} is not a manager of {}.",
917 project.code
918 )));
919 }
920 if !actor.is_admin() && other_active_managers(&mut tx, project_id, user_id).await? == 0 {
921 return Err(AppError::Conflict(format!(
922 "{} needs at least one active manager – make someone else a manager first.",
923 project.code
924 )));
925 }
926 sqlx::query(
927 "UPDATE project_members SET manager = false WHERE project_id = $1 AND user_id = $2",
928 )
929 .bind(project_id)
930 .bind(user_id)
931 .execute(&mut *tx)
932 .await?;
933 audit::record(
934 &mut tx,
935 Some(actor),
936 "project.demote_manager",
937 "project",
938 Some(project_id),
939 format!("{username} is no longer a manager of {}", project.code),
940 json!({ "user_id": user_id }),
941 )
942 .await?;
943 tx.commit().await?;
944 Ok(())
945}
946
947pub async fn set_member_expiry(
961 pool: &PgPool,
962 actor: &Actor,
963 project_id: Uuid,
964 user_id: Uuid,
965 until: Option<chrono::NaiveDate>,
966) -> AppResult<()> {
967 let project = get_for_management(pool, actor, project_id).await?;
968 let mut tx = pool.begin().await?;
969 sqlx::query("SELECT id FROM projects WHERE id = $1 FOR UPDATE")
970 .bind(project_id)
971 .execute(&mut *tx)
972 .await?;
973 require_manager(&mut tx, actor, project_id).await?;
974 let username = sqlx::query_scalar::<_, String>(
975 "UPDATE project_members pm SET expires_at = $3 FROM users u
976 WHERE pm.project_id = $1 AND pm.user_id = $2 AND u.id = pm.user_id
977 RETURNING u.username",
978 )
979 .bind(project_id)
980 .bind(user_id)
981 .bind(until)
982 .fetch_optional(&mut *tx)
983 .await?
984 .ok_or_else(|| AppError::not_found("This user is not a member of the project group."))?;
985 audit::record(
986 &mut tx,
987 Some(actor),
988 "project.member_expiry",
989 "project",
990 Some(project_id),
991 match until {
992 Some(d) => format!("{username} is a member of {} until {d}", project.code),
993 None => format!("{username} is a member of {} without an end", project.code),
994 },
995 json!({ "user_id": user_id, "expires_at": until }),
996 )
997 .await?;
998 tx.commit().await?;
999 Ok(())
1000}
1001
1002pub(crate) async fn held_checkouts(
1004 conn: &mut PgConnection,
1005 project_id: Uuid,
1006 user_id: Uuid,
1007) -> AppResult<i64> {
1008 Ok(sqlx::query_scalar::<_, i64>(
1009 "SELECT COUNT(*) FROM revisions r JOIN items i ON i.id = r.item_id
1010 WHERE i.project_id = $1 AND r.checked_out_by = $2",
1011 )
1012 .bind(project_id)
1013 .bind(user_id)
1014 .fetch_one(&mut *conn)
1015 .await?)
1016}