feat(domain): add auth database schema and domain models

Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-25 09:44:00 +00:00 committed by Jon Seager
parent 5aaeaa55c2
commit ca5d25ea10
No known key found for this signature in database
8 changed files with 373 additions and 0 deletions

20
migrations/0002_auth.sql Normal file
View file

@ -0,0 +1,20 @@
-- migrate:up
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX idx_tokens_user_id ON tokens(user_id);
CREATE INDEX idx_tokens_token_hash ON tokens(token_hash);

View file

@ -5,6 +5,8 @@ pub mod repositories;
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod timeline; pub mod timeline;
pub mod tokens;
pub mod users;
use std::fmt::Display; use std::fmt::Display;
use thiserror::Error; use thiserror::Error;

View file

@ -6,6 +6,8 @@ use crate::domain::roasters::{Roaster, UpdateRoaster};
use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::RoastSortKey;
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast}; use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
use crate::domain::tokens::{Token, TokenId};
use crate::domain::users::{User, UserId};
use async_trait::async_trait; use async_trait::async_trait;
#[async_trait] #[async_trait]
@ -76,3 +78,21 @@ pub trait TimelineEventRepository: Send + Sync {
Ok(page.items) Ok(page.items)
} }
} }
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn insert(&self, user: User) -> Result<User, RepositoryError>;
async fn get(&self, id: UserId) -> Result<User, RepositoryError>;
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError>;
async fn exists(&self) -> Result<bool, RepositoryError>;
}
#[async_trait]
pub trait TokenRepository: Send + Sync {
async fn insert(&self, token: Token) -> Result<Token, RepositoryError>;
async fn get(&self, id: TokenId) -> Result<Token, RepositoryError>;
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Token, RepositoryError>;
async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError>;
async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError>;
async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError>;
}

52
src/domain/tokens.rs Normal file
View file

@ -0,0 +1,52 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::users::UserId;
pub type TokenId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Token {
pub id: TokenId,
pub user_id: UserId,
#[serde(skip_serializing)]
pub token_hash: String,
pub name: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewToken {
pub user_id: UserId,
pub name: String,
}
impl Token {
pub fn new(
id: TokenId,
user_id: UserId,
token_hash: String,
name: String,
created_at: DateTime<Utc>,
) -> Self {
Self {
id,
user_id,
token_hash,
name,
created_at,
last_used_at: None,
revoked_at: None,
}
}
pub fn is_revoked(&self) -> bool {
self.revoked_at.is_some()
}
pub fn is_active(&self) -> bool {
!self.is_revoked()
}
}

30
src/domain/users.rs Normal file
View file

@ -0,0 +1,30 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub type UserId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub username: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewUser {
pub username: String,
pub password: String,
}
impl User {
pub fn new(id: UserId, username: String, password_hash: String, created_at: DateTime<Utc>) -> Self {
Self {
id,
username,
password_hash,
created_at,
}
}
}

View file

@ -1,3 +1,5 @@
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod timeline_events; pub mod timeline_events;
pub mod tokens;
pub mod users;

View file

@ -0,0 +1,147 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::query_as;
use crate::domain::RepositoryError;
use crate::domain::repositories::TokenRepository;
use crate::domain::tokens::{Token, TokenId};
use crate::domain::users::UserId;
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlTokenRepository {
pool: DatabasePool,
}
impl SqlTokenRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
fn to_domain(record: TokenRecord) -> Result<Token, RepositoryError> {
let TokenRecord {
id,
user_id,
token_hash,
name,
created_at,
last_used_at,
revoked_at,
} = record;
Ok(Token {
id,
user_id,
token_hash,
name,
created_at,
last_used_at,
revoked_at,
})
}
}
#[async_trait]
impl TokenRepository for SqlTokenRepository {
async fn insert(&self, token: Token) -> Result<Token, RepositoryError> {
let query = "INSERT INTO tokens (id, user_id, token_hash, name, created_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?)";
sqlx::query(query)
.bind(&token.id)
.bind(&token.user_id)
.bind(&token.token_hash)
.bind(&token.name)
.bind(&token.created_at)
.bind(&token.last_used_at)
.bind(&token.revoked_at)
.execute(&self.pool)
.await
.map_err(|err| {
if let sqlx::Error::Database(db_err) = &err {
if db_err.is_unique_violation() {
return RepositoryError::conflict("token already exists");
}
}
RepositoryError::unexpected(err.to_string())
})?;
Ok(token)
}
async fn get(&self, id: TokenId) -> Result<Token, RepositoryError> {
let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE id = ?";
let record = query_as::<_, TokenRecord>(query)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Self::to_domain(record)
}
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Token, RepositoryError> {
let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE token_hash = ?";
let record = query_as::<_, TokenRecord>(query)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Self::to_domain(record)
}
async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError> {
let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE user_id = ? ORDER BY created_at DESC";
let records = query_as::<_, TokenRecord>(query)
.bind(&user_id)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
records.into_iter().map(Self::to_domain).collect()
}
async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError> {
let query = "UPDATE tokens SET revoked_at = ? WHERE id = ?";
let now = Utc::now();
sqlx::query(query)
.bind(&now)
.bind(&id)
.execute(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
self.get(id).await
}
async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError> {
let query = "UPDATE tokens SET last_used_at = ? WHERE id = ?";
let now = Utc::now();
sqlx::query(query)
.bind(&now)
.bind(&id)
.execute(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(())
}
}
#[derive(sqlx::FromRow)]
struct TokenRecord {
id: TokenId,
user_id: UserId,
token_hash: String,
name: String,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
revoked_at: Option<DateTime<Utc>>,
}

View file

@ -0,0 +1,100 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::query_as;
use crate::domain::RepositoryError;
use crate::domain::repositories::UserRepository;
use crate::domain::users::{User, UserId};
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlUserRepository {
pool: DatabasePool,
}
impl SqlUserRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
fn to_domain(record: UserRecord) -> Result<User, RepositoryError> {
let UserRecord {
id,
username,
password_hash,
created_at,
} = record;
Ok(User::new(id, username, password_hash, created_at))
}
}
#[async_trait]
impl UserRepository for SqlUserRepository {
async fn insert(&self, user: User) -> Result<User, RepositoryError> {
let query = "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)";
sqlx::query(query)
.bind(&user.id)
.bind(&user.username)
.bind(&user.password_hash)
.bind(&user.created_at)
.execute(&self.pool)
.await
.map_err(|err| {
if let sqlx::Error::Database(db_err) = &err {
if db_err.is_unique_violation() {
return RepositoryError::conflict("user already exists");
}
}
RepositoryError::unexpected(err.to_string())
})?;
Ok(user)
}
async fn get(&self, id: UserId) -> Result<User, RepositoryError> {
let query = "SELECT id, username, password_hash, created_at FROM users WHERE id = ?";
let record = query_as::<_, UserRecord>(query)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Self::to_domain(record)
}
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError> {
let query = "SELECT id, username, password_hash, created_at FROM users WHERE username = ?";
let record = query_as::<_, UserRecord>(query)
.bind(username)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Self::to_domain(record)
}
async fn exists(&self) -> Result<bool, RepositoryError> {
let query = "SELECT COUNT(*) FROM users";
let count: i64 = sqlx::query_scalar(query)
.fetch_one(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(count > 0)
}
}
#[derive(sqlx::FromRow)]
struct UserRecord {
id: UserId,
username: String,
password_hash: String,
created_at: DateTime<Utc>,
}