import {Injectable} from '@angular/core'; import {environment} from '../../environments/environment'; import {HttpClient} from '@angular/common/http'; import {BehaviorSubject, Observable} from 'rxjs'; import ApiResponse from '../structures/ApiResponse'; import {MenuItem} from './db/MenuItem'; import {DatabaseService} from './db/database.service'; import {ConnectionService} from 'ng-connection-service'; import {Codium} from './db/Codium'; import {Database} from './db/Database'; import {DatabaseColumn} from './db/DatabaseColumn'; import {DatabaseEntry} from './db/DatabaseEntry'; import {FileGroup} from './db/FileGroup'; import {Page} from "./db/Page"; import {PageNode} from "./db/PageNode"; export class ResourceNotAvailableOfflineError extends Error { constructor(msg = 'This resource is not yet available offline on this device.') { super(msg); } } export class OfflinePrefetchWouldOverwriteLocalDataError extends Error { constructor(msg = 'Cannot run offline prefetch. There is un-synced local data that would be overwritten.') { super(msg); } } @Injectable({ providedIn: 'root' }) export class ApiService { protected baseEndpoint: string = environment.backendBase; protected statUrl: string = environment.statUrl; protected versionUrl: string = environment.versionUrl; protected offline = false; public readonly offline$: BehaviorSubject = new BehaviorSubject(false); get isOffline() { return this.offline; } constructor( protected http: HttpClient, protected db: DatabaseService, protected connection: ConnectionService, ) { connection.monitor().subscribe(isConnected => { if ( !isConnected ) { this.makeOffline(); } else { this.checkOnline().then(isOnline => { if ( isOnline ) { this.makeOnline(); } else { this.makeOffline(); } }); } }); this.checkOnline().then(isConnected => { if ( !isConnected ) { this.makeOffline(); } else { this.makeOnline(); } }); } public checkOnline(): Promise { return new Promise(res => { fetch(this.statUrl).then(resp => { res(resp && (resp.ok || resp.type === 'opaque')); }).catch(e => { console.error('Check Online Error', e); res(false); }); }); } public async makeOffline() { const offlineKV = await this.db.getKeyValue('needs_online_sync'); offlineKV.data = true; await offlineKV.save(); this.offline = true; this.offline$.next(true); } public async needsSync() { const offlineKV = await this.db.getKeyValue('needs_online_sync'); return Boolean(offlineKV.data); } public async makeOnline() { this.offline = false; this.offline$.next(false); } public get(endpoint, params = {}): Observable { return this.request(endpoint, params, 'get'); } public post(endpoint, body = {}): Observable { return this.request(endpoint, body, 'post'); } public request(endpoint, params = {}, method: 'get'|'post' = 'get'): Observable { return this._request(this._build_url(endpoint), params, method); } public stat(): Observable { return new Observable(sub => { (async () => { const statKV = await this.db.getKeyValue('host_stat'); // If offline, look up the last stored stat for information if ( this.isOffline ) { if ( typeof statKV !== 'object' ) { throw new Error('No locally stored host stat found.'); } sub.next(new ApiResponse(statKV.data)); sub.complete(); } // Otherwise, fetch the stat and cache it locally this._request(this.statUrl).subscribe(apiResponse => { statKV.data = {status: apiResponse.status, message: apiResponse.message, data: apiResponse.data}; statKV.save().then(() => { sub.next(statKV.data); sub.complete(); }); }); })(); }); } public version(): Promise { return new Promise(async (res, rej) => { const versionKV = await this.db.getKeyValue('app_version'); // If offline, look up the local app version. if ( this.isOffline ) { if ( versionKV ) { return res(versionKV.data); } else { return rej(new Error('No local app version found.')); } } // Otherwise, look up the app version and store it locally this._request(this.versionUrl).subscribe({ next: async result => { const version = result.data.text.trim(); versionKV.data = version; await versionKV.save(); res(version); }, error: rej, }); }); } protected _request(endpoint, params = {}, method: 'get'|'post' = 'get'): Observable { return new Observable(sub => { let data: any = {}; if ( method === 'get' ) { data.params = params; } else { data = params; } this.http[method](endpoint, data).subscribe({ next: (response: any) => { sub.next(new ApiResponse(response)); }, error: (err) => { const response = { status: err.status, message: err.message, data: err.error, }; sub.next(new ApiResponse(response)); }, }); }); } public _build_url(endpoint) { if ( !endpoint.startsWith('/') ) { endpoint = `/${endpoint}`; } return `${this.baseEndpoint.endsWith('/') ? this.baseEndpoint.slice(0, -1) : this.baseEndpoint}${endpoint}`; } public async syncOfflineData() { const dirtyRecords = await this.db.getDirtyRecords(); await new Promise(async (res, rej) => { this.post('/offline/sync', { dirtyRecords }).subscribe({ next: async result => { console.log('sync result', result); res(); }, error: rej, }); }); } public async prefetchOfflineData(overwriteLocalData = false) { if ( this.isOffline ) { throw new ResourceNotAvailableOfflineError(); } if ( !overwriteLocalData && await this.needsSync() ) { throw new OfflinePrefetchWouldOverwriteLocalDataError(); } const data: any = await new Promise((res, rej) => { this.get('/offline/prefetch').subscribe({ next: result => { if ( !result.data ) { return rej(new TypeError('Unable to parse data from API response.')); } res(result.data); }, error: rej, }); }); if ( Array.isArray(data.pages) ) { await this.db.pages.clear(); for ( const rec of data.pages ) { const page = new Page( rec.UUID, rec.Name, rec.OrgUserId, rec.IsPublic, rec.IsVisibleInMenu, rec.ParentId, rec.NodeIds, rec.CreatedAt, rec.UpdatedAt, rec.Active, rec.CreatedUserId, rec.UpdateUserId, rec.ChildPageIds, rec.noDelete, rec.virtual ); await page.save(); } } if ( Array.isArray(data.pageNodes) ) { await this.db.pageNodes.clear(); for ( const rec of data.pageNodes ) { const node = new PageNode( rec.UUID, rec.Type, rec.Value ? JSON.stringify(rec.Value) : JSON.stringify({}), rec.PageId, rec.CreatedAt, rec.UpdatedAt, rec.CreatedUserId, rec.UpdateUserId ); await node.save(); } } if ( Array.isArray(data.codiums) ) { await this.db.codiums.clear(); for ( const rec of data.codiums ) { const code = new Codium( rec.Language, rec.NodeId, rec.PageId, rec.code, rec.UUID ); await code.save(); } } if ( Array.isArray(data.databases) ) { await this.db.databases.clear(); for ( const rec of data.databases ) { const db = new Database( rec.Name, rec.NodeId, rec.PageId, rec.ColumnIds, rec.UUID, rec.Active ); await db.save(); } } if ( Array.isArray(data.databaseColumns) ) { await this.db.databaseColumns.clear(); for ( const rec of data.databaseColumns ) { const col = new DatabaseColumn( rec.headerName, rec.field, rec.DatabaseId, rec.UUID, rec.Type, rec.additionalData ); await col.save(); } } if ( Array.isArray(data.databaseEntries) ) { await this.db.databaseEntries.clear(); for ( const rec of data.databaseEntries ) { const entry = new DatabaseEntry( rec.DatabaseId, JSON.stringify(rec.RowData || {}), rec.UUID ); await entry.save(); } } if ( Array.isArray(data.fileGroups) ) { await this.db.fileGroups.clear(); for ( const rec of data.fileGroups ) { const group = new FileGroup( rec.NodeId, rec.PageId, rec.FileIds, JSON.stringify(rec.files || []), rec.UUID ); await group.save(); } } } public getDeviceToken(): Promise { return new Promise(async (res, rej) => { const tokenKV = await this.db.getKeyValue('device_token'); if ( !tokenKV.data && this.isOffline ) { return rej(new ResourceNotAvailableOfflineError()); } if ( tokenKV.data ) { const expDate = new Date(tokenKV.data.expiration_date); if (expDate > new Date()) { return res(tokenKV.data.token); } } this.get(`/session/device-token`).subscribe({ next: async result => { tokenKV.data = result.data; await tokenKV.save(); res(result.data.token); }, error: rej, }); }); } public resumeSession(): Promise { return new Promise(async (res, rej) => { if ( !this.isOffline ) { this.getDeviceToken().then(token => { this.post(`/session/resume/${token}`).subscribe({ next: result => { res(); }, error: rej, }); }).catch(rej); } }); } public getMenuItems(): Promise { return new Promise(async (res, rej) => { await this.db.createSchemata(); // If offline, fetch the menu from the database if ( this.isOffline ) { const items = await this.db.menuItems.toArray(); const nodes = MenuItem.inflateTree(items as MenuItem[]); return res(nodes); } // Download the latest menu items const tree: any[] = await new Promise(res2 => { this.get('/menu/items').subscribe({ next: async result => { const nodes = result.data as any[]; const items = MenuItem.deflateTree(nodes); // Update the locally stored nodes await this.db.menuItems.clear(); await Promise.all(items.map(item => item.save())); res2(nodes); }, error: rej, }); }); res(tree); }); } public getSessionData(): Promise { return new Promise(async (res, rej) => { const sessionKV = await this.db.getKeyValue('session_data'); const authenticatedUserKV = await this.db.getKeyValue('authenticated_user'); // If offline, just return the locally cached session data if ( this.isOffline ) { if ( typeof sessionKV.data !== 'object' ) { return rej(new Error('No locally cached session data found.')); } return res(sessionKV.data); } // Otherwise, fetch the session data from the server and cache it locally this.get('/session').subscribe(async result => { // If the locally logged in user is not the one in the server session, purge everything and reset if ( authenticatedUserKV.data && authenticatedUserKV.data?.user?.id && authenticatedUserKV.data.user.id !== result.data.user.id ) { await this.cleanSession(result.data); return res(result.data); } sessionKV.data = result.data; await sessionKV.save(); res(sessionKV.data); }); }); } protected async cleanSession(data: any) { await this.db.purge(); await new Promise((res, rej) => { this.stat().subscribe({ next: res, error: rej, }); }); const sessionKV = await this.db.getKeyValue('session_data'); sessionKV.data = data; await sessionKV.save(); await this.getDeviceToken(); } public saveSessionData(data: any): Promise { return new Promise(async (res, rej) => { // Update the local session data const sessionKV = await this.db.getKeyValue('session_data'); sessionKV.data = data; await sessionKV.save(); // If we're not offline, then update the data on the server if ( !this.isOffline ) { await new Promise(res2 => { this.post('/session', data || {}).subscribe({ next: res2, error: rej, }); }); } res(); }); } public deleteCodium(PageId: string, NodeId: string, CodiumId: string): Promise { return new Promise(async (res, rej) => { const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray(); const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined; if ( this.isOffline ) { if ( existingLocalCodium ) { existingLocalCodium.deleted = true; existingLocalCodium.needsServerUpdate = true; await existingLocalCodium.save(); return res(); } else { return rej(new ResourceNotAvailableOfflineError()); } } this.post(`/code/${PageId}/${NodeId}/delete/${CodiumId}`).subscribe({ next: async result => { if ( existingLocalCodium ) { await this.db.codiums.delete(existingLocalCodium.id); } res(); }, error: rej, }); }); } public saveCodium(PageId: string, NodeId: string, CodiumId: string, data: any): Promise { return new Promise(async (res, rej) => { const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray(); const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined; // If we're offline, update or create the local record if ( this.isOffline ) { if ( existingLocalCodium ) { existingLocalCodium.fillFromRecord(data); existingLocalCodium.needsServerUpdate = true; await existingLocalCodium.save(); return res(existingLocalCodium.getSaveRecord()); } else { const newLocalCodium = new Codium( data.Language, NodeId, PageId, data.code, data.UUID || Codium.getUUID(), true, ); await newLocalCodium.save(); return res(newLocalCodium.getSaveRecord()); } } // If we're online, save the data and update our local records this.post(`/code/${PageId}/${NodeId}/set/${CodiumId}`, data).subscribe({ next: async result => { if ( existingLocalCodium ) { existingLocalCodium.fillFromRecord(result.data); existingLocalCodium.needsServerUpdate = false; await existingLocalCodium.save(); return res(result.data); } else { const newLocalCodium = new Codium( result.data.Language, result.data.NodeId, result.data.PageId, result.data.code, result.data.UUID, ); await newLocalCodium.save(); return res(result.data); } }, error: rej, }); }); } public createCodium(PageId: string, NodeId: string): Promise { return new Promise(async (res, rej) => { // If offline, create a new local DB record if ( this.isOffline ) { const newLocalCodium = new Codium( 'javascript', NodeId, PageId, '', Codium.getUUID(), true ); await newLocalCodium.save(); return res(newLocalCodium.getSaveRecord()); } // If online, create a new record on the server and sync it to the local db this.post(`/code/${PageId}/${NodeId}/create`).subscribe({ next: async result => { const newLocalCodium = new Codium( result.data.Language, result.data.NodeId, result.data.PageId, result.data.code, result.data.UUID, ); await newLocalCodium.save(); res(result.data); }, error: rej, }); }); } public getCodium(PageId: string, NodeId: string, CodiumId: string): Promise { return new Promise(async (res, rej) => { const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray(); const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined; // If offline, try to load it from the local DB if ( this.isOffline ) { if ( existingLocalCodium ) { return res(existingLocalCodium.getSaveRecord()); } else { return rej(new ResourceNotAvailableOfflineError()); } } // If online, fetch the codium and store/update it locally this.get(`/code/${PageId}/${NodeId}/get/${CodiumId}`).subscribe({ next: async result => { if ( existingLocalCodium ) { existingLocalCodium.fillFromRecord(result.data); await existingLocalCodium.save(); return res(result.data); } else { const newLocalCodium = new Codium( result.data.Language, result.data.NodeId, result.data.PageId, result.data.code, result.data.UUID ); await newLocalCodium.save(); return res(result.data); } }, error: rej, }); }); } public deleteDatabase(PageId: string, NodeId: string, DatabaseId: string): Promise { return new Promise(async (res, rej) => { const existingLocalDatabase = await this.db.databases.where({ UUID: DatabaseId }).first() as Database; if ( this.isOffline ) { if ( existingLocalDatabase ) { existingLocalDatabase.needsServerUpdate = true; existingLocalDatabase.deleted = true; await existingLocalDatabase.save(); return res(); } else { return rej(new ResourceNotAvailableOfflineError()); } } this.post(`/db/${PageId}/${NodeId}/drop/${DatabaseId}`).subscribe({ next: async result => { if ( existingLocalDatabase ) { await this.db.databases.delete(existingLocalDatabase.id); res(); } }, error: rej, }); }); } public getDatabaseEntries(PageId: string, NodeId: string, DatabaseId: string): Promise { return new Promise(async (res, rej) => { if ( this.isOffline ) { const rows = (await this.db.databaseEntries.where({ DatabaseId }).toArray()) as DatabaseEntry[]; return res(rows.filter(x => !x.deleted).map(x => x.inflateToRecord())); } this.get(`/db/${PageId}/${NodeId}/get/${DatabaseId}/data`).subscribe({ next: async result => { for ( const row of result.data ) { const existingDatabaseEntry = await this.db.databaseEntries.where({ DatabaseId, UUID: row.UUID, }).first() as DatabaseEntry; if ( existingDatabaseEntry ) { existingDatabaseEntry.fillFromRecord(row); await existingDatabaseEntry.save(); } else { const newDatabaseEntry = new DatabaseEntry( row.DatabaseId, JSON.stringify(row.RowData), row.UUID ); await newDatabaseEntry.save(); } } return res(result.data); }, error: rej, }); }); } public getDatabaseColumns(PageId: string, NodeId: string, DatabaseId: string): Promise { return new Promise(async (res, rej) => { // If offline, fetch the columns from the local database if ( this.isOffline ) { const columns = (await this.db.databaseColumns.where({ DatabaseId }).toArray()) as DatabaseColumn[]; return res(columns.filter(x => !x.deleted).map(x => x.getSaveRecord())); } // If online, fetch the columns and sync the local database this.get(`/db/${PageId}/${NodeId}/get/${DatabaseId}/columns`).subscribe({ next: async results => { for ( const def of results.data ) { const existingColumnDef = await this.db.databaseColumns.where({ DatabaseId, UUID: def.UUID, }).first() as DatabaseColumn; if ( existingColumnDef ) { existingColumnDef.fillFromRecord(def); await existingColumnDef.save(); } else { const newColumnDef = new DatabaseColumn( def.headerName, def.field, def.DatabaseId, def.UUID, def.Type, def.additionalData, ); await newColumnDef.save(); } } return res(results.data); }, error: rej, }); }); } public createDatabase(PageId: string, NodeId: string): Promise { return new Promise(async (res, rej) => { if ( this.isOffline ) { const newLocalDatabase = new Database( 'New Database', NodeId, PageId, [], Database.getUUID(), true, true ); await newLocalDatabase.save(); return res(newLocalDatabase.getSaveRecord()); } this.post(`/db/${PageId}/${NodeId}/create`).subscribe({ next: async result => { const newLocalDatabase = new Database( result.data.Name, result.data.NodeId, result.data.PageId, result.data.ColumnIds, result.data.UUID, result.data.Active ); await newLocalDatabase.save(); return res(result.data); }, error: rej, }); }); } public getDatabase(PageId: string, NodeId: string, DatabaseId: string): Promise { return new Promise(async (res, rej) => { const existingLocalDatabases = await this.db.databases.where({ UUID: DatabaseId }).toArray(); const existingLocalDatabase = existingLocalDatabases.length > 0 ? existingLocalDatabases[0] as Database : undefined; if ( this.isOffline ) { if ( existingLocalDatabase ) { return res(existingLocalDatabase.getSaveRecord()); } else { return rej(new ResourceNotAvailableOfflineError()); } } this.get(`/db/${PageId}/${NodeId}/get/${DatabaseId}`).subscribe({ next: async result => { if ( existingLocalDatabase ) { existingLocalDatabase.fillFromRecord(result.data); await existingLocalDatabase.save(); return res(result.data); } else { const newLocalDatabase = new Database( result.data.Name, result.data.NodeId, result.data.PageId, result.data.ColumnIds, result.data.UUID, result.data.Active ); await newLocalDatabase.save(); return res(result.data); } }, error: rej, }); }); } public saveDatabaseEntries(PageId: string, NodeId: string, DatabaseId: string, rowData: any[]): Promise { return new Promise(async (res, rej) => { const existingDatabaseEntries = await this.db.databaseEntries.where({ DatabaseId }).toArray() as DatabaseEntry[]; if ( this.isOffline ) { const returnData = []; for ( const entry of existingDatabaseEntries ) { entry.deleted = true; entry.needsServerUpdate = true; await entry.save(); } for ( const row of rowData ) { const newDatabaseEntry = new DatabaseEntry( row.DatabaseId, JSON.stringify(row.RowData), row.UUID || DatabaseEntry.getUUID(), true ); await newDatabaseEntry.save(); returnData.push(newDatabaseEntry.inflateToRecord()); } return res(returnData); } this.post(`/db/${PageId}/${NodeId}/set/${DatabaseId}/data`, rowData).subscribe({ next: async result => { await this.db.databaseEntries.bulkDelete(existingDatabaseEntries.map(x => x.id)); for ( const row of result.data ) { const newDatabaseEntry = new DatabaseEntry( row.DatabaseId, JSON.stringify(row.RowData), row.UUID ); await newDatabaseEntry.save(); } return res(result.data); }, error: rej, }); }); } public saveDatabaseColumns(PageId: string, NodeId: string, DatabaseId: string, columns: any[]): Promise { return new Promise(async (res, rej) => { if ( this.isOffline ) { for ( const def of columns ) { const existingColumnDef = await this.db.databaseColumns.where({ DatabaseId, ...(def.UUID ? { UUID: def.UUID } : { headerName: def.headerName }), }).first() as DatabaseColumn; if ( existingColumnDef ) { existingColumnDef.fillFromRecord(def); existingColumnDef.needsServerUpdate = true; await existingColumnDef.save(); } else { const newColumnDef = new DatabaseColumn( def.headerName, def.field, def.DatabaseId, def.UUID || DatabaseColumn.getUUID(), def.Type, def.additionalData, true ); await newColumnDef.save(); } } return res(); } this.post(`/db/${PageId}/${NodeId}/set/${DatabaseId}/columns`, { columns }).subscribe({ next: async results => { for ( const def of results.data ) { const existingColumnDef = await this.db.databaseColumns.where({ DatabaseId, UUID: def.UUID, }).first() as DatabaseColumn; if ( existingColumnDef ) { existingColumnDef.fillFromRecord(def); existingColumnDef.needsServerUpdate = false; await existingColumnDef.save(); } else { const newColumnDef = new DatabaseColumn( def.headerName, def.field, def.DatabaseId, def.UUID, def.Type, def.additionalData, ); await newColumnDef.save(); } } res(); }, error: rej, }); }); } public saveDatabaseName(PageId: string, NodeId: string, DatabaseId: string, name: string): Promise { return new Promise(async (res, rej) => { const existingDatabase = await this.db.databases.where({ UUID: DatabaseId }).first() as Database; if ( this.isOffline ) { if ( existingDatabase ) { existingDatabase.Name = name; existingDatabase.needsServerUpdate = true; await existingDatabase.save(); return res(); } else { return rej(new ResourceNotAvailableOfflineError()); } } this.post(`/db/${PageId}/${NodeId}/set/${DatabaseId}/name`, { Name: name }).subscribe({ next: async result => { if ( existingDatabase ) { existingDatabase.Name = name; await existingDatabase.save(); res(); } }, error: rej, }); }); } public deleteFileGroup(PageId: string, NodeId: string, FileGroupId: string): Promise { return new Promise(async (res, rej) => { const existingFileGroup = await this.db.fileGroups.where({ UUID: FileGroupId }).first() as FileGroup; if ( this.isOffline ) { if ( existingFileGroup ) { existingFileGroup.deleted = true; existingFileGroup.needsServerUpdate = true; await existingFileGroup.save(); return res(); } else { return rej(new ResourceNotAvailableOfflineError()); } } this.post(`/files/${PageId}/${NodeId}/delete/${FileGroupId}`).subscribe({ next: async result => { if ( existingFileGroup ) { await this.db.fileGroups.delete(existingFileGroup.id); res(); } }, error: rej, }); }); } public createFileGroup(PageId: string, NodeId: string): Promise { return new Promise(async (res, rej) => { if ( this.isOffline ) { const newFileGroup = new FileGroup( NodeId, PageId, [], JSON.stringify([]), FileGroup.getUUID(), true ); await newFileGroup.save(); return res(newFileGroup.inflateToRecord()); } this.post(`/files/${PageId}/${NodeId}/create`).subscribe({ next: async result => { const newFileGroup = new FileGroup( result.data.NodeId, result.data.PageId, result.data.FileIds, JSON.stringify(result.data.files), result.data.UUID ); await newFileGroup.save(); res(result.data); }, error: rej, }); }); } public getFileGroup(PageId: string, NodeId: string, FileGroupId: string): Promise { return new Promise(async (res, rej) => { const existingFileGroup = await this.db.fileGroups.where({ UUID: FileGroupId }).first() as FileGroup; if ( this.isOffline ) { if ( existingFileGroup ) { return res(existingFileGroup.inflateToRecord()); } else { return rej(new ResourceNotAvailableOfflineError()); } } this.get(`/files/${PageId}/${NodeId}/get/${FileGroupId}`).subscribe({ next: async result => { if ( existingFileGroup ) { existingFileGroup.fillFromRecord(result.data); existingFileGroup.needsServerUpdate = false; await existingFileGroup.save(); } else { const newFileGroup = new FileGroup( result.data.NodeId, result.data.PageId, result.data.FileIds, JSON.stringify(result.data.files), result.data.UUID ); await newFileGroup.save(); } res(result.data); }, error: rej, }); }); } }