OXYGENHUB
# 0650d94ede82 oxygenhub / crates / hub / src / web / projects.rs
22.5 KiB · Rust · last changed Transfer a project to another owner 26 days ago source blame history raw
e1ec9fbf Andy McCall ↶ 1
//! Projects (hub spec §8.2): boards of issues, proposals and notes across
2
//! the repositories of one person or organisation.
3
4
use super::settings::csrf;
5
use super::{filters, Error, Page, Shell};
6
use crate::auth::{roles, Access, Viewer};
7
use crate::db::{self, projects as pdb};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use serde::Deserialize;
13
ef20fdc1 Andy McCall ↶ 14
/// What a caller may do with an owner's projects.
15
pub struct Rights {
e1ec9fbf Andy McCall ↶ 16
    /// Sees private projects.
ef20fdc1 Andy McCall ↶ 17
    pub member: bool,
e1ec9fbf Andy McCall ↶ 18
    /// Creates and changes projects.
ef20fdc1 Andy McCall ↶ 19
    pub edit: bool,
e1ec9fbf Andy McCall ↶ 20
}
21
ef20fdc1 Andy McCall ↶ 22
/// Rights of `account` (anonymous when `None`) over `owner`'s projects;
23
/// `None` when there is no such person or organisation.
24
pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result<Option<Rights>> {
25
    let admin = account.is_some_and(|a| a.is_admin);
26
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? {
27
        let me = account.is_some_and(|v| v.id == a.id);
28
        return Ok(Some(Rights { member: me || admin, edit: me || admin }));
e1ec9fbf Andy McCall ↶ 29
    }
ef20fdc1 Andy McCall ↶ 30
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? {
31
        let member = match account {
32
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(),
e1ec9fbf Andy McCall ↶ 33
            None => false,
34
        };
ef20fdc1 Andy McCall ↶ 35
        return Ok(Some(Rights { member: member || admin, edit: member || admin }));
e1ec9fbf Andy McCall ↶ 36
    }
ef20fdc1 Andy McCall ↶ 37
    Ok(None)
38
}
39
40
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
41
    rights_of(app, viewer.account(), owner)
42
        .await
43
        .map_err(|e| Error::from_db(shell.clone(), e))?
44
        .ok_or_else(|| Error::not_found(shell.clone()))
e1ec9fbf Andy McCall ↶ 45
}
46
47
#[derive(Template)]
48
#[template(path = "projects.html")]
49
pub struct ProjectsPage {
50
    shell: Shell,
51
    owner: String,
52
    projects: Vec<pdb::Project>,
53
    can_edit: bool,
54
    error: Option<String>,
55
}
56
57
pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> {
58
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
59
    let r = rights(&app, &shell, &viewer, &owner).await?;
60
    let projects = pdb::of_owner(&app.db, &owner)
61
        .await
62
        .map_err(|e| Error::from_db(shell.clone(), e))?
63
        .into_iter()
64
        .filter(|p| p.public || r.member)
65
        .collect();
66
    Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None }))
