Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list
feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
@@ -289,6 +289,53 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
|
||||
}
|
||||
|
||||
async fn caller_flags(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
resource_type: &str,
|
||||
resource_id: Uuid,
|
||||
) -> Result<(bool, bool)> {
|
||||
// One round trip, two per-row EXISTS in the SELECT. Both hit
|
||||
// covering indexes: `auth.user_favorites` UNIQUE on
|
||||
// `(user_id, item_id, item_type)` and
|
||||
// `idx_role_grants_resource` on `(resource_type, resource_id)`.
|
||||
// Sub-millisecond on hot data.
|
||||
//
|
||||
// Positional tuple decode (Dio's pattern) — no per-column
|
||||
// name lookup, no HashMap. Sqlx's 2-tuple `query_as` decodes
|
||||
// by index, matching the perf shape used in the
|
||||
// folder_db_repository listing hot path.
|
||||
let (is_favorite, is_shared): (bool, bool) = sqlx::query_as(
|
||||
"SELECT \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM auth.user_favorites \
|
||||
WHERE user_id = $1 \
|
||||
AND item_id = $2::text \
|
||||
AND item_type = $3 \
|
||||
), \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM storage.role_grants \
|
||||
WHERE resource_id = $2 \
|
||||
AND resource_type = $3 \
|
||||
)",
|
||||
)
|
||||
.bind(caller_id)
|
||||
.bind(resource_id)
|
||||
.bind(resource_type)
|
||||
.fetch_one(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error running caller_flags: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"CallerFlags",
|
||||
format!("Failed to compute caller flags: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((is_favorite, is_shared))
|
||||
}
|
||||
|
||||
async fn list_resources_paged(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -319,6 +366,14 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
NULL::text AS blob_hash,
|
||||
fld.created_by AS created_by,
|
||||
fld.updated_by AS updated_by,
|
||||
-- Every row on this feed IS a favorite by construction —
|
||||
-- hardcode the flag to skip a per-row EXISTS.
|
||||
TRUE AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = fld.id
|
||||
AND g.resource_type = 'folder'
|
||||
) AS is_shared,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
@@ -352,6 +407,13 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
f.blob_hash,
|
||||
f.created_by AS created_by,
|
||||
f.updated_by AS updated_by,
|
||||
-- Every row on this feed IS a favorite by construction.
|
||||
TRUE AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = f.id
|
||||
AND g.resource_type = 'file'
|
||||
) AS is_shared,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
@@ -532,6 +594,7 @@ SELECT
|
||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
|
||||
r.is_favorite, r.is_shared,
|
||||
r.is_owner, r.favorited_at, r.resource_path,
|
||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||
FROM resources r
|
||||
@@ -607,6 +670,8 @@ LIMIT $6"
|
||||
blob_hash: row.try_get("blob_hash").ok(),
|
||||
created_by: row.try_get("created_by").ok(),
|
||||
updated_by: row.try_get("updated_by").ok(),
|
||||
is_favorite: row.try_get("is_favorite").unwrap_or(true),
|
||||
is_shared: row.try_get("is_shared").unwrap_or(false),
|
||||
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||
favorited_at: row.get("favorited_at"),
|
||||
path: row.try_get("resource_path").ok(),
|
||||
|
||||
@@ -22,6 +22,8 @@ type MediaFileRow = (
|
||||
String, // blob_hash
|
||||
Option<Uuid>, // created_by (§14 provenance)
|
||||
Option<Uuid>, // updated_by (§14 provenance)
|
||||
bool, // is_favorite (caller-scoped EXISTS on user_favorites)
|
||||
bool, // is_shared (resource-scoped EXISTS on role_grants)
|
||||
i64, // sort_date
|
||||
Option<i32>, // width
|
||||
Option<i32>, // height
|
||||
@@ -522,7 +524,19 @@ impl FileBlobReadRepository {
|
||||
caller_id: Uuid,
|
||||
before: Option<i64>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
|
||||
) -> Result<
|
||||
(
|
||||
Vec<File>,
|
||||
Vec<i64>,
|
||||
Vec<(Option<i32>, Option<i32>)>,
|
||||
// Per-row caller flags (is_favorite, is_shared). Aligned
|
||||
// with `files` — zip 1:1. Kept parallel to `sort_dates` /
|
||||
// `dims` instead of on the File entity so the domain
|
||||
// stays caller-agnostic.
|
||||
Vec<(bool, bool)>,
|
||||
),
|
||||
DomainError,
|
||||
> {
|
||||
// Sargable keyset cursor: compare the RAW `media_sort_date` column
|
||||
// against a timestamptz bind so the planner can use the cursor as
|
||||
// an index boundary condition on `idx_files_media_timeline_by_drive`.
|
||||
@@ -562,6 +576,17 @@ impl FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM top.updated_at)::bigint,
|
||||
top.blob_hash,
|
||||
top.created_by, top.updated_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites uf
|
||||
WHERE uf.user_id = $1
|
||||
AND uf.item_id = top.id::text
|
||||
AND uf.item_type = 'file'
|
||||
) AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = top.id
|
||||
AND g.resource_type = 'file'
|
||||
) AS is_shared,
|
||||
EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date,
|
||||
fm.width, fm.height
|
||||
FROM (
|
||||
@@ -596,16 +621,36 @@ impl FileBlobReadRepository {
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
let mut sort_dates = Vec::with_capacity(rows.len());
|
||||
let mut dims = Vec::with_capacity(rows.len());
|
||||
let mut flags = Vec::with_capacity(rows.len());
|
||||
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, sd, w, h) in rows {
|
||||
for (
|
||||
id,
|
||||
name,
|
||||
fid,
|
||||
fpath,
|
||||
size,
|
||||
mime,
|
||||
ca,
|
||||
ma,
|
||||
blob_hash,
|
||||
cb,
|
||||
ub,
|
||||
is_fav,
|
||||
is_shr,
|
||||
sd,
|
||||
w,
|
||||
h,
|
||||
) in rows
|
||||
{
|
||||
files.push(Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
|
||||
)?);
|
||||
sort_dates.push(sd);
|
||||
dims.push((w, h));
|
||||
flags.push((is_fav, is_shr));
|
||||
}
|
||||
|
||||
Ok((files, sort_dates, dims))
|
||||
Ok((files, sort_dates, dims, flags))
|
||||
}
|
||||
|
||||
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
|
||||
|
||||
@@ -1417,9 +1417,11 @@ impl FolderDbRepository {
|
||||
/// Fetches `limit` rows (caller should pass `desired_page_size + 1` to
|
||||
/// detect the existence of a next page). Returns raw [`FolderResourceRow`]
|
||||
/// values; the handler / service layer converts them to DTOs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_resources_paged(
|
||||
&self,
|
||||
parent_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
limit: usize,
|
||||
cursor: Option<&FolderResourceCursor>,
|
||||
order_by: &str,
|
||||
@@ -1448,6 +1450,17 @@ impl FolderDbRepository {
|
||||
NULL::text AS blob_hash,
|
||||
f.created_by,
|
||||
f.updated_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites uf
|
||||
WHERE uf.user_id = $7::uuid
|
||||
AND uf.item_id = f.id::text
|
||||
AND uf.item_type = 'folder'
|
||||
) AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = f.id
|
||||
AND g.resource_type = 'folder'
|
||||
) AS is_shared,
|
||||
LOWER(f.name) AS sort_str,
|
||||
0::bigint AS type_order,
|
||||
0::int AS folder_first
|
||||
@@ -1469,6 +1482,17 @@ impl FolderDbRepository {
|
||||
fm.blob_hash,
|
||||
fm.created_by,
|
||||
fm.updated_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites uf
|
||||
WHERE uf.user_id = $7::uuid
|
||||
AND uf.item_id = fm.id::text
|
||||
AND uf.item_type = 'file'
|
||||
) AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = fm.id
|
||||
AND g.resource_type = 'file'
|
||||
) AS is_shared,
|
||||
LOWER(fm.name) AS sort_str,
|
||||
fm.category_order::bigint AS type_order,
|
||||
1::int AS folder_first
|
||||
@@ -1660,66 +1684,71 @@ impl FolderDbRepository {
|
||||
"SELECT resource_type, id, name, folder_id, mime_type, size, \
|
||||
created_at, modified_at, drive_id, blob_hash, \
|
||||
created_by, updated_by, \
|
||||
is_favorite, is_shared, \
|
||||
sort_str, type_order, folder_first \
|
||||
FROM ({inner}) r \
|
||||
{outer_order} \
|
||||
LIMIT $6"
|
||||
);
|
||||
|
||||
// Row: (resource_type, id, name, folder_id, mime_type, size,
|
||||
// created_at, modified_at, drive_id, blob_hash,
|
||||
// created_by, updated_by,
|
||||
// sort_str, type_order, folder_first)
|
||||
type Row = (
|
||||
String,
|
||||
Uuid,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<String>,
|
||||
i64,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Uuid, // drive_id
|
||||
Option<String>,
|
||||
Option<Uuid>, // created_by
|
||||
Option<Uuid>, // updated_by
|
||||
String,
|
||||
i64,
|
||||
i32,
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(&sql)
|
||||
// 17 columns exceed sqlx's 16-element tuple `FromRow` limit,
|
||||
// so we can't use `query_as::<_, (T1..T17)>` directly. Instead
|
||||
// we fetch raw `PgRow`s and decode each column positionally
|
||||
// into `FolderResourceRow` via `try_get_unchecked(idx)`. This
|
||||
// matches Dio's positional shape (index decode, no per-row
|
||||
// column-name HashMap) AND skips the intermediate tuple
|
||||
// struct + row-to-struct move that a manual `FromRow` impl
|
||||
// would introduce — one construction, one destination.
|
||||
//
|
||||
// Column indices below MUST match the outer `SELECT` list
|
||||
// above (resource_type, id, name, folder_id, mime_type, size,
|
||||
// created_at, modified_at, drive_id, blob_hash, created_by,
|
||||
// updated_by, is_favorite, is_shared, sort_str, type_order,
|
||||
// folder_first).
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(parent_id)
|
||||
.bind(cursor_str)
|
||||
.bind(cursor_int)
|
||||
.bind(cursor_ts)
|
||||
.bind(cursor_id)
|
||||
.bind(limit as i64)
|
||||
.bind(caller_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| FolderResourceRow {
|
||||
resource_type: r.0,
|
||||
id: r.1,
|
||||
name: r.2,
|
||||
parent_id: r.3,
|
||||
mime_type: r.4,
|
||||
size: r.5,
|
||||
created_at: r.6,
|
||||
modified_at: r.7,
|
||||
drive_id: r.8,
|
||||
blob_hash: r.9,
|
||||
created_by: r.10,
|
||||
updated_by: r.11,
|
||||
sort_str: r.12,
|
||||
type_order: r.13,
|
||||
folder_first: r.14,
|
||||
use sqlx::Row as _;
|
||||
let decode_err = |col: usize, e: sqlx::Error| -> DomainError {
|
||||
DomainError::internal_error(
|
||||
"FolderDb",
|
||||
format!("list_resources_paged decode col {col}: {e}"),
|
||||
)
|
||||
};
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(FolderResourceRow {
|
||||
resource_type: r.try_get_unchecked(0).map_err(|e| decode_err(0, e))?,
|
||||
id: r.try_get_unchecked(1).map_err(|e| decode_err(1, e))?,
|
||||
name: r.try_get_unchecked(2).map_err(|e| decode_err(2, e))?,
|
||||
parent_id: r.try_get_unchecked(3).map_err(|e| decode_err(3, e))?,
|
||||
mime_type: r.try_get_unchecked(4).map_err(|e| decode_err(4, e))?,
|
||||
size: r.try_get_unchecked(5).map_err(|e| decode_err(5, e))?,
|
||||
created_at: r.try_get_unchecked(6).map_err(|e| decode_err(6, e))?,
|
||||
modified_at: r.try_get_unchecked(7).map_err(|e| decode_err(7, e))?,
|
||||
drive_id: r.try_get_unchecked(8).map_err(|e| decode_err(8, e))?,
|
||||
blob_hash: r.try_get_unchecked(9).map_err(|e| decode_err(9, e))?,
|
||||
created_by: r.try_get_unchecked(10).map_err(|e| decode_err(10, e))?,
|
||||
updated_by: r.try_get_unchecked(11).map_err(|e| decode_err(11, e))?,
|
||||
is_favorite: r.try_get_unchecked(12).map_err(|e| decode_err(12, e))?,
|
||||
is_shared: r.try_get_unchecked(13).map_err(|e| decode_err(13, e))?,
|
||||
sort_str: r.try_get_unchecked(14).map_err(|e| decode_err(14, e))?,
|
||||
type_order: r.try_get_unchecked(15).map_err(|e| decode_err(15, e))?,
|
||||
folder_first: r.try_get_unchecked(16).map_err(|e| decode_err(16, e))?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
NULL::text AS blob_hash,
|
||||
fld.created_by AS created_by,
|
||||
fld.updated_by AS updated_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites uf
|
||||
WHERE uf.user_id = $1::uuid
|
||||
AND uf.item_id = fld.id::text
|
||||
AND uf.item_type = 'folder'
|
||||
) AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = fld.id
|
||||
AND g.resource_type = 'folder'
|
||||
) AS is_shared,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
@@ -278,6 +289,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
f.blob_hash,
|
||||
f.created_by AS created_by,
|
||||
f.updated_by AS updated_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites uf
|
||||
WHERE uf.user_id = $1::uuid
|
||||
AND uf.item_id = f.id::text
|
||||
AND uf.item_type = 'file'
|
||||
) AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = f.id
|
||||
AND g.resource_type = 'file'
|
||||
) AS is_shared,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
@@ -457,6 +479,7 @@ SELECT
|
||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
|
||||
r.is_favorite, r.is_shared,
|
||||
r.is_owner, r.accessed_at, r.resource_path,
|
||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||
FROM resources r
|
||||
@@ -536,6 +559,8 @@ LIMIT $6"
|
||||
blob_hash: row.try_get("blob_hash").ok(),
|
||||
created_by: row.try_get("created_by").ok(),
|
||||
updated_by: row.try_get("updated_by").ok(),
|
||||
is_favorite: row.try_get("is_favorite").unwrap_or(false),
|
||||
is_shared: row.try_get("is_shared").unwrap_or(false),
|
||||
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||
accessed_at: row.get("accessed_at"),
|
||||
path: row.try_get("resource_path").ok(),
|
||||
|
||||
@@ -333,9 +333,11 @@ impl TrashDbRepository {
|
||||
/// Returns rows in caller-requested sort order. The caller is expected to
|
||||
/// fetch `limit + 1` to detect end-of-results. Empty `drive_ids` returns
|
||||
/// an empty page without hitting PG.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_resources_paged(
|
||||
&self,
|
||||
drive_ids: &[Uuid],
|
||||
caller_id: Uuid,
|
||||
limit: usize,
|
||||
cursor: Option<&TrashCursor>,
|
||||
order_by: &str,
|
||||
@@ -369,6 +371,17 @@ impl TrashDbRepository {
|
||||
NULL::text AS blob_hash,
|
||||
fld.created_by AS created_by,
|
||||
fld.updated_by AS updated_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites uf
|
||||
WHERE uf.user_id = $8::uuid
|
||||
AND uf.item_id = fld.id::text
|
||||
AND uf.item_type = 'folder'
|
||||
) AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = fld.id
|
||||
AND g.resource_type = 'folder'
|
||||
) AS is_shared,
|
||||
fld.trashed_at AS trashed_at,
|
||||
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
|
||||
fld.path::text AS resource_path,
|
||||
@@ -397,6 +410,17 @@ impl TrashDbRepository {
|
||||
f.blob_hash,
|
||||
f.created_by AS created_by,
|
||||
f.updated_by AS updated_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites uf
|
||||
WHERE uf.user_id = $8::uuid
|
||||
AND uf.item_id = f.id::text
|
||||
AND uf.item_type = 'file'
|
||||
) AS is_favorite,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_id = f.id
|
||||
AND g.resource_type = 'file'
|
||||
) AS is_shared,
|
||||
f.trashed_at AS trashed_at,
|
||||
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
|
||||
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
|
||||
@@ -527,6 +551,7 @@ SELECT
|
||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
|
||||
r.is_favorite, r.is_shared,
|
||||
r.trashed_at, r.deletion_date, r.resource_path,
|
||||
r.sort_str, r.type_order, r.folder_first
|
||||
FROM resources r
|
||||
@@ -543,6 +568,7 @@ LIMIT $6"
|
||||
.bind(cur_id) // $5
|
||||
.bind(limit as i64) // $6
|
||||
.bind(self.retention_days as i32) // $7
|
||||
.bind(caller_id) // $8 — favorites EXISTS on the branch SELECTs
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -588,6 +614,8 @@ LIMIT $6"
|
||||
blob_hash: row.try_get("blob_hash").ok(),
|
||||
created_by: row.try_get("created_by").ok(),
|
||||
updated_by: row.try_get("updated_by").ok(),
|
||||
is_favorite: row.try_get("is_favorite").unwrap_or(false),
|
||||
is_shared: row.try_get("is_shared").unwrap_or(false),
|
||||
trashed_at,
|
||||
deletion_date,
|
||||
path: row.try_get("resource_path").ok(),
|
||||
|
||||
@@ -189,6 +189,14 @@ impl PathResolverService {
|
||||
// reload through the repo.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
// Caller state flags not looked up here — the
|
||||
// resolver is an internal utility that answers
|
||||
// existence/type questions, not a wire emission
|
||||
// path. Callers that emit to the SPA reload
|
||||
// through the listing repo or the caller_flags
|
||||
// helper.
|
||||
is_favorite: false,
|
||||
is_shared: false,
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
@@ -217,6 +225,10 @@ impl PathResolverService {
|
||||
// §14 provenance not selected by this resolver path
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
// Caller state flags not looked up here — see
|
||||
// the folder branch above for rationale.
|
||||
is_favorite: false,
|
||||
is_shared: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user