OXYGENHUB
◆ v0.9.1 oxygenhub / crates / hub / src / web / projects.rs
35.1 KiB · Rust · last changed Board cards can be archived, by hand or by age (refs #293) 11 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};
6a306cd0 Andy McCall ↶ 7
use crate::db::{self, issues as idb, projects as pdb};
e1ec9fbf Andy McCall ↶ 8
use crate::Shared;
9
use askama::Template;
7b26e611 Andy McCall ↶ 10
use axum::extract::{Form, Path, Query, State};
e1ec9fbf Andy McCall ↶ 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,
7b26e611 Andy McCall ↶ 52
    /// The projects shown: open or closed as `closed` says, matching `q`.
e1ec9fbf Andy McCall ↶ 53
    projects: Vec<pdb::Project>,
54
    can_edit: bool,
55
    error: Option<String>,
a024e800 Andy McCall ↶ 56
    /// On your own page, the organisations you belong to: their boards
57
    /// are one click away, and where a board for one is made (#119).
58
    orgs: Vec<String>,
7b26e611 Andy McCall ↶ 59
    /// The title filter, and whether the closed ones are shown (#124).
60
    q: String,
61
    closed: bool,
62
    open_count: usize,
63
    closed_count: usize,
e1ec9fbf Andy McCall ↶ 64
}
65
7b26e611 Andy McCall ↶ 66
impl ProjectsPage {
67
    /// The page for `all` the projects the viewer may see: counted open
68
    /// and closed among those matching `q`, then the ones asked for.
69
    #[allow(clippy::too_many_arguments)]
70
    fn build(
71
        shell: Shell,
72
        owner: String,
73
        all: Vec<pdb::Project>,
74
        can_edit: bool,
75
        error: Option<String>,
76
        orgs: Vec<String>,
77
        q: Option<String>,
78
        closed: bool,
79
    ) -> ProjectsPage {
80
        let q = q.unwrap_or_default().trim().to_string();
81
        let needle = q.to_lowercase();
82
        let matching: Vec<pdb::Project> = all
83
            .into_iter()
84
            .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle))
85
            .collect();
86
        let closed_count = matching.iter().filter(|p| p.closed).count();
87
        let open_count = matching.len() - closed_count;
88
        let projects = matching.into_iter().filter(|p| p.closed == closed).collect();
89
        ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count }
90
    }
91
}
92
93
/// `?q=TEXT&state=open|closed` on the Projects page (#124).
94
#[derive(Deserialize, Default)]
95
pub struct ProjectsQuery {
96
    #[serde(default)]
97
    q: Option<String>,
98
    #[serde(default)]
99
    state: Option<String>,
100
}
101
a024e800 Andy McCall ↶ 102
/// The organisations the viewer belongs to, when `owner` is the viewer.
103
async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> {
104
    match viewer.account() {
105
        Some(a) if a.username == owner => {
106
            crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default()
107
        }
108
        _ => vec![],
109
    }
110
}
111
7b26e611 Andy McCall ↶ 112
pub async fn list(
113
    State(app): State<Shared>,
114
    viewer: Viewer,
115
    Path(owner): Path<String>,
116
    Query(query): Query<ProjectsQuery>,
117
) -> Result<Page<ProjectsPage>, Error> {
e1ec9fbf Andy McCall ↶ 118
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
119
    let r = rights(&app, &shell, &viewer, &owner).await?;
120
    let projects = pdb::of_owner(&app.db, &owner)
121
        .await
122
        .map_err(|e| Error::from_db(shell.clone(), e))?
123
        .into_iter()
124
        .filter(|p| p.public || r.member)
125
        .collect();
a024e800 Andy McCall ↶ 126
    let orgs = own_orgs(&app, &viewer, &owner).await;
7b26e611 Andy McCall ↶ 127
    let closed = query.state.as_deref() == Some("closed");
128
    Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed)))
