팬덤 탈퇴 · 성운 창립자 권한 승계 · 대표 팬덤 자동 지정

This commit is contained in:
Sn-Kinos
2026-10-05 17:41:20 +09:00
parent 8a1b824307
commit 3a3be4c3d3
21 changed files with 229 additions and 54 deletions
+39 -14
View File
@@ -59,10 +59,8 @@ export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
* - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
* - `nebulaFounderLeaveBlocked` — 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03
* 권고 A안 · true). false 면 막지 않는다.
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지
* (Q-PN-04). `latest`(권고 B안 · 가장 최근 가입) · `earliest`(C안) · `none`(A안 · 비워 둠).
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지.
* 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치.
* 자동 지정은 C-15 대기에 세지 않는다.
*/
const FANDOM_DEFAULTS = {
@@ -71,7 +69,6 @@ const FANDOM_DEFAULTS = {
nebulaDays: 7,
nebulaConversionDeadlineEnforced: false,
primaryCooldownDays: 30,
nebulaFounderLeaveBlocked: true,
primaryOnLeave: 'latest' as 'latest' | 'earliest' | 'none',
};
@@ -204,6 +201,7 @@ export const fnNebulaCreate = onCall(async (req) => {
*
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
* 탄생 권한자가 비어 있는 성운(원장 S20)에 가입하면 그 사람이 탄생 권한자가 된다.
*/
export const fnFandomJoin = onCall(async (req) => {
const uid = callerUid(req);
@@ -218,6 +216,10 @@ export const fnFandomJoin = onCall(async (req) => {
if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.');
if (memberSnap.exists) return { fandomId, joined: false };
writeJoin(tx, uid, fandomId, fandomSnap.data());
// 원장 S20(확인 전 유지 기준) — 창립자가 혼자 떠나 탄생 권한자가 빈 성운은 다음 가입자가 이어받는다.
if (fandomSnap.get('stage') === 0 && !fandomSnap.get('founderUid')) {
tx.set(fandomSnap.ref, { founderUid: uid }, { merge: true });
}
if (!primaryOf(userSnap.data())) {
tx.set(userSnap.ref, {
primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp(),
@@ -231,10 +233,12 @@ export const fnFandomJoin = onCall(async (req) => {
* `fn-fandom-leave` — 탈퇴. 멤버가 아니면 아무것도 하지 않는다(두 번 눌러도 한 번만 내린다).
*
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
* 순번은 `memberSeq` 를 되돌리지 않으므로 다른 사용자에게 다시 주지 않는다(C-100 · B-68).
*
* - 성운(Stage0) 창립자는 탄생 전 떠날 수 없다(E-FDM-05 · Q-PN-03) — 탄생 권한자가 사라지면
* 조건을 채워도 행성이 될 수 없다.
* - 대표 팬덤을 떠나면 `primaryOnLeave` 대로 남은 팬덤 하나를 대표로 둔다(Q-PN-04). 자동 지정이라
* - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버
* 가운데 `joinedAt` 이 가장 이른 사용자에게 넘기고 알림을 남긴다. 남은 멤버가 없으면 비워 두고
* 다음 가입자가 이어받는다(원장 S20 확인 전 유지 기준). `ownerUid`(행성 문서) 등 최초 생성자 기록은 그대로다.
* - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(원장 C-101). 자동 지정이라
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
*/
export const fnFandomLeave = onCall(async (req) => {
@@ -248,31 +252,52 @@ export const fnFandomLeave = onCall(async (req) => {
tx.get(memberRef), tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}`)),
]);
if (!memberSnap.exists) return { fandomId, left: false };
if (policy.nebulaFounderLeaveBlocked === true &&
fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid) {
fail(ERR.nebulaFounderLeave, '행성이 탄생한 뒤에 탈퇴할 수 있어요.');
// 트랜잭션은 읽기를 쓰기보다 먼저 끝내야 한다 — 승계 · 대표 후보를 여기서 다 읽는다.
// 떠나는 사람을 빼고 하나가 남도록 두 건만 읽는다.
const handsOver = fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid;
let nextFounder: string | null = null;
if (handsOver) {
const first = await tx.get(fandomSnap.ref.collection('members').orderBy('joinedAt', 'asc').limit(2));
nextFounder = first.docs.map((d) => d.id).find((id) => id !== uid) ?? null;
}
const wasPrimary = primaryOf(userSnap.data()) === fandomId;
let nextPrimary: string | null = null;
if (wasPrimary && policy.primaryOnLeave !== 'none') {
// 떠나는 팬덤을 빼고 하나가 남도록 두 건만 읽는다.
const rest = await tx.get(db.collection(`users/${uid}/memberships`)
.orderBy('joinedAt', policy.primaryOnLeave === 'earliest' ? 'asc' : 'desc').limit(2));
nextPrimary = rest.docs.map((d) => d.id).find((id) => id !== fandomId) ?? null;
}
tx.delete(memberRef);
tx.delete(db.doc(`users/${uid}/memberships/${fandomId}`));
tx.set(userSnap.ref, { fandomIds: FieldValue.arrayRemove(fandomId) }, { merge: true });
tx.update(db.doc(`fandoms/${fandomId}`), {
tx.update(fandomSnap.ref, {
memberCount: FieldValue.increment(-1),
...(handsOver ? { founderUid: nextFounder } : {}),
updatedAt: FieldValue.serverTimestamp(),
});
if (nextFounder) {
// 알림함(`users/{uid}/notifications`) 한 건 — 같은 승계를 두 번 적지 않게 ID 를 고정한다.
tx.set(db.doc(`users/${nextFounder}/notifications/founder_${fandomId}_${uid}`), {
type: 'essential',
title: `「${fandomSnap.get('name') ?? ''}」 성운의 행성 탄생 권한을 이어받았어요.`,
fandomId,
read: false,
createdAt: FieldValue.serverTimestamp(),
});
}
if (wasPrimary) {
tx.set(userSnap.ref, {
primaryFandomId: nextPrimary, fandomId: nextPrimary ?? '', updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
return { fandomId, left: true, primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()) };
return {
fandomId,
left: true,
primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()),
...(handsOver ? { founderUid: nextFounder } : {}),
};
});
});
@@ -33,8 +33,6 @@ export const ERR = {
nebulaDailyLimit: 'E-FDM-02',
nebulaNotReady: 'E-FDM-03',
primaryCooldown: 'E-FDM-04',
// 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03 A안).
nebulaFounderLeave: 'E-FDM-05',
permissionDenied: 'E-PERM-01',
authFailed: 'E-AUTH-03',
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
@@ -76,7 +74,6 @@ const STATUS: Record<ErrorCode, Status> = {
'E-FDM-02': 'resource-exhausted',
'E-FDM-03': 'failed-precondition',
'E-FDM-04': 'failed-precondition',
'E-FDM-05': 'failed-precondition',
'E-PERM-01': 'permission-denied',
'E-AUTH-03': 'permission-denied',
'E-TRN-01': 'permission-denied',