OXYGENHUB
# 58ac360b3345 oxygenhub / crates / hub / src / db / projects.rs
24.1 KiB · Rust · last changed Iterations on project boards: a named span of days with a burn-up of cards done; closing one archives its Done column's cards under it, its page lists them (closes #343) 9 days 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
/// Remove a column; its cards go to the first remaining column. The last column stays.
217
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
218
    let cols = columns(pool, project_id).await?;
219
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
220
    if !cols.iter().any(|c| c.id == column_id) {
221
        return Ok(false);
222
    }
223
    let mut tx = super::begin_write(pool).await?;
224
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
225
        .bind(to.id)
226
        .fetch_one(&mut *tx)
227
        .await?;
228
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
229
        .bind(to.id)
230
        .bind(base)
231
        .bind(column_id)
232
        .execute(&mut *tx)
233
        .await?;
234
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
235
    tx.commit().await?;
236
    touch(pool, project_id).await?;
237
    Ok(true)
238
}
239
240
pub struct NewCard<'a> {
241
    pub column_id: i64,
242
    pub kind: &'a str,
243
    pub repo_id: Option<i64>,
244
    pub number: Option<i64>,
245
    pub note: &'a str,
246
    pub added_by: &'a str,
247
}
248
249
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
250
/// proposal is already on the project.
251
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
252
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
253
        .bind(c.column_id)
254
        .fetch_one(pool)
255
        .await?;
256
    let r = sqlx::query(
257
        "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",
258
    )
259
    .bind(project_id)
260
    .bind(c.column_id)
261
    .bind(pos)
262
    .bind(c.kind)
263
    .bind(c.repo_id)
264
    .bind(c.number)
265
    .bind(c.note)
266
    .bind(c.added_by)
267
    .bind(now_ms())
268
    .execute(pool)
269
    .await?;
270
    touch(pool, project_id).await?;
271
    Ok(r.rows_affected() == 1)
272
}
273
274
/// Move a card to the bottom of another column of the same project.
275
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
276
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
277
        .bind(column_id)
278
        .bind(project_id)
279
        .fetch_one(pool)
280
        .await?;
281
    if ok == 0 {
282
        return Ok(());
283
    }
284
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
285
        .bind(column_id)
286
        .fetch_one(pool)
287
        .await?;
288
    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")
289
        .bind(column_id)
290
        .bind(pos)
291
        .bind(now_ms())
292
        .bind(card_id)
293
        .bind(project_id)
294
        .execute(pool)
295
        .await?;
296
    touch(pool, project_id).await
297
}
298
299
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
300
/// (#289).
301
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
302
    let mut tx = pool.begin().await?;
303
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
304
        .bind(column_id)
305
        .fetch_one(&mut *tx)
306
        .await?;
307
    let now = now_ms();
308
    for (i, id) in card_ids.iter().enumerate() {
309
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
310
            .bind(column_id)
311
            .bind(pos + i as i64)
312
            .bind(now)
313
            .bind(id)
314
            .bind(project_id)
315
            .execute(&mut *tx)
316
            .await?;
317
    }
318
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
319
    tx.commit().await
320
}
321
322
/// Take `card_ids` off the board, kept (#293); how many were on it.
323
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
324
    let mut tx = pool.begin().await?;
325
    let now = now_ms();
326
    let mut n = 0;
327
    for id in card_ids {
328
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
329
            .bind(now)
330
            .bind(id)
331
            .bind(project_id)
332
            .execute(&mut *tx)
333
            .await?
334
            .rows_affected();
335
    }
336
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
337
    tx.commit().await?;
338
    Ok(n)
339
}
340
341
/// Put an archived card back on the board, at the end of its column, as
342
/// if it had just joined it (so its column does not archive it again at
343
/// once); `false` when it was not archived.
344
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
345
    let mut tx = pool.begin().await?;
346
    let pos: i64 = sqlx::query_scalar(
347
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
348
    )
349
    .bind(card_id)
350
    .fetch_one(&mut *tx)
351
    .await?;
352
    let now = now_ms();
353
    // Back on the board, it no longer counts as done in a past iteration.
354
    let r = sqlx::query(
355
        "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \
356
         WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
357
    )
358
    .bind(now)
359
    .bind(pos)
360
    .bind(card_id)
361
    .bind(project_id)
362
    .execute(&mut *tx)
363
    .await?;
364
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
365
    tx.commit().await?;
366
    Ok(r.rows_affected() == 1)
367
}
368
369
/// Whether `card` joined its column more than `days` ago, at `now`.
370
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
371
    card.moved_ms < now - days * DAY_MS
372
}
373
374
/// A board's iterations, newest first (#343).
375
pub async fn iterations(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Iteration>> {
376
    sqlx::query_as("SELECT * FROM project_iterations WHERE project_id = $1 ORDER BY start_ms DESC, id DESC")
377
        .bind(project_id)
378
        .fetch_all(pool)
379
        .await
380
}
381
382
/// Start an iteration of `[start_ms, end_ms)`; `None` while another is open.
383
pub async fn start_iteration(pool: &Pool, project_id: i64, name: &str, start_ms: i64, end_ms: i64) -> sqlx::Result<Option<Iteration>> {
384
    let mut tx = super::begin_write(pool).await?;
385
    let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_iterations WHERE project_id = $1 AND closed_ms IS NULL")
386
        .bind(project_id)
387
        .fetch_one(&mut *tx)
388
        .await?;
389
    if open > 0 {
390
        return Ok(None);
391
    }
392
    let it: Iteration =
393
        sqlx::query_as("INSERT INTO project_iterations (project_id, name, start_ms, end_ms) VALUES ($1, $2, $3, $4) RETURNING *")
394
            .bind(project_id)
395
            .bind(name)
396
            .bind(start_ms)
397
            .bind(end_ms)
398
            .fetch_one(&mut *tx)
399
            .await?;
400
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?;
401
    tx.commit().await?;
402
    Ok(Some(it))
403
}
404
405
/// The column whose cards are done: the one that takes finished work, or
406
/// else the last.
407
pub fn done_column(columns: &[Column]) -> Option<i64> {
408
    columns.iter().find(|c| c.takes_finished).or(columns.last()).map(|c| c.id)
409
}
410
411
/// Close an open iteration: the cards on the board in `done` (its Done
412
/// column) are archived under it. How many, or `None` when it was not open.
413
pub async fn close_iteration(pool: &Pool, project_id: i64, iteration_id: i64, done: Option<i64>) -> sqlx::Result<Option<u64>> {
414
    let mut tx = super::begin_write(pool).await?;
415
    let now = now_ms();
416
    let closed = sqlx::query("UPDATE project_iterations SET closed_ms = $1 WHERE id = $2 AND project_id = $3 AND closed_ms IS NULL")
417
        .bind(now)
418
        .bind(iteration_id)
419
        .bind(project_id)
420
        .execute(&mut *tx)
421
        .await?;
422
    if closed.rows_affected() == 0 {
423
        return Ok(None);
424
    }
425
    let mut n = 0;
426
    if let Some(done) = done {
427
        n = sqlx::query(
428
            "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL",
429
        )
430
        .bind(now)
431
        .bind(iteration_id)
432
        .bind(project_id)
433
        .bind(done)
434
        .execute(&mut *tx)
435
        .await?
436
        .rows_affected();
437
    }
438
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
439
    tx.commit().await?;
440
    Ok(Some(n))
441
}
442
443
/// When each card done in `it` reached the Done column `done`: those
444
/// archived under it, and while it is open those in `done` now or archived
445
/// from there during it.
446
pub async fn done_times(pool: &Pool, it: &Iteration, done: Option<i64>) -> sqlx::Result<Vec<i64>> {
447
    sqlx::query_scalar(
448
        "SELECT moved_ms FROM project_cards WHERE project_id = $1 AND (iteration_id = $2 \
449
         OR ($3 AND iteration_id IS NULL AND column_id = $4 AND (archived_ms IS NULL OR archived_ms >= $5))) ORDER BY moved_ms",
450
    )
451
    .bind(it.project_id)
452
    .bind(it.id)
453
    .bind(it.closed_ms.is_none())
454
    .bind(done.unwrap_or(-1))
455
    .bind(it.start_ms)
456
    .fetch_all(pool)
457
    .await
458
}
459
460
/// The burn-up of an iteration: cards done by the end of each of its days
461
/// so far, from `times` (when each reached Done; earlier than the start
462
/// counts on the first day). One entry per day up to `now` or its end.
463
pub fn burn_up(times: &[i64], start_ms: i64, end_ms: i64, now: i64) -> Vec<usize> {
464
    let days = ((end_ms - start_ms + DAY_MS - 1) / DAY_MS).max(1);
465
    let upto = ((now.min(end_ms - 1) - start_ms) / DAY_MS + 1).clamp(0, days);
466
    (0..upto).map(|d| times.iter().filter(|&&t| t < start_ms + (d + 1) * DAY_MS).count()).collect()
467
}
468
469
/// An iteration's length in days.
470
pub fn iteration_days(it: &Iteration) -> i64 {
471
    ((it.end_ms - it.start_ms + DAY_MS - 1) / DAY_MS).max(1)
472
}
473
474
/// Archive a column's cards this many days after they join it; `None`
475
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
476
/// none.
477
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
478
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
479
        .bind(days)
