feat: Implement person and tag management services

- Added `Person` and `Tag` models to the core library.
- Created `PersonService` and `TagService` traits with implementations for managing persons and tags.
- Introduced repositories for `Person`, `Tag`, `FaceRegion`, and `PersonShare` with PostgreSQL support.
- Updated authorization logic to include permissions for accessing and editing persons.
- Enhanced the schema to support new models and relationships.
- Implemented database migrations for new tables related to persons and tags.
- Added request and response structures for API interactions with persons and tags.
This commit is contained in:
2025-11-15 11:18:11 +01:00
parent 370d55f0b3
commit 4675285603
26 changed files with 1465 additions and 18 deletions

View File

@@ -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<Uuid>],
&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<Vec<FaceRegion>> {
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<Option<FaceRegion>> {
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(())
}
}

View File

@@ -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;
pub mod media_metadata_repository;
pub mod tag_repository;
pub mod person_repository;
pub mod face_region_repository;
pub mod person_share_repository;

View File

@@ -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<Option<Person>> {
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<Vec<Person>> {
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(())
}
}

View File

@@ -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<Option<PersonPermission>> {
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<Vec<(Person, PersonPermission)>> {
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())
}
}

View File

@@ -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<Vec<Tag>> {
if tag_names.is_empty() {
return Ok(Vec::new());
}
let new_ids: Vec<Uuid> = (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<Vec<Tag>> {
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<Option<Tag>> {
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))
}
}