| 1 | //! Landing page and dashboard. |
| 2 | |
| 3 | use axum::extract::State; |
| 4 | use axum::response::{IntoResponse, Response}; |
| 5 | use chrono::{DateTime, Utc}; |
| 6 | |
| 7 | use crate::error::AppResult; |
| 8 | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; |
| 9 | use crate::views::pages::{ |
| 10 | ActivityItem, BookmarkItem, Dashboard, DashChange, FeedItem, RepoSummary, StackItem, |
| 11 | }; |
| 12 | use crate::views::{self, Chrome}; |
| 13 | |
| 14 | /// Repos the viewer can reach: their own, plus any they collaborate on, plus |
| 15 | /// any belonging to an org they are a member of, plus public repos. |
| 16 | /// |
| 17 | /// Visibility is enforced *in* the query rather than by filtering afterwards — |
| 18 | /// a post-filter is how private repos leak into listings. `$1` is the viewer's |
| 19 | /// id and `$2` is whether they are an admin. |
| 20 | const VISIBLE_TO_VIEWER: &str = r#" |
| 21 | r.archived = false |
| 22 | AND ( |
| 23 | r.visibility = 'public' |
| 24 | OR r.owner_user_id = $1 |
| 25 | OR EXISTS (SELECT 1 FROM repo_collaborators c |
| 26 | WHERE c.repo_id = r.id AND c.user_id = $1) |
| 27 | OR EXISTS (SELECT 1 FROM org_members m |
| 28 | WHERE m.org_id = r.owner_org_id AND m.user_id = $1) |
| 29 | OR $2 |
| 30 | ) |
| 31 | "#; |
| 32 | |
| 33 | /// `/` — landing page when signed out, dashboard when signed in. |
| 34 | /// |
| 35 | /// Kept dual-mode rather than redirecting: every existing link and bookmark to |
| 36 | /// `/` keeps working, and `/dashboard` below serves the same body for the |
| 37 | /// design's explicit dashboard links. |
| 38 | pub async fn index( |
| 39 | State(state): State<AppState>, |
| 40 | CurrentUser(user): CurrentUser, |
| 41 | CsrfToken(csrf): CsrfToken, |
| 42 | Nonce(nonce): Nonce, |
| 43 | ) -> AppResult<Response> { |
| 44 | let Some(user) = user else { |
| 45 | let feed = public_feed(&state).await?; |
| 46 | let repos = public_repos(&state).await?; |
| 47 | let in_flight = public_in_flight(&state).await?; |
| 48 | let bookmarks = public_bookmarks(&state).await?; |
| 49 | |
| 50 | // The clone line names a repository the visitor can actually clone |
| 51 | // when there is one, and only falls back to a placeholder on an |
| 52 | // instance with nothing public in it. |
| 53 | let sample = repos.first().map(|r| format!("{}/{}", r.owner, r.name)); |
| 54 | let hint = match repos.first() { |
| 55 | Some(r) => state.config.https_clone_url(&r.owner, &r.name), |
| 56 | None => state.config.https_clone_url("your-org", "your-repo"), |
| 57 | }; |
| 58 | |
| 59 | return Ok(views::page_full( |
| 60 | Chrome { title: "Dogfood", user: None, csrf: &csrf, nonce: &nonce }, |
| 61 | views::pages::landing(views::pages::Landing { |
| 62 | clone_hint: &format!("jj git clone {hint}"), |
| 63 | sample_repo: sample.as_deref(), |
| 64 | feed: &feed, |
| 65 | repos: &repos, |
| 66 | in_flight: &in_flight, |
| 67 | bookmarks: &bookmarks, |
| 68 | }), |
| 69 | ) |
| 70 | .into_response()); |
| 71 | }; |
| 72 | |
| 73 | render_dashboard(&state, &user, &csrf, &nonce).await |
| 74 | } |
| 75 | |
| 76 | /// `/dashboard` — the signed-in dashboard, addressable on its own. |
| 77 | /// |
| 78 | /// A signed-out visitor gets the landing page rather than a login redirect: the |
| 79 | /// dashboard is not a secret, it is just empty without an account, and bouncing |
| 80 | /// somebody to an SSO round trip to learn that is worse. |
| 81 | pub async fn dashboard( |
| 82 | State(state): State<AppState>, |
| 83 | CurrentUser(user): CurrentUser, |
| 84 | CsrfToken(csrf): CsrfToken, |
| 85 | Nonce(nonce): Nonce, |
| 86 | ) -> AppResult<Response> { |
| 87 | match user { |
| 88 | Some(user) => render_dashboard(&state, &user, &csrf, &nonce).await, |
| 89 | None => Ok(axum::response::Redirect::to("/").into_response()), |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | async fn render_dashboard( |
| 94 | state: &AppState, |
| 95 | user: &df_db::models::User, |
| 96 | csrf: &str, |
| 97 | nonce: &str, |
| 98 | ) -> AppResult<Response> { |
| 99 | let repos = visible_repos(state, user).await?; |
| 100 | let awaiting = awaiting_review(state, user).await?; |
| 101 | let mine = my_open_changes(state, user).await?; |
| 102 | let activity = watched_activity(state, user).await?; |
| 103 | |
| 104 | Ok(views::page( |
| 105 | Chrome { title: "Dashboard", user: Some(user), csrf, nonce }, |
| 106 | views::pages::dashboard(Dashboard { |
| 107 | user, |
| 108 | awaiting: &awaiting, |
| 109 | mine: &mine, |
| 110 | activity: &activity, |
| 111 | repos: &repos, |
| 112 | }), |
| 113 | ) |
| 114 | .into_response()) |
| 115 | } |
| 116 | |
| 117 | // ─── queries ───────────────────────────────────────────────────────────────── |
| 118 | |
| 119 | type RepoRow = ( |
| 120 | String, |
| 121 | String, |
| 122 | Option<String>, |
| 123 | bool, |
| 124 | i64, |
| 125 | i64, |
| 126 | Option<DateTime<Utc>>, |
| 127 | ); |
| 128 | |
| 129 | fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> { |
| 130 | rows.into_iter() |
| 131 | .map( |
| 132 | |(owner, name, description, private, open_changes, conflicted, pushed_at)| RepoSummary { |
| 133 | owner, |
| 134 | name, |
| 135 | description, |
| 136 | private, |
| 137 | open_changes, |
| 138 | conflicted, |
| 139 | pushed_at, |
| 140 | }, |
| 141 | ) |
| 142 | .collect() |
| 143 | } |
| 144 | |
| 145 | /// The card's counts, as correlated subqueries. |
| 146 | /// |
| 147 | /// A `LEFT JOIN … GROUP BY` would need two conditional aggregates over the same |
| 148 | /// join and would still have to handle the no-changes case; two scalar |
| 149 | /// subqueries against `(repo_id, state)` are both cheaper and easier to read. |
| 150 | const REPO_CARD_COUNTS: &str = r#" |
| 151 | (SELECT count(*) FROM changes c |
| 152 | WHERE c.repo_id = r.id AND c.state = 'open') AS open_changes, |
| 153 | (SELECT count(*) FROM changes c |
| 154 | WHERE c.repo_id = r.id AND c.state = 'open' AND c.conflicted) AS conflicted, |
| 155 | r.pushed_at |
| 156 | "#; |
| 157 | |
| 158 | async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> { |
| 159 | let sql = format!( |
| 160 | r#" |
| 161 | SELECT COALESCE(ou.handle, og.handle) AS owner, |
| 162 | r.name::text, |
| 163 | r.description, |
| 164 | (r.visibility = 'private') AS private, |
| 165 | {REPO_CARD_COUNTS} |
| 166 | FROM repos r |
| 167 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 168 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 169 | WHERE {VISIBLE_TO_VIEWER} |
| 170 | ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC |
| 171 | LIMIT 50 |
| 172 | "# |
| 173 | ); |
| 174 | |
| 175 | let rows: Vec<RepoRow> = sqlx::query_as(&sql) |
| 176 | .bind(user.id) |
| 177 | .bind(user.is_admin) |
| 178 | .fetch_all(&state.db) |
| 179 | .await?; |
| 180 | |
| 181 | Ok(to_summaries(rows)) |
| 182 | } |
| 183 | |
| 184 | /// Public repositories, for the signed-out landing page. |
| 185 | async fn public_repos(state: &AppState) -> AppResult<Vec<RepoSummary>> { |
| 186 | let rows: Vec<RepoRow> = sqlx::query_as(&format!( |
| 187 | r#" |
| 188 | SELECT COALESCE(ou.handle, og.handle) AS owner, |
| 189 | r.name::text, |
| 190 | r.description, |
| 191 | false AS private, |
| 192 | {REPO_CARD_COUNTS} |
| 193 | FROM repos r |
| 194 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 195 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 196 | WHERE r.archived = false AND r.visibility = 'public' |
| 197 | ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC |
| 198 | LIMIT 6 |
| 199 | "# |
| 200 | )) |
| 201 | .fetch_all(&state.db) |
| 202 | .await?; |
| 203 | |
| 204 | Ok(to_summaries(rows)) |
| 205 | } |
| 206 | |
| 207 | /// Open public changes that are part of a stack — the landing page's |
| 208 | /// "in flight now". |
| 209 | /// |
| 210 | /// "Part of a stack" is exactly "has an edge in `change_edges`": a change with |
| 211 | /// a parent or a child is one somebody is building on or building from. A lone |
| 212 | /// open change is work, but it is not a stack, and the panel is about the thing |
| 213 | /// branches cannot represent. |
| 214 | async fn public_in_flight(state: &AppState) -> AppResult<Vec<StackItem>> { |
| 215 | /// `(owner, repo, number, change_id, synthetic, title, conflicted, updated_at)` |
| 216 | type Row = (String, String, i64, String, bool, String, bool, DateTime<Utc>); |
| 217 | |
| 218 | let rows: Vec<Row> = sqlx::query_as( |
| 219 | r#" |
| 220 | SELECT COALESCE(ou.handle, og.handle) AS owner, |
| 221 | r.name::text, |
| 222 | c.number, |
| 223 | c.change_id, |
| 224 | c.synthetic, |
| 225 | c.title, |
| 226 | c.conflicted, |
| 227 | c.updated_at |
| 228 | FROM changes c |
| 229 | JOIN repos r ON r.id = c.repo_id |
| 230 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 231 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 232 | WHERE r.archived = false |
| 233 | AND r.visibility = 'public' |
| 234 | AND c.state = 'open' |
| 235 | AND EXISTS ( |
| 236 | SELECT 1 FROM change_edges e |
| 237 | WHERE e.child_change = c.id OR e.parent_change = c.id |
| 238 | ) |
| 239 | ORDER BY c.updated_at DESC |
| 240 | LIMIT 4 |
| 241 | "#, |
| 242 | ) |
| 243 | .fetch_all(&state.db) |
| 244 | .await?; |
| 245 | |
| 246 | Ok(rows |
| 247 | .into_iter() |
| 248 | .map( |
| 249 | |(owner, repo, number, change_id, synthetic, title, conflicted, when)| StackItem { |
| 250 | owner, |
| 251 | repo, |
| 252 | number, |
| 253 | change_id, |
| 254 | synthetic, |
| 255 | title, |
| 256 | conflicted, |
| 257 | when, |
| 258 | }, |
| 259 | ) |
| 260 | .collect()) |
| 261 | } |
| 262 | |
| 263 | /// Recently moved bookmarks across public repositories. |
| 264 | async fn public_bookmarks(state: &AppState) -> AppResult<Vec<BookmarkItem>> { |
| 265 | let rows: Vec<(String, String, String, bool, DateTime<Utc>)> = sqlx::query_as( |
| 266 | r#" |
| 267 | SELECT COALESCE(ou.handle, og.handle) AS owner, |
| 268 | r.name::text, |
| 269 | b.name AS bookmark, |
| 270 | b.protected, |
| 271 | b.updated_at |
| 272 | FROM bookmarks b |
| 273 | JOIN repos r ON r.id = b.repo_id |
| 274 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 275 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 276 | WHERE r.archived = false AND r.visibility = 'public' |
| 277 | ORDER BY b.updated_at DESC |
| 278 | LIMIT 5 |
| 279 | "#, |
| 280 | ) |
| 281 | .fetch_all(&state.db) |
| 282 | .await?; |
| 283 | |
| 284 | Ok(rows |
| 285 | .into_iter() |
| 286 | .map(|(owner, repo, name, protected, updated_at)| BookmarkItem { |
| 287 | owner, |
| 288 | repo, |
| 289 | name, |
| 290 | protected, |
| 291 | updated_at, |
| 292 | }) |
| 293 | .collect()) |
| 294 | } |
| 295 | |
| 296 | /// The "shipping right now" feed. |
| 297 | /// |
| 298 | /// Public repositories only, and no drafts: this renders for anonymous |
| 299 | /// visitors, so anything it can reach is world-readable by definition. |
| 300 | /// |
| 301 | /// Driven by the event log rather than by `changes.updated_at`, so each row can |
| 302 | /// state what happened. The join to `changes` is an inner join on |
| 303 | /// `subject_type = 'change'`, which also drops repository- and bookmark-scoped |
| 304 | /// events — the feed is about work, and "somebody renamed a bookmark" is not |
| 305 | /// what a visitor came to see. |
| 306 | async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> { |
| 307 | /// `(owner, repo, number, change_id, synthetic, title, actor, author_name, |
| 308 | /// kind, created_at)` |
| 309 | type Row = ( |
| 310 | String, |
| 311 | String, |
| 312 | i64, |
| 313 | String, |
| 314 | bool, |
| 315 | String, |
| 316 | Option<String>, |
| 317 | Option<String>, |
| 318 | String, |
| 319 | DateTime<Utc>, |
| 320 | ); |
| 321 | |
| 322 | let rows: Vec<Row> = sqlx::query_as( |
| 323 | // `hr.author_name` is the fallback when no account matched the commit's |
| 324 | // email — the person is still known, just not linkable. |
| 325 | r#" |
| 326 | SELECT COALESCE(ou.handle, og.handle) AS owner, |
| 327 | r.name::text, |
| 328 | c.number, |
| 329 | c.change_id, |
| 330 | c.synthetic, |
| 331 | c.title, |
| 332 | ac.handle AS actor, |
| 333 | hr.author_name, |
| 334 | e.kind, |
| 335 | e.created_at |
| 336 | FROM events e |
| 337 | JOIN changes c ON c.id = e.subject_id AND e.subject_type = 'change' |
| 338 | JOIN repos r ON r.id = e.repo_id |
| 339 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 340 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 341 | LEFT JOIN users ac ON ac.id = e.actor_id |
| 342 | LEFT JOIN revisions hr ON hr.id = c.head_revision_id |
| 343 | WHERE r.archived = false |
| 344 | AND r.visibility = 'public' |
| 345 | AND c.state <> 'draft' |
| 346 | ORDER BY e.created_at DESC |
| 347 | LIMIT 12 |
| 348 | "#, |
| 349 | ) |
| 350 | .fetch_all(&state.db) |
| 351 | .await?; |
| 352 | |
| 353 | Ok(rows |
| 354 | .into_iter() |
| 355 | .map( |
| 356 | |(owner, repo, number, change_id, synthetic, title, actor, actor_name, kind, when)| { |
| 357 | FeedItem { |
| 358 | owner, |
| 359 | repo, |
| 360 | number, |
| 361 | change_id, |
| 362 | synthetic, |
| 363 | title, |
| 364 | actor, |
| 365 | actor_name, |
| 366 | kind, |
| 367 | when, |
| 368 | } |
| 369 | }, |
| 370 | ) |
| 371 | .collect()) |
| 372 | } |
| 373 | |
| 374 | type ChangeRow = ( |
| 375 | String, |
| 376 | String, |
| 377 | i64, |
| 378 | String, |
| 379 | bool, |
| 380 | String, |
| 381 | String, |
| 382 | bool, |
| 383 | Option<String>, |
| 384 | Option<String>, |
| 385 | DateTime<Utc>, |
| 386 | ); |
| 387 | |
| 388 | fn to_dash_changes(rows: Vec<ChangeRow>) -> Vec<DashChange> { |
| 389 | rows.into_iter() |
| 390 | .map( |
| 391 | |( |
| 392 | owner, |
| 393 | repo, |
| 394 | number, |
| 395 | change_id, |
| 396 | synthetic, |
| 397 | title, |
| 398 | state, |
| 399 | conflicted, |
| 400 | author, |
| 401 | author_name, |
| 402 | updated_at, |
| 403 | )| { |
| 404 | DashChange { |
| 405 | owner, |
| 406 | repo, |
| 407 | number, |
| 408 | change_id, |
| 409 | synthetic, |
| 410 | title, |
| 411 | state, |
| 412 | conflicted, |
| 413 | author, |
| 414 | author_name, |
| 415 | updated_at, |
| 416 | } |
| 417 | }, |
| 418 | ) |
| 419 | .collect() |
| 420 | } |
| 421 | |
| 422 | const CHANGE_COLUMNS: &str = r#" |
| 423 | SELECT COALESCE(ou.handle, og.handle) AS owner, |
| 424 | r.name::text, |
| 425 | c.number, |
| 426 | c.change_id, |
| 427 | c.synthetic, |
| 428 | c.title, |
| 429 | c.state::text, |
| 430 | c.conflicted, |
| 431 | au.handle AS author, |
| 432 | hr.author_name, |
| 433 | c.updated_at |
| 434 | FROM changes c |
| 435 | JOIN repos r ON r.id = c.repo_id |
| 436 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 437 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 438 | LEFT JOIN users au ON au.id = c.author_user_id |
| 439 | LEFT JOIN revisions hr ON hr.id = c.head_revision_id |
| 440 | "#; |
| 441 | |
| 442 | /// Open changes waiting on this viewer. |
| 443 | /// |
| 444 | /// There is no "requested reviewers" table — `reviews` records verdicts that |
| 445 | /// were *given*, not ones that were asked for. So "awaiting your review" is |
| 446 | /// derived: an open change in a repo you can reach, that you did not write, and |
| 447 | /// that you have not reviewed at its current head revision. Re-reviewing is |
| 448 | /// therefore prompted whenever the author pushes again, which is the behaviour |
| 449 | /// a reviewer wants. |
| 450 | async fn awaiting_review( |
| 451 | state: &AppState, |
| 452 | user: &df_db::models::User, |
| 453 | ) -> AppResult<Vec<DashChange>> { |
| 454 | let sql = format!( |
| 455 | r#" |
| 456 | {CHANGE_COLUMNS} |
| 457 | WHERE {VISIBLE_TO_VIEWER} |
| 458 | AND c.state = 'open' |
| 459 | AND c.author_user_id IS DISTINCT FROM $1 |
| 460 | AND NOT EXISTS ( |
| 461 | SELECT 1 FROM reviews rv |
| 462 | WHERE rv.change_id_fk = c.id |
| 463 | AND rv.reviewer_id = $1 |
| 464 | AND rv.revision_id = c.head_revision_id |
| 465 | ) |
| 466 | ORDER BY c.updated_at DESC |
| 467 | LIMIT 10 |
| 468 | "# |
| 469 | ); |
| 470 | |
| 471 | let rows: Vec<ChangeRow> = sqlx::query_as(&sql) |
| 472 | .bind(user.id) |
| 473 | .bind(user.is_admin) |
| 474 | .fetch_all(&state.db) |
| 475 | .await?; |
| 476 | |
| 477 | Ok(to_dash_changes(rows)) |
| 478 | } |
| 479 | |
| 480 | async fn my_open_changes( |
| 481 | state: &AppState, |
| 482 | user: &df_db::models::User, |
| 483 | ) -> AppResult<Vec<DashChange>> { |
| 484 | let sql = format!( |
| 485 | r#" |
| 486 | {CHANGE_COLUMNS} |
| 487 | WHERE {VISIBLE_TO_VIEWER} |
| 488 | AND c.author_user_id = $1 |
| 489 | AND c.state IN ('open', 'draft') |
| 490 | ORDER BY c.updated_at DESC |
| 491 | LIMIT 10 |
| 492 | "# |
| 493 | ); |
| 494 | |
| 495 | let rows: Vec<ChangeRow> = sqlx::query_as(&sql) |
| 496 | .bind(user.id) |
| 497 | .bind(user.is_admin) |
| 498 | .fetch_all(&state.db) |
| 499 | .await?; |
| 500 | |
| 501 | Ok(to_dash_changes(rows)) |
| 502 | } |
| 503 | |
| 504 | /// Recent events across every repository the viewer can reach. |
| 505 | async fn watched_activity( |
| 506 | state: &AppState, |
| 507 | user: &df_db::models::User, |
| 508 | ) -> AppResult<Vec<ActivityItem>> { |
| 509 | let sql = format!( |
| 510 | r#" |
| 511 | SELECT COALESCE(ou.handle, og.handle) AS owner, |
| 512 | r.name::text, |
| 513 | ac.handle AS actor, |
| 514 | e.kind, |
| 515 | ch.number, |
| 516 | ch.title, |
| 517 | e.created_at |
| 518 | FROM events e |
| 519 | JOIN repos r ON r.id = e.repo_id |
| 520 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 521 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 522 | LEFT JOIN users ac ON ac.id = e.actor_id |
| 523 | LEFT JOIN changes ch |
| 524 | ON ch.id = e.subject_id AND e.subject_type = 'change' |
| 525 | WHERE {VISIBLE_TO_VIEWER} |
| 526 | ORDER BY e.created_at DESC |
| 527 | LIMIT 15 |
| 528 | "# |
| 529 | ); |
| 530 | |
| 531 | let rows: Vec<( |
| 532 | String, |
| 533 | String, |
| 534 | Option<String>, |
| 535 | String, |
| 536 | Option<i64>, |
| 537 | Option<String>, |
| 538 | DateTime<Utc>, |
| 539 | )> = sqlx::query_as(&sql) |
| 540 | .bind(user.id) |
| 541 | .bind(user.is_admin) |
| 542 | .fetch_all(&state.db) |
| 543 | .await?; |
| 544 | |
| 545 | Ok(rows |
| 546 | .into_iter() |
| 547 | .map( |
| 548 | |(owner, repo, actor, kind, change_number, change_title, when)| ActivityItem { |
| 549 | owner, |
| 550 | repo, |
| 551 | actor, |
| 552 | kind, |
| 553 | change_number, |
| 554 | change_title, |
| 555 | when, |
| 556 | }, |
| 557 | ) |
| 558 | .collect()) |
| 559 | } |
559 lines · Rust