OXYGENHUB
# f2d2776b2a14 oxygenhub / crates / hub / src / db / projects.rs
31.3 KiB · Rust · last changed Board views: saved views, filter bar, lanes, WIP limits, table, keys, card history (refs #361) 2 days ago source blame history raw
e1ec9fbf Andy McCall ↶ 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,
439ecd87 Andy McCall ↶ 25
    /// Its cards are archived this many days after they joined it (#293).
26
    pub archive_after_days: Option<i64>,
1000b40a Andy McCall ↶ 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,
0bc95135 Andy McCall ↶ 30
    /// Work in progress past this many cards turns its header amber (#361).
31
    #[sqlx(default)]
32
    pub wip_limit: Option<i64>,
e1ec9fbf Andy McCall ↶ 33
}
34
35
#[derive(Clone, Debug, FromRow)]
36
pub struct Card {
37
    pub id: i64,
38
    pub project_id: i64,
39
    pub column_id: i64,
40
    pub position: i64,
41
    pub kind: String,
42
    pub repo_id: Option<i64>,
43
    pub number: Option<i64>,
44
    pub note: String,
45
    pub added_by: String,
46
    pub created_ms: i64,
439ecd87 Andy McCall ↶ 47
    /// Off the board since then (#293): kept, and restorable.
48
    pub archived_ms: Option<i64>,
49
    /// When it joined its column.
50
    pub moved_ms: i64,
046e3352 Andy McCall ↶ 51
    /// The iteration it was archived under when that closed (#343).
52
    pub iteration_id: Option<i64>,
e1ec9fbf Andy McCall ↶ 53
}
54
046e3352 Andy McCall ↶ 55
/// A named span of a board's days (#343). A board has one open at most;
56
/// closing it archives its Done column's cards under it.
57
#[derive(Clone, Debug, FromRow)]
58
pub struct Iteration {
59
    pub id: i64,
60
    pub project_id: i64,
61
    pub name: String,
62
    pub start_ms: i64,
63
    /// The end, exclusive: the midnight after its last day.
64
    pub end_ms: i64,
65
    pub closed_ms: Option<i64>,
66
}
67
439ecd87 Andy McCall ↶ 68
/// A day, for the archive's ages.
69
const DAY_MS: i64 = 86_400_000;
70
e1ec9fbf Andy McCall ↶ 71
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
72
73
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
941abdd6 Andy McCall ↶ 74
    let mut tx = super::begin_write(pool).await?;
e1ec9fbf Andy McCall ↶ 75
    let number: i64 =
5d90e0e0 Andy McCall 76
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
e1ec9fbf Andy McCall ↶ 77
    let now = now_ms();
78
    let p: Project = sqlx::query_as(
5d90e0e0 Andy McCall 79
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
e1ec9fbf Andy McCall ↶ 80
    )
81
    .bind(owner)
82
    .bind(number)
83
    .bind(title)
84
    .bind(description)
85
    .bind(public)
86
    .bind(now)
87
    .bind(now)
88
    .fetch_one(&mut *tx)
89
    .await?;
b1e92a62 Andy McCall ↶ 90
    // A board here now: an old address of a transferred one gives way (#125).
5d90e0e0 Andy McCall 91
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
e1ec9fbf Andy McCall ↶ 92
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
1000b40a Andy McCall ↶ 93
        sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)")
e1ec9fbf Andy McCall ↶ 94
            .bind(p.id)
95
            .bind(name)
96
            .bind(i as i64)
1000b40a Andy McCall ↶ 97
            .bind(*name == "Done")
e1ec9fbf Andy McCall ↶ 98
            .execute(&mut *tx)
99
            .await?;
100
    }
101
    tx.commit().await?;
102
    Ok(p)
103
}
104
105
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
5d90e0e0 Andy McCall 106
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
e1ec9fbf Andy McCall ↶ 107
}
108
b1e92a62 Andy McCall ↶ 109
/// Move project `id` to owner `to` (#125): it takes the next number there,
110
/// with its columns, cards and privacy, and its old address redirects to it.
111
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
112
    let mut tx = super::begin_write(pool).await?;