e1ec9fbf Andy McCall ↶ 129
}
130
131
#[derive(Deserialize)]
132
pub struct NewProjectForm {
133
    #[serde(rename = "_csrf")]
134
    csrf_token: String,
135
    title: String,
136
    #[serde(default)]
137
    description: String,
138
    #[serde(default)]
139
    private: Option<String>,
140
}
141
142
pub async fn create(
143
    State(app): State<Shared>,
144
    viewer: Viewer,
145
    Path(owner): Path<String>,
146
    Form(f): Form<NewProjectForm>,
147
) -> Result<Response, Error> {
148
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
149
    csrf(&viewer, &f.csrf_token, &shell)?;
150
    let r = rights(&app, &shell, &viewer, &owner).await?;
151
    if !r.edit {
152
        return Err(Error::not_found(shell));
153
    }
154
    let title = f.title.trim();
155
    if title.is_empty() || title.len() > 100 {
156
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
a024e800 Andy McCall ↶ 157
        let orgs = own_orgs(&app, &viewer, &owner).await;
7b26e611 Andy McCall ↶ 158
        return Ok(Page(ProjectsPage::build(
e1ec9fbf Andy McCall ↶ 159
            shell,
160
            owner,
161
            projects,
7b26e611 Andy McCall ↶ 162
            true,
163
            Some("A project needs a title of up to 100 characters.".into()),
a024e800 Andy McCall ↶ 164
            orgs,
7b26e611 Andy McCall ↶ 165
            None,
166
            false,
167
        ))
e1ec9fbf Andy McCall ↶ 168
        .into_response());
169
    }
170
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
171
        .await
172
        .map_err(|e| Error::from_db(shell.clone(), e))?;
173
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
174
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
175
}
176
177
/// A card as the board shows it.
178
pub struct CardView {
179
    pub card: pdb::Card,
180
    /// `owner/name#N`, for issue and proposal cards.
181
    pub reference: String,
182
    pub url: String,
183
    pub title: String,
184
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
185
    pub state: String,
6a306cd0 Andy McCall ↶ 186
    /// The issue's or proposal's labels, and an issue's assignees (#293).
187
    pub labels: Vec<idb::Label>,
188
    pub assignees: Vec<String>,
e1ec9fbf Andy McCall ↶ 189
}
190
191
pub struct ColumnView {
192
    pub column: pdb::Column,
193
    pub cards: Vec<CardView>,
657e7d5c Andy McCall ↶ 194
    /// Cards the page leaves out (#293): the oldest of a long column, or
195
    /// those a filter does not match; `total` is how many it holds.
196
    pub hidden: usize,
197
    pub total: usize,
439ecd87 Andy McCall ↶ 198
    /// Its archived cards (#293), shown on the archive view.
199
    pub archived: usize,
e1ec9fbf Andy McCall ↶ 200
}
201
657e7d5c Andy McCall ↶ 202
/// A column longer than this shows its newest cards only, with "show all"
203
/// (#293): a board's Done column otherwise grows without end.
204
pub const COLUMN_SHOWN: usize = 30;
205
206
/// How the board page shows its cards (#293).
207
#[derive(Deserialize, Default)]
208
pub struct BoardQuery {
209
    /// The column shown whole.
210
    #[serde(default)]
211
    all: Option<i64>,
6a306cd0 Andy McCall ↶ 212
    /// Only cards whose title or reference holds these words, and with
213
    /// `label:NAME`, `assignee:NAME` or `state:STATE` as the issue list takes.
657e7d5c Andy McCall ↶ 214
    #[serde(default)]
215
    q: Option<String>,
216
    /// Only cards in this state (`open`, `closed`, `landed`…).
217
    #[serde(default)]
218
    state: Option<String>,
439ecd87 Andy McCall ↶ 219
    /// The archived cards instead (`?archived=1`).
220
    #[serde(default)]
221
    archived: Option<String>,
657e7d5c Andy McCall ↶ 222
}
223
224
impl BoardQuery {
225
    fn text(&self) -> String {
226
        self.q.as_deref().map(str::trim).unwrap_or("").to_string()
227
    }
228
    fn state(&self) -> String {
229
        self.state.as_deref().map(str::trim).unwrap_or("").to_ascii_lowercase()
439ecd87 Andy McCall ↶ 230
    }
231
    fn archived(&self) -> bool {
232
        self.archived.as_deref().is_some_and(|a| !a.is_empty() && a != "0")
657e7d5c Andy McCall ↶ 233
    }
234
}
235
6a306cd0 Andy McCall ↶ 236
/// The board filter's `label:`, `assignee:` and `state:` terms, and its words.
237
#[derive(Default)]
238
struct CardFilter {
239
    labels: Vec<String>,
240
    assignees: Vec<String>,
241
    state: String,
242
    words: Vec<String>,
243
}
244
245
impl CardFilter {
246
    fn of(view: &BoardQuery) -> CardFilter {
247
        let mut f = CardFilter { state: view.state(), ..Default::default() };
248
        for tok in view.text().split_whitespace() {
249
            match tok.split_once(':') {
250
                Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()),
251
                Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()),
252
                Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(),
253
                _ => f.words.push(tok.to_lowercase()),
254
            }
255
        }
256
        f
257
    }
258
259
    fn is_empty(&self) -> bool {
260
        self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty()
261
    }
262
263
    /// Every term holds: each word in the title or reference, each label
