diff --git a/api/package.json b/api/package.json index 4c4ec38d90..29954d2e42 100644 --- a/api/package.json +++ b/api/package.json @@ -111,8 +111,10 @@ "execa": "^5.1.1", "exifr": "^7.1.2", "express": "^4.17.1", + "fast-redact": "^3.1.1", "flat": "^5.0.2", "fs-extra": "^10.0.0", + "globby": "^11.0.4", "graphql": "^15.5.0", "graphql-compose": "^9.0.1", "helmet": "^4.6.0", @@ -130,6 +132,7 @@ "lodash": "^4.17.21", "macos-release": "^2.4.1", "marked": "^4.0.3", + "micromustache": "^8.0.3", "mime-types": "^2.1.31", "ms": "^2.1.3", "nanoid": "^3.1.23", @@ -182,6 +185,7 @@ "@types/express": "4.17.13", "@types/express-pino-logger": "4.0.3", "@types/express-session": "1.17.4", + "@types/fast-redact": "^3.0.1", "@types/flat": "5.0.2", "@types/fs-extra": "9.0.13", "@types/inquirer": "8.1.3", diff --git a/api/src/app.ts b/api/src/app.ts index aad76426a1..75909b5bc7 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -13,12 +13,14 @@ import dashboardsRouter from './controllers/dashboards'; import extensionsRouter from './controllers/extensions'; import fieldsRouter from './controllers/fields'; import filesRouter from './controllers/files'; +import flowsRouter from './controllers/flows'; import foldersRouter from './controllers/folders'; import graphqlRouter from './controllers/graphql'; import itemsRouter from './controllers/items'; import notFoundHandler from './controllers/not-found'; import panelsRouter from './controllers/panels'; import notificationsRouter from './controllers/notifications'; +import operationsRouter from './controllers/operations'; import permissionsRouter from './controllers/permissions'; import presetsRouter from './controllers/presets'; import relationsRouter from './controllers/relations'; @@ -35,6 +37,7 @@ import emitter from './emitter'; import env from './env'; import { InvalidPayloadException } from './exceptions'; import { getExtensionManager } from './extensions'; +import { getFlowManager } from './flows'; import logger, { expressLogger } from './logger'; import authenticate from './middleware/authenticate'; import getPermissions from './middleware/get-permissions'; @@ -83,8 +86,10 @@ export default async function createApp(): Promise { await registerAuthProviders(); const extensionManager = getExtensionManager(); + const flowManager = getFlowManager(); await extensionManager.initialize(); + await flowManager.initialize(); const app = express(); @@ -215,9 +220,11 @@ export default async function createApp(): Promise { app.use('/extensions', extensionsRouter); app.use('/fields', fieldsRouter); app.use('/files', filesRouter); + app.use('/flows', flowsRouter); app.use('/folders', foldersRouter); app.use('/items', itemsRouter); app.use('/notifications', notificationsRouter); + app.use('/operations', operationsRouter); app.use('/panels', panelsRouter); app.use('/permissions', permissionsRouter); app.use('/presets', presetsRouter); diff --git a/api/src/controllers/activity.ts b/api/src/controllers/activity.ts index 2d0a6d0d04..c4a3fde6f0 100644 --- a/api/src/controllers/activity.ts +++ b/api/src/controllers/activity.ts @@ -1,3 +1,4 @@ +import { Action } from '@directus/shared/types'; import express from 'express'; import Joi from 'joi'; import { ForbiddenException, InvalidPayloadException } from '../exceptions'; @@ -5,7 +6,6 @@ import { respond } from '../middleware/respond'; import useCollection from '../middleware/use-collection'; import { validateBatch } from '../middleware/validate-batch'; import { ActivityService, MetaService } from '../services'; -import { Action } from '../types'; import asyncHandler from '../utils/async-handler'; import { getIPFromReq } from '../utils/get-ip-from-req'; diff --git a/api/src/controllers/flows.ts b/api/src/controllers/flows.ts new file mode 100644 index 0000000000..8a1697d03e --- /dev/null +++ b/api/src/controllers/flows.ts @@ -0,0 +1,211 @@ +import express from 'express'; +import { UUID_REGEX } from '../constants'; +import { ForbiddenException } from '../exceptions'; +import { getFlowManager } from '../flows'; +import { respond } from '../middleware/respond'; +import useCollection from '../middleware/use-collection'; +import { validateBatch } from '../middleware/validate-batch'; +import { MetaService, FlowsService } from '../services'; +import { PrimaryKey } from '../types'; +import asyncHandler from '../utils/async-handler'; + +const router = express.Router(); + +router.use(useCollection('directus_flows')); + +const webhookFlowHandler = asyncHandler(async (req, res, next) => { + const flowManager = getFlowManager(); + + const result = await flowManager.runWebhookFlow( + `${req.method}-${req.params.pk}`, + { + path: req.path, + query: req.query, + body: req.body, + method: req.method, + headers: req.headers, + }, + { + accountability: req.accountability, + schema: req.schema, + } + ); + + res.locals.payload = result; + return next(); +}); + +router.get(`/trigger/:pk(${UUID_REGEX})`, webhookFlowHandler, respond); +router.post(`/trigger/:pk(${UUID_REGEX})`, webhookFlowHandler, respond); + +router.post( + '/', + asyncHandler(async (req, res, next) => { + const service = new FlowsService({ + accountability: req.accountability, + schema: req.schema, + }); + + const savedKeys: PrimaryKey[] = []; + + if (Array.isArray(req.body)) { + const keys = await service.createMany(req.body); + savedKeys.push(...keys); + } else { + const key = await service.createOne(req.body); + savedKeys.push(key); + } + + try { + if (Array.isArray(req.body)) { + const items = await service.readMany(savedKeys, req.sanitizedQuery); + res.locals.payload = { data: items }; + } else { + const item = await service.readOne(savedKeys[0], req.sanitizedQuery); + res.locals.payload = { data: item }; + } + } catch (error) { + if (error instanceof ForbiddenException) { + return next(); + } + + throw error; + } + + return next(); + }), + respond +); + +const readHandler = asyncHandler(async (req, res, next) => { + const service = new FlowsService({ + accountability: req.accountability, + schema: req.schema, + }); + const metaService = new MetaService({ + accountability: req.accountability, + schema: req.schema, + }); + + const records = await service.readByQuery(req.sanitizedQuery); + const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery); + + res.locals.payload = { data: records || null, meta }; + return next(); +}); + +router.get('/', validateBatch('read'), readHandler, respond); +router.search('/', validateBatch('read'), readHandler, respond); + +router.get( + '/:pk', + asyncHandler(async (req, res, next) => { + const service = new FlowsService({ + accountability: req.accountability, + schema: req.schema, + }); + + const record = await service.readOne(req.params.pk, req.sanitizedQuery); + + res.locals.payload = { data: record || null }; + return next(); + }), + respond +); + +router.patch( + '/', + validateBatch('update'), + asyncHandler(async (req, res, next) => { + const service = new FlowsService({ + accountability: req.accountability, + schema: req.schema, + }); + + let keys: PrimaryKey[] = []; + + if (req.body.keys) { + keys = await service.updateMany(req.body.keys, req.body.data); + } else { + keys = await service.updateByQuery(req.body.query, req.body.data); + } + + try { + const result = await service.readMany(keys, req.sanitizedQuery); + res.locals.payload = { data: result }; + } catch (error) { + if (error instanceof ForbiddenException) { + return next(); + } + + throw error; + } + + return next(); + }), + respond +); + +router.patch( + '/:pk', + asyncHandler(async (req, res, next) => { + const service = new FlowsService({ + accountability: req.accountability, + schema: req.schema, + }); + + const primaryKey = await service.updateOne(req.params.pk, req.body); + + try { + const item = await service.readOne(primaryKey, req.sanitizedQuery); + res.locals.payload = { data: item || null }; + } catch (error) { + if (error instanceof ForbiddenException) { + return next(); + } + + throw error; + } + + return next(); + }), + respond +); + +router.delete( + '/', + asyncHandler(async (req, res, next) => { + const service = new FlowsService({ + accountability: req.accountability, + schema: req.schema, + }); + + if (Array.isArray(req.body)) { + await service.deleteMany(req.body); + } else if (req.body.keys) { + await service.deleteMany(req.body.keys); + } else { + await service.deleteByQuery(req.body.query); + } + + return next(); + }), + respond +); + +router.delete( + '/:pk', + asyncHandler(async (req, res, next) => { + const service = new FlowsService({ + accountability: req.accountability, + schema: req.schema, + }); + + await service.deleteOne(req.params.pk); + + return next(); + }), + respond +); + +export default router; diff --git a/api/src/controllers/operations.ts b/api/src/controllers/operations.ts new file mode 100644 index 0000000000..b39e86f212 --- /dev/null +++ b/api/src/controllers/operations.ts @@ -0,0 +1,184 @@ +import express from 'express'; +import { ForbiddenException } from '../exceptions'; +import { respond } from '../middleware/respond'; +import useCollection from '../middleware/use-collection'; +import { validateBatch } from '../middleware/validate-batch'; +import { MetaService, OperationsService } from '../services'; +import { PrimaryKey } from '../types'; +import asyncHandler from '../utils/async-handler'; + +const router = express.Router(); + +router.use(useCollection('directus_operations')); + +router.post( + '/', + asyncHandler(async (req, res, next) => { + const service = new OperationsService({ + accountability: req.accountability, + schema: req.schema, + }); + + const savedKeys: PrimaryKey[] = []; + + if (Array.isArray(req.body)) { + const keys = await service.createMany(req.body); + savedKeys.push(...keys); + } else { + const key = await service.createOne(req.body); + savedKeys.push(key); + } + + try { + if (Array.isArray(req.body)) { + const items = await service.readMany(savedKeys, req.sanitizedQuery); + res.locals.payload = { data: items }; + } else { + const item = await service.readOne(savedKeys[0], req.sanitizedQuery); + res.locals.payload = { data: item }; + } + } catch (error) { + if (error instanceof ForbiddenException) { + return next(); + } + + throw error; + } + + return next(); + }), + respond +); + +const readHandler = asyncHandler(async (req, res, next) => { + const service = new OperationsService({ + accountability: req.accountability, + schema: req.schema, + }); + const metaService = new MetaService({ + accountability: req.accountability, + schema: req.schema, + }); + + const records = await service.readByQuery(req.sanitizedQuery); + const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery); + + res.locals.payload = { data: records || null, meta }; + return next(); +}); + +router.get('/', validateBatch('read'), readHandler, respond); +router.search('/', validateBatch('read'), readHandler, respond); + +router.get( + '/:pk', + asyncHandler(async (req, res, next) => { + const service = new OperationsService({ + accountability: req.accountability, + schema: req.schema, + }); + + const record = await service.readOne(req.params.pk, req.sanitizedQuery); + + res.locals.payload = { data: record || null }; + return next(); + }), + respond +); + +router.patch( + '/', + validateBatch('update'), + asyncHandler(async (req, res, next) => { + const service = new OperationsService({ + accountability: req.accountability, + schema: req.schema, + }); + + let keys: PrimaryKey[] = []; + + if (req.body.keys) { + keys = await service.updateMany(req.body.keys, req.body.data); + } else { + keys = await service.updateByQuery(req.body.query, req.body.data); + } + + try { + const result = await service.readMany(keys, req.sanitizedQuery); + res.locals.payload = { data: result }; + } catch (error) { + if (error instanceof ForbiddenException) { + return next(); + } + + throw error; + } + + return next(); + }), + respond +); + +router.patch( + '/:pk', + asyncHandler(async (req, res, next) => { + const service = new OperationsService({ + accountability: req.accountability, + schema: req.schema, + }); + + const primaryKey = await service.updateOne(req.params.pk, req.body); + + try { + const item = await service.readOne(primaryKey, req.sanitizedQuery); + res.locals.payload = { data: item || null }; + } catch (error) { + if (error instanceof ForbiddenException) { + return next(); + } + + throw error; + } + + return next(); + }), + respond +); + +router.delete( + '/', + asyncHandler(async (req, res, next) => { + const service = new OperationsService({ + accountability: req.accountability, + schema: req.schema, + }); + + if (Array.isArray(req.body)) { + await service.deleteMany(req.body); + } else if (req.body.keys) { + await service.deleteMany(req.body.keys); + } else { + await service.deleteByQuery(req.body.query); + } + + return next(); + }), + respond +); + +router.delete( + '/:pk', + asyncHandler(async (req, res, next) => { + const service = new OperationsService({ + accountability: req.accountability, + schema: req.schema, + }); + + await service.deleteOne(req.params.pk); + + return next(); + }), + respond +); + +export default router; diff --git a/api/src/database/migrations/20220429A-add-flows.ts b/api/src/database/migrations/20220429A-add-flows.ts new file mode 100644 index 0000000000..443c70c8bf --- /dev/null +++ b/api/src/database/migrations/20220429A-add-flows.ts @@ -0,0 +1,89 @@ +import { Knex } from 'knex'; +import { toArray } from '@directus/shared/utils'; +import { v4 as uuidv4 } from 'uuid'; +import { parseJSON } from '../../utils/parse-json'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('directus_flows', (table) => { + table.uuid('id').primary().notNullable(); + table.string('name').notNullable(); + table.string('icon', 30); + table.string('color').nullable(); + table.text('description'); + table.string('status').notNullable().defaultTo('active'); + table.string('trigger'); + table.string('accountability').defaultTo('all'); + table.json('options'); + table.uuid('operation').unique(); + table.timestamp('date_created').defaultTo(knex.fn.now()); + table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL'); + }); + + await knex.schema.createTable('directus_operations', (table) => { + table.uuid('id').primary().notNullable(); + table.string('name'); + table.string('key').notNullable(); + table.string('type').notNullable(); + table.integer('position_x').notNullable(); + table.integer('position_y').notNullable(); + table.json('options'); + table.uuid('resolve').unique().references('id').inTable('directus_operations'); + table.uuid('reject').unique().references('id').inTable('directus_operations'); + table.uuid('flow').notNullable().references('id').inTable('directus_flows').onDelete('CASCADE'); + table.timestamp('date_created').defaultTo(knex.fn.now()); + table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL'); + }); + + const webhooks = await knex.select('*').from('directus_webhooks'); + + const flows = []; + const operations = []; + + for (const webhook of webhooks) { + const flowID = uuidv4(); + + flows.push({ + id: flowID, + name: webhook.name, + status: 'inactive', + trigger: 'hook', + options: JSON.stringify({ + name: webhook.name, + type: 'action', + scope: toArray(webhook.actions).map((scope) => `items.${scope}`), + collections: toArray(webhook.collections), + }), + }); + + operations.push({ + id: uuidv4(), + name: 'Request', + key: 'request', + type: 'request', + position_x: 21, + position_y: 1, + options: JSON.stringify({ + url: webhook.url, + headers: typeof webhook.headers === 'string' ? parseJSON(webhook.headers) : webhook.headers, + data: webhook.data ? '{{$trigger}}' : null, + method: webhook.method, + }), + date_created: new Date(), + flow: flowID, + }); + } + + if (flows.length && operations.length) { + await knex.insert(flows).into('directus_flows'); + await knex.insert(operations).into('directus_operations'); + + for (const operation of operations) { + await knex('directus_flows').update({ operation: operation.id }).where({ id: operation.flow }); + } + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('directus_operations'); + await knex.schema.dropTable('directus_flows'); +} diff --git a/api/src/database/migrations/20220429B-add-color-to-insights-icon.ts b/api/src/database/migrations/20220429B-add-color-to-insights-icon.ts new file mode 100644 index 0000000000..837230c6ff --- /dev/null +++ b/api/src/database/migrations/20220429B-add-color-to-insights-icon.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_dashboards', (table) => { + table.string('color').nullable(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_dashboards', (table) => { + table.dropColumn('color'); + }); +} diff --git a/api/src/database/migrations/20220429C-drop-non-null-from-ip-of-activity.ts b/api/src/database/migrations/20220429C-drop-non-null-from-ip-of-activity.ts new file mode 100644 index 0000000000..24085eaf96 --- /dev/null +++ b/api/src/database/migrations/20220429C-drop-non-null-from-ip-of-activity.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_activity', (table) => { + table.setNullable('ip'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_activity', (table) => { + table.dropNullable('ip'); + }); +} diff --git a/api/src/database/migrations/20220429D-drop-non-null-from-sender-of-notifications.ts b/api/src/database/migrations/20220429D-drop-non-null-from-sender-of-notifications.ts new file mode 100644 index 0000000000..6709ae4c4f --- /dev/null +++ b/api/src/database/migrations/20220429D-drop-non-null-from-sender-of-notifications.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_notifications', (table) => { + table.setNullable('sender'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_notifications', (table) => { + table.dropNullable('sender'); + }); +} diff --git a/api/src/database/seeds/05-activity.yaml b/api/src/database/seeds/05-activity.yaml index 9abddfaeeb..2344094d8f 100644 --- a/api/src/database/seeds/05-activity.yaml +++ b/api/src/database/seeds/05-activity.yaml @@ -20,7 +20,6 @@ columns: user_agent: type: string length: 255 - nullabel: false collection: type: string length: 64 diff --git a/api/src/database/system-data/collections/collections.yaml b/api/src/database/system-data/collections/collections.yaml index 6a982521b7..e9a9ad62d8 100644 --- a/api/src/database/system-data/collections/collections.yaml +++ b/api/src/database/system-data/collections/collections.yaml @@ -68,3 +68,7 @@ data: - collection: directus_shares icon: share note: $t:directus_collection.directus_shares + - collection: directus_flows + note: $t:directus_collection.directus_flows + - collection: directus_operations + note: $t:directus_collection.directus_operations diff --git a/api/src/database/system-data/fields/flows.yaml b/api/src/database/system-data/fields/flows.yaml new file mode 100644 index 0000000000..b9cb43b13c --- /dev/null +++ b/api/src/database/system-data/fields/flows.yaml @@ -0,0 +1,21 @@ +table: directus_flows + +fields: + - field: id + special: uuid + - field: name + - field: icon + - field: color + - field: note + - field: status + - field: trigger + - field: accountability + - field: options + special: cast-json + - field: operation + - field: operations + special: o2m + - field: date_created + special: date-created + - field: user_created + special: user-created diff --git a/api/src/database/system-data/fields/operations.yaml b/api/src/database/system-data/fields/operations.yaml new file mode 100644 index 0000000000..d15fc5a293 --- /dev/null +++ b/api/src/database/system-data/fields/operations.yaml @@ -0,0 +1,19 @@ +table: directus_operations + +fields: + - field: id + special: uuid + - field: name + - field: key + - field: type + - field: position_x + - field: position_y + - field: options + special: cast-json + - field: resolve + - field: reject + - field: flow + - field: date_created + special: date-created + - field: user_created + special: user-created diff --git a/api/src/database/system-data/relations/relations.yaml b/api/src/database/system-data/relations/relations.yaml index 39a526398f..74e377c9b5 100644 --- a/api/src/database/system-data/relations/relations.yaml +++ b/api/src/database/system-data/relations/relations.yaml @@ -61,6 +61,20 @@ data: many_field: dashboard one_collection: directus_dashboards one_field: panels + - many_collection: directus_flows + many_field: operation + one_collection: directus_operations + - many_collection: directus_operations + many_field: flow + one_collection: directus_flows + one_field: operations + one_deselect_action: delete + - many_collection: directus_operations + many_field: resolve + one_collection: directus_operations + - many_collection: directus_operations + many_field: reject + one_collection: directus_operations - many_collection: directus_files many_field: modified_by one_collection: directus_users @@ -88,6 +102,12 @@ data: - many_collection: directus_panels many_field: user_created one_collection: directus_users + - many_collection: directus_flows + many_field: user_created + one_collection: directus_users + - many_collection: directus_operations + many_field: user_created + one_collection: directus_users - many_collection: directus_notifications many_field: recipient one_collection: directus_users diff --git a/api/src/emitter.ts b/api/src/emitter.ts index 952e722c16..035ff25267 100644 --- a/api/src/emitter.ts +++ b/api/src/emitter.ts @@ -29,7 +29,7 @@ export class Emitter { context: EventContext ): Promise { const events = Array.isArray(event) ? event : [event]; - const listeners: FilterHandler[] = events.flatMap((event) => this.filterEmitter.listeners(event)); + const listeners = events.flatMap((event) => this.filterEmitter.listeners(event) as FilterHandler[]); let updatedPayload = payload; for (const listener of listeners) { diff --git a/api/src/extensions.ts b/api/src/extensions.ts index fa126695ae..69200d23f4 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -2,13 +2,16 @@ import express, { Router } from 'express'; import path from 'path'; import { ActionHandler, + ApiExtension, AppExtensionType, EndpointConfig, Extension, ExtensionType, FilterHandler, HookConfig, + HybridExtension, InitHandler, + OperationApiConfig, ScheduleHandler, } from '@directus/shared/types'; import { @@ -25,6 +28,8 @@ import { APP_SHARED_DEPS, EXTENSION_PACKAGE_TYPES, EXTENSION_TYPES, + HYBRID_EXTENSION_TYPES, + PACK_EXTENSION_TYPE, } from '@directus/shared/constants'; import getDatabase from './database'; import emitter, { Emitter } from './emitter'; @@ -36,7 +41,7 @@ import fse from 'fs-extra'; import { getSchema } from './utils/get-schema'; import * as services from './services'; -import { schedule, ScheduledTask, validate } from 'node-cron'; +import { schedule, validate } from 'node-cron'; import { rollup } from 'rollup'; import virtual from '@rollup/plugin-virtual'; import alias from '@rollup/plugin-alias'; @@ -44,7 +49,10 @@ import { Url } from './utils/url'; import getModuleDefault from './utils/get-module-default'; import { clone, escapeRegExp } from 'lodash'; import chokidar, { FSWatcher } from 'chokidar'; -import { pluralize } from '@directus/shared/utils'; +import { isExtensionObject, isHybridExtension, pluralize } from '@directus/shared/utils'; +import { getFlowManager } from './flows'; +import globby from 'globby'; +import { EventHandler } from './types'; let extensionManager: ExtensionManager | undefined; @@ -58,16 +66,11 @@ export function getExtensionManager(): ExtensionManager { return extensionManager; } -type EventHandler = - | { type: 'filter'; name: string; handler: FilterHandler } - | { type: 'action'; name: string; handler: ActionHandler } - | { type: 'init'; name: string; handler: InitHandler } - | { type: 'schedule'; task: ScheduledTask }; - type AppExtensions = Partial>; type ApiExtensions = { hooks: { path: string; events: EventHandler[] }[]; endpoints: { path: string }[]; + operations: { path: string }[]; }; type Options = { @@ -87,7 +90,7 @@ class ExtensionManager { private extensions: Extension[] = []; private appExtensions: AppExtensions = {}; - private apiExtensions: ApiExtensions = { hooks: [], endpoints: [] }; + private apiExtensions: ApiExtensions = { hooks: [], endpoints: [], operations: [] }; private apiEmitter: Emitter; private endpointRouter: Router; @@ -180,6 +183,7 @@ class ExtensionManager { this.registerHooks(); this.registerEndpoints(); + await this.registerOperations(); if (env.SERVE_APP) { this.appExtensions = await this.generateExtensionBundles(); @@ -191,6 +195,7 @@ class ExtensionManager { private async unload(): Promise { this.unregisterHooks(); this.unregisterEndpoints(); + this.unregisterOperations(); this.apiEmitter.offAll(); @@ -205,16 +210,18 @@ class ExtensionManager { if (this.options.watch && !this.watcher) { logger.info('Watching extensions for changes...'); - const localExtensionPaths = (env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES).map((type) => - path.posix.join( + const localExtensionPaths = (env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES).flatMap((type) => { + const typeDir = path.posix.join( path.relative('.', env.EXTENSIONS_PATH).split(path.sep).join(path.posix.sep), - pluralize(type), - '*', - 'index.js' - ) - ); + pluralize(type) + ); - this.watcher = chokidar.watch([path.resolve('.', 'package.json'), ...localExtensionPaths], { + return isHybridExtension(type) + ? [path.posix.join(typeDir, '*', 'app.js'), path.posix.join(typeDir, '*', 'api.js')] + : path.posix.join(typeDir, '*', 'index.js'); + }); + + this.watcher = chokidar.watch([path.resolve('package.json'), ...localExtensionPaths], { ignoreInitial: true, }); @@ -230,10 +237,15 @@ class ExtensionManager { const toPackageExtensionPaths = (extensions: Extension[]) => extensions .filter((extension) => !extension.local) - .map((extension) => - extension.type !== 'pack' - ? path.resolve(extension.path, extension.entrypoint || '') - : path.resolve(extension.path, 'package.json') + .flatMap((extension) => + extension.type === PACK_EXTENSION_TYPE + ? path.resolve(extension.path, 'package.json') + : isExtensionObject(extension, HYBRID_EXTENSION_TYPES) + ? [ + path.resolve(extension.path, extension.entrypoint.app), + path.resolve(extension.path, extension.entrypoint.api), + ] + : path.resolve(extension.path, extension.entrypoint) ); const addedPackageExtensionPaths = toPackageExtensionPaths(added); @@ -311,11 +323,16 @@ class ExtensionManager { } private registerHooks(): void { - const hooks = this.extensions.filter((extension) => extension.type === 'hook'); + const hooks = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'hook'); for (const hook of hooks) { try { - this.registerHook(hook); + const hookPath = path.resolve(hook.path, hook.entrypoint); + const hookInstance: HookConfig | { default: HookConfig } = require(hookPath); + + const config = getModuleDefault(hookInstance); + + this.registerHook(config, hookPath); } catch (error: any) { logger.warn(`Couldn't register hook "${hook.name}"`); logger.warn(error); @@ -324,11 +341,16 @@ class ExtensionManager { } private registerEndpoints(): void { - const endpoints = this.extensions.filter((extension) => extension.type === 'endpoint'); + const endpoints = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'endpoint'); for (const endpoint of endpoints) { try { - this.registerEndpoint(endpoint, this.endpointRouter); + const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint); + const endpointInstance: EndpointConfig | { default: EndpointConfig } = require(endpointPath); + + const config = getModuleDefault(endpointInstance); + + this.registerEndpoint(config, endpointPath, this.endpointRouter); } catch (error: any) { logger.warn(`Couldn't register endpoint "${endpoint.name}"`); logger.warn(error); @@ -336,14 +358,43 @@ class ExtensionManager { } } - private registerHook(hook: Extension) { - const hookPath = path.resolve(hook.path, hook.entrypoint || ''); - const hookInstance: HookConfig | { default: HookConfig } = require(hookPath); + private async registerOperations(): Promise { + const internalPaths = await globby( + path.posix.join(path.relative('.', __dirname).split(path.sep).join(path.posix.sep), 'operations/*/index.(js|ts)') + ); - const register = getModuleDefault(hookInstance); + const internalOperations = internalPaths.map((internalPath) => { + const dirs = internalPath.split(path.sep); + return { + name: dirs[dirs.length - 2], + path: dirs.slice(0, -1).join(path.sep), + entrypoint: { api: dirs[dirs.length - 1] }, + }; + }); + + const operations = this.extensions.filter( + (extension): extension is HybridExtension => extension.type === 'operation' + ); + + for (const operation of [...internalOperations, ...operations]) { + try { + const operationPath = path.resolve(operation.path, operation.entrypoint.api); + const operationInstance: OperationApiConfig | { default: OperationApiConfig } = require(operationPath); + + const config = getModuleDefault(operationInstance); + + this.registerOperation(config, operationPath); + } catch (error: any) { + logger.warn(`Couldn't register operation "${operation.name}"`); + logger.warn(error); + } + } + } + + private registerHook(register: HookConfig, path: string) { const hookHandler: { path: string; events: EventHandler[] } = { - path: hookPath, + path, events: [], }; @@ -410,14 +461,9 @@ class ExtensionManager { this.apiExtensions.hooks.push(hookHandler); } - private registerEndpoint(endpoint: Extension, router: Router) { - const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint || ''); - const endpointInstance: EndpointConfig | { default: EndpointConfig } = require(endpointPath); - - const mod = getModuleDefault(endpointInstance); - - const register = typeof mod === 'function' ? mod : mod.handler; - const routeName = typeof mod === 'function' ? endpoint.name : mod.id; + private registerEndpoint(config: EndpointConfig, path: string, router: Router) { + const register = typeof config === 'function' ? config : config.handler; + const routeName = typeof config === 'function' ? config.name : config.id; const scopedRouter = express.Router(); router.use(`/${routeName}`, scopedRouter); @@ -433,7 +479,17 @@ class ExtensionManager { }); this.apiExtensions.endpoints.push({ - path: endpointPath, + path, + }); + } + + private registerOperation(config: OperationApiConfig, path: string) { + const flowManager = getFlowManager(); + + flowManager.addOperation(config.id, config.handler); + + this.apiExtensions.operations.push({ + path, }); } @@ -471,4 +527,16 @@ class ExtensionManager { this.apiExtensions.endpoints = []; } + + private unregisterOperations(): void { + for (const operation of this.apiExtensions.operations) { + delete require.cache[require.resolve(operation.path)]; + } + + const flowManager = getFlowManager(); + + flowManager.clearOperations(); + + this.apiExtensions.operations = []; + } } diff --git a/api/src/flows.ts b/api/src/flows.ts new file mode 100644 index 0000000000..9c9690bd5d --- /dev/null +++ b/api/src/flows.ts @@ -0,0 +1,367 @@ +import * as sharedExceptions from '@directus/shared/exceptions'; +import { + ActionHandler, + FilterHandler, + Flow, + Operation, + OperationHandler, + SchemaOverview, + Accountability, + Action, +} from '@directus/shared/types'; +import { get } from 'micromustache'; +import { schedule, validate } from 'node-cron'; +import getDatabase from './database'; +import emitter from './emitter'; +import env from './env'; +import * as exceptions from './exceptions'; +import logger from './logger'; +import * as services from './services'; +import { FlowsService } from './services'; +import { EventHandler } from './types'; +import { constructFlowTree } from './utils/construct-flow-tree'; +import { getSchema } from './utils/get-schema'; +import { ActivityService } from './services/activity'; +import { RevisionsService } from './services/revisions'; +import { Knex } from 'knex'; +import { omit } from 'lodash'; +import { getMessenger } from './messenger'; +import fastRedact from 'fast-redact'; +import { applyOperationOptions } from './utils/operation-options'; + +let flowManager: FlowManager | undefined; + +const redactLogs = fastRedact({ + censor: '--redacted--', + paths: ['*.headers.authorization', '*.access_token', '*.headers.cookie'], + serialize: false, +}); + +export function getFlowManager(): FlowManager { + if (flowManager) { + return flowManager; + } + + flowManager = new FlowManager(); + + return flowManager; +} + +type TriggerHandler = { + id: string; + events: EventHandler[]; +}; + +const TRIGGER_KEY = '$trigger'; +const ACCOUNTABILITY_KEY = '$accountability'; +const LAST_KEY = '$last'; + +class FlowManager { + private operations: Record = {}; + + private triggerHandlers: TriggerHandler[] = []; + private operationFlowHandlers: Record = {}; + private webhookFlowHandlers: Record = {}; + + public async initialize(): Promise { + const flowsService = new FlowsService({ knex: getDatabase(), schema: await getSchema() }); + + const flows = await flowsService.readByQuery({ + filter: { status: { _eq: 'active' } }, + fields: ['*', 'operations.*'], + }); + + const flowTrees = flows.map((flow) => constructFlowTree(flow)); + + for (const flow of flowTrees) { + if (flow.trigger === 'event') { + const events: string[] = + flow.options?.scope + ?.map((scope: string) => { + if (['items.create', 'items.update', 'items.delete'].includes(scope)) { + return ( + flow.options?.collections?.map((collection: string) => { + if (collection.startsWith('directus_')) { + const action = scope.split('.')[1]; + return collection.substring(9) + '.' + action; + } + + return `${collection}.${scope}`; + }) ?? [] + ); + } + + return scope; + }) + ?.flat() ?? []; + + if (flow.options.type === 'filter') { + const handler: FilterHandler = (payload, meta, context) => + this.executeFlow( + flow, + { payload, ...meta }, + { + accountability: context.accountability, + database: context.database, + getSchema: context.schema ? () => context.schema : getSchema, + } + ); + + events.forEach((event) => emitter.onFilter(event, handler)); + this.triggerHandlers.push({ + id: flow.id, + events: events.map((event) => ({ type: 'filter', name: event, handler })), + }); + } else if (flow.options.type === 'action') { + const handler: ActionHandler = (meta, context) => + this.executeFlow(flow, meta, { + accountability: context.accountability, + database: context.database, + getSchema: context.schema ? () => context.schema : getSchema, + }); + + events.forEach((event) => emitter.onAction(event, handler)); + this.triggerHandlers.push({ + id: flow.id, + events: events.map((event) => ({ type: 'action', name: event, handler })), + }); + } + } else if (flow.trigger === 'schedule') { + if (validate(flow.options.cron)) { + const task = schedule(flow.options.cron, async () => { + try { + await this.executeFlow(flow); + } catch (error: any) { + logger.error(error); + } + }); + + this.triggerHandlers.push({ id: flow.id, events: [{ type: flow.trigger, task }] }); + } else { + logger.warn(`Couldn't register cron trigger. Provided cron is invalid: ${flow.options.cron}`); + } + } else if (flow.trigger === 'operation') { + const handler = (data: unknown, context: Record) => this.executeFlow(flow, data, context); + + this.operationFlowHandlers[flow.id] = handler; + } else if (flow.trigger === 'webhook') { + const handler = (data: unknown, context: Record) => { + if (flow.options.async) { + this.executeFlow(flow, data, context); + } else { + return this.executeFlow(flow, data, context); + } + }; + + const method = flow.options?.method ?? 'GET'; + + // Default return to $last for webhooks + flow.options.return = flow.options.return ?? '$last'; + + this.webhookFlowHandlers[`${method}-${flow.id}`] = handler; + } else if (flow.trigger === 'manual') { + const handler = (data: unknown, context: Record) => { + const enabledCollections = flow.options?.collections ?? []; + const targetCollection = (data as Record)?.body.collection; + + if (!targetCollection) { + logger.warn(`Manual trigger requires "collection" to be specified in the payload`); + throw new exceptions.ForbiddenException(); + } + + if (enabledCollections.length === 0) { + logger.warn(`There is no collections configured for this manual trigger`); + throw new exceptions.ForbiddenException(); + } + + if (!enabledCollections.includes(targetCollection)) { + logger.warn(`Specified collection must be one of: ${enabledCollections.join(', ')}.`); + throw new exceptions.ForbiddenException(); + } + + if (flow.options.async) { + this.executeFlow(flow, data, context); + } else { + return this.executeFlow(flow, data, context); + } + }; + + // Default return to $last for manual + flow.options.return = '$last'; + + this.webhookFlowHandlers[`POST-${flow.id}`] = handler; + } + } + + getMessenger().subscribe('flows', (event) => { + if (event.type === 'reload') { + this.reload(); + } + }); + } + + public async reload(): Promise { + for (const trigger of this.triggerHandlers) { + trigger.events.forEach((event) => { + switch (event.type) { + case 'filter': + emitter.offFilter(event.name, event.handler); + break; + case 'action': + emitter.offAction(event.name, event.handler); + break; + case 'schedule': + event.task.stop(); + break; + } + }); + } + + this.triggerHandlers = []; + this.operationFlowHandlers = {}; + this.webhookFlowHandlers = {}; + + await this.initialize(); + } + + public addOperation(id: string, operation: OperationHandler): void { + this.operations[id] = operation; + } + + public clearOperations(): void { + this.operations = {}; + } + + public async runOperationFlow(id: string, data: unknown, context: Record): Promise { + if (!(id in this.operationFlowHandlers)) { + logger.warn(`Couldn't find operation triggered flow with id "${id}"`); + return null; + } + + const handler = this.operationFlowHandlers[id]; + + return handler(data, context); + } + + public async runWebhookFlow(id: string, data: unknown, context: Record): Promise { + if (!(id in this.webhookFlowHandlers)) { + logger.warn(`Couldn't find webhook or manual triggered flow with id "${id}"`); + throw new exceptions.ForbiddenException(); + } + + const handler = this.webhookFlowHandlers[id]; + + return handler(data, context); + } + + private async executeFlow(flow: Flow, data: unknown = null, context: Record = {}): Promise { + const database = (context.database as Knex) ?? getDatabase(); + const schema = (context.schema as SchemaOverview) ?? (await getSchema({ database })); + + const keyedData: Record = { + [TRIGGER_KEY]: data, + [LAST_KEY]: data, + [ACCOUNTABILITY_KEY]: context?.accountability ?? null, + }; + + let nextOperation = flow.operation; + + const steps: { + operation: string; + key: string; + status: 'resolve' | 'reject' | 'unknown'; + options: Record | null; + }[] = []; + + while (nextOperation !== null) { + const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, context); + + keyedData[nextOperation.key] = data; + keyedData[LAST_KEY] = data; + steps.push({ operation: nextOperation!.id, key: nextOperation.key, status, options }); + + nextOperation = successor; + } + + if (flow.accountability !== null) { + const activityService = new ActivityService({ + knex: database, + schema: schema, + }); + + const accountability = context?.accountability as Accountability | undefined; + + const activity = await activityService.createOne({ + action: Action.RUN, + user: accountability?.user ?? null, + collection: 'directus_flows', + ip: accountability?.ip ?? null, + user_agent: accountability?.userAgent ?? null, + item: flow.id, + }); + + if (flow.accountability === 'all') { + const revisionsService = new RevisionsService({ + knex: database, + schema: schema, + }); + + await revisionsService.createOne({ + activity: activity, + collection: 'directus_flows', + item: flow.id, + data: { + steps: steps, + data: redactLogs(omit(keyedData, '$accountability.permissions')), // Permissions is a ton of data, and is just a copy of what's in the directus_permissions table + }, + }); + } + } + + if (flow.options.return === '$all') { + return keyedData; + } else if (flow.options.return) { + return get(keyedData, flow.options.return); + } + + return undefined; + } + + private async executeOperation( + operation: Operation, + keyedData: Record, + context: Record = {} + ): Promise<{ + successor: Operation | null; + status: 'resolve' | 'reject' | 'unknown'; + data: unknown; + options: Record | null; + }> { + if (!(operation.type in this.operations)) { + logger.warn(`Couldn't find operation ${operation.type}`); + return { successor: null, status: 'unknown', data: null, options: null }; + } + + const handler = this.operations[operation.type]; + + const options = applyOperationOptions(operation.options, keyedData); + + try { + const result = await handler(options, { + services, + exceptions: { ...exceptions, ...sharedExceptions }, + env, + database: getDatabase(), + logger, + getSchema, + data: keyedData, + accountability: null, + ...context, + }); + + return { successor: operation.resolve, status: 'resolve', data: result ?? null, options }; + } catch (error: unknown) { + return { successor: operation.reject, status: 'reject', data: error ?? null, options }; + } + } +} diff --git a/api/src/operations/condition/index.ts b/api/src/operations/condition/index.ts new file mode 100644 index 0000000000..cc9c894a13 --- /dev/null +++ b/api/src/operations/condition/index.ts @@ -0,0 +1,20 @@ +import { Filter } from '@directus/shared/types'; +import { defineOperationApi, validatePayload } from '@directus/shared/utils'; + +type Options = { + filter: Filter; +}; + +export default defineOperationApi({ + id: 'condition', + + handler: ({ filter }, { data }) => { + const errors = validatePayload(filter, data); + + if (errors.length > 0) { + throw errors; + } else { + return null; + } + }, +}); diff --git a/api/src/operations/item-create/index.ts b/api/src/operations/item-create/index.ts new file mode 100644 index 0000000000..fbd9a97559 --- /dev/null +++ b/api/src/operations/item-create/index.ts @@ -0,0 +1,51 @@ +import { Accountability, PrimaryKey } from '@directus/shared/types'; +import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { ItemsService } from '../../services'; +import { Item } from '../../types'; +import { optionToObject } from '../../utils/operation-options'; +import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; + +type Options = { + collection: string; + payload?: Record | string | null; + emitEvents: boolean; + permissions: string; // $public, $trigger, $full, or UUID of a role +}; + +export default defineOperationApi({ + id: 'item-create', + + handler: async ({ collection, payload, emitEvents, permissions }, { accountability, database, getSchema }) => { + const schema = await getSchema({ database }); + + let customAccountability: Accountability | null; + + if (!permissions || permissions === '$trigger') { + customAccountability = accountability; + } else if (permissions === '$full') { + customAccountability = null; + } else if (permissions === '$public') { + customAccountability = await getAccountabilityForRole(null, { database, schema, accountability }); + } else { + customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability }); + } + + const itemsService = new ItemsService(collection, { + schema: await getSchema({ database }), + accountability: customAccountability, + knex: database, + }); + + const payloadObject: Partial | Partial[] | null = optionToObject(payload) ?? null; + + let result: PrimaryKey[] | null; + + if (!payloadObject) { + result = null; + } else { + result = await itemsService.createMany(toArray(payloadObject), { emitEvents }); + } + + return result; + }, +}); diff --git a/api/src/operations/item-delete/index.ts b/api/src/operations/item-delete/index.ts new file mode 100644 index 0000000000..2c1d8ed940 --- /dev/null +++ b/api/src/operations/item-delete/index.ts @@ -0,0 +1,56 @@ +import { Accountability, PrimaryKey } from '@directus/shared/types'; +import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { ItemsService } from '../../services'; +import { optionToObject } from '../../utils/operation-options'; +import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; + +type Options = { + collection: string; + key?: PrimaryKey | PrimaryKey[] | null; + query?: Record | string | null; + permissions: string; // $public, $trigger, $full, or UUID of a role +}; + +export default defineOperationApi({ + id: 'item-delete', + + handler: async ({ collection, key, query, permissions }, { accountability, database, getSchema }) => { + const schema = await getSchema({ database }); + + let customAccountability: Accountability | null; + + if (!permissions || permissions === '$trigger') { + customAccountability = accountability; + } else if (permissions === '$full') { + customAccountability = null; + } else if (permissions === '$public') { + customAccountability = await getAccountabilityForRole(null, { database, schema, accountability }); + } else { + customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability }); + } + + const itemsService: ItemsService = new ItemsService(collection, { + schema: await getSchema({ database }), + accountability: customAccountability, + knex: database, + }); + + const queryObject = query ? optionToObject(query) : {}; + + let result: PrimaryKey | PrimaryKey[] | null; + + if (!key) { + result = await itemsService.deleteByQuery(queryObject); + } else { + const keys = toArray(key); + + if (keys.length === 1) { + result = await itemsService.deleteOne(keys[0]); + } else { + result = await itemsService.deleteMany(keys); + } + } + + return result; + }, +}); diff --git a/api/src/operations/item-read/index.ts b/api/src/operations/item-read/index.ts new file mode 100644 index 0000000000..51669e3781 --- /dev/null +++ b/api/src/operations/item-read/index.ts @@ -0,0 +1,57 @@ +import { Accountability, PrimaryKey } from '@directus/shared/types'; +import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { ItemsService } from '../../services'; +import { Item } from '../../types'; +import { optionToObject } from '../../utils/operation-options'; +import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; + +type Options = { + collection: string; + key?: PrimaryKey | PrimaryKey[] | null; + query?: Record | string | null; + permissions: string; // $public, $trigger, $full, or UUID of a role +}; + +export default defineOperationApi({ + id: 'item-read', + + handler: async ({ collection, key, query, permissions }, { accountability, database, getSchema }) => { + const schema = await getSchema({ database }); + + let customAccountability: Accountability | null; + + if (!permissions || permissions === '$trigger') { + customAccountability = accountability; + } else if (permissions === '$full') { + customAccountability = null; + } else if (permissions === '$public') { + customAccountability = await getAccountabilityForRole(null, { database, schema, accountability }); + } else { + customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability }); + } + + const itemsService = new ItemsService(collection, { + schema, + accountability: customAccountability, + knex: database, + }); + + const queryObject = query ? optionToObject(query) : {}; + + let result: Item | Item[] | null; + + if (!key) { + result = await itemsService.readByQuery(queryObject); + } else { + const keys = toArray(key); + + if (keys.length === 1) { + result = await itemsService.readOne(keys[0], queryObject); + } else { + result = await itemsService.readMany(keys, queryObject); + } + } + + return result; + }, +}); diff --git a/api/src/operations/item-update/index.ts b/api/src/operations/item-update/index.ts new file mode 100644 index 0000000000..6fadfe5668 --- /dev/null +++ b/api/src/operations/item-update/index.ts @@ -0,0 +1,64 @@ +import { Accountability, PrimaryKey } from '@directus/shared/types'; +import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { ItemsService } from '../../services'; +import { Item } from '../../types'; +import { optionToObject } from '../../utils/operation-options'; +import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; + +type Options = { + collection: string; + key?: PrimaryKey | PrimaryKey[] | null; + payload?: Record | string | null; + query?: Record | string | null; + permissions: string; // $public, $trigger, $full, or UUID of a role +}; + +export default defineOperationApi({ + id: 'item-update', + + handler: async ({ collection, key, payload, query, permissions }, { accountability, database, getSchema }) => { + const schema = await getSchema({ database }); + + let customAccountability: Accountability | null; + + if (!permissions || permissions === '$trigger') { + customAccountability = accountability; + } else if (permissions === '$full') { + customAccountability = null; + } else if (permissions === '$public') { + customAccountability = await getAccountabilityForRole(null, { database, schema, accountability }); + } else { + customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability }); + } + + const itemsService: ItemsService = new ItemsService(collection, { + schema: await getSchema({ database }), + accountability: customAccountability, + knex: database, + }); + + const payloadObject: Partial | Partial[] | null = optionToObject(payload) ?? null; + + const queryObject = query ? optionToObject(query) : {}; + + if (!payloadObject) { + return null; + } + + let result: PrimaryKey | PrimaryKey[] | null; + + if (!key) { + result = await itemsService.updateByQuery(queryObject, payloadObject); + } else { + const keys = toArray(key); + + if (keys.length === 1) { + result = await itemsService.updateOne(keys[0], payloadObject); + } else { + result = await itemsService.updateMany(keys, payloadObject); + } + } + + return result; + }, +}); diff --git a/api/src/operations/log/index.ts b/api/src/operations/log/index.ts new file mode 100644 index 0000000000..1a66f14dd7 --- /dev/null +++ b/api/src/operations/log/index.ts @@ -0,0 +1,15 @@ +import { defineOperationApi } from '@directus/shared/utils'; +import logger from '../../logger'; +import { optionToString } from '../../utils/operation-options'; + +type Options = { + message: unknown; +}; + +export default defineOperationApi({ + id: 'log', + + handler: ({ message }) => { + logger.info(optionToString(message)); + }, +}); diff --git a/api/src/operations/mail/index.ts b/api/src/operations/mail/index.ts new file mode 100644 index 0000000000..71e72fbab1 --- /dev/null +++ b/api/src/operations/mail/index.ts @@ -0,0 +1,23 @@ +import { defineOperationApi } from '@directus/shared/utils'; +import { MailService } from '../../services'; +import { md } from '../../utils/md'; + +type Options = { + body: string; + to: string; + subject: string; +}; + +export default defineOperationApi({ + id: 'mail', + + handler: async ({ body, to, subject }, { accountability, database, getSchema }) => { + const mailService = new MailService({ schema: await getSchema({ database }), accountability, knex: database }); + + await mailService.send({ + html: md(body), + to, + subject, + }); + }, +}); diff --git a/api/src/operations/notification/index.ts b/api/src/operations/notification/index.ts new file mode 100644 index 0000000000..6f204a6225 --- /dev/null +++ b/api/src/operations/notification/index.ts @@ -0,0 +1,49 @@ +import { Accountability } from '@directus/shared/types'; +import { defineOperationApi } from '@directus/shared/utils'; +import { NotificationsService } from '../../services'; +import { optionToString } from '../../utils/operation-options'; +import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; + +type Options = { + recipient: string; + subject: string; + message?: unknown | null; + permissions: string; // $public, $trigger, $full, or UUID of a role +}; + +export default defineOperationApi({ + id: 'notification', + + handler: async ({ recipient, subject, message, permissions }, { accountability, database, getSchema }) => { + const schema = await getSchema({ database }); + + let customAccountability: Accountability | null; + + if (!permissions || permissions === '$trigger') { + customAccountability = accountability; + } else if (permissions === '$full') { + customAccountability = null; + } else if (permissions === '$public') { + customAccountability = await getAccountabilityForRole(null, { database, schema, accountability }); + } else { + customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability }); + } + + const notificationsService = new NotificationsService({ + schema: await getSchema({ database }), + accountability: customAccountability, + knex: database, + }); + + const messageString = message ? optionToString(message) : null; + + const result = await notificationsService.createOne({ + recipient, + sender: customAccountability?.user ?? null, + subject, + message: messageString, + }); + + return result; + }, +}); diff --git a/api/src/operations/request/index.ts b/api/src/operations/request/index.ts new file mode 100644 index 0000000000..11f3b9d592 --- /dev/null +++ b/api/src/operations/request/index.ts @@ -0,0 +1,19 @@ +import { defineOperationApi } from '@directus/shared/utils'; +import axios, { Method } from 'axios'; + +type Options = { + url: string; + method: Method; + body: Record | string | null; + headers: Record; +}; + +export default defineOperationApi({ + id: 'request', + + handler: async ({ url, method, body, headers }) => { + const result = await axios({ url, method, data: body, headers }); + + return { status: result.status, statusText: result.statusText, headers: result.headers, data: result.data }; + }, +}); diff --git a/api/src/operations/sleep/index.ts b/api/src/operations/sleep/index.ts new file mode 100644 index 0000000000..74c54cf9a9 --- /dev/null +++ b/api/src/operations/sleep/index.ts @@ -0,0 +1,13 @@ +import { defineOperationApi } from '@directus/shared/utils'; + +type Options = { + milliseconds: string | number; +}; + +export default defineOperationApi({ + id: 'sleep', + + handler: async ({ milliseconds }) => { + await new Promise((resolve) => setTimeout(resolve, Number(milliseconds))); + }, +}); diff --git a/api/src/operations/transform/index.ts b/api/src/operations/transform/index.ts new file mode 100644 index 0000000000..094dde4583 --- /dev/null +++ b/api/src/operations/transform/index.ts @@ -0,0 +1,14 @@ +import { defineOperationApi } from '@directus/shared/utils'; +import { parseJSON } from '../../utils/parse-json'; + +type Options = { + json: string; +}; + +export default defineOperationApi({ + id: 'transform', + + handler: ({ json }) => { + return parseJSON(json); + }, +}); diff --git a/api/src/operations/trigger/index.ts b/api/src/operations/trigger/index.ts new file mode 100644 index 0000000000..7625f1b0ac --- /dev/null +++ b/api/src/operations/trigger/index.ts @@ -0,0 +1,28 @@ +import { defineOperationApi } from '@directus/shared/utils'; +import { getFlowManager } from '../../flows'; +import { optionToObject } from '../../utils/operation-options'; + +type Options = { + flow: string; + payload?: Record | Record[] | string | null; +}; + +export default defineOperationApi({ + id: 'trigger', + + handler: async ({ flow, payload }, context) => { + const flowManager = getFlowManager(); + + const payloadObject = optionToObject(payload) ?? null; + + let result: unknown | unknown[]; + + if (Array.isArray(payloadObject)) { + result = await Promise.all(payloadObject.map((payload) => flowManager.runOperationFlow(flow, payload, context))); + } else { + result = await flowManager.runOperationFlow(flow, payloadObject, context); + } + + return result; + }, +}); diff --git a/api/src/services/activity.ts b/api/src/services/activity.ts index 5048db5396..29e93be9ed 100644 --- a/api/src/services/activity.ts +++ b/api/src/services/activity.ts @@ -1,18 +1,17 @@ -import { AbstractServiceOptions, PrimaryKey, Item, Action } from '../types'; -import { ItemsService } from './items'; -import { MutationOptions } from '../types'; -import { NotificationsService } from './notifications'; -import { UsersService } from './users'; -import { AuthorizationService } from './authorization'; -import { Accountability } from '@directus/shared/types'; -import { getPermissions } from '../utils/get-permissions'; +import { Accountability, Action } from '@directus/shared/types'; +import { uniq } from 'lodash'; +import validateUUID from 'uuid-validate'; +import env from '../env'; import { ForbiddenException } from '../exceptions/forbidden'; import logger from '../logger'; -import { userName } from '../utils/user-name'; -import { uniq } from 'lodash'; -import env from '../env'; -import validateUUID from 'uuid-validate'; +import { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types'; +import { getPermissions } from '../utils/get-permissions'; import { Url } from '../utils/url'; +import { userName } from '../utils/user-name'; +import { AuthorizationService } from './authorization'; +import { ItemsService } from './items'; +import { NotificationsService } from './notifications'; +import { UsersService } from './users'; export class ActivityService extends ItemsService { notificationsService: NotificationsService; diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index c633326222..f4794f89e2 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -1,27 +1,27 @@ +import { Accountability, Action, SchemaOverview } from '@directus/shared/types'; import jwt from 'jsonwebtoken'; import { Knex } from 'knex'; +import { clone, cloneDeep } from 'lodash'; import ms from 'ms'; import { nanoid } from 'nanoid'; +import { performance } from 'perf_hooks'; +import { getAuthProvider } from '../auth'; +import { DEFAULT_AUTH_PROVIDER } from '../constants'; import getDatabase from '../database'; import emitter from '../emitter'; import env from '../env'; -import { getAuthProvider } from '../auth'; -import { DEFAULT_AUTH_PROVIDER } from '../constants'; import { InvalidCredentialsException, InvalidOTPException, - UserSuspendedException, InvalidProviderException, + UserSuspendedException, } from '../exceptions'; import { createRateLimiter } from '../rate-limiter'; -import { ActivityService } from './activity'; -import { TFAService } from './tfa'; -import { AbstractServiceOptions, Action, Session, User, DirectusTokenPayload, LoginResult } from '../types'; -import { Accountability, SchemaOverview } from '@directus/shared/types'; -import { SettingsService } from './settings'; -import { clone, cloneDeep } from 'lodash'; -import { performance } from 'perf_hooks'; +import { AbstractServiceOptions, DirectusTokenPayload, LoginResult, Session, User } from '../types'; import { stall } from '../utils/stall'; +import { ActivityService } from './activity'; +import { SettingsService } from './settings'; +import { TFAService } from './tfa'; const loginAttemptsLimiter = createRateLimiter({ duration: 0 }); diff --git a/api/src/services/flows.ts b/api/src/services/flows.ts new file mode 100644 index 0000000000..a1b5559fd6 --- /dev/null +++ b/api/src/services/flows.ts @@ -0,0 +1,49 @@ +import { FlowRaw } from '@directus/shared/types'; +import { getMessenger, Messenger } from '../messenger'; +import { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types'; +import { ItemsService } from './items'; + +export class FlowsService extends ItemsService { + messenger: Messenger; + + constructor(options: AbstractServiceOptions) { + super('directus_flows', options); + this.messenger = getMessenger(); + } + + async createOne(data: Partial, opts?: MutationOptions): Promise { + const result = await super.createOne(data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async createMany(data: Partial[], opts?: MutationOptions): Promise { + const result = await super.createMany(data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async updateOne(key: PrimaryKey, data: Partial, opts?: MutationOptions): Promise { + const result = await super.updateOne(key, data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async updateMany(keys: PrimaryKey[], data: Partial, opts?: MutationOptions): Promise { + const result = await super.updateMany(keys, data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise { + const result = await super.deleteOne(key, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise { + const result = await super.deleteMany(keys, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } +} diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index f081b6fc15..cf4e519e23 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -1,5 +1,5 @@ import { BaseException } from '@directus/shared/exceptions'; -import { Accountability, Aggregate, Filter, Query, SchemaOverview } from '@directus/shared/types'; +import { Accountability, Action, Aggregate, Filter, Query, SchemaOverview } from '@directus/shared/types'; import argon2 from 'argon2'; import { ArgumentNode, @@ -52,7 +52,7 @@ import getDatabase from '../database'; import env from '../env'; import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions'; import { getExtensionManager } from '../extensions'; -import { AbstractServiceOptions, Action, GraphQLParams, Item } from '../types'; +import { AbstractServiceOptions, GraphQLParams, Item } from '../types'; import { generateHash } from '../utils/generate-hash'; import { getGraphQLType } from '../utils/get-graphql-type'; import { reduceSchema } from '../utils/reduce-schema'; @@ -63,9 +63,11 @@ import { AuthenticationService } from './authentication'; import { CollectionsService } from './collections'; import { FieldsService } from './fields'; import { FilesService } from './files'; +import { FlowsService } from './flows'; import { FoldersService } from './folders'; import { ItemsService } from './items'; import { NotificationsService } from './notifications'; +import { OperationsService } from './operations'; import { PermissionsService } from './permissions'; import { PresetsService } from './presets'; import { RelationsService } from './relations'; @@ -1635,6 +1637,10 @@ export class GraphQLService { return new WebhooksService(opts); case 'directus_shares': return new SharesService(opts); + case 'directus_flows': + return new FlowsService(opts); + case 'directus_operations': + return new OperationsService(opts); default: return new ItemsService(collection, opts); } diff --git a/api/src/services/index.ts b/api/src/services/index.ts index 36376ddef3..29c82c9980 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -8,12 +8,14 @@ export * from './collections'; export * from './dashboards'; export * from './fields'; export * from './files'; +export * from './flows'; export * from './folders'; export * from './graphql'; export * from './import-export'; export * from './mail'; export * from './meta'; export * from './notifications'; +export * from './operations'; export * from './panels'; export * from './payload'; export * from './permissions'; diff --git a/api/src/services/items.ts b/api/src/services/items.ts index a7046ccbf2..6d24fede3d 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -1,4 +1,4 @@ -import { Accountability, PermissionsAction, Query, SchemaOverview } from '@directus/shared/types'; +import { Accountability, Action, PermissionsAction, Query, SchemaOverview } from '@directus/shared/types'; import Keyv from 'keyv'; import { Knex } from 'knex'; import { assign, clone, cloneDeep, pick, without } from 'lodash'; @@ -9,14 +9,7 @@ import emitter from '../emitter'; import env from '../env'; import { ForbiddenException } from '../exceptions'; import { translateDatabaseError } from '../exceptions/database/translate'; -import { - AbstractService, - AbstractServiceOptions, - Action, - Item as AnyItem, - MutationOptions, - PrimaryKey, -} from '../types'; +import { AbstractService, AbstractServiceOptions, Item as AnyItem, MutationOptions, PrimaryKey } from '../types'; import getASTFromQuery from '../utils/get-ast-from-query'; import { AuthorizationService } from './authorization'; import { ActivityService, RevisionsService } from './index'; diff --git a/api/src/services/notifications.ts b/api/src/services/notifications.ts index 4fc7500e2c..2b9baa6896 100644 --- a/api/src/services/notifications.ts +++ b/api/src/services/notifications.ts @@ -1,8 +1,9 @@ -import { UsersService, MailService } from '.'; import { AbstractServiceOptions, PrimaryKey, MutationOptions } from '../types'; import { ItemsService } from './items'; import { Notification } from '@directus/shared/types'; import { md } from '../utils/md'; +import { UsersService } from './users'; +import { MailService } from './mail'; export class NotificationsService extends ItemsService { usersService: UsersService; diff --git a/api/src/services/operations.ts b/api/src/services/operations.ts new file mode 100644 index 0000000000..c5ad2abe36 --- /dev/null +++ b/api/src/services/operations.ts @@ -0,0 +1,49 @@ +import { OperationRaw } from '@directus/shared/types'; +import { getMessenger, Messenger } from '../messenger'; +import { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types'; +import { ItemsService } from './items'; + +export class OperationsService extends ItemsService { + messenger: Messenger; + + constructor(options: AbstractServiceOptions) { + super('directus_operations', options); + this.messenger = getMessenger(); + } + + async createOne(data: Partial, opts?: MutationOptions): Promise { + const result = await super.createOne(data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async createMany(data: Partial[], opts?: MutationOptions): Promise { + const result = await super.createMany(data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async updateOne(key: PrimaryKey, data: Partial, opts?: MutationOptions): Promise { + const result = await super.updateOne(key, data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async updateMany(keys: PrimaryKey[], data: Partial, opts?: MutationOptions): Promise { + const result = await super.updateMany(keys, data, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise { + const result = await super.deleteOne(key, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } + + async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise { + const result = await super.deleteMany(keys, opts); + this.messenger.publish('flows', { type: 'reload' }); + return result; + } +} diff --git a/api/src/types/events.ts b/api/src/types/events.ts new file mode 100644 index 0000000000..331090e39b --- /dev/null +++ b/api/src/types/events.ts @@ -0,0 +1,8 @@ +import { ActionHandler, FilterHandler, InitHandler } from '@directus/shared/types'; +import { ScheduledTask } from 'node-cron'; + +export type EventHandler = + | { type: 'filter'; name: string; handler: FilterHandler } + | { type: 'action'; name: string; handler: ActionHandler } + | { type: 'init'; name: string; handler: InitHandler } + | { type: 'schedule'; task: ScheduledTask }; diff --git a/api/src/types/express.d.ts b/api/src/types/express.d.ts index f0c6b7b511..7b8158975c 100644 --- a/api/src/types/express.d.ts +++ b/api/src/types/express.d.ts @@ -2,8 +2,7 @@ * Custom properties on the req object in express */ -import { Accountability } from '@directus/shared/types'; -import { Query } from '@directus/shared/types'; +import { Accountability, Query } from '@directus/shared/types'; import { SchemaOverview } from './schema'; export {}; diff --git a/api/src/types/index.ts b/api/src/types/index.ts index 038f7f837c..0660a3610c 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -1,8 +1,8 @@ -export * from './activity'; export * from './assets'; export * from './ast'; export * from './auth'; export * from './collection'; +export * from './events'; export * from './files'; export * from './graphql'; export * from './items'; diff --git a/api/src/utils/construct-flow-tree.ts b/api/src/utils/construct-flow-tree.ts new file mode 100644 index 0000000000..a341e47e33 --- /dev/null +++ b/api/src/utils/construct-flow-tree.ts @@ -0,0 +1,36 @@ +import { Flow, FlowRaw, Operation, OperationRaw } from '@directus/shared/types'; +import { omit } from 'lodash'; + +export function constructFlowTree(flow: FlowRaw): Flow { + const rootOperation = flow.operations.find((operation) => operation.id === flow.operation) ?? null; + + const operationTree = constructOperationTree(rootOperation, flow.operations); + + const flowTree: Flow = { + ...omit(flow, 'operations'), + operation: operationTree, + }; + + return flowTree; +} + +function constructOperationTree(root: OperationRaw | null, operations: OperationRaw[]): Operation | null { + if (root === null) { + return null; + } + + const resolveOperation = root.resolve !== null ? operations.find((operation) => operation.id === root.resolve) : null; + const rejectOperation = root.reject !== null ? operations.find((operation) => operation.id === root.reject) : null; + + if (resolveOperation === undefined || rejectOperation === undefined) { + throw new Error('Undefined reference in operations'); + } + + const operationTree: Operation = { + ...omit(root, 'flow'), + resolve: constructOperationTree(resolveOperation, operations), + reject: constructOperationTree(rejectOperation, operations), + }; + + return operationTree; +} diff --git a/api/src/utils/get-accountability-for-role.ts b/api/src/utils/get-accountability-for-role.ts new file mode 100644 index 0000000000..be5c31c69b --- /dev/null +++ b/api/src/utils/get-accountability-for-role.ts @@ -0,0 +1,47 @@ +import { Accountability, SchemaOverview } from '@directus/shared/types'; +import { getPermissions } from './get-permissions'; +import { InvalidConfigException } from '../exceptions'; +import { Knex } from 'knex'; + +export async function getAccountabilityForRole( + role: null | string, + context: { + accountability: null | Accountability; + schema: SchemaOverview; + database: Knex; + } +): Promise { + let generatedAccountability: Accountability | null = context.accountability; + + if (role === null) { + generatedAccountability = { + role: null, + user: null, + admin: false, + app: false, + }; + + generatedAccountability.permissions = await getPermissions(generatedAccountability, context.schema); + } else { + const roleInfo = await context.database + .select(['app_access', 'admin_access']) + .from('directus_roles') + .where({ id: role }) + .first(); + + if (!roleInfo) { + throw new InvalidConfigException(`Configured role "${role}" isn't a valid role ID or doesn't exist.`); + } + + generatedAccountability = { + role, + user: null, + admin: roleInfo.admin_access === 1 || roleInfo.admin_access === '1' || roleInfo.admin_access === true, + app: roleInfo.app_access === 1 || roleInfo.app_access === '1' || roleInfo.app_access === true, + }; + + generatedAccountability.permissions = await getPermissions(generatedAccountability, context.schema); + } + + return generatedAccountability; +} diff --git a/api/src/utils/operation-options.ts b/api/src/utils/operation-options.ts new file mode 100644 index 0000000000..0164321c2d --- /dev/null +++ b/api/src/utils/operation-options.ts @@ -0,0 +1,51 @@ +import { renderFn, get, Scope } from 'micromustache'; +import { parseJSON } from './parse-json'; + +type Mustacheable = string | number | boolean | null | Mustacheable[] | { [key: string]: Mustacheable }; +type GenericString = T extends string ? string : T; + +function resolveFn(path: string, scope?: Scope): unknown { + if (!scope) return undefined; + + const value = get(scope, path); + + return typeof value === 'object' ? JSON.stringify(value) : value; +} + +function renderMustache(item: T, scope: Scope): GenericString { + if (typeof item === 'string') { + return renderFn(item, resolveFn, scope, { explicit: true }) as GenericString; + } else if (Array.isArray(item)) { + return item.map((element) => renderMustache(element, scope)) as GenericString; + } else if (typeof item === 'object' && item !== null) { + return Object.fromEntries( + Object.entries(item).map(([key, value]) => [key, renderMustache(value, scope)]) + ) as GenericString; + } else { + return item as GenericString; + } +} + +export function applyOperationOptions(options: Record, data: Record): Record { + return Object.fromEntries( + Object.entries(options).map(([key, value]) => { + if (typeof value === 'string') { + const single = value.match(/^\{\{\s*([^}\s]+)\s*\}\}$/); + + if (single !== null) { + return [key, get(data, single[1])]; + } + } + + return [key, renderMustache(value, data)]; + }) + ); +} + +export function optionToObject(option: T): Exclude { + return typeof option === 'string' ? parseJSON(option) : option; +} + +export function optionToString(option: unknown): string { + return typeof option === 'object' ? JSON.stringify(option) : String(option); +} diff --git a/app/src/components/register.ts b/app/src/components/register.ts index 7fa4450e3f..f3ee2875b2 100644 --- a/app/src/components/register.ts +++ b/app/src/components/register.ts @@ -54,6 +54,8 @@ import VTextarea from './v-textarea'; import VUpload from './v-upload'; import VDatePicker from './v-date-picker'; import VEmojiPicker from './v-emoji-picker.vue'; +import VWorkspace from './v-workspace.vue'; +import VWorkspaceTile from './v-workspace-tile.vue'; export function registerComponents(app: App): void { app.component('VAvatar', VAvatar); @@ -114,6 +116,8 @@ export function registerComponents(app: App): void { app.component('VUpload', VUpload); app.component('VDatePicker', VDatePicker); app.component('VEmojiPicker', VEmojiPicker); + app.component('VWorkspace', VWorkspace); + app.component('VWorkspaceTile', VWorkspaceTile); app.component('TransitionBounce', TransitionBounce); app.component('TransitionDialog', TransitionDialog); diff --git a/app/src/components/v-button/v-button.vue b/app/src/components/v-button/v-button.vue index 244b114902..0aa10b60dc 100644 --- a/app/src/components/v-button/v-button.vue +++ b/app/src/components/v-button/v-button.vue @@ -199,7 +199,7 @@ export default defineComponent({ --v-button-background-color: var(--primary); --v-button-background-color-hover: var(--primary-125); --v-button-background-color-active: var(--primary); - --v-button-background-color-disabled: var(--background-subdued); + --v-button-background-color-disabled: var(--background-normal); --v-button-font-size: 16px; --v-button-font-weight: 600; --v-button-line-height: 22px; diff --git a/app/src/components/v-checkbox/v-checkbox.vue b/app/src/components/v-checkbox/v-checkbox.vue index ed7356f092..bac53fd88f 100644 --- a/app/src/components/v-checkbox/v-checkbox.vue +++ b/app/src/components/v-checkbox/v-checkbox.vue @@ -215,11 +215,6 @@ body { } } - &:focus:not(:disabled) { - border-color: var(--primary); - box-shadow: 0 0 16px -8px var(--primary); - } - &:not(:disabled):not(.indeterminate) { .label { color: var(--foreground-normal); diff --git a/app/src/components/v-detail/v-detail.vue b/app/src/components/v-detail/v-detail.vue index 03167c464c..18dd668ea8 100644 --- a/app/src/components/v-detail/v-detail.vue +++ b/app/src/components/v-detail/v-detail.vue @@ -7,7 +7,7 @@ -
+
@@ -73,7 +73,6 @@ export default defineComponent({ diff --git a/app/src/components/v-drawer/v-drawer.vue b/app/src/components/v-drawer/v-drawer.vue index 2b0455fd61..6414b2b1ec 100644 --- a/app/src/components/v-drawer/v-drawer.vue +++ b/app/src/components/v-drawer/v-drawer.vue @@ -1,5 +1,5 @@ @@ -29,44 +33,41 @@
- diff --git a/app/src/components/v-workspace.vue b/app/src/components/v-workspace.vue new file mode 100644 index 0000000000..2a6191f2b8 --- /dev/null +++ b/app/src/components/v-workspace.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/app/src/composables/use-revisions.ts b/app/src/composables/use-revisions.ts new file mode 100644 index 0000000000..d1311df859 --- /dev/null +++ b/app/src/composables/use-revisions.ts @@ -0,0 +1,197 @@ +import api from '@/api'; +import { localizedFormat } from '@/utils/localized-format'; +import { localizedFormatDistance } from '@/utils/localized-format-distance'; +import { unexpectedError } from '@/utils/unexpected-error'; +import { Action, Filter } from '@directus/shared/types'; +import { isThisYear, isToday, isYesterday, parseISO, format } from 'date-fns'; +import { groupBy, orderBy } from 'lodash'; +import { ref, Ref, unref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { Revision, RevisionsByDate } from '../views/private/components/revisions-drawer-detail/types'; + +type UseRevisionsOptions = { + action?: Action; +}; + +export function useRevisions(collection: Ref, primaryKey: Ref, options?: UseRevisionsOptions) { + const { t } = useI18n(); + + const revisions = ref(null); + const revisionsByDate = ref(null); + const loading = ref(false); + const revisionsCount = ref(0); + const created = ref(); + const pagesCount = ref(0); + + watch([collection, primaryKey], () => getRevisions(), { immediate: true }); + + return { created, revisions, revisionsByDate, loading, refresh, revisionsCount, pagesCount }; + + async function getRevisions(page = 0) { + if (typeof unref(primaryKey) === 'undefined') return; + + loading.value = true; + const pageSize = 100; + + try { + const filter: Filter = { + _and: [ + { + collection: { + _eq: unref(collection), + }, + }, + { + item: { + _eq: unref(primaryKey), + }, + }, + ], + }; + + if (options?.action) { + filter._and.push({ + activity: { + action: { + _eq: options?.action, + }, + }, + }); + } + + const response = await api.get(`/revisions`, { + params: { + filter, + sort: '-id', + limit: pageSize, + page, + fields: [ + 'id', + 'data', + 'delta', + 'collection', + 'item', + 'activity.action', + 'activity.timestamp', + 'activity.user.id', + 'activity.user.email', + 'activity.user.first_name', + 'activity.user.last_name', + 'activity.ip', + 'activity.user_agent', + ], + meta: ['filter_count'], + }, + }); + + const createdResponse = await api.get(`/revisions`, { + params: { + filter: { + collection: { + _eq: unref(collection), + }, + item: { + _eq: unref(primaryKey), + }, + activity: { + action: { + _eq: 'create', + }, + }, + }, + sort: '-id', + limit: 1, + fields: [ + 'id', + 'data', + 'delta', + 'collection', + 'item', + 'activity.action', + 'activity.timestamp', + 'activity.user.id', + 'activity.user.email', + 'activity.user.first_name', + 'activity.user.last_name', + 'activity.ip', + 'activity.user_agent', + ], + meta: ['filter_count'], + }, + }); + + created.value = createdResponse.data.data?.[0]; + + const revisionsGroupedByDate = groupBy( + response.data.data.filter((revision: any) => !!revision.activity), + (revision: Revision) => { + // revision's timestamp date is in iso-8601 + const date = new Date(new Date(revision.activity.timestamp).toDateString()); + return date; + } + ); + + const revisionsGrouped: RevisionsByDate[] = []; + + for (const [key, value] of Object.entries(revisionsGroupedByDate)) { + const date = new Date(key); + const today = isToday(date); + const yesterday = isYesterday(date); + const thisYear = isThisYear(date); + + let dateFormatted: string; + + if (today) dateFormatted = t('today'); + else if (yesterday) dateFormatted = t('yesterday'); + else if (thisYear) dateFormatted = await localizedFormat(date, String(t('date-fns_date_short_no_year'))); + else dateFormatted = await localizedFormat(date, String(t('date-fns_date_short'))); + + const revisions = []; + + for (const revision of value) { + revisions.push({ + ...revision, + timestampFormatted: await getFormattedDate(revision.activity?.timestamp), + timeRelative: `${getTime(revision.activity?.timestamp)} (${await localizedFormatDistance( + parseISO(revision.activity?.timestamp), + new Date(), + { + addSuffix: true, + } + )})`, + }); + } + + revisionsGrouped.push({ + date: date, + dateFormatted: String(dateFormatted), + revisions, + }); + } + + revisionsByDate.value = orderBy(revisionsGrouped, ['date'], ['desc']); + revisions.value = orderBy(response.data.data, ['activity.timestamp'], ['desc']); + revisionsCount.value = response.data.meta.filter_count; + pagesCount.value = Math.ceil(revisionsCount.value / pageSize); + } catch (err: any) { + unexpectedError(err); + } finally { + loading.value = false; + } + } + + async function refresh(page = 0) { + await getRevisions(page); + } + + function getTime(timestamp: string) { + return format(new Date(timestamp), String(t('date-fns_time'))); + } + + async function getFormattedDate(timestamp: string) { + const date = await localizedFormat(new Date(timestamp), String(t('date-fns_date_short'))); + const time = await localizedFormat(new Date(timestamp), String(t('date-fns_time'))); + + return `${date} (${time})`; + } +} diff --git a/app/src/composables/use-system.ts b/app/src/composables/use-system.ts index e7e6bcc328..2133d2c50e 100644 --- a/app/src/composables/use-system.ts +++ b/app/src/composables/use-system.ts @@ -7,6 +7,7 @@ import { getDisplays } from '@/displays'; import { getLayouts } from '@/layouts'; import { getModules } from '@/modules'; import { getPanels } from '@/panels'; +import { getOperations } from '@/operations'; export default function useSystem(): void { provide(STORES_INJECT, stores); @@ -19,5 +20,6 @@ export default function useSystem(): void { layouts: getLayouts().layouts, modules: getModules().modules, panels: getPanels().panels, + operations: getOperations().operations, }); } diff --git a/app/src/hydrate.ts b/app/src/hydrate.ts index 2a5a35a784..ff56eb7e72 100644 --- a/app/src/hydrate.ts +++ b/app/src/hydrate.ts @@ -7,6 +7,7 @@ import { useFieldsStore, useLatencyStore, useInsightsStore, + useFlowsStore, usePermissionsStore, usePresetsStore, useRelationsStore, @@ -39,6 +40,7 @@ export function useStores( useRelationsStore, usePermissionsStore, useInsightsStore, + useFlowsStore, useNotificationsStore, ] ): GenericStore[] { diff --git a/app/src/interfaces/_system/system-filter/nodes.vue b/app/src/interfaces/_system/system-filter/nodes.vue index 17e184a26a..aa44a727c8 100644 --- a/app/src/interfaces/_system/system-filter/nodes.vue +++ b/app/src/interfaces/_system/system-filter/nodes.vue @@ -16,7 +16,8 @@
- + {{ getFieldPreview(element) }} + @@ -281,6 +282,7 @@ import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detai import ArchiveSidebarDetail from '@/views/private/components/archive-sidebar-detail'; import RefreshSidebarDetail from '@/views/private/components/refresh-sidebar-detail'; import ExportSidebarDetail from '@/views/private/components/export-sidebar-detail.vue'; +import FlowSidebarDetail from '@/views/private/components/flow-sidebar-detail.vue'; import SearchInput from '@/views/private/components/search-input'; import BookmarkAdd from '@/views/private/components/bookmark-add'; import { useRouter } from 'vue-router'; @@ -307,6 +309,7 @@ export default defineComponent({ ArchiveSidebarDetail, RefreshSidebarDetail, ExportSidebarDetail, + FlowSidebarDetail, }, props: { collection: { diff --git a/app/src/modules/content/routes/item.vue b/app/src/modules/content/routes/item.vue index 9ce329d952..667afb127c 100644 --- a/app/src/modules/content/routes/item.vue +++ b/app/src/modules/content/routes/item.vue @@ -197,6 +197,12 @@ :primary-key="internalPrimaryKey" :allowed="shareAllowed" /> + @@ -211,6 +217,7 @@ import { useCollection } from '@directus/shared/composables'; import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail'; import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail'; import SharesSidebarDetail from '@/views/private/components/shares-sidebar-detail'; +import FlowSidebarDetail from '@/views/private/components/flow-sidebar-detail.vue'; import useItem from '@/composables/use-item'; import SaveOptions from '@/views/private/components/save-options'; import useShortcut from '@/composables/use-shortcut'; @@ -229,6 +236,7 @@ export default defineComponent({ RevisionsDrawerDetail, CommentsSidebarDetail, SharesSidebarDetail, + FlowSidebarDetail, SaveOptions, }, props: { diff --git a/app/src/modules/insights/components/dashboard-dialog.vue b/app/src/modules/insights/components/dashboard-dialog.vue index 3e7ed4f56f..21220bac1c 100644 --- a/app/src/modules/insights/components/dashboard-dialog.vue +++ b/app/src/modules/insights/components/dashboard-dialog.vue @@ -10,8 +10,9 @@
- + +
@@ -59,6 +60,7 @@ export default defineComponent({ const values = reactive({ name: props.dashboard?.name ?? null, icon: props.dashboard?.icon ?? 'dashboard', + color: props.dashboard?.color ?? null, note: props.dashboard?.note ?? null, }); @@ -68,6 +70,7 @@ export default defineComponent({ if (isEqual(newValue, oldValue) === false) { values.name = props.dashboard?.name ?? null; values.icon = props.dashboard?.icon ?? 'dashboard'; + values.color = props.dashboard?.color ?? null; values.note = props.dashboard?.note ?? null; } } @@ -94,7 +97,7 @@ export default defineComponent({ router.push(`/insights/${response.data.data.id}`); } emit('update:modelValue', false); - } catch (err) { + } catch (err: any) { unexpectedError(err); } finally { saving.value = false; diff --git a/app/src/modules/insights/components/navigation.vue b/app/src/modules/insights/components/navigation.vue index 0f48778470..7dccf58f38 100644 --- a/app/src/modules/insights/components/navigation.vue +++ b/app/src/modules/insights/components/navigation.vue @@ -5,7 +5,7 @@ - + @@ -31,6 +31,7 @@ export default defineComponent({ const navItems = computed(() => insightsStore.dashboards.map((dashboard: Dashboard) => ({ icon: dashboard.icon, + color: dashboard.color, name: dashboard.name, to: `/insights/${dashboard.id}`, })) diff --git a/app/src/modules/insights/components/panel.vue b/app/src/modules/insights/components/panel.vue deleted file mode 100644 index cf621539c2..0000000000 --- a/app/src/modules/insights/components/panel.vue +++ /dev/null @@ -1,514 +0,0 @@ - - - - - diff --git a/app/src/modules/insights/components/workspace.vue b/app/src/modules/insights/components/workspace.vue deleted file mode 100644 index 5f53352101..0000000000 --- a/app/src/modules/insights/components/workspace.vue +++ /dev/null @@ -1,174 +0,0 @@ - - - - - diff --git a/app/src/modules/insights/routes/dashboard.vue b/app/src/modules/insights/routes/dashboard.vue index bac8c93265..5b900d9aa9 100644 --- a/app/src/modules/insights/routes/dashboard.vue +++ b/app/src/modules/insights/routes/dashboard.vue @@ -82,16 +82,29 @@ - + > + + { + if (panel.icon) return panel; + + return { + ...panel, + icon: panelsInfo.value.find((panelConfig) => panelConfig.id === panel.type)?.icon, + }; + }); + + return withIcons; }); const hasEdits = computed(() => stagedPanels.value.length > 0 || panelsToBeDeleted.value.length > 0); @@ -315,13 +339,13 @@ export default defineComponent({ deletePanel, attemptCancelChanges, duplicatePanel, + editPanel, movePanelLoading, t, toggleFullScreen, zoomToFit, fullScreen, toggleZoomToFit, - md, movePanelChoices, movePanelTo, confirmLeave, @@ -441,6 +465,10 @@ export default defineComponent({ stagePanelEdits({ edits: newPanel, id: '+' }); } + function editPanel(panel: Panel) { + router.push(`/insights/${panel.dashboard}/${panel.id}`); + } + function toggleFullScreen() { fullScreen.value = !fullScreen.value; } diff --git a/app/src/modules/insights/routes/overview.vue b/app/src/modules/insights/routes/overview.vue index d0885b242a..b6debfd25b 100644 --- a/app/src/modules/insights/routes/overview.vue +++ b/app/src/modules/insights/routes/overview.vue @@ -41,7 +41,7 @@ @click:row="navigateToDashboard" > - +interface Props { + group: Record; +} - +defineProps(); +defineEmits(['click']); + +const expand = ref(true); + diff --git a/app/src/views/private/components/revisions-drawer-detail/revisions-drawer-detail.vue b/app/src/views/private/components/revisions-drawer-detail/revisions-drawer-detail.vue index 5b8a6ee782..4c682d02cd 100644 --- a/app/src/views/private/components/revisions-drawer-detail/revisions-drawer-detail.vue +++ b/app/src/views/private/components/revisions-drawer-detail/revisions-drawer-detail.vue @@ -35,208 +35,47 @@ -