Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
SHELL := bash
BUN_PATH := $(HOME)/.bun/bin:$(PATH)
KUBE_VERSION ?= 1.31.0
export PATH := $(HOME)/.cargo/bin:$(HOME)/.bun/bin:$(HOME)/.local/bin:$(HOME)/go/bin:$(CURDIR)/node_modules/.bin:$(PATH)

define ensure_tool
@command -v $(1) >/dev/null 2>&1 || { \
Expand Down
1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ postgres = { version = "0.19.12", features = ["with-uuid-1"] }
paste = "1.0.15"
thiserror = "2.0.17"
bb8-redis = "0.26"
pagination = { path = "crates/pagination" }

# Queue adapter (Apalis with PostgreSQL)
apalis-core = "1.0.0-rc.7"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Revert users keyset pagination index.

DROP INDEX IF EXISTS idx_users_created_at_id;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
run_in_transaction = false
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Support keyset pagination over users ordered by creation time.

CREATE INDEX CONCURRENTLY idx_users_created_at_id ON users (created_at, id);
3 changes: 3 additions & 0 deletions backend/src/doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use crate::inbound::http::offline::{
use crate::inbound::http::schemas::{
ErrorCodeSchema, ErrorSchema, InterestThemeIdSchema, UserInterestsSchema, UserSchema,
};
use crate::inbound::http::users_pagination::{PaginatedUsersResponse, PaginationLinksSchema};
use crate::inbound::http::walk_sessions::{
CreateWalkSessionRequestBody, CreateWalkSessionResponseBody, WalkCompletionSummaryResponseBody,
WalkPrimaryStatBody, WalkSecondaryStatBody,
Expand Down Expand Up @@ -92,6 +93,8 @@ impl Modify for SecurityAddon {
UserSchema,
UserInterestsSchema,
InterestThemeIdSchema,
PaginationLinksSchema,
PaginatedUsersResponse,
ErrorSchema,
ErrorCodeSchema,
ExploreCatalogueResponse,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/domain/example_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ fn convert_seed_user(
) -> Result<ExampleDataSeedUser, UserValidationError> {
let user_id = UserId::from_uuid(seed_user.id);
let display_name = DisplayName::new(seed_user.display_name)?;
let user = User::new(user_id.clone(), display_name);
let user = User::new(user_id.clone(), display_name, *now);
let preferences = UserPreferencesBuilder::new(user_id)
.interest_theme_ids(seed_user.interest_theme_ids)
.safety_toggle_ids(seed_user.safety_toggle_ids)
Expand Down
3 changes: 3 additions & 0 deletions backend/src/domain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! - ErrorCode (alias to `error::ErrorCode`) — stable error identifier shared
//! across adapters.
//! - User (alias to `user::User`) — domain user identity and display name.
//! - UserCursorKey — stable `(created_at, id)` pagination boundary key.
//! - InterestThemeId — validated identifier for interest themes.
//! - UserInterests — selected interest themes for a user profile.
//! - LoginCredentials — validated username/password inputs for authentication.
Expand Down Expand Up @@ -85,6 +86,7 @@ pub mod user_events;
pub mod user_interests;
pub mod user_onboarding;
pub mod user_state_schema_audit;
pub mod users_pagination;
pub mod walk_session_service;
pub mod walks;

Expand Down Expand Up @@ -147,6 +149,7 @@ pub use self::user_state_schema_audit::{
EntitySchemaCoverage, InterestsStorageCoverage, LoginSchemaCoverage, MigrationDecision,
UserStateSchemaAuditReport, UserStateSchemaAuditService, audit_user_state_schema_coverage,
};
pub use self::users_pagination::UserCursorKey;
pub use self::walk_session_service::{WalkSessionCommandService, WalkSessionQueryService};
pub use self::walks::{
ParseWalkPrimaryStatKindError, ParseWalkSecondaryStatKindError, WalkCompletionSummary,
Expand Down
5 changes: 4 additions & 1 deletion backend/src/domain/ports/example_data_seed_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ pub trait ExampleDataSeedRepository: Send + Sync {
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let user_id = UserId::from_uuid(Uuid::new_v4());
/// let user = User::new(user_id.clone(), DisplayName::new("Demo user".to_string())?);
/// let user = User::with_current_timestamp(
/// user_id.clone(),
/// DisplayName::new("Demo user".to_string())?,
/// );
/// let preferences = UserPreferencesBuilder::new(user_id).revision(1).build();
/// let request = ExampleDataSeedRequest {
/// seed_key: "mossy-owl".to_string(),
Expand Down
4 changes: 2 additions & 2 deletions backend/src/domain/ports/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ pub use user_preferences_repository::{
FixtureUserPreferencesRepository, UserPreferencesRepository, UserPreferencesRepositoryError,
};
pub use user_profile_query::{FixtureUserProfileQuery, UserProfileQuery};
pub use user_repository::{UserPersistenceError, UserRepository};
pub use users_query::{FixtureUsersQuery, UsersQuery};
pub use user_repository::{ListUsersPageRequest, UserPersistenceError, UserRepository};
pub use users_query::{FixtureUsersQuery, UsersPage, UsersQuery};
#[cfg(test)]
pub use walk_session_command::MockWalkSessionCommand;
pub use walk_session_command::{
Expand Down
2 changes: 1 addition & 1 deletion backend/src/domain/ports/user_profile_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ impl UserProfileQuery for FixtureUserProfileQuery {
async fn fetch_profile(&self, user_id: &UserId) -> Result<User, Error> {
let display_name = DisplayName::new("Ada Lovelace")
.map_err(|err| Error::internal(format!("invalid fixture display name: {err}")))?;
Ok(User::new(user_id.clone(), display_name))
Ok(User::with_current_timestamp(user_id.clone(), display_name))
}
}

Expand Down
65 changes: 64 additions & 1 deletion backend/src/domain/ports/user_repository.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! Port abstraction for user persistence adapters and their errors.
use std::num::NonZeroUsize;

use async_trait::async_trait;
use pagination::Cursor;

use crate::domain::{User, UserId};
use crate::domain::{User, UserCursorKey, UserId};

use super::define_port_error;

Expand All @@ -15,11 +18,71 @@ define_port_error! {
}
}

/// Request for a keyset-ordered page from the users table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListUsersPageRequest {
cursor: Option<Cursor<UserCursorKey>>,
limit: NonZeroUsize,
}

impl ListUsersPageRequest {
/// Build a users page request from a cursor and caller-normalized limit.
///
/// # Examples
///
/// ```
/// use std::num::NonZeroUsize;
///
/// use backend::domain::ports::ListUsersPageRequest;
///
/// let limit = NonZeroUsize::new(20).expect("non-zero limit");
/// let request = ListUsersPageRequest::new(None, limit);
/// assert_eq!(request.limit(), 20);
/// assert!(request.cursor().is_none());
/// ```
#[must_use]
pub const fn new(cursor: Option<Cursor<UserCursorKey>>, limit: NonZeroUsize) -> Self {
Self { cursor, limit }
}

/// Borrow the optional page boundary cursor.
#[must_use]
pub const fn cursor(&self) -> Option<&Cursor<UserCursorKey>> {
self.cursor.as_ref()
}

/// Return the caller-normalized page size.
#[must_use]
pub const fn limit(&self) -> usize {
self.limit.get()
}

/// Consume the request into its cursor and limit components.
#[must_use]
pub fn into_parts(self) -> (Option<Cursor<UserCursorKey>>, NonZeroUsize) {
(self.cursor, self.limit)
}
}

#[async_trait]
pub trait UserRepository: Send + Sync {
/// Insert or update a user record.
async fn upsert(&self, user: &User) -> Result<(), UserPersistenceError>;

/// Fetch a user by identifier.
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, UserPersistenceError>;

/// Fetch a keyset-ordered users page.
///
/// Implementations should fetch one more row than `request.limit()` when
/// possible so the caller can detect whether another page exists. Returned
/// rows remain in `(created_at ASC, id ASC)` order for both directions.
async fn list_page(
&self,
_request: ListUsersPageRequest,
) -> Result<Vec<User>, UserPersistenceError> {
Err(UserPersistenceError::query(
"paginated user listing is not implemented",
))
}
}
169 changes: 160 additions & 9 deletions backend/src/domain/ports/users_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,129 @@

use async_trait::async_trait;

use crate::domain::ports::ListUsersPageRequest;
use crate::domain::{DisplayName, Error, User, UserId};

/// Domain users page returned by the user-list query port.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsersPage {
rows: Vec<User>,
has_more: bool,
}

impl UsersPage {
/// Build a users page from rows and an overflow flag.
///
/// # Examples
///
/// ```
/// use backend::domain::ports::UsersPage;
///
/// let page = UsersPage::new(Vec::new(), false);
/// assert!(!page.has_more());
/// assert!(page.rows().is_empty());
/// ```
#[must_use]
pub const fn new(rows: Vec<User>, has_more: bool) -> Self {
Self { rows, has_more }
}

/// Borrow the users in this page.
///
/// # Examples
///
/// ```
/// use backend::domain::User;
/// use backend::domain::ports::UsersPage;
///
/// let user = User::try_from_strings("00000000-0000-0000-0000-000000000000", "Ada")
/// .expect("valid user");
/// let page = UsersPage::new(vec![user], false);
///
/// let rows = page.rows();
/// assert_eq!(rows.len(), 1);
/// assert_eq!(rows[0].display_name().as_ref(), "Ada");
/// ```
#[must_use]
pub fn rows(&self) -> &[User] {
&self.rows
}

/// Consume the page and return its users.
///
/// # Examples
///
/// ```
/// use backend::domain::User;
/// use backend::domain::ports::UsersPage;
///
/// let user = User::try_from_strings("00000000-0000-0000-0000-000000000000", "Ada")
/// .expect("valid user");
/// let page = UsersPage::new(vec![user], true);
///
/// let rows = page.into_rows();
/// assert_eq!(rows.len(), 1);
/// assert_eq!(rows[0].display_name().as_ref(), "Ada");
/// ```
#[must_use]
pub fn into_rows(self) -> Vec<User> {
self.rows
}

/// Whether another page exists in the requested direction.
///
/// # Examples
///
/// ```
/// use backend::domain::ports::UsersPage;
///
/// let page = UsersPage::new(Vec::new(), true);
///
/// assert!(page.has_more());
/// ```
#[must_use]
pub const fn has_more(&self) -> bool {
self.has_more
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Domain use-case port for listing users.
#[async_trait]
pub trait UsersQuery: Send + Sync {
/// Return the visible users list for the authenticated user.
async fn list_users(&self, authenticated_user: &UserId) -> Result<Vec<User>, Error>;

/// Return one keyset-ordered users page for the authenticated user.
///
/// # Examples
///
/// ```
/// # async fn example() -> Result<(), backend::domain::Error> {
/// use std::num::NonZeroUsize;
///
/// use backend::domain::UserId;
/// use backend::domain::ports::{FixtureUsersQuery, ListUsersPageRequest, UsersQuery};
///
/// let query = FixtureUsersQuery;
/// let authenticated_user =
/// UserId::new("11111111-1111-1111-1111-111111111111").expect("valid user id");
/// let request = ListUsersPageRequest::new(
/// None,
/// NonZeroUsize::new(20).expect("non-zero page limit"),
/// );
///
/// let page = query.list_users_page(&authenticated_user, request).await?;
///
/// assert_eq!(page.rows().len(), 1);
/// assert!(!page.has_more());
/// # Ok(())
/// # }
/// ```
async fn list_users_page(
&self,
authenticated_user: &UserId,
request: ListUsersPageRequest,
) -> Result<UsersPage, Error>;
}

/// Temporary fixture users query used until persistence is wired.
Expand All @@ -23,19 +139,35 @@ pub struct FixtureUsersQuery;
#[async_trait]
impl UsersQuery for FixtureUsersQuery {
async fn list_users(&self, _authenticated_user: &UserId) -> Result<Vec<User>, Error> {
const FIXTURE_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
const FIXTURE_DISPLAY_NAME: &str = "Ada Lovelace";
Ok(vec![fixture_user()?])
}

// These values are compile-time constants; surface invalid data as an
// internal error so automated checks catch accidental regressions.
let id = UserId::new(FIXTURE_ID)
.map_err(|err| Error::internal(format!("invalid fixture user id: {err}")))?;
let display_name = DisplayName::new(FIXTURE_DISPLAY_NAME)
.map_err(|err| Error::internal(format!("invalid fixture display name: {err}")))?;
Ok(vec![User::new(id, display_name)])
async fn list_users_page(
&self,
_authenticated_user: &UserId,
request: ListUsersPageRequest,
) -> Result<UsersPage, Error> {
if request.cursor().is_some() {
return Ok(UsersPage::new(Vec::new(), false));
}

Ok(UsersPage::new(vec![fixture_user()?], false))
}
}

fn fixture_user() -> Result<User, Error> {
const FIXTURE_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
const FIXTURE_DISPLAY_NAME: &str = "Ada Lovelace";

// These values are compile-time constants; surface invalid data as an
// internal error so automated checks catch accidental regressions.
let id = UserId::new(FIXTURE_ID)
.map_err(|err| Error::internal(format!("invalid fixture user id: {err}")))?;
let display_name = DisplayName::new(FIXTURE_DISPLAY_NAME)
.map_err(|err| Error::internal(format!("invalid fixture display name: {err}")))?;
Ok(User::with_current_timestamp(id, display_name))
}

#[cfg(test)]
mod tests {
//! Ensures the fixture users query returns the expected static user.
Expand All @@ -50,4 +182,23 @@ mod tests {
assert_eq!(users.len(), 1);
assert_eq!(users[0].display_name().as_ref(), "Ada Lovelace");
}

#[tokio::test]
async fn fixture_users_query_returns_first_paginated_page() {
use std::num::NonZeroUsize;

let query = FixtureUsersQuery;
let user_id = UserId::new("11111111-1111-1111-1111-111111111111").expect("fixture user id");
let request =
ListUsersPageRequest::new(None, NonZeroUsize::new(20).expect("non-zero page limit"));

let page = query
.list_users_page(&user_id, request)
.await
.expect("users page");

assert_eq!(page.rows().len(), 1);
assert!(!page.has_more());
assert_eq!(page.rows()[0].display_name().as_ref(), "Ada Lovelace");
}
}
Loading
Loading