memi-board 0.37.2 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,7 +36,7 @@ const writeRoleOptions: Array<{ label: string, value: BoardWriteRole }> = [
36
36
  ]
37
37
  const visibilityOptions: Array<{ label: string, value: BoardVisibility, description?: string }> = [
38
38
  { label: '보임', value: 'public', description: '전체 목록·필터에 표시' },
39
- { label: '숨김', value: 'hidden', description: '일기장·비공개. 주소로 와도 안내만' },
39
+ { label: '숨김', value: 'hidden', description: '일기장·비공개. 댓글은 담당 스태프만' },
40
40
  ]
41
41
 
42
42
  const { isAdmin, rolePending } = useMemiBoardAuth()
@@ -168,8 +168,26 @@ async function save() {
168
168
  <MemiBoardOptionCards v-model="draft.writeRole" :options="writeRoleOptions" @update:model-value="saved = false" />
169
169
  </div>
170
170
  <div class="flex flex-col gap-2">
171
- <div><p class="text-sm font-medium">댓글쓰기 권한</p><p class="text-xs text-muted">댓글을 쓸 수 있는 최소 역할</p></div>
172
- <MemiBoardOptionCards v-model="draft.commentWriteRole" :options="writeRoleOptions" @update:model-value="saved = false" />
171
+ <div>
172
+ <p class="text-sm font-medium">댓글쓰기 권한</p>
173
+ <p class="text-xs text-muted">
174
+ {{ draft.visibility === 'hidden'
175
+ ? '숨김 게시판은 댓글이 공개 피드에 안 나오고, 담당 스태프만 작성할 수 있습니다'
176
+ : '댓글을 쓸 수 있는 최소 역할' }}
177
+ </p>
178
+ </div>
179
+ <MemiBoardOptionCards
180
+ v-if="draft.visibility !== 'hidden'"
181
+ v-model="draft.commentWriteRole"
182
+ :options="writeRoleOptions"
183
+ @update:model-value="saved = false"
184
+ />
185
+ <p
186
+ v-else
187
+ class="rounded-lg border border-default px-3 py-2.5 text-sm text-muted"
188
+ >
189
+ 담당 스태프만 (숨김 보드 고정)
190
+ </p>
173
191
  </div>
174
192
  <div v-if="needsStaffPicker" class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
175
193
  <div><p class="text-sm font-medium">담당 스태프</p><p class="text-xs text-muted">지정한 스태프만 이 보드를 설정·관리합니다. 비우면 관리자만</p></div>
@@ -201,7 +219,7 @@ async function save() {
201
219
  <div v-if="canManageStaffAssignment" class="flex flex-col gap-2">
202
220
  <div>
203
221
  <p class="text-sm font-medium">공개 범위</p>
204
- <p class="text-xs text-muted">숨김이면 전체 필터에 안 나오고, 글은 관리자·스태프·작성자만 읽을 수 있습니다</p>
222
+ <p class="text-xs text-muted">숨김이면 전체 필터에 안 나오고, 글은 관리자·담당 스태프·작성자만 읽을 수 있습니다. 댓글은 담당 스태프만 쓸 수 있습니다</p>
205
223
  </div>
206
224
  <MemiBoardOptionCards v-model="draft.visibility" :options="visibilityOptions" @update:model-value="saved = false" />
207
225
  </div>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
- import { useMemiBoardAuth } from 'memi-board/runtime'
3
+ import { canWriteCommentByRole, useMemiBoardAuth } from 'memi-board/runtime'
4
4
  import { COMMENT_BODY_MAX_LENGTH, useMemiBoardComments } from 'memi-board/runtime'
5
5
  import { useMemiBoardModeration } from 'memi-board/runtime'
6
6
  import { useMemiBoardSettings } from 'memi-board/runtime'
@@ -9,7 +9,7 @@ import type { CommentModel } from 'memi-board/runtime'
9
9
  const props = defineProps<{ boardId: string, postId: string, parent?: CommentModel | null }>()
10
10
  const emit = defineEmits<{ saved: [], cancel: [] }>()
11
11
 
12
- const { user, isSignedIn, isAdmin, canManageBoard, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
12
+ const { user, isSignedIn, isAdmin, isStaff, isWriteRestricted, restrictedMessage } = useMemiBoardAuth()
13
13
  // 목록 컴포넌트만 실시간 구독한다. 작성 폼은 mutation API만 사용한다.
14
14
  const { addComment, addReply } = useMemiBoardComments(
15
15
  props.boardId,
@@ -19,13 +19,15 @@ const { addComment, addReply } = useMemiBoardComments(
19
19
  const { checkText } = useMemiBoardModeration()
20
20
  const { getBoard } = useMemiBoardSettings()
21
21
 
22
- // 보드별 댓글쓰기 권한
22
+ const board = computed(() => getBoard(props.boardId))
23
+ const isHiddenBoard = computed(() => board.value?.visibility === 'hidden')
23
24
  const canComment = computed(() => {
24
- if (!isSignedIn.value) return false
25
- const required = getBoard(props.boardId)?.commentWriteRole ?? 'user'
26
- if (required === 'admin') return isAdmin.value
27
- if (required === 'staff') return isAdmin.value || canManageBoard(props.boardId)
28
- return true
25
+ if (!isSignedIn.value || !user.value?.uid) return false
26
+ return canWriteCommentByRole(user.value.uid, isAdmin.value, isStaff.value, board.value)
27
+ })
28
+ const deniedMessage = computed(() => {
29
+ if (isHiddenBoard.value) return '숨김 게시판에는 담당 스태프만 댓글을 남길 수 있습니다.'
30
+ return '이 게시판에는 댓글쓰기 권한이 없습니다.'
29
31
  })
30
32
 
31
33
  const body = ref('')
@@ -40,7 +42,7 @@ async function handleSubmit() {
40
42
  return
41
43
  }
42
44
  if (!canComment.value) {
43
- error.value = '댓글쓰기 권한이 없습니다.'
45
+ error.value = deniedMessage.value
44
46
  return
45
47
  }
46
48
  if (isWriteRestricted.value) {
@@ -131,6 +133,6 @@ async function handleSubmit() {
131
133
  v-else
132
134
  class="text-sm text-muted"
133
135
  >
134
- 이 게시판에는 댓글쓰기 권한이 없습니다.
136
+ {{ deniedMessage }}
135
137
  </p>
136
138
  </template>
@@ -40,7 +40,7 @@ const writeRoleOptions: Array<{ label: string, value: BoardWriteRole }> = [
40
40
  ]
41
41
  const visibilityOptions: Array<{ label: string, value: BoardVisibility, description?: string }> = [
42
42
  { label: '보임', value: 'public', description: '전체 목록·필터에 표시' },
43
- { label: '숨김', value: 'hidden', description: '일기장·비공개' },
43
+ { label: '숨김', value: 'hidden', description: '일기장·비공개. 댓글은 담당 스태프만' },
44
44
  ]
45
45
 
46
46
  const { isAdmin, rolePending } = useMemiBoardAuth()
@@ -238,12 +238,26 @@ async function runDeleteAll() {
238
238
  />
239
239
  </div>
240
240
  <div class="flex flex-col gap-2">
241
- <div><p class="text-sm font-medium">댓글쓰기 권한</p><p class="text-xs text-muted">이 카테고리에 댓글을 쓸 수 있는 최소 역할</p></div>
241
+ <div>
242
+ <p class="text-sm font-medium">댓글쓰기 권한</p>
243
+ <p class="text-xs text-muted">
244
+ {{ category.visibility === 'hidden'
245
+ ? '숨김 게시판은 댓글이 공개 피드에 안 나오고, 담당 스태프만 작성할 수 있습니다'
246
+ : '이 카테고리에 댓글을 쓸 수 있는 최소 역할' }}
247
+ </p>
248
+ </div>
242
249
  <MemiBoardOptionCards
250
+ v-if="category.visibility !== 'hidden'"
243
251
  v-model="category.commentWriteRole"
244
252
  :options="writeRoleOptions"
245
253
  @update:model-value="savedId = null"
246
254
  />
255
+ <p
256
+ v-else
257
+ class="rounded-lg border border-default px-3 py-2.5 text-sm text-muted"
258
+ >
259
+ 담당 스태프만 (숨김 보드 고정)
260
+ </p>
247
261
  </div>
248
262
  <div v-if="needsStaffPicker(category)" class="grid gap-2 sm:grid-cols-[9rem_1fr] sm:items-center">
249
263
  <div><p class="text-sm font-medium">담당 스태프</p><p class="text-xs text-muted">지정한 스태프만 이 보드를 설정·관리합니다. 비우면 관리자만</p></div>
@@ -275,7 +289,7 @@ async function runDeleteAll() {
275
289
  <div v-if="canManageStaffAssignment" class="flex flex-col gap-2">
276
290
  <div>
277
291
  <p class="text-sm font-medium">공개 범위</p>
278
- <p class="text-xs text-muted">숨김이면 전체 필터에 안 나오고 rules 로 글 읽기가 제한됩니다</p>
292
+ <p class="text-xs text-muted">숨김이면 전체 필터에 안 나오고, 글은 관리자·담당 스태프·작성자만 읽을 수 있습니다. 댓글은 담당 스태프만 쓸 수 있습니다</p>
279
293
  </div>
280
294
  <MemiBoardOptionCards
281
295
  v-model="category.visibility"
package/dist/types.d.ts CHANGED
@@ -82,6 +82,11 @@ export interface CommentModel {
82
82
  postId: string;
83
83
  /** 부모 글의 boardId — 생성 시 클라이언트가 채우고, rules 가 부모 글과 일치하는지 검증한다. */
84
84
  boardId: string;
85
+ /**
86
+ * 공개 피드·작성자 댓글 목록 노출 여부. 보드 visibility 가 hidden 이면 false.
87
+ * 글의 listed 와 같은 복제 필드. 미지정 레거시는 true 취급.
88
+ */
89
+ listed?: boolean;
85
90
  body: string;
86
91
  authorUid: string;
87
92
  authorName: string | null;
@@ -141,7 +146,7 @@ export interface AuthorMemoModel {
141
146
  }
142
147
  export type BoardListView = 'default' | 'dense' | 'image' | 'video';
143
148
  export type BoardWriteRole = 'user' | 'staff' | 'admin';
144
- /** 게시판 공개 범위. hidden 이면 일반 목록·전체 필터에서 제외되고 글 listed=false. */
149
+ /** 게시판 공개 범위. hidden 이면 일반 목록·전체 필터에서 제외되고 글·댓글 listed=false. 댓글 쓰기는 담당 스태프만. */
145
150
  export type BoardVisibility = 'public' | 'hidden';
146
151
  /**
147
152
  * 보드 1개 메타 (= 예전 BoardCategory).
@@ -154,7 +159,7 @@ export interface BoardModel {
154
159
  description?: string;
155
160
  /**
156
161
  * 보임/숨김. hidden = 일기장·비공개.
157
- * 보드 목록·칩에서 제외, 글 listed=false. 미지정은 public.
162
+ * 보드 목록·칩에서 제외, 글·댓글 listed=false, 댓글 쓰기는 담당 스태프만. 미지정은 public.
158
163
  */
159
164
  visibility?: BoardVisibility;
160
165
  listView?: BoardListView;
@@ -1,3 +1,4 @@
1
+ import { BoardVisibility, BoardWriteRole } from '../types';
1
2
  /** 스태프가 이 보드 담당인지. 목록에 있는 uid만. 비어 있으면 담당 없음(관리자만). */
2
3
  export declare function isAssignedStaff(uid: string | undefined, board: {
3
4
  allowedStaffUids?: string[];
@@ -5,3 +6,12 @@ export declare function isAssignedStaff(uid: string | undefined, board: {
5
6
  export declare function canManageBoardByRole(uid: string | undefined, isAdmin: boolean, isStaff: boolean, board: {
6
7
  allowedStaffUids?: string[];
7
8
  } | null | undefined): boolean;
9
+ /**
10
+ * 댓글 작성 권한. 숨김 보드는 commentWriteRole 과 무관하게 관리자·담당 스태프만.
11
+ * board 가 아직 없으면 공개 보드(일반 이상)와 같이 취급한다.
12
+ */
13
+ export declare function canWriteCommentByRole(uid: string | undefined, isAdmin: boolean, isStaff: boolean, board: {
14
+ visibility?: BoardVisibility;
15
+ commentWriteRole?: BoardWriteRole;
16
+ allowedStaffUids?: string[];
17
+ } | null | undefined): boolean;
@@ -122,6 +122,25 @@
122
122
  { "fieldPath": "__name__", "order": "DESCENDING" }
123
123
  ]
124
124
  },
125
+ {
126
+ "collectionGroup": "memiBoardComments",
127
+ "queryScope": "COLLECTION",
128
+ "fields": [
129
+ { "fieldPath": "authorUid", "order": "ASCENDING" },
130
+ { "fieldPath": "listed", "order": "ASCENDING" },
131
+ { "fieldPath": "createdAt", "order": "DESCENDING" },
132
+ { "fieldPath": "__name__", "order": "DESCENDING" }
133
+ ]
134
+ },
135
+ {
136
+ "collectionGroup": "memiBoardComments",
137
+ "queryScope": "COLLECTION",
138
+ "fields": [
139
+ { "fieldPath": "listed", "order": "ASCENDING" },
140
+ { "fieldPath": "createdAt", "order": "DESCENDING" },
141
+ { "fieldPath": "__name__", "order": "DESCENDING" }
142
+ ]
143
+ },
125
144
  {
126
145
  "collectionGroup": "memiBoardComments",
127
146
  "queryScope": "COLLECTION",
@@ -131,6 +150,38 @@
131
150
  { "fieldPath": "__name__", "order": "DESCENDING" }
132
151
  ]
133
152
  },
153
+ {
154
+ "collectionGroup": "memiBoardComments",
155
+ "queryScope": "COLLECTION",
156
+ "fields": [
157
+ { "fieldPath": "listed", "order": "ASCENDING" },
158
+ { "fieldPath": "likeCount", "order": "DESCENDING" },
159
+ { "fieldPath": "createdAt", "order": "DESCENDING" },
160
+ { "fieldPath": "__name__", "order": "DESCENDING" }
161
+ ]
162
+ },
163
+ {
164
+ "collectionGroup": "memiBoardComments",
165
+ "queryScope": "COLLECTION",
166
+ "fields": [
167
+ { "fieldPath": "postId", "order": "ASCENDING" },
168
+ { "fieldPath": "rootId", "order": "ASCENDING" },
169
+ { "fieldPath": "isReply", "order": "ASCENDING" },
170
+ { "fieldPath": "createdAt", "order": "DESCENDING" },
171
+ { "fieldPath": "__name__", "order": "DESCENDING" }
172
+ ]
173
+ },
174
+ {
175
+ "collectionGroup": "memiBoardComments",
176
+ "queryScope": "COLLECTION",
177
+ "fields": [
178
+ { "fieldPath": "postId", "order": "ASCENDING" },
179
+ { "fieldPath": "rootId", "order": "ASCENDING" },
180
+ { "fieldPath": "isReply", "order": "ASCENDING" },
181
+ { "fieldPath": "createdAt", "order": "ASCENDING" },
182
+ { "fieldPath": "__name__", "order": "ASCENDING" }
183
+ ]
184
+ },
134
185
  {
135
186
  "collectionGroup": "memiBoardLikes",
136
187
  "queryScope": "COLLECTION",
@@ -77,11 +77,14 @@ service cloud.firestore {
77
77
  || (requiredRole == 'admin' && isBoardAdmin()));
78
78
  }
79
79
  function canWriteBoardComment(boardId) {
80
+ let hidden = get(boardSettingsPath(boardId)).data.get('visibility', 'public') == 'hidden';
80
81
  let requiredRole = get(boardSettingsPath(boardId)).data.get('commentWriteRole', 'user');
81
82
  return exists(boardSettingsPath(boardId))
82
- && (requiredRole == 'user'
83
- || (requiredRole == 'staff' && isBoardStaff() && isAllowedStaffForBoard(boardId))
84
- || (requiredRole == 'admin' && isBoardAdmin()));
83
+ && (hidden
84
+ ? isBoardManager(boardId)
85
+ : (requiredRole == 'user'
86
+ || (requiredRole == 'staff' && isBoardManager(boardId))
87
+ || (requiredRole == 'admin' && isBoardAdmin())));
85
88
  }
86
89
  function isOwner(resource) {
87
90
  return isSignedIn() && request.auth.uid == resource.data.authorUid;
@@ -139,6 +142,18 @@ service cloud.firestore {
139
142
  || isBoardAdmin()
140
143
  || isBoardManager(data.boardId);
141
144
  }
145
+ // 공개 피드·작성자 댓글 목록은 listed==true 동등 필터로 숨김 보드 댓글을 뺀다.
146
+ // 글 상세(postId == 상수)에서는 부모 글을 읽을 수 있으면 숨김 댓글도 볼 수 있다
147
+ // (작성자가 담당 스태프 메모를 읽는 경우).
148
+ function canReadComment(data) {
149
+ return data.get('listed', true) == true
150
+ || (isSignedIn() && request.auth.uid == data.authorUid)
151
+ || isBoardAdmin()
152
+ || isBoardManager(data.boardId)
153
+ || (data.postId is string
154
+ && exists(postPath(data.postId))
155
+ && canReadPost(get(postPath(data.postId)).data));
156
+ }
142
157
  function expectedListed(boardId) {
143
158
  return get(boardSettingsPath(boardId)).data.get('visibility', 'public') != 'hidden';
144
159
  }
@@ -240,15 +255,9 @@ service cloud.firestore {
240
255
  }
241
256
 
242
257
  match /memiBoardComments/{commentId} {
243
- // 누구나 읽을 수 있다 — "작성한 댓글 보기"(useMemiBoardUserComments)가
244
- // authorUid 하나로 모든 보드를 가로질러 조회하는데, postId는 문서마다
245
- // 달라 동등(==) 필터로 고정할 수 없다. 부모 글을 get()해서 판단하는 규칙은
246
- // list 쿼리에서 get() 인자가 상수로 환원 가능해야 하는데(postId 고정 쿼리에서만
247
- // 성립), authorUid로 고정된 쿼리는 이 조건을 못 만족해 통째로 거부된다.
248
- // 부모 글이 비공개·블라인드·삭제 상태여도 댓글 본문 자체는 노출되고,
249
- // 실제 글로 이동했을 때 그 글의 접근 상태(찾을 수 없음/권한 없음 등)로
250
- // 대신 표현한다 — 댓글 하나가 아니라 글 단위로 이미 존재하는 처리다.
251
- allow read: if true;
258
+ // 공개 피드·작성자 댓글 목록은 where('listed','==',true) 로 숨김 보드 댓글을 걸러낸다.
259
+ // 글 상세는 postId 동등 필터라 부모 글 권한(canReadPost)으로 숨김 댓글도 읽을 수 있다.
260
+ allow read: if canReadComment(resource.data);
252
261
 
253
262
  allow create: if isBoardWriter()
254
263
  && request.resource.data.postId is string
@@ -263,14 +272,25 @@ service cloud.firestore {
263
272
  && request.resource.data.body is string
264
273
  && request.resource.data.body.size() > 0
265
274
  && request.resource.data.body.size() <= 1000
266
- && request.resource.data.moderationStatus == 'approved';
275
+ && request.resource.data.moderationStatus == 'approved'
276
+ && request.resource.data.listed is bool
277
+ && request.resource.data.listed == expectedListed(request.resource.data.boardId);
267
278
 
268
279
  allow delete: if isOwner(resource)
269
280
  || isOwner(get(postPath(resource.data.postId)))
270
281
  || isBoardManager(resource.data.boardId);
271
- allow update: if isOwner(resource) || isBoardManager(resource.data.boardId)
272
- || isReplyCountUpdate() || isReplyTargetProfileUpdate()
273
- || isLikeCountUpdate();
282
+ allow update: if isReplyCountUpdate() || isReplyTargetProfileUpdate()
283
+ || isLikeCountUpdate()
284
+ || (
285
+ (isOwner(resource) || isBoardManager(resource.data.boardId))
286
+ && request.resource.data.postId == resource.data.postId
287
+ && request.resource.data.boardId == resource.data.boardId
288
+ && request.resource.data.authorUid == resource.data.authorUid
289
+ && (
290
+ request.resource.data.get('listed', true) == resource.data.get('listed', true)
291
+ || isBoardManager(resource.data.boardId)
292
+ )
293
+ );
274
294
  }
275
295
 
276
296
  // 좋아요 토글 전용 flat 컬렉션. 문서ID 관례 = `${targetId}_{uid}`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memi-board",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "description": "Nuxt 4 + Nuxt UI + VueFire/Firebase 전용 게시판 모듈",
6
6
  "license": "MIT",