5d90e0e0 Andy McCall 113
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
b1e92a62 Andy McCall ↶ 114
    let number: i64 =
5d90e0e0 Andy McCall 115
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
d1bf2d69 Andy McCall ↶ 116
    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")
b1e92a62 Andy McCall ↶ 117
        .bind(&old.owner)
118
        .bind(old.number)
119
        .bind(id)
120
        .execute(&mut *tx)
121
        .await?;
5d90e0e0 Andy McCall 122
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
123
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
b1e92a62 Andy McCall ↶ 124
        .bind(to)
125
        .bind(number)
126
        .bind(now_ms())
127
        .bind(id)
128
        .fetch_one(&mut *tx)
129
        .await?;
130
    tx.commit().await?;
131
    Ok(moved)
132
}
133
134
/// The project an old address (`owner`, `number`) now redirects to, after
135
/// a transfer (#125).
136
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
5d90e0e0 Andy McCall 137
    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")
b1e92a62 Andy McCall ↶ 138
        .bind(owner)
139
        .bind(number)
140
        .fetch_optional(pool)
141
        .await
142
}
143
e1ec9fbf Andy McCall ↶ 144
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
5d90e0e0 Andy McCall 145
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
e1ec9fbf Andy McCall ↶ 146
}
147
148
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 149
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
e1ec9fbf Andy McCall ↶ 150
        .bind(title)
151
        .bind(description)
152
        .bind(public)
153
        .bind(closed)
154
        .bind(now_ms())
155
        .bind(id)
156
        .execute(pool)
157
        .await?;
158
    Ok(())
159
}
160
161
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 162
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
e1ec9fbf Andy McCall ↶ 163
    Ok(())
164
}
165
166
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 167
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
e1ec9fbf Andy McCall ↶ 168
    Ok(())
169
}
170
171
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
5d90e0e0 Andy McCall 172
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
e1ec9fbf Andy McCall ↶ 173
}
174
175
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
5d90e0e0 Andy McCall 176
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
e1ec9fbf Andy McCall ↶ 177
}
178
179
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 180
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
e1ec9fbf Andy McCall ↶ 181
        .bind(project_id)
182
        .fetch_one(pool)
183
        .await?;
5d90e0e0 Andy McCall 184
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
e1ec9fbf Andy McCall ↶ 185
        .bind(project_id)
186
        .bind(name)
187
        .bind(pos)
188
        .execute(pool)
189
        .await?;
190
    touch(pool, project_id).await
191
}
192
193
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 194
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
e1ec9fbf Andy McCall ↶ 195
        .bind(name)
196
        .bind(column_id)
197
        .bind(project_id)
198
        .execute(pool)
199
        .await?;
200
    touch(pool, project_id).await
201
}
202
203
/// Swap a column with its neighbour (`-1` left, `1` right).
204
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
205
    let cols = columns(pool, project_id).await?;
206
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
207
    let j = i as i64 + by;
208
    if j < 0 || j as usize >= cols.len() {
209
        return Ok(());
210
    }
211
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
212
    ids.swap(i, j as usize);
213
    for (pos, id) in ids.iter().enumerate() {
5d90e0e0 Andy McCall 214
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
e1ec9fbf Andy McCall ↶ 215
    }
216
    touch(pool, project_id).await
217
}
218
306aec18 Andy McCall ↶ 219
/// Move a column to `position` (0 the leftmost; past the end the rightmost).
220
pub async fn place_column(pool: &Pool, project_id: i64, column_id: i64, position: i64) -> sqlx::Result<()> {
221
    let cols = columns(pool, project_id).await?;
222
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
223
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
224
    let id = ids.remove(i);
225
    ids.insert((position.max(0) as usize).min(ids.len()), id);
226
    for (pos, id) in ids.iter().enumerate() {
227
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
228
    }
229
    touch(pool, project_id).await
230
}
231
e1ec9fbf Andy McCall ↶ 232
/// Remove a column; its cards go to the first remaining column. The last column stays.
233
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
234
    let cols = columns(pool, project_id).await?;