264
    /// and assignee among the card's, and its state.
265
    fn keeps(&self, c: &CardView) -> bool {
266
        let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase());
267
        self.words.iter().all(|w| title.contains(w) || reference.contains(w))
268
            && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l)))
269
            && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a)))
270
            && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state))
271
    }
272
}
273
657e7d5c Andy McCall ↶ 274
/// Filter and cut the columns as `view` says.
275
fn shape(columns: &mut [ColumnView], view: &BoardQuery) {
6a306cd0 Andy McCall ↶ 276
    let filter = CardFilter::of(view);
657e7d5c Andy McCall ↶ 277
    for col in columns.iter_mut() {
278
        col.total = col.cards.len();
6a306cd0 Andy McCall ↶ 279
        if !filter.is_empty() {
280
            col.cards.retain(|c| filter.keeps(c));
439ecd87 Andy McCall ↶ 281
        } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() {
657e7d5c Andy McCall ↶ 282
            // Cards join a column at its end: the newest are the last.
283
            let cut = col.cards.len() - COLUMN_SHOWN;
284
            col.cards.drain(..cut);
285
        }
286
        col.hidden = col.total - col.cards.len();
287
    }
288
}
289
e1ec9fbf Andy McCall ↶ 290
#[derive(Template)]
291
#[template(path = "project.html")]
292
pub struct BoardPage {
293
    shell: Shell,
294
    project: pdb::Project,
295
    columns: Vec<ColumnView>,
296
    /// The owner's repositories the viewer can read, for adding cards.
297
    repos: Vec<String>,
298
    /// Changes cards and columns (an open project).
299
    can_edit: bool,
300
    /// Changes the settings (to reopen a closed project too).
301
    can_manage: bool,
302
    notice: Option<String>,
303
    error: Option<String>,
b1e92a62 Andy McCall ↶ 304
    /// Owners the viewer may move the project to (#125): themselves and the
305
    /// organisations they belong to, but not its owner now.
306
    transfer_to: Vec<String>,
657e7d5c Andy McCall ↶ 307
    /// The filter the page shows (#293), and whether one is set.
308
    q: String,
309
    state: String,
310
    filtered: bool,
439ecd87 Andy McCall ↶ 311
    /// The archive view: archived cards, each restorable (#293).
312
    archived: bool,
313
    /// Archived cards on the whole board.
314
    archived_total: usize,
e1ec9fbf Andy McCall ↶ 315
}
316
657e7d5c Andy McCall ↶ 317
#[allow(clippy::too_many_arguments)]
e1ec9fbf Andy McCall ↶ 318
async fn board(
319
    app: &Shared,
320
    shell: Shell,
321
    viewer: &Viewer,
322
    owner: &str,
323
    number: i64,
324
    notice: Option<String>,
325
    error: Option<String>,
657e7d5c Andy McCall ↶ 326
    view: &BoardQuery,
e1ec9fbf Andy McCall ↶ 327
) -> Result<Page<BoardPage>, Error> {
328
    let e = |e| Error::from_db(shell.clone(), e);
329
    let r = rights(app, &shell, viewer, owner).await?;
330
    let project = pdb::get(&app.db, owner, number)
331
        .await
332
        .map_err(e)?
333
        .filter(|p| p.public || r.member)
334
        .ok_or_else(|| Error::not_found(shell.clone()))?;
439ecd87 Andy McCall ↶ 335
    let archived = view.archived();
336
    let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project, archived).await.map_err(e)?;
657e7d5c Andy McCall ↶ 337
    shape(&mut columns, view);
439ecd87 Andy McCall ↶ 338
    let archived_total = columns.iter().map(|c| c.archived).sum();
ef20fdc1 Andy McCall ↶ 339
    let can_edit = r.edit && !project.closed;
b1e92a62 Andy McCall ↶ 340
    let mut transfer_to = Vec::new();
341
    if let (true, Some(a)) = (r.edit, viewer.account()) {
342
        transfer_to.push(a.username.clone());
343
        let orgs = db::accounts::orgs_of(&app.db, a.id).await.map_err(e)?;
344
        transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name));
345
        transfer_to.retain(|o| o != owner);
346
    }
657e7d5c Andy McCall ↶ 347
    let (q, state) = (view.text(), view.state());
348
    let filtered = !q.is_empty() || !state.is_empty();
439ecd87 Andy McCall ↶ 349
    Ok(Page(BoardPage {
350
        shell,
351
        project,
352
        columns,
353
        repos,
354
        can_edit,
355
        can_manage: r.edit,
356
        notice,
357
        error,
358
        transfer_to,
359
        q,
360
        state,
361
        filtered,
362
        archived,
363
        archived_total,
364
    }))
