OXYGENHUB

Coverage / crates/hub/src/db/projects.rs 86.2%

412 of 478 lines run, 61 of 142 functions · run #531 on main · run missed

1
//! Projects, their columns and cards (hub spec §8.2).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
#[derive(Clone, Debug, FromRow)]
7
pub struct Project {
8
    pub id: i64,
9
    pub owner: String,
10
    pub number: i64,
11
    pub title: String,
12
    pub description: String,
13
    pub public: bool,
14
    pub closed: bool,
15
    pub created_ms: i64,
16
    pub updated_ms: i64,
17
}
18
19
#[derive(Clone, Debug, FromRow)]
20
pub struct Column {
21
    pub id: i64,
22
    pub project_id: i64,
23
    pub name: String,
24
    pub position: i64,
25
    /// Its cards are archived this many days after they joined it (#293).
26
    pub archive_after_days: Option<i64>,
27
    /// Cards whose issue closes or whose proposal lands or closes move here
28
    /// (#325); one column of a board at most.
29
    pub takes_finished: bool,
30
}
31
32
#[derive(Clone, Debug, FromRow)]
33
pub struct Card {
34
    pub id: i64,
35
    pub project_id: i64,
36
    pub column_id: i64,
37
    pub position: i64,
38
    pub kind: String,
39
    pub repo_id: Option<i64>,
40
    pub number: Option<i64>,
41
    pub note: String,
42
    pub added_by: String,
43
    pub created_ms: i64,
44
    /// Off the board since then (#293): kept, and restorable.
45
    pub archived_ms: Option<i64>,
46
    /// When it joined its column.
47
    pub moved_ms: i64,
48
    /// The iteration it was archived under when that closed (#343).
49
    pub iteration_id: Option<i64>,
50
}
51
52
/// A named span of a board's days (#343). A board has one open at most;
53
/// closing it archives its Done column's cards under it.
54
#[derive(Clone, Debug, FromRow)]
55
pub struct Iteration {
56
    pub id: i64,
57
    pub project_id: i64,
58
    pub name: String,
59
    pub start_ms: i64,
60
    /// The end, exclusive: the midnight after its last day.
61
    pub end_ms: i64,
62
    pub closed_ms: Option<i64>,
63
}
64
65
/// A day, for the archive's ages.
66
const DAY_MS: i64 = 86_400_000;
67
68
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
69
7011×
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
7111×
    let mut tx = super::begin_write(pool).await?;
7211×
    let number: i64 =
7311×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
7411×
    let now = now_ms();
7511×
    let p: Project = sqlx::query_as(
7611×
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
7711×
    )
7811×
    .bind(owner)
7911×
    .bind(number)
8011×
    .bind(title)
8111×
    .bind(description)
8211×
    .bind(public)
8311×
    .bind(now)
8411×
    .bind(now)
8511×
    .fetch_one(&mut *tx)
8611×
    .await?;
87
    // A board here now: an old address of a transferred one gives way (#125).
8811×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
8933×
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
9033×
        sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)")
9133×
            .bind(p.id)
9233×
            .bind(name)
9333×
            .bind(i as i64)
9433×
            .bind(*name == "Done")
9533×
            .execute(&mut *tx)
9633×
            .await?;
97
    }
9811×
    tx.commit().await?;
9911×
    Ok(p)
10011×
}
101
10276×
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
10376×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
10476×
}
105
106
/// Move project `id` to owner `to` (#125): it takes the next number there,
107
/// with its columns, cards and privacy, and its old address redirects to it.
1081×
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
1091×
    let mut tx = super::begin_write(pool).await?;
1101×
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
1111×
    let number: i64 =
1121×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
1131×
    sqlx::query("INSERT INTO project_redirects (owner, number, project_id) VALUES ($1, $2, $3) ON CONFLICT (owner, number) DO UPDATE SET project_id = excluded.project_id")
1141×
        .bind(&old.owner)
1151×
        .bind(old.number)
1161×
        .bind(id)
1171×
        .execute(&mut *tx)
1181×
        .await?;
1191×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
1201×
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
1211×
        .bind(to)
1221×
        .bind(number)
1231×
        .bind(now_ms())
1241×
        .bind(id)
1251×
        .fetch_one(&mut *tx)
