diff --git a/app/FakeRequest.js b/app/FakeRequest.js new file mode 100644 index 0000000..6fc1170 --- /dev/null +++ b/app/FakeRequest.js @@ -0,0 +1,103 @@ +const { Injectable } = require('flitter-di') +const FakeResponse = require('./FakeResponse') + +class FakeRequest extends Injectable { + static get services() { + return [...super.services, 'models', 'app', 'configs'] + } + + response = new FakeResponse() + + body = {} + session = {} + sessionID = undefined + user = undefined + is_auth = false + ip = undefined + headers = {} + connection = { + remoteAddress: undefined, + } + method = 'get' + originalUrl = undefined + path = undefined + params = {} + query = {} + xhr = false + + async deflate() { + return { + body: {...this.body}, + session_id: this.sessionID, + user_id: this.user?.id, + is_auth: this.is_auth, + ip: this.ip, + headers: {...this.headers}, + remote_addr: this.connection?.remoteAddress, + method: this.method, + original_url: this.originalUrl, + path: this.path, + params: {...this.params}, + query: {...this.query}, + xhr: this.xhr, + } + } + + static serialize_request(req) { + return { + body: {...req.body}, + session_id: req.sessionID, + user_id: req.user?.id, + is_auth: req.is_auth, + ip: req.ip, + headers: {...req.headers}, + remote_addr: req.connection?.remoteAddress, + method: req.method, + original_url: req.originalUrl, + path: req.path, + params: {...req.params}, + query: {...req.query}, + xhr: req.xhr, + } + } + + async inflate(data) { + const User = this.models.get('auth:User') + const Session = require('./models/Session') + this.app.di().inject(Session) + + this.body = data.body + + this.sessionID = data.session_id || data.sessionID + if ( this.sessionID ) { + const session = await Session.findOne({ _id: this.sessionID }) + if ( session ) { + this._session_instance = session + this.session = session.session + } + } + + this.user_id = data.user_id || data.user?.id + if ( this.user_id ) { + const user = await User.findById(this.user_id) + if ( user ) { + this.user = user + } + } + + this.is_auth = data.is_auth + this.ip = data.ip + this.headers = {...(data.headers || {})} + this.connection = { + remoteAddress: data.remote_addr || data.connection?.remoteAddress, + } + this.method = data.method + this.originalUrl = data.original_url || data.originalUrl + this.path = data.path + this.params = {...(data.params || {})} + this.query = {...(data.query || {})} + this.xhr = data.xhr + } +} + +module.exports = exports = FakeRequest diff --git a/app/FakeResponse.js b/app/FakeResponse.js new file mode 100644 index 0000000..62a6faf --- /dev/null +++ b/app/FakeResponse.js @@ -0,0 +1,52 @@ +const { Injectable } = require('flitter-di') + +class FakeResponse extends Injectable { + _message = 'OK' + _status = 200 + _api_data = undefined + _send_data = undefined + _view = undefined + _view_data = undefined + + status(set = undefined) { + if ( set ) { + this._status = set + return this + } else { + return this._status + } + } + + message(set = undefined) { + if ( set ) { + this._message = set + return this + } else { + return this._message + } + } + + api(data = {}) { + this._api_data = data + return this + } + + send(data = '') { + this._send_data = '' + return this + } + + view(name, data = {}) { + this._view = name + this._view_data = data + return this + } + + page(name, data = {}) { + this._view = name + this._view_data = data + return this + } +} + +module.exports = exports = FakeResponse diff --git a/app/controllers/api/v1/FormCode.controller.js b/app/controllers/api/v1/FormCode.controller.js index 00335c0..2662ab7 100644 --- a/app/controllers/api/v1/FormCode.controller.js +++ b/app/controllers/api/v1/FormCode.controller.js @@ -28,6 +28,21 @@ class FormCode extends Controller { code: '', }) + if ( req.body.Language ) { + code.Language = req.body.Language + } + + if ( req.body.code ) { + code.code = req.body.code + } + + if ( req.body.UUID ) { + const existingUUID = await Codium.findOne({ UUID: req.body.UUID }) + if ( !existingUUID ) { + code.UUID = req.body.UUID + } + } + await code.save() return res.api(code) } diff --git a/app/controllers/api/v1/Offline.controller.js b/app/controllers/api/v1/Offline.controller.js new file mode 100644 index 0000000..00c18f7 --- /dev/null +++ b/app/controllers/api/v1/Offline.controller.js @@ -0,0 +1,223 @@ +const { Controller } = require('libflitter') +const FakeRequest = require('../../../FakeRequest') + +class OfflineController extends Controller { + static get services() { + return [...super.services, 'models', 'controllers', 'app'] + } + + async do_sync(req, res, next) { + // TODO account for modify date to not overwrite more recent data!! + + const OfflineDataSync = this.models.get('api:OfflineDataSync') + const record = await OfflineDataSync.from_request(req) + + console.log('sync data', record) + + const return_maps = {} + + // pages + if ( Array.isArray(record.pages) ) { + return_maps.pages = await this.do_sync_pages(req, record.pages) + } + + // pageNodes + if ( Array.isArray(record.pageNodes) ) { + return_maps.pageNodes = await this.do_sync_page_nodes(req, record.pageNodes, record.pages) + } + + // codiums + if ( Array.isArray(record.codiums) ) { + return_maps.codiums = await this.do_sync_codiums(req, record.codiums) + } + + // databases + // databaseColumns + // databaseEntries + // fileGroups + + return res.api(return_maps) + } + + async do_sync_codiums(req, codium_recs) { + const FormCodeController = this.controllers.get('api:v1:FormCode') + const Codium = this.models.get('api:Codium') + + const uuid_mapping = {} + + for ( const rec of codium_recs ) { + const existing_code = await Codium.findOne({ UUID: rec.UUID }) + + const fake_req = this.app.di().make(FakeRequest) + await fake_req.inflate(req) + fake_req.params = { PageId: rec.PageId, NodeId: rec.NodeId, CodiumId: rec.UUID } + fake_req.body = rec + + if ( !existing_code && !rec.deleted ) { + // This was created on the client side + await FormCodeController.create_new(fake_req, fake_req.response) + uuid_mapping[rec.UUID] = fake_req.response?._api_data?.UUID + } else { + if ( rec.deleted ) { + // The code was deleted + await FormCodeController.drop_code(fake_req, fake_req.response) + uuid_mapping[rec.UUID] = false + } else { + // The code was updated + await FormCodeController.set_values(fake_req, fake_req.response) + uuid_mapping[rec.UUID] = fake_req.response?._api_data?.UUID + } + } + } + + return uuid_mapping + } + + async do_sync_page_nodes(req, page_node_recs, page_recs) { + const PageController = this.controllers.get('api:v1:Page') + const PageModel = this.models.get('api:Page') + const NodeModel = this.models.get('api:Node') + + const page_id_x_page = {} + for ( const page of page_recs ) { + page_id_x_page[page.UUID] = page + } + + const uuid_mapping = {} + + for ( const rec of page_node_recs ) { + rec.Value = rec.ValueJSON ? JSON.parse(rec.ValueJSON) : {} + + const existing_node = await NodeModel.findOne({ UUID: rec.UUID }) + const offline_page = page_id_x_page[rec.PageId] + let online_page = await PageModel.findOne({ UUID: rec.PageId }) + + if ( existing_node && rec.deleted ) { + // node that exists on the server was deleted + if ( online_page ) { + // if it existed in the online page, delete it + online_page.NodeIds = online_page.NodeIds.filter(x => x !== rec.UUID) + await online_page.save() + } + + await existing_node.delete() + uuid_mapping[rec.UUID] = false + } else if ( existing_node ) { + // if the node exists, we assume it's already in a page structure + // update the server-side record if the user can access it + if ( await online_page.is_accessible_by(req.user, 'edit') ) { + existing_node.Type = rec.Type + existing_node.Value = rec.Value + existing_node.UpdatedAt = new Date(rec.UpdatedAt) + existing_node.UpdateUserId = req.user.id + + await existing_node.save() + uuid_mapping[rec.UUID] = existing_node.UUID + } + } else if ( !existing_node && online_page && !rec.deleted ) { + // the node was created offline + // first, save the node to the page + const fake_req = this.app.di().make(FakeRequest) + await fake_req.inflate(req) + fake_req.params = { PageId: online_page.UUID } + fake_req.body = { nodeData: {...rec} } + + await PageController.save_node_to_page(fake_req, fake_req.response) + const new_uuid = uuid_mapping[rec.UUID] = fake_req.response?._api_data?.UUID + online_page = await PageModel.findOne({ UUID: rec.PageId }) // to refresh the model's data + + // now, try to place the node in the correct spot + // if first in offline page, make first in online page + if ( Array.isArray(offline_page?.NodeIds) && offline_page.NodeIds[0] === rec.UUID ) { + online_page.NodeIds = [new_uuid, ...online_page.NodeIds.filter(x => x !== new_uuid && x !== rec.UUID)] + } else if ( Array.isArray(offline_page?.NodeIds) && offline_page.NodeIds.includes(rec.UUID) ) { + if ( offline_page.NodeIds.slice(-1)[0] !== rec.UUID ) { + // We're not first, and we're not last, so try to place in the correct spot + const index = offline_page.NodeIds.findIndex(x => x.UUID === rec.UUID) + let predecessor = undefined + let index_diff = 1 + + while ( !predecessor && (index - index_diff) >= 0 ) { + const maybe_predecessor = offline_page.NodeIds[index - index_diff] + if ( online_page.NodeIds.includes(maybe_predecessor) ) { + predecessor = maybe_predecessor + } else { + index_diff += 1 + } + } + + if ( predecessor ) { + // We found the predecessor to insert the child in the existing node + const newNodeIds = [] + online_page.NodeIds.forEach(uuid => { + newNodeIds.push(uuid) + if ( uuid === predecessor ) { + newNodeIds.push(new_uuid) + } + }) + + online_page.NodeIds = newNodeIds + } + } + } + + await online_page.save() + } + + // assuming the pages were created first, we should never have a case + // where we have !existing_node && !online_page + } + + return uuid_mapping + } + + async do_sync_pages(req, page_recs) { + // TODO order pages by create date to prevent child-before-parent errors + const PageController = this.controllers.get('api:v1:Page') + const PageModel = this.models.get('api:Page') + + const uuid_mapping = {} + + for ( const rec of page_recs ) { + const UUID = rec.UUID + if ( !UUID ) continue; + + const existing_page = await PageModel.findOne({ UUID }) + const fake_req = this.app.di().make(FakeRequest) + await fake_req.inflate(req) + fake_req.body = {...rec} + + if ( !existing_page && !rec.deleted ) { + // create the new page + if ( parseInt(rec.ParentId) === 0 ) { + // Create a new top-level page + fake_req.body.omit_starter = true + fake_req.body.name = rec.Name + await PageController.create_top_level(fake_req, fake_req.response) + } else { + // Create a new child page + fake_req.body.omit_starter = true + fake_req.body.name = rec.Name + fake_req.body.parentId = rec.ParentId + await PageController.create_child(fake_req, fake_req.response) + } + } else if ( existing_page ) { + fake_req.params = { PageId: rec.UUID } + + if ( rec.deleted ) { + // The page was deleted on the client + await PageController.delete_page(fake_req, fake_req.response) + } else { + // update an existing page + await PageController.save_page(fake_req, fake_req.response) + } + } + + uuid_mapping[rec.UUID] = fake_req.response?._api_data?.UUID || false + } + + return uuid_mapping + } +} + +module.exports = exports = OfflineController diff --git a/app/controllers/api/v1/Page.controller.js b/app/controllers/api/v1/Page.controller.js index bca8d11..e7fdf8c 100644 --- a/app/controllers/api/v1/Page.controller.js +++ b/app/controllers/api/v1/Page.controller.js @@ -28,7 +28,7 @@ class Page extends Controller { if ( PageId ) { page = await PageModel.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return res.security.deny() } else { page = new PageModel page.CreatedUserId = req.user.id @@ -89,7 +89,7 @@ class Page extends Controller { const page = await PageModel.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return res.security.deny() const nodes = await Node.find({PageId: page.UUID}) const assoc_nodes = {} @@ -116,6 +116,14 @@ class Page extends Controller { CreatedUserId: req.user._id, UpdateUserId: req.user._id, }) + + if ( node.UUID ) { + const existingUUID = await Node.findOne({ UUID: node.UUID }) + if ( !existingUUID ) { + node_obj.UUID = node.UUID + } + } + await node_obj.save() page.NodeIds.push(node_obj.UUID); @@ -208,6 +216,13 @@ class Page extends Controller { UpdateUserId: req.user.id }) + if ( req.body.UUID ) { + const existingUUID = await PageModel.findOne({UUID: req.body.UUID}) + if ( !existingUUID ) { + new_page.UUID = req.body.UUID + } + } + await new_page.save() root_page.ChildPageIds.push(new_page.UUID) @@ -216,19 +231,21 @@ class Page extends Controller { req.user.allow(`page:${new_page.UUID}`) await req.user.save() - const starter_node = new Node({ - Type: 'paragraph', - Value: { - Value: 'Click to edit...', - }, - PageId: new_page.UUID, - CreatedUserId: req.user.id, - UpdateUserId: req.user.id - }) + if ( !req.body.omit_starter ) { + const starter_node = new Node({ + Type: 'paragraph', + Value: { + Value: 'Double-click to edit...', + }, + PageId: new_page.UUID, + CreatedUserId: req.user.id, + UpdateUserId: req.user.id + }) - await starter_node.save() - new_page.NodeIds.push(starter_node.UUID) - await new_page.save() + await starter_node.save() + new_page.NodeIds.push(starter_node.UUID) + await new_page.save() + } return res.api(new_page) } @@ -258,6 +275,13 @@ class Page extends Controller { UpdateUserId: req.user.id, }) + if ( req.body.UUID ) { + const existingUUID = await PageModel.findOne({UUID: req.body.UUID}) + if ( !existingUUID ) { + new_page.UUID = req.body.UUID + } + } + await new_page.save() parent.ChildPageIds.push(new_page.UUID) @@ -266,19 +290,21 @@ class Page extends Controller { req.user.allow(`page:${new_page.UUID}`) await req.user.save() - const starter_node = new Node({ - Type: 'paragraph', - Value: { - Value: 'Click to edit...', - }, - PageId: new_page.UUID, - CreatedUserId: req.user.id, - UpdateUserId: req.user.id - }) + if ( !req.body.omit_starter ) { + const starter_node = new Node({ + Type: 'paragraph', + Value: { + Value: 'Click to edit...', + }, + PageId: new_page.UUID, + CreatedUserId: req.user.id, + UpdateUserId: req.user.id + }) - await starter_node.save() - new_page.NodeIds.push(starter_node.UUID) - await new_page.save() + await starter_node.save() + new_page.NodeIds.push(starter_node.UUID) + await new_page.save() + } return res.api(new_page) } diff --git a/app/models/Session.js b/app/models/Session.js new file mode 100644 index 0000000..a950e80 --- /dev/null +++ b/app/models/Session.js @@ -0,0 +1,13 @@ +const { Model } = require('flitter-orm') + +class SessionModel extends Model { + static collection = 'flitter_sessions' + static get schema() { + return { + expires: Date, + session: Object, + } + } +} + +module.exports = exports = SessionModel diff --git a/app/models/api/OfflineDataSync.model.js b/app/models/api/OfflineDataSync.model.js new file mode 100644 index 0000000..609fa1c --- /dev/null +++ b/app/models/api/OfflineDataSync.model.js @@ -0,0 +1,37 @@ +const { Model } = require('flitter-orm') +const uuid = require('uuid/v4') + +class OfflineDataSyncModel extends Model { + static get schema() { + return { + user_id: String, + sync_timestamp: { type: Date, default: () => new Date() }, + UUID: { type: String, default: uuid }, + codiums: [Object], + database: [Object], + databaseColumns: [Object], + databaseEntries: [Object], + fileGroups: [Object], + pages: [Object], + pageNodes: [Object], + } + } + + static async from_request(req) { + const rec = new this({ + user_id: req.user.id, + codiums: req.body?.dirtyRecords.codiums, + database: req.body?.dirtyRecords.database, + databaseColumns: req.body?.dirtyRecords.databaseColumns, + databaseEntries: req.body?.dirtyRecords.databaseEntries, + fileGroups: req.body?.dirtyRecords.fileGroups, + pages: req.body?.dirtyRecords.pages, + pageNodes: req.body?.dirtyRecords.pageNodes, + }) + + await rec.save() + return rec + } +} + +module.exports = exports = OfflineDataSyncModel diff --git a/app/routing/routers/api/v1.routes.js b/app/routing/routers/api/v1.routes.js index 05e9ce6..43e84ae 100644 --- a/app/routing/routers/api/v1.routes.js +++ b/app/routing/routers/api/v1.routes.js @@ -99,6 +99,9 @@ const index = { // delete the specified code ref '/code/:PageId/:NodeId/delete/:CodiumId': ['controller::api:v1:FormCode.drop_code'], + + // re-sync data when an offline client goes back online + '/offline/sync': ['controller::api:v1:Offline.do_sync'], }, }