Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/api-server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10680,6 +10680,13 @@
"$ref": "#/components/schemas/PostUserInfo",
"description": "사용자 정보 (간소화)"
},
"user_has_saved": {
"type": [
"boolean",
"null"
],
"description": "뷰어가 이 룩을 저장(북마크)했는지. null=미인증/미계산(게스트), true/false=서버 진실.\n카드 저장 토글의 초기 상태 정합용(#840). list_posts 는 viewer_id 있을 때만 채움\n(게스트/admin 목록은 생략). PostDetailResponse.user_has_saved 와 동일 시맨틱."
},
"view_count": {
"type": "integer",
"format": "int32",
Expand Down Expand Up @@ -11754,6 +11761,13 @@
"$ref": "#/components/schemas/PostUserInfo",
"description": "사용자 정보 (간소화)"
},
"user_has_saved": {
"type": [
"boolean",
"null"
],
"description": "뷰어가 이 룩을 저장(북마크)했는지. null=미인증/미계산(게스트), true/false=서버 진실.\n카드 저장 토글의 초기 상태 정합용(#840). list_posts 는 viewer_id 있을 때만 채움\n(게스트/admin 목록은 생략). PostDetailResponse.user_has_saved 와 동일 시맨틱."
},
"view_count": {
"type": "integer",
"format": "int32",
Expand Down
1 change: 1 addition & 0 deletions packages/api-server/src/batch/home_curation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ mod tests {
group_profile_image_url: None,
style_tags: None,
item_thumbnails: None,
user_has_saved: None,
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/api-server/src/domains/posts/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,12 @@ pub struct PostListItem {
/// "+N" 오버플로우는 web 에서 spot_count 로 산출.
#[serde(skip_serializing_if = "Option::is_none")]
pub item_thumbnails: Option<Vec<SolutionThumb>>,

/// 뷰어가 이 룩을 저장(북마크)했는지. null=미인증/미계산(게스트), true/false=서버 진실.
/// 카드 저장 토글의 초기 상태 정합용(#840). list_posts 는 viewer_id 있을 때만 채움
/// (게스트/admin 목록은 생략). PostDetailResponse.user_has_saved 와 동일 시맨틱.
#[serde(skip_serializing_if = "Option::is_none")]
pub user_has_saved: Option<bool>,
}

/// Post에 포함된 사용자 정보 (간소화)
Expand Down
143 changes: 143 additions & 0 deletions packages/api-server/src/domains/posts/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1795,6 +1795,16 @@ pub async fn list_posts(
};
let (artist_display_map, group_display_map) = load_warehouse_display_maps(db, &posts).await?;

// 저장 상태 배치 조회(#840): viewer 가 있을 때만 1회 IN 쿼리로 조회(N+1 회피).
// 게스트(viewer_id None)는 쿼리 자체를 생략하고 user_has_saved 를 None 으로 둔다.
let saved_post_id_set = match query.viewer_id {
Some(viewer) => {
let ids: Vec<Uuid> = posts.iter().map(|p| p.id).collect();
crate::domains::saved_posts::service::saved_post_ids_for_user(db, &ids, viewer).await?
}
None => std::collections::HashSet::new(),
};

// PostListItem으로 변환 (배치 로딩 데이터 사용)
let items: Vec<PostListItem> = posts
.into_iter()
Expand Down Expand Up @@ -1854,6 +1864,9 @@ pub async fn list_posts(
group_profile_image_url,
style_tags: post.style_tags.clone(),
item_thumbnails: item_thumb_map.get(&post.id).cloned(),
user_has_saved: query
.viewer_id
.map(|_| saved_post_id_set.contains(&post.id)),
}
})
.collect();
Expand Down Expand Up @@ -2168,6 +2181,8 @@ pub async fn admin_list_posts(
group_profile_image_url,
style_tags: post.style_tags.clone(),
item_thumbnails: None,
// admin 목록은 저장 어피던스 없음 → 계산 생략(#840).
user_has_saved: None,
}
})
.collect();
Expand Down Expand Up @@ -5497,6 +5512,134 @@ mod tests {
);
}

// ── #840 user_has_saved on list cards ──