235
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
236
    if !cols.iter().any(|c| c.id == column_id) {
237
        return Ok(false);
238
    }
941abdd6 Andy McCall ↶ 239
    let mut tx = super::begin_write(pool).await?;
5d90e0e0 Andy McCall 240
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
e1ec9fbf Andy McCall ↶ 241
        .bind(to.id)
242
        .fetch_one(&mut *tx)
243
        .await?;
0bc95135 Andy McCall ↶ 244
    log_moves(&mut *tx, Some(to.id), now_ms(), "column_id = $1 AND archived_ms IS NULL", &[column_id]).await?;
5d90e0e0 Andy McCall 245
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
e1ec9fbf Andy McCall ↶ 246
        .bind(to.id)
247
        .bind(base)
248
        .bind(column_id)
249
        .execute(&mut *tx)
250
        .await?;
5d90e0e0 Andy McCall 251
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
e1ec9fbf Andy McCall ↶ 252
    tx.commit().await?;
253
    touch(pool, project_id).await?;
254
    Ok(true)
255
}
256
0bc95135 Andy McCall ↶ 257
/// Record, for every card `select` picks (`SELECT project_id, id FROM
258
/// project_cards WHERE …`, bound from `$1` on), that it moved to `column`
259
/// (`None`: off the board) at `at` (#357). Called before the update that
260
/// moves them, with the same conditions.
261
async fn log_moves<'e, E: sqlx::Executor<'e, Database = super::Db>>(
262
    ex: E,
263
    column: Option<i64>,
264
    at: i64,
265
    select: &str,
266
    binds: &[i64],
267
) -> sqlx::Result<()> {
268
    let sql = format!(
269
        "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, {}, {at} FROM project_cards WHERE {select}",
270
        match column {
271
            Some(c) => c.to_string(),
272
            None => "NULL".to_string(),
273
        }
274
    );
275
    let mut q = sqlx::query(&sql);
276
    for b in binds {
277
        q = q.bind(*b);
278
    }
279
    q.execute(ex).await?;
280
    Ok(())
281
}
282
283
/// A board's card moves since `since`, oldest first: (card, column or
284
/// `None` off the board, when).
285
pub async fn moves(pool: &Pool, project_id: i64, since: i64) -> sqlx::Result<Vec<(i64, Option<i64>, i64)>> {
286
    sqlx::query_as("SELECT card_id, column_id, at_ms FROM project_card_moves WHERE project_id = $1 AND at_ms >= $2 ORDER BY at_ms, id")
287
        .bind(project_id)
288
        .bind(since)
289
        .fetch_all(pool)
290
        .await
291
}
292
e1ec9fbf Andy McCall ↶ 293
pub struct NewCard<'a> {
294
    pub column_id: i64,
295
    pub kind: &'a str,
296
    pub repo_id: Option<i64>,
297
    pub number: Option<i64>,
298
    pub note: &'a str,
299
    pub added_by: &'a str,
300
}
301
302
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
303
/// proposal is already on the project.
304
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
5d90e0e0 Andy McCall 305
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
e1ec9fbf Andy McCall ↶ 306
        .bind(c.column_id)
307
        .fetch_one(pool)
308
        .await?;
0bc95135 Andy McCall ↶ 309
    let now = now_ms();
310
    let id: Option<i64> = sqlx::query_scalar(
311
        "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 RETURNING id",
e1ec9fbf Andy McCall ↶ 312
    )
313
    .bind(project_id)
314
    .bind(c.column_id)
315
    .bind(pos)
316
    .bind(c.kind)
317
    .bind(c.repo_id)
318
    .bind(c.number)
319
    .bind(c.note)