67
}
68
69
#[derive(Deserialize)]
70
pub struct NewProjectForm {
71
    #[serde(rename = "_csrf")]
72
    csrf_token: String,
73
    title: String,
74
    #[serde(default)]
75
    description: String,
76
    #[serde(default)]
77
    private: Option<String>,
78
}
79
80
pub async fn create(
81
    State(app): State<Shared>,
82
    viewer: Viewer,
83
    Path(owner): Path<String>,
84
    Form(f): Form<NewProjectForm>,
85
) -> Result<Response, Error> {
86
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
87
    csrf(&viewer, &f.csrf_token, &shell)?;
88
    let r = rights(&app, &shell, &viewer, &owner).await?;
89
    if !r.edit {
90
        return Err(Error::not_found(shell));
91
    }
92
    let title = f.title.trim();
93
    if title.is_empty() || title.len() > 100 {
94
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
95
        return Ok(Page(ProjectsPage {
96
            shell,
97
            owner,
98
            projects,
99
            can_edit: true,
100
            error: Some("A project needs a title of up to 100 characters.".into()),
101
        })
102
        .into_response());
103
    }
104
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
105
        .await
106
        .map_err(|e| Error::from_db(shell.clone(), e))?;
107
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
108
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
109
}
110
111
/// A card as the board shows it.
112
pub struct CardView {
113
    pub card: pdb::Card,
114
    /// `owner/name#N`, for issue and proposal cards.
115
    pub reference: String,
116
    pub url: String,
117
    pub title: String,
118
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
119
    pub state: String,
120
}
121
122
pub struct ColumnView {
123
    pub column: pdb::Column,
124
    pub cards: Vec<CardView>,
125
}
126
127
#[derive(Template)]
128
#[template(path = "project.html")]
129
pub struct BoardPage {
130
    shell: Shell,
131
    project: pdb::Project,
132
    columns: Vec<ColumnView>,
133
    /// The owner's repositories the viewer can read, for adding cards.
134
    repos: Vec<String>,
135
    /// Changes cards and columns (an open project).
136
    can_edit: bool,
137
    /// Changes the settings (to reopen a closed project too).
138
    can_manage: bool,
139
    notice: Option<String>,
140
    error: Option<String>,
b1e92a62 Andy McCall ↶ 141
    /// Owners the viewer may move the project to (#125): themselves and the
142
    /// organisations they belong to, but not its owner now.
143
    transfer_to: Vec<String>,
e1ec9fbf Andy McCall ↶ 144
}
145
146
async fn board(
147
    app: &Shared,
148
    shell: Shell,
149
    viewer: &Viewer,
150
    owner: &str,
151
    number: i64,
152
    notice: Option<String>,
153
    error: Option<String>,
154
) -> Result<Page<BoardPage>, Error> {
155
    let e = |e| Error::from_db(shell.clone(), e);
156
    let r = rights(app, &shell, viewer, owner).await?;
157
    let project = pdb::get(&app.db, owner, number)
158
        .await
159
        .map_err(e)?
160
        .filter(|p| p.public || r.member)
161
        .ok_or_else(|| Error::not_found(shell.clone()))?;
ef20fdc1 Andy McCall ↶ 162
    let (columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?;
163
    let can_edit = r.edit && !project.closed;
b1e92a62 Andy McCall ↶ 164
    let mut transfer_to = Vec::new();
165
    if let (true, Some(a)) = (r.edit, viewer.account()) {
166
        transfer_to.push(a.username.clone());
167
        let orgs = db::accounts::orgs_of(&app.db, a.id).await.map_err(e)?;
168
        transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name));
169
        transfer_to.retain(|o| o != owner);
170
    }
171
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error, transfer_to }))
ef20fdc1 Andy McCall ↶ 172
}
173
174
/// A project's columns with the cards `account` may see, and the owner's
175
/// repositories it can read (for adding cards).
176
pub async fn board_columns(
177
    app: &Shared,
178
    account: Option<&db::accounts::Account>,
179
    owner: &str,
180
    project: &pdb::Project,
181
) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> {
e1ec9fbf Andy McCall ↶ 182
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
ef20fdc1 Andy McCall ↶ 183
    for row in db::repos::list_by_owner(&app.db, owner).await? {
184
        let role = roles::role_for(&app.db, account, &row).await?;
e1ec9fbf Andy McCall ↶ 185
        if roles::allows(role, Access::Read) {
186
            readable.insert(row.id, row);
187
        }
188
    }
ef20fdc1 Andy McCall ↶ 189
    let cards = pdb::cards(&app.db, project.id).await?;
e1ec9fbf Andy McCall ↶ 190
    let mut columns = Vec::new();
ef20fdc1 Andy McCall ↶ 191
    for column in pdb::columns(&app.db, project.id).await? {
e1ec9fbf Andy McCall ↶ 192
        let mut views = Vec::new();
193
        for card in cards.iter().filter(|c| c.column_id == column.id) {
194
            match (card.kind.as_str(), card.repo_id, card.number) {
195
                ("note", _, _) => views.push(CardView {
196
                    card: card.clone(),
197
                    reference: String::new(),
198
                    url: String::new(),
199
                    title: card.note.clone(),
200
                    state: String::new(),
201
                }),
202
                (kind, Some(rid), Some(n)) => {
203
                    // Cards of repositories the viewer cannot read are not shown at all.
204
                    let Some(row) = readable.get(&rid) else { continue };
205
                    let (title, state) = if kind == "issue" {
ef20fdc1 Andy McCall ↶ 206
                        match db::issues::get(&app.db, rid, n).await? {
e1ec9fbf Andy McCall ↶ 207
                            Some(i) => (i.title, i.state),
208
                            None => continue,
209
                        }
210
                    } else {
ef20fdc1 Andy McCall ↶ 211
                        match db::proposals::get(&app.db, rid, n).await? {
e1ec9fbf Andy McCall ↶ 212
                            Some(p) => (p.title, p.state),
213
                            None => continue,
214
                        }
215
                    };
216
                    let slug = format!("{}/{}", row.owner, row.name);
217
                    views.push(CardView {
218
                        card: card.clone(),
219
                        reference: format!("{slug}#{n}"),
220
                        url: format!("/{slug}/{kind}/{n}"),
221
                        title,
222
                        state,
223
                    });
224
                }
225
                _ => {}
226
            }
227
        }
228
        columns.push(ColumnView { column, cards: views });
229
    }
230
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
231
    repos.sort();
ef20fdc1 Andy McCall ↶ 232
    Ok((columns, repos))
e1ec9fbf Andy McCall ↶ 233
}
234
b1e92a62 Andy McCall ↶ 235
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Response, Error> {
e1ec9fbf Andy McCall ↶ 236
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
b1e92a62 Andy McCall ↶ 237
    // A board transferred away (#125): its old address goes to its new one.
238
    let e = |e| Error::from_db(shell.clone(), e);
239
    if pdb::get(&app.db, &owner, number).await.map_err(e)?.is_none() {
240
        if let Some(p) = pdb::redirected(&app.db, &owner, number).await.map_err(e)? {
241
            return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response());
242
        }
243
    }
