OXYGENHUB

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

289 of 354 lines run, 43 of 106 functions · run #470 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
}
28
29
#[derive(Clone, Debug, FromRow)]
30
pub struct Card {
31
    pub id: i64,
32
    pub project_id: i64,
33
    pub column_id: i64,
34
    pub position: i64,
35
    pub kind: String,
36
    pub repo_id: Option<i64>,
37
    pub number: Option<i64>,
38
    pub note: String,
39
    pub added_by: String,
40
    pub created_ms: i64,
41
    /// Off the board since then (#293): kept, and restorable.
42
    pub archived_ms: Option<i64>,
43
    /// When it joined its column.
44
    pub moved_ms: i64,
45
}
46
47
/// A day, for the archive's ages.
48
const DAY_MS: i64 = 86_400_000;
49
50
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
51
529×
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
539×
    let mut tx = super::begin_write(pool).await?;
549×
    let number: i64 =
559×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
569×
    let now = now_ms();
579×
    let p: Project = sqlx::query_as(
589×
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
599×
    )
609×
    .bind(owner)
619×
    .bind(number)
629×
    .bind(title)
639×
    .bind(description)
649×
    .bind(public)
659×
    .bind(now)
669×
    .bind(now)
679×
    .fetch_one(&mut *tx)
689×
    .await?;
69
    // A board here now: an old address of a transferred one gives way (#125).
709×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
7127×
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
7227×
        sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
7327×
            .bind(p.id)
7427×
            .bind(name)
7527×
            .bind(i as i64)
7627×
            .execute(&mut *tx)
7727×
            .await?;
78
    }
799×
    tx.commit().await?;
809×
    Ok(p)
819×
}
82
8367×
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
8467×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
8567×
}
86
87
/// Move project `id` to owner `to` (#125): it takes the next number there,
88
/// with its columns, cards and privacy, and its old address redirects to it.
891×
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
901×
    let mut tx = super::begin_write(pool).await?;
911×
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
921×
    let number: i64 =
931×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
941×
    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")
951×
        .bind(&old.owner)
961×
        .bind(old.number)
971×
        .bind(id)
981×
        .execute(&mut *tx)
991×
        .await?;
1001×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
1011×
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
1021×
        .bind(to)
1031×
        .bind(number)
1041×
        .bind(now_ms())
1051×
        .bind(id)
1061×
        .fetch_one(&mut *tx)
1071×
        .await?;
1081×
    tx.commit().await?;
1091×
    Ok(moved)
1101×
}
111
112
/// The project an old address (`owner`, `number`) now redirects to, after
113
/// a transfer (#125).
1142×
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
1152×
    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")
1162×
        .bind(owner)
1172×
        .bind(number)
1182×
        .fetch_optional(pool)
1192×
        .await
1202×
}
121
122145×
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
123145×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
124145×
}
125
1266×
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
1276×
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
1286×
        .bind(title)
1296×
        .bind(description)
1306×
        .bind(public)
1316×
        .bind(closed)
1326×
        .bind(now_ms())
1336×
        .bind(id)
1346×
        .execute(pool)
1356×
        .await?;
1366×
    Ok(())
1376×
}
138
1391×
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
1401×
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
1411×
    Ok(())
1421×
}
143
14426×
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
14526×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
14626×
    Ok(())
14726×
}
148
149109×
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
150109×
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
151109×
}
152
15367×
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
15467×
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
15567×
}
156
1570×
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
1580×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
1590×
        .bind(project_id)
1600×
        .fetch_one(pool)
1610×
        .await?;
1620×
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
1630×
        .bind(project_id)
1640×
        .bind(name)
1650×
        .bind(pos)
1660×
        .execute(pool)
1670×
        .await?;
1680×
    touch(pool, project_id).await
1690×
}
170
1710×
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
1720×
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
1730×
        .bind(name)
1740×
        .bind(column_id)
1750×
        .bind(project_id)
1760×
        .execute(pool)
1770×
        .await?;
1780×
    touch(pool, project_id).await
1790×
}
180
181
/// Swap a column with its neighbour (`-1` left, `1` right).
1820×
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
1830×
    let cols = columns(pool, project_id).await?;
