Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
da1c6ff
feat: NoticeRepo 구현체 + 공지/공지댓글 UseCase 골격
Dongju3079 May 26, 2026
48fba06
feat: 공지 리스트/상세/작성 본 구현 + PostDetail 패턴 적용
Dongju3079 May 26, 2026
559b68e
fix: 키보드 dismiss를 UIKit tap recognizer로 전환
Dongju3079 May 26, 2026
7e838e1
fix: 댓글 입력바를 ChatingTextFieldView 레이아웃과 일치시킴
Dongju3079 May 26, 2026
b280eda
fix: 댓글 입력바를 TextEditor로 전환해 멀티라인 동작 매칭
Dongju3079 May 26, 2026
3c1c036
chore: 서버 도메인 변경 (zerod.store → 2erod.com)
Dongju3079 Jun 9, 2026
b08de09
fix: Tuist 전환 후 Fastlane 빌드 설정 정합화 (스킴/워크스페이스)
Dongju3079 Jun 9, 2026
af7c92b
chore: 마케팅 버전 1.4.0으로 상향
Dongju3079 Jun 9, 2026
a5e02f4
fix: Release config에 Manual 서명 지정 추가 (Tuist 전환 누락분)
Dongju3079 Jun 9, 2026
73c798d
fix: App Store 업로드 시 prod 번들ID 명시 (Appfile dev 기본값 방지)
Dongju3079 Jun 9, 2026
4841bef
chore: App Store 배포용 API 키를 App Manager 권한 키로 교체
Dongju3079 Jun 9, 2026
7566eae
chore: App Store 자동 제출 시 메타데이터 확인 프롬프트 생략(force)
Dongju3079 Jun 9, 2026
856e094
fix: 앱 아이콘 파일명 ASCII로 변경 (한글 파일명 유니코드 정규화 불일치로 actool이 아이콘 누락)
Dongju3079 Jun 9, 2026
395a8cf
Merge branch 'develop' into feat/#37-notice-impl
Dongju3079 Jun 11, 2026
bb1bede
feat: 공지 관련 아이콘 6종 적용 (피그마 4206-3611)
Dongju3079 Jun 11, 2026
5749c6d
fix: 시스템 공지 고정 시 타입이 .custom으로 바뀌는 버그 수정
Dongju3079 Jun 11, 2026
d4ced78
fix: FetchMeetDetail에서 verifyCreator 누락 — 라이브 모드에서 isCreator=false 문제
Dongju3079 Jun 11, 2026
b6efc69
refactor: Meet isCreator 판단 패턴을 UseCase 레벨로 통일
Dongju3079 Jun 11, 2026
318e23b
feat: 공지 작성 툴팁/핀 토글/메가폰 배지 정책 정리
Dongju3079 Jun 11, 2026
c39ee6d
fix: 댓글/후기 관련 버그 수정
Dongju3079 Jun 12, 2026
c829a2b
chore: 버전 1.4.1로 업데이트
Dongju3079 Jun 12, 2026
9af1a33
Merge branch 'develop' into feat/#37-notice-impl
Dongju3079 Jun 12, 2026
81c6725
chore: 배포 타겟 iOS 18.0로 상향 (SwiftUI ScrollPosition/onScrollGeometryCha…
Dongju3079 Jun 28, 2026
a058771
feat: 공지 작성자(writer) 응답 연동 (Notice.writer + NoticeResponse 매핑)
Dongju3079 Jun 28, 2026
c7c2489
feat: 공지 단건 조회(GET /notice/detail/{noticeId}) API 연동
Dongju3079 Jun 28, 2026
8dd1e13
fix: 공지 댓글 일정/리뷰 정합성 + 키보드/새로고침 개선
Dongju3079 Jun 28, 2026
d47299c
fix: 네트워크 응답 상태코드/데이터 로깅 복원 (Rx→async 전환 시 누락분)
Dongju3079 Jun 28, 2026
6696dff
fix: 좋아요/댓글 아이콘 + 피커 라벨 다크모드 대응
Dongju3079 Jun 28, 2026
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
112 changes: 106 additions & 6 deletions Modules/Data/Sources/Network/Endpoint/APIEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ extension APIEndpoints {
query["cursor"] = cursor
}

return try Endpoint(path: "comment/\(postId)",
return try Endpoint(path: "comment/post/\(postId)",
authenticationType: .accessToken,
method: .get,
headerParameters: HTTPHeader.getReceiveJsonHeader(),
Expand All @@ -408,7 +408,7 @@ extension APIEndpoints {
static func createComment(postId: Int,
comment: String,
mentions: [Int]) throws -> Endpoint<CommentResponse> {
return try Endpoint(path: "comment/\(postId)",
return try Endpoint(path: "comment/post/\(postId)",
authenticationType: .accessToken,
method: .post,
headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(),
Expand Down Expand Up @@ -439,7 +439,7 @@ extension APIEndpoints {
commentId: Int,
comment: String,
mentions: [Int]) throws -> Endpoint<CommentResponse> {
return try Endpoint(path: "comment/\(postId)/\(commentId)",
return try Endpoint(path: "comment/post/\(postId)/\(commentId)",
authenticationType: .accessToken,
method: .post,
headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(),
Expand All @@ -456,7 +456,7 @@ extension APIEndpoints {
cursorQuery["cursor"] = nextCursor
}

return try Endpoint(path: "comment/\(postId)/\(commentId)",
return try Endpoint(path: "comment/post/\(postId)/\(commentId)",
authenticationType: .accessToken,
method: .get,
headerParameters: HTTPHeader.getReceiveJsonHeader(),
Expand All @@ -465,7 +465,7 @@ extension APIEndpoints {

// MARK: - Like
static func likeComment(commentId: Int) throws -> Endpoint<CommentResponse> {
return try Endpoint(path: "comment/\(commentId)/likes",
return try Endpoint(path: "comment/post/\(commentId)/likes",
authenticationType: .accessToken,
method: .post,
headerParameters: HTTPHeader.getReceiveJsonHeader())
Expand Down Expand Up @@ -600,7 +600,7 @@ extension APIEndpoints {

static func subscribeMeetNotify(type: SubscribeType,
isSubscribe: Bool) throws -> Endpoint<Void> {

let path = isSubscribe ? "notification/subscribe" : "notification/unsubscribe"
return try Endpoint(path: path,
authenticationType: .accessToken,
Expand All @@ -609,3 +609,103 @@ extension APIEndpoints {
bodyParameters: ["topics": ["\(type.rawValue)"]])
}
}

// MARK: - 공지 (Notice)
extension APIEndpoints {
// 공지 리스트 — GET /notice/list/{meetId}
static func fetchNoticeList(meetId: Int,
size: Int?,
cursor: String?) throws -> Endpoint<PageResponse<NoticeResponse>> {
var query: [String: Any] = [:]
if let size { query["size"] = size }
if let cursor, !cursor.isEmpty { query["cursor"] = cursor }

return try Endpoint(path: "notice/list/\(meetId)",
authenticationType: .accessToken,
method: .get,
headerParameters: HTTPHeader.getReceiveJsonHeader(),
queryParameters: query)
}

// 공지 단건 상세 — GET /notice/detail/{noticeId}
static func fetchNoticeDetail(noticeId: Int) throws -> Endpoint<NoticeResponse> {
return try Endpoint(path: "notice/detail/\(noticeId)",
authenticationType: .accessToken,
method: .get,
headerParameters: HTTPHeader.getReceiveJsonHeader())
}

// 공지 생성 — POST /notice/create
static func createNotice(meetId: Int,
content: String) throws -> Endpoint<NoticeResponse> {
return try Endpoint(path: "notice/create",
authenticationType: .accessToken,
method: .post,
headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(),
bodyParameters: ["meetId": meetId,
"content": content])
}

// 공지 수정 — PATCH /notice/update/{noticeId}
static func updateNotice(noticeId: Int,
meetId: Int,
content: String) throws -> Endpoint<NoticeResponse> {
return try Endpoint(path: "notice/update/\(noticeId)",
authenticationType: .accessToken,
method: .patch,
headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(),
bodyParameters: ["meetId": meetId,
"content": content])
}

// 공지 삭제 — DELETE /notice/{noticeId}
static func deleteNotice(noticeId: Int) throws -> Endpoint<Void> {
return try Endpoint(path: "notice/\(noticeId)",
authenticationType: .accessToken,
method: .delete,
headerParameters: HTTPHeader.getReceiveAllHeader())
}

// 공지 고정 — PATCH /notice/pin/{noticeId}
static func pinNotice(noticeId: Int) throws -> Endpoint<NoticeResponse> {
return try Endpoint(path: "notice/pin/\(noticeId)",
authenticationType: .accessToken,
method: .patch,
headerParameters: HTTPHeader.getReceiveJsonHeader())
}

// 공지 고정 해제 — DELETE /notice/pin/{noticeId}
static func unpinNotice(noticeId: Int) throws -> Endpoint<NoticeResponse> {
return try Endpoint(path: "notice/pin/\(noticeId)",
authenticationType: .accessToken,
method: .delete,
headerParameters: HTTPHeader.getReceiveJsonHeader())
}

// 공지 댓글 조회 — GET /comment/notice/{noticeId}
static func fetchNoticeCommentList(noticeId: Int,
size: Int?,
cursor: String?) throws -> Endpoint<PageResponse<NoticeCommentResponse>> {
var query: [String: Any] = [:]
if let size { query["size"] = size }
if let cursor, !cursor.isEmpty { query["cursor"] = cursor }

return try Endpoint(path: "comment/notice/\(noticeId)",
authenticationType: .accessToken,
method: .get,
headerParameters: HTTPHeader.getReceiveJsonHeader(),
queryParameters: query)
}

// 공지 댓글 생성 — POST /comment/notice/{noticeId}
static func createNoticeComment(noticeId: Int,
content: String,
mentions: [Int]) throws -> Endpoint<NoticeCommentResponse> {
return try Endpoint(path: "comment/notice/\(noticeId)",
authenticationType: .accessToken,
method: .post,
headerParameters: HTTPHeader.getSendAndReceiveJsonHeader(),
bodyParameters: ["contents": content,
"mentions": mentions])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public protocol NetworkSessionManager {

public protocol NetworkErrorLogger {
func log(request: URLRequest)
func log(response: HTTPURLResponse, data: Data?)
func log(responseData data: Data?)
func log(error: Error)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// NoticeCommentResponse.swift
// Data
//
// Created by CatSlave on 5/26/26.
//
// 서버 NoticeCommentClientResponse — 공지 댓글 객체.
// Post 댓글과 달리 likes/replies/mentions 정보가 응답에 없어 Comment 도메인의 일부만 채운다.
//

import Foundation
import Domain

struct NoticeCommentResponse: Decodable {
let commentId: Int?
let version: Int?
let content: String?
let parentId: Int?
let writer: UserInfoResponse?
let time: String?
let noticeId: Int?
}

extension NoticeCommentResponse {
func toDomain() -> Comment {
let date = DateManager.parseServerFullDate(string: time)
return .init(id: commentId,
postId: noticeId,
parentId: parentId,
writerId: writer?.userId,
writerName: writer?.nickname,
writerThumbnailPath: writer?.image,
comment: content,
createdDate: date,
isLiked: false,
likeCount: 0,
replyCount: 0,
mentions: [])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct NoticeResponse: Decodable {
let meetId: Int?
let type: String?
let content: String?
let writer: UserInfoResponse? // 공지 작성자 (isExistBadgeCount는 응답에 없어도 옵셔널이라 디코딩 OK)
let pinned: Bool?
let createdAt: String?
}
Expand All @@ -29,6 +30,7 @@ extension NoticeResponse {
meetId: meetId,
type: NoticeType(rawValue: type ?? ""),
content: content,
writer: writer?.toDomain(),
isPinned: pinned ?? false,
createdAt: DateManager.parseServerFullDate(string: createdAt)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// ReviewSuggestionStorage.swift
// Mople
//
// Created by CatSlave on 6/12/26.
//

import Foundation

private enum UserDefaultsKey: String {
case suggestedReviewPostIds
}

/// 후기 작성 추천 알럿의 노출 이력을 게시글 단위로 관리한다.
/// - 같은 후기 게시글에 다시 들어와도 알럿이 매번 뜨지 않고, 게시글마다 최초 1회만 노출되도록 한다.
public extension UserDefaults {

/// 해당 게시글에서 후기 추천 알럿을 이미 노출했는지 여부
static func hasSuggestedReview(postId: Int) -> Bool {
let shownIds = UserDefaults.standard.array(forKey: UserDefaultsKey.suggestedReviewPostIds.rawValue) as? [Int] ?? []
return shownIds.contains(postId)
}

/// 해당 게시글의 후기 추천 알럿 노출 이력을 기록한다.
static func markSuggestedReview(postId: Int) {
var shownIds = UserDefaults.standard.array(forKey: UserDefaultsKey.suggestedReviewPostIds.rawValue) as? [Int] ?? []
guard !shownIds.contains(postId) else { return }
shownIds.append(postId)
UserDefaults.standard.set(shownIds, forKey: UserDefaultsKey.suggestedReviewPostIds.rawValue)
}
}
99 changes: 99 additions & 0 deletions Modules/Data/Sources/Repositories/Notice/DefaultNoticeRepo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// DefaultNoticeRepo.swift
// Data
//
// Created by CatSlave on 5/26/26.
//

import Foundation
import Domain

public final class DefaultNoticeRepo: BaseRepositories, NoticeRepo {

// MARK: - 공지 CRUD
public func fetchNoticeList(meetId: Int,
size: Int?,
cursor: String?) async throws -> Page<Notice> {
let response: PageResponse<NoticeResponse> = try await self.networkService.authenticatedRequest {
try APIEndpoints.fetchNoticeList(meetId: meetId,
size: size,
cursor: cursor)
}
return Page(totalCount: response.totalCount ?? 0,
content: response.content.map { $0.toDomain() },
info: response.page?.toDomain())
}

public func fetchNoticeDetail(noticeId: Int) async throws -> Notice {
let response: NoticeResponse = try await self.networkService.authenticatedRequest {
try APIEndpoints.fetchNoticeDetail(noticeId: noticeId)
}
return response.toDomain()
}

public func createNotice(meetId: Int,
content: String) async throws -> Notice {
let response: NoticeResponse = try await self.networkService.authenticatedRequest {
try APIEndpoints.createNotice(meetId: meetId,
content: content)
}
return response.toDomain()
}

public func updateNotice(noticeId: Int,
meetId: Int,
content: String) async throws -> Notice {
let response: NoticeResponse = try await self.networkService.authenticatedRequest {
try APIEndpoints.updateNotice(noticeId: noticeId,
meetId: meetId,
content: content)
}
return response.toDomain()
}

public func deleteNotice(noticeId: Int) async throws {
return try await self.networkService.authenticatedRequest {
try APIEndpoints.deleteNotice(noticeId: noticeId)
}
}

// MARK: - 고정 토글
public func pinNotice(noticeId: Int) async throws -> Notice {
let response: NoticeResponse = try await self.networkService.authenticatedRequest {
try APIEndpoints.pinNotice(noticeId: noticeId)
}
return response.toDomain()
}

public func unpinNotice(noticeId: Int) async throws -> Notice {
let response: NoticeResponse = try await self.networkService.authenticatedRequest {
try APIEndpoints.unpinNotice(noticeId: noticeId)
}
return response.toDomain()
}

// MARK: - 공지 댓글
public func fetchNoticeCommentList(noticeId: Int,
size: Int?,
cursor: String?) async throws -> Page<Comment> {
let response: PageResponse<NoticeCommentResponse> = try await self.networkService.authenticatedRequest {
try APIEndpoints.fetchNoticeCommentList(noticeId: noticeId,
size: size,
cursor: cursor)
}
return Page(totalCount: response.totalCount ?? 0,
content: response.content.map { $0.toDomain() },
info: response.page?.toDomain())
}

public func createNoticeComment(noticeId: Int,
content: String,
mentions: [Int]) async throws -> Comment {
let response: NoticeCommentResponse = try await self.networkService.authenticatedRequest {
try APIEndpoints.createNoticeComment(noticeId: noticeId,
content: content,
mentions: mentions)
}
return response.toDomain()
}
}
17 changes: 17 additions & 0 deletions Modules/Domain/Sources/Entities/Meet/Meet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,20 @@ public struct Meet {
self.pinnedNotice = pinnedNotice
}
}

public extension Meet {
// pinnedNotice는 let 이라 부분 갱신 불가 — copy with replacement 헬퍼.
// 공지 핀 토글 알림 수신 시 모임상세 상태를 새 인스턴스로 갱신할 때 사용.
func with(pinnedNotice: Notice?) -> Meet {
return Meet(
isCreator: isCreator,
meetSummary: meetSummary,
sinceDays: sinceDays,
creatorId: creatorId,
memberCount: memberCount,
firstPlanDate: firstPlanDate,
version: version,
pinnedNotice: pinnedNotice
)
}
}
3 changes: 3 additions & 0 deletions Modules/Domain/Sources/Entities/Notice/Notice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public struct Notice: Hashable {
public let meetId: Int?
public let type: NoticeType?
public let content: String?
public let writer: UserInfo? // 공지 작성자 (백엔드 writer 응답)
public let isPinned: Bool
public let createdAt: Date?

Expand All @@ -23,13 +24,15 @@ public struct Notice: Hashable {
meetId: Int? = nil,
type: NoticeType? = nil,
content: String? = nil,
writer: UserInfo? = nil,
isPinned: Bool = false,
createdAt: Date? = nil) {
self.noticeId = noticeId
self.version = version
self.meetId = meetId
self.type = type
self.content = content
self.writer = writer
self.isPinned = isPinned
self.createdAt = createdAt
}
Expand Down
Loading