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