1840×
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
1850×
    let j = i as i64 + by;
1860×
    if j < 0 || j as usize >= cols.len() {
1870×
        return Ok(());
1880×
    }
1890×
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
1900×
    ids.swap(i, j as usize);
1910×
    for (pos, id) in ids.iter().enumerate() {
1920×
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
193
    }
1940×
    touch(pool, project_id).await
1950×
}
196
197
/// Remove a column; its cards go to the first remaining column. The last column stays.
1983×
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
1993×
    let cols = columns(pool, project_id).await?;
2003×
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
2014×
    if !cols.iter().any(|c| c.id == column_id) {
2020×
        return Ok(false);
2032×
    }
2042×
    let mut tx = super::begin_write(pool).await?;
2052×
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2062×
        .bind(to.id)
2072×
        .fetch_one(&mut *tx)
2082×
        .await?;
2092×
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
2102×
        .bind(to.id)
2112×
        .bind(base)
2122×
        .bind(column_id)
2132×
        .execute(&mut *tx)
2142×
        .await?;
2152×
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
2162×
    tx.commit().await?;
2172×
    touch(pool, project_id).await?;
2182×
    Ok(true)
2193×
}
220
221
pub struct NewCard<'a> {
222
    pub column_id: i64,
223
    pub kind: &'a str,
224
    pub repo_id: Option<i64>,
225
    pub number: Option<i64>,
226
    pub note: &'a str,
227
    pub added_by: &'a str,
228
}
229
230
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
231
/// proposal is already on the project.
23215×
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
23315×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
23415×
        .bind(c.column_id)
23515×
        .fetch_one(pool)
23615×
        .await?;
23715×
    let r = sqlx::query(
23815×
        "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",
23915×
    )
24015×
    .bind(project_id)
24115×
    .bind(c.column_id)
24215×
    .bind(pos)
24315×
    .bind(c.kind)
24415×
    .bind(c.repo_id)
24515×
    .bind(c.number)
24615×
    .bind(c.note)
24715×
    .bind(c.added_by)
24815×
    .bind(now_ms())
24915×
    .execute(pool)
25015×
    .await?;
25115×
    touch(pool, project_id).await?;
25215×
    Ok(r.rows_affected() == 1)
25315×
}
254
255
/// Move a card to the bottom of another column of the same project.
2563×
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
2573×
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
2583×
        .bind(column_id)
2593×
        .bind(project_id)
2603×
        .fetch_one(pool)
2613×
        .await?;
2623×
    if ok == 0 {
2630×
        return Ok(());
2643×
    }
2653×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2663×
        .bind(column_id)
2673×
        .fetch_one(pool)
2683×
        .await?;
2693×
    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")
2703×
        .bind(column_id)
2713×
        .bind(pos)
2723×
        .bind(now_ms())
2733×
        .bind(card_id)
2743×
        .bind(project_id)
2753×
        .execute(pool)
2763×
        .await?;
2773×
    touch(pool, project_id).await
2783×
}
279
280
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
281
/// (#289).
2821×
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
2831×
    let mut tx = pool.begin().await?;
2841×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2851×
        .bind(column_id)
2861×
        .fetch_one(&mut *tx)
2871×
        .await?;
2881×
    let now = now_ms();
2891×
    for (i, id) in card_ids.iter().enumerate() {
2901×
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
2911×
            .bind(column_id)
2921×
            .bind(pos + i as i64)
2931×
            .bind(now)
2941×
            .bind(id)
2951×
            .bind(project_id)
2961×
            .execute(&mut *tx)
2971×
            .await?;
298
    }
2991×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3001×
    tx.commit().await
3011×
}
302
303
/// Take `card_ids` off the board, kept (#293); how many were on it.
3043×
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
3053×
    let mut tx = pool.begin().await?;
3063×
    let now = now_ms();
3073×
    let mut n = 0;
3083×
    for id in card_ids {
3093×
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
3103×
            .bind(now)
3113×
            .bind(id)
3123×
            .bind(project_id)
3133×
            .execute(&mut *tx)
3143×
            .await?
3153×
            .rows_affected();
316
    }
3173×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3183×
    tx.commit().await?;
3193×
    Ok(n)
3203×
}
321
322
/// Put an archived card back on the board, at the end of its column, as
323
/// if it had just joined it (so its column does not archive it again at
324
/// once); `false` when it was not archived.
3252×
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
3262×
    let mut tx = pool.begin().await?;
3272×
    let pos: i64 = sqlx::query_scalar(
3282×
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
3292×
    )
3302×
    .bind(card_id)
3312×
    .fetch_one(&mut *tx)
3322×
    .await?;
3332×
    let now = now_ms();
3342×
    let r = sqlx::query(
3352×
        "UPDATE project_cards SET archived_ms = NULL, moved_ms = $1, position = $2 WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
3362×
    )
3372×
    .bind(now)
3382×
    .bind(pos)
3392×
    .bind(card_id)
3402×
    .bind(project_id)
3412×
    .execute(&mut *tx)
3422×
    .await?;
3432×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3442×
    tx.commit().await?;
3452×
    Ok(r.rows_affected() == 1)
3462×
}
347
348
/// Whether `card` joined its column more than `days` ago, at `now`.
3498×
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
3508×
    card.moved_ms < now - days * DAY_MS
3518×
}
352
353
/// Archive a column's cards this many days after they join it; `None`
354
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
355
/// none.
3563×
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
3573×
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
3583×
        .bind(days)
3593×
        .bind(column_id)
3603×
        .bind(project_id)
3613×
        .execute(pool)
3623×
        .await?;
3633×
    touch(pool, project_id).await
3643×
}
365
366
/// Days after which a column with no setting of its own archives its
367
/// finished cards (#293): a board shows the last fortnight's done work.
368
pub const FINISHED_KEPT_DAYS: i64 = 14;
369
370
/// A column's `archive_after_days` that means never.
371
pub const NEVER: i64 = -1;
372
373
/// Archive, on every board, the cards their columns keep no longer (#293);
374
/// how many. A column that archives after N days: every card that joined it
375
/// longer ago. A column with no setting: finished cards (a closed issue, a
376
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
377
/// stops growing while open work and notes stay. [`NEVER`]: none.
378192×
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
379192×
    let set = sqlx::query(
380192×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
381192×
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
382192×
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
383192×
    )
384192×
    .bind(now)
385192×
    .bind(DAY_MS)
386192×
    .execute(pool)
387192×
    .await?;
388192×
    let finished = sqlx::query(
389192×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
390192×
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
391192×
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
392192×
               AND i.number = project_cards.number AND i.state = 'closed')) \
393192×
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
394192×
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
395192×
    )
396192×
    .bind(now)
397192×
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
398192×
    .execute(pool)
399192×
    .await?;
400191×
    Ok(set.rows_affected() + finished.rows_affected())
401191×
}
402
403
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
4040×
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
4050×
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
4060×
        .bind(card_id)
4070×
        .bind(project_id)
4080×
        .fetch_optional(pool)
4090×
        .await?
410
    else {
4110×
        return Ok(());
412
    };
4130×
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
4140×
        .bind(card.column_id)
4150×
        .fetch_all(pool)
4160×
        .await?;
4170×
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
4180×
    let j = i as i64 + by;
4190×
    if j < 0 || j as usize >= col.len() {
4200×
        return Ok(());
4210×
    }
4220×
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
4230×
    ids.swap(i, j as usize);
4240×
    for (pos, id) in ids.iter().enumerate() {
4250×
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
426
    }
4270×
    touch(pool, project_id).await
4280×
}
429
4303×
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
4313×
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
4323×
    touch(pool, project_id).await
4333×
}
434
435
/// The projects (and column names) an issue or proposal is on.
43699×
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
43799×
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
43899×
        "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'",
43999×
    )
44099×
    .bind(repo_id)
44199×
    .bind(number)
44299×
    .fetch_all(pool)
44399×
    .await?;
44499×
    let mut out = Vec::new();
44599×
    for (pid, col, archived) in rows {
4463×
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
4473×
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
4483×
            out.push((p, col));
4493×
        }
450
    }
45199×
    Ok(out)
45299×
}