Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,540 @@
import { z } from 'zod';
import { db } from '../db/client.js';
import {
createCompletedPlanExecutionLogItem,
createPlanActionHistory,
createPlanItem,
ensurePlanTable,
normalizePlanAutomationType,
planAutomationTypeSchema,
} from './plan-service.js';
import { getKstNowParts } from './worklog-automation-utils.js';
import { registerErrorLogBoardPosts } from './error-log-plan-registration-service.js';
export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks';
const scheduleModes = ['interval', 'daily'] as const;
const repeatIntervalUnits = ['minute', 'hour', 'day', 'week', 'month'] as const;
const DEFAULT_DAILY_RUN_TIME = '09:00';
export const createPlanScheduledTaskSchema = z.object({
workId: z.string().trim().optional().default('반복작업'),
note: z.string().default(''),
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
releaseTarget: z.string().trim().min(1).default('release'),
jangsingProcessingRequired: z.boolean().default(true),
autoDeployToMain: z.boolean().default(true),
enabled: z.boolean().default(true),
immediateRunEnabled: z.boolean().default(true),
scheduleMode: z.enum(scheduleModes).default('interval'),
repeatIntervalValue: z.coerce.number().int().min(1).max(525600).default(60),
repeatIntervalUnit: z.enum(repeatIntervalUnits).default('minute'),
repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(),
dailyRunTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default(DEFAULT_DAILY_RUN_TIME),
});
export const updatePlanScheduledTaskSchema = createPlanScheduledTaskSchema.partial();
function normalizeScheduledWorkId(value?: string | null) {
const workId = String(value ?? '').trim();
const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') {
return '반복작업';
}
return workId;
}
function normalizeRepeatIntervalMinutes(value: unknown) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return 60;
}
return Math.min(525600, Math.max(1, Math.round(numericValue)));
}
function normalizeRepeatIntervalValue(value: unknown) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return 60;
}
return Math.min(525600, Math.max(1, Math.round(numericValue)));
}
function normalizeRepeatIntervalUnit(value: unknown): (typeof repeatIntervalUnits)[number] {
return repeatIntervalUnits.includes(value as (typeof repeatIntervalUnits)[number])
? (value as (typeof repeatIntervalUnits)[number])
: 'minute';
}
function normalizeScheduleMode(value: unknown): (typeof scheduleModes)[number] {
return scheduleModes.includes(value as (typeof scheduleModes)[number])
? (value as (typeof scheduleModes)[number])
: 'interval';
}
function normalizeDailyRunTime(value: unknown) {
return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
? value
: DEFAULT_DAILY_RUN_TIME;
}
function toRepeatIntervalMinutes(value: unknown, unit: unknown) {
const repeatIntervalValue = normalizeRepeatIntervalValue(value);
const repeatIntervalUnit = normalizeRepeatIntervalUnit(unit);
if (repeatIntervalUnit === 'day') {
return repeatIntervalValue * 24 * 60;
}
if (repeatIntervalUnit === 'week') {
return repeatIntervalValue * 7 * 24 * 60;
}
if (repeatIntervalUnit === 'month') {
return repeatIntervalValue * 30 * 24 * 60;
}
if (repeatIntervalUnit === 'hour') {
return repeatIntervalValue * 60;
}
return repeatIntervalValue;
}
function normalizeBoolean(value: unknown, fallback: boolean) {
if (typeof value === 'boolean') {
return value;
}
if (value === 0 || value === '0' || value === 'false') {
return false;
}
if (value === 1 || value === '1' || value === 'true') {
return true;
}
return fallback;
}
function formatScheduleWorkId(workId: string, date: Date) {
const timestamp = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
String(date.getHours()).padStart(2, '0'),
String(date.getMinutes()).padStart(2, '0'),
].join('');
return `${normalizeScheduledWorkId(workId)}-${timestamp}`;
}
function getKstDateKey(value: unknown) {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(String(value));
if (Number.isNaN(date.getTime())) {
return null;
}
return getKstNowParts(date).dateKey;
}
function isDailyScheduleDue(row: Record<string, unknown>, now: Date) {
const nowParts = getKstNowParts(now);
const [hours, minutes] = normalizeDailyRunTime(row.daily_run_time).split(':').map((value) => Number(value));
const scheduledMinutesOfDay = hours * 60 + minutes;
return nowParts.minutesOfDay >= scheduledMinutesOfDay && getKstDateKey(row.last_registered_at) !== nowParts.dateKey;
}
function isIntervalScheduleDue(row: Record<string, unknown>, now: Date) {
const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null;
const intervalBaseAt = lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime())
? lastRegisteredAt
: new Date(String(row.created_at ?? now.toISOString()));
if (Number.isNaN(intervalBaseAt.getTime())) {
return true;
}
const repeatIntervalMinutes = normalizeRepeatIntervalMinutes(
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit),
);
return intervalBaseAt.getTime() <= now.getTime() - repeatIntervalMinutes * 60 * 1000;
}
function isScheduleDue(row: Record<string, unknown>, now: Date) {
if (!row.last_registered_at && normalizeBoolean(row.immediate_run_enabled, true)) {
return true;
}
return normalizeScheduleMode(row.schedule_mode) === 'daily'
? isDailyScheduleDue(row, now)
: isIntervalScheduleDue(row, now);
}
export function mapPlanScheduledTaskRow(row: Record<string, unknown>) {
return {
id: row.id,
workId: row.work_id,
note: row.note,
automationType: normalizePlanAutomationType(row.automation_type),
releaseTarget: row.release_target,
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
enabled: Boolean(row.enabled ?? true),
immediateRunEnabled: normalizeBoolean(row.immediate_run_enabled, true),
scheduleMode: normalizeScheduleMode(row.schedule_mode),
repeatIntervalValue: Number(row.repeat_interval_value ?? row.repeat_interval_minutes ?? 60),
repeatIntervalUnit: normalizeRepeatIntervalUnit(row.repeat_interval_unit),
repeatIntervalMinutes: normalizeRepeatIntervalMinutes(
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value ?? 60, row.repeat_interval_unit),
),
dailyRunTime: normalizeDailyRunTime(row.daily_run_time),
lastRegisteredAt: row.last_registered_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async function ensurePlanScheduledTaskColumn(
columnName: string,
addColumn: (table: any) => void,
) {
const hasColumn = await db.schema.hasColumn(PLAN_SCHEDULED_TASK_TABLE, columnName);
if (hasColumn) {
return;
}
await db.schema.alterTable(PLAN_SCHEDULED_TASK_TABLE, (table) => {
addColumn(table);
});
}
export async function ensurePlanScheduledTaskTable() {
const exists = await db.schema.hasTable(PLAN_SCHEDULED_TASK_TABLE);
if (!exists) {
await db.schema.createTable(PLAN_SCHEDULED_TASK_TABLE, (table) => {
table.increments('id').primary();
table.string('work_id', 120).notNullable().defaultTo('반복작업');
table.text('note').notNullable().defaultTo('');
table.string('automation_type', 40).notNullable().defaultTo('none');
table.string('release_target', 120).notNullable().defaultTo('release');
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
table.boolean('enabled').notNullable().defaultTo(true);
table.boolean('immediate_run_enabled').notNullable().defaultTo(true);
table.string('schedule_mode', 20).notNullable().defaultTo('interval');
table.integer('repeat_interval_value').notNullable().defaultTo(60);
table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute');
table.integer('repeat_interval_minutes').notNullable().defaultTo(60);
table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME);
table.timestamp('last_registered_at', { useTz: true }).nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
await ensurePlanScheduledTaskColumn('release_target', (table) => {
table.string('release_target', 120).notNullable().defaultTo('release');
});
await ensurePlanScheduledTaskColumn('automation_type', (table) => {
table.string('automation_type', 40).notNullable().defaultTo('none');
});
await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => {
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
});
await ensurePlanScheduledTaskColumn('auto_deploy_to_main', (table) => {
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
});
await ensurePlanScheduledTaskColumn('enabled', (table) => {
table.boolean('enabled').notNullable().defaultTo(true);
});
await ensurePlanScheduledTaskColumn('immediate_run_enabled', (table) => {
table.boolean('immediate_run_enabled').notNullable().defaultTo(true);
});
await ensurePlanScheduledTaskColumn('schedule_mode', (table) => {
table.string('schedule_mode', 20).notNullable().defaultTo('interval');
});
await ensurePlanScheduledTaskColumn('repeat_interval_value', (table) => {
table.integer('repeat_interval_value').notNullable().defaultTo(60);
});
await ensurePlanScheduledTaskColumn('repeat_interval_unit', (table) => {
table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute');
});
await ensurePlanScheduledTaskColumn('repeat_interval_minutes', (table) => {
table.integer('repeat_interval_minutes').notNullable().defaultTo(60);
});
await ensurePlanScheduledTaskColumn('daily_run_time', (table) => {
table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME);
});
await ensurePlanScheduledTaskColumn('last_registered_at', (table) => {
table.timestamp('last_registered_at', { useTz: true }).nullable();
});
await ensurePlanScheduledTaskColumn('created_at', (table) => {
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
await ensurePlanScheduledTaskColumn('updated_at', (table) => {
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ automation_type: 'plan_registration' })
.update({ automation_type: 'plan' });
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ automation_type: 'general_development' })
.update({ automation_type: 'auto_worker' });
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ repeat_interval_unit: 'minute' })
.update({
repeat_interval_value: db.raw('repeat_interval_minutes'),
});
}
export async function listPlanScheduledTasks() {
await ensurePlanScheduledTaskTable();
return db(PLAN_SCHEDULED_TASK_TABLE).select('*').orderBy('id', 'desc');
}
export async function getPlanScheduledTaskById(id: number) {
await ensurePlanScheduledTaskTable();
return db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first();
}
export async function createPlanScheduledTask(payload: z.infer<typeof createPlanScheduledTaskSchema>) {
await ensurePlanScheduledTaskTable();
const scheduleMode = normalizeScheduleMode(payload.scheduleMode);
const repeatIntervalValue = normalizeRepeatIntervalValue(payload.repeatIntervalValue);
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit);
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
.insert({
work_id: normalizeScheduledWorkId(payload.workId),
note: payload.note,
automation_type: normalizePlanAutomationType(payload.automationType),
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
enabled: payload.enabled,
immediate_run_enabled: payload.immediateRunEnabled,
schedule_mode: scheduleMode,
repeat_interval_value: repeatIntervalValue,
repeat_interval_unit: repeatIntervalUnit,
repeat_interval_minutes: normalizeRepeatIntervalMinutes(
payload.repeatIntervalMinutes ?? toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
),
daily_run_time: normalizeDailyRunTime(payload.dailyRunTime),
updated_at: db.fn.now(),
})
.returning('*');
return rows[0];
}
export async function updatePlanScheduledTask(id: number, payload: z.infer<typeof updatePlanScheduledTaskSchema>) {
await ensurePlanScheduledTaskTable();
const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const scheduleMode = normalizeScheduleMode(payload.scheduleMode ?? currentRow.schedule_mode);
const repeatIntervalValue = normalizeRepeatIntervalValue(
payload.repeatIntervalValue ?? currentRow.repeat_interval_value ?? currentRow.repeat_interval_minutes ?? 60,
);
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit ?? currentRow.repeat_interval_unit);
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id })
.update({
work_id: payload.workId === undefined ? currentRow.work_id : normalizeScheduledWorkId(payload.workId),
note: payload.note ?? currentRow.note,
automation_type: normalizePlanAutomationType(payload.automationType ?? currentRow.automation_type),
release_target: payload.releaseTarget ?? currentRow.release_target ?? 'release',
jangsing_processing_required:
payload.jangsingProcessingRequired ?? currentRow.jangsing_processing_required ?? true,
auto_deploy_to_main: payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true,
enabled: payload.enabled ?? currentRow.enabled ?? true,
immediate_run_enabled: payload.immediateRunEnabled ?? currentRow.immediate_run_enabled ?? true,
schedule_mode: scheduleMode,
repeat_interval_value: repeatIntervalValue,
repeat_interval_unit: repeatIntervalUnit,
repeat_interval_minutes: normalizeRepeatIntervalMinutes(
payload.repeatIntervalMinutes
?? toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit)
?? currentRow.repeat_interval_minutes
?? 60,
),
daily_run_time: normalizeDailyRunTime(payload.dailyRunTime ?? currentRow.daily_run_time),
updated_at: db.fn.now(),
})
.returning('*');
return rows[0] ?? null;
}
export async function deletePlanScheduledTask(id: number) {
await ensurePlanScheduledTaskTable();
const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).delete();
return currentRow;
}
export async function registerDuePlanScheduledTasks(now = new Date()) {
await ensurePlanTable();
await ensurePlanScheduledTaskTable();
const rows = await db(PLAN_SCHEDULED_TASK_TABLE).where({ enabled: true }).orderBy('id', 'asc');
const registered = [];
for (const row of rows.filter((item) => isScheduleDue(item, now))) {
const registration = await registerPlanScheduledTaskRow(row, now);
if (registration.createdPlan || registration.createdBoardPosts.length > 0) {
registered.push(registration);
}
}
return registered;
}
async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: Date) {
if (normalizePlanAutomationType(row.automation_type) === 'plan') {
const rangeEnd = now;
const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null;
const rangeStart =
lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime())
? lastRegisteredAt
: new Date(now.getTime() - 24 * 60 * 60 * 1000);
const registration = await registerErrorLogBoardPosts({
rangeStart,
rangeEnd,
});
const executionLogNoteLines = [
`Plan 스케줄 #${row.id} 실행 이력입니다.`,
`조회 구간: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`,
`신규 게시글 등록: ${registration.createdPosts.length}`,
`중복 제외: ${registration.skippedPosts.length}`,
];
if (registration.createdPosts.length > 0) {
executionLogNoteLines.push('');
executionLogNoteLines.push('등록된 게시글:');
executionLogNoteLines.push(
...registration.createdPosts.map((post) => `- 게시글 #${post.postId} ${post.workId} (${post.count}건)`),
);
}
if (registration.skippedPosts.length > 0) {
executionLogNoteLines.push('');
executionLogNoteLines.push('제외된 항목:');
executionLogNoteLines.push(
...registration.skippedPosts.map((post) => `- ${post.workId}: ${post.reason}`),
);
}
const executionLog = await createCompletedPlanExecutionLogItem({
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
note: executionLogNoteLines.join('\n'),
automationType: 'plan',
releaseTarget: String(row.release_target ?? 'release'),
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
repeatRequestEnabled: false,
repeatIntervalMinutes: normalizeRepeatIntervalMinutes(
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit),
),
});
await createPlanActionHistory(
Number(executionLog.id),
'스케줄등록',
`Plan 스케줄 #${row.id} 실행 이력을 저장했습니다.`,
);
await createPlanActionHistory(
Number(executionLog.id),
'완료처리',
registration.createdPosts.length > 0
? `Plan 게시판 글 ${registration.createdPosts.length}건을 등록했습니다.`
: '등록할 신규 Plan 게시판 글이 없었습니다.',
);
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id: row.id })
.update({
last_registered_at: now,
updated_at: db.fn.now(),
});
return {
createdPlan: executionLog,
createdBoardPosts: registration.createdPosts,
};
}
const repeatIntervalMinutes = normalizeRepeatIntervalMinutes(
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit),
);
const createdPlan = await createPlanItem({
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
note: String(row.note ?? ''),
automationType: normalizePlanAutomationType(row.automation_type),
releaseTarget: String(row.release_target ?? 'release'),
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
repeatRequestEnabled: false,
repeatIntervalMinutes,
});
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id: row.id })
.update({
last_registered_at: now,
updated_at: db.fn.now(),
});
await createPlanActionHistory(
Number(createdPlan.id),
'스케줄등록',
`Plan 스케줄 #${row.id} 반복 작업에서 등록했습니다.`,
);
return {
createdPlan,
createdBoardPosts: [],
};
}
export async function registerPlanScheduledTaskNow(id: number, now = new Date()) {
await ensurePlanTable();
await ensurePlanScheduledTaskTable();
const row = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id, enabled: true }).first();
if (!row || !isScheduleDue(row, now)) {
return null;
}
return registerPlanScheduledTaskRow(row, now);
}