OXYGENHUB
# c30444e35f2e oxygenhub / crates / hub / src / db / projects.rs
11.8 KiB · Rust · last changed Postgres, step 1: every query numbers its placeholders $1, $2, ... 19 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
}
26
27
#[derive(Clone, Debug, FromRow)]
28
pub struct Card {
29
    pub id: i64,
30
    pub project_id: i64,
31
    pub column_id: i64,
32
    pub position: i64,
33
    pub kind: String,
34
    pub repo_id: Option<i64>,
35
    pub number: Option<i64>,
36
    pub note: String,
37
    pub added_by: String,
38
    pub created_ms: i64,
39
}
40
41
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
42
43
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
44
    let mut tx = super::begin_write(pool).await?;
45
    let number: i64 =
46
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(owner).fetch_one(&mut *tx).await?;
47
    let now = now_ms();
48
    let p: Project = sqlx::query_as(
49
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
50
    )
51
    .bind(owner)
52
    .bind(number)
53
    .bind(title)
54
    .bind(description)
55
    .bind(public)
56
    .bind(now)
57
    .bind(now)
58
    .fetch_one(&mut *tx)
59
    .await?;
60
    // A board here now: an old address of a transferred one gives way (#125).
61
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?;
62
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
63
        sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
64
            .bind(p.id)
65
            .bind(name)
66
            .bind(i as i64)
67
            .execute(&mut *tx)
68
            .await?;
69
    }
70
    tx.commit().await?;
71
    Ok(p)
72
}
73
74
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
75
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
76
}
77
78
/// Move project `id` to owner `to` (#125): it takes the next number there,
79
/// with its columns, cards and privacy, and its old address redirects to it.
80
pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> {
81
    let mut tx = super::begin_write(pool).await?;
82
    let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = $1").bind(id).fetch_one(&mut *tx).await?;
83
    let number: i64 =
84
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = $1").bind(to).fetch_one(&mut *tx).await?;
85
    sqlx::query("INSERT OR REPLACE INTO project_redirects (owner, number, project_id) VALUES ($1, $2, $3)")
86
        .bind(&old.owner)
87
        .bind(old.number)
88
        .bind(id)
89
        .execute(&mut *tx)
90
        .await?;
91
    sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(to).bind(number).execute(&mut *tx).await?;
92
    let moved: Project = sqlx::query_as("UPDATE projects SET owner = $1, number = $2, updated_ms = $3 WHERE id = $4 RETURNING *")
93
        .bind(to)
94
        .bind(number)
95
        .bind(now_ms())
96
        .bind(id)
97
        .fetch_one(&mut *tx)
98
        .await?;
99
    tx.commit().await?;
100
    Ok(moved)
101
}
102
103
/// The project an old address (`owner`, `number`) now redirects to, after
104
/// a transfer (#125).
105
pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
106
    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")
107
        .bind(owner)
108
        .bind(number)
109
        .fetch_optional(pool)
110
        .await
111
}
112
113
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
114
    sqlx::query_as("SELECT * FROM projects WHERE owner = $1 AND number = $2").bind(owner).bind(number).fetch_optional(pool).await
115
}
116
117
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
118
    sqlx::query("UPDATE projects SET title = $1, description = $2, public = $3, closed = $4, updated_ms = $5 WHERE id = $6")
119
        .bind(title)
120
        .bind(description)
121
        .bind(public)
122
        .bind(closed)
123
        .bind(now_ms())
124
        .bind(id)
125
        .execute(pool)
126
        .await?;
127
    Ok(())
128
}
129
130
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
131
    sqlx::query("DELETE FROM projects WHERE id = $1").bind(id).execute(pool).await?;
132
    Ok(())
133
}
134
135
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
136
    sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
137
    Ok(())
138
}
139
140
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
141
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
142
}
143
144
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
145
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await
146
}
147
148
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
149
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = $1")
150
        .bind(project_id)
151
        .fetch_one(pool)
152
        .await?;
153
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)")
154
        .bind(project_id)
155
        .bind(name)
156
        .bind(pos)
157
        .execute(pool)
158
        .await?;
159
    touch(pool, project_id).await
160
}
161
162
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
163
    sqlx::query("UPDATE project_columns SET name = $1 WHERE id = $2 AND project_id = $3")
164
        .bind(name)
165
        .bind(column_id)
166
        .bind(project_id)
167
        .execute(pool)
