diff --git a/libertas_api/migrations/20251102081244_create_albums_tables.sql b/libertas_api/migrations/20251102081244_create_albums_tables.sql index 3448c76..38d501e 100644 --- a/libertas_api/migrations/20251102081244_create_albums_tables.sql +++ b/libertas_api/migrations/20251102081244_create_albums_tables.sql @@ -1,4 +1,3 @@ --- Create the 'albums' table CREATE TABLE albums ( id UUID PRIMARY KEY, owner_id UUID NOT NULL REFERENCES users (id), @@ -9,15 +8,12 @@ CREATE TABLE albums ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Create the 'album_media' join table --- This links media items to albums CREATE TABLE album_media ( album_id UUID NOT NULL REFERENCES albums (id) ON DELETE CASCADE, media_id UUID NOT NULL REFERENCES media (id) ON DELETE CASCADE, - PRIMARY KEY (album_id, media_id) -- Ensures no duplicates + PRIMARY KEY (album_id, media_id) ); --- Indexes for faster lookups CREATE INDEX idx_albums_owner_id ON albums (owner_id); CREATE INDEX idx_album_media_media_id ON album_media (media_id); \ No newline at end of file diff --git a/libertas_api/migrations/20251115085433_create_tags_table.sql b/libertas_api/migrations/20251115085433_create_tags_table.sql new file mode 100644 index 0000000..840b0f9 --- /dev/null +++ b/libertas_api/migrations/20251115085433_create_tags_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE tags ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE media_tags ( + media_id UUID NOT NULL REFERENCES media (id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags (id) ON DELETE CASCADE, + PRIMARY KEY (media_id, tag_id) +); + +CREATE INDEX idx_media_tags_tag_id ON media_tags (tag_id); \ No newline at end of file diff --git a/libertas_api/migrations/20251115085440_create_people_table.sql b/libertas_api/migrations/20251115085440_create_people_table.sql new file mode 100644 index 0000000..4386b81 --- /dev/null +++ b/libertas_api/migrations/20251115085440_create_people_table.sql @@ -0,0 +1,21 @@ +CREATE TABLE people ( + id UUID PRIMARY KEY, + owner_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL +); + + +CREATE TABLE face_regions ( + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media (id) ON DELETE CASCADE, + person_id UUID REFERENCES people (id) ON DELETE SET NULL, + + x_min REAL NOT NULL, + y_min REAL NOT NULL, + x_max REAL NOT NULL, + y_max REAL NOT NULL +); + +CREATE INDEX idx_people_owner_id ON people (owner_id); +CREATE INDEX idx_face_regions_media_id ON face_regions (media_id); +CREATE INDEX idx_face_regions_person_id ON face_regions (person_id); \ No newline at end of file diff --git a/libertas_api/migrations/20251115093642_create_people_shares_table.sql b/libertas_api/migrations/20251115093642_create_people_shares_table.sql new file mode 100644 index 0000000..b906116 --- /dev/null +++ b/libertas_api/migrations/20251115093642_create_people_shares_table.sql @@ -0,0 +1,13 @@ +CREATE TYPE person_permission AS ENUM ( + 'view', + 'can_use' +); + +CREATE TABLE person_shares ( + person_id UUID NOT NULL REFERENCES people (id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + permission person_permission NOT NULL, + PRIMARY KEY (person_id, user_id) +); + +CREATE INDEX idx_person_shares_user_id ON person_shares (user_id); \ No newline at end of file diff --git a/libertas_api/src/factory.rs b/libertas_api/src/factory.rs index 296a64a..c9bb98c 100644 --- a/libertas_api/src/factory.rs +++ b/libertas_api/src/factory.rs @@ -5,14 +5,13 @@ use libertas_core::{ error::{CoreError, CoreResult}, }; use libertas_infra::factory::{ - build_album_repository, build_album_share_repository, build_database_pool, build_media_metadata_repository, build_media_repository, build_user_repository + build_album_repository, build_album_share_repository, build_database_pool, build_face_region_repository, build_media_metadata_repository, build_media_repository, build_person_repository, build_person_share_repository, build_tag_repository, build_user_repository }; use crate::{ security::{Argon2Hasher, JwtGenerator}, services::{ - album_service::AlbumServiceImpl, media_service::MediaServiceImpl, - user_service::UserServiceImpl, + album_service::AlbumServiceImpl, media_service::MediaServiceImpl, person_service::PersonServiceImpl, tag_service::TagServiceImpl, user_service::UserServiceImpl }, state::AppState, }; @@ -31,6 +30,10 @@ pub async fn build_app_state(config: AppConfig) -> CoreResult { let album_share_repo = build_album_share_repository(&config.database, db_pool.clone()).await?; let media_metadata_repo = build_media_metadata_repository(&config.database, db_pool.clone()).await?; + let tag_repo = build_tag_repository(&config.database, db_pool.clone()).await?; + let person_repo = build_person_repository(&config.database, db_pool.clone()).await?; + let face_region_repo = build_face_region_repository(&config.database, db_pool.clone()).await?; + let person_share_repo = build_person_share_repository(&config.database, db_pool.clone()).await?; let hasher = Arc::new(Argon2Hasher::default()); let tokenizer = Arc::new(JwtGenerator::new(config.jwt_secret.clone())); @@ -51,14 +54,26 @@ pub async fn build_app_state(config: AppConfig) -> CoreResult { )); let album_service = Arc::new(AlbumServiceImpl::new( album_repo, - media_repo, + media_repo.clone(), album_share_repo, )); + let tag_service = Arc::new(TagServiceImpl::new( + tag_repo, + media_repo.clone(), + )); + let person_service = Arc::new(PersonServiceImpl::new( + person_repo, + face_region_repo, + media_repo.clone(), + person_share_repo, + )); Ok(AppState { user_service, media_service, album_service, + tag_service, + person_service, token_generator: tokenizer, nats_client, config, diff --git a/libertas_api/src/handlers/mod.rs b/libertas_api/src/handlers/mod.rs index 814e2fe..f859446 100644 --- a/libertas_api/src/handlers/mod.rs +++ b/libertas_api/src/handlers/mod.rs @@ -2,3 +2,5 @@ pub mod album_handlers; pub mod auth_handlers; pub mod media_handlers; pub mod user_handlers; +pub mod tag_handlers; +pub mod person_handlers; \ No newline at end of file diff --git a/libertas_api/src/handlers/person_handlers.rs b/libertas_api/src/handlers/person_handlers.rs new file mode 100644 index 0000000..151fde0 --- /dev/null +++ b/libertas_api/src/handlers/person_handlers.rs @@ -0,0 +1,152 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post, put}, + Json, Router, +}; + +use uuid::Uuid; + +use crate::{ + error::ApiError, + middleware::auth::UserId, + schema::{ + AssignFaceRequest, CreatePersonRequest, FaceRegionResponse, PersonResponse, + SharePersonRequest, UpdatePersonRequest, + }, + state::AppState, +}; + +pub fn people_routes() -> Router { + Router::new() + .route("/", get(list_people).post(create_person)) + .route( + "/{person_id}", + get(get_person) + .put(update_person) + .delete(delete_person), + ) + .route("/{person_id}/share", post(share_person).delete(unshare_person)) +} + +pub fn face_routes() -> Router { + Router::new() + .route("/media/{media_id}/faces", get(list_faces_for_media)) + .route("/faces/{face_id}/person", put(assign_face_to_person)) +} + +async fn create_person( + State(state): State, + UserId(user_id): UserId, + Json(payload): Json, +) -> Result { + let person = state + .person_service + .create_person(&payload.name, user_id) + .await?; + Ok((StatusCode::CREATED, Json(PersonResponse::from(person)))) +} + +async fn get_person( + State(state): State, + UserId(user_id): UserId, + Path(person_id): Path, +) -> Result, ApiError> { + let person = state + .person_service + .get_person(person_id, user_id) + .await?; + Ok(Json(PersonResponse::from(person))) +} + +async fn list_people( + State(state): State, + UserId(user_id): UserId, +) -> Result>, ApiError> { + let people = state.person_service.list_people(user_id).await?; + let response = people.into_iter().map(PersonResponse::from).collect(); + Ok(Json(response)) +} + +async fn update_person( + State(state): State, + UserId(user_id): UserId, + Path(person_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let person = state + .person_service + .update_person(person_id, &payload.name, user_id) + .await?; + Ok(Json(PersonResponse::from(person))) +} + +async fn delete_person( + State(state): State, + UserId(user_id): UserId, + Path(person_id): Path, +) -> Result { + state + .person_service + .delete_person(person_id, user_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn share_person( + State(state): State, + UserId(user_id): UserId, + Path(person_id): Path, + Json(payload): Json, +) -> Result { + state + .person_service + .share_person( + person_id, + payload.target_user_id, + payload.permission, + user_id, + ) + .await?; + Ok(StatusCode::OK) +} + +async fn unshare_person( + State(state): State, + UserId(user_id): UserId, + Path(person_id): Path, + Json(payload): Json, +) -> Result { + state + .person_service + .unshare_person(person_id, payload.target_user_id, user_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_faces_for_media( + State(state): State, + UserId(user_id): UserId, + Path(media_id): Path, +) -> Result>, ApiError> { + let faces = state + .person_service + .list_faces_for_media(media_id, user_id) + .await?; + let response = faces.into_iter().map(FaceRegionResponse::from).collect(); + Ok(Json(response)) +} + +async fn assign_face_to_person( + State(state): State, + UserId(user_id): UserId, + Path(face_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let face = state + .person_service + .assign_face_to_person(face_id, payload.person_id, user_id) + .await?; + Ok(Json(FaceRegionResponse::from(face))) +} \ No newline at end of file diff --git a/libertas_api/src/handlers/tag_handlers.rs b/libertas_api/src/handlers/tag_handlers.rs new file mode 100644 index 0000000..2b808d3 --- /dev/null +++ b/libertas_api/src/handlers/tag_handlers.rs @@ -0,0 +1,53 @@ +use axum::{Json, Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{delete, post}}; +use uuid::Uuid; + +use crate::{error::ApiError, middleware::auth::UserId, schema::{MediaTagsRequest, TagResponse}, state::AppState}; + +pub fn tag_routes() -> Router { + Router::new() + .route("/", post(add_tags_to_media).get(list_tags_for_media)) + .route( + "/{tag_name}", + delete(remove_tag_from_media), + ) +} + +async fn add_tags_to_media( + State(state): State, + UserId(user_id): UserId, + Path(media_id): Path, + Json(payload): Json, +) -> Result { + let tags = state + .tag_service + .add_tags_to_media(media_id, &payload.tags, user_id) + .await?; + + let response: Vec = tags.into_iter().map(TagResponse::from).collect(); + Ok((StatusCode::CREATED, Json(response))) +} + +async fn list_tags_for_media( + State(state): State, + UserId(user_id): UserId, + Path(media_id): Path, +) -> Result>, ApiError> { + let tags = state + .tag_service + .list_tags_for_media(media_id, user_id) + .await?; + let response = tags.into_iter().map(TagResponse::from).collect(); + Ok(Json(response)) +} + +async fn remove_tag_from_media( + State(state): State, + UserId(user_id): UserId, + Path((media_id, tag_name)): Path<(Uuid, String)>, +) -> Result { + state + .tag_service + .remove_tags_from_media(media_id, &[tag_name], user_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} \ No newline at end of file diff --git a/libertas_api/src/routes.rs b/libertas_api/src/routes.rs index 779eb20..66dccc4 100644 --- a/libertas_api/src/routes.rs +++ b/libertas_api/src/routes.rs @@ -1,7 +1,7 @@ use axum::{Router, routing::get}; use crate::{ - handlers::{album_handlers, auth_handlers, media_handlers, user_handlers}, + handlers::{album_handlers, auth_handlers, media_handlers, person_handlers, tag_handlers, user_handlers}, state::AppState, }; @@ -10,11 +10,17 @@ pub fn api_routes(max_upload_size: usize) -> Router { let user_routes = user_handlers::user_routes(); let media_routes = media_handlers::media_routes(max_upload_size); let album_routes = album_handlers::album_routes(); + let media_tag_routes = tag_handlers::tag_routes(); + let people_routes = person_handlers::people_routes(); + let face_routes = person_handlers::face_routes(); Router::new() .route("/api/v1/health", get(|| async { "OK" })) .nest("/api/v1/auth", auth_routes) .nest("/api/v1/users", user_routes) .nest("/api/v1/media", media_routes) + .nest("/api/v1/media/{media_id}/tags", media_tag_routes) .nest("/api/v1/albums", album_routes) + .nest("/api/v1/people", people_routes) + .nest("/api/v1", face_routes) } diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index 9b93eb3..ac9e951 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -1,4 +1,4 @@ -use libertas_core::{models::{Album, AlbumPermission, MediaMetadata}}; +use libertas_core::models::{Album, AlbumPermission, FaceRegion, MediaMetadata, Person, PersonPermission, Tag}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -121,3 +121,78 @@ pub struct MediaDetailsResponse { pub metadata: Vec, } +#[derive(Serialize)] +pub struct TagResponse { + pub id: Uuid, + pub name: String, +} + +impl From for TagResponse { + fn from(tag: Tag) -> Self { + Self { id: tag.id, name: tag.name } + } +} + +#[derive(Deserialize)] +pub struct MediaTagsRequest { + pub tags: Vec, +} + +#[derive(Serialize)] +pub struct PersonResponse { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, +} + +impl From for PersonResponse { + fn from(person: Person) -> Self { + Self { id: person.id, owner_id: person.owner_id, name: person.name } + } +} + +#[derive(Deserialize)] +pub struct CreatePersonRequest { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdatePersonRequest { + pub name: String, +} + +#[derive(Serialize)] +pub struct FaceRegionResponse { + pub id: Uuid, + pub media_id: Uuid, + pub person_id: Option, + pub x_min: f32, + pub y_min: f32, + pub x_max: f32, + pub y_max: f32, +} + +impl From for FaceRegionResponse { + fn from(face: FaceRegion) -> Self { + Self { + id: face.id, + media_id: face.media_id, + person_id: face.person_id, + x_min: face.x_min, + y_min: face.y_min, + x_max: face.x_max, + y_max: face.y_max, + } + } +} + +#[derive(Deserialize)] +pub struct AssignFaceRequest { + pub person_id: Uuid, +} + +#[derive(Deserialize)] +pub struct SharePersonRequest { + pub target_user_id: Uuid, + pub permission: PersonPermission, +} \ No newline at end of file diff --git a/libertas_api/src/services/mod.rs b/libertas_api/src/services/mod.rs index 2f30a25..4df541d 100644 --- a/libertas_api/src/services/mod.rs +++ b/libertas_api/src/services/mod.rs @@ -1,3 +1,5 @@ pub mod album_service; pub mod media_service; pub mod user_service; +pub mod tag_service; +pub mod person_service; \ No newline at end of file diff --git a/libertas_api/src/services/person_service.rs b/libertas_api/src/services/person_service.rs new file mode 100644 index 0000000..a6afb6c --- /dev/null +++ b/libertas_api/src/services/person_service.rs @@ -0,0 +1,228 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use libertas_core::{authz, error::{CoreError, CoreResult}, models::{FaceRegion, Media, Person, PersonPermission}, repositories::{FaceRegionRepository, MediaRepository, PersonRepository, PersonShareRepository}, services::PersonService}; +use uuid::Uuid; + +pub struct PersonServiceImpl { + person_repo: Arc, + face_repo: Arc, + media_repo: Arc, + person_share_repo: Arc, +} + +impl PersonServiceImpl { + pub fn new( + person_repo: Arc, + face_repo: Arc, + media_repo: Arc, + person_share_repo: Arc, + ) -> Self { + Self { + person_repo, + face_repo, + media_repo, + person_share_repo, + } + } + + async fn get_and_authorize_person_owner( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult { + let person = self + .person_repo + .find_by_id(person_id) + .await? + .ok_or(CoreError::NotFound("Person".to_string(), person_id))?; + + if person.owner_id != user_id { + return Err(CoreError::Auth( + "User must be the owner to perform this action".to_string(), + )); + } + + Ok(person) + } + + async fn get_and_authorize_person_access( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult { + let person = self + .person_repo + .find_by_id(person_id) + .await? + .ok_or(CoreError::NotFound("Person".to_string(), person_id))?; + + let share_permission = self.person_share_repo.get_user_permission(person_id, user_id).await?; + + if !authz::can_access_person(user_id, &person, share_permission) { + return Err(CoreError::Auth( + "User does not have permission to access this person".to_string(), + )); + } + + Ok(person) + } + + async fn get_and_authorize_person_usage( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult { + let person = self + .person_repo + .find_by_id(person_id) + .await? + .ok_or(CoreError::NotFound("Person".to_string(), person_id))?; + + let share_permission = self.person_share_repo.get_user_permission(person_id, user_id).await?; + + if !authz::can_edit_person(user_id, &person, share_permission) { + return Err(CoreError::Auth( + "User does not have permission to use this person".to_string(), + )); + } + + Ok(person) + } + + async fn authorize_media_access( + &self, + media_id: Uuid, + user_id: Uuid, + ) -> CoreResult { + let media = self.media_repo.find_by_id(media_id).await?.ok_or(CoreError::NotFound("Media".to_string(), media_id))?; + + if !authz::is_owner(user_id, &media) { + return Err(CoreError::Auth( + "User does not have permission to access this media".to_string(), + )); + } + + Ok(media) + } +} + +#[async_trait] +impl PersonService for PersonServiceImpl { + async fn create_person( + &self, + name: &str, + owner_id: Uuid, + ) -> CoreResult { + let person = Person { + id: Uuid::new_v4(), + owner_id, + name: name.to_string(), + thumbnail_media_id: None, + }; + + self.person_repo.create(person.clone()).await?; + + Ok(person) + } + + async fn get_person(&self, person_id: Uuid, user_id: Uuid) -> CoreResult { + self.get_and_authorize_person_access(person_id, user_id).await + } + + async fn list_people(&self, user_id: Uuid) -> CoreResult> { + let mut owned_people = self.person_repo.list_by_user(user_id).await?; + + let shared_people_with_perms = self + .person_share_repo + .list_people_shared_with_user(user_id) + .await?; + + let shared_people = shared_people_with_perms + .into_iter() + .map(|(person, _permission)| person) + .collect::>(); + + owned_people.extend(shared_people); + + Ok(owned_people) + } + + async fn update_person( + &self, + person_id: Uuid, + name: &str, + user_id: Uuid, + ) -> CoreResult { + let mut person = self.get_and_authorize_person_owner(person_id, user_id).await?; + person.name = name.to_string(); + self.person_repo.update(person.clone()).await?; + Ok(person) + } + + async fn delete_person(&self, person_id: Uuid, user_id: Uuid) -> CoreResult<()> { + self.get_and_authorize_person_owner(person_id, user_id).await?; + self.person_repo.delete(person_id).await + } + + async fn assign_face_to_person( + &self, + face_region_id: Uuid, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult { + self.get_and_authorize_person_usage(person_id, user_id).await?; + + let mut face = self + .face_repo + .find_by_id(face_region_id) + .await? + .ok_or(CoreError::NotFound("FaceRegion".to_string(), face_region_id))?; + + self.authorize_media_access(face.media_id, user_id).await?; + + self.face_repo + .update_person_id(face_region_id, person_id) + .await?; + face.person_id = Some(person_id); + + Ok(face) + } + + async fn list_faces_for_media( + &self, + media_id: Uuid, + user_id: Uuid, + ) -> CoreResult> { + self.authorize_media_access(media_id, user_id).await?; + + self.face_repo.find_by_media_id(media_id).await + } + + async fn share_person( + &self, + person_id: Uuid, + target_user_id: Uuid, + permission: PersonPermission, + owner_id: Uuid, + ) -> CoreResult<()> { + self.get_and_authorize_person_owner(person_id, owner_id).await?; + + self.person_share_repo + .create_or_update_share(person_id, target_user_id, permission) + .await + } + + async fn unshare_person( + &self, + person_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()> { + self.get_and_authorize_person_owner(person_id, owner_id).await?; + + self.person_share_repo + .remove_share(person_id, target_user_id) + .await + } +} \ No newline at end of file diff --git a/libertas_api/src/services/tag_service.rs b/libertas_api/src/services/tag_service.rs new file mode 100644 index 0000000..7339a3e --- /dev/null +++ b/libertas_api/src/services/tag_service.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use libertas_core::{authz, error::{CoreError, CoreResult}, models::{Media, Tag}, repositories::{MediaRepository, TagRepository}, services::TagService}; +use uuid::Uuid; + +pub struct TagServiceImpl { + tag_repo: Arc, + media_repo: Arc, +} + +impl TagServiceImpl { + pub fn new( + tag_repo: Arc, + media_repo: Arc, + ) -> Self { + Self { + tag_repo, + media_repo, + } + } + + async fn authorize_media_access( + &self, + media_id: Uuid, + user_id: Uuid, + ) -> CoreResult { + let media = self.media_repo.find_by_id(media_id).await?.ok_or(CoreError::NotFound("Media".to_string(), media_id))?; + + if !authz::is_owner(user_id, &media) { + return Err(CoreError::Auth( + "User does not have permission to access this media".to_string(), + )); + } + + Ok(media) + } +} + +#[async_trait] +impl TagService for TagServiceImpl { + async fn add_tags_to_media( + &self, + media_id: Uuid, + tag_names: &[String], + user_id: Uuid, + + ) -> CoreResult> { + self.authorize_media_access(media_id, user_id).await?; + + let mut tag_ids = Vec::new(); + let tags = self.tag_repo.find_or_create_tags(tag_names).await?; + for tag in &tags { + tag_ids.push(tag.id); + } + + self.tag_repo.add_tags_to_media(media_id, &tag_ids).await?; + + Ok(tags) + } + + async fn remove_tags_from_media( + &self, + media_id: Uuid, + tag_names: &[String], + user_id: Uuid, + ) -> CoreResult<()> { + self.authorize_media_access(media_id, user_id).await?; + + let tags = self.tag_repo.find_or_create_tags(tag_names).await?; + let mut tag_ids = Vec::new(); + for tag in &tags { + tag_ids.push(tag.id); + } + + self.tag_repo.remove_tags_from_media(media_id, &tag_ids).await?; + + Ok(()) + } + + async fn list_tags_for_media( + &self, + media_id: Uuid, + user_id: Uuid, + ) -> CoreResult> { + self.authorize_media_access(media_id, user_id).await?; + + let tags = self.tag_repo.list_tags_for_media(media_id).await?; + + Ok(tags) + } +} \ No newline at end of file diff --git a/libertas_api/src/state.rs b/libertas_api/src/state.rs index 093ccc4..6028182 100644 --- a/libertas_api/src/state.rs +++ b/libertas_api/src/state.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use libertas_core::{ config::AppConfig, - services::{AlbumService, MediaService, UserService}, + services::{AlbumService, MediaService, PersonService, TagService, UserService}, }; use crate::security::TokenGenerator; @@ -12,6 +12,8 @@ pub struct AppState { pub user_service: Arc, pub media_service: Arc, pub album_service: Arc, + pub tag_service: Arc, + pub person_service: Arc, pub token_generator: Arc, pub nats_client: async_nats::Client, pub config: AppConfig, diff --git a/libertas_core/src/authz.rs b/libertas_core/src/authz.rs index 9044515..62c6f79 100644 --- a/libertas_core/src/authz.rs +++ b/libertas_core/src/authz.rs @@ -1,21 +1,29 @@ use uuid::Uuid; -use crate::models::{Album, AlbumPermission, Media, Role, User}; +use crate::models::{Album, AlbumPermission, Media, Person, PersonPermission, Role, User}; pub trait Ownable { fn owner_id(&self) -> Uuid; } + impl Ownable for Media { fn owner_id(&self) -> Uuid { self.owner_id } } + impl Ownable for Album { fn owner_id(&self) -> Uuid { self.owner_id } } +impl Ownable for Person { + fn owner_id(&self) -> Uuid { + self.owner_id + } +} + pub fn is_admin(user: &User) -> bool { user.role == Role::Admin } @@ -39,3 +47,11 @@ pub fn can_contribute_to_album( ) -> bool { is_owner(user_id, album) || share_permission == Some(AlbumPermission::Contribute) } + +pub fn can_access_person(user_id: Uuid, person: &Person, share_permission: Option) -> bool { + is_owner(user_id, person) || share_permission.is_some() +} + +pub fn can_edit_person(user_id: Uuid, person: &Person, share_permission: Option) -> bool { + is_owner(user_id, person) || share_permission == Some(PersonPermission::CanUse) +} \ No newline at end of file diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index 1f6d65b..8b2c503 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -93,6 +93,7 @@ pub struct Album { pub updated_at: chrono::DateTime, } +#[derive(Clone, Debug)] pub struct Person { pub id: uuid::Uuid, pub owner_id: uuid::Uuid, @@ -100,6 +101,7 @@ pub struct Person { pub thumbnail_media_id: Option, } +#[derive(Clone, Debug)] pub struct FaceRegion { pub id: uuid::Uuid, pub media_id: uuid::Uuid, @@ -150,4 +152,40 @@ pub struct AlbumShare { pub struct MediaBundle { pub media: Media, pub metadata: Vec, +} + +#[derive(Clone, Debug)] +pub struct Tag { + pub id: uuid::Uuid, + pub name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum PersonPermission { + View, + CanUse, +} + +impl PersonPermission { + pub fn as_str(&self) -> &'static str { + match self { + PersonPermission::View => "view", + PersonPermission::CanUse => "can_use", + } + } +} + +impl From<&str> for PersonPermission { + fn from(s: &str) -> Self { + match s { + "can_use" => PersonPermission::CanUse, + _ => PersonPermission::View, + } + } +} + +pub struct PersonShare { + pub person_id: uuid::Uuid, + pub user_id: uuid::Uuid, + pub permission: PersonPermission, } \ No newline at end of file diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 4d43762..b56eb71 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{ error::CoreResult, - models::{Album, AlbumPermission, Media, MediaMetadata, User}, schema::ListMediaOptions, + models::{Album, AlbumPermission, FaceRegion, Media, MediaMetadata, Person, PersonPermission, Tag, User}, schema::ListMediaOptions, }; #[async_trait] @@ -57,4 +57,54 @@ pub trait AlbumShareRepository: Send + Sync { pub trait MediaMetadataRepository: Send + Sync { async fn create_batch(&self, metadata: &[MediaMetadata]) -> CoreResult<()>; async fn find_by_media_id(&self, media_id: Uuid) -> CoreResult>; +} + +#[async_trait] +pub trait TagRepository: Send + Sync { + async fn find_or_create_tags(&self, tag_names: &[String]) -> CoreResult>; + async fn add_tags_to_media(&self, media_id: Uuid, tag_ids: &[Uuid]) -> CoreResult<()>; + async fn remove_tags_from_media(&self, media_id: Uuid, tag_ids: &[Uuid]) -> CoreResult<()>; + async fn list_tags_for_media(&self, media_id: Uuid) -> CoreResult>; + async fn find_tag_by_name(&self, name: &str) -> CoreResult>; +} + +#[async_trait] +pub trait PersonRepository: Send + Sync { + async fn create(&self, person: Person) -> CoreResult<()>; + async fn find_by_id(&self, id: Uuid) -> CoreResult>; + async fn list_by_user(&self, user_id: Uuid) -> CoreResult>; + async fn update(&self, person: Person) -> CoreResult<()>; + async fn delete(&self, id: Uuid) -> CoreResult<()>; +} + +#[async_trait] +pub trait FaceRegionRepository: Send + Sync { + async fn create_batch(&self, face_regions: &[FaceRegion]) -> CoreResult<()>; + async fn find_by_media_id(&self, media_id: Uuid) -> CoreResult>; + async fn find_by_id(&self, face_region_id: Uuid) -> CoreResult>; + async fn update_person_id(&self, face_region_id: Uuid, person_id: Uuid) -> CoreResult<()>; + async fn delete(&self, face_region_id: Uuid) -> CoreResult<()>; +} + +#[async_trait] +pub trait PersonShareRepository: Send + Sync { + async fn create_or_update_share( + &self, + person_id: Uuid, + user_id: Uuid, + permission: PersonPermission, + ) -> CoreResult<()>; + + async fn remove_share(&self, person_id: Uuid, user_id: Uuid) -> CoreResult<()>; + + async fn get_user_permission( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult>; + + async fn list_people_shared_with_user( + &self, + user_id: Uuid, + ) -> CoreResult>; } \ No newline at end of file diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index 02c0a23..82a59af 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{ error::CoreResult, - models::{Album, Media, MediaBundle, User}, + models::{Album, FaceRegion, Media, MediaBundle, Person, PersonPermission, Tag, User}, schema::{ AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData, ShareAlbumData, UpdateAlbumData, UploadMediaData }, @@ -40,3 +40,48 @@ pub trait AlbumService: Send + Sync { ) -> CoreResult; async fn delete_album(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()>; } + +#[async_trait] +pub trait TagService: Send + Sync { + async fn add_tags_to_media(&self, media_id: Uuid, tag_names: &[String], user_id: Uuid) -> CoreResult>; + async fn remove_tags_from_media(&self, media_id: Uuid, tag_names: &[String], user_id: Uuid) -> CoreResult<()>; + async fn list_tags_for_media(&self, media_id: Uuid, user_id: Uuid) -> CoreResult>; +} + +#[async_trait] +pub trait PersonService: Send + Sync { + async fn create_person(&self, name: &str, owner_id: Uuid) -> CoreResult; + async fn get_person(&self, person_id: Uuid, user_id: Uuid) -> CoreResult; + async fn list_people(&self, user_id: Uuid) -> CoreResult>; + async fn update_person( + &self, + person_id: Uuid, + name: &str, + user_id: Uuid, + ) -> CoreResult; + async fn delete_person(&self, person_id: Uuid, user_id: Uuid) -> CoreResult<()>; + + async fn assign_face_to_person( + &self, + face_region_id: Uuid, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult; + + async fn list_faces_for_media(&self, media_id: Uuid, user_id: Uuid) -> CoreResult>; + + async fn share_person( + &self, + person_id: Uuid, + target_user_id: Uuid, + permission: PersonPermission, + owner_id: Uuid, + ) -> CoreResult<()>; + + async fn unshare_person( + &self, + person_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()>; +} \ No newline at end of file diff --git a/libertas_infra/src/db_models.rs b/libertas_infra/src/db_models.rs index a21e5a5..c0d2760 100644 --- a/libertas_infra/src/db_models.rs +++ b/libertas_infra/src/db_models.rs @@ -76,4 +76,44 @@ pub struct PostgresAlbumShare { pub album_id: uuid::Uuid, pub user_id: uuid::Uuid, pub permission: PostgresAlbumPermission, +} + +#[derive(sqlx::FromRow)] +pub struct PostgresTag { + pub id: uuid::Uuid, + pub name: String, +} + +#[derive(sqlx::FromRow)] +pub struct PostgresPerson { + pub id: uuid::Uuid, + pub owner_id: uuid::Uuid, + pub name: String, +} + +#[derive(sqlx::FromRow)] +pub struct PostgresFaceRegion { + pub id: uuid::Uuid, + pub media_id: uuid::Uuid, + pub person_id: Option, + pub x_min: f32, + pub y_min: f32, + pub x_max: f32, + pub y_max: f32, +} + +#[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)] +#[sqlx(rename_all = "lowercase")] +#[sqlx(type_name = "person_permission")] +pub enum PostgresPersonPermission { + View, + CanUse, +} + +#[derive(sqlx::FromRow)] +pub struct PostgresPersonShared { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub permission: PostgresPersonPermission, } \ No newline at end of file diff --git a/libertas_infra/src/factory.rs b/libertas_infra/src/factory.rs index 78b07a9..2ea948e 100644 --- a/libertas_infra/src/factory.rs +++ b/libertas_infra/src/factory.rs @@ -102,4 +102,60 @@ pub async fn build_media_metadata_repository( "Sqlite media metadata repository not implemented".to_string(), )), } +} + +pub async fn build_tag_repository( + _db_config: &DatabaseConfig, + pool: DatabasePool, +) -> CoreResult> { + match pool { + DatabasePool::Postgres(pg_pool) => Ok(Arc::new( + crate::repositories::tag_repository::PostgresTagRepository::new(pg_pool), + )), + DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database( + "Sqlite tag repository not implemented".to_string(), + )), + } +} + +pub async fn build_person_repository( + _db_config: &DatabaseConfig, + pool: DatabasePool, +) -> CoreResult> { + match pool { + DatabasePool::Postgres(pg_pool) => Ok(Arc::new( + crate::repositories::person_repository::PostgresPersonRepository::new(pg_pool), + )), + DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database( + "Sqlite person repository not implemented".to_string(), + )), + } +} + +pub async fn build_face_region_repository( + _db_config: &DatabaseConfig, + pool: DatabasePool, +) -> CoreResult> { + match pool { + DatabasePool::Postgres(pg_pool) => Ok(Arc::new( + crate::repositories::face_region_repository::PostgresFaceRegionRepository::new(pg_pool), + )), + DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database( + "Sqlite face region repository not implemented".to_string(), + )), + } +} + +pub async fn build_person_share_repository( + _db_config: &DatabaseConfig, + pool: DatabasePool, +) -> CoreResult> { + match pool { + DatabasePool::Postgres(pg_pool) => Ok(Arc::new( + crate::repositories::person_share_repository::PostgresPersonShareRepository::new(pg_pool), + )), + DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database( + "Sqlite person share repository not implemented".to_string(), + )), + } } \ No newline at end of file diff --git a/libertas_infra/src/mappers.rs b/libertas_infra/src/mappers.rs index ec1cabc..301ffe7 100644 --- a/libertas_infra/src/mappers.rs +++ b/libertas_infra/src/mappers.rs @@ -1,6 +1,6 @@ -use libertas_core::models::{Album, AlbumPermission, AlbumShare, Media, MediaMetadata, MediaMetadataSource, Role, User}; +use libertas_core::models::{Album, AlbumPermission, AlbumShare, FaceRegion, Media, MediaMetadata, MediaMetadataSource, Person, PersonPermission, Role, Tag, User}; -use crate::db_models::{PostgresAlbum, PostgresAlbumPermission, PostgresAlbumShare, PostgresMedia, PostgresMediaMetadata, PostgresMediaMetadataSource, PostgresRole, PostgresUser}; +use crate::db_models::{PostgresAlbum, PostgresAlbumPermission, PostgresAlbumShare, PostgresFaceRegion, PostgresMedia, PostgresMediaMetadata, PostgresMediaMetadataSource, PostgresPerson, PostgresPersonPermission, PostgresPersonShared, PostgresRole, PostgresTag, PostgresUser}; impl From for Role { fn from(pg_role: PostgresRole) -> Self { @@ -121,4 +121,69 @@ impl From for AlbumShare { permission: AlbumPermission::from(pg_share.permission), } } +} + +impl From for Tag { + fn from(pg_tag: PostgresTag) -> Self { + Tag { + id: pg_tag.id, + name: pg_tag.name, + } + } +} + +impl From for Person { + fn from(pg_person: PostgresPerson) -> Self { + Person { + id: pg_person.id, + owner_id: pg_person.owner_id, + name: pg_person.name, + thumbnail_media_id: None, // Not in the DB schema + } + } +} + +impl From for FaceRegion { + fn from(pg_face: PostgresFaceRegion) -> Self { + FaceRegion { + id: pg_face.id, + media_id: pg_face.media_id, + person_id: pg_face.person_id, + x_min: pg_face.x_min, + y_min: pg_face.y_min, + x_max: pg_face.x_max, + y_max: pg_face.y_max, + } + } +} + +impl From for PersonPermission { + fn from(pg_perm: PostgresPersonPermission) -> Self { + match pg_perm { + PostgresPersonPermission::View => PersonPermission::View, + PostgresPersonPermission::CanUse => PersonPermission::CanUse, + } + } +} + +impl From for PostgresPersonPermission { + fn from(perm: PersonPermission) -> Self { + match perm { + PersonPermission::View => PostgresPersonPermission::View, + PersonPermission::CanUse => PostgresPersonPermission::CanUse, + } + } +} + +impl From for (Person, PersonPermission) { + fn from(pg_shared: PostgresPersonShared) -> Self { + let person = Person { + id: pg_shared.id, + owner_id: pg_shared.owner_id, + name: pg_shared.name, + thumbnail_media_id: None, + }; + let permission = PersonPermission::from(pg_shared.permission); + (person, permission) + } } \ No newline at end of file diff --git a/libertas_infra/src/repositories/face_region_repository.rs b/libertas_infra/src/repositories/face_region_repository.rs new file mode 100644 index 0000000..68b404a --- /dev/null +++ b/libertas_infra/src/repositories/face_region_repository.rs @@ -0,0 +1,128 @@ +use async_trait::async_trait; +use libertas_core::{error::{CoreError, CoreResult}, models::FaceRegion, repositories::FaceRegionRepository}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db_models::PostgresFaceRegion; + +#[derive(Clone)] +pub struct PostgresFaceRegionRepository { + pool: PgPool, +} + +impl PostgresFaceRegionRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl FaceRegionRepository for PostgresFaceRegionRepository { + async fn create_batch(&self, face_regions: &[FaceRegion]) -> CoreResult<()> { + if face_regions.is_empty() { + return Ok(()); + } + + let mut ids = Vec::with_capacity(face_regions.len()); + let mut media_ids = Vec::with_capacity(face_regions.len()); + let mut person_ids = Vec::with_capacity(face_regions.len()); + let mut x_mins = Vec::with_capacity(face_regions.len()); + let mut y_mins = Vec::with_capacity(face_regions.len()); + let mut x_maxs = Vec::with_capacity(face_regions.len()); + let mut y_maxs = Vec::with_capacity(face_regions.len()); + + for fr in face_regions { + ids.push(fr.id); + media_ids.push(fr.media_id); + person_ids.push(fr.person_id); + x_mins.push(fr.x_min); + y_mins.push(fr.y_min); + x_maxs.push(fr.x_max); + y_maxs.push(fr.y_max); + } + + sqlx::query!( + r#" + INSERT INTO face_regions (id, media_id, person_id, x_min, y_min, x_max, y_max) + SELECT * FROM unnest( + $1::uuid[], $2::uuid[], $3::uuid[], + $4::real[], $5::real[], $6::real[], $7::real[] + ) + "#, + &ids, + &media_ids, + &person_ids as &[Option], + &x_mins, + &y_mins, + &x_maxs, + &y_maxs + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } + + async fn find_by_media_id(&self, media_id: Uuid) -> CoreResult> { + let pg_faces = sqlx::query_as!( + PostgresFaceRegion, + r#" + SELECT id, media_id, person_id, x_min, y_min, x_max, y_max + FROM face_regions + WHERE media_id = $1 + "#, + media_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(pg_faces.into_iter().map(FaceRegion::from).collect()) + } + + async fn find_by_id(&self, face_region_id: Uuid) -> CoreResult> { + let pg_face = sqlx::query_as!( + PostgresFaceRegion, + r#" + SELECT id, media_id, person_id, x_min, y_min, x_max, y_max + FROM face_regions + WHERE id = $1 + "#, + face_region_id + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(pg_face.map(FaceRegion::from)) + } + + async fn update_person_id(&self, face_region_id: Uuid, person_id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + UPDATE face_regions + SET person_id = $1 + WHERE id = $2 + "#, + person_id, + face_region_id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } + + async fn delete(&self, face_region_id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + DELETE FROM face_regions + WHERE id = $1 + "#, + face_region_id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } +} \ No newline at end of file diff --git a/libertas_infra/src/repositories/mod.rs b/libertas_infra/src/repositories/mod.rs index 4bc0368..f5d9d26 100644 --- a/libertas_infra/src/repositories/mod.rs +++ b/libertas_infra/src/repositories/mod.rs @@ -2,4 +2,8 @@ pub mod album_repository; pub mod album_share_repository; pub mod media_repository; pub mod user_repository; -pub mod media_metadata_repository; \ No newline at end of file +pub mod media_metadata_repository; +pub mod tag_repository; +pub mod person_repository; +pub mod face_region_repository; +pub mod person_share_repository; \ No newline at end of file diff --git a/libertas_infra/src/repositories/person_repository.rs b/libertas_infra/src/repositories/person_repository.rs new file mode 100644 index 0000000..0adebe8 --- /dev/null +++ b/libertas_infra/src/repositories/person_repository.rs @@ -0,0 +1,98 @@ +use async_trait::async_trait; +use libertas_core::{error::{CoreError, CoreResult}, models::Person, repositories::PersonRepository}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db_models::PostgresPerson; + +#[derive(Clone)] +pub struct PostgresPersonRepository { + pool: PgPool, +} + +impl PostgresPersonRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl PersonRepository for PostgresPersonRepository { + async fn create(&self, person: Person) -> CoreResult<()> { + sqlx::query!( + r#" + INSERT INTO people (id, owner_id, name) + VALUES ($1, $2, $3) + "#, + person.id, + person.owner_id, + person.name + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } + + async fn find_by_id(&self, id: Uuid) -> CoreResult> { + let pg_person = sqlx::query_as!( + PostgresPerson, + r#" + SELECT id, owner_id, name + FROM people + WHERE id = $1 + "#, + id + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(pg_person.map(Person::from)) + } + + async fn list_by_user(&self, user_id: Uuid) -> CoreResult> { + let pg_people = sqlx::query_as!( + PostgresPerson, + r#" + SELECT id, owner_id, name + FROM people + WHERE owner_id = $1 + "#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(pg_people.into_iter().map(Person::from).collect()) + } + + async fn update(&self, person: Person) -> CoreResult<()> { + sqlx::query!( + r#" + UPDATE people + SET name = $1 + WHERE id = $2 + "#, + person.name, + person.id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } + + async fn delete(&self, id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + DELETE FROM people + WHERE id = $1 + "#, + id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } +} \ No newline at end of file diff --git a/libertas_infra/src/repositories/person_share_repository.rs b/libertas_infra/src/repositories/person_share_repository.rs new file mode 100644 index 0000000..2851b14 --- /dev/null +++ b/libertas_infra/src/repositories/person_share_repository.rs @@ -0,0 +1,106 @@ +use async_trait::async_trait; +use libertas_core::{ + error::{CoreError, CoreResult}, + models::{Person, PersonPermission}, + repositories::PersonShareRepository, +}; +use sqlx::{types::Uuid, PgPool}; + +use crate::db_models::{PostgresPersonPermission, PostgresPersonShared}; + +#[derive(Clone)] +pub struct PostgresPersonShareRepository { + pool: PgPool, +} + +impl PostgresPersonShareRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl PersonShareRepository for PostgresPersonShareRepository { + async fn create_or_update_share( + &self, + person_id: Uuid, + user_id: Uuid, + permission: PersonPermission, + ) -> CoreResult<()> { + sqlx::query!( + r#" + INSERT INTO person_shares (person_id, user_id, permission) + VALUES ($1, $2, $3) + ON CONFLICT (person_id, user_id) + DO UPDATE SET permission = $3 + "#, + person_id, + user_id, + PostgresPersonPermission::from(permission) as PostgresPersonPermission, + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } + + async fn remove_share(&self, person_id: Uuid, user_id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + DELETE FROM person_shares + WHERE person_id = $1 AND user_id = $2 + "#, + person_id, + user_id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } + + async fn get_user_permission( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult> { + let row = sqlx::query!( + r#" + SELECT permission as "permission: PostgresPersonPermission" + FROM person_shares + WHERE person_id = $1 AND user_id = $2 + "#, + person_id, + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(row.map(|r| r.permission.into())) + } + + async fn list_people_shared_with_user( + &self, + user_id: Uuid, + ) -> CoreResult> { + let shared_people = sqlx::query_as!( + PostgresPersonShared, + r#" + SELECT p.id, p.owner_id, p.name, ps.permission as "permission: PostgresPersonPermission" + FROM people p + JOIN person_shares ps ON p.id = ps.person_id + WHERE ps.user_id = $1 + "#, + user_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(shared_people + .into_iter() + .map(PostgresPersonShared::into) + .collect()) + } +} \ No newline at end of file diff --git a/libertas_infra/src/repositories/tag_repository.rs b/libertas_infra/src/repositories/tag_repository.rs new file mode 100644 index 0000000..5bf7b7f --- /dev/null +++ b/libertas_infra/src/repositories/tag_repository.rs @@ -0,0 +1,132 @@ +use async_trait::async_trait; +use libertas_core::{error::{CoreError, CoreResult}, models::Tag, repositories::TagRepository}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db_models::PostgresTag; + +#[derive(Clone)] +pub struct PostgresTagRepository { + pool: PgPool, +} + +impl PostgresTagRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl TagRepository for PostgresTagRepository { + async fn find_or_create_tags(&self, tag_names: &[String]) -> CoreResult> { + if tag_names.is_empty() { + return Ok(Vec::new()); + } + + let new_ids: Vec = (0..tag_names.len()).map(|_| Uuid::new_v4()).collect(); + + sqlx::query!( + r#" + INSERT INTO tags (id, name) + SELECT * FROM unnest($1::uuid[], $2::text[]) + ON CONFLICT (name) DO NOTHING + "#, + &new_ids, + tag_names, + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + let pg_tags = sqlx::query_as!( + PostgresTag, + r#" + SELECT id, name + FROM tags + WHERE name = ANY($1) + "#, + tag_names + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(pg_tags.into_iter().map(Tag::from).collect()) + } + + async fn add_tags_to_media(&self, media_id: Uuid, tag_ids: &[Uuid]) -> CoreResult<()> { + if tag_ids.is_empty() { + return Ok(()); + } + + sqlx::query!( + r#" + INSERT INTO media_tags (media_id, tag_id) + SELECT $1, tag_id FROM unnest($2::uuid[]) AS tag_id + ON CONFLICT (media_id, tag_id) DO NOTHING + "#, + media_id, + tag_ids + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } + + async fn remove_tags_from_media(&self, media_id: Uuid, tag_ids: &[Uuid]) -> CoreResult<()> { + if tag_ids.is_empty() { + return Ok(()); + } + + sqlx::query!( + r#" + DELETE FROM media_tags + WHERE media_id = $1 AND tag_id = ANY($2) + "#, + media_id, + tag_ids + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } + + async fn list_tags_for_media(&self, media_id: Uuid) -> CoreResult> { + let pg_tags = sqlx::query_as!( + PostgresTag, + r#" + SELECT t.id, t.name + FROM tags t + JOIN media_tags mt ON t.id = mt.tag_id + WHERE mt.media_id = $1 + "#, + media_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(pg_tags.into_iter().map(Tag::from).collect()) + } + + async fn find_tag_by_name(&self, name: &str) -> CoreResult> { + let pg_tag = sqlx::query_as!( + PostgresTag, + r#" + SELECT id, name + FROM tags + WHERE name = $1 + "#, + name + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(pg_tag.map(Tag::from)) + } +} \ No newline at end of file