244
    Ok(board(&app, shell, &viewer, &owner, number, None, None).await?.into_response())
e1ec9fbf Andy McCall ↶ 245
}
246
247
#[derive(Deserialize)]
248
pub struct BoardForm {
249
    #[serde(rename = "_csrf")]
250
    csrf_token: String,
251
    action: String,
252
    #[serde(default)]
253
    card: Option<i64>,
254
    #[serde(default)]
255
    column: Option<i64>,
256
    #[serde(default)]
257
    name: String,
258
    /// `repo#12`, or `#12` with `repo`.
259
    #[serde(default)]
260
    reference: String,
261
    #[serde(default)]
262
    repo: String,
263
    #[serde(default)]
264
    note: String,
265
    #[serde(default)]
266
    title: String,
267
    #[serde(default)]
268
    description: String,
269
    #[serde(default)]
270
    private: Option<String>,
271
    #[serde(default)]
272
    closed: Option<String>,
b1e92a62 Andy McCall ↶ 273
    /// The owner to transfer the project to (#125).
274
    #[serde(default)]
275
    to: String,
e1ec9fbf Andy McCall ↶ 276
}
277
278
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
279
fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> {
280
    let r = reference.trim();
281
    let (name, n) = r.split_once('#')?;
282
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
283
    let n: i64 = n.trim().parse().ok()?;
284
    (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n))
ef20fdc1 Andy McCall ↶ 285
}
286
287
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
288
/// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller
289
/// should be told: an unknown repository or number, or a card already there.
290
#[allow(clippy::too_many_arguments)]
291
pub async fn add_card(
292
    app: &Shared,
293
    account: Option<&db::accounts::Account>,
294
    owner: &str,
295
    project_id: i64,
296
    column_id: i64,
297
    reference: &str,
298
    repo: &str,
299
    note: &str,
300
    actor: &str,
301
) -> sqlx::Result<Result<pdb::Card, String>> {
302
    if !note.trim().is_empty() {
303
        let note: String = note.trim().chars().take(500).collect();
304
        pdb::add_card(
305
            &app.db,
306
            project_id,
307
            pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: actor },
308
        )
309
        .await?;
310
        return Ok(Ok(newest_card(app, project_id).await?));
311
    }
312
    let Some((repo, n)) = parse_reference(reference, repo) else {
313
        return Ok(Err("Name an issue or proposal as repo#12, or write a note.".into()));
314
    };
315
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
316
    let role = roles::role_for(&app.db, account, &row).await?;
317
    let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?;
318
    let kind = if issues.contains(&n) {
319
        "issue"
320
    } else if proposals.contains(&n) {
321
        "proposal"
322
    } else {
323
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
324
    };
325
    if !roles::allows(role, Access::Read) {
326
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
327
    }
