OXYGENHUB
◆ v0.9.23 oxygenhub / crates / hub / src / db / projects.rs
24.8 KiB · Rust · last changed Board columns by API and oh, newest-run watch, close with a comment (closes #366, closes #367, closes #368) 1 day ago source blame history raw
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
70
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
71
    let mut tx = super::begin_write(pool).await?;
72
    let number: i64 =
73
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
74
    let now = now_ms();
75
    let p: Project = sqlx::query_as(
76
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
77
    )
78
    .bind(owner)
79
    .bind(number)
80
    .bind(title)
81
    .bind(description)
82
    .bind(public)
83
    .bind(now)
84
    .bind(now)
85
    .fetch_one(&mut *tx)
86
    .await?;
87
    // A board here now: an old address of a transferred one gives way (#125).
88
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
89
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
90
        sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)")
91
            .bind(p.id)
92
            .bind(name)
93
            .bind(i as i64)
94
            .bind(*name == "Done")
95
            .execute(&mut *tx)
96
            .await?;
97
    }
98
    tx.commit().await?;
99
    Ok(p)
100
}
101
102
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
103
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
104
}
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.
108
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
109
    let mut tx = super::begin_write(pool).await?;
110
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
111
    let number: i64 =
112
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
113
    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")
114
        .bind(&old.owner)
115
        .bind(old.number)
116
        .bind(id)
117
        .execute(&mut *tx)
118
        .await?;
119
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
120
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
121
        .bind(to)
122
        .bind(number)
123
        .bind(now_ms())
124
        .bind(id)
125
        .fetch_one(&mut *tx)
126
        .await?;
127
    tx.commit().await?;
128
    Ok(moved)
129
}
130
131
/// The project an old address (`owner`, `number`) now redirects to, after
132
/// a transfer (#125).
133
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
134
    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")
135
        .bind(owner)
136
        .bind(number)
137
        .fetch_optional(pool)
138
        .await
139
}
140
141
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
142
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
143
}
144
145
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
146
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
147
        .bind(title)
148
        .bind(description)
149
        .bind(public)
150
        .bind(closed)
151
        .bind(now_ms())
152
        .bind(id)
153
        .execute(pool)
154
        .await?;
155
    Ok(())
156
}
157
158
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
159
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
160
    Ok(())
161
}
162
163
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
164
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
165
    Ok(())
166
}
167
168
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
169
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
170
}
171
172
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
173
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
174
}
175
176
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
177
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
178
        .bind(project_id)
179
        .fetch_one(pool)
180
        .await?;
181
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
182
        .bind(project_id)
183
        .bind(name)
184
        .bind(pos)
185
        .execute(pool)
186
        .await?;
187
    touch(pool, project_id).await
188
}
189
190
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
191
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
192
        .bind(name)
193
        .bind(column_id)
194
        .bind(project_id)
195
        .execute(pool)
196
        .await?;
197
    touch(pool, project_id).await
198
}
199
200
/// Swap a column with its neighbour (`-1` left, `1` right).
201
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
202
    let cols = columns(pool, project_id).await?;
203
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
204
    let j = i as i64 + by;
205
    if j < 0 || j as usize >= cols.len() {
206
        return Ok(());
207
    }
208
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
209
    ids.swap(i, j as usize);
210
    for (pos, id) in ids.iter().enumerate() {
211
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
212
    }
213
    touch(pool, project_id).await
214
}
215
216
/// Move a column to `position` (0 the leftmost; past the end the rightmost).
217
pub async fn place_column(pool: &Pool, project_id: i64, column_id: i64, position: i64) -> sqlx::Result<()> {
218
    let cols = columns(pool, project_id).await?;
219
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
220
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
221
    let id = ids.remove(i);
222
    ids.insert((position.max(0) as usize).min(ids.len()), id);
223
    for (pos, id) in ids.iter().enumerate() {
224
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
225
    }
226
    touch(pool, project_id).await
227
}
228
229
/// Remove a column; its cards go to the first remaining column. The last column stays.
230
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
231
    let cols = columns(pool, project_id).await?;
232
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
233
    if !cols.iter().any(|c| c.id == column_id) {
234
        return Ok(false);
235
    }
236
    let mut tx = super::begin_write(pool).await?;
237
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
238
        .bind(to.id)
239
        .fetch_one(&mut *tx)
240
        .await?;
241
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
242
        .bind(to.id)
243
        .bind(base)
244
        .bind(column_id)
245
        .execute(&mut *tx)
246
        .await?;
247
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
248
    tx.commit().await?;
249
    touch(pool, project_id).await?;
250
    Ok(true)
251
}
252
253
pub struct NewCard<'a> {
254
    pub column_id: i64,
255
    pub kind: &'a str,
256
    pub repo_id: Option<i64>,
257
    pub number: Option<i64>,
258
    pub note: &'a str,
259
    pub added_by: &'a str,
260
}
261
262
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
263
/// proposal is already on the project.
264
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
265
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
266
        .bind(c.column_id)
267
        .fetch_one(pool)
268
        .await?;
269
    let r = sqlx::query(
270
        "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",
271
    )
272
    .bind(project_id)
273
    .bind(c.column_id)
274
    .bind(pos)
275
    .bind(c.kind)
276
    .bind(c.repo_id)
277
    .bind(c.number)
278
    .bind(c.note)
279
    .bind(c.added_by)
280
    .bind(now_ms())
281
    .execute(pool)
282
    .await?;
283
    touch(pool, project_id).await?;
284
    Ok(r.rows_affected() == 1)
285
}
286
287
/// Move a card to the bottom of another column of the same project.
288
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
289
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
290
        .bind(column_id)
291
        .bind(project_id)
292
        .fetch_one(pool)
293
        .await?;
294
    if ok == 0 {
295
        return Ok(());
296
    }
297
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
298
        .bind(column_id)
299
        .fetch_one(pool)
300
        .await?;
301
    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")
302
        .bind(column_id)
303
        .bind(pos)
304
        .bind(now_ms())
305
        .bind(card_id)
306
        .bind(project_id)
307
        .execute(pool)
308
        .await?;
309
    touch(pool, project_id).await
310
}
311
312
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
313
/// (#289).
314
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
315
    let mut tx = pool.begin().await?;
316
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
317
        .bind(column_id)
318
        .fetch_one(&mut *tx)
319
        .await?;
320
    let now = now_ms();
321
    for (i, id) in card_ids.iter().enumerate() {
322
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
323
            .bind(column_id)
324
            .bind(pos + i as i64)
325
            .bind(now)
326
            .bind(id)
327
            .bind(project_id)
328
            .execute(&mut *tx)
329
            .await?;
330
    }
331
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
332
    tx.commit().await
333
}
334
335
/// Take `card_ids` off the board, kept (#293); how many were on it.
336
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
337
    let mut tx = pool.begin().await?;
338
    let now = now_ms();
339
    let mut n = 0;
340
    for id in card_ids {
341
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
342
            .bind(now)
343
            .bind(id)
344
            .bind(project_id)
345
            .execute(&mut *tx)
346
            .await?
347
            .rows_affected();
348
    }
349
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
350
    tx.commit().await?;
351
    Ok(n)
352
}
353
354
/// Put an archived card back on the board, at the end of its column, as
355
/// if it had just joined it (so its column does not archive it again at
356
/// once); `false` when it was not archived.
357
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
358
    let mut tx = pool.begin().await?;
359
    let pos: i64 = sqlx::query_scalar(
360
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
361
    )
362
    .bind(card_id)
363
    .fetch_one(&mut *tx)
364
    .await?;
365
    let now = now_ms();
366
    // Back on the board, it no longer counts as done in a past iteration.
367
    let r = sqlx::query(
368
        "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \
369
         WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
370
    )
371
    .bind(now)
372
    .bind(pos)
373
    .bind(card_id)
374
    .bind(project_id)
375
    .execute(&mut *tx)
376
    .await?;
377
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
378
    tx.commit().await?;
379
    Ok(r.rows_affected() == 1)
380
}
381
382
/// Whether `card` joined its column more than `days` ago, at `now`.
383
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
384
    card.moved_ms < now - days * DAY_MS