ef20fdc1 Andy McCall ↶ 365
}
366
367
/// A project's columns with the cards `account` may see, and the owner's
439ecd87 Andy McCall ↶ 368
/// repositories it can read (for adding cards). `archived`: the archived
369
/// cards instead of those on the board (#293); each column counts its
370
/// archived cards either way.
ef20fdc1 Andy McCall ↶ 371
pub async fn board_columns(
372
    app: &Shared,
373
    account: Option<&db::accounts::Account>,
374
    owner: &str,
375
    project: &pdb::Project,
439ecd87 Andy McCall ↶ 376
    archived: bool,
ef20fdc1 Andy McCall ↶ 377
) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> {
e1ec9fbf Andy McCall ↶ 378
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
ef20fdc1 Andy McCall ↶ 379
    for row in db::repos::list_by_owner(&app.db, owner).await? {
380
        let role = roles::role_for(&app.db, account, &row).await?;
e1ec9fbf Andy McCall ↶ 381
        if roles::allows(role, Access::Read) {
382
            readable.insert(row.id, row);
383
        }
384
    }
439ecd87 Andy McCall ↶ 385
    let all = pdb::cards(&app.db, project.id).await?;
386
    let archived_in = |column: i64| all.iter().filter(|c| c.column_id == column && c.archived_ms.is_some()).count();
387
    let cards: Vec<&pdb::Card> = all.iter().filter(|c| c.archived_ms.is_some() == archived).collect();
6a306cd0 Andy McCall ↶ 388
    // What the cards show, read once per repository, not per card: a
389
    // board of 300 cards made 300 queries.
390
    let mut shown: std::collections::HashMap<(i64, bool, i64), Shown> = Default::default();
391
    let mut repo_ids: Vec<(i64, bool)> =
392
        cards.iter().filter_map(|c| Some((c.repo_id?, c.kind == "issue"))).filter(|(rid, _)| readable.contains_key(rid)).collect();
393
    repo_ids.sort();
394
    repo_ids.dedup();
395
    for (rid, issues) in repo_ids {
396
        shown.extend(shown_in(app, rid, issues).await?.into_iter().map(|(n, s)| ((rid, issues, n), s)));
397
    }
e1ec9fbf Andy McCall ↶ 398
    let mut columns = Vec::new();
ef20fdc1 Andy McCall ↶ 399
    for column in pdb::columns(&app.db, project.id).await? {
e1ec9fbf Andy McCall ↶ 400
        let mut views = Vec::new();
439ecd87 Andy McCall ↶ 401
        for card in cards.iter().copied().filter(|c| c.column_id == column.id) {
e1ec9fbf Andy McCall ↶ 402
            match (card.kind.as_str(), card.repo_id, card.number) {
403
                ("note", _, _) => views.push(CardView {
404
                    card: card.clone(),
405
                    reference: String::new(),
406
                    url: String::new(),
407
                    title: card.note.clone(),
408
                    state: String::new(),
6a306cd0 Andy McCall ↶ 409
                    labels: Vec::new(),
410
                    assignees: Vec::new(),
e1ec9fbf Andy McCall ↶ 411
                }),
412
                (kind, Some(rid), Some(n)) => {
413
                    // Cards of repositories the viewer cannot read are not shown at all.
414
                    let Some(row) = readable.get(&rid) else { continue };
6a306cd0 Andy McCall ↶ 415
                    let Some(s) = shown.get(&(rid, kind == "issue", n)) else { continue };
e1ec9fbf Andy McCall ↶ 416
                    let slug = format!("{}/{}", row.owner, row.name);
417
                    views.push(CardView {
418
                        card: card.clone(),
419
                        reference: format!("{slug}#{n}"),
420
                        url: format!("/{slug}/{kind}/{n}"),
6a306cd0 Andy McCall ↶ 421
                        title: s.title.clone(),
422
                        state: s.state.clone(),
423
                        labels: s.labels.clone(),
424
                        assignees: s.assignees.clone(),
e1ec9fbf Andy McCall ↶ 425
                    });
426
                }
427
                _ => {}
428
            }
429
        }
657e7d5c Andy McCall ↶ 430
        let total = views.len();
439ecd87 Andy McCall ↶ 431
        let archived = archived_in(column.id);
432
        columns.push(ColumnView { column, cards: views, hidden: 0, total, archived });
e1ec9fbf Andy McCall ↶ 433
    }
434
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
435
    repos.sort();
ef20fdc1 Andy McCall ↶ 436
    Ok((columns, repos))
6a306cd0 Andy McCall ↶ 437
}
438
439
/// What a card shows of an issue or a proposal.
440
struct Shown {
441
    title: String,
442
    state: String,
443
    labels: Vec<idb::Label>,
444
    assignees: Vec<String>,
445
}
446
447
/// A repository's issues (or proposals) as cards show them, by number.
448
async fn shown_in(app: &Shared, repo_id: i64, issues: bool) -> sqlx::Result<Vec<(i64, Shown)>> {
449
    use std::collections::HashMap;
450
    let mut out = Vec::new();
451
    if issues {
452
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
453
        for (id, l) in idb::labels_by_issue(&app.db, repo_id).await? {
454
            labels.entry(id).or_default().push(l);
455
        }
456
        let mut assignees: HashMap<i64, Vec<String>> = HashMap::new();
457
        for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? {
458
            assignees.entry(id).or_default().push(u);
459
        }
460
        for i in idb::list(&app.db, repo_id).await? {
461
            let mut a = assignees.remove(&i.id).unwrap_or_default();
462
            a.sort();
463
            let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state };
464
            out.push((i.number, s));
465
        }
466
    } else {
467
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
468
        for (id, l) in idb::labels_by_proposal(&app.db, repo_id).await? {
469
            labels.entry(id).or_default().push(l);
470
        }
471
        for p in db::proposals::list(&app.db, repo_id, &[]).await? {
472
            let s = Shown { labels: labels.remove(&p.id).unwrap_or_default(), assignees: Vec::new(), title: p.title, state: p.state };
473
            out.push((p.number, s));
474
        }
475
    }