168
        .await?;
169
    touch(pool, project_id).await
170
}
171
172
/// Swap a column with its neighbour (`-1` left, `1` right).
173
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
174
    let cols = columns(pool, project_id).await?;
175
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
176
    let j = i as i64 + by;
177
    if j < 0 || j as usize >= cols.len() {
178
        return Ok(());
179
    }
180
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
181
    ids.swap(i, j as usize);
182
    for (pos, id) in ids.iter().enumerate() {
183
        sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
184
    }
185
    touch(pool, project_id).await
186
}
187
188
/// Remove a column; its cards go to the first remaining column. The last column stays.
189
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
190
    let cols = columns(pool, project_id).await?;
191
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
192
    if !cols.iter().any(|c| c.id == column_id) {
193
        return Ok(false);
194
    }
195
    let mut tx = super::begin_write(pool).await?;
196
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
197
        .bind(to.id)
198
        .fetch_one(&mut *tx)
199
        .await?;
200
    sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3")
201
        .bind(to.id)
202
        .bind(base)
203
        .bind(column_id)
204
        .execute(&mut *tx)
205
        .await?;
206
    sqlx::query("DELETE FROM project_columns WHERE id = $1").bind(column_id).execute(&mut *tx).await?;
207
    tx.commit().await?;
208
    touch(pool, project_id).await?;
209
    Ok(true)
210
}
211
212
pub struct NewCard<'a> {
213
    pub column_id: i64,
214
    pub kind: &'a str,
215
    pub repo_id: Option<i64>,
216
    pub number: Option<i64>,
217
    pub note: &'a str,
218
    pub added_by: &'a str,
219
}
220
221
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
222
/// proposal is already on the project.
223
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
224
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
225
        .bind(c.column_id)
226
        .fetch_one(pool)
227
        .await?;
228
    let r = sqlx::query(
229
        "INSERT OR IGNORE INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
230
    )
231
    .bind(project_id)
232
    .bind(c.column_id)
233
    .bind(pos)
234
    .bind(c.kind)
235
    .bind(c.repo_id)
236
    .bind(c.number)
237
    .bind(c.note)
238
    .bind(c.added_by)
239
    .bind(now_ms())
240
    .execute(pool)
241
    .await?;
242
    touch(pool, project_id).await?;
243
    Ok(r.rows_affected() == 1)
244
}
245
246
/// Move a card to the bottom of another column of the same project.
247
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
248
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = $1 AND project_id = $2")
249
        .bind(column_id)
250
        .bind(project_id)
251
        .fetch_one(pool)
252
        .await?;
253
    if ok == 0 {
254
        return Ok(());
255
    }
256
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1")
257
        .bind(column_id)
258
        .fetch_one(pool)
259
        .await?;
260
    sqlx::query("UPDATE project_cards SET column_id = $1, position = $2 WHERE id = $3 AND project_id = $4")
261
        .bind(column_id)
262
        .bind(pos)
263
        .bind(card_id)
264
        .bind(project_id)
265
        .execute(pool)
266
        .await?;
267
    touch(pool, project_id).await
268
}
269
270
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
271
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
272
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2")
273
        .bind(card_id)
274
        .bind(project_id)
275
        .fetch_optional(pool)
276
        .await?
277
    else {
278
        return Ok(());
279
    };
280
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = $1 ORDER BY position, id")
281
        .bind(card.column_id)
282
        .fetch_all(pool)
283
        .await?;
284
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
285
    let j = i as i64 + by;
286
    if j < 0 || j as usize >= col.len() {
287
        return Ok(());
288
    }
289
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
290
    ids.swap(i, j as usize);
291
    for (pos, id) in ids.iter().enumerate() {
292
        sqlx::query("UPDATE project_cards SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?;
293
    }
294
    touch(pool, project_id).await
295
}
296
297
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
298
    sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?;
299
    touch(pool, project_id).await
300
}
301
302
/// The projects (and column names) an issue or proposal is on.
303
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
304
    let rows: Vec<(i64, String)> = sqlx::query_as(
305
        "SELECT c.project_id, col.name 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'",
306
    )
307
    .bind(repo_id)
308
    .bind(number)
309
    .fetch_all(pool)
310
    .await?;
311
    let mut out = Vec::new();
312
    for (pid, col) in rows {
313
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? {
314
            out.push((p, col));
315
        }
316
    }
317
    Ok(out)
318
}