328
    if !pdb::add_card(
329
        &app.db,
330
        project_id,
331
        pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor },
332
    )
333
    .await?
334
    {
335
        return Ok(Err(format!("{repo}#{n} is already on this project.")));
336
    }
337
    Ok(Ok(newest_card(app, project_id).await?))
338
}
339
340
async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result<pdb::Card> {
341
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await
e1ec9fbf Andy McCall ↶ 342
}
343
344
pub async fn act(
345
    State(app): State<Shared>,
346
    viewer: Viewer,
347
    Path((owner, number)): Path<(String, i64)>,
348
    Form(f): Form<BoardForm>,
349
) -> Result<Response, Error> {
350
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
351
    csrf(&viewer, &f.csrf_token, &shell)?;
352
    let r = rights(&app, &shell, &viewer, &owner).await?;
353
    let e = |e| Error::from_db(shell.clone(), e);
354
    let project = pdb::get(&app.db, &owner, number)
355
        .await
356
        .map_err(e)?
357
        .filter(|p| p.public || r.member)
358
        .ok_or_else(|| Error::not_found(shell.clone()))?;
359
    if !r.edit {
360
        return Err(Error::not_found(shell));
361
    }
b1e92a62 Andy McCall ↶ 362
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") {
e1ec9fbf Andy McCall ↶ 363
        return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into()))
364
            .await?
365
            .into_response());
366
    }
367
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
368
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
369
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
370
    let mut notice = None;
371
    let mut error = None;
372
    match f.action.as_str() {
373
        "add" => {
374
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
ef20fdc1 Andy McCall ↶ 375
            if let Err(msg) =
376
                add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)?
377
            {
378
                error = Some(msg);
e1ec9fbf Andy McCall ↶ 379
            }
380
        }
381
        "move" => {
382
            if let (Some(card), Some(col)) = (f.card, f.column) {
383
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
384
            }
385
        }
386
        "up" | "down" => {
387
            if let Some(card) = f.card {
388
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
389
            }
390
        }
391
        "remove" => {
392
            if let Some(card) = f.card {
393
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
394
            }
395
        }
396
        "add_column" => {
397
            let name = f.name.trim();
398
            if name.is_empty() || name.len() > 40 {
399
                error = Some("A column has a name of up to 40 characters.".into());
400
            } else {
401
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
402
            }
403
        }
404
        "rename_column" => {
405
            if let (Some(col), name) = (f.column, f.name.trim()) {
406
                if !name.is_empty() && name.len() <= 40 {
407
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
408
                }
409
            }
410
        }
411
        "left" | "right" => {
412
            if let Some(col) = f.column {
413
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
414
            }
415
        }
416
        "remove_column" => {
417
            if let Some(col) = f.column {
418
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
419
                    error = Some("A project keeps at least one column.".into());
420
                }
421
            }
422
        }
423
        "settings" => {
424
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
425
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
426
            notice = Some("Saved.".into());
b1e92a62 Andy McCall ↶ 427
        }
428
        "transfer" => {
429
            let to = f.to.trim();
430
            match rights_of(&app, viewer.account(), to).await.map_err(e)? {
431
                _ if to == owner => error = Some(format!("It is {owner}'s already.")),
432
                Some(t) if t.edit => {
433
                    let moved = pdb::transfer(&app.db, project.id, to).await.map_err(e)?;
434
                    let detail = format!("{owner}/{number} to {to}/{}", moved.number);
435
                    db::accounts::audit(&app.db, viewer.account(), "project.transferred", &detail, &project.title).await.ok();
436
                    return Ok(Redirect::to(&format!("/projects/{to}/{}", moved.number)).into_response());
437
                }
438
                _ => error = Some(format!("You cannot make projects under {to}, so you cannot move this one there.")),
439
            }
e1ec9fbf Andy McCall ↶ 440
        }
441
        "delete" => {
442
            pdb::delete(&app.db, project.id).await.map_err(e)?;
443
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
444
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
445
        }
446
        _ => return Err(Error::bad(shell, "unknown action")),
447
    }
448
    if notice.is_none() && error.is_none() {
449
        // Back to the board, so a reload does not repeat the action.
450
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
451
    }
452
    Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response())
453
}
454
455
/// The projects an issue or proposal is on that the viewer may see, with
456
/// their column.
457
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
458
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
459
    let mut out = Vec::new();
