OXYGENHUB

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

326 of 391 lines run, 48 of 116 functions · run #485 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
}
49
50
/// A day, for the archive's ages.
51
const DAY_MS: i64 = 86_400_000;
52
53
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
54
5510×
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
5610×
    let mut tx = super::begin_write(pool).await?;
5710×
    let number: i64 =
5810×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
5910×
    let now = now_ms();
6010×
    let p: Project = sqlx::query_as(
6110×
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
6210×
    )
6310×
    .bind(owner)
6410×
    .bind(number)
6510×
    .bind(title)
6610×
    .bind(description)
6710×
    .bind(public)
6810×
    .bind(now)
6910×
    .bind(now)
7010×
    .fetch_one(&mut *tx)
7110×
    .await?;
72
    // A board here now: an old address of a transferred one gives way (#125).
7310×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
7430×
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
7530×
        sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)")
7630×
            .bind(p.id)
7730×
            .bind(name)
7830×
            .bind(i as i64)
7930×
            .bind(*name == "Done")
8030×
            .execute(&mut *tx)
8130×
            .await?;
82
    }
8310×
    tx.commit().await?;
8410×
    Ok(p)
8510×
}
86
8772×
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
8872×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
8972×
}
90
91
/// Move project `id` to owner `to` (#125): it takes the next number there,
92
/// with its columns, cards and privacy, and its old address redirects to it.
931×
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
941×
    let mut tx = super::begin_write(pool).await?;
951×
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
961×
    let number: i64 =
971×
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
981×
    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")
991×
        .bind(&old.owner)
1001×
        .bind(old.number)
1011×
        .bind(id)
1021×
        .execute(&mut *tx)
1031×
        .await?;
1041×
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
1051×
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
1061×
        .bind(to)
1071×
        .bind(number)
1081×
        .bind(now_ms())
1091×
        .bind(id)
1101×
        .fetch_one(&mut *tx)
1111×
        .await?;
1121×
    tx.commit().await?;
1131×
    Ok(moved)
1141×
}
115
116
/// The project an old address (`owner`, `number`) now redirects to, after
117
/// a transfer (#125).
1182×
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
1192×
    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")
1202×
        .bind(owner)
1212×
        .bind(number)
1222×
        .fetch_optional(pool)
1232×
        .await
1242×
}
125
126155×
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
127155×
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
128155×
}
129
1306×
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
1316×
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
1326×
        .bind(title)
1336×
        .bind(description)
1346×
        .bind(public)
1356×
        .bind(closed)
1366×
        .bind(now_ms())
1376×
        .bind(id)
1386×
        .execute(pool)
1396×
        .await?;
1406×
    Ok(())
1416×
}
142
1431×
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
1441×
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
1451×
    Ok(())
1461×
}
147
14834×
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
14934×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
15034×
    Ok(())
15134×
}
152
153118×
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
154118×
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
155118×
}
156
15770×
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
15870×
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
15970×
}
160
1610×
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
1620×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
1630×
        .bind(project_id)
1640×
        .fetch_one(pool)
1650×
        .await?;
1660×
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
1670×
        .bind(project_id)
1680×
        .bind(name)
1690×
        .bind(pos)
1700×
        .execute(pool)
1710×
        .await?;
1720×
    touch(pool, project_id).await
1730×
}
174
1750×
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
1760×
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
1770×
        .bind(name)
1780×
        .bind(column_id)
1790×
        .bind(project_id)
1800×
        .execute(pool)
1810×
        .await?;
1820×
    touch(pool, project_id).await
1830×
}
184
185
/// Swap a column with its neighbour (`-1` left, `1` right).
1860×
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
1870×
    let cols = columns(pool, project_id).await?;
1880×
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
1890×
    let j = i as i64 + by;
1900×
    if j < 0 || j as usize >= cols.len() {
1910×
        return Ok(());
1920×
    }
1930×
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
1940×
    ids.swap(i, j as usize);
1950×
    for (pos, id) in ids.iter().enumerate() {
1960×
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
197
    }
1980×
    touch(pool, project_id).await
1990×
}
200
201
/// Remove a column; its cards go to the first remaining column. The last column stays.
2023×
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
2033×
    let cols = columns(pool, project_id).await?;
2043×
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
2054×
    if !cols.iter().any(|c| c.id == column_id) {
2060×
        return Ok(false);
2072×
    }
