use super::*; use time::OffsetDateTime; impl PostgresRegistry { pub async fn upsert_bootstrap_user( &self, email: &str, display_name: &str, password_hash: &str, ) -> Result { let user_id = format!("user_{}", uuid::Uuid::now_v7().simple()); let row = sqlx::query!( "insert into users ( id, email, display_name, password_hash, status, created_at ) values ( $1, $2, $3, $4, 'active', now() ) on conflict (email) do update set display_name = excluded.display_name, password_hash = excluded.password_hash, status = 'active' returning id", user_id, email, display_name, password_hash, ) .fetch_one(&self.pool) .await?; Ok(UserId::new(row.id)) } pub async fn ensure_membership( &self, workspace_id: &WorkspaceId, user_id: &UserId, role: MembershipRole, ) -> Result<(), RegistryError> { sqlx::query( "insert into memberships ( workspace_id, user_id, role, created_at ) values ( $1, $2, $3, now() ) on conflict (workspace_id, user_id) do update set role = excluded.role", ) .bind(workspace_id.as_str()) .bind(user_id.as_str()) .bind(serialize_enum_text(&role, "role")?) .execute(&self.pool) .await?; Ok(()) } pub async fn get_auth_user_by_email( &self, email: &str, ) -> Result, RegistryError> { let row = sqlx::query!( "select id, email, display_name, password_hash as \"password_hash!\", status, created_at as \"created_at!: OffsetDateTime\" from users where email = $1 limit 1", email, ) .fetch_optional(&self.pool) .await?; row.map(|row| { Ok(AuthUserRecord { user: User { id: UserId::new(row.id), email: row.email, display_name: row.display_name, status: deserialize_enum_text(&row.status, "status")?, created_at: row.created_at, }, password_hash: row.password_hash, }) }) .transpose() } pub async fn get_auth_user_by_id( &self, user_id: &UserId, ) -> Result, RegistryError> { let row = sqlx::query!( "select id, email, display_name, password_hash as \"password_hash!\", status, created_at as \"created_at!: OffsetDateTime\" from users where id = $1 limit 1", user_id.as_str(), ) .fetch_optional(&self.pool) .await?; row.map(|row| { Ok(AuthUserRecord { user: User { id: UserId::new(row.id), email: row.email, display_name: row.display_name, status: deserialize_enum_text(&row.status, "status")?, created_at: row.created_at, }, password_hash: row.password_hash, }) }) .transpose() } pub async fn update_user_profile( &self, user_id: &UserId, email: &str, display_name: &str, ) -> Result { let row = sqlx::query!( "update users set email = $2, display_name = $3 where id = $1 returning id, email, display_name, status, created_at as \"created_at!: OffsetDateTime\"", user_id.as_str(), email, display_name, ) .fetch_one(&self.pool) .await .map_err(|error| map_user_update_error(error, user_id, email))?; build_user( row.id, row.email, row.display_name, row.status, row.created_at, ) } pub async fn update_user_password( &self, user_id: &UserId, password_hash: &str, ) -> Result<(), RegistryError> { let result = sqlx::query( "update users set password_hash = $2 where id = $1", ) .bind(user_id.as_str()) .bind(password_hash) .execute(&self.pool) .await?; if result.rows_affected() == 0 { return Err(RegistryError::UserNotFound { user_id: user_id.as_str().to_owned(), }); } Ok(()) } pub async fn create_user_session( &self, session_id: &UserSessionId, user_id: &UserId, current_workspace_id: Option<&WorkspaceId>, secret_hash: &str, expires_at: &OffsetDateTime, ) -> Result<(), RegistryError> { sqlx::query( "insert into user_sessions ( id, user_id, current_workspace_id, secret_hash, status, expires_at, last_seen_at, created_at ) values ( $1, $2, $3, $4, 'active', $5::timestamptz, now(), now() )", ) .bind(session_id.as_str()) .bind(user_id.as_str()) .bind(current_workspace_id.map(|id| id.as_str())) .bind(secret_hash) .bind(*expires_at) .execute(&self.pool) .await?; Ok(()) } pub async fn get_user_session( &self, session_id: &UserSessionId, secret_hash: &str, ) -> Result, RegistryError> { let row = sqlx::query!( "select s.id, s.user_id, s.current_workspace_id, u.email, u.display_name, u.status, u.created_at as \"created_at!: OffsetDateTime\" from user_sessions s join users u on u.id = s.user_id where s.id = $1 and s.secret_hash = $2 and s.status = 'active' and s.expires_at > now() limit 1", session_id.as_str(), secret_hash, ) .fetch_optional(&self.pool) .await?; let Some(row) = row else { return Ok(None); }; let user_id = UserId::new(row.user_id); let user = User { id: user_id.clone(), email: row.email, display_name: row.display_name, status: deserialize_enum_text(&row.status, "status")?, created_at: row.created_at, }; let memberships = self.list_workspaces_for_user(&user_id).await?; let stored_workspace_id = row.current_workspace_id.map(WorkspaceId::new); let current_workspace_id = stored_workspace_id .filter(|workspace_id| { memberships .iter() .any(|membership| membership.workspace.id == *workspace_id) }) .or_else(|| { memberships .first() .map(|membership| membership.workspace.id.clone()) }); Ok(Some(SessionRecord { session_id: UserSessionId::new(row.id), user, memberships, current_workspace_id, })) } pub async fn touch_user_session( &self, session_id: &UserSessionId, ) -> Result<(), RegistryError> { sqlx::query( "update user_sessions set last_seen_at = now() where id = $1", ) .bind(session_id.as_str()) .execute(&self.pool) .await?; Ok(()) } pub async fn revoke_user_session( &self, session_id: &UserSessionId, ) -> Result<(), RegistryError> { sqlx::query( "update user_sessions set status = 'revoked' where id = $1", ) .bind(session_id.as_str()) .execute(&self.pool) .await?; Ok(()) } pub async fn set_user_session_current_workspace( &self, session_id: &UserSessionId, workspace_id: &WorkspaceId, ) -> Result<(), RegistryError> { sqlx::query( "update user_sessions set current_workspace_id = $2 where id = $1", ) .bind(session_id.as_str()) .bind(workspace_id.as_str()) .execute(&self.pool) .await?; Ok(()) } pub async fn user_has_workspace_access( &self, user_id: &UserId, workspace_id: &WorkspaceId, ) -> Result { let row = sqlx::query!( "select exists( select 1 from memberships where user_id = $1 and workspace_id = $2 ) as \"allowed!\"", user_id.as_str(), workspace_id.as_str(), ) .fetch_one(&self.pool) .await?; Ok(row.allowed) } pub async fn save_auth_profile( &self, request: SaveAuthProfileRequest<'_>, ) -> Result<(), RegistryError> { sqlx::query( "insert into auth_profiles ( id, workspace_id, name, kind, config_json, created_at, updated_at ) values ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz) on conflict(id) do update set workspace_id = excluded.workspace_id, name = excluded.name, kind = excluded.kind, config_json = excluded.config_json, updated_at = excluded.updated_at", ) .bind(request.profile.id.as_str()) .bind(request.workspace_id.as_str()) .bind(&request.profile.name) .bind(serialize_enum_text(&request.profile.kind, "kind")?) .bind(Json(serialize_json_value(&request.profile.config)?)) .bind(request.profile.created_at) .bind(request.profile.updated_at) .execute(&self.pool) .await?; Ok(()) } pub async fn get_auth_profile( &self, workspace_id: &WorkspaceId, auth_profile_id: &crank_core::AuthProfileId, ) -> Result, RegistryError> { let row = sqlx::query!( "select id, workspace_id, name, kind, config_json, created_at as \"created_at!: OffsetDateTime\", updated_at as \"updated_at!: OffsetDateTime\" from auth_profiles where workspace_id = $1 and id = $2", workspace_id.as_str(), auth_profile_id.as_str(), ) .fetch_optional(&self.pool) .await?; row.map(|row| { build_auth_profile( row.id, row.workspace_id, row.name, row.kind, row.config_json, row.created_at, row.updated_at, ) }) .transpose() } pub async fn list_auth_profiles( &self, workspace_id: &WorkspaceId, ) -> Result, RegistryError> { let rows = sqlx::query!( "select id, workspace_id, name, kind, config_json, created_at as \"created_at!: OffsetDateTime\", updated_at as \"updated_at!: OffsetDateTime\" from auth_profiles where workspace_id = $1 order by name asc", workspace_id.as_str(), ) .fetch_all(&self.pool) .await?; rows.into_iter() .map(|row| { build_auth_profile( row.id, row.workspace_id, row.name, row.kind, row.config_json, row.created_at, row.updated_at, ) }) .collect() } pub async fn list_auth_profiles_referencing_secret( &self, workspace_id: &WorkspaceId, secret_id: &SecretId, ) -> Result, RegistryError> { let profiles = self.list_auth_profiles(workspace_id).await?; Ok(profiles .into_iter() .filter(|profile| { profile .config .secret_ids() .into_iter() .any(|candidate| candidate == secret_id) }) .collect()) } }