관리형 사이트 백업·복원, 서버 이전, 소유권 이전, 중지·해지·보존 생명주기와 운영 사이트 수 반환 (§14·§19~§23)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:43:21 +00:00
co-authored by Claude Fable 5.1
parent c397452acd
commit a014e3cd05
24 changed files with 1674 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
/**
* portal/transfer/api.mjs — 소유권 이전 화면·API(§22)와 운영자 내부 API.
* GET /transfers · /transfers/new · /transfers/:id
* GET /api/transfer 목록(보낸·받은) POST /api/transfer { toEmail|toGroupId, siteIds[], mailServiceIds[], confirm:true }
* POST /api/transfer/preview 범위 미리보기 GET /api/transfer/:id
* POST /api/transfer/:id/accept {token?} · /decline {reason} · /cancel · /dispute {note} · /recheck
* 운영자: GET /internal/ops/transfer · GET /internal/ops/transfer/:id · POST /internal/ops/transfer/:id/approve {operator, review:{ownershipProof, domainChecks}, confirm:{fromGroupId,toGroupId}} · /reject · /resolve-dispute {outcome}
*/
import { HttpError, esc } from '../../lib/http.mjs';
import { requireGroup, requireRole, requireUser } from '../auth/guard.mjs';
import * as svc from './service.mjs';
import { opsGuard, operatorOf, requireConfirm } from '../lifecycle/common.mjs';
export async function register(app, deps) {
const { page, t } = deps;
const shell = (ctx, o) => page({ user: ctx.user, group: ctx.group, groups: ctx.groups || (ctx.group ? [ctx.group] : []), csrf: ctx.session?.csrf, unread: ctx.unread || 0, ...o });
app.static('/ui/transfer', new URL('./public', import.meta.url).pathname);
app.get('/transfers', (ctx) => { requireGroup(ctx); return shell(ctx, { title: '소유권 이전', nav: 'group', crumbs: [{ label: t('nav.group'), href: '/group' }, { label: '소유권 이전' }], actions: [{ label: '새 이전 요청', href: '/transfers/new', kind: 'primary' }], body: `<div id="tr-list">${deps.statePanel('loading')}</div>`, scripts: ['/ui/transfer/transfer.js'] }); });
app.get('/transfers/new', (ctx) => { requireRole(ctx, 'owner'); return shell(ctx, { title: '새 소유권 이전 요청', nav: 'group', crumbs: [{ label: '소유권 이전', href: '/transfers' }, { label: '새 요청' }], body: `<div id="tr-new">${deps.statePanel('loading')}</div>`, scripts: ['/ui/transfer/transfer.js'] }); });
app.get('/transfers/:id', (ctx) => { requireUser(ctx); return shell(ctx, { title: '소유권 이전 상세', nav: 'group', crumbs: [{ label: '소유권 이전', href: '/transfers' }, { label: ctx.params.id }], body: `<div id="tr-detail" data-id="${esc(ctx.params.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/transfer/transfer.js'] }); });
const visible = (ctx, id) => { requireUser(ctx); const tr = svc.get(deps, id); if (!tr) throw new HttpError(404, 'not_found', t('error.notFound')); const gid = ctx.group ? ctx.group.id : null; const byEmail = tr.to_email && ctx.user.email && ctx.user.email.toLowerCase() === tr.to_email; if (tr.from_group_id !== gid && tr.to_group_id !== gid && !byEmail && !ctx.query.token) throw new HttpError(404, 'not_found', t('error.notFound')); return tr; };
app.get('/api/transfer', (ctx) => { requireGroup(ctx); const gid = ctx.group.id; const rows = deps.store.all('SELECT * FROM ownership_transfers WHERE from_group_id=? OR to_group_id=? OR to_email=? ORDER BY created_at DESC LIMIT 100', gid, gid, (ctx.user.email || '').toLowerCase()); return { ok: true, transfers: rows.map((r) => svc.view(deps, svc.get(deps, r.id), gid)), excluded: svc.EXCLUDED }; });
app.get('/api/transfer/candidates', (ctx) => { requireRole(ctx, 'owner'); const gid = ctx.group.id; return { ok: true, sites: deps.store.all('SELECT id, name, lifecycle, primary_domain FROM sites WHERE group_id=? AND deleted_at IS NULL ORDER BY name', gid), mailServices: deps.store.all('SELECT id, name, domain, status FROM mail_services WHERE group_id=? AND kind=\'standalone\' AND deleted_at IS NULL ORDER BY name', gid), excluded: svc.EXCLUDED }; });
app.post('/api/transfer/preview', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); const scope = svc.scopeOf(deps, ctx.group.id, { siteIds: b.siteIds || [], mailServiceIds: b.mailServiceIds || [] }); return { ok: true, scope }; });
app.post('/api/transfer', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); const r = svc.request(deps, { fromGroup: ctx.group, by: ctx.user.id, toEmail: b.toEmail || null, toGroupId: b.toGroupId || null, siteIds: b.siteIds || [], mailServiceIds: b.mailServiceIds || [] }); return { ok: true, transfer: svc.view(deps, r.transfer, ctx.group.id) }; });
app.get('/api/transfer/:id', (ctx) => { const tr = visible(ctx, ctx.params.id); return { ok: true, transfer: svc.view(deps, tr, ctx.group ? ctx.group.id : null) }; });
app.post('/api/transfer/:id/accept', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); const tr = svc.accept(deps, ctx.params.id, { user: ctx.user, group: ctx.group, token: b.token || null }); return { ok: true, transfer: svc.view(deps, tr, ctx.group.id) }; });
app.post('/api/transfer/:id/recheck', (ctx) => { requireRole(ctx, 'owner'); const tr = svc.get(deps, ctx.params.id); if (!tr || tr.to_group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); return { ok: true, transfer: svc.view(deps, svc.recheck(deps, tr.id), ctx.group.id) }; });
app.post('/api/transfer/:id/decline', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); const tr = visible(ctx, ctx.params.id); if (tr.from_group_id === ctx.group.id) throw new HttpError(403, 'forbidden', '보내는 쪽은 취소를 사용하세요'); return { ok: true, transfer: svc.view(deps, svc.decline(deps, tr.id, { by: ctx.user.id, groupId: ctx.group.id, reason: b.reason }), ctx.group.id) }; });
app.post('/api/transfer/:id/cancel', (ctx) => { requireRole(ctx, 'owner'); return { ok: true, transfer: svc.view(deps, svc.cancel(deps, ctx.params.id, { by: ctx.user.id, groupId: ctx.group.id }), ctx.group.id) }; });
app.post('/api/transfer/:id/dispute', async (ctx) => { requireRole(ctx, 'owner'); const b = await ctx.body(); requireConfirm(b); return { ok: true, transfer: svc.view(deps, svc.dispute(deps, ctx.params.id, { by: ctx.user.id, groupId: ctx.group.id, note: b.note, email: ctx.user.email }), ctx.group.id) }; });
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
app.get('/internal/ops/transfer', ops((ctx) => ({ ok: true, transfers: deps.store.all(`SELECT id FROM ownership_transfers ${ctx.query.status ? 'WHERE status=?' : ''} ORDER BY created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => svc.view(deps, svc.get(deps, r.id), null)) })));
app.get('/internal/ops/transfer/:id', ops((ctx) => { const tr = svc.get(deps, ctx.params.id); if (!tr) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); return { ok: true, transfer: svc.view(deps, tr, null), finalConfirmation: { fromGroupId: tr.from_group_id, toGroupId: tr.to_group_id, siteIds: tr.scope.siteIds, mailServiceIds: tr.scope.mailServiceIds }, domains: (tr.scope.siteIds || []).flatMap((s) => deps.store.all('SELECT name, ownership_state, dns_state, cert_state FROM domains WHERE site_id=?', s)) }; }));
app.post('/internal/ops/transfer/:id/approve', ops(async (ctx) => { const b = await ctx.body(); const out = svc.approve(deps, ctx.params.id, { by: operatorOf(b), review: b.review || {}, confirm: b.confirm || {} }); return { ok: true, transfer: out.transfer, created: out.created }; }));
app.post('/internal/ops/transfer/:id/reject', ops(async (ctx) => { const b = await ctx.body(); return { ok: true, transfer: svc.reject(deps, ctx.params.id, { by: operatorOf(b), reason: b.reason }) }; }));
app.post('/internal/ops/transfer/:id/resolve-dispute', ops(async (ctx) => { const b = await ctx.body(); return { ok: true, transfer: svc.resolveDispute(deps, ctx.params.id, { by: operatorOf(b), outcome: b.outcome === 'resume' ? 'resume' : 'cancel', note: b.note }) }; }));
app.post('/internal/ops/transfer/run-due', ops(() => ({ ok: true, expired: svc.runDue(deps) })));
}
+13
View File
@@ -0,0 +1,13 @@
/**
* portal/transfer/model.mjs — 소유권 이전 영역 추가 열.
* ownership_transfers.hold_json 받는 쪽 권리 부족 내역(필요 상품 안내 §22)
* ownership_transfers.completion_json 완료 시 옮긴 대상 요약(감사·화면)
* ownership_transfers.cancelled_by / declined_reason
*/
export const MIGRATIONS = [{ id: 'transfer_0001', sql: `
ALTER TABLE ownership_transfers ADD COLUMN hold_json TEXT;
ALTER TABLE ownership_transfers ADD COLUMN completion_json TEXT;
ALTER TABLE ownership_transfers ADD COLUMN cancelled_by TEXT;
ALTER TABLE ownership_transfers ADD COLUMN declined_reason TEXT;
ALTER TABLE ownership_transfers ADD COLUMN disputed_at TEXT;
` }];
+25
View File
@@ -0,0 +1,25 @@
/* portal/transfer/public/transfer.js — 소유권 이전 화면(§22): 목록·새 요청(범위 미리보기)·상세(수락·거절·취소·분쟁) */
(() => {
const { api, esc, state, toast, confirmDanger, fmt } = window.aw;
const ST = { requested: ['waiting', '수락 대기'], accepted: ['waiting', '권리 확인'], on_hold: ['warning', '보류(권리 부족)'], reviewing: ['waiting', '운영자 심사'], approved: ['running', '승인됨'], completed: ['done', '완료'], rejected: ['failed', '거절(운영자)'], declined: ['failed', '거절(받는 쪽)'], cancelled: ['neutral', '취소'], expired: ['neutral', '만료'], disputed: ['failed', '분쟁 보류'] };
const badge = (k) => { const [c, l] = ST[k] || ['neutral', k]; return `<span class="badge ${c}">${esc(l)}</span>`; };
const scopeHtml = (sc, ex) => !sc ? '' : `<h3>이전 대상</h3>${sc.sites.map((s) => `<div class="card"><b>${esc(s.name)}</b> <span class="sub">${esc(s.lifecycle)}${s.siteCount === 'in_use' ? ' · 운영 사이트 수 사용 중' : ''}</span><dl class="kv"><dt>도메인</dt><dd>${s.domains.map((d) => esc(d.name) + (d.is_primary ? '(대표)' : '')).join(', ') || '없음'}</dd><dt>사이트 대표 메일</dt><dd>${s.siteMail.map((m) => esc(m.domain)).join(', ') || '없음'}</dd><dt>백업</dt><dd>${s.backup ? `옵션 ${esc(s.backup.status)} · 백업 ${s.backup.backups}개` : '없음'}</dd><dt>배정 용량</dt><dd>${s.allocations.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') || '없음'}</dd><dt>진행 작업</dt><dd>${s.inProgress.length ? s.inProgress.map((c) => esc(c.type + ' ' + c.status)).join(', ') : '없음'}</dd><dt>미납</dt><dd>${s.unpaid.length ? s.unpaid.map((o) => `${esc(o.order_no)} ${o.total}`).join(', ') : '없음'}</dd></dl></div>`).join('')}${sc.mailServices.map((m) => `<div class="card"><b>${esc(m.name)}</b> (독립 메일 ${esc(m.domain)}) <span class="sub">메일함 ${m.mailboxes}개 · 배정 ${m.allocations.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') || '없음'}</span></div>`).join('')}<p class="sub"><b>이전하지 않는 항목:</b> ${ex.map(esc).join(', ')}</p>`;
const list = document.getElementById('tr-list');
if (list) (async () => { try { const d = await api('GET', '/api/transfer'); if (!d.transfers.length) return state(list, 'empty', '소유권 이전 요청이 없습니다'); list.innerHTML = `<table class="r"><thead><tr><th>상태</th><th>방향</th><th>상대</th><th>대상</th><th>요청</th><th>만료</th></tr></thead><tbody>${d.transfers.map((x) => `<tr><td data-l="상태">${badge(x.status)}</td><td data-l="방향">${x.role === 'sender' ? '보냄' : '받음'}</td><td data-l="상대">${esc(x.role === 'sender' ? x.toGroupName : x.fromGroupName)}</td><td data-l="대상"><a href="/transfers/${x.id}">사이트 ${(x.scope.siteIds || []).length} · 메일 ${(x.scope.mailServiceIds || []).length}</a></td><td data-l="요청">${esc(fmt(x.created_at))}</td><td data-l="만료">${esc(fmt(x.expires_at))}</td></tr>`).join('')}</tbody></table>`; } catch (e) { state(list, e.status === 403 ? 'forbidden' : 'failed', e.message); } })();
const nw = document.getElementById('tr-new');
if (nw) (async () => { try { const d = await api('GET', '/api/transfer/candidates'); nw.innerHTML = `<form class="card" id="tr-form" data-dirty-guard><h2>대상 선택</h2>${d.sites.length ? d.sites.map((s) => `<label class="f"><span><input type="checkbox" name="site" value="${esc(s.id)}"> ${esc(s.name)} <span class="sub">${esc(s.primary_domain || '')} · ${esc(s.lifecycle)}</span></span></label>`).join('') : '<p class="muted">사이트가 없습니다</p>'}<h3>독립 메일 서비스(명시적으로 고른 것만 포함)</h3>${d.mailServices.length ? d.mailServices.map((m) => `<label class="f"><span><input type="checkbox" name="mail" value="${esc(m.id)}"> ${esc(m.name)} (${esc(m.domain)})</span></label>`).join('') : '<p class="muted">없음</p>'}<div class="f"><label for="to">받는 사람 이메일</label><input id="to" name="toEmail" type="email" required placeholder="receiver@example.com"><span class="hint">받는 사람이 가입 사용자이면 알림으로, 아니면 이메일로 수락 링크가 전달됩니다. 요청은 7일 뒤 만료되며 운영자 승인 전까지 취소할 수 있습니다</span></div><p class="sub"><b>이전하지 않는 항목:</b> ${d.excluded.map(esc).join(', ')}</p><div id="tr-preview"></div><div class="actions"><button type="button" class="btn" id="tr-prev">범위 미리보기</button><button type="submit" class="btn primary">이전 요청</button></div></form>`;
const sel = () => ({ siteIds: [...nw.querySelectorAll('input[name=site]:checked')].map((i) => i.value), mailServiceIds: [...nw.querySelectorAll('input[name=mail]:checked')].map((i) => i.value) });
document.getElementById('tr-prev').onclick = async () => { try { const r = await api('POST', '/api/transfer/preview', sel()); document.getElementById('tr-preview').innerHTML = scopeHtml(r.scope, d.excluded) + (r.scope.blocking.length ? `<p class="err">진행 중 작업이 끝나야 요청할 수 있습니다: ${r.scope.blocking.map((b) => esc(b.subject + ' ' + b.type)).join(', ')}</p>` : ''); } catch (e) { toast(e.message); } };
document.getElementById('tr-form').onsubmit = async (ev) => { ev.preventDefault(); const s = sel(); const to = document.getElementById('to').value.trim(); if (!(await confirmDanger({ title: '소유권 이전 요청', target: `${to} 에게 사이트 ${s.siteIds.length}개 · 메일 ${s.mailServiceIds.length}개`, impacts: ['받는 쪽 수락과 운영자 심사 뒤 한 번에 이전됩니다', '완료 시각부터 청구 책임이 넘어갑니다', '이전 뒤 우리 그룹 구성원은 접근할 수 없습니다'], typeToConfirm: '이전', confirmLabel: '요청' }))) return; try { const r = await api('POST', '/api/transfer', { ...s, toEmail: to, confirm: true }); window.aw.markClean(); location.href = `/transfers/${r.transfer.id}`; } catch (e) { toast(e.message); } };
} catch (e) { state(nw, e.status === 403 ? 'forbidden' : 'failed', e.message); } })();
const det = document.getElementById('tr-detail');
if (det) { const id = det.dataset.id; const token = new URLSearchParams(location.search).get('token'); const render = async () => { try { const d = await api('GET', `/api/transfer/${id}${token ? '?token=' + encodeURIComponent(token) : ''}`); const x = d.transfer;
det.innerHTML = `<div class="card"><h2>${badge(x.status)} ${esc(x.fromGroupName)} → ${esc(x.toGroupName)}</h2><dl class="kv"><dt>요청</dt><dd>${esc(fmt(x.created_at))}</dd><dt>만료</dt><dd>${esc(fmt(x.expires_at))}</dd><dt>취소 가능</dt><dd>${esc(x.cancelDeadline)} (보내는 쪽) · ${esc(x.declineDeadline)} (받는 쪽)</dd><dt>청구</dt><dd>${x.billing.senderEndsAt ? `종료/시작 ${esc(fmt(x.billing.senderEndsAt))} · ` : ''}${esc(x.billing.note)}</dd>${x.hold ? `<dt>보류 사유</dt><dd class="err">${x.hold.map((h) => `${esc(h.label)} ${h.shortage} 부족 — ${esc(h.product)}`).join('<br>')}</dd>` : ''}${x.status === 'disputed' ? `<dt>분쟁</dt><dd class="err">기존 서비스 유지 · 설정 변경·삭제·추가 이전 동결 · 문의: 운영자 지원 창구 ${esc(x.dispute_note || '')}</dd>` : ''}${x.completion ? `<dt>완료</dt><dd>사이트 ${x.completion.moved.sites} · 도메인 ${x.completion.moved.domains} · 메일 ${x.completion.moved.mailServices} · 배정 ${x.completion.moved.allocations}</dd>` : ''}</dl>
${scopeHtml(x.scopeDetail, x.excluded)}
<div class="actions">${x.canAccept ? '<button class="btn primary" id="t-acc">수락(현재 그룹으로 받기)</button>' : ''}${x.canRecheck ? '<button class="btn" id="t-re">권리 다시 확인</button>' : ''}${x.canDecline ? '<button class="btn" id="t-dec">거절</button>' : ''}${x.canCancel ? '<button class="btn danger" id="t-can">요청 취소</button>' : ''}${x.canDispute ? '<button class="btn danger" id="t-dis">분쟁 신고</button>' : ''}</div></div>`;
const q = (i) => document.getElementById(i); const act = async (p, body) => { try { await api('POST', `/api/transfer/${id}/${p}`, body); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } };
if (q('t-acc')) q('t-acc').onclick = async () => { if (await confirmDanger({ title: '소유권 이전 수락', target: '현재 사용자 그룹으로 받습니다', impacts: ['우리 그룹의 상품 권리로 대상을 받습니다(부족하면 보류)', '운영자 심사 뒤 완료 시각부터 청구 책임이 시작됩니다'], confirmLabel: '수락' })) act('accept', { confirm: true, token }); };
if (q('t-re')) q('t-re').onclick = () => act('recheck'); if (q('t-dec')) q('t-dec').onclick = () => act('decline', { reason: '' }); if (q('t-can')) q('t-can').onclick = async () => { if (await confirmDanger({ title: '요청 취소', target: '이 이전 요청', impacts: ['받는 쪽에 취소가 알려집니다'], confirmLabel: '취소' })) act('cancel'); };
if (q('t-dis')) q('t-dis').onclick = async () => { if (await confirmDanger({ title: '분쟁 신고', target: '이 이전 요청', impacts: ['기존 서비스는 유지됩니다', '설정 변경·삭제·추가 이전이 동결됩니다', '운영자가 확인할 때까지 보류됩니다'], typeToConfirm: '분쟁', confirmLabel: '신고' })) act('dispute', { confirm: true, note: '' }); };
} catch (e) { state(det, e.status === 403 ? 'forbidden' : e.status === 404 ? 'empty' : 'failed', e.message); } }; render(); }
})();
+146
View File
@@ -0,0 +1,146 @@
/**
* 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: '완료 시각부터 보내는 쪽 청구 책임이 끝나고 받는 쪽이 시작됩니다' } };
}