feat: auth hardening + codebase quality sweep
Refresh tokens: RefreshToken entity, PostgresRefreshTokenRepository, login returns refresh token, POST /auth/refresh (rotation), POST /auth/logout, JWT expiry 24h→1h, configurable via with_expiry(). Route protection: require_auth middleware on protected routes, public routes split (register, login, refresh, sharing/access). Authorization: caller_id added to ReadAssetFileQuery, ReadDerivativeQuery, GetStackQuery, DeleteStackCommand with ownership checks. Admin-only gates on processing, storage, sidecar, duplicates handlers. Quality fixes: visibility filtering bypass in search(), unwrap panics in date parsing, DRY auth header parsing, centralized parsers module, email validation via email_address crate, value objects (Username, MimeType, RelativePath), domain events (UserCreated, UserDeleted, AlbumCreated, TagCreated, DuplicateDetected), postgres error mapping for constraint violations, OptionExt::or_not_found helper, in_memory_repo! macro, GetStackQuery moved to queries, album add_entry 200→201.
This commit is contained in:
@@ -19,10 +19,14 @@ pub struct JwtTokenIssuer {
|
||||
|
||||
impl JwtTokenIssuer {
|
||||
pub fn new(secret: &str) -> Self {
|
||||
Self::with_expiry(secret, 1)
|
||||
}
|
||||
|
||||
pub fn with_expiry(secret: &str, expiry_hours: i64) -> Self {
|
||||
Self {
|
||||
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
||||
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||
expiry_hours: 24,
|
||||
expiry_hours,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,29 @@ pub enum EventPayload {
|
||||
error: String,
|
||||
timestamp: String,
|
||||
},
|
||||
UserCreated {
|
||||
user_id: String,
|
||||
timestamp: String,
|
||||
},
|
||||
UserDeleted {
|
||||
user_id: String,
|
||||
timestamp: String,
|
||||
},
|
||||
AlbumCreated {
|
||||
album_id: String,
|
||||
creator_id: String,
|
||||
timestamp: String,
|
||||
},
|
||||
TagCreated {
|
||||
tag_id: String,
|
||||
asset_id: String,
|
||||
timestamp: String,
|
||||
},
|
||||
DuplicateDetected {
|
||||
group_id: String,
|
||||
asset_ids: Vec<String>,
|
||||
timestamp: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -69,6 +92,11 @@ impl EventPayload {
|
||||
Self::JobEnqueued { .. } => "jobs.enqueued",
|
||||
Self::JobCompleted { .. } => "jobs.completed",
|
||||
Self::JobFailed { .. } => "jobs.failed",
|
||||
Self::UserCreated { .. } => "users.created",
|
||||
Self::UserDeleted { .. } => "users.deleted",
|
||||
Self::AlbumCreated { .. } => "albums.created",
|
||||
Self::TagCreated { .. } => "tags.created",
|
||||
Self::DuplicateDetected { .. } => "duplicates.detected",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +191,41 @@ impl From<&DomainEvent> for EventPayload {
|
||||
error: error.clone(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::UserCreated { user_id, timestamp } => Self::UserCreated {
|
||||
user_id: user_id.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::UserDeleted { user_id, timestamp } => Self::UserDeleted {
|
||||
user_id: user_id.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::AlbumCreated {
|
||||
album_id,
|
||||
creator_id,
|
||||
timestamp,
|
||||
} => Self::AlbumCreated {
|
||||
album_id: album_id.to_string(),
|
||||
creator_id: creator_id.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::TagCreated {
|
||||
tag_id,
|
||||
asset_id,
|
||||
timestamp,
|
||||
} => Self::TagCreated {
|
||||
tag_id: tag_id.to_string(),
|
||||
asset_id: asset_id.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
DomainEvent::DuplicateDetected {
|
||||
group_id,
|
||||
asset_ids,
|
||||
timestamp,
|
||||
} => Self::DuplicateDetected {
|
||||
group_id: group_id.to_string(),
|
||||
asset_ids: asset_ids.iter().map(|id| id.to_string()).collect(),
|
||||
timestamp: timestamp.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,6 +336,44 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
error,
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::UserCreated { user_id, timestamp } => DomainEvent::UserCreated {
|
||||
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::UserDeleted { user_id, timestamp } => DomainEvent::UserDeleted {
|
||||
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::AlbumCreated {
|
||||
album_id,
|
||||
creator_id,
|
||||
timestamp,
|
||||
} => DomainEvent::AlbumCreated {
|
||||
album_id: SystemId::from_uuid(parse_uuid(&album_id, "album_id")?),
|
||||
creator_id: SystemId::from_uuid(parse_uuid(&creator_id, "creator_id")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::TagCreated {
|
||||
tag_id,
|
||||
asset_id,
|
||||
timestamp,
|
||||
} => DomainEvent::TagCreated {
|
||||
tag_id: SystemId::from_uuid(parse_uuid(&tag_id, "tag_id")?),
|
||||
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
EventPayload::DuplicateDetected {
|
||||
group_id,
|
||||
asset_ids,
|
||||
timestamp,
|
||||
} => DomainEvent::DuplicateDetected {
|
||||
group_id: SystemId::from_uuid(parse_uuid(&group_id, "group_id")?),
|
||||
asset_ids: asset_ids
|
||||
.iter()
|
||||
.map(|id| parse_uuid(id, "asset_id").map(SystemId::from_uuid))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
timestamp: parse_timestamp(×tamp)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
9
crates/adapters/postgres/migrations/013_asset_stacks.sql
Normal file
9
crates/adapters/postgres/migrations/013_asset_stacks.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE asset_stacks (
|
||||
stack_id UUID PRIMARY KEY,
|
||||
stack_type TEXT NOT NULL,
|
||||
primary_asset_id UUID NOT NULL REFERENCES assets(asset_id),
|
||||
owner_user_id UUID NOT NULL,
|
||||
members JSONB NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_stacks_owner ON asset_stacks(owner_user_id);
|
||||
10
crates/adapters/postgres/migrations/014_refresh_tokens.sql
Normal file
10
crates/adapters/postgres/migrations/014_refresh_tokens.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE refresh_tokens (
|
||||
token_id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
@@ -3,12 +3,16 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
entities::{
|
||||
Asset, AssetMetadata, AssetType, DerivativeAsset, DerivativeProfile, DetectionMethod,
|
||||
DuplicateCandidate, DuplicateGroup, DuplicateStatus, GenerationStatus, MetadataSource,
|
||||
SourceReference,
|
||||
Asset, AssetFilters, AssetMetadata, AssetStack, AssetStackMember, AssetType,
|
||||
DerivativeAsset, DerivativeProfile, DetectionMethod, DuplicateCandidate, DuplicateGroup,
|
||||
DuplicateStatus, GenerationStatus, MetadataSource, SourceReference, StackMemberRole,
|
||||
StackType,
|
||||
},
|
||||
errors::DomainError,
|
||||
ports::{AssetMetadataRepository, AssetRepository, DerivativeRepository, DuplicateRepository},
|
||||
ports::{
|
||||
AssetMetadataRepository, AssetRepository, AssetStackRepository, DerivativeRepository,
|
||||
DuplicateRepository,
|
||||
},
|
||||
value_objects::{Checksum, DateTimeStamp, MetadataValue, StructuredData, SystemId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -123,6 +127,75 @@ impl AssetRepository for PostgresAssetRepository {
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
owner_id: &SystemId,
|
||||
filters: &AssetFilters,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Asset>, DomainError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
|
||||
file_size, is_processed, owner_user_id, created_at
|
||||
FROM assets WHERE owner_user_id = $1",
|
||||
);
|
||||
let mut param_idx = 2u32;
|
||||
|
||||
if filters.asset_type.is_some() {
|
||||
sql.push_str(&format!(" AND asset_type = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.mime_type.is_some() {
|
||||
sql.push_str(&format!(" AND mime_type = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.date_from.is_some() {
|
||||
sql.push_str(&format!(" AND created_at >= ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.date_to.is_some() {
|
||||
sql.push_str(&format!(" AND created_at <= ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.is_processed.is_some() {
|
||||
sql.push_str(&format!(" AND is_processed = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
|
||||
param_idx,
|
||||
param_idx + 1
|
||||
));
|
||||
|
||||
let mut query = sqlx::query_as::<_, AssetRow>(&sql).bind(*owner_id.as_uuid());
|
||||
|
||||
if let Some(ref t) = filters.asset_type {
|
||||
query = query.bind(asset_type_to_str(t));
|
||||
}
|
||||
if let Some(ref m) = filters.mime_type {
|
||||
query = query.bind(m.as_str());
|
||||
}
|
||||
if let Some(ref d) = filters.date_from {
|
||||
query = query.bind(d.as_datetime());
|
||||
}
|
||||
if let Some(ref d) = filters.date_to {
|
||||
query = query.bind(d.as_datetime());
|
||||
}
|
||||
if let Some(p) = filters.is_processed {
|
||||
query = query.bind(p);
|
||||
}
|
||||
|
||||
let rows = query
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
|
||||
@@ -597,3 +670,156 @@ impl DerivativeRepository for PostgresDerivativeRepository {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── AssetStack ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct StackRow {
|
||||
stack_id: Uuid,
|
||||
stack_type: String,
|
||||
primary_asset_id: Uuid,
|
||||
owner_user_id: Uuid,
|
||||
members: serde_json::Value,
|
||||
}
|
||||
|
||||
fn stack_type_from_str(s: &str) -> StackType {
|
||||
match s {
|
||||
"live_photo" => StackType::LivePhoto,
|
||||
"format_pair" => StackType::FormatPair,
|
||||
"burst_sequence" => StackType::BurstSequence,
|
||||
"exposure_bracket" => StackType::ExposureBracket,
|
||||
"manual_group" => StackType::ManualGroup,
|
||||
_ => StackType::ManualGroup,
|
||||
}
|
||||
}
|
||||
|
||||
fn stack_type_to_str(t: &StackType) -> &'static str {
|
||||
match t {
|
||||
StackType::LivePhoto => "live_photo",
|
||||
StackType::FormatPair => "format_pair",
|
||||
StackType::BurstSequence => "burst_sequence",
|
||||
StackType::ExposureBracket => "exposure_bracket",
|
||||
StackType::ManualGroup => "manual_group",
|
||||
}
|
||||
}
|
||||
|
||||
fn member_role_from_str(s: &str) -> StackMemberRole {
|
||||
match s {
|
||||
"primary_display" => StackMemberRole::PrimaryDisplay,
|
||||
"high_res_source" => StackMemberRole::HighResSource,
|
||||
"motion_clip" => StackMemberRole::MotionClip,
|
||||
"alternate_frame" => StackMemberRole::AlternateFrame,
|
||||
_ => StackMemberRole::AlternateFrame,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_role_to_str(r: &StackMemberRole) -> &'static str {
|
||||
match r {
|
||||
StackMemberRole::PrimaryDisplay => "primary_display",
|
||||
StackMemberRole::HighResSource => "high_res_source",
|
||||
StackMemberRole::MotionClip => "motion_clip",
|
||||
StackMemberRole::AlternateFrame => "alternate_frame",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct MemberJson {
|
||||
asset_id: Uuid,
|
||||
role: String,
|
||||
sort_order: u32,
|
||||
}
|
||||
|
||||
fn members_from_json(v: serde_json::Value) -> Vec<AssetStackMember> {
|
||||
let arr: Vec<MemberJson> = serde_json::from_value(v).unwrap_or_default();
|
||||
arr.into_iter()
|
||||
.map(|m| AssetStackMember {
|
||||
asset_id: SystemId::from_uuid(m.asset_id),
|
||||
role: member_role_from_str(&m.role),
|
||||
sort_order: m.sort_order,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn members_to_json(members: &[AssetStackMember]) -> serde_json::Value {
|
||||
let arr: Vec<MemberJson> = members
|
||||
.iter()
|
||||
.map(|m| MemberJson {
|
||||
asset_id: *m.asset_id.as_uuid(),
|
||||
role: member_role_to_str(&m.role).to_string(),
|
||||
sort_order: m.sort_order,
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_value(arr).unwrap_or(serde_json::Value::Array(vec![]))
|
||||
}
|
||||
|
||||
impl From<StackRow> for AssetStack {
|
||||
fn from(r: StackRow) -> Self {
|
||||
Self {
|
||||
stack_id: SystemId::from_uuid(r.stack_id),
|
||||
stack_type: stack_type_from_str(&r.stack_type),
|
||||
primary_asset_id: SystemId::from_uuid(r.primary_asset_id),
|
||||
owner_user_id: SystemId::from_uuid(r.owner_user_id),
|
||||
members: members_from_json(r.members),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pg_repo!(PostgresAssetStackRepository);
|
||||
|
||||
#[async_trait]
|
||||
impl AssetStackRepository for PostgresAssetStackRepository {
|
||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError> {
|
||||
let row = sqlx::query_as::<_, StackRow>(
|
||||
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
|
||||
FROM asset_stacks WHERE stack_id = $1",
|
||||
)
|
||||
.bind(*id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, StackRow>(
|
||||
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
|
||||
FROM asset_stacks WHERE members @> $1::jsonb",
|
||||
)
|
||||
.bind(serde_json::json!([{"asset_id": asset_id.as_uuid()}]))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO asset_stacks (stack_id, stack_type, primary_asset_id, owner_user_id, members)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (stack_id) DO UPDATE SET
|
||||
stack_type = EXCLUDED.stack_type,
|
||||
primary_asset_id = EXCLUDED.primary_asset_id,
|
||||
members = EXCLUDED.members",
|
||||
)
|
||||
.bind(*stack.stack_id.as_uuid())
|
||||
.bind(stack_type_to_str(&stack.stack_type))
|
||||
.bind(*stack.primary_asset_id.as_uuid())
|
||||
.bind(*stack.owner_user_id.as_uuid())
|
||||
.bind(members_to_json(&stack.members))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM asset_stacks WHERE stack_id = $1")
|
||||
.bind(*id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ fn aggregate_id(event: &DomainEvent) -> Uuid {
|
||||
DomainEvent::JobEnqueued { job_id, .. }
|
||||
| DomainEvent::JobCompleted { job_id, .. }
|
||||
| DomainEvent::JobFailed { job_id, .. } => *job_id.as_uuid(),
|
||||
|
||||
DomainEvent::UserCreated { user_id, .. } | DomainEvent::UserDeleted { user_id, .. } => {
|
||||
*user_id.as_uuid()
|
||||
}
|
||||
|
||||
DomainEvent::AlbumCreated { album_id, .. } => *album_id.as_uuid(),
|
||||
|
||||
DomainEvent::TagCreated { tag_id, .. } => *tag_id.as_uuid(),
|
||||
|
||||
DomainEvent::DuplicateDetected { group_id, .. } => *group_id.as_uuid(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
/// Extension trait for converting `sqlx::Error` into `DomainError`.
|
||||
pub trait MapDomainError<T> {
|
||||
fn map_pg(self) -> Result<T, DomainError>;
|
||||
}
|
||||
|
||||
impl<T> MapDomainError<T> for Result<T, sqlx::Error> {
|
||||
fn map_pg(self) -> Result<T, DomainError> {
|
||||
self.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
self.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505") => {
|
||||
DomainError::Conflict(
|
||||
db_err
|
||||
.constraint()
|
||||
.map(|c| format!("Duplicate: {c}"))
|
||||
.unwrap_or_else(|| "Duplicate entry".into()),
|
||||
)
|
||||
}
|
||||
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23503") => {
|
||||
DomainError::NotFound(
|
||||
db_err
|
||||
.constraint()
|
||||
.map(|c| format!("Referenced entity not found: {c}"))
|
||||
.unwrap_or_else(|| "Referenced entity not found".into()),
|
||||
)
|
||||
}
|
||||
_ => DomainError::Internal(e.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a Postgres repository struct with a `PgPool` field and a `new` constructor.
|
||||
///
|
||||
/// ```ignore
|
||||
/// pg_repo!(PostgresFooRepository);
|
||||
/// // expands to:
|
||||
/// // pub struct PostgresFooRepository { pool: PgPool }
|
||||
/// // impl PostgresFooRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
/// ```
|
||||
macro_rules! pg_repo {
|
||||
($name:ident) => {
|
||||
pub struct $name {
|
||||
|
||||
@@ -2,9 +2,10 @@ use crate::helpers::{MapDomainError, pg_repo};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
entities::RefreshToken,
|
||||
errors::DomainError,
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, SystemId},
|
||||
ports::{RefreshTokenRepository, UserRepository},
|
||||
value_objects::{DateTimeStamp, Email, PasswordHash, SystemId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -109,3 +110,82 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- PostgresRefreshTokenRepository ---
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RefreshTokenRow {
|
||||
token_id: Uuid,
|
||||
user_id: Uuid,
|
||||
token_hash: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
revoked: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<RefreshTokenRow> for RefreshToken {
|
||||
fn from(r: RefreshTokenRow) -> Self {
|
||||
Self {
|
||||
token_id: SystemId::from_uuid(r.token_id),
|
||||
user_id: SystemId::from_uuid(r.user_id),
|
||||
token_hash: r.token_hash,
|
||||
expires_at: DateTimeStamp::from_datetime(r.expires_at),
|
||||
revoked: r.revoked,
|
||||
created_at: DateTimeStamp::from_datetime(r.created_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pg_repo!(PostgresRefreshTokenRepository);
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshTokenRepository for PostgresRefreshTokenRepository {
|
||||
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (token_id, user_id, token_hash, expires_at, revoked, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (token_id) DO UPDATE SET revoked = EXCLUDED.revoked",
|
||||
)
|
||||
.bind(*token.token_id.as_uuid())
|
||||
.bind(*token.user_id.as_uuid())
|
||||
.bind(&token.token_hash)
|
||||
.bind(*token.expires_at.as_datetime())
|
||||
.bind(token.revoked)
|
||||
.bind(*token.created_at.as_datetime())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError> {
|
||||
let row = sqlx::query_as::<_, RefreshTokenRow>(
|
||||
"SELECT token_id, user_id, token_hash, expires_at, revoked, created_at
|
||||
FROM refresh_tokens WHERE token_hash = $1",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_tokens WHERE user_id = $1")
|
||||
.bind(*user_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_tokens WHERE token_id = $1")
|
||||
.bind(*id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,59 @@ impl JobRepository for PostgresJobRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_all(
|
||||
&self,
|
||||
status: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Job>, DomainError> {
|
||||
let rows = match status {
|
||||
Some(s) => sqlx::query_as::<_, JobRow>(
|
||||
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
|
||||
payload, result_data, retry_count, max_retries, created_at,
|
||||
started_at, completed_at, error_message
|
||||
FROM jobs WHERE status = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(s)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?,
|
||||
None => sqlx::query_as::<_, JobRow>(
|
||||
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
|
||||
payload, result_data, retry_count, max_retries, created_at,
|
||||
started_at, completed_at, error_message
|
||||
FROM jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?,
|
||||
};
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn count(&self, status: Option<&str>) -> Result<u64, DomainError> {
|
||||
let count: (i64,) = match status {
|
||||
Some(s) => sqlx::query_as("SELECT COUNT(*) FROM jobs WHERE status = $1")
|
||||
.bind(s)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_pg()?,
|
||||
None => sqlx::query_as("SELECT COUNT(*) FROM jobs")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_pg()?,
|
||||
};
|
||||
Ok(count.0 as u64)
|
||||
}
|
||||
|
||||
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, JobRow>(
|
||||
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
|
||||
|
||||
Reference in New Issue
Block a user