1012 lines
30 KiB
TypeScript
Executable File
1012 lines
30 KiB
TypeScript
Executable File
import { FinancialBalance, User } from "../db/interfaces.ts";
|
|
import {
|
|
BrainProgress,
|
|
sqlCreateQuizConfig,
|
|
sqlCreateQuizQuestion,
|
|
sqlCreateQuizResult,
|
|
sqlCreateUserRole,
|
|
sqlDeleteBrainProgress,
|
|
sqlDeleteQuizConfig,
|
|
sqlDeleteQuizQuestion,
|
|
sqlDeleteQuizQuestionsByQuiz,
|
|
sqlDeleteQuizResult,
|
|
sqlDeleteQuizResultsByQuiz,
|
|
sqlDeleteSessionConfig,
|
|
sqlDeleteStateKey,
|
|
sqlDeleteUser,
|
|
sqlDeleteUserPin,
|
|
sqlDeleteUserRole,
|
|
sqlDeleteUserTokens,
|
|
sqlGetAllBrainProgress,
|
|
sqlGetAllFinancialBalances,
|
|
sqlGetAllQuizConfigs,
|
|
sqlGetAllQuizQuestions,
|
|
sqlGetAllQuizResults,
|
|
sqlGetAllRoles,
|
|
sqlGetAllSessionConfigs,
|
|
sqlGetAllStateKeys,
|
|
sqlGetAllUserPins,
|
|
sqlGetAllUserRoles,
|
|
sqlGetAllUsers,
|
|
sqlGetAllUserTokens,
|
|
sqlGetBrainProgressById,
|
|
sqlGetFinancialBalancesByUser,
|
|
sqlGetQuizConfig,
|
|
sqlGetQuizQuestion,
|
|
sqlGetQuizQuestionsByQuiz as _sqlGetQuizQuestionsByQuiz,
|
|
sqlGetQuizResult,
|
|
sqlGetQuizResultsByQuiz as _sqlGetQuizResultsByQuiz,
|
|
sqlGetQuizResultsByUser as _sqlGetQuizResultsByUser,
|
|
sqlGetSessionConfig,
|
|
sqlGetStateKeys,
|
|
sqlGetUser,
|
|
sqlGetUserPin,
|
|
sqlGetUserRoles,
|
|
sqlGetUserTokens,
|
|
sqlSetSessionConfig,
|
|
sqlSetStateKey,
|
|
sqlSetUserPin,
|
|
sqlSetUserTokens,
|
|
sqlUpdateQuizConfig,
|
|
sqlUpdateQuizQuestion,
|
|
sqlUpdateQuizResult,
|
|
sqlUpdateSessionConfig as _sqlUpdateSessionConfig,
|
|
sqlUpdateUserPin as _sqlUpdateUserPin,
|
|
sqlUpsertBrainProgress,
|
|
sqlUpsertFinancialBalance,
|
|
sqlUpsertUser as _sqlUpsertUser,
|
|
sqlUpsertUserFull,
|
|
} from "../db/sqlUserRepository.ts";
|
|
|
|
export function handleDatabaseError(error: unknown): void {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
console.error("🔄 Database error detected. Triggering app reload...");
|
|
console.error("Error details:", errorMessage);
|
|
|
|
const isDevelopment = Deno.env.get("DEVELOPMENT") === "true";
|
|
const restartCmd = isDevelopment ? "exec deno task dev" : "exec ./server";
|
|
|
|
const command = new Deno.Command("sh", {
|
|
args: [
|
|
"-c",
|
|
`lsof -ti:8080 | xargs kill -9 2>/dev/null || true; ${restartCmd}`,
|
|
],
|
|
cwd: Deno.cwd(),
|
|
stdin: "inherit",
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
});
|
|
command.spawn();
|
|
Deno.exit(0);
|
|
}
|
|
|
|
interface KvAdapter {
|
|
get<T = unknown>(key: Deno.KvKey): Promise<{ value: T | null }>;
|
|
set<T = unknown>(key: Deno.KvKey, value: T): Promise<Deno.KvCommitResult>;
|
|
delete(key: Deno.KvKey): Promise<void>;
|
|
list<T = unknown>(
|
|
options?: { prefix?: Deno.KvKey },
|
|
): AsyncIterable<{ key: Deno.KvKey; value: T }>;
|
|
close(): Promise<void> | void;
|
|
}
|
|
|
|
export class KvCacheStore {
|
|
private memKv: KvAdapter;
|
|
private fileKv: KvAdapter;
|
|
|
|
constructor(memKv: KvAdapter, fileKv: KvAdapter) {
|
|
this.memKv = memKv;
|
|
this.fileKv = fileKv;
|
|
}
|
|
|
|
static async initialize(filePath: string): Promise<KvCacheStore> {
|
|
try {
|
|
await Deno.mkdir(filePath, { recursive: true });
|
|
} catch (err) {
|
|
if (!(err instanceof Deno.errors.AlreadyExists)) {
|
|
console.error("Failed to create KV cache directory:", err);
|
|
}
|
|
}
|
|
|
|
const memKv: KvAdapter = await Deno.openKv(":memory:") as KvAdapter;
|
|
|
|
const fileKv = await Deno.openKv(`${filePath}/kv-cache`) as KvAdapter;
|
|
|
|
for await (const entry of fileKv.list({ prefix: [] })) {
|
|
await memKv.set(entry.key, entry.value);
|
|
}
|
|
|
|
return new KvCacheStore(memKv, fileKv);
|
|
}
|
|
|
|
async get<T = unknown>(key: Deno.KvKey): Promise<T | null> {
|
|
try {
|
|
const result = await this.memKv.get<T>(key);
|
|
if (result.value !== null) {
|
|
return result.value;
|
|
}
|
|
const sqlResult = await this.fetchFromSql<T>(key);
|
|
if (sqlResult) {
|
|
await this.memKv.set(key, sqlResult);
|
|
await this.fileKv.set(key, sqlResult);
|
|
return sqlResult;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error("KV/SQL get failed:", error);
|
|
handleDatabaseError(error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async set<T = unknown>(key: Deno.KvKey, value: T): Promise<void> {
|
|
try {
|
|
const existing = await this.get<T>(key);
|
|
if (
|
|
existing && typeof existing === "object" && existing !== null &&
|
|
"created_on" in existing
|
|
) {
|
|
const createdOn =
|
|
(existing as unknown as { created_on: number }).created_on;
|
|
if (createdOn) {
|
|
value = {
|
|
...value,
|
|
created_on: createdOn,
|
|
};
|
|
}
|
|
}
|
|
|
|
await this.memKv.set(key, value);
|
|
await this.fileKv.set(key, value);
|
|
await this.persistToSql(key, value);
|
|
} catch (error) {
|
|
console.error("KV/SQL set failed:", error);
|
|
handleDatabaseError(error);
|
|
}
|
|
}
|
|
|
|
async delete(key: Deno.KvKey): Promise<void> {
|
|
try {
|
|
await this.memKv.delete(key);
|
|
await this.fileKv.delete(key);
|
|
this.deleteFromSql(key);
|
|
} catch (error) {
|
|
console.error("KV/SQL delete failed:", error);
|
|
handleDatabaseError(error);
|
|
}
|
|
}
|
|
|
|
async *list<T = unknown>(
|
|
options?: { prefix?: Deno.KvKey },
|
|
): AsyncIterable<{ key: Deno.KvKey; value: T }> {
|
|
const yieldedKeys = new Set<string>();
|
|
|
|
for await (const entry of this.memKv.list<T>(options ?? {})) {
|
|
const keyStr = JSON.stringify(entry.key);
|
|
if (!yieldedKeys.has(keyStr)) {
|
|
yieldedKeys.add(keyStr);
|
|
yield entry;
|
|
}
|
|
}
|
|
for await (const entry of this.fileKv.list<T>(options ?? {})) {
|
|
const keyStr = JSON.stringify(entry.key);
|
|
if (!yieldedKeys.has(keyStr)) {
|
|
yieldedKeys.add(keyStr);
|
|
yield entry;
|
|
}
|
|
}
|
|
|
|
const prefix = options?.prefix;
|
|
const namespace = prefix && prefix.length > 0 ? String(prefix[0]) : null;
|
|
|
|
if (namespace) {
|
|
const sqlEntries = await this.listFromSql<T>(namespace);
|
|
for (const entry of sqlEntries) {
|
|
const keyStr = JSON.stringify(entry.key);
|
|
if (!yieldedKeys.has(keyStr)) {
|
|
if (prefix && !this.keyMatchesPrefix(entry.key, prefix)) {
|
|
continue;
|
|
}
|
|
yieldedKeys.add(keyStr);
|
|
yield entry;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private keyMatchesPrefix(key: Deno.KvKey, prefix: Deno.KvKey): boolean {
|
|
if (key.length < prefix.length) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < prefix.length; i++) {
|
|
if (key[i] !== prefix[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async listFromSql<T = unknown>(
|
|
namespace: string,
|
|
): Promise<Array<{ key: Deno.KvKey; value: T }>> {
|
|
const results: Array<{ key: Deno.KvKey; value: T }> = [];
|
|
|
|
switch (namespace) {
|
|
case "users": {
|
|
const users = await sqlGetAllUsers();
|
|
for (const user of users) {
|
|
results.push({
|
|
key: ["users", user.id],
|
|
value: {
|
|
id: user.id,
|
|
name: user.name || "",
|
|
email: user.email || "",
|
|
phone: user.phone || "",
|
|
address_1: user.address_1 || "",
|
|
address_2: user.address_2 || "",
|
|
city: user.city || "",
|
|
state: user.state || "",
|
|
zip: user.zip || "",
|
|
country: user.country || "",
|
|
user_type: user.user_type,
|
|
onboarded_landlord: user.onboarded_landlord ?? false,
|
|
onboarded_tenant: user.onboarded_tenant ?? false,
|
|
email_verified: user.email_verified,
|
|
banking_setup_complete: user.banking_setup_complete,
|
|
onboarding_step: user.onboarding_step,
|
|
encrypted_data: user.encrypted_data,
|
|
started_on: user.started_on || Date.now(),
|
|
updated_on: user.updated_on || Date.now(),
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "roles": {
|
|
const roles = await sqlGetAllRoles();
|
|
for (const role of roles) {
|
|
results.push({
|
|
key: ["roles", role.id],
|
|
value: {
|
|
id: role.id,
|
|
description: role.description || "",
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "user_roles": {
|
|
const userRoles = await sqlGetAllUserRoles();
|
|
const grouped = new Map<string, string[]>();
|
|
for (const ur of userRoles) {
|
|
const existing = grouped.get(ur.userId || "") || [];
|
|
if (!ur.userId) {
|
|
continue;
|
|
}
|
|
existing.push(ur.roleId || "");
|
|
grouped.set(ur.userId || "", existing);
|
|
}
|
|
for (const [userId, roleIds] of grouped) {
|
|
results.push({
|
|
key: ["user_roles", userId],
|
|
value: roleIds as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "state_keys": {
|
|
const stateKeys = await sqlGetAllStateKeys();
|
|
for (const sk of stateKeys) {
|
|
results.push({
|
|
key: ["state_keys", sk.state_id],
|
|
value: {
|
|
seal_key: sk.seal_key,
|
|
state_id: sk.state_id,
|
|
cookie_hash: sk.cookie_hash,
|
|
created_on: sk.created_on,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "financial_balances": {
|
|
const balances = await sqlGetAllFinancialBalances();
|
|
for (const balance of balances) {
|
|
results.push({
|
|
key: ["financial_balances", balance.user_id || ""],
|
|
value: balance as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "user_tokens": {
|
|
const tokens = await sqlGetAllUserTokens();
|
|
for (const token of tokens) {
|
|
results.push({
|
|
key: ["user_tokens", token.user_id || ""],
|
|
value: {
|
|
user_id: token.user_id,
|
|
state_id: token.state_id,
|
|
access_token: token.access_token,
|
|
refresh_token: token.refresh_token,
|
|
created_on: token.created_on,
|
|
updated_on: token.updated_on,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "user_pins": {
|
|
const pins = await sqlGetAllUserPins();
|
|
for (const pin of pins) {
|
|
results.push({
|
|
key: ["user_pins", pin.user_id || ""],
|
|
value: {
|
|
user_id: pin.user_id,
|
|
pin: pin.pin,
|
|
created_on: pin.created_on,
|
|
modified_on: pin.modified_on,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "session_configs": {
|
|
const configs = await sqlGetAllSessionConfigs();
|
|
for (const config of configs) {
|
|
results.push({
|
|
key: [
|
|
"session_configs",
|
|
config.user_id || "",
|
|
config.device_id || "",
|
|
],
|
|
value: {
|
|
device_id: config.device_id,
|
|
user_id: config.user_id,
|
|
other_data: config.other_data,
|
|
active_date: config.active_date,
|
|
quiz_configs: config.quiz_configs,
|
|
active_route: config.active_route,
|
|
page_data: config.page_data,
|
|
active_quiz_index: config.active_quiz_index,
|
|
active_question_index: config.active_question_index,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "quiz_configs": {
|
|
const configs = await sqlGetAllQuizConfigs();
|
|
for (const config of configs) {
|
|
results.push({
|
|
key: [
|
|
"quiz_configs",
|
|
config.owner_id || "",
|
|
config.quiz_config_id || "",
|
|
],
|
|
value: {
|
|
quiz_config_id: config.quiz_config_id,
|
|
quiz_name: config.quiz_name,
|
|
owner_id: config.owner_id,
|
|
quiz_mode: config.quiz_mode,
|
|
active_date: config.active_date,
|
|
tab_name: config.tab_name,
|
|
tab_index: config.tab_index,
|
|
list_index: config.list_index,
|
|
questions: config.questions,
|
|
results: config.results,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "quiz_questions": {
|
|
const questions = await sqlGetAllQuizQuestions();
|
|
for (const question of questions) {
|
|
results.push({
|
|
key: ["quiz_questions", question.owner_id || "", question.id || ""],
|
|
value: {
|
|
id: question.id,
|
|
quiz_id: question.quiz_id,
|
|
owner_id: question.owner_id,
|
|
language_1: question.language_1,
|
|
language_2: question.language_2,
|
|
text_1: question.text_1,
|
|
audio_file_1: question.audio_file_1,
|
|
text_2: question.text_2,
|
|
audio_file_2: question.audio_file_2,
|
|
order_num: question.order_num,
|
|
background_file: question.background_file,
|
|
background_image_data: question.background_image_data,
|
|
video_file: question.video_file,
|
|
video_file_data: question.video_file_data,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "quiz_results": {
|
|
const quizResults = await sqlGetAllQuizResults();
|
|
for (const result of quizResults) {
|
|
results.push({
|
|
key: ["quiz_results", result.user_id || "", result.id || ""],
|
|
value: {
|
|
id: result.id,
|
|
quiz_config_id: result.quiz_config_id,
|
|
user_id: result.user_id,
|
|
success: result.success,
|
|
total_time: result.total_time,
|
|
help_count: result.help_count,
|
|
completed_on: result.completed_on,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "brain_question_progress": {
|
|
const brainProgress = await sqlGetAllBrainProgress();
|
|
for (const progress of brainProgress) {
|
|
results.push({
|
|
key: [
|
|
"brain_question_progress",
|
|
progress.user_id || "",
|
|
progress.id || "",
|
|
],
|
|
value: {
|
|
id: progress.id,
|
|
user_id: progress.user_id,
|
|
quiz_id: progress.quiz_id,
|
|
question_id: progress.question_id,
|
|
current_mode: progress.current_mode,
|
|
mode_index: progress.mode_index ?? 0,
|
|
attempts: progress.attempts,
|
|
first_try_successes: progress.first_try_successes,
|
|
consecutive_first_try: progress.consecutive_first_try ?? 0,
|
|
last_result: progress.last_result,
|
|
next_due_at: progress.next_due_at,
|
|
interval_days: progress.interval_days,
|
|
ease_factor: progress.ease_factor,
|
|
is_review_only: progress.is_review_only ?? false,
|
|
created_on: progress.created_on,
|
|
updated_on: progress.updated_on,
|
|
} as T,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async close() {
|
|
await this.memKv.close();
|
|
await this.fileKv.close();
|
|
}
|
|
|
|
private async persistToSql(key: Deno.KvKey, value: unknown): Promise<void> {
|
|
const [namespace, id] = key;
|
|
if (typeof namespace !== "string") {
|
|
return;
|
|
}
|
|
|
|
switch (namespace) {
|
|
case "users": {
|
|
const user = value as User;
|
|
sqlUpsertUserFull({
|
|
id: user.id,
|
|
name: user.name ?? "",
|
|
email: user.email ?? "",
|
|
phone: user.phone ?? "",
|
|
address_1: user.address_1 ?? "",
|
|
address_2: user.address_2 ?? "",
|
|
city: user.city ?? "",
|
|
state: user.state ?? "",
|
|
zip: user.zip ?? "",
|
|
country: user.country ?? "",
|
|
user_type: user.user_type ?? null,
|
|
onboarded_landlord: user.onboarded_landlord ?? false,
|
|
onboarded_tenant: user.onboarded_tenant ?? false,
|
|
email_verified: user.email_verified ?? null,
|
|
banking_setup_complete: user.banking_setup_complete ?? null,
|
|
onboarding_step: user.onboarding_step ?? null,
|
|
encrypted_data: user.encrypted_data ?? null,
|
|
started_on: user.started_on ?? Date.now(),
|
|
updated_on: user.updated_on ?? Date.now(),
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "roles": {
|
|
const role = value as { id: string; description: string };
|
|
void role;
|
|
break;
|
|
}
|
|
|
|
case "user_roles": {
|
|
const roleIds = value as string[];
|
|
const userId = id as string;
|
|
sqlDeleteUserRole(userId);
|
|
for (const roleId of roleIds) {
|
|
sqlCreateUserRole(userId, roleId);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "state_keys": {
|
|
const stateKey = value as {
|
|
seal_key: string;
|
|
state_id: string;
|
|
cookie_hash: string;
|
|
created_on: number;
|
|
};
|
|
sqlSetStateKey(
|
|
stateKey.seal_key,
|
|
stateKey.state_id,
|
|
stateKey.cookie_hash,
|
|
);
|
|
break;
|
|
}
|
|
|
|
case "financial_balances": {
|
|
const balance = value as FinancialBalance;
|
|
sqlUpsertFinancialBalance(
|
|
balance.user_id || "",
|
|
balance.balance || 0,
|
|
balance.balance_date || 0,
|
|
);
|
|
break;
|
|
}
|
|
case "user_tokens": {
|
|
const token = value as {
|
|
user_id: string;
|
|
state_id: string;
|
|
access_token: string;
|
|
refresh_token: string;
|
|
};
|
|
sqlSetUserTokens(
|
|
token.user_id,
|
|
token.state_id,
|
|
token.access_token,
|
|
token.refresh_token,
|
|
);
|
|
break;
|
|
}
|
|
case "user_pins": {
|
|
const userPin = value as {
|
|
user_id: string;
|
|
pin: string;
|
|
created_on: number;
|
|
modified_on: number;
|
|
};
|
|
sqlSetUserPin(userPin.user_id, userPin.pin);
|
|
break;
|
|
}
|
|
case "session_configs": {
|
|
const sessionConfig = value as {
|
|
device_id: string;
|
|
user_id: string;
|
|
other_data: string;
|
|
active_date: number;
|
|
quiz_configs: Array<Array<string>>;
|
|
active_route: string;
|
|
page_data: string;
|
|
active_quiz_index: number;
|
|
active_question_index: number;
|
|
};
|
|
sqlSetSessionConfig(
|
|
sessionConfig.device_id,
|
|
sessionConfig.user_id,
|
|
sessionConfig.other_data,
|
|
sessionConfig.active_route,
|
|
sessionConfig.page_data,
|
|
sessionConfig.active_quiz_index,
|
|
sessionConfig.active_question_index,
|
|
sessionConfig.quiz_configs,
|
|
);
|
|
break;
|
|
}
|
|
case "quiz_configs": {
|
|
const quizConfig = value as {
|
|
quiz_config_id: string;
|
|
quiz_name: string;
|
|
owner_id: string;
|
|
quiz_mode: string;
|
|
active_date: number;
|
|
tab_name?: string;
|
|
tab_index?: number;
|
|
list_index?: number;
|
|
questions: any[];
|
|
results: any[];
|
|
};
|
|
const existing = await sqlGetQuizConfig(quizConfig.quiz_config_id);
|
|
if (existing) {
|
|
await sqlUpdateQuizConfig(quizConfig.quiz_config_id, {
|
|
quiz_name: quizConfig.quiz_name,
|
|
owner_id: quizConfig.owner_id,
|
|
quiz_mode: quizConfig.quiz_mode,
|
|
active_date: quizConfig.active_date,
|
|
tab_name: quizConfig.tab_name,
|
|
tab_index: quizConfig.tab_index,
|
|
list_index: quizConfig.list_index,
|
|
questions: quizConfig.questions,
|
|
results: quizConfig.results,
|
|
});
|
|
} else {
|
|
await sqlCreateQuizConfig(
|
|
quizConfig.quiz_config_id,
|
|
quizConfig.quiz_name,
|
|
quizConfig.owner_id,
|
|
quizConfig.quiz_mode,
|
|
quizConfig.questions,
|
|
quizConfig.results,
|
|
quizConfig.tab_name,
|
|
quizConfig.tab_index,
|
|
quizConfig.list_index,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "quiz_questions": {
|
|
const question = value as {
|
|
id: string;
|
|
quiz_id: string;
|
|
owner_id: string;
|
|
language_1: string;
|
|
language_2: string;
|
|
text_1: string;
|
|
audio_file_1: string;
|
|
text_2: string;
|
|
audio_file_2: string;
|
|
order_num: number;
|
|
background_file: string;
|
|
background_image_data: string;
|
|
video_file: string;
|
|
video_file_data: string;
|
|
};
|
|
const existing = await sqlGetQuizQuestion(question.id);
|
|
if (existing) {
|
|
await sqlUpdateQuizQuestion(question.id, question);
|
|
} else {
|
|
await sqlCreateQuizQuestion(
|
|
question.id,
|
|
question.quiz_id,
|
|
question.owner_id,
|
|
question.language_1,
|
|
question.language_2,
|
|
question.text_1,
|
|
question.audio_file_1,
|
|
question.text_2,
|
|
question.audio_file_2,
|
|
question.order_num,
|
|
question.background_file,
|
|
question.background_image_data,
|
|
question.video_file,
|
|
question.video_file_data,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "quiz_results": {
|
|
const result = value as {
|
|
id: string;
|
|
quiz_config_id: string;
|
|
user_id: string;
|
|
success: boolean;
|
|
total_time: number;
|
|
help_count: number;
|
|
completed_on: number;
|
|
};
|
|
const existing = await sqlGetQuizResult(result.id);
|
|
if (existing) {
|
|
await sqlUpdateQuizResult(result.id, {
|
|
quiz_config_id: result.quiz_config_id,
|
|
user_id: result.user_id,
|
|
success: result.success,
|
|
total_time: result.total_time,
|
|
help_count: result.help_count,
|
|
completed_on: result.completed_on,
|
|
});
|
|
} else {
|
|
await sqlCreateQuizResult(
|
|
result.id,
|
|
result.quiz_config_id,
|
|
result.user_id,
|
|
result.success,
|
|
result.total_time,
|
|
result.help_count,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "brain_question_progress": {
|
|
const progress = value as BrainProgress;
|
|
await sqlUpsertBrainProgress(progress);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
private deleteFromSql(key: Deno.KvKey): void {
|
|
const [namespace, id] = key;
|
|
if (typeof namespace !== "string") {
|
|
return;
|
|
}
|
|
|
|
switch (namespace) {
|
|
case "users":
|
|
sqlDeleteUser(id as string);
|
|
break;
|
|
case "user_roles":
|
|
sqlDeleteUserRole(id as string);
|
|
break;
|
|
case "state_keys":
|
|
sqlDeleteStateKey(id as string);
|
|
break;
|
|
case "user_tokens":
|
|
sqlDeleteUserTokens(id as string);
|
|
break;
|
|
case "user_pins":
|
|
sqlDeleteUserPin(id as string);
|
|
break;
|
|
case "session_configs": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (recordId) sqlDeleteSessionConfig(recordId);
|
|
break;
|
|
}
|
|
case "quiz_configs": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (recordId) {
|
|
sqlDeleteQuizConfig(recordId);
|
|
sqlDeleteQuizQuestionsByQuiz(recordId);
|
|
sqlDeleteQuizResultsByQuiz(recordId);
|
|
}
|
|
break;
|
|
}
|
|
case "quiz_questions": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (recordId) sqlDeleteQuizQuestion(recordId);
|
|
break;
|
|
}
|
|
case "quiz_results": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (recordId) sqlDeleteQuizResult(recordId);
|
|
break;
|
|
}
|
|
case "brain_question_progress": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (recordId) sqlDeleteBrainProgress(recordId);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async fetchFromSql<T = unknown>(key: Deno.KvKey): Promise<T | null> {
|
|
const [namespace, id] = key;
|
|
if (typeof namespace !== "string") {
|
|
return null;
|
|
}
|
|
|
|
switch (namespace) {
|
|
case "users": {
|
|
const user = await sqlGetUser(id as string);
|
|
if (!user) return null;
|
|
return {
|
|
id: user.id,
|
|
name: user.name || "",
|
|
email: user.email || "",
|
|
phone: user.phone || "",
|
|
address_1: user.address_1 || "",
|
|
address_2: user.address_2 || "",
|
|
city: user.city || "",
|
|
state: user.state || "",
|
|
zip: user.zip || "",
|
|
country: user.country || "",
|
|
user_type: user.user_type,
|
|
onboarded_landlord: user.onboarded_landlord ?? false,
|
|
onboarded_tenant: user.onboarded_tenant ?? false,
|
|
email_verified: user.email_verified,
|
|
banking_setup_complete: user.banking_setup_complete,
|
|
onboarding_step: user.onboarding_step,
|
|
encrypted_data: user.encrypted_data,
|
|
started_on: user.started_on || Date.now(),
|
|
updated_on: user.updated_on || Date.now(),
|
|
} as T;
|
|
}
|
|
|
|
case "roles": {
|
|
const roles = await sqlGetAllRoles();
|
|
const role = roles.find((r: any) => r.id === id);
|
|
if (!role) return null;
|
|
return {
|
|
id: role.id,
|
|
description: role.description || "",
|
|
} as T;
|
|
}
|
|
|
|
case "user_roles": {
|
|
const roles = await sqlGetUserRoles(id as string);
|
|
if (!roles || roles.length === 0) return null;
|
|
return roles.map((r: any) => r.roleId).filter((r: any) =>
|
|
r !== ""
|
|
) as T;
|
|
}
|
|
|
|
case "state_keys": {
|
|
const stateKeys = await sqlGetStateKeys(id as string);
|
|
if (!stateKeys || stateKeys.length === 0) return null;
|
|
const sortedKeys = stateKeys.sort((a: any, b: any) =>
|
|
b.created_on - a.created_on
|
|
);
|
|
return {
|
|
seal_key: sortedKeys[0].seal_key,
|
|
state_id: sortedKeys[0].state_id,
|
|
cookie_hash: sortedKeys[0].cookie_hash,
|
|
created_on: sortedKeys[0].created_on,
|
|
} as T;
|
|
}
|
|
|
|
case "financial_balances": {
|
|
const balances = await sqlGetFinancialBalancesByUser(id as string);
|
|
if (!balances || balances.length === 0) return null;
|
|
return balances[0] as T;
|
|
}
|
|
|
|
case "user_tokens": {
|
|
const tokens = await sqlGetUserTokens(id as string);
|
|
if (!tokens) return null;
|
|
return {
|
|
user_id: tokens.user_id,
|
|
state_id: tokens.state_id,
|
|
access_token: tokens.access_token,
|
|
refresh_token: tokens.refresh_token,
|
|
created_on: tokens.created_on,
|
|
updated_on: tokens.updated_on,
|
|
} as T;
|
|
}
|
|
|
|
case "user_pins": {
|
|
const userPin = await sqlGetUserPin(id as string);
|
|
if (!userPin) return null;
|
|
return {
|
|
user_id: userPin.user_id,
|
|
pin: userPin.pin,
|
|
created_on: userPin.created_on,
|
|
modified_on: userPin.modified_on,
|
|
} as T;
|
|
}
|
|
|
|
case "session_configs": {
|
|
const owner = key[1] as string | undefined;
|
|
if (!owner) return null;
|
|
const sessionConfig = await sqlGetSessionConfig(owner);
|
|
if (!sessionConfig) return null;
|
|
return {
|
|
device_id: sessionConfig.device_id,
|
|
user_id: sessionConfig.user_id,
|
|
other_data: sessionConfig.other_data,
|
|
active_date: sessionConfig.active_date,
|
|
quiz_configs: sessionConfig.quiz_configs,
|
|
active_route: sessionConfig.active_route,
|
|
page_data: sessionConfig.page_data,
|
|
active_quiz_index: sessionConfig.active_quiz_index,
|
|
active_question_index: sessionConfig.active_question_index,
|
|
} as T;
|
|
}
|
|
|
|
case "quiz_configs": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (!recordId) return null;
|
|
const quizConfig = await sqlGetQuizConfig(recordId);
|
|
if (!quizConfig) return null;
|
|
return {
|
|
quiz_config_id: quizConfig.quiz_config_id,
|
|
quiz_name: quizConfig.quiz_name,
|
|
owner_id: quizConfig.owner_id,
|
|
quiz_mode: quizConfig.quiz_mode,
|
|
active_date: quizConfig.active_date,
|
|
tab_name: quizConfig.tab_name,
|
|
tab_index: quizConfig.tab_index,
|
|
list_index: quizConfig.list_index,
|
|
questions: quizConfig.questions,
|
|
results: quizConfig.results,
|
|
} as T;
|
|
}
|
|
|
|
case "quiz_questions": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (!recordId) return null;
|
|
const question = await sqlGetQuizQuestion(recordId);
|
|
if (!question) return null;
|
|
return {
|
|
id: question.id,
|
|
quiz_id: question.quiz_id,
|
|
owner_id: question.owner_id,
|
|
question_index: question.question_index,
|
|
language_1: question.language_1,
|
|
language_2: question.language_2,
|
|
text_1: question.text_1,
|
|
audio_file_1: question.audio_file_1,
|
|
text_2: question.text_2,
|
|
audio_file_2: question.audio_file_2,
|
|
order_num: question.order_num,
|
|
background_file: question.background_file,
|
|
background_image_data: question.background_image_data,
|
|
video_file: question.video_file,
|
|
video_file_data: question.video_file_data,
|
|
} as T;
|
|
}
|
|
|
|
case "quiz_results": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (!recordId) return null;
|
|
const result = await sqlGetQuizResult(recordId);
|
|
if (!result) return null;
|
|
return {
|
|
id: result.id,
|
|
quiz_config_id: result.quiz_config_id,
|
|
user_id: result.user_id,
|
|
success: result.success,
|
|
total_time: result.total_time,
|
|
help_count: result.help_count,
|
|
completed_on: result.completed_on,
|
|
} as T;
|
|
}
|
|
|
|
case "brain_question_progress": {
|
|
const recordId = key[2] as string | undefined;
|
|
if (!recordId) return null;
|
|
const progress = await sqlGetBrainProgressById(recordId);
|
|
if (!progress) return null;
|
|
return {
|
|
id: progress.id,
|
|
user_id: progress.user_id,
|
|
quiz_id: progress.quiz_id,
|
|
question_id: progress.question_id,
|
|
current_mode: progress.current_mode,
|
|
mode_index: progress.mode_index ?? 0,
|
|
attempts: progress.attempts,
|
|
first_try_successes: progress.first_try_successes,
|
|
consecutive_first_try: progress.consecutive_first_try ?? 0,
|
|
last_result: progress.last_result,
|
|
next_due_at: progress.next_due_at,
|
|
interval_days: progress.interval_days,
|
|
ease_factor: progress.ease_factor,
|
|
is_review_only: progress.is_review_only ?? false,
|
|
created_on: progress.created_on,
|
|
updated_on: progress.updated_on,
|
|
} as T;
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
const defaultPersistDir = decodeURIComponent(
|
|
new URL("../data", import.meta.url).pathname,
|
|
);
|
|
const persistPath = Deno.env.get("KV_CACHE_PATH") ?? defaultPersistDir;
|
|
export const kvStore = await KvCacheStore.initialize(persistPath);
|