476
    Ok(out)
e1ec9fbf Andy McCall ↶ 477
}
478
657e7d5c Andy McCall ↶ 479
pub async fn show(
480
    State(app): State<Shared>,
481
    viewer: Viewer,
482
    Path((owner, number)): Path<(String, i64)>,
483
    Query(view): Query<BoardQuery>,
484
) -> Result<Response, Error> {
e1ec9fbf Andy McCall ↶ 485
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
b1e92a62 Andy McCall ↶ 486
    // A board transferred away (#125): its old address goes to its new one.
487
    let e = |e| Error::from_db(shell.clone(), e);
488
    if pdb::get(&app.db, &owner, number).await.map_err(e)?.is_none() {
489
        if let Some(p) = pdb::redirected(&app.db, &owner, number).await.map_err(e)? {
490
            return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response());
491
        }
492
    }
657e7d5c Andy McCall ↶ 493
    Ok(board(&app, shell, &viewer, &owner, number, None, None, &view).await?.into_response())
e1ec9fbf Andy McCall ↶ 494
}
495
496
#[derive(Deserialize)]
497
pub struct BoardForm {
498
    #[serde(rename = "_csrf")]
499
    csrf_token: String,
500
    action: String,
501
    #[serde(default)]
502
    card: Option<i64>,
503
    #[serde(default)]
504
    column: Option<i64>,
505
    #[serde(default)]
506
    name: String,
507
    /// `repo#12`, or `#12` with `repo`.
508
    #[serde(default)]
509
    reference: String,
510
    #[serde(default)]
511
    repo: String,
512
    #[serde(default)]
513
    note: String,
514
    #[serde(default)]
515
    title: String,
516
    #[serde(default)]
517
    description: String,
518
    #[serde(default)]
519
    private: Option<String>,
520
    #[serde(default)]
521
    closed: Option<String>,
b1e92a62 Andy McCall ↶ 522
    /// The owner to transfer the project to (#125).
523
    #[serde(default)]
524
    to: String,
439ecd87 Andy McCall ↶ 525
    /// Days, for archiving a column's older cards (#293); empty is never.
526
    #[serde(default)]
527
    days: String,
e1ec9fbf Andy McCall ↶ 528
}
529
530
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
efb51b39 Andy McCall ↶ 531
/// `repo#12`, `#12` with `repo`, or `owner/repo#12` when `owner` is the
532
/// board's (the form the board itself shows, oxygenhub#118). `Err` says
533
/// what is wrong.
534
fn parse_reference(reference: &str, repo: &str, owner: &str) -> Result<(String, i64), String> {
535
    let bad = || "Name an issue or proposal as repo#12, or write a note.".to_string();
e1ec9fbf Andy McCall ↶ 536
    let r = reference.trim();
efb51b39 Andy McCall ↶ 537
    let (name, n) = r.split_once('#').ok_or_else(bad)?;
e1ec9fbf Andy McCall ↶ 538
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
efb51b39 Andy McCall ↶ 539
    let name = match name.split_once('/') {
540
        Some((o, rest)) if o.eq_ignore_ascii_case(owner) => rest,
541
        Some((o, _)) => return Err(format!("This board holds only {owner}'s issues and proposals, not {o}'s.")),
542
        None => name,
543
    };
544
    let n: i64 = n.trim().parse().map_err(|_| bad())?;
545
    if name.is_empty() || n <= 0 || name.contains('/') {
546
        return Err(bad());
547
    }
548
    Ok((name.to_string(), n))
ef20fdc1 Andy McCall ↶ 549
}
550
551
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
552
/// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller
553
/// should be told: an unknown repository or number, or a card already there.
554
#[allow(clippy::too_many_arguments)]
555
pub async fn add_card(
556
    app: &Shared,
557
    account: Option<&db::accounts::Account>,
558
    owner: &str,
559
    project_id: i64,
560
    column_id: i64,
561
    reference: &str,
562
    repo: &str,
563
    note: &str,
564
    actor: &str,
565
) -> sqlx::Result<Result<pdb::Card, String>> {
566
    if !note.trim().is_empty() {
567
        let note: String = note.trim().chars().take(500).collect();
568
        pdb::add_card(
569
            &app.db,
570
            project_id,
571
            pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: actor },
572
        )
573
        .await?;
574
        return Ok(Ok(newest_card(app, project_id).await?));
575
    }