1261×
        .await?;
1271×
    tx.commit().await?;
1281×
    Ok(moved)
1291×
}
130
131
/// The project an old address (`owner`, `number`) now redirects to, after
132
/// a transfer (#125).
1332×
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
1342×
    sqlx::query_as("SELECT p.* FROM project_redirects r JOIN projects p ON p.id = r.project_id WHERE r.owner = $1 AND r.number = $2")
1352×
        .bind(owner)
1362×
        .bind(number)
1372×
        .fetch_optional(pool)
1382×
        .await
1392×
}
140
141180×
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
142180×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
143180×
}
144
1456×
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
1466×
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
1476×
        .bind(title)
1486×
        .bind(description)
1496×
        .bind(public)
1506×
        .bind(closed)
1516×
        .bind(now_ms())
1526×
        .bind(id)
1536×
        .execute(pool)
1546×
        .await?;
1556×
    Ok(())
1566×
}
157
1581×
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
1591×
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
1601×
    Ok(())
1611×
}
162
16334×
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
16434×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
16534×
    Ok(())
16634×
}
167
168137×
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
169137×
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
170137×
}
171
17282×
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
17382×
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
17482×
}
175
1760×
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
1770×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
1780×
        .bind(project_id)
1790×
        .fetch_one(pool)
1800×
        .await?;
1810×
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
1820×
        .bind(project_id)
1830×
        .bind(name)
1840×
        .bind(pos)
1850×
        .execute(pool)
1860×
        .await?;
1870×
    touch(pool, project_id).await
1880×
}
189
1900×
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
1910×
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
1920×
        .bind(name)
1930×
        .bind(column_id)
1940×
        .bind(project_id)
1950×
        .execute(pool)
1960×
        .await?;
1970×
    touch(pool, project_id).await
1980×
}
199
200
/// Swap a column with its neighbour (`-1` left, `1` right).
2010×
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
2020×
    let cols = columns(pool, project_id).await?;
2030×
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
2040×
    let j = i as i64 + by;
2050×
    if j < 0 || j as usize >= cols.len() {
2060×
        return Ok(());
2070×
    }
2080×
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
2090×
    ids.swap(i, j as usize);
2100×
    for (pos, id) in ids.iter().enumerate() {
2110×
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
212
    }
2130×
    touch(pool, project_id).await
2140×
}
215
216
/// Remove a column; its cards go to the first remaining column. The last column stays.
2173×
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
2183×
    let cols = columns(pool, project_id).await?;
2193×
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
2204×
    if !cols.iter().any(|c| c.id == column_id) {
2210×
        return Ok(false);
2222×
    }
2232×
    let mut tx = super::begin_write(pool).await?;
2242×
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2252×
        .bind(to.id)
2262×
        .fetch_one(&mut *tx)
2272×
        .await?;
2282×
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
2292×
        .bind(to.id)
2302×
        .bind(base)
2312×
        .bind(column_id)
2322×
        .execute(&mut *tx)
2332×
        .await?;
2342×
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
2352×
    tx.commit().await?;
2362×
    touch(pool, project_id).await?;
2372×
    Ok(true)
2383×
}
239
240
pub struct NewCard<'a> {
241
    pub column_id: i64,
242
    pub kind: &'a str,
243
    pub repo_id: Option<i64>,
244
    pub number: Option<i64>,
245
    pub note: &'a str,
246
    pub added_by: &'a str,
247
}
248
249
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
250
/// proposal is already on the project.
25119×
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
25219×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
25319×
        .bind(c.column_id)
25419×
        .fetch_one(pool)
25519×
        .await?;
25619×
    let r = sqlx::query(
25719×
        "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms, moved_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT DO NOTHING",
25819×
    )
25919×
    .bind(project_id)
26019×
    .bind(c.column_id)
26119×
    .bind(pos)
26219×
    .bind(c.kind)
26319×
    .bind(c.repo_id)
26419×
    .bind(c.number)
26519×
    .bind(c.note)
26619×
    .bind(c.added_by)
26719×
    .bind(now_ms())
26819×
    .execute(pool)
26919×
    .await?;
27019×
    touch(pool, project_id).await?;
27119×
    Ok(r.rows_affected() == 1)