385
}
386
387
/// A board's iterations, newest first (#343).
388
pub async fn iterations(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Iteration>> {
389
    sqlx::query_as("SELECT * FROM project_iterations WHERE project_id = $1 ORDER BY start_ms DESC, id DESC")
390
        .bind(project_id)
391
        .fetch_all(pool)
392
        .await
393
}
394
395
/// Start an iteration of `[start_ms, end_ms)`; `None` while another is open.
396
pub async fn start_iteration(pool: &Pool, project_id: i64, name: &str, start_ms: i64, end_ms: i64) -> sqlx::Result<Option<Iteration>> {
397
    let mut tx = super::begin_write(pool).await?;
398
    let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_iterations WHERE project_id = $1 AND closed_ms IS NULL")
399
        .bind(project_id)
400
        .fetch_one(&mut *tx)
401
        .await?;
402
    if open > 0 {
403
        return Ok(None);
404
    }
405
    let it: Iteration =
406
        sqlx::query_as("INSERT INTO project_iterations (project_id, name, start_ms, end_ms) VALUES ($1, $2, $3, $4) RETURNING *")
407
            .bind(project_id)
408
            .bind(name)
409
            .bind(start_ms)
410
            .bind(end_ms)
411
            .fetch_one(&mut *tx)
412
            .await?;
413
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?;
414
    tx.commit().await?;
415
    Ok(Some(it))
416
}
417
418
/// The column whose cards are done: the one that takes finished work, or
419
/// else the last.
420
pub fn done_column(columns: &[Column]) -> Option<i64> {
421
    columns.iter().find(|c| c.takes_finished).or(columns.last()).map(|c| c.id)
422
}
423
424
/// Close an open iteration: the cards on the board in `done` (its Done
425
/// column) are archived under it. How many, or `None` when it was not open.
426
pub async fn close_iteration(pool: &Pool, project_id: i64, iteration_id: i64, done: Option<i64>) -> sqlx::Result<Option<u64>> {
427
    let mut tx = super::begin_write(pool).await?;
428
    let now = now_ms();
429
    let closed = sqlx::query("UPDATE project_iterations SET closed_ms = $1 WHERE id = $2 AND project_id = $3 AND closed_ms IS NULL")
430
        .bind(now)
431
        .bind(iteration_id)
432
        .bind(project_id)
433
        .execute(&mut *tx)
434
        .await?;
435
    if closed.rows_affected() == 0 {
436
        return Ok(None);
437
    }
438
    let mut n = 0;
439
    if let Some(done) = done {
440
        n = sqlx::query(
441
            "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL",
442
        )
443
        .bind(now)
444
        .bind(iteration_id)
445
        .bind(project_id)
446
        .bind(done)
447
        .execute(&mut *tx)
448
        .await?
449
        .rows_affected();
450
    }
451
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
452
    tx.commit().await?;
453
    Ok(Some(n))
454
}
455
456
/// When each card done in `it` reached the Done column `done`: those
457
/// archived under it, and while it is open those in `done` now or archived
458
/// from there during it.
459
pub async fn done_times(pool: &Pool, it: &Iteration, done: Option<i64>) -> sqlx::Result<Vec<i64>> {
460
    sqlx::query_scalar(
461
        "SELECT moved_ms FROM project_cards WHERE project_id = $1 AND (iteration_id = $2 \
462
         OR ($3 AND iteration_id IS NULL AND column_id = $4 AND (archived_ms IS NULL OR archived_ms >= $5))) ORDER BY moved_ms",
463
    )
464
    .bind(it.project_id)
465
    .bind(it.id)
466
    .bind(it.closed_ms.is_none())
467
    .bind(done.unwrap_or(-1))
468
    .bind(it.start_ms)
469
    .fetch_all(pool)
470
    .await
471
}
472
473
/// The burn-up of an iteration: cards done by the end of each of its days
474
/// so far, from `times` (when each reached Done; earlier than the start
475
/// counts on the first day). One entry per day up to `now` or its end.
476
pub fn burn_up(times: &[i64], start_ms: i64, end_ms: i64, now: i64) -> Vec<usize> {
477
    let days = ((end_ms - start_ms + DAY_MS - 1) / DAY_MS).max(1);
478
    let upto = ((now.min(end_ms - 1) - start_ms) / DAY_MS + 1).clamp(0, days);
479
    (0..upto).map(|d| times.iter().filter(|&&t| t < start_ms + (d + 1) * DAY_MS).count()).collect()
480
}
481
482
/// An iteration's length in days.
483
pub fn iteration_days(it: &Iteration) -> i64 {
484
    ((it.end_ms - it.start_ms + DAY_MS - 1) / DAY_MS).max(1)
485
}
486
487
/// Archive a column's cards this many days after they join it; `None`
488
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
489
/// none.
490
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
491
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
492
        .bind(days)