#[tokio::test]
async fn list_posts_user_has_saved_true_when_viewer_saved() {
use crate::tests::fixtures;
use sea_orm::{DatabaseBackend, MockDatabase};

// viewer_id 가 있으면 saved_posts 배치 조회가 발사되고, 저장한 카드는
// user_has_saved == Some(true) 로 채워져야 한다(카드 초기 상태 정합).
let mut post = fixtures::post_model();
post.post_magazine_id = Some(fixtures::test_uuid(80));

let magazine = crate::entities::post_magazines::Model {
id: fixtures::test_uuid(80),
title: "Mag Title".to_string(),
subtitle: None,
keyword: None,
layout_json: None,
status: "published".to_string(),
review_summary: None,
error_log: None,
created_at: fixtures::test_timestamp(),
updated_at: fixtures::test_timestamp(),
published_at: Some(fixtures::test_timestamp()),
};

let saved_row = crate::entities::saved_posts::Model {
id: fixtures::test_uuid(90),
post_id: post.id,
user_id: fixtures::test_uuid(10),
created_at: fixtures::test_timestamp(),
};

let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results([vec![fixtures::count_row(1)]]) // count
.append_query_results([vec![post]]) // posts
.append_query_results([vec![fixtures::user_model()]]) // users
.append_query_results([
Vec::<std::collections::BTreeMap<String, sea_orm::Value>>::new(),
]) // spot_counts
.append_query_results([
Vec::<std::collections::BTreeMap<String, sea_orm::Value>>::new(),
]) // comment_counts
.append_query_results([
Vec::<std::collections::BTreeMap<String, sea_orm::Value>>::new(),
]) // item_thumbnails
.append_query_results([vec![magazine]]) // magazine titles
.append_query_results([vec![saved_row]]) // saved_posts batch (#840)
.into_connection();
let query = PostListQuery {
viewer_id: Some(fixtures::test_uuid(10)),
mood: None,
artist_name: None,
group_name: None,
context: None,
category: None,
user_id: None,
artist_id: None,
group_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
has_solutions: None,
has_magazine: None,
color: None,
season: None,
min_solutions: None,
complete_outfit: None,
include_magazine_items: None,
};
let result = list_posts(&db, query).await;
assert!(result.is_ok(), "unexpected err: {:?}", result.err());
let resp = result.unwrap();
assert_eq!(resp.data.len(), 1);
assert_eq!(resp.data[0].user_has_saved, Some(true));
}

#[tokio::test]
async fn list_posts_user_has_saved_none_for_guest() {
use crate::tests::fixtures;
use sea_orm::{DatabaseBackend, MockDatabase};

// 게스트(viewer_id None)는 저장 배치 조회를 발사하지 않고 user_has_saved == None.
// magazine 없음 → magazine 쿼리 스킵, saved 쿼리 스킵 → 총 6 쿼리.
let post = fixtures::post_model();

let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results([vec![fixtures::count_row(1)]]) // count
.append_query_results([vec![post]]) // posts
.append_query_results([vec![fixtures::user_model()]]) // users
.append_query_results([
Vec::<std::collections::BTreeMap<String, sea_orm::Value>>::new(),
]) // spot_counts
.append_query_results([
Vec::<std::collections::BTreeMap<String, sea_orm::Value>>::new(),
]) // comment_counts
.append_query_results([
Vec::<std::collections::BTreeMap<String, sea_orm::Value>>::new(),
]) // item_thumbnails
.into_connection();
let query = PostListQuery {
viewer_id: None,
mood: None,
artist_name: None,
group_name: None,
context: None,
category: None,
user_id: None,
artist_id: None,
group_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
has_solutions: None,
has_magazine: None,
color: None,
season: None,
min_solutions: None,
complete_outfit: None,
include_magazine_items: None,
};
let result = list_posts(&db, query).await;
assert!(result.is_ok(), "unexpected err: {:?}", result.err());
let resp = result.unwrap();
assert_eq!(resp.data.len(), 1);
assert_eq!(resp.data[0].user_has_saved, None);
}

// ── list_tries populated ──

#[tokio::test]
Expand Down
20 changes: 20 additions & 0 deletions packages/api-server/src/domains/saved_posts/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,23 @@ pub async fn count_saves_by_post_id(db: &DatabaseConnection, post_id: Uuid) -> A

Ok(count)
}

