diff --git a/src/ops-console/components/SchoolDetailsComponents/ClassDetailsPage.test.tsx b/src/ops-console/components/SchoolDetailsComponents/ClassDetailsPage.test.tsx
index 8cf8c6c43f..f26f0932c7 100644
--- a/src/ops-console/components/SchoolDetailsComponents/ClassDetailsPage.test.tsx
+++ b/src/ops-console/components/SchoolDetailsComponents/ClassDetailsPage.test.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { render, waitFor } from '@testing-library/react';
import ClassDetailsPage from './ClassDetailsPage';
import type { ClassRow, SchoolDetailsData } from './SchoolClass';
@@ -21,7 +20,9 @@ jest.mock('../../../utility/logger', () => ({
error: jest.fn(),
}));
-const mockSchoolStudents = jest.fn(() =>
);
+const mockSchoolStudents = jest.fn((_props: unknown) => (
+
+));
jest.mock('./SchoolStudents', () => ({
__esModule: true,
diff --git a/src/services/api/ServiceApi.ts b/src/services/api/ServiceApi.ts
index ded82c7450..4ff7db4665 100644
--- a/src/services/api/ServiceApi.ts
+++ b/src/services/api/ServiceApi.ts
@@ -1,4 +1,3 @@
-import User from '../../models/User';
import Course from '../../models/Course';
import Lesson from '../../models/Lesson';
import { StudentLessonResult } from '../../common/courseConstants';
@@ -31,7 +30,6 @@ import {
CampaignListingStatus,
} from '../../common/constants';
import { AvatarObj } from '../../components/animation/Avatar';
-import { DocumentData } from 'firebase/firestore';
import {
RoleType,
CreateSchoolNoteInput,
@@ -48,7 +46,6 @@ import {
UserStickerProgress,
} from '../../interface/modelInterfaces';
import { Database, Json } from '../database';
-import logger from '../../utility/logger';
export interface LeaderboardInfo {
weekly: StudentLeaderboardInfo[];
diff --git a/src/services/api/SqliteApi.ts b/src/services/api/SqliteApi.ts
index 0b6b9e6807..56621192ae 100644
--- a/src/services/api/SqliteApi.ts
+++ b/src/services/api/SqliteApi.ts
@@ -1,221 +1,9 @@
-import {
- CapacitorSQLite,
- DBSQLiteValues,
- SQLiteConnection,
- SQLiteDBConnection,
- capSQLiteResult,
- capSQLiteVersionUpgrade,
-} from '@capacitor-community/sqlite';
-import { App } from '@capacitor/app';
-import { Capacitor } from '@capacitor/core';
-import { LiveUpdate } from '@capawesome/capacitor-live-update';
-import { v4 as uuidv4 } from 'uuid';
-import {
- AVATARS,
- BASE_NAME,
- CACHETABLES,
- CHIMPLE_DIGITAL_SKILLS,
- CHIMPLE_ENGLISH,
- CHIMPLE_HINDI,
- CHIMPLE_MATHS,
- CLASS,
- COURSES,
- CURRENT_SQLITE_VERSION,
- CoordinatorAPIResponse,
- CoordinatorInfo,
- DEFAULT_LOCALE_ID,
- DEFAULT_SUBJECT_IDS,
- DELETED_CLASSES,
- EVENTS,
- EnumType,
- FilteredSchoolsForSchoolListingOps,
- GRADE1_KANNADA,
- GRADE1_MARATHI,
- GeoDataParams,
- LATEST_LEARNING_PATH,
- LATEST_STARS,
- LIDO_ASSESSMENT,
- LIVE_QUIZ,
- LeaderboardDropdownList,
- LeaderboardRewards,
- MODES,
- MUTATE_TYPES,
- OTHER_CURRICULUM,
- PROFILETYPE,
- PrincipalAPIResponse,
- PrincipalInfo,
- RESULT_STATUS,
- REWARD_LEARNING_PATH,
- REWARD_LESSON,
- STUDENT_RESULT,
- RequestTypes,
- SCHOOL,
- STATUS,
- SchoolRoleMap,
- SchoolVisitAction,
- SchoolVisitType,
- SearchSchoolsParams,
- SearchSchoolsResult,
- StudentAPIResponse,
- StudentInfo,
- TABLES,
- SOURCE,
- TabType,
- TableTypes,
- TeacherAPIResponse,
- TeacherInfo,
-} from '../../common/constants';
-import { StudentLessonResult } from '../../common/courseConstants';
-import { AvatarObj } from '../../components/animation/Avatar';
-import {
- PaginatedResponse,
- RoleType,
- SchoolNote,
- StickerBook,
- StickerMeta,
- UserStickerProgress,
-} from '../../interface/modelInterfaces';
-import Course from '../../models/Course';
-import Lesson from '../../models/Lesson';
-import {
- UserSchoolClassParams,
- UserSchoolClassResult,
-} from '../../ops-console/pages/NewUserPageOps';
-import { FCSchoolStats } from '../../ops-console/pages/SchoolDetailsPage';
-import { store } from '../../redux/store';
-import { setGlobalLoading } from '../../redux/slices/auth/authSlice';
-import {
- readAssignmentCartFromStorage,
- writeAssignmentCartToStorage,
-} from '../../teachers-module/pages/AssignmentCartStorage';
-import logger from '../../utility/logger';
-import { isRecoverableStorageError } from '../../utility/recoverableStorageError';
-import { ensureLocalStickerBookSvgUri } from '../../utility/stickerBookAssets';
-import { Util } from '../../utility/util';
-import { getDailyRewardClaimedEvent } from '../../analytics/rewardEvents';
-import type { SqlStatement } from '../../workers/background.worker.types';
-import { runBackgroundWorkerStreamingSync } from '../../workers/backgroundWorkerClient';
-import { Json } from '../database';
-import { APIMode, ServiceConfig } from '../ServiceConfig';
-import {
- AssignmentBatchGroupRow,
- AssignmentCartData,
- AssignmentDateRangeData,
- CampaignCancellationDetails,
- CampaignAssignmentOptions,
- CampaignAssignmentOptionsParams,
- CampaignDashboardMetric,
- CampaignListingItem,
- CampaignListingParams,
- CampaignAudienceOptions,
- CampaignAudiencePayload,
- CampaignAudienceSummary,
- CampaignAudienceSummaryParams,
- CampaignSavedAudienceGroup,
- CampaignSetupOptions,
- ClassMetricsForClassListingRow,
- CreateCampaignSetupPayload,
- CreateCampaignSetupResult,
- GetSchoolsWithProgramAccessParams,
- JoinClassInviteLookupResult,
- LeaderboardInfo,
- LaunchCampaignPayload,
- OpsStudentPerformanceBandRow,
- OpsStudentPerformanceBandsParams,
- SchoolProgramAccessResponse,
- ServiceApi,
- CampaignAssignmentsResponse,
- CampaignRewardsReportParams,
- CampaignRewardsReportResponse,
- CampaignOption,
- CampaignAssignmentFilters,
- CampaignMessagingQueryParams,
- CampaignMessagingResponse,
- UpdateCampaignMessagingRowPayload,
-} from './ServiceApi';
+import { ServiceApi } from './ServiceApi';
import { SupabaseApi } from './SupabaseApi';
-import { isAssessmentBatchClosed } from '../assessment/assessmentBatchStatus.service';
+import { SqliteApiPal } from './sqlite/SqliteApi.pal';
-type ImportJsonTable = {
- name: string;
- schema?: unknown[];
- values?: unknown[][];
- indexes?: unknown[];
- triggers?: unknown[];
-};
-
-type ImportJsonData = {
- database: string;
- version: number;
- encrypted: boolean;
- mode: string;
- tables: ImportJsonTable[];
- views?: unknown[];
- overwrite?: boolean;
-};
-
-export class SqliteApi implements ServiceApi {
+export class SqliteApi extends SqliteApiPal implements ServiceApi {
public static i: SqliteApi;
- private _db: SQLiteDBConnection | undefined;
- private _sqlite: SQLiteConnection | undefined;
- private DB_NAME = 'db_issue10';
- private DB_VERSION = 15;
- // Tracks the native shell plus active hot-update bundle so new bundled data is imported once per app asset version.
- private BUNDLED_IMPORT_APP_VERSION_KEY = 'bundledImportAppVersion';
- private BUNDLED_IMPORT_PULL_SYNC_TABLE = 'pull_sync_info';
- private BUNDLED_IMPORT_TABLES = new Set([
- TABLES.Curriculum,
- TABLES.Subject,
- TABLES.Grade,
- TABLES.Language,
- TABLES.Badge,
- TABLES.Course,
- TABLES.Sticker,
- TABLES.Reward,
- TABLES.Chapter,
- TABLES.Lesson,
- TABLES.ChapterLesson,
- TABLES.ChapterLinks,
- TABLES.RiveReward,
- TABLES.Framework,
- TABLES.Domain,
- TABLES.Competency,
- TABLES.Outcome,
- TABLES.Skill,
- TABLES.SkillRelation,
- TABLES.SkillLesson,
- TABLES.SubjectLesson,
- TABLES.LanguageLocale,
- TABLES.Locale,
- TABLES.StickerBook,
- ]);
- private _serverApi: SupabaseApi;
- private _currentMode: MODES;
- private _currentStudent: TableTypes<'user'> | undefined;
- private _currentClass: TableTypes<'class'> | undefined;
- private _currentSchool: TableTypes<'school'> | undefined;
- private chapterCourseIdCache = new Map();
- private courseLanguageIdCache = new Map();
- private courseLanguageIdPromiseCache = new Map<
- string,
- Promise
- >();
- private _currentCourse:
- | Map | undefined>
- | undefined;
- private _syncTableData: Record = {};
- private _tablesNeedingFullSync = new Set();
- private _tableColumnsCache = new Map();
- private _initPromise: Promise | null = null;
-
- private _syncInProgress: boolean = false;
- private _syncRequestedAgain: boolean = false;
- private _retryRefreshTables: TABLES[] = [];
- private _postSyncAssetPrefetchScheduled: boolean = false;
- private _postSyncAssetPrefetchRequestedAt: number | null = null;
- private _shouldImportBundledDataAfterUpgrade: boolean = false;
-
- private _cachedRewards: TableTypes<'rive_reward'>[] | undefined;
public static getI(): SqliteApi {
if (!SqliteApi.i) {
@@ -225,132 +13,6 @@ export class SqliteApi implements ServiceApi {
return SqliteApi.i;
}
- private async resolveCourseLanguageId(
- courseId: string,
- ): Promise {
- // Reuse successful course-language resolution across chapter lesson fetches.
- if (this.courseLanguageIdCache.has(courseId)) {
- return this.courseLanguageIdCache.get(courseId) ?? null;
- }
-
- // Deduplicate concurrent lookups for the same course while one query is in flight.
- const inFlightPromise = this.courseLanguageIdPromiseCache.get(courseId);
- if (inFlightPromise) {
- return inFlightPromise;
- }
-
- const languagePromise = (async () => {
- const courseRes = await this.executeQuery(
- `
- SELECT code
- FROM ${TABLES.Course}
- WHERE id = ?
- AND is_deleted = 0
- LIMIT 1;
- `,
- [courseId],
- );
- if (!courseRes) {
- throw new Error(
- `Failed to fetch course code while resolving language for course ${courseId}`,
- );
- }
-
- const courseCode = (
- ((courseRes as DBSQLiteValues | undefined)?.values?.[0] ?? {}) as {
- code?: string | null;
- }
- ).code
- ?.trim()
- .toLowerCase();
- const courseLanguageCode =
- courseCode === COURSES.MATHS
- ? COURSES.ENGLISH
- : courseCode?.includes('-')
- ? courseCode.split('-').pop()
- : courseCode;
-
- if (!courseLanguageCode) {
- return null;
- }
-
- const languageRes = await this.executeQuery(
- `
- SELECT id
- FROM ${TABLES.Language}
- WHERE LOWER(code) = ?
- AND is_deleted = 0
- LIMIT 1;
- `,
- [courseLanguageCode],
- );
- if (!languageRes) {
- throw new Error(
- `Failed to fetch language id while resolving language for course ${courseId}`,
- );
- }
-
- const courseLanguageId = (
- ((languageRes as DBSQLiteValues | undefined)?.values?.[0] ?? {}) as {
- id?: string | null;
- }
- ).id;
-
- return courseLanguageId ?? null;
- })()
- .then((languageId) => {
- // Cache only confirmed language ids so transient query failures can retry later.
- if (languageId) {
- this.courseLanguageIdCache.set(courseId, languageId);
- }
- return languageId;
- })
- .finally(() => {
- this.courseLanguageIdPromiseCache.delete(courseId);
- });
-
- this.courseLanguageIdPromiseCache.set(courseId, languagePromise);
- return languagePromise;
- }
-
- private async resolveCourseLanguageIdForChapter(
- chapterId: string,
- ): Promise {
- const cachedCourseId = this.chapterCourseIdCache.get(chapterId);
- if (cachedCourseId !== undefined) {
- return cachedCourseId
- ? this.resolveCourseLanguageId(cachedCourseId)
- : Promise.resolve(null);
- }
-
- const courseRes = await this.executeQuery(
- `
- SELECT course_id
- FROM ${TABLES.Chapter}
- WHERE id = ?
- AND is_deleted = 0
- LIMIT 1;
- `,
- [chapterId],
- );
- if (!courseRes) {
- throw new Error(
- `Failed to fetch chapter course while resolving language for chapter ${chapterId}`,
- );
- }
-
- const courseId = (
- ((courseRes as DBSQLiteValues | undefined)?.values?.[0] ?? {}) as {
- course_id?: string | null;
- }
- ).course_id;
-
- // Keep the chapter -> course mapping only when we resolved a real course id.
- if (courseId) {
- this.chapterCourseIdCache.set(chapterId, courseId);
- }
- return courseId ? this.resolveCourseLanguageId(courseId) : null;
- }
public static async getInstance(): Promise {
if (!SqliteApi.i) {
SqliteApi.i = new SqliteApi();
@@ -359,11206 +21,4 @@ export class SqliteApi implements ServiceApi {
await SqliteApi.i.ensureInitialized();
return SqliteApi.i;
}
-
- private async ensureInitialized(): Promise {
- if (this._db && this._sqlite) return;
- if (!this._initPromise) {
- this._initPromise = this.initializeWithRetry();
- }
- await this._initPromise;
- }
-
- private isRecoverableInitError(error: unknown): boolean {
- return isRecoverableStorageError(error);
- }
-
- private resetDbHandles(): void {
- this._db = undefined;
- }
-
- private async initializeWithRetry(
- attempts = 3,
- delayMs = 400,
- ): Promise {
- let lastError: unknown;
-
- for (let attempt = 1; attempt <= attempts; attempt++) {
- try {
- await this.init();
- return;
- } catch (error) {
- lastError = error;
- this.resetDbHandles();
-
- if (!this.isRecoverableInitError(error) || attempt === attempts) {
- this._initPromise = null;
- throw error;
- }
-
- // Resume can briefly race with an in-flight native SQLite connection,
- // so retry a couple of times before surfacing the failure as real.
- logger.warn(
- 'SqliteApi init failed during recoverable resume window. Retrying.',
- {
- attempt,
- attempts,
- error,
- },
- );
- await new Promise((resolve) => window.setTimeout(resolve, delayMs));
- }
- }
-
- this._initPromise = null;
- throw lastError;
- }
-
- private async init(): Promise {
- SupabaseApi.getInstance();
- const platform = Capacitor.getPlatform();
- this._sqlite = new SQLiteConnection(CapacitorSQLite);
- if (platform === 'web') {
- const jeepEl = document.createElement('jeep-sqlite');
- document.body.appendChild(jeepEl);
-
- await customElements.whenDefined('jeep-sqlite');
-
- await this._sqlite.initWebStore();
- }
-
- let ret: capSQLiteResult | undefined;
- let isConn: boolean | undefined;
- try {
- ret = await this._sqlite.checkConnectionsConsistency();
- isConn = (await this._sqlite.isConnection(this.DB_NAME, false)).result;
- } catch (error) {
- logger.error('π ~ Api ~ init ~ error:', error);
- }
- try {
- const localVersion = localStorage.getItem(CURRENT_SQLITE_VERSION);
-
- if (
- localVersion &&
- !Number.isNaN(localVersion) &&
- Number(localVersion) !== this.DB_VERSION
- ) {
- const upgradeStatements: capSQLiteVersionUpgrade[] = [];
- const localVersionNumber = Number(localVersion);
-
- const data = await fetch('databases/upgradeStatements.json');
-
- if (!data || !data.ok) return;
- const upgradeStatementsMap: Record<
- string,
- { statements?: string[]; tableChanges?: Record }
- > = await data.json();
-
- for (
- let version = localVersionNumber + 1;
- version <= this.DB_VERSION;
- version++
- ) {
- const versionData = upgradeStatementsMap[version];
-
- if (versionData && versionData['statements']) {
- const currentStatements = [...versionData['statements']];
-
- // Track tables with schema changes for forced full sync
- for (const statement of versionData['statements']) {
- const match = statement.match(
- /(?:ALTER|CREATE|DROP)\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i,
- );
- if (match && match[1]) {
- const tableName = match[1];
- // Mark this table for full sync (will use old timestamp)
- this._tablesNeedingFullSync.add(tableName);
- logger.warn(
- 'setUpDatabase: Auto-detected schema change for table: ' +
- tableName +
- '. Will force full sync.',
- );
- }
- }
-
- upgradeStatements.push({
- toVersion: version,
- statements: currentStatements,
- });
- }
-
- if (versionData['tableChanges']) {
- for (const tableName in versionData['tableChanges']) {
- const changeDate = versionData['tableChanges'][tableName];
- if (!this._syncTableData[tableName]) {
- this._syncTableData[tableName] = changeDate;
- } else {
- if (
- new Date(this._syncTableData[tableName]) >
- new Date(changeDate)
- ) {
- this._syncTableData[tableName] = changeDate;
- }
- }
- }
- }
- }
-
- logger.info(
- 'π ~ SqliteApi ~ init ~ upgradeStatements:',
- upgradeStatements,
- );
-
- await this._sqlite.addUpgradeStatement(this.DB_NAME, upgradeStatements);
- this._shouldImportBundledDataAfterUpgrade = true;
-
- localStorage.setItem(
- CURRENT_SQLITE_VERSION,
- this.DB_VERSION.toString(),
- );
- }
- } catch (error) {
- logger.error('π ~ SqliteApi ~ init ~ error:', JSON.stringify(error));
- }
-
- if (ret && ret.result && isConn) {
- this._db = await this._sqlite.retrieveConnection(this.DB_NAME, false);
- } else {
- this._db = await this._sqlite.createConnection(
- this.DB_NAME,
- false,
- 'no-encryption',
- this.DB_VERSION,
- false,
- );
- }
- try {
- await this._db?.open();
- } catch (err) {
- logger.error('π ~ SqliteApi ~ init ~ err:', err);
- throw err;
- }
- await this.setUpDatabase();
- }
-
- public async close(): Promise {
- if (this._initPromise) {
- try {
- await this._initPromise;
- } catch (error) {
- logger.error(
- 'Error waiting for SQLite initialization before close:',
- error,
- );
- }
- }
-
- const db = this._db;
- const sqlite = this._sqlite;
- if (!db || !sqlite) return;
-
- try {
- await db.close();
- } catch (error) {
- logger.error('Error closing SQLite database:', error);
- }
-
- try {
- await sqlite.closeConnection(this.DB_NAME, false);
- } catch (error) {
- logger.error('Error closing SQLite connection:', error);
- } finally {
- this.resetDbHandles();
- this._initPromise = null;
- }
- }
-
- private async setUpDatabase() {
- if (!this._db || !this._sqlite) return;
-
- let res1: DBSQLiteValues | undefined = undefined;
- try {
- const stmt =
- "SELECT COUNT(*) as count FROM sqlite_master WHERE type='table';";
- res1 = await this._db.query(stmt);
- // logger.info("π sqlite_master count result:", res1);
- } catch (error) {
- logger.error(
- 'π ~ SqliteApi ~ setUpDatabase ~ error:',
- JSON.stringify(error),
- );
- }
- if (
- !res1 ||
- !res1.values ||
- !res1.values.length ||
- res1.values[0].count < 10
- ) {
- try {
- try {
- // logger.info("β¬οΈ About to import SQLite schema from JSON");
- const importData = await fetch('databases/import.json');
- if (!importData || !importData.ok) return;
- const importJson = JSON.stringify((await importData.json()) ?? {});
- const resImport = await this._sqlite.importFromJson(importJson);
- // logger.info("β
importFromJson SUCCESS:", resImport);
- localStorage.setItem(
- CURRENT_SQLITE_VERSION,
- this.DB_VERSION.toString(),
- );
- // Fresh installs already imported the bundled data, so record the marker to avoid re-importing on the next launch.
- localStorage.setItem(
- this.BUNDLED_IMPORT_APP_VERSION_KEY,
- await this.getCurrentAppVersionMarker(),
- );
- logger.info('π ~ SqliteApi ~ setUpDatabase ~ resImport:', resImport);
-
- // Keep current web behavior; avoid native full page reload on first import.
- if (!Capacitor.isNativePlatform()) {
- window.location.replace(BASE_NAME || '/');
- return;
- }
- } catch (error) {
- logger.info('π ~ SqliteApi ~ setUpDatabase ~ error:', error);
- }
- } catch (error) {
- logger.info('π ~ SqliteApi ~ setUpDatabase ~ error:', error);
- }
- } else {
- try {
- store.dispatch(setGlobalLoading(true));
- logger.warn(
- 'π ~ SqliteApi ~ Updating Local Database from import.json after app update',
- );
- await this.importBundledDataAfterUpgrade();
- } finally {
- store.dispatch(setGlobalLoading(false));
- logger.warn('π ~ SqliteApi ~ Local Database update complete');
- }
- }
- if (this._syncTableData) {
- const tableNames = Object.keys(this._syncTableData) ?? [];
- if (tableNames.length > 0) {
- const tables = "'" + tableNames.join("', '") + "'";
- const tablePullSync = `SELECT * FROM pull_sync_info WHERE table_name IN (${tables});`;
- const res = (await this._db?.query(tablePullSync))?.values ?? [];
- res.forEach((row) => {
- if (
- row.last_pulled &&
- new Date(this._syncTableData[row.table_name]) >
- new Date(row.last_pulled)
- ) {
- this._syncTableData[row.table_name] = row.last_pulled;
- }
- });
- for (const _tableName of Object.keys(this._syncTableData)) {
- const updatePullSyncQuery = `UPDATE pull_sync_info SET last_pulled = '${this._syncTableData[_tableName]}' WHERE table_name = '${_tableName}'`;
- logger.info(
- 'π ~ SqliteApi ~ setUpDatabase ~ updatePullSyncQuery:',
- updatePullSyncQuery,
- );
- await this.executeQuery(updatePullSyncQuery);
- }
- }
- }
-
- await this.checkAndSyncData();
- }
-
- private async importBundledDataAfterUpgrade(): Promise {
- if (!this._db || !this._sqlite) return;
-
- const currentAppVersion = await this.getCurrentAppVersionMarker();
- const storedAppVersion = localStorage.getItem(
- this.BUNDLED_IMPORT_APP_VERSION_KEY,
- );
- // Import after db.open() when either schema migrations ran or a newer bundled app asset version is active.
- const shouldImport =
- this._shouldImportBundledDataAfterUpgrade ||
- this.shouldImportBundledDataForAppVersion(
- storedAppVersion,
- currentAppVersion,
- );
-
- if (!shouldImport) return;
-
- try {
- const importData = await fetch('databases/import.json');
- if (!importData || !importData.ok) {
- logger.warn(
- 'SqliteApi: Unable to fetch bundled import.json for app update import.',
- {
- status: importData?.status,
- statusText: importData?.statusText,
- },
- );
- return;
- }
- logger.warn(
- 'SqliteApi: Fetched bundled import.json for app update import.',
- {
- status: importData.status,
- },
- );
-
- const importJson = ((await importData.json()) ?? {}) as ImportJsonData;
- const tables = (importJson.tables ?? []).filter(
- (table) =>
- this.BUNDLED_IMPORT_TABLES.has(table.name) &&
- (table.values?.length ?? 0) > 0,
- );
-
- if (tables.length === 0) {
- this._shouldImportBundledDataAfterUpgrade = false;
- // Empty eligible data is still a completed check for this app asset version.
- localStorage.setItem(
- this.BUNDLED_IMPORT_APP_VERSION_KEY,
- currentAppVersion,
- );
- return;
- }
-
- const bundledPullSyncInfo = this.getBundledPullSyncInfo(importJson);
- const importedTableNames = await this.upsertBundledImportTables(tables);
- await this.markBundledImportTablesPulled(
- importedTableNames,
- bundledPullSyncInfo,
- );
-
- if (!Capacitor.isNativePlatform()) {
- await this._sqlite.saveToStore(this.DB_NAME);
- }
-
- this._shouldImportBundledDataAfterUpgrade = false;
- // Store only after successful local ingestion so failed imports retry on the next launch.
- localStorage.setItem(
- this.BUNDLED_IMPORT_APP_VERSION_KEY,
- currentAppVersion,
- );
- logger.info(
- 'π ~ SqliteApi ~ importBundledDataAfterUpgrade ~ imported tables:',
- importedTableNames,
- );
- } catch (error) {
- logger.error(
- 'π ~ SqliteApi ~ importBundledDataAfterUpgrade ~ error:',
- error,
- );
- }
- }
-
- private async getCurrentAppVersionMarker(): Promise {
- if (!Capacitor.isNativePlatform()) {
- return `web-sqlite-${this.DB_VERSION}`;
- }
-
- try {
- const appInfo = await App.getInfo();
- const version = appInfo.version || `native-sqlite-${this.DB_VERSION}`;
- // LiveUpdate can replace import.json without changing the native APK version, so include the active bundle id.
- const bundleId = await this.getCurrentLiveUpdateBundleId();
- const nativeMarker = appInfo.build
- ? `${version}|${appInfo.build}`
- : version;
- return bundleId ? `${nativeMarker}|${bundleId}` : nativeMarker;
- } catch (error) {
- logger.warn(
- 'SqliteApi: Unable to read native app version for bundled import marker.',
- error,
- );
- return `native-sqlite-${this.DB_VERSION}`;
- }
- }
-
- private async getCurrentLiveUpdateBundleId(): Promise {
- try {
- const bundle = await LiveUpdate.getCurrentBundle();
- return bundle.bundleId || undefined;
- } catch (error) {
- logger.warn(
- 'SqliteApi: Unable to read LiveUpdate bundle for bundled import marker.',
- error,
- );
- return undefined;
- }
- }
-
- private shouldImportBundledDataForAppVersion(
- storedAppVersion: string | null,
- currentAppVersion: string,
- ): boolean {
- // Web keeps its existing fresh-import behavior; this marker is only for native app/hot-update assets.
- if (!Capacitor.isNativePlatform()) return false;
- // Old installs will not have this key, so run once to align local SQLite with the bundled import.
- if (!storedAppVersion) return true;
-
- // A different marker should import only when it is newer, preventing old APKs from re-importing after rollback.
- return (
- this.compareAppVersionMarkers(currentAppVersion, storedAppVersion) > 0
- );
- }
-
- private compareAppVersionMarkers(current: string, stored: string): number {
- // Parse both markers so native version/build can be compared before considering hot-update bundle changes.
- const currentVersion = this.parseAppVersionMarker(current);
- const storedVersion = this.parseAppVersionMarker(stored);
-
- // If either marker is not parseable, avoid importing on unknown differences because that could re-run on rollback.
- if (!currentVersion || !storedVersion) {
- return current === stored ? 0 : -1;
- }
-
- // Version strings can have different lengths, so compare missing parts as zero: 3.5 equals 3.5.0.
- const versionLength = Math.max(
- currentVersion.versionParts.length,
- storedVersion.versionParts.length,
- );
-
- // Native version decides whether this is an upgrade or rollback before build or bundle checks.
- for (let index = 0; index < versionLength; index++) {
- const currentPart = currentVersion.versionParts[index] ?? 0;
- const storedPart = storedVersion.versionParts[index] ?? 0;
- if (currentPart !== storedPart) return currentPart - storedPart;
- }
-
- // Build number catches same-version native releases.
- const buildComparison = currentVersion.build - storedVersion.build;
- if (buildComparison !== 0) return buildComparison;
-
- // Same native build and same hot-update bundle means this import already completed.
- if (currentVersion.bundleId === storedVersion.bundleId) return 0;
- // If the active bundle cannot be read, do not import repeatedly on an unknown bundle state.
- if (!currentVersion.bundleId) return 0;
-
- // Same native build with a different active LiveUpdate bundle means import.json may have changed.
- return 1;
- }
-
- private parseAppVersionMarker(
- marker: string,
- ): { versionParts: number[]; build: number; bundleId?: string } | undefined {
- // Current markers use pipes so semantic versions like 3.5.0 and bundle ids stay separate.
- const markerParts = marker.split('|');
- // First segment is the native version because native downgrade protection must be checked first.
- let versionMarker = markerParts[0] ?? '';
- // Second segment is the native build number when App.getInfo() provides it.
- let buildMarker = markerParts[1] ?? '';
- // Remaining segments belong to the LiveUpdate bundle id, which can contain arbitrary characters.
- const bundleId =
- markerParts.length > 2 ? markerParts.slice(2).join('|') : undefined;
-
- // Earlier marker format used version-build, so keep parsing it to avoid forcing a one-time false update.
- if (markerParts.length === 1) {
- const legacyBuildSeparatorIndex = marker.lastIndexOf('-');
- const legacyBuildMarker = marker.slice(legacyBuildSeparatorIndex + 1);
-
- // Treat the suffix as a build only when it is numeric; otherwise it may be part of a version label.
- if (legacyBuildSeparatorIndex > -1 && /^\d+$/.test(legacyBuildMarker)) {
- versionMarker = marker.slice(0, legacyBuildSeparatorIndex);
- buildMarker = legacyBuildMarker;
- }
- }
-
- // Extract numeric version pieces so 3.5.0, v3.5.0, and similar native strings compare consistently.
- const versionParts =
- versionMarker.match(/\d+/g)?.map((part) => Number(part)) ?? [];
- // Without numeric version parts we cannot safely decide upgrade versus rollback.
- if (versionParts.length === 0) return undefined;
-
- // Missing or malformed build should behave like build 0 instead of blocking app-version comparison.
- const build = buildMarker ? Number(buildMarker) : 0;
-
- return {
- versionParts,
- build: Number.isFinite(build) ? build : 0,
- bundleId,
- };
- }
-
- private quoteSqlIdentifier(identifier: string): string {
- return `"${identifier.replace(/"/g, '""')}"`;
- }
-
- private getBundledPullSyncInfo(
- importJson: ImportJsonData,
- ): Map {
- const pullSyncTable = (importJson.tables ?? []).find(
- (table) => table.name === this.BUNDLED_IMPORT_PULL_SYNC_TABLE,
- );
- if (!pullSyncTable) return new Map();
-
- const getColumnName = (column: unknown): string | undefined =>
- typeof column === 'object' &&
- column !== null &&
- 'column' in column &&
- typeof column.column === 'string'
- ? column.column
- : undefined;
-
- const tableNameIndex = (pullSyncTable.schema ?? []).findIndex(
- (column) => getColumnName(column) === 'table_name',
- );
- const lastPulledIndex = (pullSyncTable.schema ?? []).findIndex(
- (column) => getColumnName(column) === 'last_pulled',
- );
-
- if (tableNameIndex < 0 || lastPulledIndex < 0) return new Map();
-
- const bundledPullSyncInfo = new Map();
- for (const row of pullSyncTable.values ?? []) {
- const tableName = row[tableNameIndex];
- const lastPulled = row[lastPulledIndex];
- if (typeof tableName === 'string' && typeof lastPulled === 'string') {
- bundledPullSyncInfo.set(tableName, lastPulled);
- }
- }
-
- return bundledPullSyncInfo;
- }
-
- private async upsertBundledImportTables(
- tables: ImportJsonTable[],
- ): Promise {
- if (!this._db) return [];
-
- const importedTableNames: string[] = [];
- const batchSize = this.getSyncWriteTuning().defaultBatchSize;
-
- for (const table of tables) {
- const schema = table.schema ?? [];
- const values = table.values ?? [];
- if (schema.length === 0 || values.length === 0) continue;
-
- const importColumns = schema
- .map((column) =>
- typeof column === 'object' &&
- column !== null &&
- 'column' in column &&
- typeof column.column === 'string'
- ? column.column
- : undefined,
- )
- .filter((column): column is string => !!column);
- if (importColumns.length === 0) continue;
-
- const columns = await this.getTableColumns(table.name);
- if (!columns) continue;
- const existingColumns = new Set(columns);
- const columnIndexes = importColumns
- .map((column, index) => ({ column, index }))
- .filter(({ column }) => existingColumns.has(column));
- if (columnIndexes.length === 0) continue;
-
- const conflictColumn =
- columnIndexes.find(({ column }) => column === 'id')?.column ??
- columnIndexes[0].column;
- const insertColumns = columnIndexes.map(({ column }) => column);
- const quotedTableName = this.quoteSqlIdentifier(table.name);
- const quotedInsertColumns = insertColumns
- .map((column) => this.quoteSqlIdentifier(column))
- .join(', ');
- const placeholders = insertColumns.map(() => '?').join(', ');
- // Primary path matches the required bundled-data upsert behavior without guessing a conflict target.
- const statement = `INSERT OR REPLACE INTO ${quotedTableName} (${quotedInsertColumns}) VALUES (${placeholders});`;
- const updateColumns = insertColumns.filter(
- (column) => column !== conflictColumn,
- );
- const conflictClause =
- updateColumns.length > 0
- ? `DO UPDATE SET ${updateColumns
- .map((column) => {
- const quotedColumn = this.quoteSqlIdentifier(column);
- return `${quotedColumn} = excluded.${quotedColumn}`;
- })
- .join(', ')}`
- : 'DO NOTHING';
- const fallbackStatement = `INSERT INTO ${quotedTableName} (${quotedInsertColumns}) VALUES (${placeholders}) ON CONFLICT(${this.quoteSqlIdentifier(
- conflictColumn,
- )}) ${conflictClause};`;
-
- for (let offset = 0; offset < values.length; offset += batchSize) {
- const rows = values.slice(offset, offset + batchSize);
- const buildBatch = (sqlStatement: string): SqlStatement[] =>
- rows.map((row) => ({
- statement: sqlStatement,
- values: columnIndexes.map(({ index }) =>
- this.normalizeSqliteValue(row[index]),
- ),
- }));
-
- try {
- await this.executeSqlStatementBatch(buildBatch(statement));
- } catch (error) {
- logger.warn(
- 'SqliteApi: INSERT OR REPLACE bundled import failed. Retrying with conflict upsert.',
- error,
- );
- // Keep the previous conflict-upsert path as a compatibility fallback if INSERT OR REPLACE fails.
- await this.executeSqlStatementBatch(buildBatch(fallbackStatement));
- }
- await new Promise((resolve) => window.setTimeout(resolve, 0));
- }
-
- importedTableNames.push(table.name);
- }
-
- return importedTableNames;
- }
-
- private async markBundledImportTablesPulled(
- tableNames: string[],
- bundledPullSyncInfo: Map,
- ): Promise {
- if (!this._db || tableNames.length === 0) return;
-
- const fallbackLastPulled = new Date().toISOString();
- const statements = tableNames.map((tableName) => ({
- statement:
- 'INSERT OR REPLACE INTO pull_sync_info (table_name, last_pulled) VALUES (?, ?)',
- values: [
- tableName,
- bundledPullSyncInfo.get(tableName) ?? fallbackLastPulled,
- ],
- }));
-
- await this.executeSqlStatementBatch(statements);
-
- for (const tableName of tableNames) {
- delete this._syncTableData[tableName];
- this._tablesNeedingFullSync.delete(tableName);
- }
- }
-
- private async checkAndSyncData() {
- try {
- const config = ServiceConfig.getInstance(APIMode.SQLITE);
- const isUserLoggedIn = await config.authHandler.isUserLoggedIn();
-
- if (isUserLoggedIn) {
- logger.warn('checkAndSyncData: User logged in, triggering sync');
- const user = await config.authHandler.getCurrentUser();
-
- if (!user) {
- await this.syncDbNow();
- logger.warn('checkAndSyncData: No user, syncDbNow awaited');
- } else {
- this.syncDbNow();
- logger.warn('checkAndSyncData: User exists, syncDbNow called');
- }
- }
- } catch (error) {
- logger.warn('checkAndSyncData: Error during sync check: ' + error);
- }
- }
-
- private async executeQuery(
- statement: string,
- values?: any[] | undefined,
- isSQL92?: boolean | undefined,
- ) {
- await this.ensureInitialized();
- if (!this._db || !this._sqlite) return;
- const res = await this._db.query(statement, values, isSQL92);
- if (!Capacitor.isNativePlatform())
- await this._sqlite?.saveToStore(this.DB_NAME);
-
- return res;
- }
-
- private normalizeSqliteValue(value: unknown): unknown {
- if (Array.isArray(value)) return JSON.stringify(value);
- if (
- value &&
- typeof value === 'object' &&
- Object.getPrototypeOf(value) === Object.prototype
- ) {
- return JSON.stringify(value);
- }
- return value;
- }
-
- private isWebPlatform(): boolean {
- return Capacitor.getPlatform() === 'web';
- }
-
- private getSyncWriteTuning(): {
- defaultBatchSize: number;
- userTableBatchSize: number;
- rowsPerChunk: number;
- } {
- const nav =
- typeof navigator !== 'undefined'
- ? (navigator as Navigator & {
- deviceMemory?: number;
- hardwareConcurrency?: number;
- })
- : undefined;
- const deviceMemory =
- typeof nav?.deviceMemory === 'number' ? nav.deviceMemory : undefined;
- const hardwareConcurrency =
- typeof nav?.hardwareConcurrency === 'number'
- ? nav.hardwareConcurrency
- : undefined;
- const hasDeviceHints =
- typeof deviceMemory === 'number' ||
- typeof hardwareConcurrency === 'number';
-
- const isLowEndDevice =
- !hasDeviceHints ||
- (typeof deviceMemory === 'number' && deviceMemory <= 3) ||
- (typeof hardwareConcurrency === 'number' && hardwareConcurrency <= 4);
- const isMidRangeDevice =
- !isLowEndDevice &&
- ((typeof deviceMemory === 'number' && deviceMemory <= 6) ||
- (typeof hardwareConcurrency === 'number' && hardwareConcurrency <= 6));
-
- if (isLowEndDevice) {
- return {
- defaultBatchSize: 60,
- userTableBatchSize: 120,
- rowsPerChunk: 120,
- };
- }
-
- if (isMidRangeDevice) {
- return {
- defaultBatchSize: 100,
- userTableBatchSize: 200,
- rowsPerChunk: 180,
- };
- }
-
- return {
- defaultBatchSize: 140,
- userTableBatchSize: 280,
- rowsPerChunk: 260,
- };
- }
-
- private async executeSqlStatementBatch(
- batch: SqlStatement[],
- useImplicitTransaction = true,
- ) {
- if (!this._db || batch.length === 0) return;
- await this._db.executeSet(batch as any, useImplicitTransaction);
- }
-
- private async isSqliteTransactionActive(): Promise {
- if (!this._db) return false;
-
- try {
- return (await this._db.isTransactionActive()).result === true;
- } catch {
- return false;
- }
- }
-
- private schedulePostSyncAssetPrefetch(): void {
- if (!Capacitor.isNativePlatform() || this._postSyncAssetPrefetchScheduled) {
- return;
- }
-
- this._postSyncAssetPrefetchScheduled = true;
- this._postSyncAssetPrefetchRequestedAt = Date.now();
- logger.warn('[ANRGuard] Scheduled post-sync asset prefetch', {
- delayMs: 30000,
- visibilityState: document.visibilityState,
- });
- window.setTimeout(() => {
- this.runPostSyncAssetPrefetch();
- }, 30000);
- }
-
- private async runPostSyncAssetPrefetch(): Promise {
- const startedAt = Date.now();
- try {
- if (document.visibilityState !== 'visible') {
- logger.warn('[ANRGuard] Deferred post-sync prefetch while not visible');
- window.addEventListener(
- 'visibilitychange',
- () => {
- if (document.visibilityState === 'visible') {
- this.runPostSyncAssetPrefetch();
- }
- },
- { once: true },
- );
- return;
- }
-
- await this.prefetchStickerBookAssetsAfterSync();
- await new Promise((resolve) => {
- window.setTimeout(resolve, 1000);
- });
- await this.prefetchLidoCommonAudioAfterSync();
- logger.warn('[ANRGuard] Post-sync prefetch completed', {
- queueDelayMs: this._postSyncAssetPrefetchRequestedAt
- ? startedAt - this._postSyncAssetPrefetchRequestedAt
- : null,
- durationMs: Date.now() - startedAt,
- });
- } catch (error) {
- logger.warn('[ANRGuard] Post-sync prefetch failed', error);
- } finally {
- this._postSyncAssetPrefetchScheduled = false;
- this._postSyncAssetPrefetchRequestedAt = null;
- }
- }
-
- private async showToastWithRetry(
- message: string,
- actionLabel = 'Retry',
- duration = 15000,
- ): Promise {
- return new Promise((resolve) => {
- let resolved = false;
- let timeoutId: number | null = null;
- let overlay: HTMLDivElement | null = null;
-
- const finish = (val: boolean) => {
- if (resolved) return;
- resolved = true;
- try {
- if (overlay && overlay.parentElement)
- overlay.parentElement.removeChild(overlay);
- } catch {}
- if (timeoutId) window.clearTimeout(timeoutId);
- resolve(val);
- };
-
- try {
- // Ionic style presenter
- if (typeof (window as any).presentToast === 'function') {
- (window as any).presentToast({
- message,
- duration,
- position: 'bottom',
- color: 'warning',
- buttons: [
- {
- text: actionLabel,
- handler: () => finish(true),
- },
- ],
- });
- timeoutId = window.setTimeout(() => finish(false), duration + 200);
- return;
- }
-
- // Fallback DOM toast
- overlay = document.createElement('div');
- overlay.setAttribute(
- 'style',
- [
- 'position:fixed',
- 'left:12px',
- 'right:12px',
- 'bottom:20px',
- 'z-index:2147483647',
- 'display:flex',
- 'align-items:center',
- 'justify-content:space-between',
- 'gap:12px',
- 'padding:10px 14px',
- 'background:rgba(0,0,0,0.85)',
- 'color:#fff',
- 'border-radius:8px',
- "font-family:system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial",
- 'box-shadow:0 6px 18px rgba(0,0,0,0.3)',
- ].join(';'),
- );
-
- const msgSpan = document.createElement('div');
- msgSpan.textContent = message;
- msgSpan.style.flex = '1';
- msgSpan.style.fontSize = '13px';
- msgSpan.style.overflow = 'hidden';
- msgSpan.style.textOverflow = 'ellipsis';
- msgSpan.style.whiteSpace = 'nowrap';
-
- const btn = document.createElement('button');
- btn.textContent = actionLabel;
- btn.setAttribute(
- 'style',
- [
- 'margin-left:12px',
- 'padding:6px 10px',
- 'border-radius:6px',
- 'border:none',
- 'background:#fff',
- 'color:#000',
- 'cursor:pointer',
- 'font-weight:600',
- ].join(';'),
- );
- btn.onclick = () => finish(true);
-
- overlay.appendChild(msgSpan);
- overlay.appendChild(btn);
- document.body?.appendChild(overlay);
-
- timeoutId = window.setTimeout(() => finish(false), duration + 200);
- } catch (err) {
- logger.warn('Fallback toast failed:', err);
- timeoutId = window.setTimeout(() => finish(false), duration);
- }
- });
- }
-
- private async pullChanges(
- tableNames: TABLES[],
- isFirstSync?: boolean,
- isRefreshSync = false,
- ): Promise {
- if (!this._db) return;
-
- const isInitialFetch = isFirstSync;
- logger.info('π ~ pullChanges ~ isInitialFetch:', isInitialFetch);
-
- // Update pull_sync_info table with old timestamp for tables needing full sync
- const FORCE_FULL_SYNC_DATE = '2024-01-01T00:00:00.000Z';
- const LESSON_FORCE_FULL_SYNC_DATE = '2026-04-10T00:00:00.000Z';
- if (this._tablesNeedingFullSync.size > 0) {
- for (const tableName of this._tablesNeedingFullSync) {
- if (tableNames.includes(tableName as TABLES)) {
- const fullSyncDate =
- tableName === TABLES.Lesson
- ? LESSON_FORCE_FULL_SYNC_DATE
- : FORCE_FULL_SYNC_DATE;
- await this.executeQuery(
- `INSERT OR REPLACE INTO pull_sync_info (table_name, last_pulled) VALUES (?, ?)`,
- [tableName, fullSyncDate],
- );
- logger.info(`Forcing full sync for table: ${tableName}`);
- }
- }
- this._tablesNeedingFullSync.clear();
- }
-
- // ensure USER table syncs first (single-row, heavy JSON)
- const orderedTableNames = tableNames.includes(TABLES.User)
- ? [TABLES.User, ...tableNames.filter((t) => t !== TABLES.User)]
- : [...tableNames];
-
- const tables = orderedTableNames.map((t) => `'${t}'`).join(', ');
- const tablePullSync = `SELECT * FROM pull_sync_info WHERE table_name IN (${tables});`;
- let lastPullTables = new Map();
- try {
- const res = (await this._db.query(tablePullSync)).values ?? [];
- res.forEach((row) => lastPullTables.set(row.table_name, row.last_pulled));
- } catch (error) {
- logger.error('π ~ Api ~ syncDB ~ error:', error);
- await this.createSyncTables();
- }
- let data = new Map();
- if (isInitialFetch === true) {
- let attempt = 1;
- const maxAttempts = 5;
- while (true) {
- try {
- data = await this._serverApi.getTablesData(
- orderedTableNames,
- lastPullTables,
- isInitialFetch,
- );
- break;
- } catch (err) {
- logger.error(`β Attempt ${attempt}: getTablesData failed`, err);
- if (attempt < maxAttempts) {
- const delay = 500 * Math.pow(2, attempt);
- await new Promise((res) => setTimeout(res, delay));
- attempt += 1;
- continue;
- }
- logger.warn('β All retries failed. Truncating local tables...');
- if (!this._db) return;
- const query = `PRAGMA foreign_keys=OFF;`;
- const result = await this._db?.query(query);
- logger.info(result);
- for (const table of orderedTableNames) {
- const tableDel = `DELETE FROM "${table}";`;
- const res = await this._db.query(tableDel);
- logger.info(res);
- }
- const vaccum = `VACUUM;`;
- const resv = await this._db.query(vaccum);
- logger.info(resv);
- const querys = `PRAGMA foreign_keys=ON;`;
- const results = await this._db?.query(querys);
- logger.info(results);
- const userWantsRetry = await this.showToastWithRetry(
- 'Sync failed. Retry now?',
- );
- if (userWantsRetry) {
- logger.warn('π Final retry triggered by user.');
- attempt = 1;
- continue;
- }
- logger.warn('β User canceled final retry.');
- return;
- }
- }
- } else {
- data = await this._serverApi.getTablesData(
- orderedTableNames,
- lastPullTables,
- isInitialFetch,
- );
- }
-
- const lastPulled = new Date().toISOString();
- const syncWriteTuning = this.getSyncWriteTuning();
- const DEFAULT_DB_BATCH_SIZE = syncWriteTuning.defaultBatchSize;
- const SAFE_USER_BATCH_SIZE = syncWriteTuning.userTableBatchSize;
- const STREAM_ROWS_CHUNK = syncWriteTuning.rowsPerChunk;
- const tablesForWorker: Record = {};
- const tableColumnsByName: Record = {};
- const tablesWritten = new Set();
- const tableColumnEntries = await Promise.all(
- orderedTableNames.map(
- async (tableName) =>
- [tableName, await this.getTableColumns(tableName)] as const,
- ),
- );
- for (const [tableName, existingColumns] of tableColumnEntries) {
- const tableData = data.get(tableName) ?? [];
- if (tableData.length === 0) continue;
- if (!existingColumns || existingColumns.length === 0) continue;
- tablesForWorker[tableName] = tableData;
- tableColumnsByName[tableName] = existingColumns;
- tablesWritten.add(tableName);
- }
-
- const pullSyncStatements: SqlStatement[] = orderedTableNames.map(
- (tableName) => ({
- statement:
- 'INSERT OR REPLACE INTO pull_sync_info (table_name, last_pulled) VALUES (?, ?)',
- values: [tableName, lastPulled],
- }),
- );
- const isWebPlatform = this.isWebPlatform();
- let syncWriteTransactionOpen = false;
- let webStoreDirty = false;
-
- const beginSyncWriteTransaction = async () => {
- if (!this._db || syncWriteTransactionOpen) return;
- await this._db.beginTransaction();
- syncWriteTransactionOpen = await this.isSqliteTransactionActive();
- };
-
- const commitSyncWriteTransaction = async () => {
- if (!this._db || !syncWriteTransactionOpen) return;
- const isTransactionActive = await this.isSqliteTransactionActive();
- if (!isTransactionActive) {
- syncWriteTransactionOpen = false;
- webStoreDirty = false;
- return;
- }
- await this._db.commitTransaction();
- syncWriteTransactionOpen = false;
- if (isWebPlatform && webStoreDirty) {
- await this._sqlite?.saveToStore(this.DB_NAME);
- webStoreDirty = false;
- }
- };
-
- const rollbackSyncWriteTransaction = async () => {
- if (!this._db || !syncWriteTransactionOpen) return;
- try {
- if (await this.isSqliteTransactionActive()) {
- await this._db.rollbackTransaction();
- }
- } finally {
- syncWriteTransactionOpen = false;
- webStoreDirty = false;
- }
- };
-
- const writeSyncBatch = async (batch: SqlStatement[]) => {
- if (!batch.length) return;
- await this.executeSqlStatementBatch(batch, !syncWriteTransactionOpen);
- if (isWebPlatform) {
- webStoreDirty = true;
- }
- };
-
- try {
- await beginSyncWriteTransaction();
- try {
- await runBackgroundWorkerStreamingSync(
- {
- tables: tablesForWorker,
- tableColumns: tableColumnsByName,
- defaultBatchSize: DEFAULT_DB_BATCH_SIZE,
- userTableName: TABLES.User,
- userTableBatchSize: SAFE_USER_BATCH_SIZE,
- rowsPerChunk: STREAM_ROWS_CHUNK,
- },
- async (batch) => {
- await writeSyncBatch(batch);
- },
- );
- } catch (workerError) {
- logger.warn(
- 'Background worker sync batch generation failed:',
- workerError,
- );
- await rollbackSyncWriteTransaction();
- if (Capacitor.isNativePlatform()) {
- throw workerError;
- }
- logger.warn('Falling back to main-thread sync batch generation on web');
- await beginSyncWriteTransaction();
-
- for (const tableName of Object.keys(tablesForWorker)) {
- const existingColumns = tableColumnsByName[tableName] ?? [];
- const tableData = tablesForWorker[tableName] ?? [];
- if (!existingColumns.length || !tableData.length) continue;
- const isUserTable = tableName === TABLES.User;
- const batchSize = isUserTable
- ? SAFE_USER_BATCH_SIZE
- : DEFAULT_DB_BATCH_SIZE;
- let batchQueries: SqlStatement[] = [];
- let currentFieldNames: string[] | null = null;
- let currentRows: unknown[][] = [];
-
- const flushBatchRows = async () => {
- if (!currentFieldNames || currentRows.length === 0) {
- return;
- }
- const placeholdersPerRow = `(${currentFieldNames
- .map(() => '?')
- .join(', ')})`;
- const valuesPlaceholders = currentRows
- .map(() => placeholdersPerRow)
- .join(', ');
- const updateSetClause = currentFieldNames
- .filter((f) => f !== 'id')
- .map((f) => `${f} = excluded.${f}`)
- .join(', ');
- const statement = updateSetClause
- ? `
- INSERT INTO ${tableName} (${currentFieldNames.join(', ')})
- VALUES ${valuesPlaceholders}
- ON CONFLICT(id) DO UPDATE SET
- ${updateSetClause}
- `
- : `
- INSERT INTO ${tableName} (${currentFieldNames.join(', ')})
- VALUES ${valuesPlaceholders}
- ON CONFLICT(id) DO NOTHING;
- `;
- batchQueries.push({
- statement,
- values: currentRows.flat(),
- });
- currentFieldNames = null;
- currentRows = [];
-
- if (batchQueries.length >= batchSize) {
- await writeSyncBatch(batchQueries);
- batchQueries = [];
- }
- };
-
- for (const row of tableData) {
- const fieldNames = existingColumns.filter((columnName) =>
- Object.prototype.hasOwnProperty.call(row, columnName),
- );
- if (fieldNames.length === 0) continue;
- const fieldValues = fieldNames.map((f) =>
- this.normalizeSqliteValue(row[f]),
- );
- const maxRowsPerStatement = Math.max(
- Math.floor(900 / fieldNames.length),
- 1,
- );
- const fieldSignature = fieldNames.join('|');
- const currentSignature = currentFieldNames?.join('|');
-
- if (
- currentFieldNames &&
- (currentSignature !== fieldSignature ||
- currentRows.length >= maxRowsPerStatement)
- ) {
- await flushBatchRows();
- }
-
- if (!currentFieldNames) {
- currentFieldNames = fieldNames;
- }
-
- currentRows.push(fieldValues);
-
- if (currentRows.length >= maxRowsPerStatement) {
- await flushBatchRows();
- }
- }
- await flushBatchRows();
- if (batchQueries.length > 0) {
- await writeSyncBatch(batchQueries);
- }
- }
- }
-
- await writeSyncBatch(pullSyncStatements);
- await commitSyncWriteTransaction();
- } catch (error) {
- await rollbackSyncWriteTransaction();
- throw error;
- }
-
- // Update debug info (avoid expensive full JSON serialization on hot path).
- let totalpulledRows = 0;
- for (const value of data.values()) {
- if (Array.isArray(value) && value.length > 0) {
- totalpulledRows += value.length;
- }
- }
- const pulledRowsSizeInBytes = totalpulledRows * 128;
- this.updateDebugInfo(0, totalpulledRows, pulledRowsSizeInBytes);
-
- if (tablesWritten.has(TABLES.RiveReward)) {
- this._cachedRewards = undefined;
- }
-
- if (!isInitialFetch && !isRefreshSync) {
- const new_school = data.get(TABLES.School);
- const school_user_data = data.get(TABLES.SchoolUser);
- const hasSelectionUpdates =
- (new_school?.length ?? 0) > 0 ||
- (school_user_data?.length ?? 0) > 0 ||
- (data.get(TABLES.Class)?.length ?? 0) > 0 ||
- (data.get(TABLES.ClassUser)?.length ?? 0) > 0;
- if ((new_school && new_school?.length > 0) || hasSelectionUpdates) {
- const localSchoolRaw = localStorage.getItem(SCHOOL);
-
- if (localSchoolRaw) {
- let localSchool: TableTypes<'school'>;
-
- try {
- localSchool = JSON.parse(localSchoolRaw);
- } catch (e) {
- localStorage.removeItem(SCHOOL);
- logger.warn('invalid local school data removed');
- return;
- }
-
- const localSchoolId = localSchool?.id;
-
- if (!localSchoolId || !Array.isArray(school_user_data)) return;
-
- const deletedSchoolUser = school_user_data.find(
- (entry: TableTypes<'school_user'>) =>
- entry.school_id === localSchoolId && entry.is_deleted === true,
- );
-
- if (deletedSchoolUser) {
- localStorage.removeItem(SCHOOL);
- localStorage.removeItem(CLASS);
- logger.info('local school removed because school_user is_deleted');
- }
- }
- // Selection updates need one follow-up refresh, but refresh syncs must
- // not request themselves again or syncDbNow can loop indefinitely.
- await this.syncDbNow(Object.values(TABLES), [
- TABLES.Assignment,
- TABLES.Assignment_user,
- TABLES.School,
- TABLES.SchoolCourse,
- TABLES.Class,
- TABLES.ClassInvite_code,
- TABLES.Result,
- TABLES.User,
- TABLES.ClassUser,
- TABLES.SchoolUser,
- TABLES.ClassCourse,
- ]);
- }
-
- if (hasSelectionUpdates) {
- await this.reconcileCurrentClassSelection();
- }
- }
- }
-
- async getTableColumns(tableName: string): Promise {
- await this.ensureInitialized();
- const cachedColumns = this._tableColumnsCache.get(tableName);
- if (cachedColumns?.length) {
- return cachedColumns;
- }
- const query = `PRAGMA table_info(${tableName})`;
- const result = await this._db?.query(query);
- const columns = result?.values
- ?.map((row: any) => row.name)
- .filter((name): name is string => Boolean(name));
- if (columns?.length) {
- this._tableColumnsCache.set(tableName, columns);
- }
- return columns;
- }
-
- private async reconcileCurrentClassSelection() {
- const currentUser = await ServiceConfig.getI().authHandler.getCurrentUser();
- const currentSchool = Util.getCurrentSchool();
- const storedClass = Util.getCurrentClass();
-
- if (!currentUser?.id || !currentSchool?.id) {
- return;
- }
-
- const classes = await this.getClassesForSchool(
- currentSchool.id,
- currentUser.id,
- );
-
- if (!classes.length) {
- await Util.setCurrentClass(null);
- return;
- }
-
- const resolvedClass = storedClass
- ? (classes.find((classItem) => classItem.id === storedClass.id) ??
- classes[0])
- : classes[0];
-
- if (storedClass?.id !== resolvedClass.id) {
- await Util.setCurrentClass(resolvedClass);
- }
- }
-
- private async pushChanges(tableNames: TABLES[]) {
- if (!this._db) return false;
- const tables = "'" + tableNames.join("', '") + "'";
-
- const tablePushSync = `SELECT * FROM push_sync_info ORDER BY created_at;`;
- let res: any[] = [];
- try {
- res = (await this._db.query(tablePushSync)).values ?? [];
- logger.info('π ~ syncDB ~ tablePushSync:', res);
-
- this.updateDebugInfo(res.length, 0, 0); //update debug info
- } catch (error) {
- logger.error('π ~ Api ~ syncDB ~ error:', error);
- await this.createSyncTables();
- }
- if (res && res.length) {
- for (const data of res) {
- const newData = JSON.parse(data.data);
- const mutate = await this._serverApi.mutate(
- data.change_type,
- data.table_name,
- newData,
- newData.id,
- );
- let networkError = false;
- let isPermissionDenied = false;
- if (!mutate || mutate.error) {
- const _currentUser =
- await ServiceConfig.getI().authHandler.getCurrentUser();
- Util.logEvent(EVENTS.ERROR_LOGS, {
- user_id: _currentUser?.id,
- ...mutate?.error,
- });
- const mutateStatus = Number(mutate?.status ?? 0);
- const mutateCode = String(mutate?.error?.code ?? '').toLowerCase();
- const mutateMessage = String(
- mutate?.error?.message ?? mutate?.error?.details ?? '',
- ).toLowerCase();
- const isDuplicateConflict =
- mutateCode === '23505' || mutateStatus === 409;
- isPermissionDenied =
- mutateStatus === 401 ||
- mutateStatus === 403 ||
- mutateCode === '42501' ||
- mutateMessage.includes('permission denied') ||
- mutateMessage.includes('row-level security') ||
- mutateMessage.includes('violates row-level security') ||
- mutateMessage.includes('unauthorized');
- networkError =
- mutateStatus === 0 ||
- mutateStatus >= 500 ||
- mutateMessage.includes('network error') ||
- mutateMessage.includes('failed to fetch');
-
- if (networkError) {
- logger.warn(
- 'π Network error during push, will retry in next sync',
- {
- user_id: _currentUser?.id,
- ...mutate?.error,
- },
- );
- return false;
- }
- if (isDuplicateConflict || !isPermissionDenied) {
- logger.info('π’ Duplicate key ignored (already exists on server)');
- } else {
- logger.info('π΄ Real push error:', mutate?.error);
- return false;
- }
- }
- await this.executeQuery(
- `DELETE FROM push_sync_info WHERE id = ? AND table_name = ?`,
- [data.id, data.table_name],
- );
- if (mutate?.error && isPermissionDenied) {
- continue;
- }
- await this.executeQuery(
- `INSERT OR REPLACE INTO pull_sync_info (table_name, last_pulled) VALUES (?, ?)`,
- [data.table_name, new Date().toISOString()],
- );
- }
- }
- return true;
- }
-
- async syncDbNow(
- tableNames: TABLES[] = Object.values(TABLES),
- refreshTables: TABLES[] = [],
- isFirstSync?: boolean,
- ): Promise {
- await this.ensureInitialized();
- if (!this._db) return;
- await this.createSyncTables();
- // π LOCK
- if (this._syncInProgress) {
- if (refreshTables && refreshTables.length > 0) {
- this._retryRefreshTables.push(...refreshTables);
- }
- this._syncRequestedAgain = true;
- return true;
- }
- this._syncInProgress = true;
- try {
- if (refreshTables.length > 0) {
- const refresh_tables = "'" + refreshTables.join("', '") + "'";
- logger.info(
- 'logs to check synced tables',
- JSON.stringify(refresh_tables),
- );
- await this.executeQuery(
- `UPDATE pull_sync_info SET last_pulled = '2024-01-01 00:00:00' WHERE table_name IN (${refresh_tables})`,
- );
- }
- await this.pullChanges(tableNames, isFirstSync, refreshTables.length > 0);
- this.schedulePostSyncAssetPrefetch();
- const res = await this.pushChanges(Object.values(TABLES));
- const tables = "'" + tableNames.join("', '") + "'";
- // logger.info('logs to check synced tables1', JSON.stringify(tables));
- const currentTimestamp = new Date();
- const reducedTimestamp = new Date(currentTimestamp); // clone it
- reducedTimestamp.setMinutes(reducedTimestamp.getMinutes() - 1);
- const formattedTimestamp = reducedTimestamp.toISOString();
- this.executeQuery(
- `UPDATE pull_sync_info SET last_pulled = '${formattedTimestamp}' WHERE table_name IN (${tables})`,
- );
- return res;
- } finally {
- this._syncInProgress = false;
- if (this._syncRequestedAgain) {
- logger.info(
- 'π Running sync again because changes happened during sync',
- );
- this._syncRequestedAgain = false;
-
- const retryTablesToRefresh = [
- ...new Set([...this._retryRefreshTables]),
- ];
- this._retryRefreshTables = [];
-
- setTimeout(() => {
- this.syncDbNow(Object.values(TABLES), retryTablesToRefresh);
- }, 0);
- }
- }
- // logger.info("logs to check synced tables2", JSON.stringify(tables));
- }
-
- private async createSyncTables() {
- const createPullSyncInfoTable = `CREATE TABLE IF NOT EXISTS pull_sync_info (
- table_name TEXT NOT NULL PRIMARY KEY,
- last_pulled TIMESTAMP NOT NULL
- )`;
- const createPushSyncInfoTable = `CREATE TABLE IF NOT EXISTS push_sync_info (
- id TEXT NOT NULL PRIMARY KEY,
- table_name TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- change_type TEXT NOT NULL,
- data TEXT NOT NULL
- )`;
-
- await this.executeQuery(createPullSyncInfoTable);
- await this.executeQuery(createPushSyncInfoTable);
- }
-
- private async updatePushChanges(
- tableName: TABLES,
- mutateType: MUTATE_TYPES,
- data: { [key: string]: any },
- ) {
- if (!this._db) return;
- data['updated_at'] = new Date().toISOString();
- const stmt = `INSERT OR REPLACE INTO push_sync_info (id, table_name, change_type, data) VALUES (?, ?, ?, ?)`;
- const variables = [
- uuidv4(),
- tableName.toString(),
- mutateType,
- JSON.stringify(data),
- ];
- await this.executeQuery(stmt, variables);
- return await this.syncDbNow(
- [tableName],
- undefined,
- undefined,
- // is_sync_immediate,
- );
- }
-
- async createProfile(
- name: string,
- age: number | undefined,
- gender: string | undefined,
- avatar: string | undefined,
- image: string | undefined,
- boardDocId: string | undefined,
- gradeDocId: string | undefined,
- languageDocId: string | undefined,
- tcVersion: number,
- ): Promise> {
- const _currentUser =
- await ServiceConfig.getI().authHandler.getCurrentUser();
- if (!_currentUser) throw 'User is not Logged in';
- const countryCode = await this.getClientCountryCode();
- const locale = await this.getLocaleByIdOrCode(undefined, countryCode);
-
- const studentId = uuidv4();
- const now = new Date().toISOString();
- const newStudent: TableTypes<'user'> = {
- id: studentId,
- name,
- age: age ?? null,
- gender: gender ?? null,
- avatar: avatar ?? null,
- image: image ?? null,
- curriculum_id: boardDocId ?? null,
- grade_id: gradeDocId ?? null,
- language_id: languageDocId ?? null,
- locale_id: locale?.id ?? DEFAULT_LOCALE_ID,
- created_at: now,
- updated_at: now,
- is_deleted: false,
- is_tc_accepted: true,
- tc_agreed_version: tcVersion ?? 0,
- email: null,
- phone: null,
- fcm_token: null,
- music_off: false,
- sfx_off: false,
- student_id: null,
- firebase_id: null,
- is_firebase: null,
- is_ops: null,
- learning_path: null,
- ops_created_by: null,
- reward: null,
- stars: null,
- is_wa_contact: null,
- };
-
- await this.executeQuery(
- `
- INSERT INTO user (
- id, name, age, gender, avatar, image,
- curriculum_id, grade_id, language_id,
- created_at, updated_at, locale_id
- )
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
- `,
- [
- newStudent.id,
- newStudent.name,
- newStudent.age,
- newStudent.gender,
- newStudent.avatar,
- newStudent.image,
- newStudent.curriculum_id,
- newStudent.grade_id,
- newStudent.language_id,
- newStudent.created_at,
- newStudent.updated_at,
- newStudent.locale_id,
- ],
- );
-
- const parentUserId = uuidv4();
- await this.executeQuery(
- `
- INSERT INTO parent_user (id, parent_id, student_id, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?);
- `,
- [parentUserId, _currentUser.id, studentId, now, now],
- );
-
- this.updatePushChanges(TABLES.User, MUTATE_TYPES.INSERT, newStudent);
-
- this.updatePushChanges(TABLES.ParentUser, MUTATE_TYPES.INSERT, {
- id: parentUserId,
- parent_id: _currentUser.id,
- student_id: studentId,
- created_at: now,
- updated_at: now,
- is_deleted: false,
- });
-
- await this.assignCoursesToStudent(
- studentId,
- gradeDocId,
- boardDocId,
- languageDocId,
- );
- return newStudent;
- }
-
- async addProfileImages(
- id: string,
- file: File,
- profileType: PROFILETYPE,
- ): Promise {
- return await this._serverApi.addProfileImages(id, file, profileType);
- }
-
- async uploadData(payload: any): Promise {
- return await this._serverApi.uploadData(payload);
- }
-
- async migrateSchoolData(payload: { school_ids: string[] }): Promise {
- return await this._serverApi.migrateSchoolData(payload);
- }
-
- async createSchool(
- name: string,
- group1: string,
- group2: string,
- group3: string,
- group4: string | null,
- status: EnumType<'status'> | null,
- image: File | null,
- program_id: string | null,
- udise: string | null,
- address: string | null,
- country: string | null,
- onlySchool?: boolean,
- onlySchoolUser?: boolean,
- ): Promise> {
- const oSchool = onlySchool ?? true;
- const oSchoolUser = onlySchoolUser ?? true;
- const _currentUser =
- await ServiceConfig.getI().authHandler.getCurrentUser();
- if (!_currentUser) throw 'User is not Logged in';
-
- const schoolId = uuidv4();
- const result = image
- ? await this.addProfileImages(schoolId, image, PROFILETYPE.SCHOOL)
- : null;
- const newSchool: TableTypes<'school'> = {
- id: schoolId,
- name,
- group1: group1 ?? null,
- group2: group2 ?? null,
- group3: group3 ?? null,
- group4: group4 ?? null,
- image: result ?? null,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- is_deleted: false,
- program_id: program_id ?? null,
- udise: udise ?? null,
- address: address ?? null,
- model: null,
- academic_year: null,
- firebase_id: null,
- is_firebase: null,
- is_ops: null,
- language: null,
- ops_created_by: null,
- student_login_type: null,
- status: status,
- key_contacts: null,
- country: country,
- location_link: null,
- whatsapp_bot_number: null,
- };
- if (oSchool) {
- await this.executeQuery(
- `
- INSERT INTO school (id, name, group1, group2, group3, image, created_at, updated_at, is_deleted, status, country)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
- `,
- [
- newSchool.id,
- newSchool.name,
- newSchool.group1,
- newSchool.group2,
- newSchool.group3,
- newSchool.image,
- newSchool.created_at,
- newSchool.updated_at,
- newSchool.is_deleted,
- newSchool.status,
- newSchool.country,
- ],
- );
-
- await this.updatePushChanges(
- TABLES.School,
- MUTATE_TYPES.INSERT,
- newSchool,
- );
- }
-
- // Insert into school_user table
- const schoolUserId = uuidv4();
- const newSchoolUser: TableTypes<'school_user'> = {
- id: schoolUserId,
- school_id: schoolId,
- user_id: _currentUser.id,
- role: RoleType.PRINCIPAL,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- is_deleted: false,
- is_firebase: null,
- is_ops: null,
- ops_created_by: null,
- };
-
- if (oSchoolUser) {
- await this.executeQuery(
- `
- INSERT INTO school_user (id,school_id, user_id, role, created_at, updated_at, is_deleted)
- VALUES (?, ?, ?, ?, ?, ?, ?);
- `,
- [
- newSchoolUser.id,
- newSchoolUser.school_id,
- newSchoolUser.user_id,
- newSchoolUser.role,
- newSchoolUser.created_at,
- newSchoolUser.updated_at,
- newSchoolUser.is_deleted,
- ],
- );
-
- await this.updatePushChanges(
- TABLES.SchoolUser,
- MUTATE_TYPES.INSERT,
- newSchoolUser,
- );
- }
- return newSchool;
- }
- async updateSchoolProfile(
- school: TableTypes<'school'>,
- name: string,
- group1: string,
- group2: string,
- group3: string,
- image: File | null,
- group4: string | null,
- program_id: string | null,
- udise: string | null,
- address: string | null,
- ): Promise> {
- const _currentUser =
- await ServiceConfig.getI().authHandler.getCurrentUser();
- if (!_currentUser) throw 'User is not Logged in';
-
- const result = image
- ? await this.addProfileImages(school.id, image, PROFILETYPE.SCHOOL)
- : school.image;
-
- const updatedSchool: TableTypes<'school'> = {
- name: name ?? school.name,
- group1: group1 ?? school.group1,
- group2: group2 ?? school.group2,
- group3: group3 ?? school.group3,
- image: result ?? school.image,
- group4: group4 ?? school.group4,
- updated_at: new Date().toISOString(),
- created_at: school.created_at,
- id: school.id,
- is_deleted: false,
- program_id: program_id ?? null,
- udise: udise ?? null,
- address: address ?? null,
- model: null,
- academic_year: null,
- firebase_id: null,
- is_firebase: null,
- is_ops: null,
- language: null,
- ops_created_by: null,
- student_login_type: null,
- status: null,
- key_contacts: null,
- country: null,
- location_link: null,
- whatsapp_bot_number: null,
- };
- const updatedSchoolQuery = `
- UPDATE school
- SET name = ?, group1 = ?, group2 = ?, group3 = ?, image = ?, updated_at=?
- WHERE id = ?;
- `;
-
- await this.executeQuery(updatedSchoolQuery, [
- updatedSchool.name,
- updatedSchool.group1,
- updatedSchool.group2,
- updatedSchool.group3,
- updatedSchool.image,
- updatedSchool.updated_at,
- school.id,
- ]);
-
- this.updatePushChanges(TABLES.School, MUTATE_TYPES.UPDATE, updatedSchool);
-
- return updatedSchool;
- }
-
- async updateSchoolLocation(
- schoolId: string,
- lat: number,
- lng: number,
- ): Promise {
- const _currentUser =
- await ServiceConfig.getI().authHandler.getCurrentUser();
- if (!_currentUser) throw 'User is not Logged in';
-
- const locationString = `https://www.google.com/maps?q=${lat},${lng}`;
- const query = `UPDATE school SET location_link = ?, updated_at = ? WHERE id = ?`;
- const updatedAt = new Date().toISOString();
-
- await this.executeQuery(query, [locationString, updatedAt, schoolId]);
-
- const school = await this.getSchoolById(schoolId);
- if (school) {
- this.updatePushChanges(TABLES.School, MUTATE_TYPES.UPDATE, school);
- }
- }
-
- // add request for creating new school
- async requestNewSchool(
- name: string,
- state: string,
- district: string,
- city: string,
- image: File | null,
- udise_id?: string,
- ): Promise | null> {
- const _currentUser =
- await ServiceConfig.getI().authHandler.getCurrentUser();
- if (!_currentUser) throw 'User is not Logged in';
-
- // Check if request already exists for the user
- const res = await this.executeQuery(
- `SELECT * FROM req_new_school WHERE user_id = ?`,
- [_currentUser.id],
- );
- const existingRequests = res?.values ?? [];
-
- if (existingRequests.length > 0) {
- return existingRequests[0];
- }
-
- const requestId = uuidv4();
- const imageUrl = image
- ? await this.addProfileImages(requestId, image, PROFILETYPE.SCHOOL)
- : null;
-
- const newRequest: TableTypes<'req_new_school'> = {
- id: requestId,
- user_id: _currentUser.id,
- name,
- state,
- district,
- city,
- image: imageUrl ?? null,
- udise_id: udise_id ?? null,
- is_resolved: false,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- is_deleted: false,
- };
- await this.executeQuery(
- `INSERT INTO req_new_school (id, user_id, name, state, district, city, image, udise_id, is_resolved, created_at, updated_at, is_deleted)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [
- newRequest.id,
- newRequest.user_id,
- newRequest.name,
- newRequest.state,
- newRequest.district,
- newRequest.city,
- newRequest.image,
- newRequest.udise_id,
- newRequest.is_resolved,
- newRequest.created_at,
- newRequest.updated_at,
- newRequest.is_deleted,
- ],
- );
-
- await this.updatePushChanges(
- TABLES.ReqNewSchool,
- MUTATE_TYPES.INSERT,
- newRequest,
- );
-
- return newRequest;
- }
- // Add this new function to check if a create school request already exists
- async getExistingSchoolRequest(
- requested_by: string,
- ): Promise | null> {
- await this.ensureInitialized();
- const query = `
- SELECT *
- FROM ${TABLES.OpsRequests}
- WHERE requested_by = ? AND is_deleted = 0`;
- const res = await this._db?.query(query, [requested_by]);
- return res?.values?.length ? res.values[0] : null;
- }
-
- async deleteApprovedOpsRequestsForUser(
- requested_by: string,
- school_id?: string,
- class_id?: string,
- ): Promise {
- await this.ensureInitialized();
- if (!this._db) return;
-
- const query1 = `
- SELECT *
- FROM ${TABLES.OpsRequests}
- WHERE requested_by = ? AND class_id = ? AND school_id = ?`;
-
- const res1 = await this._db?.query(query1, [
- requested_by,
- class_id,
- school_id,
- ]);
-
- let ops_rq;
- if (res1 && res1.values && res1.values.length > 0) {
- ops_rq = res1.values[0];
- }
- if (!ops_rq) {
- return;
- }
-
- let query = `
- UPDATE ${TABLES.OpsRequests}
- SET is_deleted = 1,
- updated_at = ?
- WHERE requested_by = ?
- AND is_deleted = 0
- `;
-
- const params: any[] = [new Date().toISOString(), requested_by];
-
- if (school_id) {
- query += ` AND school_id = ?`;
- params.push(school_id);
- }
-
- if (class_id) {
- query += ` AND class_id = ?`;
- params.push(class_id);
- }
-
- // Execute the UPDATE
- await this._db.run(query, params);
-
- // Push sync mutation
- await this.updatePushChanges(TABLES.OpsRequests, MUTATE_TYPES.UPDATE, {
- id: ops_rq.id,
- requested_by,
- school_id: school_id ?? null,
- class_id: class_id ?? null,
- is_deleted: 1,
- });
- }
-
- async createStudentProfile(
- name: string,
- age: number | undefined,
- gender: string | undefined,
- avatar: string | null,
- image: string | null,
- boardDocId: string | null,
- gradeDocId: string | null,
- languageDocId: string | null,
- classId: string,
- role: 'student',
- studentId: string,
- tcVersion: number,
- ): Promise> {
- const _currentUser =
- await ServiceConfig.getI().authHandler.getCurrentUser();
- if (!_currentUser) throw 'User is not Logged in';
- const countryCode = await this.getClientCountryCode();
- let locale: TableTypes<'locale'> | null = await this.getLocaleByIdOrCode(
- undefined,
- countryCode,
- );
-
- const userId = uuidv4();
- const newStudent: TableTypes<'user'> = {
- id: userId,
- name,
- age: age ?? null,
- gender: gender ?? null,
- avatar: avatar ?? null,
- image: image ?? null,
- curriculum_id: boardDocId ?? null,
- grade_id: gradeDocId ?? null,
- language_id: languageDocId ?? null,
- locale_id: locale?.id ?? DEFAULT_LOCALE_ID,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- is_deleted: false,
- is_tc_accepted: true,
- tc_agreed_version: tcVersion ?? 0,
- email: null,
- phone: null,
- fcm_token: null,
- music_off: false,
- sfx_off: false,
- student_id: studentId ?? null,
- firebase_id: null,
- is_firebase: null,
- is_ops: null,
- learning_path: null,
- ops_created_by: null,
- stars: null,
- reward: null,
- is_wa_contact: null,
- };
- // Insert into user table
- await this.executeQuery(
- `
- INSERT INTO user (id, name, age, gender, avatar, image, curriculum_id, grade_id, language_id, created_at, updated_at, student_id, locale_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
- `,
- [
- newStudent.id,
- newStudent.name,
- newStudent.age,
- newStudent.gender,
- newStudent.avatar,
- newStudent.image,
- newStudent.curriculum_id,
- newStudent.grade_id,
- newStudent.language_id,
- newStudent.created_at,
- newStudent.updated_at,
- newStudent.student_id,
- newStudent.locale_id,
- ],
- );
- await this.updatePushChanges(TABLES.User, MUTATE_TYPES.INSERT, newStudent);
- // Insert into class_user table
- const classUserId = uuidv4();
- const newClassUser: TableTypes<'class_user'> = {
- id: classUserId,
- class_id: classId,
- user_id: userId,
- role: role,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- is_deleted: false,
- is_firebase: null,
- is_ops: null,
- ops_created_by: null,
- };
-
- await this.executeQuery(
- `
- INSERT INTO class_user (id, class_id, user_id, role, created_at, updated_at, is_deleted)
- VALUES (?, ?, ?, ?, ?, ?, ?);
- `,
- [
- newClassUser.id,
- newClassUser.class_id,
- newClassUser.user_id,
- newClassUser.role,
- newClassUser.created_at,
- newClassUser.updated_at,
- newClassUser.is_deleted,
- ],
- );
- this.updatePushChanges(TABLES.ClassUser, MUTATE_TYPES.INSERT, newClassUser);
- return newStudent;
- }
-
- async updateSchoolCourseSelection(
- schoolId: string,
- selectedCourseIds: string[],
- ): Promise {
- await this.ensureInitialized();
- const currentDate = new Date().toISOString();
-
- for (let idx = 0; idx < selectedCourseIds.length; idx++) {
- const courseId = selectedCourseIds[idx];
- const isLast = idx === selectedCourseIds.length - 1; // β
last index check
-
- // Check if the course is already assigned to the school
- const isExist = await this._db?.query(
- `SELECT * FROM school_course WHERE school_id = ? AND course_id = ?;`,
- [schoolId, courseId],
- );
-
- if (!isExist || !isExist.values || isExist.values.length < 1) {
- // Case 1: Course is not assigned, so we insert it
- const newId = uuidv4();
- const newSchoolCourseEntry = {
- id: newId,
- school_id: schoolId,
- course_id: courseId,
- created_at: currentDate,
- updated_at: currentDate,
- is_deleted: false,
- };
-
- await this.executeQuery(
- `INSERT INTO school_course (id, school_id, course_id, created_at, updated_at, is_deleted)
- VALUES (?, ?, ?, ?, ?, ?);`,
- [
- newSchoolCourseEntry.id,
- newSchoolCourseEntry.school_id,
- newSchoolCourseEntry.course_id,
- newSchoolCourseEntry.created_at,
- newSchoolCourseEntry.updated_at,
- newSchoolCourseEntry.is_deleted,
- ],
- );
-
- // If not last index β isLast = false, otherwise true
- this.updatePushChanges(
- TABLES.SchoolCourse,
- MUTATE_TYPES.INSERT,
- newSchoolCourseEntry,
- );
- } else {
- // Case 2: Course is already assigned
- const existingEntry = isExist.values[0];
-
- if (existingEntry.is_deleted) {
- // Case 2a: Course was marked as deleted, reactivate it
- await this.executeQuery(
- `UPDATE school_course SET is_deleted = 0, updated_at = ? WHERE id = ?;`,
- [currentDate, existingEntry.id],
- );
-
- this.updatePushChanges(TABLES.SchoolCourse, MUTATE_TYPES.UPDATE, {
- id: existingEntry.id,
- is_deleted: false,
- updated_at: currentDate,
- });
- } else {
- // Case 2b: Course is already active, update the updated_at field
- await this.executeQuery(
- `UPDATE school_course SET updated_at = ? WHERE id = ?;`,
- [currentDate, existingEntry.id],
- );
-
- this.updatePushChanges(TABLES.SchoolCourse, MUTATE_TYPES.UPDATE, {
- id: existingEntry.id,
- updated_at: currentDate,
- });
- }
- }
- }
- }
-
- async updateClassCourseSelection(
- classId: string,
- selectedCourseIds: string[],
- ): Promise {
- await this.ensureInitialized();
- const currentDate = new Date().toISOString();
- for (let idx = 0; idx < selectedCourseIds.length; idx++) {
- const courseId = selectedCourseIds[idx];
- const isLast = idx === selectedCourseIds.length - 1; // β
last item check
-
- // Check if the course is already assigned to the class
- const isExist = await this._db?.query(
- `SELECT * FROM class_course WHERE class_id = ? AND course_id = ?;`,
- [classId, courseId],
- );
-
- if (!isExist || !isExist.values || isExist.values.length < 1) {
- // Case 1: Course is not assigned, so we insert it
- const newId = uuidv4();
- const newClassCourseEntry = {
- id: newId,
- class_id: classId,
- course_id: courseId,
- created_at: currentDate,
- updated_at: currentDate,
- is_deleted: false,
- };
-
- await this.executeQuery(
- `INSERT INTO class_course (id, class_id, course_id, created_at, updated_at, is_deleted)
- VALUES (?, ?, ?, ?, ?, ?);`,
- [
- newClassCourseEntry.id,
- newClassCourseEntry.class_id,
- newClassCourseEntry.course_id,
- newClassCourseEntry.created_at,
- newClassCourseEntry.updated_at,
- newClassCourseEntry.is_deleted,
- ],
- );
-
- // Insert notification β pass isLast
- this.updatePushChanges(
- TABLES.ClassCourse,
- MUTATE_TYPES.INSERT,
- newClassCourseEntry,
- );
- } else {
- // Case 2: Course is already assigned
- const existingEntry = isExist.values[0];
-
- if (existingEntry.is_deleted) {
- // Case 2a: Reactivate deleted course
- await this.executeQuery(
- `UPDATE class_course SET is_deleted = 0, updated_at = ? WHERE id = ?;`,
- [currentDate, existingEntry.id],
- );
-
- this.updatePushChanges(TABLES.ClassCourse, MUTATE_TYPES.UPDATE, {
- id: existingEntry.id,
- is_deleted: false,
- updated_at: currentDate,
- });
- } else {
- // Case 2b: Course already active, just update timestamp
- await this.executeQuery(
- `UPDATE class_course SET updated_at = ? WHERE id = ?;`,
- [currentDate, existingEntry.id],
- );
-
- this.updatePushChanges(TABLES.ClassCourse, MUTATE_TYPES.UPDATE, {
- id: existingEntry.id,
- updated_at: currentDate,
- });
- }
- }
- }
- }
-
- async deleteProfile(studentId: string) {
- await this.ensureInitialized();
- if (!this._db) return;
- try {
- const authHandler = ServiceConfig.getI()?.authHandler;
- const currentUser = await authHandler?.getCurrentUser();
- if (!currentUser) return;
- await this._serverApi.deleteProfile(studentId);
-
- const localParentId = currentUser.id;
- const timestamp = new Date().toISOString();
-
- // Get all class_ids the student is connected to
- const classResults = await this._db.query(
- `SELECT DISTINCT class_id FROM class_user WHERE user_id = ? AND is_deleted = 0`,
- [studentId],
- );
-
- const classIds: string[] =
- classResults?.values?.map((row) => row.class_id) ?? [];
-
- for (const classId of classIds) {
- // Soft delete student from class_user
- await this.executeQuery(
- `UPDATE class_user SET is_deleted = 1, updated_at = ? WHERE user_id = ? AND class_id = ? AND is_deleted = 0`,
- [timestamp, studentId, classId],
- );
-
- // Check if other children of the parent are connected to the same class
- const otherChildrenConnected = await this._db.query(
- `
- SELECT 1
- FROM class_user cu
- JOIN parent_user pu ON cu.user_id = pu.student_id
- WHERE cu.class_id = ?
- AND pu.parent_id = ?
- AND pu.student_id != ?
- AND cu.is_deleted = 0
- AND pu.is_deleted = 0
- `,
- [classId, localParentId, studentId],
- );
- // If no other child is connected, remove the parent's connection from the class
- if (
- otherChildrenConnected.values == null ||
- otherChildrenConnected.values.length < 1 ||
- !otherChildrenConnected.values[0]
- ) {
- await this.executeQuery(
- `
- UPDATE class_user
- SET is_deleted = 1,
- updated_at = ?
- WHERE class_id = ? AND user_id = ? AND role = 'parent' AND is_deleted = 0
- `,
- [timestamp, classId, localParentId],
- );
- }
- }
-
- // Soft delete the parent-student connection
- await this.executeQuery(
- `UPDATE parent_user SET is_deleted = 1, updated_at = ? WHERE student_id = ? AND parent_id = ? AND is_deleted = 0`,
- [timestamp, studentId, localParentId],
- );
-
- // Clear only this student's cached latest pathway snapshot.
- localStorage.removeItem(`${LATEST_LEARNING_PATH}:${studentId}`);
- } catch (error) {
- logger.error('π ~ SqliteApi ~ deleteProfile ~ error:', error);
- }
- }
- async getCourseByUserGradeId(
- gradeDocId: string | null,
- boardDocId: string | null,
- ): Promise[]> {
- if (!gradeDocId) {
- throw new Error('Grade document ID is required.');
- }
-
- if (!boardDocId) {
- throw new Error('Board document ID is required.');
- }
-
- let courseIds: TableTypes<'course'>[] = [];
- const gradeCourses = await this.getCoursesByGrade(gradeDocId);
- const curriculumCourses = gradeCourses.filter(
- (course: TableTypes<'course'>) => {
- return course.curriculum_id === boardDocId;
- },
- );
-
- curriculumCourses.forEach((course: TableTypes<'course'>) => {
- courseIds.push(course);
- });
-
- let subjectIds: string[] = [];
- curriculumCourses.forEach((course: TableTypes<'course'>) => {
- if (course.subject_id) {
- subjectIds.push(course.subject_id);
- }
- });
-
- const remainingSubjects = DEFAULT_SUBJECT_IDS.filter(
- (subjectId) => !subjectIds.includes(subjectId),
- );
-
- remainingSubjects.forEach((subjectId) => {
- const courses = gradeCourses.filter((course) => {
- const subjectRef = course.subject_id;
- return (
- !!subjectRef &&
- subjectRef === subjectId &&
- course.curriculum_id === OTHER_CURRICULUM
- );
- });
- courses.forEach((course) => {
- courseIds.push(course);
- });
- });
-
- return courseIds;
- }
-
- async getAllCurriculums(): Promise[]> {
- await this.ensureInitialized();
- const res = await this._db?.query(
- `SELECT * FROM ${TABLES.Curriculum} ORDER BY name ASC`,
- );
- logger.info('π ~ SqliteApi ~ getAllCurriculums ~ res:', res);
- return res?.values ?? [];
- }
- async getAllGrades(): Promise[]> {
- await this.ensureInitialized();
- const res = await this._db?.query('select * from ' + TABLES.Grade);
- return res?.values ?? [];
- }
-
- async getGradeById(id: string): Promise | undefined> {
- await this.ensureInitialized();
- const res = await this._db?.query(
- `select * from ${TABLES.Grade} where id = "${id}"`,
- );
- if (!res || !res.values || res.values.length < 1) return;
- return res.values[0];
- }
- async getGradeByName(name: string): Promise | undefined> {
- const res = await this._db?.query(
- `SELECT * FROM ${TABLES.Grade} WHERE name = ? AND is_deleted = 0 LIMIT 1`,
- [name],
- );
- if (!res || !res.values || res.values.length < 1) return;
- return res.values[0];
- }
- async getGradesByIds(gradeIds: string[]): Promise[]> {
- await this.ensureInitialized();
- if (!gradeIds || gradeIds.length === 0) {
- return [];
- }
- // Format the IDs for the SQL query
- const formattedIds = gradeIds.map((id) => `"${id}"`).join(', ');
- // Construct and execute the query
- const res = await this._db?.query(
- `SELECT * FROM ${TABLES.Grade} WHERE id IN (${formattedIds})`,
- );
-
- if (!res || !res.values || res.values.length === 0) {
- return []; // Return an empty array if no grades are found
- }
- // Return the retrieved grades
- return res.values;
- }
-
- async getCurriculumById(
- id: string,
- ): Promise | undefined> {
- await this.ensureInitialized();
- const res = await this._db?.query(
- `select * from ${TABLES.Curriculum} where id = "${id}"`,
- );
- if (!res || !res.values || res.values.length < 1) return;
- return res.values[0];
- }
- async getCurriculumsByIds(
- ids: string[],
- ): Promise[]> {
- await this.ensureInitialized();
- if (!ids || ids.length === 0) {
- return [];
- }
-
- // Format the IDs for the SQL query
- const formattedIds = ids.map((id) => `"${id}"`).join(', ');
-
- // Construct and execute the query
- const res = await this._db?.query(
- `SELECT * FROM ${TABLES.Curriculum} WHERE id IN (${formattedIds})`,
- );
-
- if (!res || !res.values || res.values.length < 1) {
- return [];
- }
-
- // Assuming you need to return the first item or an empty array
- return res.values;
- }
-
- async getAllLanguages(): Promise[]> {
- await this.ensureInitialized();
- const res = await this._db?.query('select * from ' + TABLES.Language);
- logger.info('π ~ SqliteApi ~ getAllLanguages ~ res:', res);
- return res?.values ?? [];
- }
-
- async subscribeToClassTopic(): Promise {
- var students: TableTypes<'user'>[] = await this.getParentStudentProfiles();
- for (const student of students) {
- const linkedData = await this.getStudentClassesAndSchools(student.id);
- if (
- !!linkedData &&
- !!linkedData.classes &&
- linkedData.classes.length > 0
- ) {
- Util.subscribeToClassTopic(
- linkedData.classes[0].id,
- linkedData.schools[0].id,
- );
- }
- }
- }
-
- async getParentStudentProfiles(): Promise[]> {
- await this.ensureInitialized();
- if (!this._db) throw 'Db is not initialized';
- const authHandler = ServiceConfig.getI()?.authHandler;
- const currentUser = await authHandler?.getCurrentUser();
- if (!currentUser) throw 'User is not Logged in';
- const query = `
- SELECT *
- FROM ${TABLES.ParentUser} AS parent
- JOIN ${TABLES.User} AS student ON parent.student_id = student.id
- WHERE parent.parent_id = "${currentUser.id}" AND parent.is_deleted = 0 AND student.is_deleted = 0;
-`;
- const res = await this._db.query(query);
- return res.values ?? [];
- }
-
- get currentStudent(): TableTypes<'user'> | undefined {
- return this._currentStudent;
- }
-
- set currentStudent(value: TableTypes<'user'> | undefined) {
- this._currentStudent = value;
- }
-
- get currentClass(): TableTypes<'class'> | undefined {
- return this._currentClass;
- }
-
- set currentClass(value: TableTypes<'class'> | undefined) {
- this._currentClass = value;
- }
-
- get currentSchool(): TableTypes<'school'> | undefined {
- return this._currentSchool;
- }
-
- set currentSchool(value: TableTypes<'school'> | undefined) {
- this._currentSchool = value;
- }
-
- get currentCourse():
- | Map | undefined>
- | undefined {
- return this._currentCourse;
- }
-
- set currentCourse(
- value: Map | undefined> | undefined,
- ) {
- this._currentCourse = value;
- }
-
- async updateSoundFlag(userId: string, value: boolean) {
- const query = `
- UPDATE "user"
- SET sfx_off = ${value ? 1 : 0}
- WHERE id = "${userId}";
- `;
- const res = await this.executeQuery(query);
- this.updatePushChanges(TABLES.User, MUTATE_TYPES.UPDATE, {
- sfx_off: value ? 1 : 0,
- id: userId,
- });
- }
- async updateMusicFlag(userId: string, value: boolean) {
- const query = `
- UPDATE "user"
- SET music_off = ${value ? 1 : 0}
- WHERE id = "${userId}";
- `;
- const res = await this.executeQuery(query);
- logger.info('π ~ SqliteApi ~ updateMusicFlag ~ res:', res);
- this.updatePushChanges(TABLES.User, MUTATE_TYPES.UPDATE, {
- music_off: value ? 1 : 0,
- id: userId,
- });
- }
- async updateLanguage(userId: string, value: string) {
- const countryCode = await this.getClientCountryCode();
- const locale = await this.getLocaleByIdOrCode(undefined, countryCode);
- const localeId = locale?.id ?? DEFAULT_LOCALE_ID;
-
- const query = `
- UPDATE "user"
- SET language_id = ?, locale_id = ?
- WHERE id = ?;
- `;
- const res = await this.executeQuery(query, [value, localeId, userId]);
- logger.info('π ~ SqliteApi ~ updateLanguage ~ res:', res);
- this.updatePushChanges(TABLES.User, MUTATE_TYPES.UPDATE, {
- language_id: value,
- locale_id: localeId,
- id: userId,
- });
- }
- async updateTcAccept(userId: string) {
- const query = `
- UPDATE "user"
- SET is_tc_accepted = 1
- WHERE id = "${userId}";
- `;
- const res = await this.executeQuery(query);
- logger.info(
- 'π ~ SqliteApi ~ updateTcAccept ~ res:',
- res,
- ServiceConfig.getI().authHandler.currentUser,
- );
- const auth = ServiceConfig.getI().authHandler;
- const currentUser = await auth.getCurrentUser();
- if (currentUser) {
- auth.currentUser = {
- ...currentUser,
- is_tc_accepted: true,
- };
- }
- this.updatePushChanges(TABLES.User, MUTATE_TYPES.UPDATE, {
- is_tc_accepted: 1,
- id: userId,
- });
- }
-
- async updateTcAgreedVersion(userId: string, version: number) {
- const query = `
- UPDATE "user"
- SET tc_agreed_version = ${version},
- updated_at = "${new Date().toISOString()}"
- WHERE id = "${userId}";
- `;
- try {
- await this.executeQuery(query);
- this.updatePushChanges(TABLES.User, MUTATE_TYPES.UPDATE, {
- tc_agreed_version: version,
- id: userId,
- });
- } catch (e: unknown) {
- const errorMessage = e instanceof Error ? e.message : '';
-
- if (errorMessage.includes('no such column: tc_agreed_version')) {
- try {
- await this.executeQuery(`
- ALTER TABLE "user"
- ADD COLUMN tc_agreed_version NUMERIC;
- `);
-
- await this.executeQuery(query);
-
- this.updatePushChanges(TABLES.User, MUTATE_TYPES.UPDATE, {
- tc_agreed_version: version,
- id: userId,
- });
- } catch (alterError: unknown) {
- logger.error('Failed to add column and retry update', alterError);
- }
- } else {
- logger.error('Update failed', e);
- }
- }
- }
- async getLanguageWithId(
- id: string,
- ): Promise | undefined> {
- await this.ensureInitialized();
- const res = await this._db?.query(
- `select * from ${TABLES.Language} where id = "${id}"`,
- );
- if (!res || !res.values || res.values.length < 1) return;
- return res.values[0];
- }
-
- async getLessonWithCocosLessonId(
- lessonId: string,
- ): Promise | null> {
- await this.ensureInitialized();
- const res = await this._db?.query(
- `
- select *
- from ${TABLES.Lesson}
- where cocos_lesson_id = ?
- and is_deleted = 0
- order by
- coalesce(datetime(updated_at), datetime(created_at)) desc,
- updated_at desc,
- created_at desc
- limit 1
- `,
- [lessonId],
- );
- if (!res || !res.values || res.values.length < 1) return null;
- return res.values[0];
- }
-
- async getCoursesForParentsStudent(
- studentId: string,
- ): Promise[]> {
- await this.ensureInitialized();
- const query = `
- SELECT *
- FROM ${TABLES.UserCourse} AS uc
- JOIN ${TABLES.Course} AS course ON uc.course_id= course.id
- WHERE uc.user_id = "${studentId}" AND uc.is_deleted = 0;
- `;
- const res = await this._db?.query(query);
- return res?.values ?? [];
- }
-
- async getAdditionalCourses(
- studentId: string,
- ): Promise[]> {
- await this.ensureInitialized();
- const res = await this._db?.query(`
- SELECT c.*
- FROM ${TABLES.Course} c
- LEFT JOIN user_course uc ON c.id = uc.course_id AND uc.user_id = "${studentId}"
- WHERE uc.course_id IS NULL;
- `);
- return res?.values ?? [];
- }
-
- async getCoursesForClassStudent(
- classId: string,
- ): Promise[]> {
- await this.ensureInitialized();
- const query = `
- SELECT course.*
- FROM ${TABLES.ClassCourse} AS cc
- JOIN ${TABLES.Course} AS course ON cc.course_id = course.id
- WHERE cc.class_id = ? AND cc.is_deleted = 0
- ORDER BY course.sort_index ASC;
- `;
- const res = await this._db?.query(query, [classId]);
- return res?.values ?? [];
- }
-
- async getLesson(id: string): Promise | undefined> {
- await this.ensureInitialized();
- const res = await this._db?.query(
- `select * from ${TABLES.Lesson} where id = "${id}" and is_deleted = 0`,
- );
- if (!res || !res.values || res.values.length < 1) return;
- return res.values[0];
- }
- async getChapterById(id: string): Promise | undefined> {
- await this.ensureInitialized();
- const res = await this._db?.query(
- `select * from ${TABLES.Chapter} where id = "${id}" and is_deleted = 0`,
- );
- if (!res || !res.values || res.values.length < 1) return;
- return res.values[0];
- }
-
- async getLessonsForChapter(
- chapterId: string,
- ): Promise[]> {
- await this.ensureInitialized();
- const student = this.currentStudent;
- let langId = student?.language_id;
- const localeId = student?.locale_id;
-
- try {
- const courseLanguageId =
- await this.resolveCourseLanguageIdForChapter(chapterId);
- if (courseLanguageId) {
- langId = courseLanguageId;
- }
- } catch (error) {
- logger.error('Error resolving chapter course language:', error);
- }
-
- const query = `
- SELECT *
- FROM ${TABLES.ChapterLesson} AS cl
- JOIN ${TABLES.Lesson} AS lesson ON cl.lesson_id= lesson.id
- WHERE cl.chapter_id = "${chapterId}"
- AND cl.is_deleted = 0
- AND (
- (cl.language_id IS NULL AND cl.locale_id IS NULL)
- ${
- langId
- ? `OR (cl.language_id = "${langId}" AND cl.locale_id IS NULL)`
- : ''
- }
- ${
- localeId
- ? `OR (cl.language_id IS NULL AND cl.locale_id = "${localeId}")`
- : ''
- }
- ${
- langId && localeId
- ? `OR (cl.language_id = "${langId}" AND cl.locale_id = "${localeId}")`
- : ''
- }
- )
- ORDER BY cl.sort_index ASC;
- `;
- const res = await this._db?.query(query);
- return res?.values ?? [];
- }
-
- async getDifferentGradesForCourse(course: TableTypes<'course'>): Promise<{
- grades: TableTypes<'grade'>[];
- courses: TableTypes<'course'>[];
- }> {
- await this.ensureInitialized();
- const query = `
- SELECT c.*,
- JSON_OBJECT(
- 'id',g.id,
- 'name',g.name,
- 'image',g.image,
- 'description',g.description,
- 'sort_index',g.sort_index,
- 'created_at',g.created_at,
- 'updated_at',g.updated_at,
- 'is_deleted',g.is_deleted
- ) AS grade
- FROM ${TABLES.Course} c
- JOIN ${TABLES.Grade} g ON c.grade_id = g.id
- WHERE c.subject_id = "${course.subject_id}"
- AND c.curriculum_id = "${course.curriculum_id}";
-
- `;
- const res = await this._db?.query(query);
- const gradesById = new Map>();
- const coursesByGradeId = new Map[]>();
- for (const data of res?.values ?? []) {
- const grade = JSON.parse(data.grade);
- delete data.grade;
- const courseDoc = data;
- gradesById.set(grade.id, grade);
- const currentGradeCourses = coursesByGradeId.get(grade.id) ?? [];
- currentGradeCourses.push(courseDoc);
- coursesByGradeId.set(grade.id, currentGradeCourses);
- }
-
- if (course.grade_id) {
- const currentGradeCourses = coursesByGradeId.get(course.grade_id) ?? [];
- if (!currentGradeCourses.some((_course) => _course.id === course.id)) {
- currentGradeCourses.push(course);
- coursesByGradeId.set(course.grade_id, currentGradeCourses);
- }
-
- if (!gradesById.has(course.grade_id)) {
- const courseGrade = await this.getGradeById(course.grade_id);
- if (courseGrade) {
- gradesById.set(course.grade_id, courseGrade);
- }
- }
- }
-
- const currentCourseCode = course.code?.toLowerCase() ?? '';
- const isMathCourse =
- currentCourseCode === COURSES.MATHS ||
- currentCourseCode.startsWith(`${COURSES.MATHS}-`);
-
- const pickCourseForGrade = (gradeId: string) => {
- const gradeCourses = coursesByGradeId.get(gradeId) ?? [];
- if (gradeCourses.length === 0) return undefined;
-
- if (course.grade_id === gradeId) {
- const selectedCourse = gradeCourses.find(
- (_course) => _course.id === course.id,
- );
- if (selectedCourse) return selectedCourse;
- }
-
- if (isMathCourse) {
- const matchingMathVariant = gradeCourses.find(
- (_course) => _course.code?.toLowerCase() === currentCourseCode,
- );
- if (matchingMathVariant) return matchingMathVariant;
-
- const regularMathCourse = gradeCourses.find(
- (_course) => _course.code?.toLowerCase() === COURSES.MATHS,
- );
- if (regularMathCourse) return regularMathCourse;
- }
-
- return gradeCourses[0];
- };
-
- const grades = Array.from(gradesById.values()).sort((a, b) => {
- //Number.MAX_SAFE_INTEGER is using when sortIndex is not found GRADES (i.e it gives default value)
- const sortIndexA = a.sort_index || Number.MAX_SAFE_INTEGER;
- const sortIndexB = b.sort_index || Number.MAX_SAFE_INTEGER;
-
- return sortIndexA - sortIndexB;
- });
- const gradeMap = {
- grades,
- courses: grades
- .map((grade) => pickCourseForGrade(grade.id))
- .filter(
- (mappedCourse): mappedCourse is TableTypes<'course'> =>
- !!mappedCourse,
- ),
- };
- return gradeMap as any;
- }
-
- getAvatarInfo(): Promise {
- throw new Error('Method not implemented.');
- }
-
- getLessonResultsForStudent(
- studentId: string,
- ): Promise