efb51b39 Andy McCall ↶ 576
    let (repo, n) = match parse_reference(reference, repo, owner) {
577
        Ok(r) => r,
578
        Err(why) => return Ok(Err(why)),
ef20fdc1 Andy McCall ↶ 579
    };
580
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
581
    let role = roles::role_for(&app.db, account, &row).await?;
582
    let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?;
583
    let kind = if issues.contains(&n) {
584
        "issue"
585
    } else if proposals.contains(&n) {
586
        "proposal"
587
    } else {
588
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
589
    };
590
    if !roles::allows(role, Access::Read) {
591
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
592
    }
593
    if !pdb::add_card(
594
        &app.db,
595
        project_id,
596
        pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor },
597
    )
598
    .await?
599
    {
600
        return Ok(Err(format!("{repo}#{n} is already on this project.")));
601
    }
602
    Ok(Ok(newest_card(app, project_id).await?))
603
}
604
605
async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result<pdb::Card> {
5d90e0e0 Andy McCall 606
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await
e1ec9fbf Andy McCall ↶ 607
}
608
609
pub async fn act(
610
    State(app): State<Shared>,
611
    viewer: Viewer,
612
    Path((owner, number)): Path<(String, i64)>,
613
    Form(f): Form<BoardForm>,
614
) -> Result<Response, Error> {
615
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
616
    csrf(&viewer, &f.csrf_token, &shell)?;
617
    let r = rights(&app, &shell, &viewer, &owner).await?;
618
    let e = |e| Error::from_db(shell.clone(), e);
619
    let project = pdb::get(&app.db, &owner, number)
620
        .await
621
        .map_err(e)?
622
        .filter(|p| p.public || r.member)
623
        .ok_or_else(|| Error::not_found(shell.clone()))?;
624
    if !r.edit {
625
        return Err(Error::not_found(shell));
626
    }
b1e92a62 Andy McCall ↶ 627
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") {
657e7d5c Andy McCall ↶ 628
        return Ok(board(
629
            &app,
630
            shell,
631
            &viewer,
632
            &owner,
633
            number,
634
            None,
635
            Some("This project is closed; reopen it to change it.".into()),
636
            &BoardQuery::default(),
637
        )
638
        .await?
639
        .into_response());
e1ec9fbf Andy McCall ↶ 640
    }
641
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
642
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
643
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
644
    let mut notice = None;
645
    let mut error = None;
646
    match f.action.as_str() {
647
        "add" => {
648
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
ef20fdc1 Andy McCall ↶ 649
            if let Err(msg) =
650
                add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)?
651
            {
652
                error = Some(msg);
e1ec9fbf Andy McCall ↶ 653
            }
654
        }
655
        "move" => {
656
            if let (Some(card), Some(col)) = (f.card, f.column) {
657
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
658
            }
659
        }
660
        "up" | "down" => {
661
            if let Some(card) = f.card {
662
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
663
            }
664
        }
665
        "remove" => {
666
            if let Some(card) = f.card {
667
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
439ecd87 Andy McCall ↶ 668
            }
669
        }
670
        "archive" => {
671
            if let Some(card) = f.card {
672
                pdb::archive_cards(&app.db, project.id, &[card]).await.map_err(e)?;
673
            }
674
        }