2082×
    let mut tx = super::begin_write(pool).await?;
2092×
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2102×
        .bind(to.id)
2112×
        .fetch_one(&mut *tx)
2122×
        .await?;
2132×
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
2142×
        .bind(to.id)
2152×
        .bind(base)
2162×
        .bind(column_id)
2172×
        .execute(&mut *tx)
2182×
        .await?;
2192×
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
2202×
    tx.commit().await?;
2212×
    touch(pool, project_id).await?;
2222×
    Ok(true)
2233×
}
224
225
pub struct NewCard<'a> {
226
    pub column_id: i64,
227
    pub kind: &'a str,
228
    pub repo_id: Option<i64>,
229
    pub number: Option<i64>,
230
    pub note: &'a str,
231
    pub added_by: &'a str,
232
}
233
234
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
235
/// proposal is already on the project.
23619×
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
23719×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
23819×
        .bind(c.column_id)
23919×
        .fetch_one(pool)
24019×
        .await?;
24119×
    let r = sqlx::query(
24219×
        "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",
24319×
    )
24419×
    .bind(project_id)
24519×
    .bind(c.column_id)
24619×
    .bind(pos)
24719×
    .bind(c.kind)
24819×
    .bind(c.repo_id)
24919×
    .bind(c.number)
25019×
    .bind(c.note)
25119×
    .bind(c.added_by)
25219×
    .bind(now_ms())
25319×
    .execute(pool)
25419×
    .await?;
25519×
    touch(pool, project_id).await?;
25619×
    Ok(r.rows_affected() == 1)
25719×
}
258
259
/// Move a card to the bottom of another column of the same project.
2605×
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
2615×
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
2625×
        .bind(column_id)
2635×
        .bind(project_id)
2645×
        .fetch_one(pool)
2655×
        .await?;
2665×
    if ok == 0 {
2670×
        return Ok(());
2685×
    }
2695×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2705×
        .bind(column_id)
2715×
        .fetch_one(pool)
2725×
        .await?;
2735×
    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")
2745×
        .bind(column_id)
2755×
        .bind(pos)
2765×
        .bind(now_ms())
2775×
        .bind(card_id)
2785×
        .bind(project_id)
2795×
        .execute(pool)
2805×
        .await?;
2815×
    touch(pool, project_id).await
2825×
}
283
284
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
285
/// (#289).
2861×
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
2871×
    let mut tx = pool.begin().await?;
2881×
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
2891×
        .bind(column_id)
2901×
        .fetch_one(&mut *tx)
2911×
        .await?;
2921×
    let now = now_ms();
2931×
    for (i, id) in card_ids.iter().enumerate() {
2941×
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
2951×
            .bind(column_id)
2961×
            .bind(pos + i as i64)
2971×
            .bind(now)
2981×
            .bind(id)
2991×
            .bind(project_id)
3001×
            .execute(&mut *tx)
3011×
            .await?;
302
    }
3031×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3041×
    tx.commit().await
3051×
}
306
307
/// Take `card_ids` off the board, kept (#293); how many were on it.
3083×
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
3093×
    let mut tx = pool.begin().await?;
3103×
    let now = now_ms();
3113×
    let mut n = 0;
3123×
    for id in card_ids {
3133×
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
3143×
            .bind(now)
3153×
            .bind(id)
3163×
            .bind(project_id)
3173×
            .execute(&mut *tx)
3183×
            .await?
3193×
            .rows_affected();
320
    }
3213×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3223×
    tx.commit().await?;
3233×
    Ok(n)
3243×
}
325
326
/// Put an archived card back on the board, at the end of its column, as
327
/// if it had just joined it (so its column does not archive it again at
328
/// once); `false` when it was not archived.
3292×
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
3302×
    let mut tx = pool.begin().await?;
3312×
    let pos: i64 = sqlx::query_scalar(
3322×
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
3332×
    )
3342×
    .bind(card_id)
3352×
    .fetch_one(&mut *tx)
3362×
    .await?;
3372×
    let now = now_ms();
3382×
    let r = sqlx::query(
3392×
        "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",
3402×
    )
3412×
    .bind(now)
3422×
    .bind(pos)
3432×
    .bind(card_id)
3442×
    .bind(project_id)
3452×
    .execute(&mut *tx)
3462×
    .await?;
3472×
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
3482×
    tx.commit().await?;