320
    .bind(c.added_by)
0bc95135 Andy McCall ↶ 321
    .bind(now)
322
    .fetch_optional(pool)
e1ec9fbf Andy McCall ↶ 323
    .await?;
0bc95135 Andy McCall ↶ 324
    if let Some(id) = id {
325
        log_moves(pool, Some(c.column_id), now, "id = $1", &[id]).await?;
326
    }
e1ec9fbf Andy McCall ↶ 327
    touch(pool, project_id).await?;
0bc95135 Andy McCall ↶ 328
    Ok(id.is_some())
e1ec9fbf Andy McCall ↶ 329
}
330
331
/// Move a card to the bottom of another column of the same project.
332
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 333
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
e1ec9fbf Andy McCall ↶ 334
        .bind(column_id)
335
        .bind(project_id)
336
        .fetch_one(pool)
337
        .await?;
338
    if ok == 0 {
339
        return Ok(());
340
    }
5d90e0e0 Andy McCall 341
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
e1ec9fbf Andy McCall ↶ 342
        .bind(column_id)
343
        .fetch_one(pool)
344
        .await?;
0bc95135 Andy McCall ↶ 345
    let now = now_ms();
346
    log_moves(
347
        pool,
348
        Some(column_id),
349
        now,
350
        "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL",
351
        &[card_id, project_id, column_id],
352
    )
353
    .await?;
439ecd87 Andy McCall ↶ 354
    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")
e1ec9fbf Andy McCall ↶ 355
        .bind(column_id)
356
        .bind(pos)
0bc95135 Andy McCall ↶ 357
        .bind(now)
e1ec9fbf Andy McCall ↶ 358
        .bind(card_id)
359
        .bind(project_id)
360
        .execute(pool)
361
        .await?;
362
    touch(pool, project_id).await
363
}
364
456f4197 Andy McCall ↶ 365
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
366
/// (#289).
367
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
368
    let mut tx = pool.begin().await?;
369
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
370
        .bind(column_id)
371
        .fetch_one(&mut *tx)
372
        .await?;
439ecd87 Andy McCall ↶ 373
    let now = now_ms();
456f4197 Andy McCall ↶ 374
    for (i, id) in card_ids.iter().enumerate() {
0bc95135 Andy McCall ↶ 375
        log_moves(
376
            &mut *tx,
377
            Some(column_id),
378
            now,
379
            "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL",
380
            &[*id, project_id, column_id],
381
        )
382
        .await?;
439ecd87 Andy McCall ↶ 383
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
456f4197 Andy McCall ↶ 384
            .bind(column_id)
385
            .bind(pos + i as i64)
439ecd87 Andy McCall ↶ 386
            .bind(now)
456f4197 Andy McCall ↶ 387
            .bind(id)
388
            .bind(project_id)
389
            .execute(&mut *tx)
390
            .await?;
391
    }
439ecd87 Andy McCall ↶ 392
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
456f4197 Andy McCall ↶ 393
    tx.commit().await
439ecd87 Andy McCall ↶ 394
}
395
396
/// Take `card_ids` off the board, kept (#293); how many were on it.
397
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
398
    let mut tx = pool.begin().await?;
399
    let now = now_ms();
400
    let mut n = 0;
401
    for id in card_ids {
0bc95135 Andy McCall ↶ 402
        log_moves(&mut *tx, None, now, "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[*id, project_id]).await?;
439ecd87 Andy McCall ↶ 403
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
404
            .bind(now)
405
            .bind(id)
406
            .bind(project_id)
407
            .execute(&mut *tx)
408
            .await?
409
            .rows_affected();
410
    }
411
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
412
    tx.commit().await?;
413
    Ok(n)
414
}
415
416
/// Put an archived card back on the board, at the end of its column, as
417
/// if it had just joined it (so its column does not archive it again at
418
/// once); `false` when it was not archived.
419
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
420
    let mut tx = pool.begin().await?;
