OXYGENHUB
# dd0788b756f2 oxygenhub / crates / hub / src / db / projects.rs
19.4 KiB · Rust · last changed Finished work moves to Done 10 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
}
49
50
/// A day, for the archive's ages.
51
const DAY_MS: i64 = 86_400_000;
52
53
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
54
55
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
56
    let mut tx = super::begin_write(pool).await?;
57
    let number: i64 =
58
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
59
    let now = now_ms();
60
    let p: Project = sqlx::query_as(
61
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
62
    )
63
    .bind(owner)
64
    .bind(number)
65
    .bind(title)
66
    .bind(description)
67
    .bind(public)
68
    .bind(now)
69
    .bind(now)
70
    .fetch_one(&mut *tx)
71
    .await?;
72
    // A board here now: an old address of a transferred one gives way (#125).
73
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
74
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
75
        sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)")
76
            .bind(p.id)
77
            .bind(name)
78
            .bind(i as i64)
79
            .bind(*name == "Done")
80
            .execute(&mut *tx)
81
            .await?;
82
    }
83
    tx.commit().await?;
84
    Ok(p)
85
}
86
87
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
88
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
89
}
90
91
/// Move project `id` to owner `to` (#125): it takes the next number there,
92
/// with its columns, cards and privacy, and its old address redirects to it.
93
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
94
    let mut tx = super::begin_write(pool).await?;
95
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
96
    let number: i64 =
97
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
98
    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")
99
        .bind(&old.owner)
100
        .bind(old.number)
101
        .bind(id)
102
        .execute(&mut *tx)
103
        .await?;
104
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
105
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
106
        .bind(to)
107
        .bind(number)
108
        .bind(now_ms())
109
        .bind(id)
110
        .fetch_one(&mut *tx)
111
        .await?;
112
    tx.commit().await?;
113
    Ok(moved)
114
}
115
116
/// The project an old address (`owner`, `number`) now redirects to, after
117
/// a transfer (#125).
118
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
119
    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")
120
        .bind(owner)
121
        .bind(number)
122
        .fetch_optional(pool)
123
        .await
124
}
125
126
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
127
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
128
}
129
130
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
131
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
132
        .bind(title)
133
        .bind(description)
134
        .bind(public)
135
        .bind(closed)
136
        .bind(now_ms())
137
        .bind(id)
138
        .execute(pool)
139
        .await?;
140
    Ok(())
141
}
142
143
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
144
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
145
    Ok(())
146
}
147
148
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
149
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
150
    Ok(())
151
}
152
153
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
154
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
155
}
156
157
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
158
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
159
}
160
161
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
162
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
163
        .bind(project_id)
164
        .fetch_one(pool)
165
        .await?;
166
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
167
        .bind(project_id)
168
        .bind(name)
169
        .bind(pos)
170
        .execute(pool)
171
        .await?;
172
    touch(pool, project_id).await
173
}
174
175
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
176
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
177
        .bind(name)
178
        .bind(column_id)
179
        .bind(project_id)
180
        .execute(pool)
181
        .await?;
182
    touch(pool, project_id).await
183
}
184
185
/// Swap a column with its neighbour (`-1` left, `1` right).
186
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
187
    let cols = columns(pool, project_id).await?;
188
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
189
    let j = i as i64 + by;
190
    if j < 0 || j as usize >= cols.len() {
191
        return Ok(());
192
    }
193
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
194
    ids.swap(i, j as usize);
195
    for (pos, id) in ids.iter().enumerate() {
196
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
197
    }
198
    touch(pool, project_id).await
199
}
200
201
/// Remove a column; its cards go to the first remaining column. The last column stays.
202
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
203
    let cols = columns(pool, project_id).await?;
204
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
205
    if !cols.iter().any(|c| c.id == column_id) {
206
        return Ok(false);
207
    }
208
    let mut tx = super::begin_write(pool).await?;
209
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
210
        .bind(to.id)
211
        .fetch_one(&mut *tx)
212
        .await?;
213
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
214
        .bind(to.id)
215
        .bind(base)
216
        .bind(column_id)
217
        .execute(&mut *tx)