3492×
    Ok(r.rows_affected() == 1)
3502×
}
351
352
/// Whether `card` joined its column more than `days` ago, at `now`.
3538×
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
3548×
    card.moved_ms < now - days * DAY_MS
3558×
}
356
357
/// Archive a column's cards this many days after they join it; `None`
358
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
359
/// none.
3603×
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
3613×
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
3623×
        .bind(days)
3633×
        .bind(column_id)
3643×
        .bind(project_id)
3653×
        .execute(pool)
3663×
        .await?;
3673×
    touch(pool, project_id).await
3683×
}
369
370
/// Days after which a column with no setting of its own archives its
371
/// finished cards (#293): a board shows the last fortnight's done work.
372
pub const FINISHED_KEPT_DAYS: i64 = 14;
373
374
/// A column's `archive_after_days` that means never.
375
pub const NEVER: i64 = -1;
376
377
/// Archive, on every board, the cards their columns keep no longer (#293);
378
/// how many. A column that archives after N days: every card that joined it
379
/// longer ago. A column with no setting: finished cards (a closed issue, a
380
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
381
/// stops growing while open work and notes stay. [`NEVER`]: none.
382194×
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
383194×
    let set = sqlx::query(
384194×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
385194×
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
386194×
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
387194×
    )
388194×
    .bind(now)
389194×
    .bind(DAY_MS)
390194×
    .execute(pool)
391194×
    .await?;
392194×
    let finished = sqlx::query(
393194×
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
394194×
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
395194×
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
396194×
               AND i.number = project_cards.number AND i.state = 'closed')) \
397194×
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
398194×
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
399194×
    )
400194×
    .bind(now)
401194×
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
402194×
    .execute(pool)
403194×
    .await?;
404194×
    Ok(set.rows_affected() + finished.rows_affected())
405194×
}
406
407
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
4080×
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
4090×
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
4100×
        .bind(card_id)
4110×
        .bind(project_id)
4120×
        .fetch_optional(pool)
4130×
        .await?
414
    else {
4150×
        return Ok(());
416
    };
4170×
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
4180×
        .bind(card.column_id)
4190×
        .fetch_all(pool)
4200×
        .await?;
4210×
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
4220×
    let j = i as i64 + by;
4230×
    if j < 0 || j as usize >= col.len() {
4240×
        return Ok(());
4250×
    }
4260×
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
4270×
    ids.swap(i, j as usize);
4280×
    for (pos, id) in ids.iter().enumerate() {
4290×
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
430
    }
4310×
    touch(pool, project_id).await
4320×
}
433
4343×
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
4353×
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
4363×
    touch(pool, project_id).await
4373×
}
438
439
/// The projects (and column names) an issue or proposal is on.
440
/// Make `column_id` the board's column for finished work, or none (#325).
4412×
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
4422×
    let mut tx = super::begin_write(pool).await?;
4432×
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
4442×
    if let Some(c) = column_id {
4451×
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
4461×
            .bind(c)
4471×
            .bind(project_id)
4481×
            .execute(&mut *tx)
4491×
            .await?;
4501×
    }
4512×
    tx.commit().await?;
4522×
    touch(pool, project_id).await
4532×
}
454
455
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
456
/// its cards move to their boards' column for finished work, where a board
457
/// has one (#325). Archived cards stay archived.
45834×
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
45934×
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
46034×
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
46134×
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
46234×
    )
46334×
    .bind(repo_id)
46434×
    .bind(number)
46534×
    .fetch_all(pool)
46634×
    .await?;
46734×
    for (card, project, column) in cards {
4683×
        let to: Option<i64> =
4693×
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
4703×
                .bind(project)
4713×
                .fetch_optional(pool)
4723×
                .await?;
4733×
        if let Some(to) = to.filter(|to| *to != column) {
4742×
            move_card(pool, project, card, to).await?;
4751×
        }
476
    }
47734×
    Ok(())
47834×
}
479
480103×
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
481103×
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
482103×
        "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'",
483103×
    )
484103×
    .bind(repo_id)
485103×
    .bind(number)
486103×
    .fetch_all(pool)
487103×
    .await?;
488103×
    let mut out = Vec::new();
489103×
    for (pid, col, archived) in rows {
4905×
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
4915×
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
4925×
            out.push((p, col));
4935×
        }
494
    }
495103×
    Ok(out)
496103×
}