675
        "restore" => {
676
            if let Some(card) = f.card {
677
                pdb::restore_card(&app.db, project.id, card).await.map_err(e)?;
678
            }
679
            return Ok(Redirect::to(&format!("/projects/{owner}/{number}?archived=1")).into_response());
680
        }
681
        "archive_older" | "archive_after" => {
682
            let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) };
683
            let days = match f.days.trim() {
684
                "" if f.action == "archive_after" => None,
685
                d => match d.parse::<i64>() {
686
                    Ok(n) if (0..=3650).contains(&n) => Some(n),
687
                    _ => {
688
                        error = Some("Give a number of days, 0 to 3650.".into());
689
                        None
690
                    }
691
                },
692
            };
693
            let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default();
694
            if error.is_none() && f.action == "archive_after" {
695
                pdb::set_archive_after(&app.db, project.id, col, days).await.map_err(e)?;
696
                notice = Some(match days {
697
                    Some(n) => format!("{name} archives its cards {n} day{} after they reach it.", if n == 1 { "" } else { "s" }),
698
                    None => format!("{name} keeps its cards until you archive them."),
699
                });
700
            } else if let (None, Some(days)) = (&error, days) {
701
                let now = db::now_ms();
702
                let ids: Vec<i64> = pdb::cards(&app.db, project.id)
703
                    .await
704
                    .map_err(e)?
705
                    .into_iter()
706
                    .filter(|c| c.column_id == col && c.archived_ms.is_none() && pdb::older_than(c, days, now))
707
                    .map(|c| c.id)
708
                    .collect();
709
                let n = pdb::archive_cards(&app.db, project.id, &ids).await.map_err(e)?;
710
                let detail = format!("{n} card(s) of {name} older than {days} days");
711
                db::accounts::audit(&app.db, viewer.account(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok();
712
                notice = Some(format!("Archived {n} card{} from {name}.", if n == 1 { "" } else { "s" }));
e1ec9fbf Andy McCall ↶ 713
            }
714
        }
715
        "add_column" => {
716
            let name = f.name.trim();
717
            if name.is_empty() || name.len() > 40 {
718
                error = Some("A column has a name of up to 40 characters.".into());
719
            } else {
720
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
721
            }
722
        }
723
        "rename_column" => {
724
            if let (Some(col), name) = (f.column, f.name.trim()) {
725
                if !name.is_empty() && name.len() <= 40 {
726
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
727
                }
728
            }
729
        }
730
        "left" | "right" => {
731
            if let Some(col) = f.column {
732
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
733
            }
734
        }
735
        "remove_column" => {
736
            if let Some(col) = f.column {
737
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
738
                    error = Some("A project keeps at least one column.".into());
739
                }
740
            }
741
        }
742
        "settings" => {
743
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
744
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
745
            notice = Some("Saved.".into());
b1e92a62 Andy McCall ↶ 746
        }
747
        "transfer" => {
748
            let to = f.to.trim();
749
            match rights_of(&app, viewer.account(), to).await.map_err(e)? {
750
                _ if to == owner => error = Some(format!("It is {owner}'s already.")),
751
                Some(t) if t.edit => {
752
                    let moved = pdb::transfer(&app.db, project.id, to).await.map_err(e)?;
753
                    let detail = format!("{owner}/{number} to {to}/{}", moved.number);
754
                    db::accounts::audit(&app.db, viewer.account(), "project.transferred", &detail, &project.title).await.ok();
755
                    return Ok(Redirect::to(&format!("/projects/{to}/{}", moved.number)).into_response());
756
                }
757
                _ => error = Some(format!("You cannot make projects under {to}, so you cannot move this one there.")),
758
            }
e1ec9fbf Andy McCall ↶ 759
        }
760
        "delete" => {
761
            pdb::delete(&app.db, project.id).await.map_err(e)?;
762
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
763
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
764
        }
765
        _ => return Err(Error::bad(shell, "unknown action")),
766
    }
767
    if notice.is_none() && error.is_none() {
768
        // Back to the board, so a reload does not repeat the action.
769
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
770
    }
657e7d5c Andy McCall ↶ 771
    Ok(board(&app, shell, &viewer, &owner, number, notice, error, &BoardQuery::default()).await?.into_response())
e1ec9fbf Andy McCall ↶ 772
}
773
774
/// The projects an issue or proposal is on that the viewer may see, with
775
/// their column.
776
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
777
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
778
    let mut out = Vec::new();
779
    for (p, col) in all {
780
        let visible = p.public
781
            || viewer.is_admin()
782
            || match viewer.account() {
783
                Some(v) if v.username == p.owner => true,
784
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
785
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
786
                    None => false,
787
                },
788
                None => false,
789
            };
790
        if visible {
791
            out.push((p, col));
792
        }
793
    }
794
    out
795
}
796
797
#[cfg(test)]
798
mod tests {
799
    use super::parse_reference;
800
801
    #[test]
802
    fn references() {
efb51b39 Andy McCall ↶ 803
        let p = |r: &str, repo: &str| parse_reference(r, repo, "andy").ok();
804
        assert_eq!(p("oxygen#12", ""), Some(("oxygen".into(), 12)));
805
        assert_eq!(p("#7", "hub"), Some(("hub".into(), 7)));
806
        assert_eq!(p(" hub # 3 ", ""), Some(("hub".into(), 3)));
807
        assert_eq!(p("#7", ""), None, "a repository is needed");
808
        assert_eq!(p("andy/hub#4", ""), Some(("hub".into(), 4)), "the board's own owner, as it shows cards");
809
        assert!(parse_reference("other/repo#1", "", "andy").unwrap_err().contains("only andy's"), "another owner is refused, saying why");
810
        assert_eq!(p("hub#0", ""), None);
811
        assert_eq!(p("hub", ""), None);
e1ec9fbf Andy McCall ↶ 812
    }
813
}
a1b98695 Andy McCall ↶ 814
815
#[derive(Deserialize)]
816
pub struct PlaceForm {
817
    #[serde(rename = "_csrf")]
818
    csrf_token: String,
819
    /// The project's number among its owner's.
820
    project: i64,
821
    /// `add` or `remove`.
822
    action: String,
823
}
824
825
/// Put an issue or proposal on one of its repository owner's projects (in
826
/// the first column), or take it off, from its own page.
827
async fn place(
828
    app: Shared,
829
    viewer: Viewer,
830
    owner: String,
831
    name: String,
832
    kind: &'static str,
833
    number: i64,
834
    f: PlaceForm,
835
) -> Result<Response, Error> {
836
    let page = format!("/{owner}/{name}/{kind}/{number}");
837
    let shell = Shell::new(&app, &page, &viewer);
838
    csrf(&viewer, &f.csrf_token, &shell)?;
839
    let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?;
840
    let r = rights(&app, &shell, &viewer, &row.owner).await?;
841
    if !r.edit {
842
        return Err(Error::not_found(shell));
843
    }
844
    let e = |e| Error::from_db(shell.clone(), e);
845
    let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
846
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
847
    let mut error = None;
848
    match f.action.as_str() {
849
        "add" => {
850
            let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
851
            let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) };
852
            if let Err(msg) =
853
                add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor)
854
                    .await
855
                    .map_err(e)?
856
            {
857
                error = Some(msg);
858
            }
859
        }
860
        "remove" => {
861
            for c in pdb::cards(&app.db, project.id).await.map_err(e)? {
862
                if c.repo_id == Some(row.id) && c.number == Some(number) {
863
                    pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?;
864
                }
865
            }
866
        }
867
        _ => return Err(Error::bad(shell, "add or remove")),
868
    }
869
    let to = match error {
870
        Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)),
871
        None => page,
872
    };
873
    Ok(Redirect::to(&to).into_response())
874
}
875
876
/// `POST /{owner}/{name}/proposal/{number}/project`.
877
pub async fn place_proposal(
878
    State(app): State<Shared>,
879
    viewer: Viewer,
880
    Path((owner, name, number)): Path<(String, String, i64)>,
881
    Form(f): Form<PlaceForm>,
882
) -> Result<Response, Error> {
883
    place(app, viewer, owner, name, "proposal", number, f).await
884
}
885
886
/// `POST /{owner}/{name}/issue/{number}/project`.
887
pub async fn place_issue(
888
    State(app): State<Shared>,
889
    viewer: Viewer,
890
    Path((owner, name, number)): Path<(String, String, i64)>,
891
    Form(f): Form<PlaceForm>,
892
) -> Result<Response, Error> {
893
    place(app, viewer, owner, name, "issue", number, f).await
894
}
895
896
/// The owner's open projects `viewer` may add an item to that it is not on
897
/// yet; `None` when the viewer may not change the owner's projects at all.
898
pub async fn project_choices(
899
    app: &Shared,
900
    viewer: &Viewer,
901
    repo: &db::repos::RepoRow,
902
    placed: &[(pdb::Project, String)],
903
) -> Option<Vec<pdb::Project>> {
904
    let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None };
905
    if !r.edit {
906
        return None;
907
    }
908
    Some(
909
        pdb::of_owner(&app.db, &repo.owner)
910
            .await
911
            .unwrap_or_default()
912
            .into_iter()
913
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
914
            .collect(),
915
    )
916
}