UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

230 lines (201 loc) 8.45 kB
const cds = require('../../cds') const { getFrom } = require('../../../../lib/compile/for/flows') const FLOW_STATUS = '@flow.status' const FROM = '@from' const TO = '@to' const FLOW_PREVIOUS = '$flow.previous' const $transitions_ = Symbol.for('transitions_') function isCurrentStatusInFrom(result, action, statusElementName, statusEnum) { const fromList = getFrom(action) const allowed = fromList.filter(from => { const value = from['#'] ? (statusEnum[from['#']]?.val ?? statusEnum[from['#']]['$path'].at(-1)) : from return result[statusElementName] === value }) return allowed.length } async function checkStatus(subject, action, statusElementName, statusEnum) { const result = await SELECT.one.from(subject) if (!result) cds.error(404) const allowed = isCurrentStatusInFrom(result, action, statusElementName, statusEnum) if (!allowed) { const from = getFrom(action) const fromValues = JSON.stringify(from.flatMap(el => Object.values(el))) cds.error({ status: 409, message: from.length > 1 ? 'INVALID_FLOW_TRANSITION_MULTI' : 'INVALID_FLOW_TRANSITION_SINGLE', args: [action.name, statusElementName, fromValues] }) } } // REVISIT: what about renamed keys? const buildUpKeys = async (entity, data, subject) => { const parentKeys = Object.keys(entity.keys).filter(k => k !== 'IsActiveEntity') // REVISIT: when do we not hava all keys? const keyValues = data && parentKeys.every(key => key in data) ? data : await SELECT.one.from(subject).columns(parentKeys) const upKeys = {} for (let i = 0; i < parentKeys.length; i++) { upKeys[`up__${parentKeys[i]}`] = keyValues[parentKeys[i]] } return upKeys } const resolveTo = (action, statusEnum) => { let to = action[TO] to = to['#'] ? (statusEnum[to['#']].val ?? statusEnum[to['#']]['$path'].at(-1)) : to return to } const handleTransition = async (entity, data, subject, to) => { const isPrevious = to['='] === FLOW_PREVIOUS if (isPrevious) { const upKeys = await buildUpKeys(entity, data, subject) const previous = await SELECT.one .from(entity[$transitions_].target) .where({ ...upKeys }) .orderBy('timestamp desc') .limit(1, 1) if (!previous) cds.error({ status: 409, message: 'No change has been made yet, cannot transition to previous status.' }) to = previous.status } return to } const getStatusInfo = statusElement => { let statusEnum, statusElementName if (statusElement.enum) { statusEnum = statusElement.enum statusElementName = statusElement.name } else if (statusElement?._target?.elements['code']) { statusEnum = statusElement._target.elements['code'].enum statusElementName = statusElement.name + '_code' } else { cds.error({ status: 409, message: `Status element in ${statusElement.parent.name} must be an enum or target an entity with an enum named "code"` }) } return { statusEnum, statusElementName } } const from_factory = (entity, action, { statusElementName, statusEnum }) => { async function handle_flow_from(req) { const subject = cds.clone(req.subject) if (entity.name.endsWith('.drafts')) subject.ref[0].id = entity.name await checkStatus(subject, action, statusElementName, statusEnum) } handle_flow_from._initial = true return handle_flow_from } const to___factory = (entity, action, { statusElementName, statusEnum }) => { return async function handle_flow_to(req, next) { const res = await next() let subject = cds.clone(req.subject) if (entity.name.endsWith('.drafts')) subject.ref[0].id = entity.name // REVISIT: this only happens on CREATE, where req.subject is a collection // -> could be avoided if setting the status would be done via req.data if (!subject.ref[0].id) { const keys = Object.keys(entity.keys).reduce((acc, cur) => { acc[cur] = res[cur] return acc }, {}) subject = SELECT.from(entity.name, keys).SELECT.from } let to = resolveTo(action, statusEnum) if (Object.prototype.hasOwnProperty.call(entity, $transitions_)) { to = await handleTransition(entity, req.data, subject, to) } await UPDATE(subject).with({ [statusElementName]: to }) // REVISIT: for stack, we now need to delete the last to transitions if (cds.env.features.flows_history_stack && resolveTo(action, statusEnum)['='] === FLOW_PREVIOUS) { const upKeys = await buildUpKeys(entity, req.data, req.subject) const timestamps = SELECT('timestamp') .from(entity[$transitions_].target) .where({ ...upKeys }) .orderBy('timestamp desc') .limit(2) await DELETE.from(entity[$transitions_].target) .where({ ...upKeys }) .where(`timestamp in`, timestamps) } return res } } /** * handler registration */ module.exports = cds.service.impl(function () { const b4 = [] const on = [] const after = [] for (const entity of this.entities) { if (!entity.actions || !entity.elements) continue const statusElement = Object.values(entity.elements).find(el => el[FLOW_STATUS]) if (!statusElement) continue const statusInfo = getStatusInfo(statusElement) // determine and cache target for transitions recording, if any let base = entity while (base.__proto__.kind === 'entity') base = base.__proto__ if (base.compositions?.transitions_) { entity[$transitions_] = base.compositions.transitions_ // track changes on db level cds.connect.to('db').then(db => { db.after(['CREATE', 'UPDATE', 'UPSERT'], entity, async (res, req) => { if ((res.affectedRows ?? res) !== 1) return if (!(statusInfo.statusElementName in req.data)) return const status = req.data[statusInfo.statusElementName] const upKeys = await buildUpKeys(entity, req.data, req.subject) const last = await SELECT.one.from(entity[$transitions_].target).orderBy('timestamp desc').where(upKeys) if (last?.status !== status) await UPSERT.into(entity[$transitions_].target).entries({ ...upKeys, status }) }) }) } // register handlers for (const action of entity.actions) { const to__ = action[TO] const from = action[FROM] // REVISIT: for CRUD and Draft, we could set status in before handlers (on db level) to save roundtrips switch (action.name) { // CRUD case 'CREATE': if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)]) break case 'READ': // nothing to do break case 'UPDATE': if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)]) if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)]) break case 'DELETE': if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)]) break // Draft case 'NEW': if (to__) on.push(['CREATE', entity.drafts, to___factory(entity.drafts, action, statusInfo)]) break case 'PATCH': if (from) b4.push(['UPDATE', entity.drafts, from_factory(entity.drafts, action, statusInfo)]) if (to__) on.push(['UPDATE', entity.drafts, to___factory(entity.drafts, action, statusInfo)]) break case 'SAVE': if (from) b4.push([action.name, entity.drafts, from_factory(entity.drafts, action, statusInfo)]) if (to__) on.push([action.name, entity.drafts, to___factory(entity, action, statusInfo)]) break case 'EDIT': if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)]) if (to__) on.push([action.name, entity, to___factory(entity.drafts, action, statusInfo)]) break case 'DISCARD': if (from) b4.push([action.name, entity.drafts, from_factory(entity.drafts, action, statusInfo)]) break // custom actions default: if (from) b4.push([action.name, entity, from_factory(entity, action, statusInfo)]) if (to__) on.push([action.name, entity, to___factory(entity, action, statusInfo)]) } } } this.prepend(function () { for (const each of b4) this.before(...each) for (const each of on) this.on(...each) for (const each of after) this.after(...each) }) })