421
    let pos: i64 = sqlx::query_scalar(
422
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
423
    )
424
    .bind(card_id)
425
    .fetch_one(&mut *tx)
426
    .await?;
427
    let now = now_ms();
0bc95135 Andy McCall ↶ 428
    sqlx::query(&format!(
429
        "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, column_id, {now} FROM project_cards \
430
         WHERE id = $1 AND project_id = $2 AND archived_ms IS NOT NULL"
431
    ))
432
    .bind(card_id)
433
    .bind(project_id)
434
    .execute(&mut *tx)
435
    .await?;
046e3352 Andy McCall ↶ 436
    // Back on the board, it no longer counts as done in a past iteration.
439ecd87 Andy McCall ↶ 437
    let r = sqlx::query(
046e3352 Andy McCall ↶ 438
        "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \
439
         WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
439ecd87 Andy McCall ↶ 440
    )
441
    .bind(now)
442
    .bind(pos)
443
    .bind(card_id)
444
    .bind(project_id)
445
    .execute(&mut *tx)
446
    .await?;
447
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
448
    tx.commit().await?;
449
    Ok(r.rows_affected() == 1)
450
}
451
452
/// Whether `card` joined its column more than `days` ago, at `now`.
453
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
454
    card.moved_ms < now - days * DAY_MS
046e3352 Andy McCall ↶ 455
}
456
457
/// A board's iterations, newest first (#343).
458
pub async fn iterations(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Iteration>> {
459
    sqlx::query_as("SELECT * FROM project_iterations WHERE project_id = $1 ORDER BY start_ms DESC, id DESC")
460
        .bind(project_id)
461
        .fetch_all(pool)
462
        .await
463
}
464
465
/// Start an iteration of `[start_ms, end_ms)`; `None` while another is open.
466
pub async fn start_iteration(pool: &Pool, project_id: i64, name: &str, start_ms: i64, end_ms: i64) -> sqlx::Result<Option<Iteration>> {
467
    let mut tx = super::begin_write(pool).await?;
468
    let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_iterations WHERE project_id = $1 AND closed_ms IS NULL")
469
        .bind(project_id)
470
        .fetch_one(&mut *tx)
471
        .await?;
472
    if open > 0 {
473
        return Ok(None);
474
    }
475
    let it: Iteration =
476
        sqlx::query_as("INSERT INTO project_iterations (project_id, name, start_ms, end_ms) VALUES ($1, $2, $3, $4) RETURNING *")
477
            .bind(project_id)
478
            .bind(name)
479
            .bind(start_ms)
480
            .bind(end_ms)
481
            .fetch_one(&mut *tx)
482
            .await?;
483
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?;
484
    tx.commit().await?;
485
    Ok(Some(it))
486
}
487
488
/// The column whose cards are done: the one that takes finished work, or
489
/// else the last.
490
pub fn done_column(columns: &[Column]) -> Option<i64> {
491
    columns.iter().find(|c| c.takes_finished).or(columns.last()).map(|c| c.id)
492
}
493
494
/// Close an open iteration: the cards on the board in `done` (its Done
495
/// column) are archived under it. How many, or `None` when it was not open.
496
pub async fn close_iteration(pool: &Pool, project_id: i64, iteration_id: i64, done: Option<i64>) -> sqlx::Result<Option<u64>> {
497
    let mut tx = super::begin_write(pool).await?;
498
    let now = now_ms();
499
    let closed = sqlx::query("UPDATE project_iterations SET closed_ms = $1 WHERE id = $2 AND project_id = $3 AND closed_ms IS NULL")
500
        .bind(now)
501
        .bind(iteration_id)
502
        .bind(project_id)
503
        .execute(&mut *tx)
504
        .await?;
505
    if closed.rows_affected() == 0 {
506
        return Ok(None);
507
    }
508
    let mut n = 0;
509
    if let Some(done) = done {
0bc95135 Andy McCall ↶ 510
        log_moves(&mut *tx, None, now, "project_id = $1 AND column_id = $2 AND archived_ms IS NULL", &[project_id, done]).await?;
046e3352 Andy McCall ↶ 511
        n = sqlx::query(
512
            "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL",
513
        )
514
        .bind(now)
515
        .bind(iteration_id)
516
        .bind(project_id)
517
        .bind(done)
518
        .execute(&mut *tx)
519
        .await?
520
        .rows_affected();
521
    }
522
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
523
    tx.commit().await?;
524
    Ok(Some(n))
525
}
526
527
/// When each card done in `it` reached the Done column `done`: those
528
/// archived under it, and while it is open those in `done` now or archived
529
/// from there during it.
530
pub async fn done_times(pool: &Pool, it: &Iteration, done: Option<i64>) -> sqlx::Result<Vec<i64>> {
531
    sqlx::query_scalar(
532
        "SELECT moved_ms FROM project_cards WHERE project_id = $1 AND (iteration_id = $2 \
533
         OR ($3 AND iteration_id IS NULL AND column_id = $4 AND (archived_ms IS NULL OR archived_ms >= $5))) ORDER BY moved_ms",
534
    )
535
    .bind(it.project_id)
536
    .bind(it.id)
537
    .bind(it.closed_ms.is_none())
538
    .bind(done.unwrap_or(-1))
539
    .bind(it.start_ms)
540
    .fetch_all(pool)
541
    .await
542
}
543
544
/// The burn-up of an iteration: cards done by the end of each of its days
545
/// so far, from `times` (when each reached Done; earlier than the start
546
/// counts on the first day). One entry per day up to `now` or its end.
547
pub fn burn_up(times: &[i64], start_ms: i64, end_ms: i64, now: i64) -> Vec<usize> {
548
    let days = ((end_ms - start_ms + DAY_MS - 1) / DAY_MS).max(1);
549
    let upto = ((now.min(end_ms - 1) - start_ms) / DAY_MS + 1).clamp(0, days);
550
    (0..upto).map(|d| times.iter().filter(|&&t| t < start_ms + (d + 1) * DAY_MS).count()).collect()
551
}
552
553
/// An iteration's length in days.
554
pub fn iteration_days(it: &Iteration) -> i64 {
555
    ((it.end_ms - it.start_ms + DAY_MS - 1) / DAY_MS).max(1)
439ecd87 Andy McCall ↶ 556
}
557
946fe7a0 Andy McCall ↶ 558
/// Archive a column's cards this many days after they join it; `None`
559
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
560
/// none.
439ecd87 Andy McCall ↶ 561
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
562
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
563
        .bind(days)