460
    for (p, col) in all {
461
        let visible = p.public
462
            || viewer.is_admin()
463
            || match viewer.account() {
464
                Some(v) if v.username == p.owner => true,
465
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
466
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
467
                    None => false,
468
                },
469
                None => false,
470
            };
471
        if visible {
472
            out.push((p, col));
473
        }
474
    }
475
    out
476
}
477
478
#[cfg(test)]
479
mod tests {
480
    use super::parse_reference;
481
482
    #[test]
483
    fn references() {
484
        assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12)));
485
        assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7)));
486
        assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3)));
487
        assert_eq!(parse_reference("#7", ""), None, "a repository is needed");
488
        assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories");
489
        assert_eq!(parse_reference("hub#0", ""), None);
490
        assert_eq!(parse_reference("hub", ""), None);
491
    }
492
}
a1b98695 Andy McCall ↶ 493
494
#[derive(Deserialize)]
495
pub struct PlaceForm {
496
    #[serde(rename = "_csrf")]
497
    csrf_token: String,
498
    /// The project's number among its owner's.
499
    project: i64,
500
    /// `add` or `remove`.
501
    action: String,
502
}
503
504
/// Put an issue or proposal on one of its repository owner's projects (in
505
/// the first column), or take it off, from its own page.
506
async fn place(
507
    app: Shared,
508
    viewer: Viewer,
509
    owner: String,
510
    name: String,
511
    kind: &'static str,
512
    number: i64,
513
    f: PlaceForm,
514
) -> Result<Response, Error> {
515
    let page = format!("/{owner}/{name}/{kind}/{number}");
516
    let shell = Shell::new(&app, &page, &viewer);
517
    csrf(&viewer, &f.csrf_token, &shell)?;
518
    let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?;
519
    let r = rights(&app, &shell, &viewer, &row.owner).await?;
520
    if !r.edit {
521
        return Err(Error::not_found(shell));
522
    }
523
    let e = |e| Error::from_db(shell.clone(), e);
524
    let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
525
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
526
    let mut error = None;
527
    match f.action.as_str() {
528
        "add" => {
529
            let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
530
            let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) };
531
            if let Err(msg) =
532
                add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor)
533
                    .await
534
                    .map_err(e)?
535
            {
536
                error = Some(msg);
537
            }
538
        }
539
        "remove" => {
540
            for c in pdb::cards(&app.db, project.id).await.map_err(e)? {
541
                if c.repo_id == Some(row.id) && c.number == Some(number) {
542
                    pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?;
543
                }
544
            }
545
        }
546
        _ => return Err(Error::bad(shell, "add or remove")),
547
    }
548
    let to = match error {
549
        Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)),
550
        None => page,
551
    };
552
    Ok(Redirect::to(&to).into_response())
553
}
554
555
/// `POST /{owner}/{name}/proposal/{number}/project`.
556
pub async fn place_proposal(
557
    State(app): State<Shared>,
558
    viewer: Viewer,
559
    Path((owner, name, number)): Path<(String, String, i64)>,
560
    Form(f): Form<PlaceForm>,
561
) -> Result<Response, Error> {
562
    place(app, viewer, owner, name, "proposal", number, f).await
563
}
564
565
/// `POST /{owner}/{name}/issue/{number}/project`.
566
pub async fn place_issue(
567
    State(app): State<Shared>,
568
    viewer: Viewer,
569
    Path((owner, name, number)): Path<(String, String, i64)>,
570
    Form(f): Form<PlaceForm>,
571
) -> Result<Response, Error> {
572
    place(app, viewer, owner, name, "issue", number, f).await
573
}
574
575
/// The owner's open projects `viewer` may add an item to that it is not on
576
/// yet; `None` when the viewer may not change the owner's projects at all.
577
pub async fn project_choices(
578
    app: &Shared,
579
    viewer: &Viewer,
580
    repo: &db::repos::RepoRow,
581
    placed: &[(pdb::Project, String)],
582
) -> Option<Vec<pdb::Project>> {
583
    let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None };
584
    if !r.edit {
585
        return None;
586
    }
587
    Some(
588
        pdb::of_owner(&app.db, &repo.owner)
589
            .await
590
            .unwrap_or_default()
591
            .into_iter()
592
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
593
            .collect(),
594
    )
595
}