팬덤 탈퇴 · 성운 창립자 권한 승계 · 대표 팬덤 자동 지정
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user