218
        .await?;
219
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
220
    tx.commit().await?;
221
    touch(pool, project_id).await?;
222
    Ok(true)
223
}
224
225
pub struct NewCard<'a> {
226
    pub column_id: i64,
227
    pub kind: &'a str,
228
    pub repo_id: Option<i64>,
229
    pub number: Option<i64>,
230
    pub note: &'a str,
231
    pub added_by: &'a str,
232
}
233
234
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
235
/// proposal is already on the project.
236
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
237
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
238
        .bind(c.column_id)
239
        .fetch_one(pool)
240
        .await?;
241
    let r = sqlx::query(
242
        "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",
243
    )
244
    .bind(project_id)
245
    .bind(c.column_id)
246
    .bind(pos)
247
    .bind(c.kind)
248
    .bind(c.repo_id)
249
    .bind(c.number)
250
    .bind(c.note)
251
    .bind(c.added_by)
252
    .bind(now_ms())
253
    .execute(pool)
254
    .await?;
255
    touch(pool, project_id).await?;
256
    Ok(r.rows_affected() == 1)
257
}
258
259
/// Move a card to the bottom of another column of the same project.
260
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
261
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
262
        .bind(column_id)
263
        .bind(project_id)
264
        .fetch_one(pool)
265
        .await?;
266
    if ok == 0 {
267
        return Ok(());
268
    }
269
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
270
        .bind(column_id)
271
        .fetch_one(pool)
272
        .await?;
273
    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")
274
        .bind(column_id)
275
        .bind(pos)
276
        .bind(now_ms())
277
        .bind(card_id)
278
        .bind(project_id)
279
        .execute(pool)
280
        .await?;
281
    touch(pool, project_id).await
282
}
283
284
/// Move `card_ids`, in that order, to the end of `column_id`, all or none
285
/// (#289).
286
pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> {
287
    let mut tx = pool.begin().await?;
288
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
289
        .bind(column_id)
290
        .fetch_one(&mut *tx)
291
        .await?;
292
    let now = now_ms();
293
    for (i, id) in card_ids.iter().enumerate() {
294
        sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5")
295
            .bind(column_id)
296
            .bind(pos + i as i64)
297
            .bind(now)
298
            .bind(id)
299
            .bind(project_id)
300
            .execute(&mut *tx)
301
            .await?;
302
    }
303
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
304
    tx.commit().await
305
}
306
307
/// Take `card_ids` off the board, kept (#293); how many were on it.
308
pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> {
309
    let mut tx = pool.begin().await?;
310
    let now = now_ms();
311
    let mut n = 0;
312
    for id in card_ids {
313
        n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL")
314
            .bind(now)
315
            .bind(id)
316
            .bind(project_id)
317
            .execute(&mut *tx)
318
            .await?
319
            .rows_affected();
320
    }
321
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
322
    tx.commit().await?;
323
    Ok(n)
324
}
325
326
/// Put an archived card back on the board, at the end of its column, as
327
/// if it had just joined it (so its column does not archive it again at
328
/// once); `false` when it was not archived.
329
pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> {
330
    let mut tx = pool.begin().await?;
331
    let pos: i64 = sqlx::query_scalar(
332
        "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)",
333
    )
334
    .bind(card_id)
335
    .fetch_one(&mut *tx)
336
    .await?;
337
    let now = now_ms();
338
    let r = sqlx::query(
339
        "UPDATE project_cards SET archived_ms = NULL, moved_ms = $1, position = $2 WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL",
340
    )
341
    .bind(now)
342
    .bind(pos)
343
    .bind(card_id)
344
    .bind(project_id)
345
    .execute(&mut *tx)
346
    .await?;
347
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?;
348
    tx.commit().await?;
349
    Ok(r.rows_affected() == 1)
350
}
351
352
/// Whether `card` joined its column more than `days` ago, at `now`.
353
pub fn older_than(card: &Card, days: i64, now: i64) -> bool {
354
    card.moved_ms < now - days * DAY_MS
355
}
356
357
/// Archive a column's cards this many days after they join it; `None`
358
/// finished cards after [`FINISHED_KEPT_DAYS`] (the default); [`NEVER`]
359
/// none.
360
pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> {
361
    sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3")
362
        .bind(days)
363
        .bind(column_id)
364
        .bind(project_id)
365
        .execute(pool)
366
        .await?;
367
    touch(pool, project_id).await
368
}
369
370
/// Days after which a column with no setting of its own archives its
371
/// finished cards (#293): a board shows the last fortnight's done work.
372
pub const FINISHED_KEPT_DAYS: i64 = 14;
373
374
/// A column's `archive_after_days` that means never.
375
pub const NEVER: i64 = -1;
376
377
/// Archive, on every board, the cards their columns keep no longer (#293);
378
/// how many. A column that archives after N days: every card that joined it
379
/// longer ago. A column with no setting: finished cards (a closed issue, a
380
/// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column
381
/// stops growing while open work and notes stay. [`NEVER`]: none.
382
pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> {
383
    let set = sqlx::query(
384
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \
385
         (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \
386
          AND project_cards.moved_ms < $1 - c.archive_after_days * $2)",
387
    )
388
    .bind(now)
389
    .bind(DAY_MS)
390
    .execute(pool)
391
    .await?;
392
    let finished = sqlx::query(
393
        "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \
394
         AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \
395
         AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \
396
               AND i.number = project_cards.number AND i.state = 'closed')) \
