Files

147 lines
27 KiB
JavaScript

/**
* portal/transfer/service.mjs — 사이트·서비스 소유권 이전(§22) 업무 규칙.
*
* 흐름: 보내는 그룹 요청(request) → 받는 쪽 수락(accept; 권리 부족이면 on_hold) → 운영자 심사(approve/reject: 소유 증빙·도메인 확인) → 완료(complete: 한 트랜잭션)
* 상태: requested → accepted → on_hold | reviewing → approved → completed / rejected / declined / cancelled / expired / disputed
* 이전 대상: 사이트(+웹 도메인·사이트 대표 메일·백업 옵션과 백업·배정 용량·진행 작업·미납 항목 표시), 명시적으로 고른 독립 메일 서비스.
* 제외: AI Wallet, 그룹 공용 미배정 용량, 기존 청구·결제 이력(§22).
* 분쟁(disputed): 대상의 설정 변경·삭제·추가 이전을 동결한다 — 다른 영역은 isFrozen() 으로 확인한다.
* 완료는 전부 옮겨지거나 전혀 옮겨지지 않는다(store.tx). 완료 뒤 이전 그룹 구성원은 사이트별 권한이 지워져 접근할 수 없다.
*/
import { HttpError } from '../../lib/http.mjs';
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
import { notify, resolveIncident } from '../../lib/notify.mjs';
import { audit } from '../../lib/audit.mjs';
import * as quota from '../../lib/quota.mjs';
import { inProgress, nowIso, parseJson, toJson, addMs, DAY } from '../lifecycle/common.mjs';
export const EXPIRY_DAYS = 7;
export const EXCLUDED = ['AI Wallet(잔액·충전 이력)', '사용자 그룹 공용 미배정 용량', '기존 청구·결제 이력'];
const OPEN = ['requested', 'accepted', 'on_hold', 'reviewing', 'approved', 'disputed'];
/** 이전 범위 계산(화면 표시용·검증용) */
export function scopeOf(deps, fromGroupId, { siteIds = [], mailServiceIds = [] } = {}) {
const sites = siteIds.map((id) => deps.store.get('SELECT * FROM sites WHERE id=? AND group_id=? AND deleted_at IS NULL', id, fromGroupId)).filter(Boolean);
if (sites.length !== siteIds.length) throw new HttpError(404, 'not_found', '이전할 사이트를 찾을 수 없습니다');
const standalone = mailServiceIds.map((id) => deps.store.get('SELECT * FROM mail_services WHERE id=? AND group_id=? AND kind=\'standalone\' AND deleted_at IS NULL', id, fromGroupId)).filter(Boolean);
if (standalone.length !== mailServiceIds.length) throw new HttpError(404, 'not_found', '이전할 독립 메일 서비스를 찾을 수 없습니다(독립 메일은 명시적으로 선택한 것만 포함)');
const items = sites.map((s) => ({ type: 'site', id: s.id, name: s.name, lifecycle: s.lifecycle, siteCount: s.site_count_state,
domains: deps.store.all('SELECT id, name, purpose, is_primary FROM domains WHERE site_id=?', s.id),
siteMail: deps.store.all('SELECT id, name, domain, status FROM mail_services WHERE site_id=? AND deleted_at IS NULL', s.id),
backup: (() => { const o = deps.store.get('SELECT id, status, ends_at FROM backup_options WHERE site_id=?', s.id); return o ? { ...o, backups: deps.store.get('SELECT COUNT(*) AS n FROM backups WHERE site_id=? AND deleted_at IS NULL', s.id).n } : null; })(),
allocations: deps.store.all('SELECT resource, amount, state FROM quota_allocations WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', fromGroupId, s.id),
inProgress: inProgress(deps, 'site', s.id).filter((c) => c.type !== 'transfer'),
unpaid: deps.store.all('SELECT id, order_no, total, status, due_at FROM orders WHERE group_id=? AND target_type=\'site\' AND target_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\')', fromGroupId, s.id) }));
const mails = standalone.map((m) => ({ type: 'mail_service', id: m.id, name: m.name, domain: m.domain, status: m.status, allocations: deps.store.all('SELECT resource, amount, state FROM quota_allocations WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', fromGroupId, m.id), inProgress: inProgress(deps, 'mail_service', m.id).filter((c) => c.type !== 'transfer'), mailboxes: deps.store.get('SELECT COUNT(*) AS n FROM mailboxes WHERE mail_service_id=? AND deleted_at IS NULL', m.id).n }));
// 사이트 대표 메일의 배정도 함께 옮긴다
const siteMailAlloc = items.flatMap((s) => s.siteMail.flatMap((m) => deps.store.all('SELECT resource, amount, state FROM quota_allocations WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', fromGroupId, m.id)));
const needed = {}; for (const a of [...items.flatMap((s) => s.allocations), ...mails.flatMap((m) => m.allocations), ...siteMailAlloc]) needed[a.resource] = (needed[a.resource] || 0) + a.amount;
return { sites: items, mailServices: mails, excluded: EXCLUDED, needed, siteIds, mailServiceIds, blocking: [...items.flatMap((s) => s.inProgress.map((c) => ({ subject: s.name, ...c }))), ...mails.flatMap((m) => m.inProgress.map((c) => ({ subject: m.name, ...c })))] };
}
/** 받는 그룹의 권리 부족 계산 */
export function shortages(deps, toGroupId, needed) { const out = []; for (const [res, amt] of Object.entries(needed)) { const f = quota.figures(deps.store, toGroupId, res); if (amt > f.unallocated) out.push({ resource: res, label: f.label, needed: amt, available: f.unallocated, shortage: amt - f.unallocated, product: `${res} 권리를 ${amt - f.unallocated} 만큼 추가 구매` }); } return out; }
export function get(deps, id) { const t = deps.store.get('SELECT * FROM ownership_transfers WHERE id=?', id); return t ? { ...t, scope: parseJson(t.scope_json, {}), review: parseJson(t.review_json, null), hold: parseJson(t.hold_json, null), completion: parseJson(t.completion_json, null) } : null; }
/** 대상이 분쟁 동결 상태인지(다른 영역이 설정 변경·삭제·추가 이전 전에 부른다) */
export function isFrozen(deps, subjectType, subjectId) { return deps.store.all('SELECT scope_json FROM ownership_transfers WHERE status=\'disputed\'').some((t) => { const sc = parseJson(t.scope_json, {}); return (subjectType === 'site' ? sc.siteIds : sc.mailServiceIds || []).includes(subjectId); }); }
export function assertNotFrozen(deps, subjectType, subjectId) { if (isFrozen(deps, subjectType, subjectId)) throw new HttpError(423, 'transfer_disputed', '소유권 분쟁으로 동결되어 설정 변경·삭제·추가 이전을 할 수 없습니다. 문의 경로: 알림 센터의 분쟁 안내를 확인하세요'); }
/** 요청(보내는 그룹 §22) */
export function request(deps, { fromGroup, by, toEmail = null, toGroupId = null, siteIds = [], mailServiceIds = [] }) {
if (!siteIds.length && !mailServiceIds.length) throw new HttpError(400, 'empty_scope', '이전할 사이트 또는 독립 메일 서비스를 하나 이상 고르세요');
if (toGroupId === fromGroup.id) throw new HttpError(400, 'same_group', '같은 그룹으로는 이전할 수 없습니다');
if (!toEmail && !toGroupId) throw new HttpError(400, 'receiver_required', '받는 쪽 이메일 또는 그룹을 지정하세요');
const scope = scopeOf(deps, fromGroup.id, { siteIds, mailServiceIds });
if (scope.blocking.length) throw new HttpError(409, 'conflict', '진행 중인 배포·복원·이전이 완료 또는 취소되어야 소유권 이전을 시작할 수 있습니다', { blocking: scope.blocking });
for (const id of [...siteIds, ...mailServiceIds]) { const open = deps.store.all('SELECT id, scope_json, status FROM ownership_transfers WHERE status IN (' + OPEN.map(() => '?').join(',') + ')', ...OPEN).find((t) => { const sc = parseJson(t.scope_json, {}); return [...(sc.siteIds || []), ...(sc.mailServiceIds || [])].includes(id); }); if (open) throw new HttpError(409, open.status === 'disputed' ? 'transfer_disputed' : 'transfer_open', open.status === 'disputed' ? '분쟁 중인 대상은 추가로 이전할 수 없습니다' : '이미 진행 중인 소유권 이전이 있습니다', { transferId: open.id }); }
const id = newId('tr_'); const token = randomToken(24); const now = nowIso(); const expires = addMs(now, EXPIRY_DAYS * DAY);
deps.store.run('INSERT INTO ownership_transfers(id, from_group_id, to_group_id, to_email, scope_json, status, token_hash, requested_by, expires_at, created_at, updated_at) VALUES(?,?,?,?,?,\'requested\',?,?,?,?,?)', id, fromGroup.id, toGroupId, toEmail ? String(toEmail).toLowerCase() : null, toJson({ siteIds, mailServiceIds, needed: scope.needed, summary: { sites: scope.sites.map((s) => ({ id: s.id, name: s.name, domains: s.domains.map((d) => d.name), siteMail: s.siteMail.map((m) => m.domain), backup: !!s.backup })), mailServices: scope.mailServices.map((m) => ({ id: m.id, name: m.name, domain: m.domain })) } }), sha256(token), by, expires, now, now);
audit(deps.store, { actorType: 'user', actorId: by, groupId: fromGroup.id, subjectType: 'transfer', subjectId: id, action: 'ownership.request', detail: { siteIds, mailServiceIds, toGroupId, toEmail } });
// 받는 쪽 알림(가입 사용자면 그 사용자의 알림으로, 그룹이면 그룹 알림으로). 토큰은 저장하지 않고 이메일 발송기에만 넘긴다.
const toUser = toEmail ? deps.store.get('SELECT id FROM users WHERE email=?', String(toEmail).toLowerCase()) : null;
notify(deps.store, { groupId: toGroupId || null, userId: toUser ? toUser.id : null, category: 'membership', severity: 'info', mandatory: true, title: `${fromGroup.name} 이(가) 소유권 이전을 요청했습니다`, body: `대상: 사이트 ${siteIds.length}개, 독립 메일 ${mailServiceIds.length}개. ${expires} 까지 수락하지 않으면 만료됩니다`, link: `/transfers/${id}`, subjectType: 'transfer', subjectId: id, email: true, detail: { acceptToken: token } });
notify(deps.store, { groupId: fromGroup.id, category: 'membership', title: '소유권 이전을 요청했습니다', body: `받는 쪽이 수락하고 운영자 심사가 끝나면 완료됩니다. 승인 전까지 취소할 수 있습니다 (만료 ${expires})`, link: `/transfers/${id}`, subjectType: 'transfer', subjectId: id });
return { transfer: get(deps, id), token };
}
/** 받는 쪽 수락: 권리 확인 → on_hold 또는 reviewing */
export function accept(deps, id, { user, group, token = null }) {
const t = get(deps, id); if (!t) throw new HttpError(404, 'not_found', '이전 요청이 없습니다');
if (t.status !== 'requested') throw new HttpError(409, 'transfer_not_acceptable', `지금 상태(${t.status})에서는 수락할 수 없습니다`);
if (Date.parse(t.expires_at) < Date.now()) { expire(deps, t.id); throw new HttpError(409, 'transfer_expired', '요청이 만료되었습니다'); }
const byEmail = t.to_email && user && user.email && user.email.toLowerCase() === t.to_email; const byGroup = t.to_group_id && group && group.id === t.to_group_id; const byToken = token && sha256(token) === t.token_hash;
if (!(byEmail || byGroup || byToken)) throw new HttpError(403, 'forbidden', '이 요청의 받는 쪽이 아닙니다');
if (!group || group.id === t.from_group_id) throw new HttpError(400, 'receiver_group_required', '받을 사용자 그룹을 선택하세요(보내는 그룹과 달라야 합니다)');
deps.store.run('UPDATE ownership_transfers SET to_group_id=?, accepted_by=?, status=\'accepted\', updated_at=? WHERE id=?', group.id, user.id, nowIso(), id);
audit(deps.store, { actorType: 'user', actorId: user.id, groupId: group.id, subjectType: 'transfer', subjectId: id, action: 'ownership.accept' });
return recheck(deps, id);
}
/** 받는 쪽 권리 재확인(수락 직후·상품 구매 뒤) */
export function recheck(deps, id) {
const t = get(deps, id); if (!t || !['accepted', 'on_hold'].includes(t.status)) return t;
const sh = shortages(deps, t.to_group_id, t.scope.needed || {});
if (sh.length) { deps.store.run('UPDATE ownership_transfers SET status=\'on_hold\', hold_json=?, updated_at=? WHERE id=?', toJson(sh), nowIso(), id); notify(deps.store, { groupId: t.to_group_id, category: 'membership', severity: 'warning', incidentKey: `transfer:${id}:hold`, title: '소유권 이전 보류 — 받을 권리가 부족합니다', body: sh.map((s) => `${s.label}: ${s.shortage} 부족 (${s.product})`).join(' / '), link: `/transfers/${id}`, actionRequired: '필요한 상품을 구매한 뒤 다시 확인하세요' }); notify(deps.store, { groupId: t.from_group_id, category: 'membership', incidentKey: `transfer:${id}:hold:from`, title: '소유권 이전이 보류 중입니다', body: '받는 쪽의 상품 권리가 부족합니다. 받는 쪽이 준비되면 운영자 심사로 넘어갑니다', link: `/transfers/${id}` }); }
else { deps.store.run('UPDATE ownership_transfers SET status=\'reviewing\', hold_json=NULL, updated_at=? WHERE id=?', nowIso(), id); notify(deps.store, { groupId: null, category: 'ops', severity: 'info', incidentKey: `ops:transfer:${id}`, title: `[운영] 소유권 이전 심사 대기 — ${id}`, body: '소유 증빙과 도메인 확인 결과를 심사하세요', link: `/ops/transfer/${id}` }); }
return get(deps, id);
}
/** 운영자 심사 승인 → 즉시 완료(원자적) */
export function approve(deps, id, { by, review = {}, confirm = {} }) {
const t = get(deps, id); if (!t) throw new HttpError(404, 'not_found', '이전 요청이 없습니다');
if (t.status === 'completed') return { transfer: t, created: false };
if (t.status !== 'reviewing') throw new HttpError(409, 'transfer_not_approvable', `지금 상태(${t.status})에서는 승인할 수 없습니다`);
if (confirm.fromGroupId !== t.from_group_id || confirm.toGroupId !== t.to_group_id) throw new HttpError(400, 'final_confirmation_mismatch', '최종 확인(보내는 그룹·받는 그룹)이 요청과 다릅니다', { expected: { fromGroupId: t.from_group_id, toGroupId: t.to_group_id } });
if (!review.ownershipProof || !review.domainChecks) throw new HttpError(400, 'review_incomplete', '소유 증빙과 도메인 확인 결과를 기록해야 승인할 수 있습니다');
deps.store.run('UPDATE ownership_transfers SET status=\'approved\', reviewed_by=?, review_json=?, updated_at=? WHERE id=?', by, toJson(review), nowIso(), id);
audit(deps.store, { actorType: 'operator', actorId: by, groupId: t.from_group_id, subjectType: 'transfer', subjectId: id, action: 'ownership.approve', detail: { review } });
return { transfer: complete(deps, id, by), created: true };
}
/** 완료: 한 트랜잭션에서 모든 대상을 옮긴다. 중간에 실패하면 아무것도 옮겨지지 않는다. */
export function complete(deps, id, by = 'system') {
const t = get(deps, id); if (!t || t.status !== 'approved') throw new HttpError(409, 'transfer_not_approved', '승인된 요청만 완료할 수 있습니다');
const { siteIds = [], mailServiceIds = [] } = t.scope; const from = t.from_group_id; const to = t.to_group_id; const now = nowIso();
return deps.store.tx(() => {
const scope = scopeOf(deps, from, { siteIds, mailServiceIds }); if (scope.blocking.length) throw new HttpError(409, 'conflict', '진행 중인 작업이 있어 완료할 수 없습니다', { blocking: scope.blocking });
const sh = shortages(deps, to, scope.needed); if (sh.length) throw new HttpError(409, 'quota_insufficient', '받는 그룹의 권리가 부족합니다', { shortages: sh });
const moved = { sites: 0, domains: 0, mailServices: 0, mailboxes: 0, backups: 0, allocations: 0, permissionsCleared: 0 };
const siteMailIds = siteIds.length ? deps.store.all(`SELECT id FROM mail_services WHERE site_id IN (${siteIds.map(() => '?').join(',')})`, ...siteIds).map((r) => r.id) : [];
const allMail = [...new Set([...mailServiceIds, ...siteMailIds])];
for (const sid of siteIds) {
moved.sites += deps.store.run('UPDATE sites SET group_id=?, updated_at=? WHERE id=? AND group_id=?', to, now, sid, from).changes;
moved.domains += deps.store.run('UPDATE domains SET group_id=?, updated_at=? WHERE site_id=? AND group_id=?', to, now, sid, from).changes;
deps.store.run('UPDATE deployments SET group_id=? WHERE site_id=?', to, sid); deps.store.run('UPDATE backup_options SET group_id=?, updated_at=? WHERE site_id=?', to, now, sid); moved.backups += deps.store.run('UPDATE backups SET group_id=? WHERE site_id=?', to, sid).changes; deps.store.run('UPDATE restores SET group_id=? WHERE site_id=?', to, sid); deps.store.run('UPDATE relocations SET group_id=? WHERE subject_type=\'site\' AND subject_id=?', to, sid);
moved.permissionsCleared += deps.store.run('DELETE FROM site_permissions WHERE site_id=?', sid).changes; // 이전 그룹 구성원의 사이트 권한 소멸
moved.allocations += deps.store.run('UPDATE quota_allocations SET group_id=?, updated_at=? WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', to, now, from, sid).changes; deps.store.run('UPDATE quota_reservations SET group_id=? WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', to, from, sid); deps.store.run('UPDATE quota_usage SET group_id=? WHERE group_id=? AND subject_type=\'site\' AND subject_id=?', to, from, sid);
deps.store.run('UPDATE jobs SET group_id=? WHERE subject_type=\'site\' AND subject_id=? AND group_id=?', to, sid, from);
if (deps._transferTxHook) deps._transferTxHook(sid); // 테스트: 중간 실패 시뮬레이션
}
for (const mid of allMail) {
moved.mailServices += deps.store.run('UPDATE mail_services SET group_id=?, updated_at=? WHERE id=? AND group_id=?', to, now, mid, from).changes; moved.mailboxes += deps.store.run('UPDATE mailboxes SET group_id=?, updated_at=? WHERE mail_service_id=? AND group_id=?', to, now, mid, from).changes;
deps.store.run('UPDATE domains SET group_id=?, updated_at=? WHERE mail_service_id=? AND group_id=?', to, now, mid, from);
moved.allocations += deps.store.run('UPDATE quota_allocations SET group_id=?, updated_at=? WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', to, now, from, mid).changes; deps.store.run('UPDATE quota_reservations SET group_id=? WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', to, from, mid); deps.store.run('UPDATE quota_usage SET group_id=? WHERE group_id=? AND subject_type=\'mail_service\' AND subject_id=?', to, from, mid); deps.store.run('UPDATE relocations SET group_id=? WHERE subject_type=\'mail_service\' AND subject_id=?', to, mid);
}
deps.store.run('UPDATE ownership_transfers SET status=\'completed\', completed_at=?, billing_cutover_at=?, completion_json=?, updated_at=? WHERE id=?', now, now, toJson({ moved, siteIds, mailServiceIds: allMail }), now, id);
for (const g of [from, to]) audit(deps.store, { actorType: by === 'system' ? 'system' : 'operator', actorId: by, groupId: g, subjectType: 'transfer', subjectId: id, action: 'ownership.complete', detail: { from, to, moved, siteIds, mailServiceIds: allMail, billingCutoverAt: now } });
notify(deps.store, { groupId: from, category: 'membership', mandatory: true, title: '소유권 이전이 완료되었습니다', body: `사이트 ${moved.sites}개·메일 서비스 ${moved.mailServices}개가 새 소유자에게 넘어갔습니다. 이 시각(${now})부터 청구 책임이 종료되며 다음 청구에 반영됩니다`, link: '/transfers', subjectType: 'transfer', subjectId: id });
notify(deps.store, { groupId: to, category: 'membership', mandatory: true, title: '소유권을 넘겨받았습니다', body: `사이트 ${moved.sites}개·메일 서비스 ${moved.mailServices}개. 이 시각(${now})부터 청구 책임이 시작되며 다음 청구에 반영됩니다. 구성원 권한은 다시 지정하세요`, link: '/sites', subjectType: 'transfer', subjectId: id });
return get(deps, id);
});
}
export function reject(deps, id, { by, reason }) { const t = get(deps, id); if (!t || !['reviewing', 'on_hold', 'accepted'].includes(t.status)) throw new HttpError(409, 'transfer_not_rejectable', '심사 중인 요청만 거절할 수 있습니다'); deps.store.run('UPDATE ownership_transfers SET status=\'rejected\', reviewed_by=?, declined_reason=?, updated_at=? WHERE id=?', by, reason || null, nowIso(), id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: t.from_group_id, subjectType: 'transfer', subjectId: id, action: 'ownership.reject', detail: { reason } }); for (const g of [t.from_group_id, t.to_group_id].filter(Boolean)) notify(deps.store, { groupId: g, category: 'membership', severity: 'warning', title: '소유권 이전이 거절되었습니다', body: reason || '', link: `/transfers/${id}` }); return get(deps, id); }
/** 보내는 쪽 취소(승인 전까지) */
export function cancel(deps, id, { by, groupId }) { const t = get(deps, id); if (!t || t.from_group_id !== groupId) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); if (!['requested', 'accepted', 'on_hold', 'reviewing'].includes(t.status)) throw new HttpError(409, 'transfer_not_cancellable', `지금 상태(${t.status})에서는 취소할 수 없습니다(운영자 승인 뒤에는 취소 불가)`); deps.store.run('UPDATE ownership_transfers SET status=\'cancelled\', cancelled_by=?, updated_at=? WHERE id=?', by, nowIso(), id); audit(deps.store, { actorType: 'user', actorId: by, groupId, subjectType: 'transfer', subjectId: id, action: 'ownership.cancel' }); if (t.to_group_id) notify(deps.store, { groupId: t.to_group_id, category: 'membership', title: '소유권 이전 요청이 취소되었습니다', link: `/transfers/${id}` }); return get(deps, id); }
/** 받는 쪽 거절(심사 전까지) */
export function decline(deps, id, { by, groupId, reason }) { const t = get(deps, id); if (!t) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); if (!['requested', 'accepted', 'on_hold'].includes(t.status)) throw new HttpError(409, 'transfer_not_declinable', `지금 상태(${t.status})에서는 거절할 수 없습니다`); deps.store.run('UPDATE ownership_transfers SET status=\'declined\', declined_reason=?, updated_at=? WHERE id=?', reason || null, nowIso(), id); audit(deps.store, { actorType: 'user', actorId: by, groupId, subjectType: 'transfer', subjectId: id, action: 'ownership.decline', detail: { reason } }); notify(deps.store, { groupId: t.from_group_id, category: 'membership', severity: 'warning', title: '받는 쪽이 소유권 이전을 거절했습니다', body: reason || '', link: `/transfers/${id}` }); return get(deps, id); }
/** 분쟁 신고(양쪽 누구나): 기존 서비스 유지, 설정 변경·삭제·추가 이전 동결 */
export function dispute(deps, id, { by, groupId, note, email = null }) { const t = get(deps, id); if (!t || !([t.from_group_id, t.to_group_id].includes(groupId) || (email && t.to_email === String(email).toLowerCase()))) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); if (['cancelled', 'expired', 'declined', 'rejected'].includes(t.status)) throw new HttpError(409, 'transfer_closed', '종료된 요청에는 분쟁을 걸 수 없습니다'); deps.store.run('UPDATE ownership_transfers SET status=\'disputed\', dispute_note=?, disputed_at=?, updated_at=? WHERE id=?', String(note || '').slice(0, 1000), nowIso(), nowIso(), id); audit(deps.store, { actorType: 'user', actorId: by, groupId, subjectType: 'transfer', subjectId: id, action: 'ownership.dispute', detail: { note } }); const toUser = !t.to_group_id && t.to_email ? deps.store.get('SELECT id FROM users WHERE email=?', t.to_email) : null; for (const g of [t.from_group_id, t.to_group_id || (toUser ? { userId: toUser.id } : null)].filter(Boolean)) notify(deps.store, { ...(typeof g === 'string' ? { groupId: g } : { groupId: null, userId: g.userId }), category: 'membership', severity: 'critical', mandatory: true, incidentKey: `transfer:${id}:dispute:${typeof g === 'string' ? g : g.userId}`, title: '소유권 이전이 분쟁 보류 상태입니다', body: '기존 서비스는 유지되며 설정 변경·삭제·추가 이전은 동결됩니다. 문의: 운영자에게 알림 센터 또는 지원 창구로 연락하세요', link: `/transfers/${id}` }); notify(deps.store, { groupId: null, category: 'ops', severity: 'critical', incidentKey: `ops:transfer:${id}:dispute`, title: `[운영] 소유권 분쟁 — ${id}`, body: note || '', link: `/ops/transfer/${id}` }); return get(deps, id); }
/** 운영자 분쟁 해소: resume(심사 계속) | cancel */
export function resolveDispute(deps, id, { by, outcome, note }) { const t = get(deps, id); if (!t || t.status !== 'disputed') throw new HttpError(409, 'not_disputed', '분쟁 상태가 아닙니다'); const next = outcome === 'resume' ? (t.completed_at ? 'completed' : t.to_group_id ? 'reviewing' : 'requested') : 'cancelled'; deps.store.run('UPDATE ownership_transfers SET status=?, dispute_note=?, updated_at=? WHERE id=?', next, note || t.dispute_note, nowIso(), id); audit(deps.store, { actorType: 'operator', actorId: by, groupId: t.from_group_id, subjectType: 'transfer', subjectId: id, action: 'ownership.dispute_resolve', detail: { outcome, note } }); for (const g of [t.from_group_id, t.to_group_id].filter(Boolean)) { resolveIncident(deps.store, `transfer:${id}:dispute:${g}`, '해소'); notify(deps.store, { groupId: g, category: 'membership', title: '소유권 분쟁이 해소되었습니다', body: `결과: ${outcome === 'resume' ? '절차 계속' : '이전 취소'} ${note || ''}`, link: `/transfers/${id}` }); } return get(deps, id); }
export function expire(deps, id) { deps.store.run('UPDATE ownership_transfers SET status=\'expired\', updated_at=? WHERE id=? AND status IN (\'requested\',\'accepted\',\'on_hold\')', nowIso(), id); const t = get(deps, id); if (t && t.status === 'expired') notify(deps.store, { groupId: t.from_group_id, category: 'membership', title: '소유권 이전 요청이 만료되었습니다', link: `/transfers/${id}` }); return t; }
/** 주기 작업: 만료 처리 */
export function runDue(deps, now = nowIso()) { const rows = deps.store.all('SELECT id FROM ownership_transfers WHERE status IN (\'requested\',\'accepted\',\'on_hold\') AND expires_at<=?', now); for (const r of rows) expire(deps, r.id); return rows.map((r) => r.id); }
/** 화면용 상세(보는 쪽에 따라 가능한 행동) */
export function view(deps, t, groupId) {
const sender = t.from_group_id === groupId; const receiver = t.to_group_id === groupId;
const fromGroup = deps.store.get('SELECT name FROM groups WHERE id=?', t.from_group_id); const toGroup = t.to_group_id ? deps.store.get('SELECT name FROM groups WHERE id=?', t.to_group_id) : null;
let scope = null; try { scope = t.status === 'completed' ? null : scopeOf(deps, t.from_group_id, { siteIds: t.scope.siteIds || [], mailServiceIds: t.scope.mailServiceIds || [] }); } catch (_) { scope = null; }
return { ...t, role: sender ? 'sender' : receiver ? 'receiver' : 'other', fromGroupName: fromGroup ? fromGroup.name : '', toGroupName: toGroup ? toGroup.name : (t.to_email || ''), scopeDetail: scope, excluded: EXCLUDED,
canCancel: sender && ['requested', 'accepted', 'on_hold', 'reviewing'].includes(t.status), cancelDeadline: '운영자 승인 전까지', canAccept: !sender && t.status === 'requested', canDecline: !sender && ['requested', 'accepted', 'on_hold'].includes(t.status), declineDeadline: '운영자 심사 시작 전까지', canDispute: (sender || receiver) && !['cancelled', 'expired', 'declined', 'rejected', 'disputed'].includes(t.status), canRecheck: receiver && t.status === 'on_hold',
billing: t.completed_at ? { senderEndsAt: t.billing_cutover_at, receiverStartsAt: t.billing_cutover_at, note: '다음 청구부터 새 소유관계로 반영됩니다' } : { note: '완료 시각부터 보내는 쪽 청구 책임이 끝나고 받는 쪽이 시작됩니다' } };
}