전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
428
apps/web/src/pages/admin/dataNormalization.ts
Normal file
428
apps/web/src/pages/admin/dataNormalization.ts
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import type {
|
||||
AdminManagedUser,
|
||||
AdminSupportTicket,
|
||||
AdminTicketsResponse,
|
||||
AdminUsersResponse,
|
||||
} from "../../lib/api";
|
||||
|
||||
export interface AdminDataWarning {
|
||||
key: string;
|
||||
message: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function warn(
|
||||
warnings: AdminDataWarning[],
|
||||
key: string,
|
||||
message: string,
|
||||
detail: string,
|
||||
): void {
|
||||
warnings.push({ key, message, detail });
|
||||
}
|
||||
|
||||
function stringField(
|
||||
source: Record<string, unknown>,
|
||||
field: string,
|
||||
fallback: string,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): string {
|
||||
const value = source[field];
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) {
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`,
|
||||
`${field}=null`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`,
|
||||
`${field}=${typeof value}`,
|
||||
);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
source: Record<string, unknown>,
|
||||
field: string,
|
||||
fallback: boolean,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): boolean {
|
||||
const value = source[field];
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value == null) {
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`,
|
||||
`${field}=null`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`,
|
||||
`${field}=${typeof value}`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function numberField(
|
||||
source: Record<string, unknown>,
|
||||
field: string,
|
||||
fallback: number,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): number {
|
||||
const value = source[field];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`,
|
||||
`${field}=string`,
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`,
|
||||
`${field}=${value == null ? "null" : typeof value}`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function cohortIdsField(
|
||||
source: Record<string, unknown>,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): string[] {
|
||||
const value = source.cohort_ids;
|
||||
if (!Array.isArray(value)) {
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.cohort_ids`,
|
||||
`${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`,
|
||||
`cohort_ids=${value == null ? "null" : typeof value}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const normalized = value.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
if (normalized.length !== value.length) {
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.cohort_ids.items`,
|
||||
`${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`,
|
||||
`kept=${normalized.length}, raw=${value.length}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function numberRecordField(
|
||||
source: Record<string, unknown>,
|
||||
field: string,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): Record<string, number> {
|
||||
const value = source[field];
|
||||
if (value == null) {
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`,
|
||||
`${field}=null`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}`,
|
||||
`${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`,
|
||||
`${field}=${typeof value}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
const normalized: Record<string, number> = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
normalized[key] = raw;
|
||||
continue;
|
||||
}
|
||||
const parsed = typeof raw === "string" ? Number(raw) : Number.NaN;
|
||||
if (Number.isFinite(parsed)) {
|
||||
normalized[key] = parsed;
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}.${key}`,
|
||||
`${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`,
|
||||
`${field}.${key}=string`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.${field}.${key}`,
|
||||
`${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`,
|
||||
`${field}.${key}=${raw == null ? "null" : typeof raw}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function roleField(
|
||||
source: Record<string, unknown>,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): AdminManagedUser["role"] {
|
||||
const value = source.role;
|
||||
if (value === "learner" || value === "teacher" || value === "admin") {
|
||||
return value;
|
||||
}
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.role`,
|
||||
`${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`,
|
||||
`role=${String(value)}`,
|
||||
);
|
||||
return "learner";
|
||||
}
|
||||
|
||||
function accountStatusField(
|
||||
source: Record<string, unknown>,
|
||||
warnings: AdminDataWarning[],
|
||||
label: string,
|
||||
): AdminManagedUser["account_status"] {
|
||||
const value = source.account_status;
|
||||
if (value === "pending" || value === "approved" || value === "suspended") {
|
||||
return value;
|
||||
}
|
||||
warn(
|
||||
warnings,
|
||||
`${label}.account_status`,
|
||||
`${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`,
|
||||
`account_status=${String(value)}`,
|
||||
);
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function userSourceField(
|
||||
source: Record<string, unknown>,
|
||||
parentSource: AdminUsersResponse["source"],
|
||||
): AdminManagedUser["source"] {
|
||||
const value = source.source;
|
||||
return value === "server_session_registry" || value === "database"
|
||||
? value
|
||||
: parentSource;
|
||||
}
|
||||
|
||||
export function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
|
||||
response: AdminUsersResponse;
|
||||
warnings: AdminDataWarning[];
|
||||
} {
|
||||
const warnings: AdminDataWarning[] = [];
|
||||
const root = asRecord(rawResponse);
|
||||
const source: AdminUsersResponse["source"] =
|
||||
root.source === "server_session_registry"
|
||||
? "server_session_registry"
|
||||
: "database";
|
||||
const durable =
|
||||
typeof root.durable === "boolean" ? root.durable : source === "database";
|
||||
const rawUsers = Array.isArray(root.users) ? root.users : [];
|
||||
|
||||
if (!Array.isArray(root.users)) {
|
||||
warn(
|
||||
warnings,
|
||||
"admin.users",
|
||||
"/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.",
|
||||
`users=${root.users == null ? "null" : typeof root.users}`,
|
||||
);
|
||||
}
|
||||
|
||||
const users = rawUsers.map((item, index): AdminManagedUser => {
|
||||
const sourceRecord = asRecord(item);
|
||||
const label = `users[${index}]`;
|
||||
const userId = stringField(
|
||||
sourceRecord,
|
||||
"user_id",
|
||||
`unknown-${index + 1}`,
|
||||
warnings,
|
||||
label,
|
||||
);
|
||||
return {
|
||||
user_id: userId,
|
||||
email: stringField(sourceRecord, "email", userId, warnings, label),
|
||||
display_name: stringField(
|
||||
sourceRecord,
|
||||
"display_name",
|
||||
userId,
|
||||
warnings,
|
||||
label,
|
||||
),
|
||||
role: roleField(sourceRecord, warnings, label),
|
||||
admin_access: booleanField(
|
||||
sourceRecord,
|
||||
"admin_access",
|
||||
false,
|
||||
warnings,
|
||||
label,
|
||||
),
|
||||
super_admin: booleanField(
|
||||
sourceRecord,
|
||||
"super_admin",
|
||||
false,
|
||||
warnings,
|
||||
label,
|
||||
),
|
||||
account_status: accountStatusField(sourceRecord, warnings, label),
|
||||
cohort_ids: cohortIdsField(sourceRecord, warnings, label),
|
||||
affiliation: stringField(
|
||||
sourceRecord,
|
||||
"affiliation",
|
||||
"",
|
||||
warnings,
|
||||
label,
|
||||
),
|
||||
active_sessions: numberField(
|
||||
sourceRecord,
|
||||
"active_sessions",
|
||||
0,
|
||||
warnings,
|
||||
label,
|
||||
),
|
||||
created_at: numberField(sourceRecord, "created_at", 0, warnings, label),
|
||||
last_seen_at: numberField(
|
||||
sourceRecord,
|
||||
"last_seen_at",
|
||||
0,
|
||||
warnings,
|
||||
label,
|
||||
),
|
||||
source: userSourceField(sourceRecord, source),
|
||||
};
|
||||
});
|
||||
|
||||
return { response: { source, durable, users }, warnings };
|
||||
}
|
||||
|
||||
export function normalizeAdminTicketsResponse(
|
||||
rawResponse: AdminTicketsResponse,
|
||||
): {
|
||||
response: AdminTicketsResponse;
|
||||
warnings: AdminDataWarning[];
|
||||
} {
|
||||
const warnings: AdminDataWarning[] = [];
|
||||
const root = asRecord(rawResponse);
|
||||
const source: AdminTicketsResponse["source"] =
|
||||
root.source === "database" ? "database" : "unavailable";
|
||||
const durable =
|
||||
typeof root.durable === "boolean" ? root.durable : source === "database";
|
||||
const rawTickets = Array.isArray(root.tickets) ? root.tickets : [];
|
||||
if (!Array.isArray(root.tickets)) {
|
||||
warn(
|
||||
warnings,
|
||||
"admin.tickets",
|
||||
"/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.",
|
||||
`tickets=${root.tickets == null ? "null" : typeof root.tickets}`,
|
||||
);
|
||||
}
|
||||
|
||||
const summaryRoot = asRecord(root.summary);
|
||||
if (
|
||||
!root.summary ||
|
||||
typeof root.summary !== "object" ||
|
||||
Array.isArray(root.summary)
|
||||
) {
|
||||
warn(
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
"/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.",
|
||||
`summary=${root.summary == null ? "null" : typeof root.summary}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
response: {
|
||||
source,
|
||||
durable,
|
||||
generated_at: numberField(
|
||||
root,
|
||||
"generated_at",
|
||||
0,
|
||||
warnings,
|
||||
"admin.tickets",
|
||||
),
|
||||
tickets: rawTickets as AdminSupportTicket[],
|
||||
summary: {
|
||||
total: numberField(
|
||||
summaryRoot,
|
||||
"total",
|
||||
rawTickets.length,
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
),
|
||||
open_count: numberField(
|
||||
summaryRoot,
|
||||
"open_count",
|
||||
0,
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
),
|
||||
high_priority_count: numberField(
|
||||
summaryRoot,
|
||||
"high_priority_count",
|
||||
0,
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
),
|
||||
stale_count: numberField(
|
||||
summaryRoot,
|
||||
"stale_count",
|
||||
0,
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
),
|
||||
by_category: numberRecordField(
|
||||
summaryRoot,
|
||||
"by_category",
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
),
|
||||
by_priority: numberRecordField(
|
||||
summaryRoot,
|
||||
"by_priority",
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
),
|
||||
by_status: numberRecordField(
|
||||
summaryRoot,
|
||||
"by_status",
|
||||
warnings,
|
||||
"admin.tickets.summary",
|
||||
),
|
||||
},
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue