Files

37 lines
8.2 KiB
JavaScript

/**
* portal/relocation/api.mjs — 서버 이전 화면·API(§21)와 운영자 내부 API(§30.4).
* GET /sites/:id/relocation 이전 화면(종류·경계·진행·되돌리기)
* GET /api/relocation/sites/:id 현황·자동 이전 조건·경계값
* POST /api/relocation/sites/:id { toServerId?, reason, confirm:true } 고객 요청(유료)
* GET /api/relocation/:id 상세(작업 포함) POST /api/relocation/:id/cancel POST /api/relocation/:id/revert {confirm:true}
* 운영자: GET /internal/ops/relocation · GET /internal/ops/relocation/:id · POST /internal/ops/relocation {subjectType, subjectId, toServerId, kind, reason, operator}
* POST /internal/ops/relocation/:id/approve {operator, scheduledAt, confirm:{subjectId,toServerId,paymentConfirmed}} · /reject · /retry · /revert
*/
import { HttpError, esc } from '../../lib/http.mjs';
import { requireGroup } from '../auth/guard.mjs';
import * as svc from './service.mjs';
import { getSite, opsGuard, operatorOf, jobView, requireConfirm, nowIso, toJson } from '../lifecycle/common.mjs';
export async function register(app, deps) {
svc.registerJobs(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/relocation', new URL('./public', import.meta.url).pathname);
app.get('/sites/:id/relocation', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${site.name} · 서버 이전`, nav: 'sites', crumbs: [{ label: t('nav.sites'), href: '/sites' }, { label: site.name, href: `/sites/${site.id}` }, { label: '서버 이전' }], body: `<div id="rl-site" data-site="${esc(site.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/relocation/relocation.js'] }); });
const mine = (ctx, id) => { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', id); if (!r) throw new HttpError(404, 'not_found', t('error.notFound')); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'view'); else { requireGroup(ctx); if (r.group_id !== ctx.group.id) throw new HttpError(404, 'not_found', t('error.notFound')); } return r; };
app.get('/api/relocation/sites/:id', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); const subject = { type: 'site', id: site.id, site, serverId: site.server_id }; const list = deps.store.all('SELECT * FROM relocations WHERE subject_type=\'site\' AND subject_id=? ORDER BY created_at DESC LIMIT 20', site.id).map((r) => ({ ...svc.view(deps, r), job: jobView(deps, r.job_id) })); return { ok: true, managed: site.deploy_mode === 'managed', runtime: svc.runtimeOf(site), auto: svc.autoEligible(deps, subject), boundaries: { customer: svc.boundaries(deps, subject, 'customer_paid'), platform: svc.boundaries(deps, subject, svc.runtimeOf(site) === 'static' ? 'auto_static' : 'approved') }, relocations: list, stepTitles: svc.STEP_TITLES, currentLocation: site.server_id ? '관리형 서버(내부 위치는 표시하지 않습니다)' : '미배치' }; });
app.post('/api/relocation/sites/:id', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'admin'); const body = await ctx.body(); requireConfirm(body); const r = await svc.request(deps, { subjectType: 'site', subjectId: site.id, toServerId: body.toServerId || null, kind: 'customer_paid', reason: String(body.reason || '고객 요청'), origin: 'customer', by: ctx.user.id }); return { ok: true, relocation: svc.view(deps, r.relocation), created: r.created }; });
app.get('/api/relocation/:id', (ctx) => { const r = mine(ctx, ctx.params.id); return { ok: true, relocation: svc.view(deps, r), job: jobView(deps, r.job_id) }; });
app.post('/api/relocation/:id/cancel', async (ctx) => { const r = mine(ctx, ctx.params.id); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'admin'); return { ok: true, relocation: await svc.cancel(deps, r.id, ctx.user.id) }; });
app.post('/api/relocation/:id/revert', async (ctx) => { const r = mine(ctx, ctx.params.id); if (r.subject_type === 'site') getSite(deps, ctx, r.subject_id, 'admin'); const body = await ctx.body(); requireConfirm(body); if (!r.job_id) throw new HttpError(409, 'no_job', '되돌릴 작업이 없습니다'); deps.store.run('UPDATE jobs SET subject_json=? WHERE id=?', toJson({ ...deps.jobs.get(r.job_id).subject, by: ctx.user.id }), r.job_id); const j = await deps.jobs.revert(r.job_id, ctx.user.id); return { ok: true, job: jobView(deps, j.id), relocation: deps.store.get('SELECT * FROM relocations WHERE id=?', r.id) }; });
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
app.get('/internal/ops/relocation', ops((ctx) => ({ ok: true, relocations: deps.store.all(`SELECT r.*, g.name AS group_name FROM relocations r JOIN groups g ON g.id=r.group_id ${ctx.query.status ? 'WHERE r.status=?' : ''} ORDER BY r.created_at DESC LIMIT 300`, ...(ctx.query.status ? [ctx.query.status] : [])).map((r) => ({ ...svc.view(deps, r), job: jobView(deps, r.job_id) })) })));
app.get('/internal/ops/relocation/:id', ops((ctx) => { const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r) throw new HttpError(404, 'not_found', '이전 요청이 없습니다'); const subject = r.subject_type === 'site' ? deps.store.get('SELECT id, name, primary_domain, server_id, lifecycle FROM sites WHERE id=?', r.subject_id) : deps.store.get('SELECT id, name, domain, arimail_server_id FROM mail_services WHERE id=?', r.subject_id); return { ok: true, relocation: svc.view(deps, r), job: jobView(deps, r.job_id), subject, children: r.job_id ? deps.jobs.childrenSummary(r.job_id) : null, finalConfirmation: { subjectId: r.subject_id, fromServerId: r.from_server_id, toServerId: r.to_server_id, cost: r.cost, charged: !!r.charged } }; }));
app.post('/internal/ops/relocation', ops(async (ctx) => { const body = await ctx.body(); const r = await svc.request(deps, { subjectType: body.subjectType || 'site', subjectId: body.subjectId, toServerId: body.toServerId || null, kind: body.kind || null, reason: body.reason || '플랫폼 운영', origin: 'platform', by: operatorOf(body), scheduledAt: body.scheduledAt || null }); return { ok: true, relocation: svc.view(deps, r.relocation), created: r.created }; }));
app.post('/internal/ops/relocation/:id/approve', ops(async (ctx) => { const body = await ctx.body(); const out = svc.approve(deps, ctx.params.id, { by: operatorOf(body), scheduledAt: body.scheduledAt || null, confirm: body.confirm || {} }); return { ok: true, relocation: svc.view(deps, out.relocation), job: out.job ? jobView(deps, out.job.id) : null, created: out.created }; }));
app.post('/internal/ops/relocation/:id/reject', ops(async (ctx) => { const body = await ctx.body(); return { ok: true, relocation: svc.reject(deps, ctx.params.id, { by: operatorOf(body), reason: body.reason }) }; }));
app.post('/internal/ops/relocation/:id/retry', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE relocations SET status=\'queued\', updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; }));
app.post('/internal/ops/relocation/:id/resume', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = deps.jobs.retry(r.job_id, operatorOf(body)); deps.store.run('UPDATE relocations SET status=\'queued\', updated_at=? WHERE id=?', nowIso(), r.id); return { ok: true, job: jobView(deps, j.id) }; }));
app.post('/internal/ops/relocation/:id/revert', ops(async (ctx) => { const body = await ctx.body(); const r = deps.store.get('SELECT * FROM relocations WHERE id=?', ctx.params.id); if (!r || !r.job_id) throw new HttpError(404, 'not_found', '이전 작업이 없습니다'); const j = await deps.jobs.revert(r.job_id, operatorOf(body)); return { ok: true, job: jobView(deps, j.id) }; }));
}