480
        .bind(column_id)
481
        .bind(project_id)
482
        .execute(pool)
483
        .await?;
484
    touch(pool, project_id).await
485
}
486
487
/// Days after which a column with no setting of its own archives its
488
/// finished cards (#293): a board shows the last fortnight's done work.
489
pub const FINISHED_KEPT_DAYS: i64 = 14;
490
491
/// A column's `archive_after_days` that means never.
492
pub const NEVER: i64 = -1;
493
494
/// Archive, on every board, the cards their columns keep no longer (#293);
495
/// how many. A column that archives after N days: every card that joined it
496
/// longer ago. A column with no setting: finished cards (a closed issue, a
497
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
498
/// stops growing while open work and notes stay. [`NEVER`]: none.
499
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
500
    let set = sqlx::query(
501
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
502
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
503
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
504
    )
505
    .bind(now)
506
    .bind(DAY_MS)
507
    .execute(pool)
508
    .await?;
509
    let finished = sqlx::query(
510
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
511
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
512
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
513
               AND i.number = project_cards.number AND i.state = 'closed')) \
514
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
515
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
516
    )
517
    .bind(now)
518
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
519
    .execute(pool)
520
    .await?;
521
    Ok(set.rows_affected() + finished.rows_affected())
522
}
523
524
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
525
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
526
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
527
        .bind(card_id)
528
        .bind(project_id)
529
        .fetch_optional(pool)
530
        .await?
531
    else {
532
        return Ok(());
533
    };
534
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
535
        .bind(card.column_id)
536
        .fetch_all(pool)
537
        .await?;
538
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
539
    let j = i as i64 + by;
540
    if j < 0 || j as usize >= col.len() {
541
        return Ok(());
542
    }
543
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
544
    ids.swap(i, j as usize);
545
    for (pos, id) in ids.iter().enumerate() {
546
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
547
    }
548
    touch(pool, project_id).await
549
}
550
551
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
552
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
553
    touch(pool, project_id).await
554
}
555
556
/// The projects (and column names) an issue or proposal is on.
557
/// Make `column_id` the board's column for finished work, or none (#325).
558
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
559
    let mut tx = super::begin_write(pool).await?;
560
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
561
    if let Some(c) = column_id {
562
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
563
            .bind(c)
564
            .bind(project_id)
565
            .execute(&mut *tx)
566
            .await?;
567
    }
568
    tx.commit().await?;
569
    touch(pool, project_id).await
570
}
571
572
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
573
/// its cards move to their boards' column for finished work, where a board
574
/// has one (#325). Archived cards stay archived.
575
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
576
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
577
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
578
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
579
    )
580
    .bind(repo_id)
581
    .bind(number)
582
    .fetch_all(pool)
583
    .await?;
584
    for (card, project, column) in cards {
585
        let to: Option<i64> =
586
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
587
                .bind(project)
588
                .fetch_optional(pool)
589
                .await?;
590
        if let Some(to) = to.filter(|to| *to != column) {
591
            move_card(pool, project, card, to).await?;
592
        }
593
    }
594
    Ok(())
595
}
596
597
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
598
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
599
        "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'",
600
    )
601
    .bind(repo_id)
602
    .bind(number)
603
    .fetch_all(pool)
604
    .await?;
605
    let mut out = Vec::new();
606
    for (pid, col, archived) in rows {
607
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
608
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
609
            out.push((p, col));
610
        }
611
    }
612
    Ok(out)
613
}