564
        .bind(column_id)
565
        .bind(project_id)
566
        .execute(pool)
567
        .await?;
568
    touch(pool, project_id).await
569
}
570
946fe7a0 Andy McCall ↶ 571
/// Days after which a column with no setting of its own archives its
572
/// finished cards (#293): a board shows the last fortnight's done work.
573
pub const FINISHED_KEPT_DAYS: i64 = 14;
0bc95135 Andy McCall ↶ 574
575
/// The finished cards due for archiving: in a column with no setting of
576
/// its own, joined before `$1`, and their issue closed or proposal closed
577
/// or landed.
578
const FINISHED_DUE: &str = "archived_ms IS NULL AND moved_ms < $1 \
579
     AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
580
     AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
581
           AND i.number = project_cards.number AND i.state = 'closed')) \
582
       OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
583
           AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))";
946fe7a0 Andy McCall ↶ 584
585
/// A column's `archive_after_days` that means never.
586
pub const NEVER: i64 = -1;
587
588
/// Archive, on every board, the cards their columns keep no longer (#293);
589
/// how many. A column that archives after N days: every card that joined it
590
/// longer ago. A column with no setting: finished cards (a closed issue, a
591
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
592
/// stops growing while open work and notes stay. [`NEVER`]: none.
439ecd87 Andy McCall ↶ 593
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
0bc95135 Andy McCall ↶ 594
    log_moves(
595
        pool,
596
        None,
597
        now,
598
        "archived_ms IS NULL AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id \
599
         AND c.archive_after_days >= 0 AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
600
        &[now, DAY_MS],
601
    )
