feat: api
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
ChronicDisease,
|
||||
ExerciseFrequency,
|
||||
Gender,
|
||||
PainLevel,
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
|
||||
export interface GuestSelfAssessmentProfile {
|
||||
name: string;
|
||||
phone: string;
|
||||
birthday: string;
|
||||
gender: Gender;
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
email: string | null;
|
||||
emergencyContact: string | null;
|
||||
emergencyPhone: string | null;
|
||||
consent: boolean;
|
||||
consentVersion: "v1";
|
||||
}
|
||||
|
||||
export interface GuestSelfAssessmentDraft {
|
||||
profile: GuestSelfAssessmentProfile;
|
||||
assessment: SelfAssessmentRequest;
|
||||
provisionalRiskLevel: RiskLevel;
|
||||
/** 同一份草稿重送正式自評時沿用同一個 key。 */
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
|
||||
|
||||
const GENDER_VALUES: Gender[] = ["male", "female"];
|
||||
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
|
||||
"none",
|
||||
"oneToTwo",
|
||||
"threeOrMore",
|
||||
];
|
||||
const PAIN_LEVEL_VALUES: PainLevel[] = ["none", "mild", "moderate", "severe"];
|
||||
const RISK_LEVEL_VALUES: RiskLevel[] = ["low", "followUp", "consultation"];
|
||||
const WANTED_IMPROVEMENT_VALUES: WantedImprovement[] = [
|
||||
"strength",
|
||||
"balance",
|
||||
"cardio",
|
||||
"flexibility",
|
||||
"agility",
|
||||
"bodyComposition",
|
||||
];
|
||||
const CHRONIC_DISEASE_VALUES: ChronicDisease[] = [
|
||||
"hypertension",
|
||||
"diabetes",
|
||||
"hyperlipidemia",
|
||||
"heartDisease",
|
||||
"osteoarthritis",
|
||||
"osteoporosis",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isOneOf<T extends string>(
|
||||
values: readonly T[],
|
||||
value: unknown,
|
||||
): value is T {
|
||||
return typeof value === "string" && values.includes(value as T);
|
||||
}
|
||||
|
||||
function isStringArray<T extends string>(
|
||||
values: readonly T[],
|
||||
value: unknown,
|
||||
): value is T[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => isOneOf(values, item)) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isValidProfile(value: unknown): value is GuestSelfAssessmentProfile {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof value.name === "string" &&
|
||||
typeof value.phone === "string" &&
|
||||
typeof value.birthday === "string" &&
|
||||
isOneOf(GENDER_VALUES, value.gender) &&
|
||||
typeof value.heightCm === "number" &&
|
||||
typeof value.weightKg === "number" &&
|
||||
isNullableString(value.email) &&
|
||||
isNullableString(value.emergencyContact) &&
|
||||
isNullableString(value.emergencyPhone) &&
|
||||
typeof value.consent === "boolean" &&
|
||||
value.consentVersion === "v1"
|
||||
);
|
||||
}
|
||||
|
||||
function isValidAssessment(value: unknown): value is SelfAssessmentRequest {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isOneOf(EXERCISE_FREQUENCY_VALUES, value.exerciseFrequency) &&
|
||||
isStringArray(WANTED_IMPROVEMENT_VALUES, value.wantedImprovements) &&
|
||||
typeof value.hasFalls === "boolean" &&
|
||||
isOneOf(PAIN_LEVEL_VALUES, value.painLevel) &&
|
||||
isStringArray(CHRONIC_DISEASE_VALUES, value.chronicDiseases)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isValidProfile(value.profile) &&
|
||||
isValidAssessment(value.assessment) &&
|
||||
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
|
||||
(value.idempotencyKey === undefined ||
|
||||
typeof value.idempotencyKey === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/** 依後端規則計算訪客階段的暫存結果;正式結果仍以後端回應為準。 */
|
||||
export function calculateProvisionalRiskLevel(
|
||||
assessment: SelfAssessmentRequest,
|
||||
): RiskLevel {
|
||||
if (
|
||||
assessment.painLevel === "moderate" ||
|
||||
assessment.painLevel === "severe" ||
|
||||
assessment.chronicDiseases.length >= 2
|
||||
) {
|
||||
return "consultation";
|
||||
}
|
||||
|
||||
if (assessment.exerciseFrequency === "none" || assessment.hasFalls === true) {
|
||||
return "followUp";
|
||||
}
|
||||
|
||||
return "low";
|
||||
}
|
||||
|
||||
/** 僅在自評完成並確認送出後保存一份完整訪客草稿。 */
|
||||
export function saveGuestSelfAssessmentDraft(
|
||||
draft: GuestSelfAssessmentDraft,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
GUEST_SELF_ASSESSMENT_DRAFT_KEY,
|
||||
JSON.stringify(draft),
|
||||
);
|
||||
} catch {
|
||||
// 儲存空間不可用時不讓表單提交流程因瀏覽器安全設定而崩潰。
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀取並驗證訪客草稿;格式不正確的資料視為不存在。 */
|
||||
export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawDraft = window.sessionStorage.getItem(
|
||||
GUEST_SELF_ASSESSMENT_DRAFT_KEY,
|
||||
);
|
||||
if (!rawDraft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedDraft: unknown = JSON.parse(rawDraft);
|
||||
return isValidDraft(parsedDraft) ? parsedDraft : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 正式保存成功後清除訪客草稿;登入取消或 API 失敗時不應呼叫。 */
|
||||
export function clearGuestSelfAssessmentDraft(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.removeItem(GUEST_SELF_ASSESSMENT_DRAFT_KEY);
|
||||
} catch {
|
||||
// sessionStorage 不可用時沒有可清除的資料,保持流程可繼續。
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user