Files
AriWeb-Suite/portal/lifecycle/api.mjs

48 lines
8.1 KiB
JavaScript

/**
* portal/lifecycle/api.mjs — 생명주기(§23)·사이트 수 반환(§14) 화면·API, 운영자 내부 API, 주기 작업 시작.
* GET /sites/:id/lifecycle · GET /mail/:id/lifecycle
* GET /api/lifecycle/sites/:id §23.3 한 화면 정보 + 중지/해지 영향
* POST /api/lifecycle/sites/:id/pause|resume|cancel|undo-cancel|export
* GET /api/lifecycle/sites/:id/site-count-return?mailChoice= §14 체크리스트 POST … { mailChoice, acknowledge, confirm }
* GET /api/lifecycle/mail/:id · POST /api/lifecycle/mail/:id/cancel { confirm, acknowledge }
* 운영자: GET /internal/ops/lifecycle?state= · GET /internal/ops/lifecycle/sites/:id · POST /internal/ops/lifecycle/sites/:id/hold {reason} · /release-hold · POST /internal/ops/lifecycle/run-due
* 주기 작업: 1분마다 backup·transfer·lifecycle 의 runDue (테스트 환경 NODE_ENV=test 에서는 켜지 않는다).
*/
import { HttpError, esc } from '../../lib/http.mjs';
import { requireGroup } from '../auth/guard.mjs';
import * as svc from './service.mjs';
import * as backup from '../backup/service.mjs';
import * as transfer from '../transfer/service.mjs';
import { getSite, getMailService, opsGuard, operatorOf, jobView, requireConfirm } from './common.mjs';
import { safeError } from '../../lib/redact.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/lifecycle', new URL('./public', import.meta.url).pathname);
app.get('/sites/:id/lifecycle', (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="lc-site" data-site="${esc(site.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/lifecycle/lifecycle.js'] }); });
app.get('/mail/:id/lifecycle', (ctx) => { const ms = getMailService(deps, ctx, ctx.params.id, 'view'); return shell(ctx, { title: `${ms.name} · 생명주기`, nav: 'mail', crumbs: [{ label: t('nav.mail'), href: '/mail' }, { label: ms.name, href: `/mail/${ms.id}` }, { label: '생명주기' }], body: `<div id="lc-mail" data-id="${esc(ms.id)}">${deps.statePanel('loading')}</div>`, scripts: ['/ui/lifecycle/lifecycle.js'] }); });
app.get('/api/lifecycle/sites/:id', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return { ok: true, view: svc.lifecycleView(deps, site), pauseImpact: svc.pauseImpact(deps, site), cancelImpact: svc.cancelImpact(deps, site), siteCount: await svc.siteCountChecklist(deps, site), canManage: ['owner', 'admin', 'billing'].includes(ctx.membership.role) }; });
app.post('/api/lifecycle/sites/:id/pause', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'admin'); requireConfirm(await ctx.body()); const r = svc.pauseSite(deps, site, { by: ctx.user.id }); return { ok: true, site: r.site, job: jobView(deps, r.job.id) }; });
app.post('/api/lifecycle/sites/:id/resume', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'admin'); const r = svc.resumeSite(deps, site, { by: ctx.user.id }); return { ok: true, site: r.site, job: jobView(deps, r.job.id), backupNeedsConfirm: r.backupNeedsConfirm }; });
app.post('/api/lifecycle/sites/:id/cancel', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const b = await ctx.body(); requireConfirm(b); const r = await svc.scheduleCancel(deps, site, { by: ctx.user.id, at: b.at || null, mailChoice: b.mailChoice || null, acknowledge: !!b.acknowledge }); return { ok: true, site: r.site, executed: r.executed, view: svc.lifecycleView(deps, r.site) }; });
app.post('/api/lifecycle/sites/:id/undo-cancel', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); return { ok: true, site: svc.undoCancel(deps, site, { by: ctx.user.id }) }; });
app.post('/api/lifecycle/sites/:id/export', (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return { ok: true, ...svc.requestExport(deps, site, { by: ctx.user.id }) }; });
app.get('/api/lifecycle/sites/:id/site-count-return', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'view'); return { ok: true, checklist: await svc.siteCountChecklist(deps, site, { mailChoice: ctx.query.mailChoice || null, acknowledge: ctx.query.acknowledge === '1' }) }; });
app.post('/api/lifecycle/sites/:id/site-count-return', async (ctx) => { const site = getSite(deps, ctx, ctx.params.id, 'billing'); const b = await ctx.body(); return { ok: true, ...(await svc.returnSiteCount(deps, site, { by: ctx.user.id, mailChoice: b.mailChoice || null, acknowledge: !!b.acknowledge, confirm: b.confirm === true })) }; });
app.get('/api/lifecycle/mail/:id', (ctx) => { const ms = getMailService(deps, ctx, ctx.params.id, 'view'); return { ok: true, view: svc.mailLifecycleView(deps, ms) }; });
app.post('/api/lifecycle/mail/:id/cancel', async (ctx) => { const ms = getMailService(deps, ctx, ctx.params.id, 'billing'); const b = await ctx.body(); requireConfirm(b); if (!b.acknowledge) throw new HttpError(400, 'acknowledge_required', '보존 종료일과 삭제 예정일을 확인했다는 표시가 필요합니다'); const m = await svc.endMail(deps, ms, ctx.user.id); return { ok: true, mailService: m, view: svc.mailLifecycleView(deps, deps.store.get('SELECT * FROM mail_services WHERE id=?', ms.id)) }; });
const ops = (fn) => async (ctx) => { await opsGuard(ctx, deps); return fn(ctx); };
app.get('/internal/ops/lifecycle', ops((ctx) => ({ ok: true, sites: deps.store.all(`SELECT s.id, s.name, s.group_id, g.name AS group_name, s.lifecycle, s.cancel_at, s.retention_until, s.delete_after, s.next_notice_at, s.delete_hold_reason, s.site_count_state FROM sites s JOIN groups g ON g.id=s.group_id ${ctx.query.state ? 'WHERE s.lifecycle=?' : 'WHERE s.lifecycle<>\'live\''} ORDER BY s.updated_at DESC LIMIT 500`, ...(ctx.query.state ? [ctx.query.state] : [])), mail: deps.store.all('SELECT id, name, group_id, domain, lifecycle, cancelled_at, retention_until, delete_after FROM mail_services WHERE lifecycle<>\'active\' ORDER BY updated_at DESC LIMIT 500'), retentionPolicy: svc.retentionPolicy(deps) })));
app.get('/internal/ops/lifecycle/sites/:id', ops(async (ctx) => { const site = deps.store.get('SELECT * FROM sites WHERE id=?', ctx.params.id); if (!site) throw new HttpError(404, 'not_found', '사이트가 없습니다'); return { ok: true, view: svc.lifecycleView(deps, site), siteCount: await svc.siteCountChecklist(deps, site), jobs: deps.jobs.list({ subjectType: 'site', subjectId: site.id, limit: 20 }).map((j) => jobView(deps, j.id)) }; }));
app.post('/internal/ops/lifecycle/sites/:id/hold', ops(async (ctx) => { const b = await ctx.body(); const site = deps.store.get('SELECT * FROM sites WHERE id=?', ctx.params.id); if (!site) throw new HttpError(404, 'not_found', '사이트가 없습니다'); return { ok: true, site: svc.setDeleteHold(deps, site, { by: operatorOf(b), reason: b.reason }) }; }));
app.post('/internal/ops/lifecycle/sites/:id/release-hold', ops(async (ctx) => { const b = await ctx.body(); const site = deps.store.get('SELECT * FROM sites WHERE id=?', ctx.params.id); if (!site) throw new HttpError(404, 'not_found', '사이트가 없습니다'); return { ok: true, site: svc.releaseDeleteHold(deps, site, { by: operatorOf(b) }) }; }));
app.post('/internal/ops/lifecycle/run-due', ops(async () => ({ ok: true, lifecycle: await svc.runDue(deps), backup: backup.runDue(deps), transfer: transfer.runDue(deps) })));
// 주기 작업(운영): 1분마다. 각 runDue 는 멱등이라 겹쳐 실행돼도 안전하다.
if (process.env.NODE_ENV !== 'test' && !deps.__lifecycleTimer) { const tick = async () => { try { await svc.runDue(deps); backup.runDue(deps); transfer.runDue(deps); } catch (e) { deps.log('[lifecycle] 주기 작업 오류', safeError(e)); } }; deps.__lifecycleTimer = setInterval(tick, 60000); deps.__lifecycleTimer.unref(); setTimeout(tick, 5000).unref(); }
}