602
    .await?;
946fe7a0 Andy McCall ↶ 603
    let set = sqlx::query(
439ecd87 Andy McCall ↶ 604
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
946fe7a0 Andy McCall ↶ 605
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
439ecd87 Andy McCall ↶ 606
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
607
    )
608
    .bind(now)
609
    .bind(DAY_MS)
946fe7a0 Andy McCall ↶ 610
    .execute(pool)
611
    .await?;
0bc95135 Andy McCall ↶ 612
    log_moves(pool, None, now, FINISHED_DUE, &[now - FINISHED_KEPT_DAYS * DAY_MS]).await?;
613
    let finished = sqlx::query(&format!("UPDATE project_cards SET archived_ms = $2 WHERE {FINISHED_DUE}"))
614
        .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
615
        .bind(now)
616
        .execute(pool)
617
        .await?;
946fe7a0 Andy McCall ↶ 618
    Ok(set.rows_affected() + finished.rows_affected())
456f4197 Andy McCall ↶ 619
}
620
e1ec9fbf Andy McCall ↶ 621
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
622
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
5d90e0e0 Andy McCall 623
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
e1ec9fbf Andy McCall ↶ 624
        .bind(card_id)
625
        .bind(project_id)
626
        .fetch_optional(pool)
627
        .await?
628
    else {
629
        return Ok(());
630
    };
5d90e0e0 Andy McCall 631
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
e1ec9fbf Andy McCall ↶ 632
        .bind(card.column_id)
633
        .fetch_all(pool)
634
        .await?;
635
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
636
    let j = i as i64 + by;
637
    if j < 0 || j as usize >= col.len() {
638
        return Ok(());
639
    }
640
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
641
    ids.swap(i, j as usize);
642
    for (pos, id) in ids.iter().enumerate() {
5d90e0e0 Andy McCall 643
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
e1ec9fbf Andy McCall ↶ 644
    }
645
    touch(pool, project_id).await
646
}
647
648
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
0bc95135 Andy McCall ↶ 649
    log_moves(pool, None, now_ms(), "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[card_id, project_id]).await?;
5d90e0e0 Andy McCall 650
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
e1ec9fbf Andy McCall ↶ 651
    touch(pool, project_id).await
652
}
653
654
/// The projects (and column names) an issue or proposal is on.
1000b40a Andy McCall ↶ 655
/// Make `column_id` the board's column for finished work, or none (#325).
656
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
657
    let mut tx = super::begin_write(pool).await?;
658
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
659
    if let Some(c) = column_id {
660
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
661
            .bind(c)
662
            .bind(project_id)
663
            .execute(&mut *tx)
664
            .await?;
665
    }
666
    tx.commit().await?;
667
    touch(pool, project_id).await
668
}
669
670
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
671
/// its cards move to their boards' column for finished work, where a board
672
/// has one (#325). Archived cards stay archived.
673
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
674
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
675
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
676
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
677
    )
678
    .bind(repo_id)
679
    .bind(number)
680
    .fetch_all(pool)
681
    .await?;
682
    for (card, project, column) in cards {
683
        let to: Option<i64> =
684
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
685
                .bind(project)
686
                .fetch_optional(pool)
687
                .await?;
688
        if let Some(to) = to.filter(|to| *to != column) {
689
            move_card(pool, project, card, to).await?;
690
        }
691
    }
692
    Ok(())
693
}
694
e1ec9fbf Andy McCall ↶ 695
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
439ecd87 Andy McCall ↶ 696
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
697
        "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'",
e1ec9fbf Andy McCall ↶ 698
    )
699
    .bind(repo_id)
700
    .bind(number)
701
    .fetch_all(pool)