/// 주어진 post_ids 중 user 가 저장한 post_id 집합. 카드 목록의 저장상태 배치 조회용(#840).
/// 단건 `user_has_saved` 를 카드마다 호출하는 N+1 을 피하려 한 번의 IN 쿼리로 조회한다.
pub async fn saved_post_ids_for_user(
db: &DatabaseConnection,
post_ids: &[Uuid],
user_id: Uuid,
) -> AppResult<std::collections::HashSet<Uuid>> {
if post_ids.is_empty() {
return Ok(std::collections::HashSet::new());
}

let rows = saved_posts::Entity::find()
.filter(saved_posts::Column::UserId.eq(user_id))
.filter(saved_posts::Column::PostId.is_in(post_ids.iter().copied()))
.all(db)
.await?;

Ok(rows.into_iter().map(|r| r.post_id).collect())
}
3 changes: 3 additions & 0 deletions packages/web/app/[locale]/(shell)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ export default async function Home({
artistName: artistName || undefined,
// List model carries no detected-item count yet — omit (no fake counts).
match: getPostMatch(post), // null until #646 lands
// Saved-state when the list carries it (#840). null on the cached public
// feed (no viewer_id) → undefined → card falls back to unsaved.
userHasSaved: post.user_has_saved ?? undefined,
};
}

Expand Down
6 changes: 5 additions & 1 deletion packages/web/lib/components/home/LookCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export function RecommendedLookCard({
<MatchPill matchPct={card.match.pct} tier={card.match.tier} />
</span>
)}
<SaveLookButton postId={card.id} className="absolute right-2 top-2" />
<SaveLookButton
postId={card.id}
initialSaved={card.userHasSaved}
className="absolute right-2 top-2"
/>
</div>
</Link>
);
Expand Down
13 changes: 9 additions & 4 deletions packages/web/lib/components/home/SaveLookButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@ import { cn } from "@/lib/utils";
/**
* Card-overlay save button (top-right of look cards, per design).
*
* The list model doesn't carry user_has_saved yet, so the initial state is
* unfilled and the fill is local-optimistic after a click. The underlying
* toggle is real (auth-gated via useSavedPost).
* `initialSaved` seeds the filled state from the list model's `user_has_saved`
* when present (authed/uncached lists — #840). On the cached public home feed
* that field is absent, so it falls back to unsaved and fills optimistically
* after a click; reflecting real saved-state on the public feed needs
* client-hydration (#840 follow-up). The underlying toggle is real (auth-gated
* via useSavedPost).
*/
export function SaveLookButton({
postId,
initialSaved = false,
className,
}: {
postId: string;
initialSaved?: boolean;
className?: string;
}) {
const { toggle, isPending } = useSavedPost(postId);
const [saved, setSaved] = useState(false);
const [saved, setSaved] = useState(initialSaved);

return (
<button
Expand Down
49 changes: 49 additions & 0 deletions packages/web/lib/components/home/__tests__/SaveLookButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";

// useSavedPost 는 React Query + auth 게이팅에 의존 — seam 테스트는 토글의 진짜
// 동작이 아니라 initialSaved 가 카드 초기 렌더에 반영되는지를 본다(#840).
const toggle = vi.fn();
vi.mock("@/lib/hooks/useSavedPost", () => ({
useSavedPost: () => ({ toggle, isPending: false }),
}));

import { SaveLookButton } from "../SaveLookButton";

describe("SaveLookButton initialSaved (#840)", () => {
beforeEach(() => {
cleanup();
toggle.mockClear();
});

test("initialSaved=true 면 저장됨(Unsave) 상태로 seed 된다", () => {
render(<SaveLookButton postId="p1" initialSaved />);
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Unsave look"
);
});

test("initialSaved 부재(캐시드 공개 피드)면 미저장(Save)으로 fallback", () => {
render(<SaveLookButton postId="p1" />);
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Save look"
);
});

test("저장 상태에서 클릭하면 unsave(toggle=false) 호출 + 라벨 반전", () => {
render(<SaveLookButton postId="p1" initialSaved />);
fireEvent.click(screen.getByRole("button"));
expect(toggle).toHaveBeenCalledWith(false);
expect(screen.getByRole("button")).toHaveAttribute(
"aria-label",
"Save look"
);
});
});
5 changes: 5 additions & 0 deletions packages/web/lib/components/home/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export interface HomeLookCard {
itemCount?: number;
/** null until #646 match% lands (REDESIGN_FLAGS.matchPct) — pill hidden. */
match?: PostMatch | null;
/** Viewer's saved state, when the list model carries it (authed/uncached
* lists, e.g. Library #852). Absent on the cached public home feed — the
* card then falls back to unsaved; making home cards reflect saved-state
* needs client-hydration (#840 follow-up). See SaveLookButton. */
userHasSaved?: boolean;
}

/** Real per-post style read from posts.cody_analysis (`style`). */
Expand Down
Loading