397
           OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \
398
               AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))",
399
    )
400
    .bind(now)
401
    .bind(now - FINISHED_KEPT_DAYS * DAY_MS)
402
    .execute(pool)
403
    .await?;
404
    Ok(set.rows_affected() + finished.rows_affected())
405
}
406
407
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
408
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
409
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
410
        .bind(card_id)
411
        .bind(project_id)
412
        .fetch_optional(pool)
413
        .await?
414
    else {
415
        return Ok(());
416
    };
417
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
418
        .bind(card.column_id)
419
        .fetch_all(pool)
420
        .await?;
421
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
422
    let j = i as i64 + by;
423
    if j < 0 || j as usize >= col.len() {
424
        return Ok(());
425
    }
426
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
427
    ids.swap(i, j as usize);
428
    for (pos, id) in ids.iter().enumerate() {
429
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
430
    }
431
    touch(pool, project_id).await
432
}
433
434
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
435
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
436
    touch(pool, project_id).await
437
}
438
439
/// The projects (and column names) an issue or proposal is on.
440
/// Make `column_id` the board's column for finished work, or none (#325).
441
pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> {
442
    let mut tx = super::begin_write(pool).await?;
443
    sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?;
444
    if let Some(c) = column_id {
445
        sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2")
446
            .bind(c)
447
            .bind(project_id)
448
            .execute(&mut *tx)
449
            .await?;
450
    }
451
    tx.commit().await?;
452
    touch(pool, project_id).await
453
}
454
455
/// Issue or proposal `number` of `repo_id` is finished (closed, landed):
456
/// its cards move to their boards' column for finished work, where a board
457
/// has one (#325). Archived cards stay archived.
458
pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> {
459
    let cards: Vec<(i64, i64, i64)> = sqlx::query_as(
460
        "SELECT c.id, c.project_id, c.column_id FROM project_cards c
461
         WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL",
462
    )
463
    .bind(repo_id)
464
    .bind(number)
465
    .fetch_all(pool)
466
    .await?;
467
    for (card, project, column) in cards {
468
        let to: Option<i64> =
469
            sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1")
470
                .bind(project)
471
                .fetch_optional(pool)
472
                .await?;
473
        if let Some(to) = to.filter(|to| *to != column) {
474
            move_card(pool, project, card, to).await?;
475
        }
476
    }
477
    Ok(())
478
}
479
480
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
481
    let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as(
482
        "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'",
483
    )
484
    .bind(repo_id)
485
    .bind(number)
486
    .fetch_all(pool)
487
    .await?;
488
    let mut out = Vec::new();
489
    for (pid, col, archived) in rows {
490
        let col = if archived.is_some() { format!("{col}, archived") } else { col };
491
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
492
            out.push((p, col));
493
        }
494
    }
495
    Ok(out)
496
}