702
    .await?;
703
    let mut out = Vec::new();
439ecd87 Andy McCall ↶ 704
    for (pid, col, archived) in rows {
705
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
5d90e0e0 Andy McCall 706
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
e1ec9fbf Andy McCall ↶ 707
            out.push((p, col));
708
        }
709
    }
710
    Ok(out)
0bc95135 Andy McCall ↶ 711
}
712
713
/// A board's saved view (#361): a layout with its filter, lanes and sort.
714
#[derive(Clone, Debug, FromRow)]
715
pub struct View {
716
    pub id: i64,
717
    pub project_id: i64,
718
    pub name: String,
719
    /// `board` or `table`.
720
    pub layout: String,
721
    pub q: String,
722
    /// Swimlanes (board) or groups (table): `repo`, `assignee`, `label`,
723
    /// `milestone`, or empty.
724
    pub lane: String,
725
    /// The table's order: `title`, `reference`, `state`, `column`, `age`,
726
    /// a `-` before it for the reverse; empty is the board's order.
727
    pub sort: String,
728
    pub position: i64,
729
    pub created_ms: i64,
730
}
731
732
pub async fn views(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<View>> {
733
    sqlx::query_as("SELECT * FROM project_views WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
734
}
735
736
/// Save a view under `name`, replacing the one of that name.
737
pub async fn save_view(pool: &Pool, project_id: i64, name: &str, layout: &str, q: &str, lane: &str, sort: &str) -> sqlx::Result<View> {
738
    let mut tx = super::begin_write(pool).await?;
739
    let existing: Option<i64> = sqlx::query_scalar("SELECT id FROM project_views WHERE project_id = $1 AND name = $2")
740
        .bind(project_id)
741
        .bind(name)
742
        .fetch_optional(&mut *tx)
743
        .await?;
744
    let v: View = match existing {
745
        Some(id) => {
746
            sqlx::query_as("UPDATE project_views SET layout = $1, q = $2, lane = $3, sort = $4 WHERE id = $5 RETURNING *")
747
                .bind(layout)
748
                .bind(q)
749
                .bind(lane)
750
                .bind(sort)
751
                .bind(id)
752
                .fetch_one(&mut *tx)
753
                .await?
754
        }
755
        None => {
756
            let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_views WHERE project_id = $1")
757
                .bind(project_id)
758
                .fetch_one(&mut *tx)
759
                .await?;
760
            sqlx::query_as(
761
                "INSERT INTO project_views (project_id, name, layout, q, lane, sort, position, created_ms) \
762
                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
763
            )
764
            .bind(project_id)
765
            .bind(name)
766
            .bind(layout)
767
            .bind(q)
768
            .bind(lane)
769
            .bind(sort)
770
            .bind(pos)
771
            .bind(now_ms())
772
            .fetch_one(&mut *tx)
773
            .await?
774
        }
775
    };
776
    tx.commit().await?;
777
    touch(pool, project_id).await?;
778
    Ok(v)
779
}
780
781
pub async fn delete_view(pool: &Pool, project_id: i64, view_id: i64) -> sqlx::Result<bool> {
782
    let r = sqlx::query("DELETE FROM project_views WHERE id = $1 AND project_id = $2").bind(view_id).bind(project_id).execute(pool).await?;
783
    touch(pool, project_id).await?;
784
    Ok(r.rows_affected() == 1)
785
}
786
787
/// A column's work-in-progress limit (#361); `None` for none.
788
pub async fn set_wip_limit(pool: &Pool, project_id: i64, column_id: i64, limit: Option<i64>) -> sqlx::Result<()> {
789
    sqlx::query("UPDATE project_columns SET wip_limit = $1 WHERE id = $2 AND project_id = $3")
790
        .bind(limit)
791
        .bind(column_id)
792
        .bind(project_id)
793
        .execute(pool)
794
        .await?;
795
    touch(pool, project_id).await
e1ec9fbf Andy McCall ↶ 796
}