27219×
}
273
274
/// Move a card to the bottom of another column of the same project.
2755×
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
2765×
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
2775×
        .bind(column_id)
2785×
        .bind(project_id)
2795×
        .fetch_one(pool)
2805×
        .await?;
2815×
    if ok == 0 {
2820×
        return Ok(());
2835×
    }
2845×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2855×
        .bind(column_id)
2865×
        .fetch_one(pool)
2875×
        .await?;
2885×
    sqlx::query("UPDATE project_cards SET moved_ms = CASE WHEN column_id = $1 THEN moved_ms ELSE $3 END, column_id = $1, position = $2 WHERE id = $4 AND project_id = $5")
2895×
        .bind(column_id)
2905×
        .bind(pos)
2915×
        .bind(now_ms())
2925×
        .bind(card_id)
2935×
        .bind(project_id)
2945×
        .execute(pool)
2955×
        .await?;
2965×
    touch(pool, project_id).await
2975×
}
298
299
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
300
/// (#289).
3011×
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
3021×
    let mut tx = pool.begin().await?;
3031×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
3041×
        .bind(column_id)
3051×
        .fetch_one(&mut *tx)
3061×
        .await?;
3071×
    let now = now_ms();
3081×
    for (i, id) in card_ids.iter().enumerate() {
3091×
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
3101×
            .bind(column_id)
3111×
            .bind(pos + i as i64)
3121×
            .bind(now)
3131×
            .bind(id)
3141×
            .bind(project_id)
3151×
            .execute(&mut *tx)
3161×
            .await?;
317
    }
3181×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3191×
    tx.commit().await
3201×
}
321
322
/// Take `card_ids` off the board, kept (#293); how many were on it.
3233×
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
3243×
    let mut tx = pool.begin().await?;
3253×
    let now = now_ms();
3263×
    let mut n = 0;
3273×
    for id in card_ids {
3283×
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
3293×
            .bind(now)
3303×
            .bind(id)
3313×
            .bind(project_id)
3323×
            .execute(&mut *tx)
3333×
            .await?
3343×
            .rows_affected();
335
    }
3363×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3373×
    tx.commit().await?;
3383×
    Ok(n)
3393×
}
340
341
/// Put an archived card back on the board, at the end of its column, as
342
/// if it had just joined it (so its column does not archive it again at
343
/// once); `false` when it was not archived.
3443×
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
3453×
    let mut tx = pool.begin().await?;
3463×
    let pos: i64 = sqlx::query_scalar(
3473×
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
3483×
    )
3493×
    .bind(card_id)
3503×
    .fetch_one(&mut *tx)
3513×
    .await?;
3523×
    let now = now_ms();
353
    // Back on the board, it no longer counts as done in a past iteration.
3543×
    let r = sqlx::query(
3553×
        "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \
3563×
         WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
3573×
    )
3583×
    .bind(now)
3593×
    .bind(pos)
3603×
    .bind(card_id)
3613×
    .bind(project_id)
3623×
    .execute(&mut *tx)
3633×
    .await?;
3643×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3653×
    tx.commit().await?;
3663×
    Ok(r.rows_affected() == 1)
3673×
}
368
369
/// Whether `card` joined its column more than `days` ago, at `now`.
3708×
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
3718×
    card.moved_ms < now - days * DAY_MS
