13
crates/adapters/sqlite/Cargo.toml
Normal file
13
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "sqlite"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
config.workspace = true
|
||||
async-trait.workspace = true
|
||||
sqlx.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
30
crates/adapters/sqlite/src/db.rs
Normal file
30
crates/adapters/sqlite/src/db.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
include_str!("migrations/001_initial.sql"),
|
||||
include_str!("migrations/002_push_subscriptions.sql"),
|
||||
];
|
||||
|
||||
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||
let options: SqliteConnectOptions = database_url
|
||||
.parse::<SqliteConnectOptions>()?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.foreign_keys(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
for migration in MIGRATIONS {
|
||||
sqlx::raw_sql(*migration).execute(pool).await?;
|
||||
}
|
||||
tracing::info!("database migrations completed");
|
||||
Ok(())
|
||||
}
|
||||
4
crates/adapters/sqlite/src/lib.rs
Normal file
4
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod db;
|
||||
pub mod repositories;
|
||||
|
||||
pub use db::{create_pool, run_migrations};
|
||||
77
crates/adapters/sqlite/src/migrations/001_initial.sql
Normal file
77
crates/adapters/sqlite/src/migrations/001_initial.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
timezone TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'User',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mood_entries (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
mood INTEGER NOT NULL,
|
||||
logged_at TEXT NOT NULL,
|
||||
content TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_activities (
|
||||
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id),
|
||||
PRIMARY KEY (entry_id, activity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
photo_id TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_id, photo_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_voice_memos (
|
||||
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
voice_memo_id TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_id, voice_memo_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
monday TEXT,
|
||||
tuesday TEXT,
|
||||
wednesday TEXT,
|
||||
thursday TEXT,
|
||||
friday TEXT,
|
||||
saturday TEXT,
|
||||
sunday TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_user_id ON activities(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mood_entries_user_id ON mood_entries(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mood_entries_logged_at ON mood_entries(logged_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_user_id ON reminders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
58
crates/adapters/sqlite/src/repositories/activity/command.rs
Normal file
58
crates/adapters/sqlite/src/repositories/activity/command.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::{Activity, ActivityId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteActivityCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteActivityCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ActivityCommandPort for SqliteActivityCommandRepository {
|
||||
async fn save(&self, activity: &Activity) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO activities (id, user_id, name, category, archived, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, category = excluded.category,
|
||||
archived = excluded.archived",
|
||||
)
|
||||
.bind(activity.id().value().to_string())
|
||||
.bind(activity.user_id().value().to_string())
|
||||
.bind(activity.name().value())
|
||||
.bind(activity.category().map(|c| c.value().to_string()))
|
||||
.bind(activity.is_archived())
|
||||
.bind(activity.created_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ActivityId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM activities WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM activities WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/activity/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/activity/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteActivityCommandRepository;
|
||||
pub use query::SqliteActivityQueryRepository;
|
||||
52
crates/adapters/sqlite/src/repositories/activity/query.rs
Normal file
52
crates/adapters/sqlite/src/repositories/activity/query.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::{Activity, ActivityId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::ActivityRow;
|
||||
|
||||
pub struct SqliteActivityQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteActivityQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ActivityQueryPort for SqliteActivityQueryRepository {
|
||||
async fn find_by_id(&self, id: &ActivityId) -> Result<Option<Activity>, DomainError> {
|
||||
let row = sqlx::query_as::<_, ActivityRow>("SELECT * FROM activities WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(ActivityRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ActivityRow>(
|
||||
"SELECT * FROM activities WHERE user_id = ? ORDER BY name",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ActivityRow::into_domain).collect())
|
||||
}
|
||||
|
||||
async fn find_active_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ActivityRow>(
|
||||
"SELECT * FROM activities WHERE user_id = ? AND archived = 0 ORDER BY name",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ActivityRow::into_domain).collect())
|
||||
}
|
||||
}
|
||||
25
crates/adapters/sqlite/src/repositories/activity/rows.rs
Normal file
25
crates/adapters/sqlite/src/repositories/activity/rows.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use domain::activity::{Activity, ActivityId, ActivityName, CategoryName};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ActivityRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub category: Option<String>,
|
||||
pub archived: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl ActivityRow {
|
||||
pub fn into_domain(self) -> Activity {
|
||||
Activity::from_persistence(
|
||||
ActivityId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
ActivityName::from_persistence(self.name),
|
||||
self.category.map(CategoryName::from_persistence),
|
||||
self.archived,
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
127
crates/adapters/sqlite/src/repositories/cascade/mod.rs
Normal file
127
crates/adapters/sqlite/src/repositories/cascade/mod.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::entry::{DateRange, MoodEntry};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::entry::rows::{EntryRow, hydrate_batch};
|
||||
use super::shared::db_err;
|
||||
|
||||
pub struct SqliteCascadeDeleteRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteCascadeDeleteRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
|
||||
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM activities WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM activities WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_entries_in_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let entries = hydrate_batch(&self.pool, rows).await?;
|
||||
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
203
crates/adapters/sqlite/src/repositories/entry/command.rs
Normal file
203
crates/adapters/sqlite/src/repositories/entry/command.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{DateRange, MoodEntry, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteEntryCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteEntryCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
async fn save_relations(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||
let entry_id = entry.id().value().to_string();
|
||||
|
||||
sqlx::query("DELETE FROM entry_activities WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for activity_id in entry.activities() {
|
||||
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(activity_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entry_photos WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for photo_id in entry.photos() {
|
||||
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(photo_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entry_voice_memos WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for voice_memo_id in entry.voice_memos() {
|
||||
sqlx::query("INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(voice_memo_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
|
||||
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||
content = excluded.content, updated_at = excluded.updated_at"
|
||||
)
|
||||
.bind(entry.id().value().to_string())
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.bind(entry.mood().value() as i32)
|
||||
.bind(entry.logged_at().to_rfc3339())
|
||||
.bind(entry.content().map(|c| c.value().to_string()))
|
||||
.bind(entry.created_at().to_rfc3339())
|
||||
.bind(entry.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
self.save_relations(entry).await
|
||||
}
|
||||
|
||||
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry_id = entry.id().value().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||
content = excluded.content, updated_at = excluded.updated_at"
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.bind(entry.mood().value() as i32)
|
||||
.bind(entry.logged_at().to_rfc3339())
|
||||
.bind(entry.content().map(|c| c.value().to_string()))
|
||||
.bind(entry.created_at().to_rfc3339())
|
||||
.bind(entry.updated_at().to_rfc3339())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
for activity_id in entry.activities() {
|
||||
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(activity_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
for photo_id in entry.photos() {
|
||||
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(photo_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
for voice_memo_id in entry.voice_memos() {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.bind(voice_memo_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &MoodEntryId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM mood_entries WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn replace_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
old_activity_id: &ActivityId,
|
||||
new_activity_id: &ActivityId,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE entry_activities SET activity_id = ?
|
||||
WHERE activity_id = ? AND entry_id IN (SELECT id FROM mood_entries WHERE user_id = ?)",
|
||||
)
|
||||
.bind(new_activity_id.value().to_string())
|
||||
.bind(old_activity_id.value().to_string())
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/entry/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/entry/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
pub(crate) mod rows;
|
||||
|
||||
pub use command::SqliteEntryCommandRepository;
|
||||
pub use query::SqliteEntryQueryRepository;
|
||||
110
crates/adapters/sqlite/src/repositories/entry/query.rs
Normal file
110
crates/adapters/sqlite/src/repositories/entry/query.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{EntryRow, hydrate_batch, hydrate_single};
|
||||
|
||||
pub struct SqliteEntryQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteEntryQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
|
||||
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError> {
|
||||
let row = sqlx::query_as::<_, EntryRow>("SELECT * FROM mood_entries WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(hydrate_single(&self.pool, r).await?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let limit = limit.unwrap_or(i64::MAX);
|
||||
let offset = offset.unwrap_or(0);
|
||||
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? ORDER BY logged_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
|
||||
async fn find_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
|
||||
async fn find_by_mood(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
mood: Mood,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? AND mood = ? ORDER BY logged_at DESC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(mood.value() as i32)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
|
||||
async fn find_by_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT me.* FROM mood_entries me
|
||||
INNER JOIN entry_activities ea ON ea.entry_id = me.id
|
||||
WHERE me.user_id = ? AND ea.activity_id = ?
|
||||
ORDER BY me.logged_at DESC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(activity_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
}
|
||||
153
crates/adapters/sqlite/src/repositories/entry/rows.rs
Normal file
153
crates/adapters/sqlite/src/repositories/entry/rows.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct EntryRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub mood: i32,
|
||||
pub logged_at: String,
|
||||
pub content: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RelationRow {
|
||||
entry_id: String,
|
||||
related_id: String,
|
||||
}
|
||||
|
||||
pub fn row_to_entry(
|
||||
row: EntryRow,
|
||||
activity_ids: Vec<String>,
|
||||
photo_ids: Vec<String>,
|
||||
voice_memo_ids: Vec<String>,
|
||||
) -> Result<MoodEntry, DomainError> {
|
||||
Ok(MoodEntry::from_persistence(MoodEntryData {
|
||||
id: MoodEntryId::from_uuid(row.id.parse().unwrap()),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().unwrap()),
|
||||
mood: Mood::try_from(row.mood as u8)?,
|
||||
logged_at: row.logged_at.parse().unwrap(),
|
||||
activities: activity_ids
|
||||
.into_iter()
|
||||
.map(|id| ActivityId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
content: row.content.map(Content::from_persistence),
|
||||
photos: photo_ids
|
||||
.into_iter()
|
||||
.map(|id| PhotoId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
voice_memos: voice_memo_ids
|
||||
.into_iter()
|
||||
.map(|id| VoiceMemoId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
created_at: row.created_at.parse().unwrap(),
|
||||
updated_at: row.updated_at.parse().unwrap(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn hydrate_single(pool: &SqlitePool, row: EntryRow) -> Result<MoodEntry, DomainError> {
|
||||
let entry_id = row.id.clone();
|
||||
|
||||
let activities: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let photos: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let voice_memos: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
row_to_entry(
|
||||
row,
|
||||
activities.into_iter().map(|r| r.related_id).collect(),
|
||||
photos.into_iter().map(|r| r.related_id).collect(),
|
||||
voice_memos.into_iter().map(|r| r.related_id).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn hydrate_batch(
|
||||
pool: &SqlitePool,
|
||||
rows: Vec<EntryRow>,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
if rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let entry_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
|
||||
let activities = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let photos = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let voice_memos = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let mut entries = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id = row.id.clone();
|
||||
entries.push(row_to_entry(
|
||||
row,
|
||||
activities.get(&id).cloned().unwrap_or_default(),
|
||||
photos.get(&id).cloned().unwrap_or_default(),
|
||||
voice_memos.get(&id).cloned().unwrap_or_default(),
|
||||
)?);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn batch_load(
|
||||
pool: &SqlitePool,
|
||||
sql: &str,
|
||||
entry_ids: &[String],
|
||||
) -> Result<HashMap<String, Vec<String>>, DomainError> {
|
||||
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id);
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(pool).await.map_err(db_err)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
map.entry(row.entry_id).or_default().push(row.related_id);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
21
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
21
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
pub mod shared;
|
||||
|
||||
mod activity;
|
||||
mod cascade;
|
||||
mod entry;
|
||||
mod push_subscription;
|
||||
mod refresh_session;
|
||||
mod reminder;
|
||||
mod user;
|
||||
|
||||
pub use activity::{SqliteActivityCommandRepository, SqliteActivityQueryRepository};
|
||||
pub use cascade::SqliteCascadeDeleteRepository;
|
||||
pub use entry::{SqliteEntryCommandRepository, SqliteEntryQueryRepository};
|
||||
pub use push_subscription::{
|
||||
SqlitePushSubscriptionCommandRepository, SqlitePushSubscriptionQueryRepository,
|
||||
};
|
||||
pub use refresh_session::{
|
||||
SqliteRefreshSessionCommandRepository, SqliteRefreshSessionQueryRepository,
|
||||
};
|
||||
pub use reminder::{SqliteReminderCommandRepository, SqliteReminderQueryRepository};
|
||||
pub use user::{SqliteUserCommandRepository, SqliteUserQueryRepository};
|
||||
@@ -0,0 +1,68 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::push::{PushSubscription, PushSubscriptionId};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqlitePushSubscriptionCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqlitePushSubscriptionCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::PushSubscriptionCommandPort for SqlitePushSubscriptionCommandRepository {
|
||||
async fn save(&self, sub: &PushSubscription) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth",
|
||||
)
|
||||
.bind(sub.id().value().to_string())
|
||||
.bind(sub.user_id().value().to_string())
|
||||
.bind(sub.endpoint())
|
||||
.bind(sub.p256dh())
|
||||
.bind(sub.auth())
|
||||
.bind(sub.created_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?")
|
||||
.bind(endpoint)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqlitePushSubscriptionCommandRepository;
|
||||
pub use query::SqlitePushSubscriptionQueryRepository;
|
||||
@@ -0,0 +1,48 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::push::PushSubscription;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::PushSubscriptionRow;
|
||||
|
||||
pub struct SqlitePushSubscriptionQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqlitePushSubscriptionQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::PushSubscriptionQueryPort for SqlitePushSubscriptionQueryRepository {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<PushSubscription>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PushSubscriptionRow>(
|
||||
"SELECT * FROM push_subscriptions WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into_entity()).collect())
|
||||
}
|
||||
|
||||
async fn find_by_endpoint(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
) -> Result<Option<PushSubscription>, DomainError> {
|
||||
let row = sqlx::query_as::<_, PushSubscriptionRow>(
|
||||
"SELECT * FROM push_subscriptions WHERE endpoint = ?",
|
||||
)
|
||||
.bind(endpoint)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(row.map(|r| r.into_entity()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use domain::push::{PushSubscription, PushSubscriptionId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct PushSubscriptionRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub endpoint: String,
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl PushSubscriptionRow {
|
||||
pub fn into_entity(self) -> PushSubscription {
|
||||
PushSubscription::from_persistence(
|
||||
PushSubscriptionId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
self.endpoint,
|
||||
self.p256dh,
|
||||
self.auth,
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::auth::RefreshSession;
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteRefreshSessionCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRefreshSessionCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RefreshSessionCommandPort for SqliteRefreshSessionCommandRepository {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(session.id().value().to_string())
|
||||
.bind(session.user_id().value().to_string())
|
||||
.bind(session.token())
|
||||
.bind(session.expires_at().to_rfc3339())
|
||||
.bind(session.created_at().to_rfc3339())
|
||||
.execute(&self.pool).await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteRefreshSessionCommandRepository;
|
||||
pub use query::SqliteRefreshSessionQueryRepository;
|
||||
@@ -0,0 +1,31 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::auth::RefreshSession;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::RefreshSessionRow;
|
||||
|
||||
pub struct SqliteRefreshSessionQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRefreshSessionQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RefreshSessionQueryPort for SqliteRefreshSessionQueryRepository {
|
||||
async fn find_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||
"SELECT * FROM refresh_sessions WHERE token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(RefreshSessionRow::into_domain))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use domain::auth::{RefreshSession, RefreshSessionId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct RefreshSessionRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub token: String,
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl RefreshSessionRow {
|
||||
pub fn into_domain(self) -> RefreshSession {
|
||||
RefreshSession::from_persistence(
|
||||
RefreshSessionId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
self.token,
|
||||
self.expires_at.parse().unwrap(),
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
65
crates/adapters/sqlite/src/repositories/reminder/command.rs
Normal file
65
crates/adapters/sqlite/src/repositories/reminder/command.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use chrono::Weekday;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::reminder::{Reminder, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::format_time;
|
||||
|
||||
pub struct SqliteReminderCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteReminderCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ReminderCommandPort for SqliteReminderCommandRepository {
|
||||
async fn save(&self, reminder: &Reminder) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO reminders (id, user_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, enabled, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
monday = excluded.monday, tuesday = excluded.tuesday,
|
||||
wednesday = excluded.wednesday, thursday = excluded.thursday,
|
||||
friday = excluded.friday, saturday = excluded.saturday,
|
||||
sunday = excluded.sunday, enabled = excluded.enabled"
|
||||
)
|
||||
.bind(reminder.id().value().to_string())
|
||||
.bind(reminder.user_id().value().to_string())
|
||||
.bind(reminder.schedule().time_for(Weekday::Mon).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Tue).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Wed).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Thu).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Fri).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Sat).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Sun).map(format_time))
|
||||
.bind(reminder.is_enabled())
|
||||
.bind(reminder.created_at().to_rfc3339())
|
||||
.execute(&self.pool).await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ReminderId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM reminders WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/reminder/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/reminder/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteReminderCommandRepository;
|
||||
pub use query::SqliteReminderQueryRepository;
|
||||
47
crates/adapters/sqlite/src/repositories/reminder/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/reminder/query.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::reminder::{Reminder, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::ReminderRow;
|
||||
|
||||
pub struct SqliteReminderQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteReminderQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ReminderQueryPort for SqliteReminderQueryRepository {
|
||||
async fn find_by_id(&self, id: &ReminderId) -> Result<Option<Reminder>, DomainError> {
|
||||
let row = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(ReminderRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Reminder>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ReminderRow::into_domain).collect())
|
||||
}
|
||||
|
||||
async fn find_all_enabled(&self) -> Result<Vec<Reminder>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE enabled = 1")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ReminderRow::into_domain).collect())
|
||||
}
|
||||
}
|
||||
49
crates/adapters/sqlite/src/repositories/reminder/rows.rs
Normal file
49
crates/adapters/sqlite/src/repositories/reminder/rows.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use chrono::NaiveTime;
|
||||
|
||||
use domain::reminder::{DaySchedule, Reminder, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ReminderRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub monday: Option<String>,
|
||||
pub tuesday: Option<String>,
|
||||
pub wednesday: Option<String>,
|
||||
pub thursday: Option<String>,
|
||||
pub friday: Option<String>,
|
||||
pub saturday: Option<String>,
|
||||
pub sunday: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl ReminderRow {
|
||||
pub fn into_domain(self) -> Reminder {
|
||||
let schedule = DaySchedule::from_persistence(
|
||||
self.monday.and_then(|s| parse_time(&s)),
|
||||
self.tuesday.and_then(|s| parse_time(&s)),
|
||||
self.wednesday.and_then(|s| parse_time(&s)),
|
||||
self.thursday.and_then(|s| parse_time(&s)),
|
||||
self.friday.and_then(|s| parse_time(&s)),
|
||||
self.saturday.and_then(|s| parse_time(&s)),
|
||||
self.sunday.and_then(|s| parse_time(&s)),
|
||||
);
|
||||
|
||||
Reminder::from_persistence(
|
||||
ReminderId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
schedule,
|
||||
self.enabled,
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_time(s: &str) -> Option<NaiveTime> {
|
||||
NaiveTime::parse_from_str(s, "%H:%M").ok()
|
||||
}
|
||||
|
||||
pub fn format_time(t: NaiveTime) -> String {
|
||||
t.format("%H:%M").to_string()
|
||||
}
|
||||
5
crates/adapters/sqlite/src/repositories/shared.rs
Normal file
5
crates/adapters/sqlite/src/repositories/shared.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub fn db_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InvalidInput(format!("database error: {e}"))
|
||||
}
|
||||
53
crates/adapters/sqlite/src/repositories/user/command.rs
Normal file
53
crates/adapters/sqlite/src/repositories/user/command.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteUserCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::UserCommandPort for SqliteUserCommandRepository {
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, email, password_hash, display_name, timezone, role, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
username = excluded.username, email = excluded.email,
|
||||
password_hash = excluded.password_hash, display_name = excluded.display_name,
|
||||
timezone = excluded.timezone, role = excluded.role,
|
||||
updated_at = excluded.updated_at"
|
||||
)
|
||||
.bind(user.id().value().to_string())
|
||||
.bind(user.username().value())
|
||||
.bind(user.email().value())
|
||||
.bind(user.password_hash().value())
|
||||
.bind(user.display_name().map(|d| d.value().to_string()))
|
||||
.bind(user.timezone().map(|t| t.value().to_string()))
|
||||
.bind(format!("{:?}", user.role()))
|
||||
.bind(user.created_at().to_rfc3339())
|
||||
.bind(user.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/user/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/user/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteUserCommandRepository;
|
||||
pub use query::SqliteUserQueryRepository;
|
||||
47
crates/adapters/sqlite/src/repositories/user/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/user/query.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::{Email, User, UserId, Username};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::UserRow;
|
||||
|
||||
pub struct SqliteUserQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE username = ?")
|
||||
.bind(username.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(email.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
}
|
||||
}
|
||||
37
crates/adapters/sqlite/src/repositories/user/rows.rs
Normal file
37
crates/adapters/sqlite/src/repositories/user/rows.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use domain::user::{
|
||||
DisplayName, Email, PasswordHash, Timezone, User, UserData, UserId, UserRole, Username,
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct UserRow {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub role: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl UserRow {
|
||||
pub fn into_domain(self) -> User {
|
||||
let role = match self.role.as_str() {
|
||||
"Admin" => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
User::from_persistence(UserData {
|
||||
id: UserId::from_uuid(self.id.parse().unwrap()),
|
||||
username: Username::from_persistence(self.username),
|
||||
email: Email::from_persistence(self.email),
|
||||
password_hash: PasswordHash::new(self.password_hash),
|
||||
display_name: self.display_name.map(DisplayName::from_persistence),
|
||||
timezone: self.timezone.map(Timezone::from_persistence),
|
||||
role,
|
||||
created_at: self.created_at.parse().unwrap(),
|
||||
updated_at: self.updated_at.parse().unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user