Files
MindScope/lib/assessment-db.ts
T

247 lines
9.0 KiB
TypeScript

import {
AssessmentProfile,
AssessmentRecord,
EncryptedAssessmentRecord,
MindScopeBackup,
} from '@/lib/assessment-types';
import {
clearDeviceRecordKey,
decryptDeviceRecord,
encryptRecordForDevice,
isEncryptedRecord,
} from '@/lib/record-crypto';
import { buildScoreSummary } from '@/lib/score-summary';
const DB_NAME = 'mindscope';
const DB_VERSION = 1;
const PROFILE_STORE = 'profiles';
const RECORD_STORE = 'records';
const ACTIVE_PROFILE_KEY = 'mindscope_active_profile';
function createId(prefix: string) {
return `${prefix}_${Date.now()}_${crypto.randomUUID()}`;
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(PROFILE_STORE)) {
db.createObjectStore(PROFILE_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(RECORD_STORE)) {
const records = db.createObjectStore(RECORD_STORE, { keyPath: 'id' });
records.createIndex('profileId', 'profileId');
records.createIndex('questionnaireId', 'questionnaireId');
}
};
});
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function normalizeRecord(record: AssessmentRecord): AssessmentRecord {
if (record.questionnaireVersion && record.scoreVersion && record.scoreSummary?.questionnaireVersion) {
return record;
}
const scoreSummary = buildScoreSummary(
record.questionnaireId,
record.answers.map((answer) => answer.value),
);
return {
...record,
questionnaireVersion: record.questionnaireVersion || scoreSummary.questionnaireVersion,
scoreVersion: record.scoreVersion || scoreSummary.scoreVersion,
scoreSummary: {
...scoreSummary,
...record.scoreSummary,
questionnaireVersion: record.scoreSummary?.questionnaireVersion || scoreSummary.questionnaireVersion,
scoreVersion: record.scoreSummary?.scoreVersion || scoreSummary.scoreVersion,
min: record.scoreSummary?.min ?? scoreSummary.min,
max: record.scoreSummary?.max ?? scoreSummary.max,
reverseItems: record.scoreSummary?.reverseItems || scoreSummary.reverseItems,
direction: record.scoreSummary?.direction || scoreSummary.direction,
highScoreMeaning: record.scoreSummary?.highScoreMeaning || scoreSummary.highScoreMeaning,
thresholds: record.scoreSummary?.thresholds || scoreSummary.thresholds,
scoringStatus: record.scoreSummary?.scoringStatus || scoreSummary.scoringStatus,
},
};
}
export function getActiveProfileId() {
return localStorage.getItem(ACTIVE_PROFILE_KEY);
}
export function setActiveProfileId(profileId: string) {
localStorage.setItem(ACTIVE_PROFILE_KEY, profileId);
window.dispatchEvent(new CustomEvent('mindscope-profile-change'));
}
export async function getProfiles(): Promise<AssessmentProfile[]> {
const db = await openDatabase();
const profiles = await requestResult(
db.transaction(PROFILE_STORE, 'readonly').objectStore(PROFILE_STORE).getAll(),
);
db.close();
return profiles.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
export async function createProfile(name: string): Promise<AssessmentProfile> {
const now = new Date().toISOString();
const profile: AssessmentProfile = {
id: createId('profile'),
name: name.trim(),
createdAt: now,
updatedAt: now,
};
const db = await openDatabase();
await requestResult(
db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE).add(profile),
);
db.close();
return profile;
}
export async function ensureActiveProfile(): Promise<AssessmentProfile> {
const profiles = await getProfiles();
const activeId = getActiveProfileId();
const active = profiles.find((profile) => profile.id === activeId);
if (active) return active;
const profile = profiles[0] || (await createProfile('我'));
setActiveProfileId(profile.id);
return profile;
}
export async function renameProfile(profileId: string, name: string) {
const db = await openDatabase();
const store = db.transaction(PROFILE_STORE, 'readwrite').objectStore(PROFILE_STORE);
const profile = await requestResult<AssessmentProfile | undefined>(store.get(profileId));
if (profile) {
await requestResult(
store.put({ ...profile, name: name.trim(), updatedAt: new Date().toISOString() }),
);
}
db.close();
}
export async function deleteProfile(profileId: string) {
const db = await openDatabase();
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
transaction.objectStore(PROFILE_STORE).delete(profileId);
const index = transaction.objectStore(RECORD_STORE).index('profileId');
const recordKeys = await requestResult(index.getAllKeys(profileId));
recordKeys.forEach((key) => transaction.objectStore(RECORD_STORE).delete(key));
await new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
db.close();
}
export async function addAssessmentRecord(
record: Omit<AssessmentRecord, 'id'>,
): Promise<AssessmentRecord> {
const completeRecord = { ...record, id: createId('record') };
const encrypted = await encryptRecordForDevice(completeRecord);
const db = await openDatabase();
await requestResult(
db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).add(encrypted),
);
db.close();
return completeRecord;
}
export async function updateRecordAnalysis(recordId: string, analysisText: string) {
const db = await openDatabase();
const store = db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE);
const stored = await requestResult<AssessmentRecord | EncryptedAssessmentRecord | undefined>(store.get(recordId));
const record = stored ? await decryptDeviceRecord(stored) : undefined;
if (record && record.analysisText !== analysisText) {
await requestResult(store.put(await encryptRecordForDevice({ ...record, analysisText })));
}
db.close();
}
export async function getRecords(profileId?: string): Promise<AssessmentRecord[]> {
const db = await openDatabase();
const store = db.transaction(RECORD_STORE, 'readonly').objectStore(RECORD_STORE);
const storedRecords = profileId
? await requestResult(store.index('profileId').getAll(profileId))
: await requestResult(store.getAll());
db.close();
const typedRecords = storedRecords as Array<AssessmentRecord | EncryptedAssessmentRecord>;
const records = (await Promise.all(typedRecords.map(decryptDeviceRecord))).map(normalizeRecord);
const plaintextRecords = typedRecords.filter((record) => !isEncryptedRecord(record)) as AssessmentRecord[];
if (plaintextRecords.length) {
await migratePlaintextRecords(plaintextRecords);
}
return records.sort((a, b) => b.completedAt.localeCompare(a.completedAt));
}
async function migratePlaintextRecords(records: AssessmentRecord[]) {
const db = await openDatabase();
const store = db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE);
for (const record of records) {
await requestResult(store.put(await encryptRecordForDevice(record)));
}
db.close();
}
export async function deleteRecord(recordId: string) {
const db = await openDatabase();
await requestResult(
db.transaction(RECORD_STORE, 'readwrite').objectStore(RECORD_STORE).delete(recordId),
);
db.close();
}
export async function exportBackup(): Promise<MindScopeBackup> {
return {
format: 'mindscope-backup',
version: 1,
exportedAt: new Date().toISOString(),
profiles: await getProfiles(),
records: await getRecords(),
};
}
export async function importBackup(backup: MindScopeBackup) {
if (backup.format !== 'mindscope-backup' || backup.version !== 1) {
throw new Error('不支持的备份格式');
}
const db = await openDatabase();
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
backup.profiles.forEach((profile) => transaction.objectStore(PROFILE_STORE).put(profile));
for (const record of backup.records) {
transaction.objectStore(RECORD_STORE).put(await encryptRecordForDevice(record));
}
await new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
db.close();
}
export async function clearAllAssessmentData() {
const db = await openDatabase();
const transaction = db.transaction([PROFILE_STORE, RECORD_STORE], 'readwrite');
transaction.objectStore(PROFILE_STORE).clear();
transaction.objectStore(RECORD_STORE).clear();
await new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
db.close();
localStorage.removeItem(ACTIVE_PROFILE_KEY);
clearDeviceRecordKey();
}