3728×
}
373
374
/// A board's iterations, newest first (#343).
37552×
pub async fn iterations(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Iteration>> {
37652×
    sqlx::query_as("SELECT * FROM project_iterations WHERE project_id = $1 ORDER BY start_ms DESC, id DESC")
37752×
        .bind(project_id)
37852×
        .fetch_all(pool)
37952×
        .await
38052×
}
381
382
/// Start an iteration of `[start_ms, end_ms)`; `None` while another is open.
3832×
pub async fn start_iteration(pool: &Pool, project_id: i64, name: &str, start_ms: i64, end_ms: i64) -> sqlx::Result<Option<Iteration>> {
3842×
    let mut tx = super::begin_write(pool).await?;
3852×
    let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_iterations WHERE project_id = $1 AND closed_ms IS NULL")
3862×
        .bind(project_id)
3872×
        .fetch_one(&mut *tx)
3882×
        .await?;
3892×
    if open > 0 {
3901×
        return Ok(None);
3911×
    }
3921×
    let it: Iteration =
3931×
        sqlx::query_as("INSERT INTO project_iterations (project_id, name, start_ms, end_ms) VALUES ($1, $2, $3, $4) RETURNING *")
3941×
            .bind(project_id)
3951×
            .bind(name)
3961×
            .bind(start_ms)
3971×
            .bind(end_ms)
3981×
            .fetch_one(&mut *tx)
3991×
            .await?;
4001×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?;
4011×
    tx.commit().await?;
4021×
    Ok(Some(it))
4032×
}
404
405
/// The column whose cards are done: the one that takes finished work, or
406
/// else the last.
40754×
pub fn done_column(columns: &[Column]) -> Option<i64> {
40854×
    columns.iter().find(|c| c.takes_finished).or(columns.last()).map(|c| c.id)
40954×
}
410
411
/// Close an open iteration: the cards on the board in `done` (its Done
412
/// column) are archived under it. How many, or `None` when it was not open.
4132×
pub async fn close_iteration(pool: &Pool, project_id: i64, iteration_id: i64, done: Option<i64>) -> sqlx::Result<Option<u64>> {
4142×
    let mut tx = super::begin_write(pool).await?;
4152×
    let now = now_ms();
4162×
    let closed = sqlx::query("UPDATE project_iterations SET closed_ms = $1 WHERE id = $2 AND project_id = $3 AND closed_ms IS NULL")
4172×
        .bind(now)
4182×
        .bind(iteration_id)
4192×
        .bind(project_id)
4202×
        .execute(&mut *tx)
4212×
        .await?;
4222×
    if closed.rows_affected() == 0 {
4231×
        return Ok(None);
4241×
    }
4251×
    let mut n = 0;
4261×
    if let Some(done) = done {
4271×
        n = sqlx::query(
4281×
            "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL",
4291×
        )
4301×
        .bind(now)
4311×
        .bind(iteration_id)
4321×
        .bind(project_id)
4331×
        .bind(done)
4341×
        .execute(&mut *tx)
4351×
        .await?
4361×
        .rows_affected();
4370×
    }
4381×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
4391×
    tx.commit().await?;
4401×
    Ok(Some(n))
4412×
}
442
443
/// When each card done in `it` reached the Done column `done`: those
444
/// archived under it, and while it is open those in `done` now or archived
445
/// from there during it.
44611×
pub async fn done_times(pool: &Pool, it: &Iteration, done: Option<i64>) -> sqlx::Result<Vec<i64>> {
44711×
    sqlx::query_scalar(
44811×
        "SELECT moved_ms FROM project_cards WHERE project_id = $1 AND (iteration_id = $2 \
44911×
         OR ($3 AND iteration_id IS NULL AND column_id = $4 AND (archived_ms IS NULL OR archived_ms >= $5))) ORDER BY moved_ms",
45011×
    )
45111×
    .bind(it.project_id)
45211×
    .bind(it.id)
45311×
    .bind(it.closed_ms.is_none())
45411×
    .bind(done.unwrap_or(-1))
45511×
    .bind(it.start_ms)
45611×
    .fetch_all(pool)
45711×
    .await
45811×
}
459
460
/// The burn-up of an iteration: cards done by the end of each of its days
461
/// so far, from `times` (when each reached Done; earlier than the start
462
/// counts on the first day). One entry per day up to `now` or its end.
46314×
pub fn burn_up(times: &[i64], start_ms: i64, end_ms: i64, now: i64) -> Vec<usize> {
46414×
    let days = ((end_ms - start_ms + DAY_MS - 1) / DAY_MS).max(1);
46514×
    let upto = ((now.min(end_ms - 1) - start_ms) / DAY_MS + 1).clamp(0, days);
466112×
    (0..upto).map(|d| times.iter().filter(|&&t| t < start_ms + (d + 1) * DAY_MS).count()).collect()
46714×
}
468
469
/// An iteration's length in days.
47011×
pub fn iteration_days(it: &Iteration) -> i64 {
47111×
    ((it.end_ms - it.start_ms + DAY_MS - 1) / DAY_MS).max(1)
47211×
}
473
474
/// Archive a column's cards this many days after they join it; `None`
475
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
476
/// none.
4773×
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
4783×
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
4793×
        .bind(days)
4803×
        .bind(column_id)
4813×
        .bind(project_id)
4823×
        .execute(pool)
4833×
        .await?;
4843×
    touch(pool, project_id).await
4853×
}
486
487
/// Days after which a column with no setting of its own archives its
488
/// finished cards (#293): a board shows the last fortnight's done work.
489
pub const FINISHED_KEPT_DAYS: i64 = 14;
490
491
/// A column's `archive_after_days` that means never.
492
pub const NEVER: i64 = -1;
493
494
/// Archive, on every board, the cards their columns keep no longer (#293);
495
/// how many. A column that archives after N days: every card that joined it
496
/// longer ago. A column with no setting: finished cards (a closed issue, a
497
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
498
/// stops growing while open work and notes stay. [`NEVER`]: none.
499205×
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
500205×
    let set = sqlx::query(
501205×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
502205×
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
503205×
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
504205×
    )
505205×
    .bind(now)
506205×
    .bind(DAY_MS)
507205×
    .execute(pool)
508205×
    .await?;
509205×
    let finished = sqlx::query(
510205×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
511205×
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
512205×
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
513205×
               AND i.number = project_cards.number AND i.state = 'closed')) \
514205×
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
515205×
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
516205×
    )
517205×
    .bind(now)
518205×
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
519205×
    .execute(pool)
520205×
    .await?;
521204×
    Ok(set.rows_affected() + finished.rows_affected())
522204×
}
523
524
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
5250×
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
5260×
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
5270×
        .bind(card_id)
5280×
        .bind(project_id)
5290×
        .fetch_optional(pool)
5300×
        .await?
531
    else {
5320×
        return Ok(());
533
    };
5340×
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
5350×
        .bind(card.column_id)
5360×
        .fetch_all(pool)
5370×
        .await?;
5380×
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
5390×
    let j = i as i64 + by;
5400×
    if j < 0 || j as usize >= col.len() {
5410×
        return Ok(());
5420×
    }
5430×
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
5440×
    ids.swap(i, j as usize);
5450×
    for (pos, id) in ids.iter().enumerate() {
5460×
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
547
    }
5480×
    touch(pool, project_id).await
5490×
}
550
5513×
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
5523×
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
5533×
    touch(pool, project_id).await
5543×
}
555
556
/// The projects (and column names) an issue or proposal is on.
557
/// Make `column_id` the board's column for finished work, or none (#325).
5582×
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
5592×
    let mut tx = super::begin_write(pool).await?;
5602×
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
5612×
    if let Some(c) = column_id {
5621×
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
5631×
            .bind(c)
5641×
            .bind(project_id)
5651×
            .execute(&mut *tx)
5661×
            .await?;
5671×
    }
5682×
    tx.commit().await?;
5692×
    touch(pool, project_id).await
5702×
}
571
572
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
573
/// its cards move to their boards' column for finished work, where a board
574
/// has one (#325). Archived cards stay archived.
57537×
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
57637×
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
57737×
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
57837×
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
57937×
    )
58037×
    .bind(repo_id)
58137×
    .bind(number)
58237×
    .fetch_all(pool)
58337×
    .await?;
58437×
    for (card, project, column) in cards {
5853×
        let to: Option<i64> =
5863×
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
5873×
                .bind(project)
5883×
                .fetch_optional(pool)
5893×
                .await?;
5903×
        if let Some(to) = to.filter(|to| *to != column) {
5912×
            move_card(pool, project, card, to).await?;
5921×
        }
593
    }
59437×
    Ok(())
59537×
}
596
597106×
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
598106×
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
599106×
        "SELECT c.project_id, col.name, c.archived_ms FROM project_cards c JOIN project_columns col ON col.id = c.column_id WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note'",
600106×
    )
601106×
    .bind(repo_id)
602106×
    .bind(number)
603106×
    .fetch_all(pool)
604106×
    .await?;
605106×
    let mut out = Vec::new();
606106×
    for (pid, col, archived) in rows {
6075×
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
6085×
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
6095×
            out.push((p, col));
6105×
        }
611
    }
612106×
    Ok(out)
613106×
}