init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

View 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

View 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(())
}

View File

@@ -0,0 +1,4 @@
mod db;
pub mod repositories;
pub use db::{create_pool, run_migrations};

View 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);

View File

@@ -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);

View 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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteActivityCommandRepository;
pub use query::SqliteActivityQueryRepository;

View 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())
}
}

View 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(),
)
}
}

View 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)
}
}

View 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())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
pub(crate) mod rows;
pub use command::SqliteEntryCommandRepository;
pub use query::SqliteEntryQueryRepository;

View 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
}
}

View 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)
}

View 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};

View File

@@ -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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqlitePushSubscriptionCommandRepository;
pub use query::SqlitePushSubscriptionQueryRepository;

View File

@@ -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()))
}
}

View File

@@ -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(),
)
}
}

View File

@@ -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())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteRefreshSessionCommandRepository;
pub use query::SqliteRefreshSessionQueryRepository;

View File

@@ -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))
}
}

View File

@@ -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(),
)
}
}

View 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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteReminderCommandRepository;
pub use query::SqliteReminderQueryRepository;

View 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())
}
}

View 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()
}

View File

@@ -0,0 +1,5 @@
use domain::errors::DomainError;
pub fn db_err(e: sqlx::Error) -> DomainError {
DomainError::InvalidInput(format!("database error: {e}"))
}

View 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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteUserCommandRepository;
pub use query::SqliteUserQueryRepository;

View 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))
}
}

View 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(),
})
}
}