493
        .bind(column_id)
494
        .bind(project_id)
495
        .execute(pool)
496
        .await?;
497
    touch(pool, project_id).await
498
}
499
500
/// Days after which a column with no setting of its own archives its
501
/// finished cards (#293): a board shows the last fortnight's done work.
502
pub const FINISHED_KEPT_DAYS: i64 = 14;
503
504
/// A column's `archive_after_days` that means never.
505
pub const NEVER: i64 = -1;
506
507
/// Archive, on every board, the cards their columns keep no longer (#293);
508
/// how many. A column that archives after N days: every card that joined it
509
/// longer ago. A column with no setting: finished cards (a closed issue, a
510
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
511
/// stops growing while open work and notes stay. [`NEVER`]: none.
512
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
513
    let set = sqlx::query(
514
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
515
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
516
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
517
    )
518
    .bind(now)
519
    .bind(DAY_MS)
520
    .execute(pool)
521
    .await?;
522
    let finished = sqlx::query(
523
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
524
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
525
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
526
               AND i.number = project_cards.number AND i.state = 'closed')) \
527
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
528
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
529
    )
530
    .bind(now)
531
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
532
    .execute(pool)
533
    .await?;
534
    Ok(set.rows_affected() + finished.rows_affected())
535
}
536
537
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
538
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
539
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
540
        .bind(card_id)
541
        .bind(project_id)
542
        .fetch_optional(pool)
543
        .await?
544
    else {
545
        return Ok(());
546
    };
547
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
548
        .bind(card.column_id)
549
        .fetch_all(pool)
550
        .await?;
551
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
552
    let j = i as i64 + by;
553
    if j < 0 || j as usize >= col.len() {
554
        return Ok(());
555
    }
556
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
557
    ids.swap(i, j as usize);
558
    for (pos, id) in ids.iter().enumerate() {
559
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
560
    }
561
    touch(pool, project_id).await
562
}
563
564
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
565
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
566
    touch(pool, project_id).await
567
}
568
569
/// The projects (and column names) an issue or proposal is on.
570
/// Make `column_id` the board's column for finished work, or none (#325).
571
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
572
    let mut tx = super::begin_write(pool).await?;
573
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
574
    if let Some(c) = column_id {
575
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
576
            .bind(c)
577
            .bind(project_id)
578
            .execute(&mut *tx)
579
            .await?;
580
    }
581
    tx.commit().await?;
582
    touch(pool, project_id).await
583
}
584
585
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
586
/// its cards move to their boards' column for finished work, where a board
587
/// has one (#325). Archived cards stay archived.
588
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
589
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
590
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
591
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
592
    )
593
    .bind(repo_id)
594
    .bind(number)
595
    .fetch_all(pool)
596
    .await?;
597
    for (card, project, column) in cards {
598
        let to: Option<i64> =
599
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
600
                .bind(project)
601
                .fetch_optional(pool)
602
                .await?;
603
        if let Some(to) = to.filter(|to| *to != column) {
604
            move_card(pool, project, card, to).await?;
605
        }
606
    }
607
    Ok(())
608
}
609
610
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
611
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
612
        "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'",
613
    )
614
    .bind(repo_id)
615
    .bind(number)
616
    .fetch_all(pool)
617
    .await?;
618
    let mut out = Vec::new();
619
    for (pid, col, archived) in rows {
620
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
621
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
622
            out.push((p, col));
623
        }
624
    }
625
    Ok(out)
626
}