From c499cbf1c46535f5a5a1814af727651491e8f735 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 21 Sep 2026 10:25:18 +0200 Subject: [PATCH 1/8] Improvements --- CLAUDE.md | 1 + backend/i18n/frontend_de.json | 2 + backend/i18n/frontend_en.json | 2 + backend/i18n/frontend_fr.json | 2 + backend/i18n/frontend_it.json | 2 + backend/i18n/frontend_nl.json | 2 + backend/i18n/frontend_pt.json | 2 + backend/i18n/frontend_zh.json | 2 + backend/i18n/source/frontend_en.json | 2 + .../shared/forms/field-editor.component.html | 3 + .../shared/forms/field-editor.component.scss | 5 + .../shared/forms/field-editor.component.ts | 3 +- .../shared/services/assets.service.spec.ts | 20 +- .../src/app/shared/services/assets.service.ts | 4 +- .../shared/state/asset-uploader.state.spec.ts | 42 +- .../app/shared/state/asset-uploader.state.ts | 16 +- todo.md | 510 +++++++++++++++++- 17 files changed, 591 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc0665a5d5..6bf579fff1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ npm run lint - i18n texts live in `backend/i18n`, translations are generated into the frontend — do not edit generated translation files by hand. - Do not write JsDoc comments. +- In tests, keep `verify` calls and `expect` calls next to each other, without blank lines between them. ## Backend diff --git a/backend/i18n/frontend_de.json b/backend/i18n/frontend_de.json index 4da35d1599..eee6a426d7 100644 --- a/backend/i18n/frontend_de.json +++ b/backend/i18n/frontend_de.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "Asset löschen", "assets.downloadVersion": "Diese Version herunterladen", "assets.dropToUpdate": "Zum Aktualisieren hier ablegen", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "Asset wurde bereits hochgeladen.", "assets.edit": "Asset bearbeiten", "assets.editor.flipHorizontally": "Horizontal spiegeln", diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index be749d4e1a..3532565041 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "Delete asset", "assets.downloadVersion": "Download this Version", "assets.dropToUpdate": "Drop to update", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "Asset has already been uploaded.", "assets.edit": "Edit Asset", "assets.editor.flipHorizontally": "Flip Horizontally", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index 41d4dc725e..5564d68204 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "Supprimer l'actif", "assets.downloadVersion": "Télécharger cette version", "assets.dropToUpdate": "Déposez pour mettre à jour", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "L'actif a déjà été téléchargé.", "assets.edit": "Modifier l'actif", "assets.editor.flipHorizontally": "Retourner horizontalement", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 175ce9119b..aa01749249 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "Risorsa cancellata", "assets.downloadVersion": "Scarica questa versione", "assets.dropToUpdate": "Trascina il file per aggiornare", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "La risorsa è già stata caricata.", "assets.edit": "Edit Asset", "assets.editor.flipHorizontally": "Capovolgi orizzontalmente", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 53332ddc0f..e8dfba5538 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "Verwijder bestand", "assets.downloadVersion": "Download deze versie", "assets.dropToUpdate": "Zet neer om te updaten", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "Asset is al geüpload.", "assets.edit": "Asset aanpassen", "assets.editor.flipHorizontally": "Horizontaal spiegelen", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 8f148a34b6..f918b9d5b9 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "Excluir o ficheiro", "assets.downloadVersion": "Descarregue esta versão", "assets.dropToUpdate": "Deixar para atualizar", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "O ficheiro já foi carregado.", "assets.edit": "Edição de ficheiros", "assets.editor.flipHorizontally": "Virar horizontalmente", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 3e58dfea50..27fc0c3790 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "删除资源", "assets.downloadVersion": "下载此版本", "assets.dropToUpdate": "下拉更新", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "资源已经上传。", "assets.edit": "Edit Asset", "assets.editor.flipHorizontally": "Flip Horizontally", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index be749d4e1a..3532565041 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -87,6 +87,8 @@ "assets.deleteReferrerConfirmTitle": "Delete asset", "assets.downloadVersion": "Download this Version", "assets.dropToUpdate": "Drop to update", + "assets.duplicateConfirmText": "This file has already been uploaded. Do you want to upload it anyway as a new asset?", + "assets.duplicateConfirmTitle": "Duplicate asset", "assets.duplicateFile": "Asset has already been uploaded.", "assets.edit": "Edit Asset", "assets.editor.flipHorizontally": "Flip Horizontally", diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.html b/frontend/src/app/features/content/shared/forms/field-editor.component.html index b5ec46740b..8b99e1bbdb 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.html +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.html @@ -5,6 +5,9 @@
@if (field.isDisabled) { Disabled diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.scss b/frontend/src/app/features/content/shared/forms/field-editor.component.scss index d224c26b20..72986d422b 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.scss +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.scss @@ -7,6 +7,11 @@ color: $color-theme-error; } + &-required-publish { + color: $color-theme-warning; + cursor: default; + } + &-disabled { color: $color-text-decent; font-size: $font-smallest; diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.ts b/frontend/src/app/features/content/shared/forms/field-editor.component.ts index c584586ac1..5e1e3415c6 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.ts +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.ts @@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common'; import { booleanAttribute, Component, ElementRef, EventEmitter, Input, numberAttribute, Output, ViewChild } from '@angular/core'; import { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BehaviorSubject, Observable } from 'rxjs'; -import { AbstractContentForm, AnnotationCreate, AnnotationsSelected, AnyFieldDto, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentSelected, CommentsState, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FieldSelected, FormHintComponent, GeolocationEditorComponent, hasNoValue$, HTTP, IndeterminateValueDirective, MarkdownDirective, MathHelper, MenuComponent, MenuItemComponent, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, ReferencesFieldPropertiesDto, RichEditorComponent, StarsComponent, Subscriptions, TagEditorComponent, ToggleComponent, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; +import { AbstractContentForm, AnnotationCreate, AnnotationsSelected, AnyFieldDto, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentSelected, CommentsState, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FieldSelected, FormHintComponent, GeolocationEditorComponent, hasNoValue$, HTTP, IndeterminateValueDirective, MarkdownDirective, MathHelper, MenuComponent, MenuItemComponent, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, ReferencesFieldPropertiesDto, RichEditorComponent, StarsComponent, Subscriptions, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; import { ReferenceDropdownComponent } from '../references/reference-dropdown.component'; import { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component'; import { ReferencesEditorComponent } from '../references/references-editor.component'; @@ -59,6 +59,7 @@ import { UserInfoEditorComponent } from './user-info-editor.component'; StockPhotoEditorComponent, TagEditorComponent, ToggleComponent, + TooltipDirective, TransformInputDirective, UserInfoEditorComponent, ], diff --git a/frontend/src/app/shared/services/assets.service.spec.ts b/frontend/src/app/shared/services/assets.service.spec.ts index b25e2d8306..3b379d19ad 100644 --- a/frontend/src/app/shared/services/assets.service.spec.ts +++ b/frontend/src/app/shared/services/assets.service.spec.ts @@ -248,6 +248,22 @@ describe('AssetsService', () => { expect(asset!).toEqual(createAsset(12)); })); + it('should make post with duplicate flag to create asset', inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { + let asset: AssetDto; + assetsService.postAssetFile('my-app', null!, 'parent1', true).subscribe(result => { + asset = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?parentId=parent1&duplicate=true'); + + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush(assetResponse(12)); + + expect(asset!).toEqual(createAsset(12)); + })); + it('should return proper error if upload failed with 413', inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { let error: ErrorDto; assetsService.postAssetFile('my-app', null!).subscribe({ @@ -517,7 +533,7 @@ describe('AssetsService', () => { } }); -export function createAsset(id: number, tags?: string[], suffix = '', parentId?: string) { +export function createAsset(id: number, tags?: string[], suffix = '', parentId?: string, isDuplicate = true) { parentId = parentId || MathHelper.EMPTY_GUID; const key = `${id}${suffix}`; @@ -553,7 +569,7 @@ export function createAsset(id: number, tags?: string[], suffix = '', parentId?: update: new ResourceLinkDto({ method: 'PUT', href: `/assets/${id}` }), }, _meta: new AssetMetaDto({ - isDuplicate: 'true', + isDuplicate: isDuplicate ? 'true' : 'false', }), }); } diff --git a/frontend/src/app/shared/services/assets.service.ts b/frontend/src/app/shared/services/assets.service.ts index 5f3d2a21eb..8647472686 100644 --- a/frontend/src/app/shared/services/assets.service.ts +++ b/frontend/src/app/shared/services/assets.service.ts @@ -106,8 +106,8 @@ export class AssetsService { pretifyError('i18n:assets.loadFailed')); } - public postAssetFile(appName: string, file: HTTP.UploadFile, parentId?: string): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets${StringHelper.buildQuery({ parentId })}`); + public postAssetFile(appName: string, file: HTTP.UploadFile, parentId?: string, duplicate?: boolean): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets${StringHelper.buildQuery({ parentId, duplicate })}`); return HTTP.upload(this.http, 'POST', url, file).pipe( filter(event => diff --git a/frontend/src/app/shared/state/asset-uploader.state.spec.ts b/frontend/src/app/shared/state/asset-uploader.state.spec.ts index 1a460a7361..7fcdfd0ec8 100644 --- a/frontend/src/app/shared/state/asset-uploader.state.spec.ts +++ b/frontend/src/app/shared/state/asset-uploader.state.spec.ts @@ -6,7 +6,7 @@ */ import { lastValueFrom, NEVER, Observable, of, onErrorResumeNextWith, throwError } from 'rxjs'; -import { IMock, Mock } from 'typemoq'; +import { IMock, It, Mock, Times } from 'typemoq'; import { AssetsService, AssetUploaderState, DialogService } from '@app/shared/internal'; import { createAsset } from '../services/assets.service.spec'; import { TestValues } from './_test-helpers'; @@ -18,7 +18,7 @@ describe('AssetUploaderState', () => { let dialogs: IMock; let assetUploader: AssetUploaderState; - const asset = createAsset(1); + const asset = createAsset(1, undefined, '', undefined, false); beforeEach(() => { dialogs = Mock.ofType(); @@ -88,6 +88,44 @@ describe('AssetUploaderState', () => { expect(uploadedAsset).toEqual(asset); }); + it('should upload file again if duplicate is confirmed', async () => { + const file: File = { name: 'my-file' }; + + const duplicate = createAsset(2); + + assetsService.setup(x => x.postAssetFile(app, file, undefined)) + .returns(() => of(10, duplicate)).verifiable(); + + assetsService.setup(x => x.postAssetFile(app, file, undefined, true)) + .returns(() => of(10, asset)).verifiable(); + + dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString())) + .returns(() => of(true)).verifiable(); + + const uploadedAsset = await lastValueFrom(assetUploader.uploadFile(file)); + + expect(uploadedAsset).toEqual(asset); + dialogs.verifyAll(); + }); + + it('should return existing asset if duplicate is not confirmed', async () => { + const file: File = { name: 'my-file' }; + + const duplicate = createAsset(2); + + assetsService.setup(x => x.postAssetFile(app, file, undefined)) + .returns(() => of(10, duplicate)).verifiable(); + + dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString())) + .returns(() => of(false)).verifiable(); + + const uploadedAsset = await lastValueFrom(assetUploader.uploadFile(file)); + + expect(uploadedAsset).toEqual(duplicate); + assetsService.verify(x => x.postAssetFile(app, file, undefined, true), Times.never()); + dialogs.verifyAll(); + }); + it('should create initial state if uploading asset', () => { const file: File = { name: 'my-file' }; diff --git a/frontend/src/app/shared/state/asset-uploader.state.ts b/frontend/src/app/shared/state/asset-uploader.state.ts index bc8497190b..d446314414 100644 --- a/frontend/src/app/shared/state/asset-uploader.state.ts +++ b/frontend/src/app/shared/state/asset-uploader.state.ts @@ -6,7 +6,7 @@ */ import { Injectable } from '@angular/core'; -import { Observable, shareReplay, Subject, takeUntil } from 'rxjs'; +import { Observable, of, shareReplay, Subject, switchMap, take, takeUntil } from 'rxjs'; import { debug, DialogService, HTTP, MathHelper, State, Types } from '@app/framework'; import { AssetDto } from '../model'; import { AssetsService } from '../services/assets.service'; @@ -72,7 +72,19 @@ export class AssetUploaderState extends State { } public uploadFile(file: HTTP.UploadFile, parentId?: string): Observable { - const stream = this.assetsService.postAssetFile(this.appName, file, parentId); + const stream = this.assetsService.postAssetFile(this.appName, file, parentId).pipe( + switchMap(event => { + if (Types.isNumber(event) || !event.isDuplicate) { + return of(event); + } + + // Let the user decide whether to keep the existing asset or upload the file again. + return this.dialogs.confirm('i18n:assets.duplicateConfirmTitle', 'i18n:assets.duplicateConfirmText').pipe( + take(1), + switchMap(confirmed => confirmed ? + this.assetsService.postAssetFile(this.appName, file, parentId, true) : + of(event))); + })); return this.upload(stream, MathHelper.guid(), file.name); } diff --git a/todo.md b/todo.md index dd57a01085..cfffbf4501 100644 --- a/todo.md +++ b/todo.md @@ -1,29 +1,499 @@ -# Backend Improvements +# Ten feature requests worth implementing next -All four items are fixed on `migration-improvements`. +Selected from the **195 not-implemented feature requests** extracted from + (see [FEATURE-REQUESTS.md](FEATURE-REQUESTS.md) for +all 588). Every item was checked against the working copy at `D:\Squidex` +(branch `stability`, HEAD `fc026a3`, v7.23.0) before being listed. -## 1. Protected assets were served with `Cache-Control: public` — done +**This revision:** self-service password reset was dropped — with an external +identity provider in most deployments it is dead weight. The list is also +weighted towards substantial features, and every *declined* and *postponed* +candidate was re-read against today's code rather than taken at face value. -`AssetContentController` now sends `private,no-store` when `asset.IsProtected`, so a CDN or shared -proxy can no longer store the file under a URL that needs no credentials. The client-supplied -`?cache=` value is clamped to `assets:maxCacheDuration` (default 365 days). +## Why re-reading the declines mattered -## 2. GraphQL had no depth or complexity limit — done +Most declines date from 2019–2022 and several rest on constraints the codebase +no longer has. Three of the ten below are recommended **because the maintainer's +own stated blocker has since been removed:** -`AddSquidexGraphQL` registers the complexity analyzer from `graphQL:maxDepth` (default 30, 0 -disables) and `graphQL:maxComplexity` (default 0 — off, because the factor depends on the schema). -Apollo tracing instrumented every field of every request; it is now behind -`graphQL:enableTracing`, off by default. +| Then | Now | +| --- | --- | +| #5286: *"we are only dealing with strings at the moment. So a solution could be to save the remirror state tree instead of html or string"* (2023) | Exactly that shipped — *"New rich text field that stores the content as JSON structure"* (`CHANGELOG.md:300`), with `RichTextNode`/`RichTextMark`/`SquidexRichText` in the domain | +| #1366/#3461: UI export is *"a lot of work with very little benefit"*, *"not possible"* (2019–2021) | A generic job framework exists — `IJobRunner` with `RunAsync` **and `DownloadAsync(job, stream, ct)`**, described as unifying *"rules, backups and future background jobs"* (`CHANGELOG.md:266`) | +| #1229/#5676: asset permissions are *"super complicated"* (2019–2024) | Per-schema scoping is now proven in the same permission grammar — `contents.{schema}.read`, even `contents.{schema}.read.own` (`PermissionIds.cs:212-215`) | -## 3. Image resizing had no deduplication and no concurrency cap — done +Conversely, the same exercise **removed** items: #3321 (concrete GraphQL +reference types) and #1852 (markdown editor) have quietly been implemented since +being postponed and declined. See +[Rejected because already covered](#rejected-because-already-covered). -New `AssetResizeGate` serializes per `(appId, assetId, fileVersion, suffix)` and bounds the total -number of parallel resizes via `assets:maxConcurrentResizes` (default: number of cores). Requests -that lose the race re-check the file store after the gate instead of decoding the image again. -Applied to both `AssetContentController` and `AppImageController`. +## The list -## 4. In-process caches had no size limit — done +| # | Feature | Forum status | Views | Size | +| --- | --- | --- | --- | --- | +| [1](#1-visual-diff-between-content-versions-5286) | Visual diff between content versions | Postponed | 1412 | large | +| [2](#2-export-and-import-content-from-the-ui-as-jobs-3461-1366-3438) | Export/import content from the UI as jobs | Declined ×2 | 5381 | large | +| [3](#3-scope-asset-permissions-to-folders-5921-5676-1229) | Scope asset permissions to folders | Open ×2 + Declined | 3857 | large | +| [4](#4-dark-mode-and-per-app-theming-5720-816) | Dark mode and per-app theming | Open + Declined | 2102 | large | +| [5](#5-azure-service-bus-flow-step-4522) | Azure Service Bus flow step | Postponed | 1838 | medium | +| [6](#6-give-scripts-a-way-to-log-2822) | Give scripts a way to log | Unresolved | 2575 | medium | +| [7](#7-upsert-mode-for-the-create-content-flow-step-5616) | Upsert mode for "Create content" step | Unresolved | 786 | small | +| [8](#8-regenerate-a-client-secret-from-the-ui-5807) | Regenerate a client secret from the UI | Open | 1383 | small | +| [9](#9-show-which-fields-are-required-when-publishing-4375) | ✅ **Done** — Mark "required when publishing" fields | Declined | 1707 | tiny | +| [10](#10-let-users-upload-a-duplicate-asset-anyway-3144) | ✅ **Done** — "Upload anyway" for duplicate assets | Declined | 1356 | tiny | -`UsageGate`, `AssetUsageTracker` and the extensions' `ClientPool` now set `SizeLimit` on their -`MemoryCache` and a `Size` on every entry, so they no longer grow with the number of tenants, -assets or client configurations. +Items 1–4 are projects. 5–6 are a few days each. 7–10 are hours, and are kept +because they are cheap enough that leaving them undone is the expensive choice. + +--- + +## 1. Visual diff between content versions (#5286) + +**Asked:** highlight what changed between two versions of a long article, +instead of leaving the reviewer to read both. + +· Postponed · 1412 views · 2023-11 + +**Why it matters.** Review is where a CMS earns its keep, and it is the one +place Squidex currently hands the work back to the human. The same need arrives +from three directions in the archive: this request, +[#6120](https://support.squidex.io/t/implement-a-way-to-add-suggestions-to-draft-items/6120) +(*"We need to review blog posts before they are published… comments aren't well +suited to review longer blogposts as it's hard to reference the actual +content"*, Open, 2026-03) and +[#4518](https://support.squidex.io/t/very-unlikely-allow-multiple-users-to-view-content-and-only-one-user-to-edit/4518) +(knowing whether someone else is mid-edit). Workflows already move content +through review states; what is missing is seeing *what* is under review. + +**Why the decline no longer applies.** This is the clearest changed-circumstance +in the archive. The maintainer's reasoning in 2023 was: + +> *"The problem is that we are only dealing with strings at the moment. So a +> solution could be to save the remirror state tree instead of html or string +> and then make the html or markdown conversion on the fly."* + +That prerequisite is now shipped. `CHANGELOG.md:300` records a *"New rich text +field that stores the content as JSON structure and provides on demand +formatting into HTML and markdown over GraphQL"*, and the domain model is +there: `RichTextNode`, `RichTextMark`, `RichTextExtensions`, `SquidexRichText` +(`backend/src/Squidex.Domain.Apps.Core.Model/Contents/`). Retrieving the two +versions to compare is already supported — +`ContentsController.GetContent(app, schema, id, long version)` (line 147) takes +a version and passes it to `contentQuery.FindAsync`. + +**Scope it narrowly.** The second objection in that thread was that a two-state +diff is ambiguous (*"Hello World"* → *"World is big"* — what changed?) and that +tools like the Yjs demo work by keeping a change history instead. That is an +argument against *perfect attribution*, not against a diff. The requester asked +for the modest version — *"the simplest implementation… would be to add change +highlighting"*. A node-level structural diff over `RichTextNode` (paragraph or +block added / removed / changed, with text-level diff inside a changed block) +answers the actual need and needs no new storage. Full change tracking can +follow the collaborative-editing work +([#5186](https://support.squidex.io/t/collaborative-editing/5186)) if it is ever +wanted. + +**Note.** Non-rich-text fields (strings, numbers, references) can be diffed +trivially and are worth doing in the same pass — the existing comparison view +is where this belongs. + +--- + +## 2. Export and import content from the UI as jobs (#3461, #1366, #3438) + +**Asked:** export schemas and content to CSV from the UI, and import from a +spreadsheet, without dropping to the CLI. +- · Declined · 1514 views +- · Declined · 1424 views +- · Unresolved · 2443 views + +**Why it matters.** 5381 views across three threads spanning 2019–2021, and the +maintainer acknowledged the pattern himself: *"it has been requested a few times +now"*. The standing answer is "use the CLI", which does not help the people who +ask — editors and evaluators, not operators. One thread is explicitly a customer +evaluating Squidex *"as a master database"*. Cloud users cannot be pointed at a +CLI-shaped answer as readily as self-hosters, and #3461 is precisely about that. + +**Why the declines no longer apply.** Both were refused on cost and +feasibility — *"there are no plans to implement such features in the UI, as it +is a lot of work with very little benefit"* (2020) and *"it is not possible and +also not planned"* (2021). The expensive parts were long-running work, progress +reporting, and delivering a large file. All three are now framework concerns: + +- `IJobRunner` (`backend/src/Squidex.Domain.Apps.Entities/Jobs/IJobRunner.cs`) + is a four-member interface: `Name`, `RunAsync(JobRunContext, ct)`, and + optional `DownloadAsync(Job, Stream, ct)` / `CleanupAsync`. +- `DownloadAsync` exists *for exactly this shape of job* — `BackupJob` implements + it to stream an archive back to the user + (`.../Backup/BackupJob.cs:28,32,54`). +- `JobLogMessage` and `JobProcessor` give per-job progress and logs, which is + what an importer needs to report which rows failed. +- Six runners already exist to copy from: `BackupJob`, `RestoreJob`, + `CreateIndexJob`, `DropIndexJob`, `MigrateContentsJob`, `RuleRunnerJob`. + +**Split the two halves; they are not equally hard.** + +*Export* is now genuinely straightforward: a job that runs the existing content +query, writes CSV/JSON to a stream, and returns it through `DownloadAsync`. The +CLI already contains the field-flattening logic to borrow. + +*Import* keeps the one objection that still stands — from #3438: *"Usually the +CSV does not match to the content format. Therefore you need some kind of +mapping and a UI to define this mapping."* That is real, and it is the mapping +UI, not the ingestion. The write path is already there: `BulkUpdateContents`, +`BulkUpdateJob` and `UpsertContent` +(`backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/`). Ship export +first; it is most of the demand and a fraction of the work. + +--- + +## 3. Scope asset permissions to folders (#5921, #5676, #1229) + +**Asked:** restrict a role to particular asset folders rather than all-or-nothing +read/write. +- · **Open** · 775 views · 2025-05 +- · **Open** · 1174 views · 2024-09 +- · Declined · 1908 views · 2019-10 + +**Why it matters.** Asked three times over six years, twice still open, 3857 +views combined, and always with the same concrete shape: an agency or +multi-tenant install wanting `clients/XYZ` visible only to that client's +editors. The current workaround the maintainer offers — *"a workaround would be +to have a second app"*, *"create multiple apps and automate app management with +the API or CLI"* — fragments content across apps and, as he notes himself in +#5676, *"linking is difficult then"*. This is the largest recurring gap in the +permission model. + +**Why the decline deserves another look.** Assets are the only major entity with +no scoping. Content has had per-schema permissions all along, and the grammar +supports finer grain than that: + +``` +backend/src/Squidex.Shared/PermissionIds.cs:212-215 +public const string AppContents = "squidex.apps.{app}.contents.{schema}"; +public const string AppContentsRead = "squidex.apps.{app}.contents.{schema}.read"; +public const string AppContentsReadOwn = "squidex.apps.{app}.contents.{schema}.read.own"; +``` + +Assets, by contrast, are flat (lines 166-178): `assets.read`, `assets.create`, +`assets.upload`, `assets.update`, `assets.delete`, plus `assets.folders.*` for +managing folders themselves. Adding a folder placeholder follows a pattern that +already works, in the same file, with the same matching machinery. + +**Decide the two open design questions up front** — they are why this stalled, +and both have a defensible narrow answer: + +1. *Folders are renamed and moved, so paths are unstable* (#5921). Scope by + folder **id**, not path, exactly as content scopes by schema id. The + maintainer reached the same conclusion in-thread: *"only the folder ID would + work. You probably just want to associate a folder with a role."* +2. *What happens when a permitted user references an asset that another user + cannot see?* (#5676: *"user A adds an asset to a field that he is allowed to + see and user B opens the content. What is supposed to happen?"*). Scope + **browsing and picking** — the asset library and the picker — and keep + already-referenced assets resolvable. That is what "this client's folder" + actually means, it avoids breaking published content, and it does not pretend + to be a confidentiality boundary. Say so in the docs; the alternative + (filtering referenced assets out of delivery) is the version that, in his + words, *"would remove the asset"* from content that legitimately uses it. + +--- + +## 4. Dark mode and per-app theming (#5720, #816) + +**Asked:** a dark UI; and separately, brand colours and a custom logo per app. +- · **Open** · 745 views · 2024-11 +- · Declined · 1357 views · 2019-06 + +**Why it matters.** 2102 views across two threads. Editors live in this UI all +day, and dark mode has moved from a preference to an expectation in tools of +this kind. The theming half is a different and commercially sharper case: +agencies putting clients into Squidex want their own logo and colours, and today +the only route is *"if you build Squidex by yourself you can customize the +colors in a single file"* — i.e. maintain a fork, which is the same trap the +Service Bus requester described in #4522. + +**Why these declines are worth revisiting.** Neither was refused on technical +grounds. #5720: *"It is very likely not going to happen. Just a matter of +priority tbh."* #816: *"It is planned, but with very low priority"*, then *"I +have decided not to work on this"* — with a Trello link that is now dead. In +neither case was cost the argument. The +requester's own suggestion was to fold it into other work: *"Hopefully this will +be considered in the future when doing a UI refresh or something related."* +That is the situation now — 7.22 alone brought improved form rows, a new +responsive field menu, an option to disable the frontend UI, accessibility +fixes and an Angular 21 upgrade, and a previous +[design modernization](https://support.squidex.io/t/implemented-design-modernization/2442) +(5765 views) already shipped once. + +**The honest cost, and why it buys two features.** The theme is still +build-time SCSS: `frontend/src/app/theme/_vars.scss` defines `$color-border`, +`$color-theme-brand`, `$color-dark-onboarding` and the rest as Sass variables, +and the file contains **zero CSS custom properties**. Dark mode therefore is not +a stylesheet you can add — it needs the palette converted to CSS custom +properties on `:root` so it can be swapped at runtime. That conversion is large +but mechanical and low-risk, and it is the same prerequisite for per-app +theming: once colours are runtime values, #816 becomes a settings form writing a +handful of variables, and dark mode becomes one alternate block plus a +`prefers-color-scheme` default. + +**Sequence it:** convert the palette to custom properties and ship dark mode +first (one palette, no API, no persistence beyond a user preference), then +per-app branding on top. Doing theming first without the refactor is what made +it look expensive in 2019. + +--- + +## 5. Azure Service Bus flow step (#4522) + +**Asked:** contribute a Service Bus action upstream. + +· Postponed (`INACTIVE`) · 1838 views · 4 likes · **3 votes** (joint highest of +any not-implemented request, with #669 and #4305) + +**Why it matters.** Service Bus is the default eventing backbone in Azure +estates, and Squidex already ships twenty rule actions / flow steps including +Kafka, Azure Queue, SignalR, Elasticsearch, OpenSearch and Typesense. The +requester was running a **forked build** to get it — *"currently we have to +merge our plugin code with Squidex's source code and then build a Docker +image"* — and offered to generalise their code and get employer approval to +contribute it. The maintainer's answer was *"Yes, why not. Sounds helpful, but +only if the credentials can be configured in the action."* Nobody refused this; +it lapsed. + +**Why it is easy.** `AzureQueueFlowStep` is a template for nearly the whole +file — `[FlowStep(Title/IconImage/Display/Description/ReadMore)]` metadata, +`[Editor]`/`[Expression]`-annotated settings (which is also how the +credentials-in-the-action condition is met), a static `ClientPool` for +connection reuse, `ValidateAsync` and `ExecuteAsync` +(`backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueFlowStep.cs`). +A Service Bus step swaps `CloudQueue` for `ServiceBusClient` and adds a +queue/topic name. + +**Why not already covered.** Only `AzureQueue` exists. Azure Queue Storage is a +different service: no topics or subscriptions, no sessions, no FIFO guarantees, +64 KB messages. Fan-out to multiple subscribers — the usual reason to want +this — cannot be expressed with it. + +**For whoever picks it up:** write a `FlowStep`, not a `RuleAction`. +`CreateContentAction` is `[Obsolete("Has been replaced by flows.")]`; existing +actions keep a thin `IConvertibleToAction` shim for migration only. + +--- + +## 6. Give scripts a way to log (#2822) + +**Asked:** `console.log` in scripting, and documentation of what `ctx` holds. + +· Unresolved · 2575 views · 2021-01 + +**Why it matters.** Scripting is the standard answer to requests declined +elsewhere in this very forum — *"you could also achieve that with scripting"* +(#5045), *"the only option would be scripting"* (#1229), *"create a script to +fill the field automatically"* (#330). That answer is only as good as the +debugging story, and right now a script author cannot print a value, trace a +branch, or see why nothing happened. The reporter was evaluating platforms and +concluded Squidex *"can probably do everything I need, but the lack of detailed +documentation may make execution very difficult"*. The same pain recurs as +[#5624](https://support.squidex.io/t/stale-scripts-execute-maybe-a-error/5624) +(`[STALE] Scripts execute maybe a error`) and +[#5089](https://support.squidex.io/t/getassetblurhash-asset-script-example/5089), +where a user needed someone else to write the example because they had no way to +inspect anything. + +**Why it is easy.** There is a purpose-built extension point. Script functions +come from classes implementing `IJintExtension, IScriptDescriptor` — +`StringJintExtension`, `HttpJintExtension`, `DateTimeJintExtension` and four +more, registered in +`backend/src/Squidex/Config/Domain/InfrastructureServices.cs:75-93`. A +`LoggerJintExtension` in the same shape is one new file plus one registration +line, and implementing `IScriptDescriptor` puts the new function into the script +editor's autocomplete for free — the completion list is built from those +descriptors (`.../Scripting/ScriptingCompleter.cs:25`). + +**Why not already covered.** There is no logging, console or debug facility in +the scripting tree at all; the only match for "debug" is `Debugger.IsAttached` +in `JintScript.cs:64`, which concerns the host process. + +**Design note.** Output has to reach the *script author*, who on the cloud has +no server logs. Collecting it and returning it with the validation/preview +response is the version that helps. `JobLogMessage` shows the codebase already +has a notion of user-visible log lines to imitate. + +--- + +## 7. Upsert mode for the "Create content" flow step (#5616) + +**Asked:** a rule that creates content when it does not exist and updates it +when it does. + +· Unresolved · 786 views · 2024-08 + +**Why it matters.** This is the shape of every sync-from-another-system +integration — the requester lists `page`, `product`, `brand`, `marketplace` +collections kept in step with an external source. Without upsert each run either +fails on the second pass or creates duplicates, so the work moves out of rules +into bespoke code, taking the rule engine's retry, logging and configurability +with it. + +**Why it is easy.** The domain already has the operation: `UpsertContent.cs` +sits beside `CreateContent.cs` in +`backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/`. The flow step +simply never offers it — `CreateContentFlowStep` carries only `Data`, `Schema`, +`Client`, `Publish` +(`backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs:38-51`). +Add an optional id/key and switch the command issued. No new infrastructure. + +**The requester's framing was accurate:** *"The CreateContent rule almost does +exactly what it needs to do."* + +--- + +## 8. Regenerate a client secret from the UI (#5807) + +**Asked:** a button to issue a new secret for an existing client. + +· Open · 1383 views · 2025-01 + +**Why it matters.** Rotation is routine security hygiene — after a leak, after +someone leaves, on a schedule. The only route today is deleting the client and +creating a new one, which changes the **client id** too and breaks every +integration using it. A 30-second rotation becomes a coordinated redeployment, +so in practice secrets never get rotated. + +**Why it is easy.** The generation logic exists: `AttachClient` sets +`Secret = RandomHash.New()` +(`backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs:22`). +The work is letting an existing client take a new one — `UpdateClient` (same +folder) carries only `Name`, `Role`, `ApiCallsLimit`, `ApiTrafficLimit`, +`AllowAnonymous` — plus an event and a button. + +**Why not already covered.** `AppClientsController` exposes only `HttpPost`, +`HttpPut` and `HttpDelete` (lines 63/89/113). Nothing can change a secret. + +**The maintainer already agreed:** *"Actually not, but should be easy to build."* +The thread then stalled on *"The secret never expires"* — which is the reason +rotation must be manual, not a reason to omit it. + +--- + +## 9. Show which fields are required when publishing (#4375) + +> ✅ **Implemented.** Fields with *Required when publishing* (and not plain *Required*) show an orange `*` with a tooltip next to the label in the content editor (`field-editor.component.html`). + +**Asked:** a visual indicator for fields validated by *Required when publishing*, +like the asterisk plain *Required* fields get. + +· Declined (`VERY_UNLIKELY`) · 1707 views · 2 votes + +**Why it matters.** Precise complaint: nothing marks the field, so publishing +fails and the editor then hunts for the cause. It hits the least technical users +at the moment they are trying to ship. + +**Why it is easy.** One template binding. The marker already exists for the +other flag: + +``` +frontend/src/app/features/content/shared/forms/field-editor.component.html:7 +{{ field.displayName }} {{ displaySuffix }} +* +``` + +`isRequiredOnPublish` is on the same properties object — edited in +`field-form-validation.component.html:6`, present in +`shared/model/generated.ts:3124`. A second marker bound to it, a CSS rule and an +i18n key. + +**Why not already covered.** Nothing in the content editor reads +`isRequiredOnPublish`; the only occurrences are the schema editor and the +generated model. + +**On the decline.** The reply was *"Just write a hint for your users 😉"* and the +topic auto-closed after two days — asking every customer to document around a +missing marker whose data the UI already holds. Cheapest item here and worth +reversing. + +--- + +## 10. Let users upload a duplicate asset anyway (#3144) + +> ✅ **Implemented.** Uploading a new file that the backend reports as a duplicate now asks the user whether to upload it anyway; confirming retries the upload with `duplicate=true` (`asset-uploader.state.ts`), declining keeps the existing asset as before. + +**Asked:** stop blocking a file because an identical one exists in another +folder. + +· Declined · 1356 views + +**Why it matters.** The uploader hits a wall with no way past it, and folders +are routinely used to separate clients, campaigns or locales where holding the +same file twice is deliberate. + +**Why it is easy.** The backend already supports the override. `CreateAsset` and +`UpsertAsset` both carry a `Duplicate` flag, and +`AssetCommandMiddleware.UploadWithDuplicateCheckAsync` skips the check when it +is set, otherwise completing with an `AssetDuplicate` result +(`.../Assets/DomainObject/AssetCommandMiddleware.cs:57-84`). Only the UI is +missing: handle `AssetDuplicate`, confirm, retry with `duplicate: true`. + +**Why not already covered.** Nothing in the frontend references duplicates — the +flag is reachable only by calling the API directly. + +**The maintainer already scoped it:** *"it is only a flag in the API, so I can +probably enhance the UI to upload the file anyway. Like with an extra confirm +dialog or so."* + +**Worth knowing:** the check is `FindByHashAsync(context, fileHash, fileName, +fileSize)` — content hash *and* name *and* size, app-wide — so it only fires for +genuinely identical files. That makes "upload anyway" the right fix and a +per-folder scope change unnecessary. + +--- + +## Rejected because already covered + +Verified in the source, not taken from the thread. Two had been postponed or +declined and have since been implemented without the thread being updated — +which is the main reason this check is worth running: + +| Request | Views | Why it was dropped | +| --- | --- | --- | +| [#3321 Fetch concrete references with GraphQL](https://support.squidex.io/t/unlikely-fetch-concrete-references-with-graphql-and-the-other-array-fields/3321) | 2170 | **Implemented since being postponed.** `ContentUnionGraphType` / `ComponentUnionGraphType` exist and are used for reference fields, components and rich text (`FieldVisitor.cs:285,309`, `RichTextGraphType.cs:55`). | +| [#1852 Update the markdown editor](https://support.squidex.io/t/declined-update-markdown-editor-component/1852) | 2130 | **Superseded since being declined.** A new structured content editor shipped (`CHANGELOG.md:300,323`), with the old one kept as a sample editor. | +| [#3442 SignalR notification rule](https://support.squidex.io/t/add-signalr-notification-rule/3442) | 1991 | Already shipped — `backend/extensions/Squidex.Extensions/Actions/SignalR/` has `SignalRAction`, `SignalRFlowStep`, `SignalRPlugin`. | +| [#5672 Hide "business account" / signup on login](https://support.squidex.io/t/login-popup-possiblity-to-remove-enter-business-account-and-signup/5672) | 940 | Already configurable — `Login.cshtml:62` gates that form on `HasCustomAuth`, set from `allowCustomDomains` (`AccountController.cs:217`). | +| [#4499 Change image format via URL](https://support.squidex.io/t/assets-change-image-format-via-url/4499) | 1453 | Already supported — `Format` and `Auto` on `AssetContentQueryDto` (lines 88, 106). | +| [#538 Preview URL supports one placeholder](https://support.squidex.io/t/solved-preview-url-support-only-1-placeholder-increase-the-limit/538) | 969 | Fixed — `interpolate()` replaces every match (`frontend/src/app/framework/utils/interpolator.ts:13`). | +| [#1168 Expose schemas as JSON Schema](https://support.squidex.io/t/declined-squidexs-json-schema/1168) | 3500 | Covered by the generated OpenAPI documents, which embed JSON Schema. | +| [#1625 Content revisions / versions](https://support.squidex.io/t/content-revisions-versions/1625) | 4745 | A maintainer-authored 2020 concept for reworking the workflow system; the workflow feature it describes shipped. | + +## Re-examined declines that still hold + +The blocker is unchanged, so these stay off the list: + +- [#3025 Filter assets not linked to any content](https://support.squidex.io/t/declined-filter-by-assets-not-linked-to-any-contents/3025) (1380 views) — *"a lot of effort to maintain a secondary index"*. Still true; nothing in the archive or the code changes it. +- [#2306 Backup all apps at once](https://support.squidex.io/t/declined-backup-all-apps-with-one-click/2306) (1032) — *"you can easier backup your mongodb database"*. The job framework would make it easy now, but the objection was value, not cost, and it is a fair one. +- [#3568 Full-text search improvements](https://support.squidex.io/t/unlikely-content-fulltext-search-improvement/3568) (2246) — answered with a detailed technical rebuttal, and search has since moved on independently (Atlas Search option at `appsettings.json:736`, plus searchable string fields in 7.22). +- [#4462 Localizable components](https://support.squidex.io/t/localizable-components/4462) (2179) — declined on a defensible design ground: localized fields containing localized subfields. +- [#1675 Taxonomies](https://support.squidex.io/t/taxonomies-for-content-items/1675) (4983) and [#1667 Tree view](https://support.squidex.io/t/unlikely-tree-view-for-content/1667) (3737) — the most-demanded cluster in the archive, but the maintainer's objection is a design one he has not resolved (*"we would create a second system for references"*), and it is unresolved by anything in the current code. Worth a decision, not a ticket. + +## Dropped from the previous revision + +Removed to make room for larger items, not because they stopped being worth +doing. Each is still small and still uncovered: + +- [#4363 Say when auto-translate is not configured](https://support.squidex.io/t/auto-translation-with-google-translate-api-and-frontend-settings/4363) — **7038 views**, the most-viewed unresolved request in the archive. The translate action renders unconditionally (`content-field.component.html:119-125`) with no check for a configured provider, and the key lives only in `appsettings.json:819`. Cheap to fix and high-traffic; promote it if you want an eleventh. +- [#1363 Respect `maxItems` in the asset editor](https://support.squidex.io/t/postponed-single-asset-media-image-type/1363) (2622) — `array-editor.component.ts:91,104` gates adding at the limit; `assets-editor.component.ts` does not. +- [#2183 Larger page sizes in list views](https://support.squidex.io/t/declined-content-view-display-250/2183) (1189) — one constant, `pager.component.ts:15`. The decline's cloud-performance concern argues for making it configurable, not for refusing. +- [#1042 Hide UI sections a role should not see](https://support.squidex.io/t/ui-customization/1042) (1499) — a separate ask from theming, despite the title: hiding the schemas section from content editors and specific dashboard charts. *"Why should the user who only has very minimum content access be able to see the whole site schema?"* The maintainer's reply was *"we could make it more customizable and hide specific charts"* and *"I think it has already been requested somewhere, but it is not on my roadmap yet"*. 7.22 added an option to disable the whole frontend, which is all-or-nothing rather than per-section. A reasonable follow-on to item 3, since both are permission-shaped. +- Self-service password reset (#5997) — **dropped at your direction**; most deployments use an external identity provider, so it would mostly be unused code. No trace of it exists in the backend if it is ever wanted. + +## Caveats + +- Status reflects the forum's categorisation at archive time (2026-09-20). Where + it mattered, the claim was checked in the source and the file and line cited. +- Size labels come from reading the code, not building it, and assume the change + stays within the extension point named. Items 1–4 have genuine unknowns; items + 3 and 4 each need a design decision before work starts, and both decisions are + spelled out above. +- `D:\Squidex` is on branch `stability`, ahead of public `master`. Anything + merged after `fc026a3` is not reflected here. From 5a9a94421c70bf4e490f4d039e7cd3a0f434c602 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 21 Sep 2026 10:41:44 +0200 Subject: [PATCH 2/8] Frontend improvements. --- .../administration/guards/user-must-exist.guard.spec.ts | 4 ---- .../administration/state/event-consumers.state.spec.ts | 1 - .../app/features/administration/state/users.state.spec.ts | 1 - .../src/app/features/teams/state/team-auth.state.spec.ts | 1 - .../features/teams/state/team-contributors.state.spec.ts | 1 - .../src/app/features/teams/state/team-plans.state.spec.ts | 2 -- .../framework/angular/forms/confirm-click.directive.spec.ts | 1 - .../app/framework/angular/routers/router-2-state.spec.ts | 4 ---- frontend/src/app/framework/services/dialog.service.spec.ts | 3 --- frontend/src/app/shared/guards/app-must-exist.guard.spec.ts | 1 - .../src/app/shared/guards/content-must-exist.guard.spec.ts | 4 ---- frontend/src/app/shared/guards/load-apps.guard.spec.ts | 1 - frontend/src/app/shared/guards/load-languages.guard.spec.ts | 1 - frontend/src/app/shared/guards/load-schemas.guard.spec.ts | 1 - frontend/src/app/shared/guards/load-settings.guard.spec.ts | 1 - frontend/src/app/shared/guards/load-teams.guard.spec.ts | 1 - .../app/shared/guards/must-be-authenticated.guard.spec.ts | 3 --- .../shared/guards/must-be-not-authenticated.guard.spec.ts | 4 ---- .../src/app/shared/guards/rule-must-exist.guard.spec.ts | 4 ---- .../shared/guards/schema-must-exist-published.guard.spec.ts | 4 ---- .../src/app/shared/guards/schema-must-exist.guard.spec.ts | 1 - .../guards/schema-must-not-be-singleton.guard.spec.ts | 6 ------ .../src/app/shared/guards/team-must-exist.guard.spec.ts | 1 - frontend/src/app/shared/guards/unset-app.guard.spec.ts | 1 - frontend/src/app/shared/guards/unset-team.guard.spec.ts | 1 - .../src/app/shared/interceptors/auth.interceptor.spec.ts | 1 - frontend/src/app/shared/services/autosave.service.spec.ts | 3 --- .../src/app/shared/services/users-provider.service.spec.ts | 4 ---- frontend/src/app/shared/state/apps.state.spec.ts | 2 -- frontend/src/app/shared/state/asset-scripts.state.spec.ts | 1 - frontend/src/app/shared/state/assets.state.spec.ts | 1 - frontend/src/app/shared/state/clients.state.spec.ts | 1 - frontend/src/app/shared/state/contributors.state.spec.ts | 1 - frontend/src/app/shared/state/indexes.state.spec.ts | 3 --- frontend/src/app/shared/state/jobs.state.spec.ts | 3 --- frontend/src/app/shared/state/languages.state.spec.ts | 2 -- frontend/src/app/shared/state/plans.state.spec.ts | 2 -- frontend/src/app/shared/state/queries.spec.ts | 4 ---- frontend/src/app/shared/state/resolvers.spec.ts | 3 --- frontend/src/app/shared/state/roles.state.spec.ts | 1 - frontend/src/app/shared/state/rule-events.state.spec.ts | 1 - frontend/src/app/shared/state/rule-simulator.state.spec.ts | 2 -- frontend/src/app/shared/state/rules.state.spec.ts | 1 - frontend/src/app/shared/state/schemas.state.spec.ts | 2 -- frontend/src/app/shared/state/table-settings.spec.ts | 4 ---- frontend/src/app/shared/state/template.state.spec.ts | 1 - frontend/src/app/shared/state/workflows.state.spec.ts | 1 - 47 files changed, 97 deletions(-) diff --git a/frontend/src/app/features/administration/guards/user-must-exist.guard.spec.ts b/frontend/src/app/features/administration/guards/user-must-exist.guard.spec.ts index 789f7f05b9..69838d17b4 100644 --- a/frontend/src/app/features/administration/guards/user-must-exist.guard.spec.ts +++ b/frontend/src/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -47,7 +47,6 @@ describe('UserMustExistGuard', () => { const result = await firstValueFrom(userMustExistGuard(route)); expect(result).toBeTruthy(); - usersState.verify(x => x.select('123'), Times.once()); }); @@ -64,7 +63,6 @@ describe('UserMustExistGuard', () => { const result = await firstValueFrom(userMustExistGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); @@ -81,7 +79,6 @@ describe('UserMustExistGuard', () => { const result = await firstValueFrom(userMustExistGuard(route)); expect(result).toBeTruthy(); - usersState.verify(x => x.select(null), Times.once()); }); @@ -98,7 +95,6 @@ describe('UserMustExistGuard', () => { const result = await firstValueFrom(userMustExistGuard(route)); expect(result).toBeTruthy(); - usersState.verify(x => x.select(null), Times.once()); }); }); diff --git a/frontend/src/app/features/administration/state/event-consumers.state.spec.ts b/frontend/src/app/features/administration/state/event-consumers.state.spec.ts index 3978e034d3..33c21ea88f 100644 --- a/frontend/src/app/features/administration/state/event-consumers.state.spec.ts +++ b/frontend/src/app/features/administration/state/event-consumers.state.spec.ts @@ -41,7 +41,6 @@ describe('EventConsumersState', () => { expect(eventConsumersState.snapshot.eventConsumers).toEqual([eventConsumer1, eventConsumer2]); expect(eventConsumersState.snapshot.isLoaded).toBeTruthy(); expect(eventConsumersState.snapshot.isLoading).toBeFalsy(); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/features/administration/state/users.state.spec.ts b/frontend/src/app/features/administration/state/users.state.spec.ts index bc09ee2cd2..d5863d2de7 100644 --- a/frontend/src/app/features/administration/state/users.state.spec.ts +++ b/frontend/src/app/features/administration/state/users.state.spec.ts @@ -44,7 +44,6 @@ describe('UsersState', () => { expect(usersState.snapshot.isLoaded).toBeTruthy(); expect(usersState.snapshot.isLoading).toBeFalsy(); expect(usersState.snapshot.total).toEqual(200); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/features/teams/state/team-auth.state.spec.ts b/frontend/src/app/features/teams/state/team-auth.state.spec.ts index e2aca304f1..33650e0e35 100644 --- a/frontend/src/app/features/teams/state/team-auth.state.spec.ts +++ b/frontend/src/app/features/teams/state/team-auth.state.spec.ts @@ -48,7 +48,6 @@ describe('TeamAuthState', () => { expect(authState.snapshot.isLoaded).toBeTruthy(); expect(authState.snapshot.canUpdate).toBeTruthy(); expect(authState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/features/teams/state/team-contributors.state.spec.ts b/frontend/src/app/features/teams/state/team-contributors.state.spec.ts index 1a1d4777a5..7be0fe6e5f 100644 --- a/frontend/src/app/features/teams/state/team-contributors.state.spec.ts +++ b/frontend/src/app/features/teams/state/team-contributors.state.spec.ts @@ -52,7 +52,6 @@ describe('TeamContributorsState', () => { expect(contributorsState.snapshot.total).toEqual(20); expect(contributorsState.snapshot.maxContributors).toBe(oldContributors.maxContributors); expect(contributorsState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/features/teams/state/team-plans.state.spec.ts b/frontend/src/app/features/teams/state/team-plans.state.spec.ts index 95af855f3e..de2a4d7782 100644 --- a/frontend/src/app/features/teams/state/team-plans.state.spec.ts +++ b/frontend/src/app/features/teams/state/team-plans.state.spec.ts @@ -76,7 +76,6 @@ describe('TeamPlansState', () => { ]); expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); @@ -93,7 +92,6 @@ describe('TeamPlansState', () => { expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.isLoading).toBeFalsy(); expect(plansState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/framework/angular/forms/confirm-click.directive.spec.ts b/frontend/src/app/framework/angular/forms/confirm-click.directive.spec.ts index 76db5c0f02..3879401180 100644 --- a/frontend/src/app/framework/angular/forms/confirm-click.directive.spec.ts +++ b/frontend/src/app/framework/angular/forms/confirm-click.directive.spec.ts @@ -35,7 +35,6 @@ describe('ConfirmClickDirective', () => { confirmClickDirective.onClick(new Event('click')); expect(invoked).toBe(true); - dialogs.verify(x => x.confirm(It.isAnyString(), It.isAnyString(), It.isAny()), Times.never()); }); diff --git a/frontend/src/app/framework/angular/routers/router-2-state.spec.ts b/frontend/src/app/framework/angular/routers/router-2-state.spec.ts index 0d5a6116a9..9fe2ea9019 100644 --- a/frontend/src/app/framework/angular/routers/router-2-state.spec.ts +++ b/frontend/src/app/framework/angular/routers/router-2-state.spec.ts @@ -116,7 +116,6 @@ describe('Router2State', () => { const query = synchronizer.parseFromState(state); expect(query).toEqual({ page: '10', pageSize: '20' }); - localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); }); @@ -126,7 +125,6 @@ describe('Router2State', () => { const query = synchronizer.parseFromState(state); expect(query).toEqual({ page: undefined, pageSize: '20' }); - localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); }); @@ -239,7 +237,6 @@ describe('Router2State', () => { expect(routeExtras!.replaceUrl).toBe(true); expect(routeExtras!.queryParamsHandling).toBe('merge'); expect(routeExtras!.queryParams).toEqual({ state1: 'hello', state2: 'squidex,cms' }); - router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.exactly(2)); }); @@ -262,7 +259,6 @@ describe('Router2State', () => { expect(routeExtras!.replaceUrl).toBe(true); expect(routeExtras!.queryParamsHandling).toBe('merge'); expect(routeExtras!.queryParams).toEqual({ state1: 'hello', state2: 'squidex,cms' }); - router.verify(x => x.navigate(It.isAny(), It.isAny()), Times.exactly(2)); }); }); diff --git a/frontend/src/app/framework/services/dialog.service.spec.ts b/frontend/src/app/framework/services/dialog.service.spec.ts index 57366580ce..f9ed1928d1 100644 --- a/frontend/src/app/framework/services/dialog.service.spec.ts +++ b/frontend/src/app/framework/services/dialog.service.spec.ts @@ -60,7 +60,6 @@ describe('DialogService', () => { expect(isCompleted).toBeTruthy(); expect(isNext!).toEqual(confirmed); - localStore.verify(x => x.setInt(It.isAnyString(), It.isAnyNumber()), Times.never()); }); }); @@ -87,7 +86,6 @@ describe('DialogService', () => { expect(isCompleted).toBeTruthy(); expect(isNext!).toEqual(confirmed); - localStore.verify(x => x.setInt(It.isAnyString(), It.isAnyNumber()), Times.never()); }); }); @@ -118,7 +116,6 @@ describe('DialogService', () => { expect(isCompleted).toBeTruthy(); expect(isNext!).toEqual(confirmed); - localStore.verify(x => x.setBoolean('dialogs.confirm.MyKey', It.isAny()), Times.exactly(saved)); }); }); diff --git a/frontend/src/app/shared/guards/app-must-exist.guard.spec.ts b/frontend/src/app/shared/guards/app-must-exist.guard.spec.ts index e4eb5d39df..4bb7a8aa60 100644 --- a/frontend/src/app/shared/guards/app-must-exist.guard.spec.ts +++ b/frontend/src/app/shared/guards/app-must-exist.guard.spec.ts @@ -53,7 +53,6 @@ describe('AppMustExistGuard', () => { const result = await firstValueFrom(appMustExistGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); diff --git a/frontend/src/app/shared/guards/content-must-exist.guard.spec.ts b/frontend/src/app/shared/guards/content-must-exist.guard.spec.ts index e620f16265..fb84a73516 100644 --- a/frontend/src/app/shared/guards/content-must-exist.guard.spec.ts +++ b/frontend/src/app/shared/guards/content-must-exist.guard.spec.ts @@ -47,7 +47,6 @@ describe('ContentMustExistGuard', () => { const result = await firstValueFrom(contentMustExistGuard(route)); expect(result).toBeTruthy(); - router.verify(x => x.navigate(It.isAny()), Times.never()); }); @@ -64,7 +63,6 @@ describe('ContentMustExistGuard', () => { const result = await firstValueFrom(contentMustExistGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); @@ -81,7 +79,6 @@ describe('ContentMustExistGuard', () => { const result = await firstValueFrom(contentMustExistGuard(route)); expect(result!).toBeTruthy(); - contentsState.verify(x => x.select(null), Times.once()); }); @@ -98,7 +95,6 @@ describe('ContentMustExistGuard', () => { const result = await firstValueFrom(contentMustExistGuard(route)); expect(result!).toBeTruthy(); - contentsState.verify(x => x.select(null), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/load-apps.guard.spec.ts b/frontend/src/app/shared/guards/load-apps.guard.spec.ts index dc60764997..48b6b417c1 100644 --- a/frontend/src/app/shared/guards/load-apps.guard.spec.ts +++ b/frontend/src/app/shared/guards/load-apps.guard.spec.ts @@ -27,7 +27,6 @@ describe('LoadAppsGuard', () => { const result = await firstValueFrom(loadAppsGuard()); expect(result).toBeTruthy(); - appsState.verify(x => x.load(), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/load-languages.guard.spec.ts b/frontend/src/app/shared/guards/load-languages.guard.spec.ts index 33f7c7329d..26634247fd 100644 --- a/frontend/src/app/shared/guards/load-languages.guard.spec.ts +++ b/frontend/src/app/shared/guards/load-languages.guard.spec.ts @@ -27,7 +27,6 @@ describe('LoadLanguagesGuard', () => { const result = await firstValueFrom(loadLanguagesGuard()); expect(result).toBeTruthy(); - languagesState.verify(x => x.load(), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/load-schemas.guard.spec.ts b/frontend/src/app/shared/guards/load-schemas.guard.spec.ts index 190d0dc550..e9217b7518 100644 --- a/frontend/src/app/shared/guards/load-schemas.guard.spec.ts +++ b/frontend/src/app/shared/guards/load-schemas.guard.spec.ts @@ -27,7 +27,6 @@ describe('LoadSchemasGuard', () => { const result = await firstValueFrom(loadSchemasGuard()); expect(result).toBeTruthy(); - schemasState.verify(x => x.load(), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/load-settings.guard.spec.ts b/frontend/src/app/shared/guards/load-settings.guard.spec.ts index 3e85428e2b..021ea39087 100644 --- a/frontend/src/app/shared/guards/load-settings.guard.spec.ts +++ b/frontend/src/app/shared/guards/load-settings.guard.spec.ts @@ -27,7 +27,6 @@ describe('LoadAppsGuard', () => { const result = await firstValueFrom(loadSettingsGuard()); expect(result).toBeTruthy(); - uiState.verify(x => x.load(), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/load-teams.guard.spec.ts b/frontend/src/app/shared/guards/load-teams.guard.spec.ts index 1caa617a3a..0beb546c74 100644 --- a/frontend/src/app/shared/guards/load-teams.guard.spec.ts +++ b/frontend/src/app/shared/guards/load-teams.guard.spec.ts @@ -27,7 +27,6 @@ describe('LoadTeamsGuard', () => { const result = await firstValueFrom(loadTeamsGuard()); expect(result).toBeTruthy(); - teamsState.verify(x => x.load(), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/must-be-authenticated.guard.spec.ts b/frontend/src/app/shared/guards/must-be-authenticated.guard.spec.ts index bfc6501c01..9aab9d1aa8 100644 --- a/frontend/src/app/shared/guards/must-be-authenticated.guard.spec.ts +++ b/frontend/src/app/shared/guards/must-be-authenticated.guard.spec.ts @@ -55,7 +55,6 @@ describe('MustBeAuthenticatedGuard', () => { const result = await firstValueFrom(mustBeAuthenticatedGuard()); expect(result).toBeFalsy(); - router.verify(x => x.navigate([''], { queryParams: { redirectPath: '/my-path' } }), Times.once()); }); @@ -66,7 +65,6 @@ describe('MustBeAuthenticatedGuard', () => { const result = await firstValueFrom(mustBeAuthenticatedGuard()); expect(result!).toBeTruthy(); - router.verify(x => x.navigate(It.isAny()), Times.never()); }); @@ -79,7 +77,6 @@ describe('MustBeAuthenticatedGuard', () => { const result = await firstValueFrom(mustBeAuthenticatedGuard()); expect(result!).toBeFalsy(); - authService.verify(x => x.loginRedirect('/my-path'), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/must-be-not-authenticated.guard.spec.ts b/frontend/src/app/shared/guards/must-be-not-authenticated.guard.spec.ts index bfe1686424..9b5e8bb0a7 100644 --- a/frontend/src/app/shared/guards/must-be-not-authenticated.guard.spec.ts +++ b/frontend/src/app/shared/guards/must-be-not-authenticated.guard.spec.ts @@ -55,7 +55,6 @@ describe('MustBeNotAuthenticatedGuard', () => { const result = await firstValueFrom(mustBeNotAuthenticatedGuard({} as any)); expect(result!).toBeFalsy(); - router.verify(x => x.navigate(['app'], { queryParams: { redirectPath: '/my-path' } }), Times.once()); }); @@ -66,7 +65,6 @@ describe('MustBeNotAuthenticatedGuard', () => { const result = await firstValueFrom(mustBeNotAuthenticatedGuard({} as any)); expect(result).toBeTruthy(); - router.verify(x => x.navigate(It.isAny()), Times.never()); }); @@ -79,7 +77,6 @@ describe('MustBeNotAuthenticatedGuard', () => { const result = await firstValueFrom(mustBeNotAuthenticatedGuard({ queryParams: {} } as any)); expect(result).toBeFalsy(); - authService.verify(x => x.loginRedirect('/my-path'), Times.once()); }); @@ -92,7 +89,6 @@ describe('MustBeNotAuthenticatedGuard', () => { const result = await firstValueFrom(mustBeNotAuthenticatedGuard({ queryParams: { logout: true } } as any)); expect(result).toBeTruthy(); - authService.verify(x => x.loginRedirect('/my-path'), Times.never()); }); }); diff --git a/frontend/src/app/shared/guards/rule-must-exist.guard.spec.ts b/frontend/src/app/shared/guards/rule-must-exist.guard.spec.ts index 45b282298f..094c1c6feb 100644 --- a/frontend/src/app/shared/guards/rule-must-exist.guard.spec.ts +++ b/frontend/src/app/shared/guards/rule-must-exist.guard.spec.ts @@ -47,7 +47,6 @@ describe('RuleMustExistGuard', () => { const result = await firstValueFrom(ruleMustExistGuard(route)); expect(result).toBeTruthy(); - router.verify(x => x.navigate(It.isAny()), Times.never()); }); @@ -64,7 +63,6 @@ describe('RuleMustExistGuard', () => { const result = await firstValueFrom(ruleMustExistGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); @@ -81,7 +79,6 @@ describe('RuleMustExistGuard', () => { const result = await firstValueFrom(ruleMustExistGuard(route)); expect(result).toBeTruthy(); - rulesState.verify(x => x.select(null), Times.once()); }); @@ -98,7 +95,6 @@ describe('RuleMustExistGuard', () => { const result = await firstValueFrom(ruleMustExistGuard(route)); expect(result).toBeTruthy(); - rulesState.verify(x => x.select(null), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/schema-must-exist-published.guard.spec.ts b/frontend/src/app/shared/guards/schema-must-exist-published.guard.spec.ts index 49acc09011..b937415f89 100644 --- a/frontend/src/app/shared/guards/schema-must-exist-published.guard.spec.ts +++ b/frontend/src/app/shared/guards/schema-must-exist-published.guard.spec.ts @@ -47,7 +47,6 @@ describe('SchemaMustExistPublishedGuard', () => { const result = await firstValueFrom(schemaMustExistPublishedGuard(route)); expect(result).toBeTruthy(); - router.verify(x => x.navigate(It.isAny()), Times.never()); }); @@ -58,7 +57,6 @@ describe('SchemaMustExistPublishedGuard', () => { const result = await firstValueFrom(schemaMustExistPublishedGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); @@ -69,7 +67,6 @@ describe('SchemaMustExistPublishedGuard', () => { const result = await firstValueFrom(schemaMustExistPublishedGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); @@ -80,7 +77,6 @@ describe('SchemaMustExistPublishedGuard', () => { const result = await firstValueFrom(schemaMustExistPublishedGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/schema-must-exist.guard.spec.ts b/frontend/src/app/shared/guards/schema-must-exist.guard.spec.ts index e0eaccd81a..ab54e99cfc 100644 --- a/frontend/src/app/shared/guards/schema-must-exist.guard.spec.ts +++ b/frontend/src/app/shared/guards/schema-must-exist.guard.spec.ts @@ -56,7 +56,6 @@ describe('SchemaMustExistGuard', () => { const result = await firstValueFrom(schemaMustExistGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts b/frontend/src/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts index 06823ee0ab..c19febf972 100644 --- a/frontend/src/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts +++ b/frontend/src/app/shared/guards/schema-must-not-be-singleton.guard.spec.ts @@ -54,7 +54,6 @@ describe('SchemaMustNotBeSingletonGuard', () => { const result = await firstValueFrom(schemaMustNotBeSingletonGuard(false)(route, state)); expect(result).toBeTruthy(); - router.verify(x => x.navigate(It.isAny()), Times.never()); }); @@ -67,7 +66,6 @@ describe('SchemaMustNotBeSingletonGuard', () => { const result = await firstValueFrom(schemaMustNotBeSingletonGuard(false)(route, state)); expect(result).toBeFalsy(); - router.verify(x => x.navigate([state.url, '123']), Times.once()); }); @@ -80,7 +78,6 @@ describe('SchemaMustNotBeSingletonGuard', () => { const result = await firstValueFrom(schemaMustNotBeSingletonGuard(false)(route, state)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['schemas/my-schema/', '123']), Times.once()); }); @@ -93,7 +90,6 @@ describe('SchemaMustNotBeSingletonGuard', () => { const result = await firstValueFrom(schemaMustNotBeSingletonGuard(false)(route, state)); expect(result).toBeFalsy(); - router.verify(x => x.navigate([state.url, 'extension']), Times.once()); }); @@ -106,7 +102,6 @@ describe('SchemaMustNotBeSingletonGuard', () => { const result = await firstValueFrom(schemaMustNotBeSingletonGuard(true)(route, state)); expect(result).toBeFalsy(); - router.verify(x => x.navigate([state.url, '..']), Times.once()); }); @@ -119,7 +114,6 @@ describe('SchemaMustNotBeSingletonGuard', () => { const result = await firstValueFrom(schemaMustNotBeSingletonGuard(true)(route, state)); expect(result).toBeTruthy(); - router.verify(x => x.navigate(It.isAny()), Times.never()); }); }); diff --git a/frontend/src/app/shared/guards/team-must-exist.guard.spec.ts b/frontend/src/app/shared/guards/team-must-exist.guard.spec.ts index 4247bf12a1..7d4df1bef0 100644 --- a/frontend/src/app/shared/guards/team-must-exist.guard.spec.ts +++ b/frontend/src/app/shared/guards/team-must-exist.guard.spec.ts @@ -47,7 +47,6 @@ describe('TeamMustExistGuard', () => { const result = await firstValueFrom(teamMustExistGuard(route)); expect(result).toBeFalsy(); - router.verify(x => x.navigate(['/404']), Times.once()); }); diff --git a/frontend/src/app/shared/guards/unset-app.guard.spec.ts b/frontend/src/app/shared/guards/unset-app.guard.spec.ts index 768ff64e65..13971574ec 100644 --- a/frontend/src/app/shared/guards/unset-app.guard.spec.ts +++ b/frontend/src/app/shared/guards/unset-app.guard.spec.ts @@ -27,7 +27,6 @@ describe('UnsetAppGuard', () => { const result = await firstValueFrom(unsetAppGuard()); expect(result).toBeTruthy(); - appsState.verify(x => x.select(null), Times.once()); }); }); diff --git a/frontend/src/app/shared/guards/unset-team.guard.spec.ts b/frontend/src/app/shared/guards/unset-team.guard.spec.ts index 3ad0aee122..f2f1e8253b 100644 --- a/frontend/src/app/shared/guards/unset-team.guard.spec.ts +++ b/frontend/src/app/shared/guards/unset-team.guard.spec.ts @@ -27,7 +27,6 @@ describe('UnsetTeamGuard', () => { const result = await firstValueFrom(unsetTeamGuard()); expect(result).toBeTruthy(); - teamsState.verify(x => x.select(null), Times.once()); }); }); diff --git a/frontend/src/app/shared/interceptors/auth.interceptor.spec.ts b/frontend/src/app/shared/interceptors/auth.interceptor.spec.ts index 3f1839f913..348ca03e9c 100644 --- a/frontend/src/app/shared/interceptors/auth.interceptor.spec.ts +++ b/frontend/src/app/shared/interceptors/auth.interceptor.spec.ts @@ -144,7 +144,6 @@ describe('AuthInterceptor', () => { expect(invokedRequest.length).toEqual(2); expect(req1.headers.get('Authorization')).toEqual('token1'); expect(req2.headers.get('Authorization')).toEqual('token2'); - authService.verify(x => x.logoutRedirect('/my-path'), Times.once()); }); diff --git a/frontend/src/app/shared/services/autosave.service.spec.ts b/frontend/src/app/shared/services/autosave.service.spec.ts index 13b9fb1e18..634e0f5abf 100644 --- a/frontend/src/app/shared/services/autosave.service.spec.ts +++ b/frontend/src/app/shared/services/autosave.service.spec.ts @@ -70,7 +70,6 @@ describe('AutoSaveService', () => { const content = autoSaveService.fetch(key); expect(content).toEqual({ text: 'Hello' }); - localStore.verify(x => x.remove(It.isAnyString()), Times.once()); }); @@ -83,7 +82,6 @@ describe('AutoSaveService', () => { const content = autoSaveService.fetch(key); expect(content).toEqual({ text: 'Hello' }); - localStore.verify(x => x.remove(It.isAnyString()), Times.once()); }); @@ -93,7 +91,6 @@ describe('AutoSaveService', () => { const content = autoSaveService.fetch(null!); expect(content).toBeNull(); - localStore.verify(x => x.get(It.isAnyString()), Times.never()); }); }); diff --git a/frontend/src/app/shared/services/users-provider.service.spec.ts b/frontend/src/app/shared/services/users-provider.service.spec.ts index 3e2c6009f9..73edc264d3 100644 --- a/frontend/src/app/shared/services/users-provider.service.spec.ts +++ b/frontend/src/app/shared/services/users-provider.service.spec.ts @@ -33,7 +33,6 @@ describe('UsersProviderService', () => { }).unsubscribe(); expect(resultingUser!).toBe(user); - usersService.verifyAll(); }); @@ -52,7 +51,6 @@ describe('UsersProviderService', () => { }).unsubscribe(); expect(resultingUser!).toBe(user); - usersService.verifyAll(); }); @@ -72,7 +70,6 @@ describe('UsersProviderService', () => { }).unsubscribe(); expect(resultingUser!).toEqual(new UserDto({ id: '123', displayName: 'Me' } as any)); - usersService.verifyAll(); }); @@ -90,7 +87,6 @@ describe('UsersProviderService', () => { }).unsubscribe(); expect(resultingUser!).toEqual(new UserDto({ id: 'Unknown', displayName: 'Unknown' } as any)); - usersService.verifyAll(); }); }); diff --git a/frontend/src/app/shared/state/apps.state.spec.ts b/frontend/src/app/shared/state/apps.state.spec.ts index 704432ba4c..b24ead5ba9 100644 --- a/frontend/src/app/shared/state/apps.state.spec.ts +++ b/frontend/src/app/shared/state/apps.state.spec.ts @@ -48,7 +48,6 @@ describe('AppsState', () => { expect(appSelect).toBe(app1); expect(appsState.snapshot.selectedApp).toBe(app1); expect(appsState.snapshot.selectedSettings).not.toBeNull(); - appsService.verify(x => x.getSettings(app1.name), Times.once()); }); @@ -71,7 +70,6 @@ describe('AppsState', () => { expect(appSelected).toBeNull(); expect(appsState.snapshot.selectedApp).toBeNull(); expect(appsState.snapshot.selectedSettings).toBeNull(); - appsService.verify(x => x.getSettings(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/asset-scripts.state.spec.ts b/frontend/src/app/shared/state/asset-scripts.state.spec.ts index 14647e2726..a9fd2bcfac 100644 --- a/frontend/src/app/shared/state/asset-scripts.state.spec.ts +++ b/frontend/src/app/shared/state/asset-scripts.state.spec.ts @@ -45,7 +45,6 @@ describe('AssetScriptsState', () => { expect(assetScriptsState.snapshot.isLoaded).toBeTruthy(); expect(assetScriptsState.snapshot.isLoading).toBeFalsy(); expect(assetScriptsState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/assets.state.spec.ts b/frontend/src/app/shared/state/assets.state.spec.ts index 299cbb46a0..feea34ca59 100644 --- a/frontend/src/app/shared/state/assets.state.spec.ts +++ b/frontend/src/app/shared/state/assets.state.spec.ts @@ -57,7 +57,6 @@ describe('AssetsState', () => { expect(assetsState.snapshot.isLoaded).toBeTruthy(); expect(assetsState.snapshot.isLoading).toBeFalsy(); expect(assetsState.snapshot.total).toEqual(200); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/clients.state.spec.ts b/frontend/src/app/shared/state/clients.state.spec.ts index 968fb96a68..7315943d8d 100644 --- a/frontend/src/app/shared/state/clients.state.spec.ts +++ b/frontend/src/app/shared/state/clients.state.spec.ts @@ -42,7 +42,6 @@ describe('ClientsState', () => { expect(clientsState.snapshot.isLoaded).toBeTruthy(); expect(clientsState.snapshot.isLoading).toBeFalsy(); expect(clientsState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/contributors.state.spec.ts b/frontend/src/app/shared/state/contributors.state.spec.ts index abbf0fa33a..f41d9e2635 100644 --- a/frontend/src/app/shared/state/contributors.state.spec.ts +++ b/frontend/src/app/shared/state/contributors.state.spec.ts @@ -51,7 +51,6 @@ describe('ContributorsState', () => { expect(contributorsState.snapshot.total).toEqual(20); expect(contributorsState.snapshot.maxContributors).toBe(oldContributors.maxContributors); expect(contributorsState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/indexes.state.spec.ts b/frontend/src/app/shared/state/indexes.state.spec.ts index 070b14e3d9..bc4f4fcff8 100644 --- a/frontend/src/app/shared/state/indexes.state.spec.ts +++ b/frontend/src/app/shared/state/indexes.state.spec.ts @@ -47,7 +47,6 @@ describe('IndexesState', () => { expect(indexesState.snapshot.indexes).toEqual([index1, index2]); expect(indexesState.snapshot.isLoaded).toBeTruthy(); expect(indexesState.snapshot.isLoading).toBeFalsy(); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); @@ -105,7 +104,6 @@ describe('IndexesState', () => { indexesState.create(request).subscribe(); expect(indexesState.snapshot.indexes.length).toBe(2); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); @@ -116,7 +114,6 @@ describe('IndexesState', () => { indexesState.delete(index1).subscribe(); expect(indexesState.snapshot.indexes.length).toBe(2); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); }); diff --git a/frontend/src/app/shared/state/jobs.state.spec.ts b/frontend/src/app/shared/state/jobs.state.spec.ts index b8c03025e7..0e2b449da5 100644 --- a/frontend/src/app/shared/state/jobs.state.spec.ts +++ b/frontend/src/app/shared/state/jobs.state.spec.ts @@ -42,7 +42,6 @@ describe('JobsState', () => { expect(jobsState.snapshot.jobs).toEqual([job1, job2]); expect(jobsState.snapshot.isLoaded).toBeTruthy(); expect(jobsState.snapshot.isLoading).toBeFalsy(); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); @@ -98,7 +97,6 @@ describe('JobsState', () => { jobsState.startBackup().subscribe(); expect(jobsState.snapshot.jobs.length).toBe(2); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); @@ -109,7 +107,6 @@ describe('JobsState', () => { jobsState.delete(job1).subscribe(); expect(jobsState.snapshot.jobs.length).toBe(2); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); }); }); diff --git a/frontend/src/app/shared/state/languages.state.spec.ts b/frontend/src/app/shared/state/languages.state.spec.ts index bc64a1df23..7b5e769a6b 100644 --- a/frontend/src/app/shared/state/languages.state.spec.ts +++ b/frontend/src/app/shared/state/languages.state.spec.ts @@ -42,7 +42,6 @@ describe('LanguagesState', () => { afterEach(() => { languagesService.verifyAll(); - allLanguagesService.verifyAll(); }); @@ -65,7 +64,6 @@ describe('LanguagesState', () => { expect(languagesState.snapshot.isLoaded).toBeTruthy(); expect(languagesState.snapshot.isLoading).toBeFalsy(); expect(languagesState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/plans.state.spec.ts b/frontend/src/app/shared/state/plans.state.spec.ts index f627aeec55..71e57b910a 100644 --- a/frontend/src/app/shared/state/plans.state.spec.ts +++ b/frontend/src/app/shared/state/plans.state.spec.ts @@ -75,7 +75,6 @@ describe('PlansState', () => { ]); expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); @@ -92,7 +91,6 @@ describe('PlansState', () => { expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.isLoading).toBeFalsy(); expect(plansState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/queries.spec.ts b/frontend/src/app/shared/state/queries.spec.ts index 8a824f5253..2acecb5598 100644 --- a/frontend/src/app/shared/state/queries.spec.ts +++ b/frontend/src/app/shared/state/queries.spec.ts @@ -105,7 +105,6 @@ describe('Queries', () => { queries.addUser('key3', { fullText: 'text3' }); expect(true).toBeTruthy(); - uiState.verify(x => x.setAppUser('schemas.my-schema.queries.key3', '{"fullText":"text3"}'), Times.once()); }); @@ -113,7 +112,6 @@ describe('Queries', () => { queries.addShared('key3', { fullText: 'text3' }); expect(true).toBeTruthy(); - uiState.verify(x => x.setAppShared('schemas.my-schema.queries.key3', '{"fullText":"text3"}'), Times.once()); }); @@ -121,7 +119,6 @@ describe('Queries', () => { queries.removeShared({ name: 'key3' }); expect(true).toBeTruthy(); - uiState.verify(x => x.removeAppShared('schemas.my-schema.queries.key3'), Times.once()); }); @@ -129,7 +126,6 @@ describe('Queries', () => { queries.removeUser({ name: 'key3' }); expect(true).toBeTruthy(); - uiState.verify(x => x.removeAppUser('schemas.my-schema.queries.key3'), Times.once()); }); }); diff --git a/frontend/src/app/shared/state/resolvers.spec.ts b/frontend/src/app/shared/state/resolvers.spec.ts index 19f1af0a38..99d223ca90 100644 --- a/frontend/src/app/shared/state/resolvers.spec.ts +++ b/frontend/src/app/shared/state/resolvers.spec.ts @@ -113,7 +113,6 @@ describe('ResolveContents', () => { contents[1], contents[2], ]); - contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); }); @@ -137,7 +136,6 @@ describe('ResolveContents', () => { contents[0], contents[1], ]); - contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); }); @@ -159,7 +157,6 @@ describe('ResolveContents', () => { contents[0], contents[1], ]); - contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); }); diff --git a/frontend/src/app/shared/state/roles.state.spec.ts b/frontend/src/app/shared/state/roles.state.spec.ts index 36da9f0b19..bdf4012cc9 100644 --- a/frontend/src/app/shared/state/roles.state.spec.ts +++ b/frontend/src/app/shared/state/roles.state.spec.ts @@ -38,7 +38,6 @@ describe('RolesState', () => { expect(rolesState.snapshot.isLoading).toBeFalsy(); expect(rolesState.snapshot.roles).toEqual(oldRoles.items); expect(rolesState.snapshot.version).toEqual(version); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/rule-events.state.spec.ts b/frontend/src/app/shared/state/rule-events.state.spec.ts index b373d809a8..f0d80e97b7 100644 --- a/frontend/src/app/shared/state/rule-events.state.spec.ts +++ b/frontend/src/app/shared/state/rule-events.state.spec.ts @@ -43,7 +43,6 @@ describe('RuleEventsState', () => { expect(ruleEventsState.snapshot.isLoaded).toBeTruthy(); expect(ruleEventsState.snapshot.isLoading).toBeFalsy(); expect(ruleEventsState.snapshot.total).toEqual(200); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/rule-simulator.state.spec.ts b/frontend/src/app/shared/state/rule-simulator.state.spec.ts index 4e6307674b..d2c3360009 100644 --- a/frontend/src/app/shared/state/rule-simulator.state.spec.ts +++ b/frontend/src/app/shared/state/rule-simulator.state.spec.ts @@ -47,7 +47,6 @@ describe('RuleSimulatorState', () => { expect(ruleSimulatorState.snapshot.isLoaded).toBeTruthy(); expect(ruleSimulatorState.snapshot.isLoading).toBeFalsy(); expect(ruleSimulatorState.snapshot.total).toEqual(200); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); @@ -62,7 +61,6 @@ describe('RuleSimulatorState', () => { expect(ruleSimulatorState.snapshot.isLoaded).toBeTruthy(); expect(ruleSimulatorState.snapshot.isLoading).toBeFalsy(); expect(ruleSimulatorState.snapshot.total).toEqual(200); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/rules.state.spec.ts b/frontend/src/app/shared/state/rules.state.spec.ts index 5877b11edb..265091bf20 100644 --- a/frontend/src/app/shared/state/rules.state.spec.ts +++ b/frontend/src/app/shared/state/rules.state.spec.ts @@ -53,7 +53,6 @@ describe('RulesState', () => { expect(ruleRunning).toBe(rule1); expect(rulesState.snapshot.runningRuleId).toBe(rule1.id); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/schemas.state.spec.ts b/frontend/src/app/shared/state/schemas.state.spec.ts index 398a61ed42..a5cbd46034 100644 --- a/frontend/src/app/shared/state/schemas.state.spec.ts +++ b/frontend/src/app/shared/state/schemas.state.spec.ts @@ -51,7 +51,6 @@ describe('SchemasState', () => { expect(schemasState.snapshot.schemas).toEqualIgnoringProps(oldSchemas.items); expect(schemasState.snapshot.isLoaded).toBeTruthy(); - schemasService.verifyAll(); }); @@ -65,7 +64,6 @@ describe('SchemasState', () => { expect(schemasState.snapshot.isLoaded).toBeTruthy(); expect(schemasState.snapshot.isLoading).toBeFalsy(); expect(schemasState.snapshot.schemas).toEqualIgnoringProps(oldSchemas.items); - schemasService.verifyAll(); }); diff --git a/frontend/src/app/shared/state/table-settings.spec.ts b/frontend/src/app/shared/state/table-settings.spec.ts index 63a91a7cee..5158eab686 100644 --- a/frontend/src/app/shared/state/table-settings.spec.ts +++ b/frontend/src/app/shared/state/table-settings.spec.ts @@ -177,7 +177,6 @@ describe('TableSettings', () => { tableSettings.updateSize(META_FIELDS.version.name, 100, true); uiState.verify(x => x.setAppUser('schemas.my-schema.config', { ...EMPTY, sizes: { [META_FIELDS.version.name]: 100 } }), Times.once()); - expect(fieldSizes!).toEqual({ [META_FIELDS.version.name]: 100 }); }); @@ -196,7 +195,6 @@ describe('TableSettings', () => { tableSettings.updateSize(META_FIELDS.version.name, 100, false); uiState.verify(x => x.setAppUser('schemas.my-schema.config', It.isAny()), Times.never()); - expect(fieldSizes!).toEqual({ [META_FIELDS.version.name]: 100 }); }); @@ -215,7 +213,6 @@ describe('TableSettings', () => { tableSettings.toggleWrapping(META_FIELDS.version.name, true); uiState.verify(x => x.setAppUser('schemas.my-schema.config', { ...EMPTY, wrappings: { [META_FIELDS.version.name]: true } }), Times.once()); - expect(fieldWrappings!).toEqual({ [META_FIELDS.version.name]: true }); }); @@ -234,7 +231,6 @@ describe('TableSettings', () => { tableSettings.toggleWrapping(META_FIELDS.version.name, false); uiState.verify(x => x.setAppUser('schemas.my-schema.config', It.isAny()), Times.never()); - expect(fieldWrappings!).toEqual({ [META_FIELDS.version.name]: true }); }); diff --git a/frontend/src/app/shared/state/template.state.spec.ts b/frontend/src/app/shared/state/template.state.spec.ts index 49f165c589..3516cff9b1 100644 --- a/frontend/src/app/shared/state/template.state.spec.ts +++ b/frontend/src/app/shared/state/template.state.spec.ts @@ -39,7 +39,6 @@ describe('TemplatesState', () => { expect(templatesState.snapshot.templates).toEqual([template1, template2]); expect(templatesState.snapshot.isLoaded).toBeTruthy(); expect(templatesState.snapshot.isLoading).toBeFalsy(); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); diff --git a/frontend/src/app/shared/state/workflows.state.spec.ts b/frontend/src/app/shared/state/workflows.state.spec.ts index c99066a9d9..bf72f4585b 100644 --- a/frontend/src/app/shared/state/workflows.state.spec.ts +++ b/frontend/src/app/shared/state/workflows.state.spec.ts @@ -42,7 +42,6 @@ describe('WorkflowsState', () => { expect(workflowsState.snapshot.isLoading).toBeFalsy(); expect(workflowsState.snapshot.version).toEqual(version); expect(workflowsState.snapshot.workflows).toEqual(oldWorkflows.items); - dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); }); From 04d501fcf32c1c732d3391394baef385e1565804 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 21 Sep 2026 12:27:04 +0200 Subject: [PATCH 3/8] New action. --- .../AzureServiceBusFlowStep.cs | 97 +++++++++++++++++++ .../AzureServiceBus/AzureServiceBusPlugin.cs | 20 ++++ .../CreateContent/CreateContentFlowStep.cs | 68 ++++++++++--- .../Squidex.Extensions.csproj | 1 + todo.md | 8 +- 5 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusFlowStep.cs create mode 100644 backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusPlugin.cs diff --git a/backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusFlowStep.cs b/backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusFlowStep.cs new file mode 100644 index 0000000000..f30f21f1e6 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusFlowStep.cs @@ -0,0 +1,97 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Azure.Messaging.ServiceBus; +using Squidex.Flows; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Extensions.Actions.AzureServiceBus; + +[FlowStep( + Title = "Azure Service Bus", + IconImage = "", + IconColor = "#0d9bf9", + Display = "Send to Azure Service Bus", + Description = "Send an event to an azure service bus queue or topic.", + ReadMore = "https://azure.microsoft.com/en-us/products/service-bus/")] +public sealed record AzureServiceBusFlowStep : FlowStep +{ + [LocalizedRequired] + [Display(Name = "Connection", Description = "The connection string to the service bus namespace.")] + [Editor(FlowStepEditor.Text)] + [Expression] + public string ConnectionString { get; set; } + + [LocalizedRequired] + [Display(Name = "Queue or Topic", Description = "The name of the queue or topic.")] + [Editor(FlowStepEditor.Text)] + [Expression] + public string QueueOrTopic { get; set; } + + [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] + [Editor(FlowStepEditor.TextArea)] + [Expression(ExpressionFallback.Envelope)] + public string? Payload { get; set; } + + [Display(Name = "Subject (Optional)", Description = "The subject of the message, commonly used for filtering in subscriptions.")] + [Editor(FlowStepEditor.Text)] + [Expression] + public string? Subject { get; set; } + + [Display(Name = "Session ID (Optional)", Description = "The session ID, required for session enabled queues and subscriptions.")] + [Editor(FlowStepEditor.Text)] + [Expression] + public string? SessionId { get; set; } + + [Display(Name = "Message ID (Optional)", Description = "The message ID, used for duplicate detection.")] + [Editor(FlowStepEditor.Text)] + [Expression] + public string? MessageId { get; set; } + + // The client owns the connection and is thread safe, senders are cheap and are created per message. + private static readonly ClientPool Clients = new ClientPool(key => + { + return new ServiceBusClient(key); + }); + + public async override ValueTask ExecuteAsync(FlowExecutionContext executionContext, + CancellationToken ct) + { + if (executionContext.IsSimulation) + { + executionContext.LogSkipSimulation(); + return Next(); + } + + var client = await Clients.GetClientAsync(ConnectionString); + + var message = new ServiceBusMessage(Payload); + + if (!string.IsNullOrWhiteSpace(Subject)) + { + message.Subject = Subject; + } + + if (!string.IsNullOrWhiteSpace(SessionId)) + { + message.SessionId = SessionId; + } + + if (!string.IsNullOrWhiteSpace(MessageId)) + { + message.MessageId = MessageId; + } + + await using var sender = client.CreateSender(QueueOrTopic); + + await sender.SendMessageAsync(message, ct); + + executionContext.Log($"Message sent to '{QueueOrTopic}'."); + return Next(); + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusPlugin.cs new file mode 100644 index 0000000000..99661020b9 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/AzureServiceBus/AzureServiceBusPlugin.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Infrastructure.Plugins; + +namespace Squidex.Extensions.Actions.AzureServiceBus; + +public sealed class AzureServiceBusPlugin : IPlugin +{ + public void ConfigureServices(IServiceCollection services, IConfiguration config) + { + services.AddFlowStep(); + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs index 5e328349a8..894ac2ea61 100644 --- a/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs +++ b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs @@ -17,7 +17,9 @@ using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Validation; -using Command = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent; +using ContentDataCommand = Squidex.Domain.Apps.Entities.Contents.Commands.ContentDataCommand; +using CreateCommand = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent; +using UpsertCommand = Squidex.Domain.Apps.Entities.Contents.Commands.UpsertContent; namespace Squidex.Extensions.Actions.CreateContent; @@ -26,7 +28,7 @@ namespace Squidex.Extensions.Actions.CreateContent; IconImage = "", IconColor = "#3389ff", Display = "Create content", - Description = "Create a a new content item for any schema.")] + Description = "Create a new content item for any schema, or update it if it already exists.")] #pragma warning disable CS0618 // Type or member is obsolete public sealed record CreateContentFlowStep : FlowStep, IConvertibleToAction #pragma warning restore CS0618 // Type or member is obsolete @@ -47,19 +49,34 @@ public sealed record CreateContentFlowStep : FlowStep, IConvertibleToAction public string? Client { get; set; } [Display(Name = "Publish", Description = "Publish the content.")] - [Editor(FlowStepEditor.Text)] + [Editor(FlowStepEditor.Checkbox)] public bool Publish { get; set; } + [Display(Name = "ID (Optional)", Description = "The ID of the content. If set, the content is created when it does not exist and updated otherwise.")] + [Editor(FlowStepEditor.Text)] + [Expression] + public string? Id { get; set; } + + [Display(Name = "Patch", Description = "Only update the fields from the data when the content already exists. Requires an ID.")] + [Editor(FlowStepEditor.Checkbox)] + public bool Patch { get; set; } + + public override ValueTask ValidateAsync(FlowValidationContext validationContext, AddStepError addError, + CancellationToken ct) + { + if (Patch && string.IsNullOrWhiteSpace(Id)) + { + addError(nameof(Patch), "Patch requires an ID."); + } + + return base.ValidateAsync(validationContext, addError, ct); + } + public override async ValueTask ExecuteAsync(FlowExecutionContext executionContext, CancellationToken ct) { var @event = ((FlowEventContext)executionContext.Context).Event; - var command = new Command - { - AppId = @event.AppId, - }; - var schema = await executionContext.Resolve() .GetSchemaAsync(@event.AppId.Id, Schema, true, ct); @@ -76,6 +93,26 @@ await executionContext.Resolve() return Next(); } + var status = Publish ? Status.Published : (Status?)null; + + ContentDataCommand command; + if (!string.IsNullOrWhiteSpace(Id)) + { + command = new UpsertCommand + { + ContentId = DomainId.Create(Id.Trim()), + EnrichDefaults = true, + EnrichRequiredFields = false, + Patch = Patch, + Status = status + }; + } + else + { + command = new CreateCommand { Status = status }; + } + + command.AppId = @event.AppId; command.SchemaId = schema.NamedId(); command.FromRule = true; command.Data = executionContext.DeserializeJson(Data); @@ -89,15 +126,18 @@ await executionContext.Resolve() command.Actor = userEvent.Actor; } - if (Publish) - { - command.Status = Status.Published; - } - await executionContext.Resolve() .PublishAsync(command, ct); - executionContext.Log($"Content created for schema '{schema.Name}', ID: {command.ContentId}"); + if (command is UpsertCommand) + { + executionContext.Log($"Content upserted for schema '{schema.Name}', ID: {command.ContentId}"); + } + else + { + executionContext.Log($"Content created for schema '{schema.Name}', ID: {command.ContentId}"); + } + return Next(); } diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj index 4c83f16a9e..b40e13e979 100644 --- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj +++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj @@ -11,6 +11,7 @@ + diff --git a/todo.md b/todo.md index cfffbf4501..cfc1849a29 100644 --- a/todo.md +++ b/todo.md @@ -35,9 +35,9 @@ being postponed and declined. See | [2](#2-export-and-import-content-from-the-ui-as-jobs-3461-1366-3438) | Export/import content from the UI as jobs | Declined ×2 | 5381 | large | | [3](#3-scope-asset-permissions-to-folders-5921-5676-1229) | Scope asset permissions to folders | Open ×2 + Declined | 3857 | large | | [4](#4-dark-mode-and-per-app-theming-5720-816) | Dark mode and per-app theming | Open + Declined | 2102 | large | -| [5](#5-azure-service-bus-flow-step-4522) | Azure Service Bus flow step | Postponed | 1838 | medium | +| [5](#5-azure-service-bus-flow-step-4522) | ✅ **Done** — Azure Service Bus flow step | Postponed | 1838 | medium | | [6](#6-give-scripts-a-way-to-log-2822) | Give scripts a way to log | Unresolved | 2575 | medium | -| [7](#7-upsert-mode-for-the-create-content-flow-step-5616) | Upsert mode for "Create content" step | Unresolved | 786 | small | +| [7](#7-upsert-mode-for-the-create-content-flow-step-5616) | ✅ **Done** — Upsert mode for "Create content" step | Unresolved | 786 | small | | [8](#8-regenerate-a-client-secret-from-the-ui-5807) | Regenerate a client secret from the UI | Open | 1383 | small | | [9](#9-show-which-fields-are-required-when-publishing-4375) | ✅ **Done** — Mark "required when publishing" fields | Declined | 1707 | tiny | | [10](#10-let-users-upload-a-duplicate-asset-anyway-3144) | ✅ **Done** — "Upload anyway" for duplicate assets | Declined | 1356 | tiny | @@ -246,6 +246,8 @@ it look expensive in 2019. ## 5. Azure Service Bus flow step (#4522) +> ✅ **Implemented.** New `AzureServiceBusFlowStep` (`Actions/AzureServiceBus/`) sends to a queue or topic, with connection string, payload, subject, session ID and message ID configurable per step. + **Asked:** contribute a Service Bus action upstream. · Postponed (`INACTIVE`) · 1838 views · 4 likes · **3 votes** (joint highest of @@ -324,6 +326,8 @@ has a notion of user-visible log lines to imitate. ## 7. Upsert mode for the "Create content" flow step (#5616) +> ✅ **Implemented.** `CreateContentFlowStep` has an optional `ID` (expression) and a `Patch` flag. With an ID it issues `UpsertContent` (create if missing, update or patch otherwise); without one it creates as before. + **Asked:** a rule that creates content when it does not exist and updates it when it does. From e532a7a20a6234c4aece8063c903d4274c70c997 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 21 Sep 2026 13:02:14 +0200 Subject: [PATCH 4/8] Regenerate client secret from the UI. Adds a RegenerateClientSecret command and AppClientSecretRegenerated event that issue a new secret while keeping the client ID, exposed as PUT apps/{app}/clients/{id}/secret and as a button in the client settings. Co-Authored-By: Claude Opus 5 --- backend/i18n/frontend_de.json | 4 + backend/i18n/frontend_en.json | 4 + backend/i18n/frontend_fr.json | 4 + backend/i18n/frontend_it.json | 4 + backend/i18n/frontend_nl.json | 4 + backend/i18n/frontend_pt.json | 4 + backend/i18n/frontend_zh.json | 4 + backend/i18n/source/backend_en.json | 1 + backend/i18n/source/frontend_en.json | 4 + .../Apps/AppClient.cs | 2 +- .../Apps/AppClients.cs | 19 +++ .../Apps/AppHistoryEventsCreator.cs | 5 + .../Apps/Commands/RegenerateClientSecret.cs | 22 +++ .../DomainObject/AppDomainObject.State.cs | 4 + .../Apps/DomainObject/AppDomainObject.cs | 15 ++ .../DomainObject/Guards/GuardAppClients.cs | 15 ++ .../Apps/AppClientSecretRegenerated.cs | 18 +++ backend/src/Squidex.Shared/Texts.de.resx | 3 + backend/src/Squidex.Shared/Texts.fr.resx | 3 + backend/src/Squidex.Shared/Texts.it.resx | 3 + backend/src/Squidex.Shared/Texts.nl.resx | 3 + backend/src/Squidex.Shared/Texts.pt.resx | 3 + backend/src/Squidex.Shared/Texts.resx | 3 + backend/src/Squidex.Shared/Texts.zh.resx | 3 + .../Controllers/Apps/AppClientsController.cs | 24 ++++ .../Api/Controllers/Apps/Models/ClientDto.cs | 3 + .../Model/Apps/AppClientsTests.cs | 16 +++ .../Apps/DomainObject/AppDomainObjectTests.cs | 15 ++ .../Guards/GuardAppClientsTests.cs | 30 ++++ ...eate_events_and_update_secret.verified.txt | 129 ++++++++++++++++++ .../pages/clients/client.component.html | 12 ++ .../pages/clients/client.component.ts | 4 + frontend/src/app/shared/model/custom.ts | 4 + frontend/src/app/shared/model/generated.ts | 4 + .../shared/services/clients.service.spec.ts | 28 +++- .../app/shared/services/clients.service.ts | 12 ++ .../app/shared/state/clients.state.spec.ts | 11 ++ .../src/app/shared/state/clients.state.ts | 8 ++ todo.md | 5 +- 39 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RegenerateClientSecret.cs create mode 100644 backend/src/Squidex.Domain.Apps.Events/Apps/AppClientSecretRegenerated.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt diff --git a/backend/i18n/frontend_de.json b/backend/i18n/frontend_de.json index eee6a426d7..fdc2b27411 100644 --- a/backend/i18n/frontend_de.json +++ b/backend/i18n/frontend_de.json @@ -191,6 +191,10 @@ "clients.empty": "Noch kein Client erstellt.", "clients.loadFailed": "Fehler beim Laden der Clients. Bitte neu laden.", "clients.refreshTooltip": "Clients aktualisieren", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "Clients neu geladen.", "clients.revokeFailed": "Fehler beim Widerrufen des Clients. Bitte neu laden.", "clients.tokenFailed": "Fehler beim Erstellen des Tokens. Bitte versuchen Sie es erneut.", diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 3532565041..c1794543b5 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -191,6 +191,10 @@ "clients.empty": "No client created yet.", "clients.loadFailed": "Failed to load clients. Please reload.", "clients.refreshTooltip": "Refresh clients", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "Clients reloaded.", "clients.revokeFailed": "Failed to revoke client. Please reload.", "clients.tokenFailed": "Failed to create token. Please retry.", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index 5564d68204..9a5d681086 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -191,6 +191,10 @@ "clients.empty": "Aucun client créé pour le moment.", "clients.loadFailed": "Échec du chargement des clients. Veuillez recharger.", "clients.refreshTooltip": "Actualiser les clients", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "Clients rechargés.", "clients.revokeFailed": "Échec de la révocation du client. Veuillez recharger.", "clients.tokenFailed": "Échec de la création du jeton. Veuillez réessayer.", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index aa01749249..7bc544b05c 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -191,6 +191,10 @@ "clients.empty": "Nessun client ancora creato.", "clients.loadFailed": "Non è stato possibile caricare i client. Per favore ricarica.", "clients.refreshTooltip": "Aggiorna i client", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "Client ricaricati.", "clients.revokeFailed": "Non è stato possibile rimuovere il client. Per favore ricarica.", "clients.tokenFailed": "Non è stato possibile creare il token. Per favore riprova.", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index e8dfba5538..7fed3fd00f 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -191,6 +191,10 @@ "clients.empty": "Nog geen client aangemaakt.", "clients.loadFailed": "Kan clients niet laden. Laad opnieuw.", "clients.refreshTooltip": "Ververs clients", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "Clients herladen.", "clients.revokeFailed": "Kan client niet intrekken. Laad opnieuw.", "clients.tokenFailed": "Maken van token is mislukt. Probeer het opnieuw.", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index f918b9d5b9..32e692c65d 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -191,6 +191,10 @@ "clients.empty": "Nenhum cliente foi criado ainda.", "clients.loadFailed": "Falhou em carregar clientes. Por favor, recarregue.", "clients.refreshTooltip": "Atualizar clientes", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "Clientes recarregados.", "clients.revokeFailed": "Falhou em revogar o cliente. Por favor, recarregue.", "clients.tokenFailed": "Falhou em criar um símbolo. Por favor, reda o redando.", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 27fc0c3790..6fe983cae3 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -191,6 +191,10 @@ "clients.empty": "尚未创建客户端。", "clients.loadFailed": "加载客户端失败。请重新加载。", "clients.refreshTooltip": "刷新客户端", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "客户端重新加载。", "clients.revokeFailed": "撤销客户端失败。请重新加载。", "clients.tokenFailed": "创建令牌失败。请重试。", diff --git a/backend/i18n/source/backend_en.json b/backend/i18n/source/backend_en.json index be1ae1ea58..dc53b2771b 100644 --- a/backend/i18n/source/backend_en.json +++ b/backend/i18n/source/backend_en.json @@ -217,6 +217,7 @@ "history.apps.assetScriptsConfigured": "updated asset scripts", "history.apps.clientAdded": "added client {[Id]} to app", "history.apps.clientRevoked": "revoked client {[Id]}", + "history.apps.clientSecretRegenerated": "regenerated secret of client {[Id]}", "history.apps.clientUpdated": "updated client {[Id]}", "history.apps.common.updated": "updated general settings", "history.apps.contributoreAssigned": "assigned {user:[Contributor]} as {[Role]}", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 3532565041..c1794543b5 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -191,6 +191,10 @@ "clients.empty": "No client created yet.", "clients.loadFailed": "Failed to load clients. Please reload.", "clients.refreshTooltip": "Refresh clients", + "clients.regenerateSecret": "Regenerate Client Secret", + "clients.regenerateSecretConfirmText": "Do you really want to regenerate the secret? Applications that use the current secret cannot request new access tokens anymore.", + "clients.regenerateSecretConfirmTitle": "Regenerate secret", + "clients.regenerateSecretFailed": "Failed to regenerate client secret. Please reload.", "clients.reloaded": "Clients reloaded.", "clients.revokeFailed": "Failed to revoke client. Please reload.", "clients.tokenFailed": "Failed to create token. Please retry.", diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs index 3450e900d4..57e930fffd 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs @@ -15,7 +15,7 @@ public sealed record AppClient(string Name, string Secret) { public string Name { get; init; } = Guard.NotNullOrEmpty(Name); - public string Secret { get; } = Guard.NotNullOrEmpty(Secret); + public string Secret { get; init; } = Guard.NotNullOrEmpty(Secret); public long ApiCallsLimit { get; init; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs index cdd874e22a..63ca5ad7d0 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppClients.cs @@ -56,6 +56,25 @@ public AppClients Add(string id, string secret, string? role = null) return new AppClients(updated); } + [Pure] + public AppClients RegenerateSecret(string id, string secret) + { + Guard.NotNullOrEmpty(id); + Guard.NotNullOrEmpty(secret); + + if (!TryGetValue(id, out var client)) + { + return this; + } + + if (!this.TrySet(id, client with { Secret = secret }, out var updated)) + { + return this; + } + + return new AppClients(updated); + } + [Pure] public AppClients Update(string id, string? name = null, string? role = null, long? apiCallsLimit = null, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs index 78e2344de6..197606ee4e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -30,6 +30,9 @@ public AppHistoryEventsCreator(TypeRegistry typeRegistry) AddEventMessage( "history.apps.clientRevoked"); + AddEventMessage( + "history.apps.clientSecretRegenerated"); + AddEventMessage( "history.apps.clientUpdated"); @@ -107,6 +110,8 @@ public AppHistoryEventsCreator(TypeRegistry typeRegistry) return CreateClientsEvent(e, e.Id); case AppClientRevoked e: return CreateClientsEvent(e, e.Id); + case AppClientSecretRegenerated e: + return CreateClientsEvent(e, e.Id); case AppLanguageAdded e: return CreateLanguagesEvent(e, e.Language); case AppLanguageUpdated e: diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RegenerateClientSecret.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RegenerateClientSecret.cs new file mode 100644 index 0000000000..d231611f80 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RegenerateClientSecret.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands; + +public sealed class RegenerateClientSecret : AppCommand +{ + public string Id { get; set; } + + public string Secret { get; set; } + + public RegenerateClientSecret() + { + Secret = RandomHash.New(); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs index 0f644cc5e4..d02cb2d51d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs @@ -75,6 +75,10 @@ protected override App Apply(App snapshot, Envelope @event) newSnapshot = snapshot.UpdateClients(e, (e, c) => c.Update(e.Id, e.Name, e.Role, e.ApiCallsLimit, e.ApiTrafficLimit, e.AllowAnonymous)); break; + case AppClientSecretRegenerated e: + newSnapshot = snapshot.UpdateClients(e, (e, c) => c.RegenerateSecret(e.Id, e.Secret)); + break; + case AppClientRevoked e: newSnapshot = snapshot.UpdateClients(e, (e, c) => c.Revoke(e.Id)); break; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs index 3d3fd446eb..875ee88467 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs @@ -178,6 +178,16 @@ public override Task ExecuteAsync(IAggregateCommand command, return Snapshot; }, ct); + case RegenerateClientSecret regenerateClientSecret: + return ApplyReturn(regenerateClientSecret, c => + { + GuardAppClients.CanRegenerateSecret(c, Snapshot); + + RegenerateClientSecret(c); + + return Snapshot; + }, ct); + case RevokeClient revokeClient: return ApplyReturn(revokeClient, c => { @@ -419,6 +429,11 @@ private void AttachClient(AttachClient command) Raise(command, new AppClientAttached()); } + private void RegenerateClientSecret(RegenerateClientSecret command) + { + Raise(command, new AppClientSecretRegenerated()); + } + private void RevokeClient(RevokeClient command) { Raise(command, new AppClientRevoked()); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppClients.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppClients.cs index 205e1fc97f..58d9039e02 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppClients.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppClients.cs @@ -49,6 +49,21 @@ public static void CanRevoke(RevokeClient command, App app) }); } + public static void CanRegenerateSecret(RegenerateClientSecret command, App app) + { + Guard.NotNull(command); + + GetClientOrThrow(app.Clients, command.Id); + + Validate.It(e => + { + if (string.IsNullOrWhiteSpace(command.Id)) + { + e(Not.Defined("ClientId"), nameof(command.Id)); + } + }); + } + public static void CanUpdate(UpdateClient command, App app) { Guard.NotNull(command); diff --git a/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientSecretRegenerated.cs b/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientSecretRegenerated.cs new file mode 100644 index 0000000000..308894398d --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientSecretRegenerated.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps; + +[EventType(nameof(AppClientSecretRegenerated))] +public sealed class AppClientSecretRegenerated : AppEvent +{ + public string Id { get; set; } + + public string Secret { get; set; } +} diff --git a/backend/src/Squidex.Shared/Texts.de.resx b/backend/src/Squidex.Shared/Texts.de.resx index 781073a04e..c8f39aea03 100644 --- a/backend/src/Squidex.Shared/Texts.de.resx +++ b/backend/src/Squidex.Shared/Texts.de.resx @@ -736,6 +736,9 @@ hat Client {[Id]} widerrufen + + regenerated secret of client {[Id]} + hat Client {[Id]} aktualisiert diff --git a/backend/src/Squidex.Shared/Texts.fr.resx b/backend/src/Squidex.Shared/Texts.fr.resx index 60a13a758c..49d5f81074 100644 --- a/backend/src/Squidex.Shared/Texts.fr.resx +++ b/backend/src/Squidex.Shared/Texts.fr.resx @@ -736,6 +736,9 @@ client révoqué {[Id]} + + regenerated secret of client {[Id]} + client mis à jour {[Id]} diff --git a/backend/src/Squidex.Shared/Texts.it.resx b/backend/src/Squidex.Shared/Texts.it.resx index 974684eeff..6d6efe5970 100644 --- a/backend/src/Squidex.Shared/Texts.it.resx +++ b/backend/src/Squidex.Shared/Texts.it.resx @@ -736,6 +736,9 @@ Revocato client {[Id]} + + regenerated secret of client {[Id]} + Aggiornato client {[Id]} diff --git a/backend/src/Squidex.Shared/Texts.nl.resx b/backend/src/Squidex.Shared/Texts.nl.resx index d1b5a36e2b..175fb3dff8 100644 --- a/backend/src/Squidex.Shared/Texts.nl.resx +++ b/backend/src/Squidex.Shared/Texts.nl.resx @@ -736,6 +736,9 @@ ingetrokken klant {[Id]} + + regenerated secret of client {[Id]} + bijgewerkte client {[Id]} diff --git a/backend/src/Squidex.Shared/Texts.pt.resx b/backend/src/Squidex.Shared/Texts.pt.resx index 598e5f9d63..7f0084dcdf 100644 --- a/backend/src/Squidex.Shared/Texts.pt.resx +++ b/backend/src/Squidex.Shared/Texts.pt.resx @@ -736,6 +736,9 @@ cliente {[Id]} revogado + + regenerated secret of client {[Id]} + cliente {[Id]} actualizado diff --git a/backend/src/Squidex.Shared/Texts.resx b/backend/src/Squidex.Shared/Texts.resx index 6ae4457721..c60378d2b4 100644 --- a/backend/src/Squidex.Shared/Texts.resx +++ b/backend/src/Squidex.Shared/Texts.resx @@ -736,6 +736,9 @@ revoked client {[Id]} + + regenerated secret of client {[Id]} + updated client {[Id]} diff --git a/backend/src/Squidex.Shared/Texts.zh.resx b/backend/src/Squidex.Shared/Texts.zh.resx index 9412439084..b2b3c69875 100644 --- a/backend/src/Squidex.Shared/Texts.zh.resx +++ b/backend/src/Squidex.Shared/Texts.zh.resx @@ -736,6 +736,9 @@ 已撤销客户端 {[Id]} + + regenerated secret of client {[Id]} + 更新的客户端 {[Id]} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index 323f41df2c..c66f772a2d 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -100,6 +100,30 @@ public async Task PutClient(string app, string id, [FromBody] Upd return Ok(response); } + /// + /// Regenerates the secret of an app client. + /// + /// The name of the app. + /// The ID of the client that must be updated. + /// Client secret regenerated. + /// Client or app not found. + /// + /// The client ID stays the same, but applications that use the old secret cannot request new access tokens anymore. + /// + [HttpPut] + [Route("apps/{app}/clients/{id}/secret/")] + [ProducesResponseType(typeof(ClientsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(PermissionIds.AppClientsUpdate)] + [ApiCosts(1)] + public async Task PutClientSecret(string app, string id) + { + var command = new RegenerateClientSecret { Id = id }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + /// /// Revoke an app client. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs index 3dda1fa350..7b69030c48 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs @@ -61,6 +61,9 @@ public ClientDto CreateLinks(Resources resources) { AddPutLink("update", resources.Url(x => nameof(x.PutClient), values)); + + AddPutLink("secret", + resources.Url(x => nameof(x.PutClientSecret), values)); } if (resources.CanDeleteClient) diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs index 66c404ff39..1b7236c506 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientsTests.cs @@ -87,6 +87,22 @@ public void Should_return_same_clients_if_client_to_update_not_found() Assert.Same(clients_0, clients_1); } + [Fact] + public void Should_regenerate_secret() + { + var clients_1 = clients_0.RegenerateSecret("1", "new-secret"); + + Assert.Equal(new AppClient("1", "new-secret"), clients_1["1"]); + } + + [Fact] + public void Should_return_same_clients_if_client_to_regenerate_secret_not_found() + { + var clients_1 = clients_0.RegenerateSecret("2", "new-secret"); + + Assert.Same(clients_0, clients_1); + } + [Fact] public void Should_revoke_client() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs index b6ada10672..1af8b9a8dc 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs @@ -374,6 +374,21 @@ public async Task UpdateClient_should_create_events_and_update_client() await VerifySutAsync(actual); } + [Fact] + public async Task RegenerateClientSecret_should_create_events_and_update_secret() + { + var command = new RegenerateClientSecret { Id = clientId }; + + await ExecuteCreateAsync(); + await ExecuteAttachClientAsync(); + + var actual = await PublishAsync(sut, command); + + await VerifySutAsync(actual); + + Assert.Equal(command.Secret, sut.Snapshot.Clients[clientId].Secret); + } + [Fact] public async Task RevokeClient_should_create_events_and_remove_client() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppClientsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppClientsTests.cs index e855a135a2..6645f587da 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppClientsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppClientsTests.cs @@ -82,6 +82,36 @@ public void CanRevoke_should_not_throw_exception_if_client_is_found() GuardAppClients.CanRevoke(command, App); } + [Fact] + public void CanRegenerateSecret_should_throw_execption_if_client_id_is_null() + { + var command = new RegenerateClientSecret(); + + ValidationAssert.Throws(() => GuardAppClients.CanRegenerateSecret(command, App), + new ValidationError("Client ID is required.", "Id")); + } + + [Fact] + public void CanRegenerateSecret_should_throw_exception_if_client_is_not_found() + { + var command = new RegenerateClientSecret { Id = "ios" }; + + Assert.Throws(() => GuardAppClients.CanRegenerateSecret(command, App)); + } + + [Fact] + public void CanRegenerateSecret_should_not_throw_exception_if_client_is_found() + { + var command = new RegenerateClientSecret { Id = "ios" }; + + App = App with + { + Clients = AppClients.Empty.Add("ios", "secret"), + }; + + GuardAppClients.CanRegenerateSecret(command, App); + } + [Fact] public void CanUpdate_should_throw_execption_if_client_id_is_null() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt new file mode 100644 index 0000000000..daee59acf2 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt @@ -0,0 +1,129 @@ +{ + sut: { + UniqueId: Guid_1, + Snapshot: { + Name: my-app, + Contributors: { + me: Owner + }, + Roles: { + All: [ + { + Name: Owner, + Permissions: [ + { + Id: * + } + ], + IsDefault: true + }, + { + Name: Reader, + Properties: { + ui.api.hide: true + }, + Permissions: [ + { + Id: assets.read + }, + { + Id: contents.*.read + } + ], + IsDefault: true + }, + { + Name: Editor, + Properties: { + ui.api.hide: true + }, + Permissions: [ + { + Id: assets + }, + { + Id: contents.* + }, + { + Id: roles.read + }, + { + Id: workflows.read + } + ], + IsDefault: true + }, + { + Name: Developer, + Permissions: [ + { + Id: assets + }, + { + Id: contents.* + }, + { + Id: roles.read + }, + { + Id: rules + }, + { + Id: schemas + }, + { + Id: workflows + } + ], + IsDefault: true + } + ] + }, + Clients: { + client: { + Name: client, + AllowAnonymous: false, + Role: Editor + } + }, + Settings: { + HideScheduler: true, + HideDateTimeModeButton: false + }, + AssetScripts: {}, + Languages: { + Master: en, + AllKeys: [ + en + ], + Values: { + en: { + IsOptional: false + } + } + }, + IsDeleted: false, + Id: Guid_1, + CreatedBy: subject:me, + LastModifiedBy: subject:me, + Version: 4, + UniqueId: Guid_1 + }, + Version: 4, + ObjectState: Created + }, + events: [ + { + Headers: { + AggregateId: Guid_1, + EventId: Guid_2 + }, + Payload: { + Id: client, + AppId: Guid_1,my-app, + Actor: subject:me, + FromRule: false + } + } + ] +} \ No newline at end of file diff --git a/frontend/src/app/features/settings/pages/clients/client.component.html b/frontend/src/app/features/settings/pages/clients/client.component.html index 7a782cc74f..f69867af54 100644 --- a/frontend/src/app/features/settings/pages/clients/client.component.html +++ b/frontend/src/app/features/settings/pages/clients/client.component.html @@ -54,6 +54,18 @@ type="button"> + @if (client.canRegenerateSecret) { + + }
diff --git a/frontend/src/app/features/settings/pages/clients/client.component.ts b/frontend/src/app/features/settings/pages/clients/client.component.ts index 105b448bcd..5dd25ebab6 100644 --- a/frontend/src/app/features/settings/pages/clients/client.component.ts +++ b/frontend/src/app/features/settings/pages/clients/client.component.ts @@ -56,6 +56,10 @@ export class ClientComponent { this.clientsState.revoke(this.client); } + public regenerateSecret() { + this.clientsState.regenerateSecret(this.client); + } + public updateRole(role: string) { const request = new UpdateClientDto({ role }); diff --git a/frontend/src/app/shared/model/custom.ts b/frontend/src/app/shared/model/custom.ts index f584caacd9..c81999c24a 100644 --- a/frontend/src/app/shared/model/custom.ts +++ b/frontend/src/app/shared/model/custom.ts @@ -212,6 +212,10 @@ export class AuthSchemeResponseDto extends generated.AuthSchemeResponseDto { } export class ClientDto extends generated.ClientDto { + public get canRegenerateSecret() { + return this.compute('canRegenerateSecret', () => hasAnyLink(this._links, 'secret')); + } + public get canRevoke() { return this.compute('canRevoke', () => hasAnyLink(this._links, 'delete')); } diff --git a/frontend/src/app/shared/model/generated.ts b/frontend/src/app/shared/model/generated.ts index b3e7d398c1..e8d566c460 100644 --- a/frontend/src/app/shared/model/generated.ts +++ b/frontend/src/app/shared/model/generated.ts @@ -14489,6 +14489,10 @@ export class ClientDto extends ResourceDto implements IClientDto { /** True to allow anonymous access without an access token for this client. */ readonly allowAnonymous!: boolean; + public get canRegenerateSecret() { + return this.compute('canRegenerateSecret', () => hasAnyLink(this._links, 'secret')); + } + public get canRevoke() { return this.compute('canRevoke', () => hasAnyLink(this._links, 'delete')); } diff --git a/frontend/src/app/shared/services/clients.service.spec.ts b/frontend/src/app/shared/services/clients.service.spec.ts index eb7879f66e..02c166870e 100644 --- a/frontend/src/app/shared/services/clients.service.spec.ts +++ b/frontend/src/app/shared/services/clients.service.spec.ts @@ -100,7 +100,33 @@ describe('ClientsService', () => { expect(clients!).toEqual({ payload: createClients(1, 2), version: new VersionTag('2') }); })); - it('should make delete request to remove client', inject([ClientsService, HttpTestingController], (clientsService: ClientsService, httpMock: HttpTestingController) => { + it('should make put request to regenerate client secret', inject([ClientsService, HttpTestingController], (clientsService: ClientsService, httpMock: HttpTestingController) => { + const resource: Resource = { + _links: { + secret: { method: 'PUT', href: '/api/apps/my-app/clients/client1/secret' }, + }, + }; + + let clients: Versioned; + clientsService.putClientSecret('my-app', resource, version).subscribe(result => { + clients = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/clients/client1/secret'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toEqual(version.value); + + req.flush(clientsResponse(1, 2), { + headers: { + etag: '2', + }, + }); + + expect(clients!).toEqual({ payload: createClients(1, 2), version: new VersionTag('2') }); + })); + + it('should make delete request to remove client',inject([ClientsService, HttpTestingController], (clientsService: ClientsService, httpMock: HttpTestingController) => { const resource: Resource = { _links: { delete: { method: 'DELETE', href: '/api/apps/my-app/clients/client1' }, diff --git a/frontend/src/app/shared/services/clients.service.ts b/frontend/src/app/shared/services/clients.service.ts index ebb3bd34fa..b5394c76de 100644 --- a/frontend/src/app/shared/services/clients.service.ts +++ b/frontend/src/app/shared/services/clients.service.ts @@ -62,6 +62,18 @@ export class ClientsService { pretifyError('i18n:clients.revokeFailed')); } + public putClientSecret(appName: string, resource: Resource, version: VersionOrTag): Observable> { + const link = resource._links['secret']; + + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( + mapVersioned(({ body }) => { + return ClientsDto.fromJSON(body); + }), + pretifyError('i18n:clients.regenerateSecretFailed')); + } + public deleteClient(appName: string, resource: Resource, version: VersionOrTag): Observable> { const link = resource._links['delete']; diff --git a/frontend/src/app/shared/state/clients.state.spec.ts b/frontend/src/app/shared/state/clients.state.spec.ts index 7315943d8d..e827ab23d3 100644 --- a/frontend/src/app/shared/state/clients.state.spec.ts +++ b/frontend/src/app/shared/state/clients.state.spec.ts @@ -111,6 +111,17 @@ describe('ClientsState', () => { expectNewClients(updated); }); + it('should update clients if secret regenerated', () => { + const updated = createClients(1, 2, 3); + + clientsService.setup(x => x.putClientSecret(app, oldClients.items[0], version)) + .returns(() => of(versioned(newVersion, updated))).verifiable(); + + clientsState.regenerateSecret(oldClients.items[0]).subscribe(); + + expectNewClients(updated); + }); + it('should update clients if client revoked', () => { const updated = createClients(1, 2, 3); diff --git a/frontend/src/app/shared/state/clients.state.ts b/frontend/src/app/shared/state/clients.state.ts index 49d320c621..4ee10d3548 100644 --- a/frontend/src/app/shared/state/clients.state.ts +++ b/frontend/src/app/shared/state/clients.state.ts @@ -98,6 +98,14 @@ export class ClientsState extends State { shareSubscribed(this.dialogs)); } + public regenerateSecret(client: ClientDto): Observable { + return this.clientsService.putClientSecret(this.appName, client, this.version).pipe( + tap(({ version, payload }) => { + this.replaceClients(payload, version); + }), + shareSubscribed(this.dialogs)); + } + public update(client: ClientDto, request: UpdateClientDto): Observable { return this.clientsService.putClient(this.appName, client, request, this.version).pipe( tap(({ version, payload }) => { diff --git a/todo.md b/todo.md index cfc1849a29..3ade678cb9 100644 --- a/todo.md +++ b/todo.md @@ -38,7 +38,7 @@ being postponed and declined. See | [5](#5-azure-service-bus-flow-step-4522) | ✅ **Done** — Azure Service Bus flow step | Postponed | 1838 | medium | | [6](#6-give-scripts-a-way-to-log-2822) | Give scripts a way to log | Unresolved | 2575 | medium | | [7](#7-upsert-mode-for-the-create-content-flow-step-5616) | ✅ **Done** — Upsert mode for "Create content" step | Unresolved | 786 | small | -| [8](#8-regenerate-a-client-secret-from-the-ui-5807) | Regenerate a client secret from the UI | Open | 1383 | small | +| [8](#8-regenerate-a-client-secret-from-the-ui-5807) | ✅ **Done** — Regenerate a client secret from the UI | Open | 1383 | small | | [9](#9-show-which-fields-are-required-when-publishing-4375) | ✅ **Done** — Mark "required when publishing" fields | Declined | 1707 | tiny | | [10](#10-let-users-upload-a-duplicate-asset-anyway-3144) | ✅ **Done** — "Upload anyway" for duplicate assets | Declined | 1356 | tiny | @@ -285,6 +285,7 @@ actions keep a thin `IConvertibleToAction` shim for migration only. ## 6. Give scripts a way to log (#2822) + **Asked:** `console.log` in scripting, and documentation of what `ctx` holds. · Unresolved · 2575 views · 2021-01 @@ -355,6 +356,8 @@ exactly what it needs to do."* ## 8. Regenerate a client secret from the UI (#5807) +> ✅ **Implemented.** New `RegenerateClientSecret` command and `AppClientSecretRegenerated` event keep the client ID and issue a new secret. Exposed as `PUT apps/{app}/clients/{id}/secret` (link `secret`) and as a button next to the secret in the client settings. + **Asked:** a button to issue a new secret for an existing client. · Open · 1383 views · 2025-01 From 024e6e67035b0929124e13982e588ee1413f63c8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 21 Sep 2026 14:17:28 +0200 Subject: [PATCH 5/8] Backend --- .../Actions/Script/ScriptFlowStep.cs | 15 +- .../AppDbContext.cs | 1 + .../Entities/Scripting/EFScriptLogBuilder.cs | 31 + .../Scripting/EFScriptLogRepository.cs | 108 ++ .../20260921120832_AddScriptLogs.Designer.cs | 1665 ++++++++++++++++ .../20260921120832_AddScriptLogs.cs | 53 + .../Migrations/MySqlDbContextModelSnapshot.cs | 35 + .../20260921120842_AddScriptLogs.Designer.cs | 1665 ++++++++++++++++ .../20260921120842_AddScriptLogs.cs | 48 + .../PostgresDbContextModelSnapshot.cs | 35 + .../20260921120852_AddScriptLogs.Designer.cs | 1667 +++++++++++++++++ .../20260921120852_AddScriptLogs.cs | 48 + .../SqlServerDbContextModelSnapshot.cs | 35 + .../ServiceExtensions.cs | 5 + .../Scripting/MongoScriptLogClassMap.cs | 28 + .../Scripting/MongoScriptLogRepository.cs | 109 ++ .../MongoClientFactory.cs | 2 + .../Squidex.Data.MongoDb/ServiceExtensions.cs | 5 + .../Apps/Roles.cs | 1 + .../Properties/Resources.Designer.cs | 6 + .../Properties/Resources.resx | 3 + .../Extensions/ConsoleJintExtension.cs | 131 ++ .../Scripting/IScriptLogStore.cs | 18 + .../Scripting/ScriptLog.cs | 146 ++ .../Scripting/ScriptLogEntry.cs | 18 + .../Scripting/ScriptLogRecord.cs | 26 + .../Guards/ScriptingExtensions.cs | 17 +- .../Guards/ScriptingExtensions.cs | 22 +- .../LogMessages.cs | 9 + .../Scripting/BackgroundScriptLogStore.cs | 208 ++ .../Repositories/IScriptLogRepository.cs | 30 + .../Scripting/ScriptLogOptions.cs | 25 + backend/src/Squidex.Shared/PermissionIds.cs | 4 + .../ScriptLogs/Models/ScriptLogDto.cs | 49 + .../ScriptLogs/Models/ScriptLogEntryDto.cs | 29 + .../ScriptLogs/Models/ScriptLogsDto.cs | 23 + .../ScriptLogs/ScriptLogsController.cs | 48 + .../Config/Domain/InfrastructureServices.cs | 11 + backend/src/Squidex/appsettings.json | 13 +- .../Scripting/EFScriptLogRepositoryTests.cs | 24 + .../MongoScriptLogRepositoryTests.cs | 26 + .../Shared/ScriptLogRepositoryTests.cs | 167 ++ .../Model/Apps/RolesTests.cs | 4 +- .../Scripting/ConsoleJintExtensionTests.cs | 246 +++ .../BackgroundScriptLogStoreTests.cs | 201 ++ ...reate_events_and_add_language.verified.txt | 3 + ...ld_create_events_and_add_role.verified.txt | 3 + ...reate_events_and_add_workflow.verified.txt | 3 + ...te_events_and_add_contributor.verified.txt | 3 + ...events_and_update_contributor.verified.txt | 3 + ..._create_events_and_add_client.verified.txt | 3 + ...create_events_and_update_plan.verified.txt | 3 + ...ould_reset_plan_for_free_plan.verified.txt | 3 + ...create_events_and_update_plan.verified.txt | 3 + ..._billing_manager_for_callback.verified.txt | 3 + ..._not_make_update_for_redirect.verified.txt | 3 + ...ould_reset_plan_for_free_plan.verified.txt | 3 + ...events_and_set_intitial_state.verified.txt | 3 + ..._assign_client_as_contributor.verified.txt | 3 + ...create_events_and_delete_role.verified.txt | 3 + ...te_events_and_remove_workflow.verified.txt | 3 + ...vents_and_update_deleted_flag.verified.txt | 3 + ...te_events_with_permanent_flag.verified.txt | 3 + ...eate_events_and_update_secret.verified.txt | 3 + ...events_and_remove_contributor.verified.txt | 3 + ...reate_events_and_update_image.verified.txt | 3 + ...te_events_and_remove_language.verified.txt | 3 + ...eate_events_and_remove_client.verified.txt | 3 + ...ld_create_events_and_set_team.verified.txt | 3 + ...ld_create_events_and_set_team.verified.txt | 3 + ...eate_events_and_update_client.verified.txt | 3 + ...te_events_and_update_language.verified.txt | 3 + ...create_events_and_update_role.verified.txt | 3 + ...ate_event_and_update_settings.verified.txt | 3 + ...te_events_and_update_workflow.verified.txt | 3 + ..._update_label_and_description.verified.txt | 3 + ...reate_events_and_update_image.verified.txt | 3 + todo.md | 3 +- 78 files changed, 7139 insertions(+), 20 deletions(-) create mode 100644 backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogBuilder.cs create mode 100644 backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogRepository.cs create mode 100644 backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.Designer.cs create mode 100644 backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.cs create mode 100644 backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.Designer.cs create mode 100644 backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.cs create mode 100644 backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.Designer.cs create mode 100644 backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.cs create mode 100644 backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs create mode 100644 backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogRepository.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptLogStore.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogRecord.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Scripting/BackgroundScriptLogStore.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Scripting/Repositories/IScriptLogRepository.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Scripting/ScriptLogOptions.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogsDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/ScriptLogsController.cs create mode 100644 backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Scripting/EFScriptLogRepositoryTests.cs create mode 100644 backend/tests/Squidex.Data.Tests/MongoDb/Domain/Scripting/MongoScriptLogRepositoryTests.cs create mode 100644 backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs diff --git a/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs b/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs index 68101d18de..76ba1a540a 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs @@ -7,6 +7,7 @@ using System.ComponentModel.DataAnnotations; using Squidex.Domain.Apps.Core.Rules.Deprecated; +using Squidex.Domain.Apps.Core.Scripting; using Squidex.Flows; using Squidex.Infrastructure.Reflection; @@ -33,7 +34,19 @@ public override async ValueTask ExecuteAsync(FlowExecutionContex { if (!string.IsNullOrWhiteSpace(Script)) { - await executionContext.RenderAsync($"Script({Script})", executionContext.Context); + using var log = ScriptLog.Begin("flows/script"); + try + { + await executionContext.RenderAsync($"Script({Script})", executionContext.Context); + } + finally + { + // Also write the log when the script fails, because this is when it is needed the most. + foreach (var line in log.ToLines()) + { + executionContext.Log(line); + } + } } return Next(); diff --git a/backend/src/Squidex.Data.EntityFramework/AppDbContext.cs b/backend/src/Squidex.Data.EntityFramework/AppDbContext.cs index bcb4a16ebc..4c55146f8c 100644 --- a/backend/src/Squidex.Data.EntityFramework/AppDbContext.cs +++ b/backend/src/Squidex.Data.EntityFramework/AppDbContext.cs @@ -59,6 +59,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.UseRequest(jsonSerializer, jsonColumnType); builder.UseRules(jsonSerializer, jsonColumnType); builder.UseSchema(jsonSerializer, jsonColumnType); + builder.UseScriptLogs(jsonSerializer, jsonColumnType); builder.UseSettings(jsonSerializer, jsonColumnType); builder.UseTags(jsonSerializer, jsonColumnType); builder.UseTeams(jsonSerializer, jsonColumnType); diff --git a/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogBuilder.cs b/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogBuilder.cs new file mode 100644 index 0000000000..4d3876549d --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogBuilder.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; + +namespace Microsoft.EntityFrameworkCore; + +public static class EFScriptLogBuilder +{ + public static void UseScriptLogs(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn) + { + builder.Entity(b => + { + b.ToTable("ScriptLogs"); + b.HasKey(x => x.Id); + b.HasIndex(x => new { x.AppId, x.Timestamp }); + b.HasIndex(x => x.Timestamp); + b.Property(x => x.Id).AsString(); + b.Property(x => x.AppId).AsString(); + b.Property(x => x.Name).HasMaxLength(255); + b.Property(x => x.Entries).AsJsonString(jsonSerializer, jsonColumn); + b.Property(x => x.Timestamp).AsDateTimeOffset(); + }); + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogRepository.cs b/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogRepository.cs new file mode 100644 index 0000000000..dae1c204cd --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Scripting/EFScriptLogRepository.cs @@ -0,0 +1,108 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Scripting; + +public sealed class EFScriptLogRepository(IDbContextFactory dbContextFactory) + : IScriptLogRepository where TContext : DbContext +{ + public async Task InsertManyAsync(IEnumerable records, + CancellationToken ct = default) + { + Guard.NotNull(records); + + var entities = records.ToList(); + if (entities.Count == 0) + { + return; + } + + await using var dbContext = await CreateDbContextAsync(ct); + await dbContext.BulkInsertAsync(entities, ct); + } + + public async Task TrimAsync(DomainId appId, int maxCount, + CancellationToken ct = default) + { + await using var dbContext = await CreateDbContextAsync(ct); + + // Find the newest record that exceeds the limit. Everything that is not newer is deleted. + var boundary = + await dbContext.Set() + .Where(x => x.AppId == appId) + .OrderByDescending(x => x.Timestamp) + .Skip(maxCount) + .Take(1) + .Select(x => x.Timestamp) + .ToListAsync(ct); + + if (boundary.Count == 0) + { + return; + } + + var timestamp = boundary[0]; + + await dbContext.Set().Where(x => x.AppId == appId && x.Timestamp <= timestamp) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteAsync(DomainId appId, + CancellationToken ct = default) + { + await using var dbContext = await CreateDbContextAsync(ct); + + await dbContext.Set().Where(x => x.AppId == appId) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteOlderThanAsync(Instant timestamp, + CancellationToken ct = default) + { + await using var dbContext = await CreateDbContextAsync(ct); + + await dbContext.Set().Where(x => x.Timestamp < timestamp) + .ExecuteDeleteAsync(ct); + } + + public async Task> QueryAsync(DomainId appId, string? name, int skip, int take, + CancellationToken ct = default) + { + await using var dbContext = await CreateDbContextAsync(ct); + + var query = dbContext.Set().Where(x => x.AppId == appId); + + if (!string.IsNullOrWhiteSpace(name)) + { + // The name is a path, therefore the filter matches all logs in the same group. + // The overload with a comparison type cannot be translated to SQL. +#pragma warning disable RECS0063 // Warns when a culture-aware 'StartsWith' call is used by default. + query = query.Where(x => x.Name.StartsWith(name)); +#pragma warning restore RECS0063 // Warns when a culture-aware 'StartsWith' call is used by default. + } + + var result = + await query + .OrderByDescending(x => x.Timestamp) + .Skip(skip) + .Take(take) + .ToListAsync(ct); + + return result; + } + + private Task CreateDbContextAsync(CancellationToken ct) + { + return dbContextFactory.CreateDbContextAsync(ct); + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.Designer.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.Designer.cs new file mode 100644 index 0000000000..cf22762557 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.Designer.cs @@ -0,0 +1,1665 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Squidex.Providers.MySql.App; + +#nullable disable + +namespace Squidex.Providers.MySql.App.Migrations +{ + [DbContext(typeof(MySqlAppDbContext))] + [Migration("20260921120832_AddScriptLogs")] + partial class AddScriptLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime(6)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("varchar(255)"); + + b.Property("RoleId") + .HasColumnType("varchar(255)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("varchar(255)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("varchar(255)"); + + b.Property("ApplicationId") + .HasColumnType("varchar(255)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("Scopes") + .HasColumnType("longtext"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("varchar(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("varchar(255)"); + + b.Property("ApplicationId") + .HasColumnType("varchar(255)"); + + b.Property("AuthorizationId") + .HasColumnType("varchar(255)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Payload") + .HasColumnType("longtext"); + + b.Property("Properties") + .HasColumnType("longtext"); + + b.Property("RedemptionDate") + .HasColumnType("datetime(6)"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("varchar(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type") + .HasAnnotation("MySql:IndexPrefixLength", new[] { 0, 0, 0, 50 }); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Squidex.AI.EntityFramework.EFChatEntity", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("LastUpdated") + .HasColumnType("datetime(6)"); + + b.Property("LastVersion") + .IsConcurrencyToken() + .HasColumnType("char(36)") + .HasColumnName("Version"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("LastUpdated"); + + b.ToTable("Chats", (string)null); + }); + + modelBuilder.Entity("Squidex.Assets.EntityFramework.EFAssetKeyValueEntity", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Expires") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Key"); + + b.HasIndex("Expires"); + + b.ToTable("AssetKeyValueStore_TusMetadata", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Core.Scripting.ScriptLogRecord", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Entries") + .IsRequired() + .HasColumnType("json"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TotalEntries") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AppId", "Timestamp"); + + b.ToTable("ScriptLogs", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Apps.EFAppEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("IndexedCreated") + .HasColumnType("datetime(6)") + .HasColumnName("Created"); + + b.Property("IndexedDeleted") + .HasColumnType("tinyint(1)") + .HasColumnName("Deleted"); + + b.Property("IndexedName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("Name"); + + b.Property("IndexedTeamId") + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasColumnName("TeamId"); + + b.Property("IndexedUserIds") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("UserIds"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_App", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Assets.EFAssetEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("FileHash") + .HasColumnType("longtext"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileVersion") + .HasColumnType("bigint"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("tinyint(1)"); + + b.Property("IsProtected") + .HasColumnType("tinyint(1)"); + + b.Property("LastModified") + .HasColumnType("datetime(6)"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("json"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ParentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Slug") + .HasColumnType("longtext"); + + b.Property("Tags") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("TotalSize") + .HasColumnType("bigint"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.HasIndex("IndexedAppId", "Id"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Assets.EFAssetFolderEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("FolderName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("tinyint(1)"); + + b.Property("LastModified") + .HasColumnType("datetime(6)"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ParentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.HasIndex("IndexedAppId", "Id"); + + b.ToTable("AssetFolders"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentCompleteEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("json"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IndexedSchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("tinyint(1)"); + + b.Property("LastModified") + .HasColumnType("datetime(6)"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NewData") + .HasColumnType("json"); + + b.Property("NewStatus") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ScheduleJob") + .HasColumnType("json"); + + b.Property("ScheduledAt") + .HasColumnType("datetime(6)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("TranslationStatus") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("ContentsAll", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentPublishedEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("json"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IndexedSchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("tinyint(1)"); + + b.Property("LastModified") + .HasColumnType("datetime(6)"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NewData") + .HasColumnType("json"); + + b.Property("NewStatus") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ScheduleJob") + .HasColumnType("json"); + + b.Property("ScheduledAt") + .HasColumnType("datetime(6)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("TranslationStatus") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("ContentsPublished", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentTableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("AppId", "SchemaId") + .IsUnique(); + + b.ToTable("ContentTables", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFReferenceCompleteEntity", b => + { + b.Property("AppId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("FromKey") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ToId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("FromSchema") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasDefaultValue("00000000-0000-0000-0000-000000000000"); + + b.HasKey("AppId", "FromKey", "ToId"); + + b.ToTable("ContentReferencesAll", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFReferencePublishedEntity", b => + { + b.Property("AppId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("FromKey") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ToId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("FromSchema") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasDefaultValue("00000000-0000-0000-0000-000000000000"); + + b.HasKey("AppId", "FromKey", "ToId"); + + b.ToTable("ContentReferencesPublished", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexGeoEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("varchar(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("GeoField") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("GeoObject") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ServeAll") + .HasColumnType("tinyint(1)"); + + b.Property("ServePublished") + .HasColumnType("tinyint(1)"); + + b.Property("Stage") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.ToTable("Geos"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("varchar(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ServeAll") + .HasColumnType("tinyint(1)"); + + b.Property("ServePublished") + .HasColumnType("tinyint(1)"); + + b.Property("Stage") + .HasColumnType("tinyint unsigned"); + + b.Property("Texts") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Texts"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexUserInfoEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("varchar(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ServeAll") + .HasColumnType("tinyint(1)"); + + b.Property("ServePublished") + .HasColumnType("tinyint(1)"); + + b.Property("Stage") + .HasColumnType("tinyint unsigned"); + + b.Property("UserInfoApiKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserInfoRole") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserInfoApiKey"); + + b.ToTable("UserInfos"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b => + { + b.Property("UniqueContentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("UniqueContentId"); + + b.ToTable("TextState", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.History.HistoryEvent", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Channel") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("HistoryEvent"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Rules.EFRuleEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasColumnName("AppId"); + + b.Property("IndexedDeleted") + .HasColumnType("tinyint(1)") + .HasColumnName("Deleted"); + + b.Property("IndexedId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasColumnName("Id"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Rule", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Schemas.EFSchemaEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasColumnName("AppId"); + + b.Property("IndexedDeleted") + .HasColumnType("tinyint(1)") + .HasColumnName("Deleted"); + + b.Property("IndexedId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasColumnName("Id"); + + b.Property("IndexedName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("Name"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Schema", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Teams.EFTeamEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("IndexedAuthDomain") + .HasColumnType("longtext") + .HasColumnName("AuthDomain"); + + b.Property("IndexedDeleted") + .HasColumnType("tinyint(1)") + .HasColumnName("Deleted"); + + b.Property("IndexedUserIds") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("UserIds"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Team", (string)null); + }); + + modelBuilder.Entity("Squidex.Events.EntityFramework.EFEventCommit", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EventStream") + .IsRequired() + .HasMaxLength(750) + .HasColumnType("varchar(750)"); + + b.Property("EventStreamOffset") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EventsCount") + .HasColumnType("bigint"); + + b.Property("Position") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("EventStream", "EventStreamOffset") + .IsUnique(); + + b.HasIndex("EventStream", "Position"); + + b.HasIndex("EventStream", "Timestamp"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Squidex.Events.EntityFramework.EFPosition", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Position") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("EventPosition"); + }); + + modelBuilder.Entity("Squidex.Flows.EntityFramework.EFCronJobEntity", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DueTime") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("DueTime"); + + b.ToTable("CronJobs", (string)null); + }); + + modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("DefinitionId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("DueTime") + .HasColumnType("datetime(6)"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("SchedulePartition") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("DueTime", "SchedulePartition"); + + b.ToTable("Flows", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Caching.EFCacheEntity", b => + { + b.Property("Key") + .HasColumnType("varchar(255)"); + + b.Property("Expires") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Key"); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Log.EFRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("json"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Key"); + + b.ToTable("Requests", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Migrations.EFMigrationEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("IsLocked") + .HasColumnType("tinyint(1)"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Migrations", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UISettings", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Index_TagHistory", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UsageNotifications", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Counters", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_JobsState", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UsageTracker", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Index_Tags", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Identity_Keys", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Identity_Xml", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_EventConsumerState", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Document") + .HasColumnType("json"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Names", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.UsageTracking.EFUsageCounterEntity", b => + { + b.Property("Key") + .HasColumnType("varchar(255)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("Category") + .HasColumnType("varchar(255)"); + + b.Property("CounterKey") + .HasColumnType("varchar(255)"); + + b.Property("CounterValue") + .HasColumnType("double"); + + b.HasKey("Key", "Date", "Category", "CounterKey"); + + b.ToTable("Counter", (string)null); + }); + + modelBuilder.Entity("Squidex.Messaging.EntityFramework.EFMessage", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("ChannelName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("MessageData") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("MessageHeaders") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TimeHandled") + .HasColumnType("datetime(6)"); + + b.Property("TimeToLive") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelName", "TimeHandled"); + + b.ToTable("Messages", (string)null); + }); + + modelBuilder.Entity("Squidex.Messaging.EntityFramework.EFMessagingDataEntity", b => + { + b.Property("Group") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Expiration") + .HasColumnType("datetime(6)"); + + b.Property("ValueData") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("ValueFormat") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Group", "Key"); + + b.HasIndex("Expiration"); + + b.ToTable("MessagingData", (string)null); + }); + + modelBuilder.Entity("YDotNet.Server.EntityFramework.YDotNetDocument", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("Expiration") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("YDotNetDocument", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.cs new file mode 100644 index 0000000000..d83d839015 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260921120832_AddScriptLogs.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Squidex.Providers.MySql.App.Migrations +{ + /// + public partial class AddScriptLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ScriptLogs", + columns: table => new + { + Id = table.Column(type: "varchar(255)", maxLength: 255, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + AppId = table.Column(type: "varchar(255)", maxLength: 255, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Name = table.Column(type: "varchar(255)", maxLength: 255, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + Entries = table.Column(type: "json", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + TotalEntries = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScriptLogs", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ScriptLogs_AppId_Timestamp", + table: "ScriptLogs", + columns: new[] { "AppId", "Timestamp" }); + + migrationBuilder.CreateIndex( + name: "IX_ScriptLogs_Timestamp", + table: "ScriptLogs", + column: "Timestamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ScriptLogs"); + } + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs index 615ca9a142..79a6d994a3 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs @@ -367,6 +367,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AssetKeyValueStore_TusMetadata", (string)null); }); + modelBuilder.Entity("Squidex.Domain.Apps.Core.Scripting.ScriptLogRecord", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Entries") + .IsRequired() + .HasColumnType("json"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TotalEntries") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AppId", "Timestamp"); + + b.ToTable("ScriptLogs", (string)null); + }); + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Apps.EFAppEntity", b => { b.Property("DocumentId") diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.Designer.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.Designer.cs new file mode 100644 index 0000000000..513eec08b0 --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.Designer.cs @@ -0,0 +1,1665 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Squidex.Providers.Postgres.App; + +#nullable disable + +namespace Squidex.Providers.Postgres.App.Migrations +{ + [DbContext(typeof(PostgresAppDbContext))] + [Migration("20260921120842_AddScriptLogs")] + partial class AddScriptLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Squidex.AI.EntityFramework.EFChatEntity", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("LastVersion") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("Version"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastUpdated"); + + b.ToTable("Chats", (string)null); + }); + + modelBuilder.Entity("Squidex.Assets.EntityFramework.EFAssetKeyValueEntity", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.HasIndex("Expires"); + + b.ToTable("AssetKeyValueStore_TusMetadata", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Core.Scripting.ScriptLogRecord", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Entries") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalEntries") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AppId", "Timestamp"); + + b.ToTable("ScriptLogs", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Apps.EFAppEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("IndexedCreated") + .HasColumnType("timestamp with time zone") + .HasColumnName("Created"); + + b.Property("IndexedDeleted") + .HasColumnType("boolean") + .HasColumnName("Deleted"); + + b.Property("IndexedName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Name"); + + b.Property("IndexedTeamId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("TeamId"); + + b.Property("IndexedUserIds") + .IsRequired() + .HasColumnType("text") + .HasColumnName("UserIds"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_App", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Assets.EFAssetEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FileHash") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileVersion") + .HasColumnType("bigint"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Slug") + .HasColumnType("text"); + + b.Property("Tags") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TotalSize") + .HasColumnType("bigint"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.HasIndex("IndexedAppId", "Id"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Assets.EFAssetFolderEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FolderName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ParentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.HasIndex("IndexedAppId", "Id"); + + b.ToTable("AssetFolders"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentCompleteEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndexedSchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NewData") + .HasColumnType("jsonb"); + + b.Property("NewStatus") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ScheduleJob") + .HasColumnType("jsonb"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TranslationStatus") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("ContentsAll", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentPublishedEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndexedSchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NewData") + .HasColumnType("jsonb"); + + b.Property("NewStatus") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ScheduleJob") + .HasColumnType("jsonb"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TranslationStatus") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("ContentsPublished", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentTableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("AppId", "SchemaId") + .IsUnique(); + + b.ToTable("ContentTables", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFReferenceCompleteEntity", b => + { + b.Property("AppId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FromKey") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ToId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FromSchema") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasDefaultValue("00000000-0000-0000-0000-000000000000"); + + b.HasKey("AppId", "FromKey", "ToId"); + + b.ToTable("ContentReferencesAll", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFReferencePublishedEntity", b => + { + b.Property("AppId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FromKey") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ToId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FromSchema") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasDefaultValue("00000000-0000-0000-0000-000000000000"); + + b.HasKey("AppId", "FromKey", "ToId"); + + b.ToTable("ContentReferencesPublished", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexGeoEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("GeoField") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("GeoObject") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ServeAll") + .HasColumnType("boolean"); + + b.Property("ServePublished") + .HasColumnType("boolean"); + + b.Property("Stage") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("Geos"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ServeAll") + .HasColumnType("boolean"); + + b.Property("ServePublished") + .HasColumnType("boolean"); + + b.Property("Stage") + .HasColumnType("smallint"); + + b.Property("Texts") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Texts"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexUserInfoEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ServeAll") + .HasColumnType("boolean"); + + b.Property("ServePublished") + .HasColumnType("boolean"); + + b.Property("Stage") + .HasColumnType("smallint"); + + b.Property("UserInfoApiKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserInfoRole") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserInfoApiKey"); + + b.ToTable("UserInfos"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b => + { + b.Property("UniqueContentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UniqueContentId"); + + b.ToTable("TextState", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.History.HistoryEvent", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Channel") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("HistoryEvent"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Rules.EFRuleEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("AppId"); + + b.Property("IndexedDeleted") + .HasColumnType("boolean") + .HasColumnName("Deleted"); + + b.Property("IndexedId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("Id"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Rule", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Schemas.EFSchemaEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("AppId"); + + b.Property("IndexedDeleted") + .HasColumnType("boolean") + .HasColumnName("Deleted"); + + b.Property("IndexedId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("Id"); + + b.Property("IndexedName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Name"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Schema", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Teams.EFTeamEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("IndexedAuthDomain") + .HasColumnType("text") + .HasColumnName("AuthDomain"); + + b.Property("IndexedDeleted") + .HasColumnType("boolean") + .HasColumnName("Deleted"); + + b.Property("IndexedUserIds") + .IsRequired() + .HasColumnType("text") + .HasColumnName("UserIds"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Team", (string)null); + }); + + modelBuilder.Entity("Squidex.Events.EntityFramework.EFEventCommit", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EventStream") + .IsRequired() + .HasMaxLength(750) + .HasColumnType("character varying(750)"); + + b.Property("EventStreamOffset") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("EventsCount") + .HasColumnType("bigint"); + + b.Property("Position") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventStream", "EventStreamOffset") + .IsUnique(); + + b.HasIndex("EventStream", "Position"); + + b.HasIndex("EventStream", "Timestamp"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Squidex.Events.EntityFramework.EFPosition", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Position") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("EventPosition"); + }); + + modelBuilder.Entity("Squidex.Flows.EntityFramework.EFCronJobEntity", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("DueTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DueTime"); + + b.ToTable("CronJobs", (string)null); + }); + + modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("DefinitionId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SchedulePartition") + .HasColumnType("integer"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DueTime", "SchedulePartition"); + + b.ToTable("Flows", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Caching.EFCacheEntity", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Key"); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Log.EFRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key"); + + b.ToTable("Requests", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Migrations.EFMigrationEntity", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Migrations", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UISettings", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Index_TagHistory", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UsageNotifications", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Counters", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_JobsState", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UsageTracker", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Index_Tags", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Identity_Keys", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Identity_Xml", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_EventConsumerState", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Document") + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Names", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.UsageTracking.EFUsageCounterEntity", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CounterKey") + .HasColumnType("text"); + + b.Property("CounterValue") + .HasColumnType("double precision"); + + b.HasKey("Key", "Date", "Category", "CounterKey"); + + b.ToTable("Counter", (string)null); + }); + + modelBuilder.Entity("Squidex.Messaging.EntityFramework.EFMessage", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ChannelName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("MessageData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("MessageHeaders") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TimeHandled") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeToLive") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ChannelName", "TimeHandled"); + + b.ToTable("Messages", (string)null); + }); + + modelBuilder.Entity("Squidex.Messaging.EntityFramework.EFMessagingDataEntity", b => + { + b.Property("Group") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ValueData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ValueFormat") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Group", "Key"); + + b.HasIndex("Expiration"); + + b.ToTable("MessagingData", (string)null); + }); + + modelBuilder.Entity("YDotNet.Server.EntityFramework.YDotNetDocument", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("YDotNetDocument", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.cs new file mode 100644 index 0000000000..58d0b1d7ff --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260921120842_AddScriptLogs.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Squidex.Providers.Postgres.App.Migrations +{ + /// + public partial class AddScriptLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ScriptLogs", + columns: table => new + { + Id = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + AppId = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + Entries = table.Column(type: "jsonb", nullable: false), + TotalEntries = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScriptLogs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ScriptLogs_AppId_Timestamp", + table: "ScriptLogs", + columns: new[] { "AppId", "Timestamp" }); + + migrationBuilder.CreateIndex( + name: "IX_ScriptLogs_Timestamp", + table: "ScriptLogs", + column: "Timestamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ScriptLogs"); + } + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs index 35d60a044d..13631a53ea 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs @@ -367,6 +367,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AssetKeyValueStore_TusMetadata", (string)null); }); + modelBuilder.Entity("Squidex.Domain.Apps.Core.Scripting.ScriptLogRecord", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Entries") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalEntries") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AppId", "Timestamp"); + + b.ToTable("ScriptLogs", (string)null); + }); + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Apps.EFAppEntity", b => { b.Property("DocumentId") diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.Designer.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.Designer.cs new file mode 100644 index 0000000000..ca6671329c --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.Designer.cs @@ -0,0 +1,1667 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Squidex.Providers.SqlServer.App; + +#nullable disable + +namespace Squidex.Providers.SqlServer.App.Migrations +{ + [DbContext(typeof(SqlServerAppDbContext))] + [Migration("20260921120852_AddScriptLogs")] + partial class AddScriptLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationId") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("Scopes") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("ApplicationId") + .HasColumnType("nvarchar(450)"); + + b.Property("AuthorizationId") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("ExpirationDate") + .HasColumnType("datetime2"); + + b.Property("Payload") + .HasColumnType("nvarchar(max)"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("RedemptionDate") + .HasColumnType("datetime2"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique() + .HasFilter("[ReferenceId] IS NOT NULL"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Squidex.AI.EntityFramework.EFChatEntity", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastUpdated") + .HasColumnType("datetime2"); + + b.Property("LastVersion") + .IsConcurrencyToken() + .HasColumnType("uniqueidentifier") + .HasColumnName("Version"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("LastUpdated"); + + b.ToTable("Chats", (string)null); + }); + + modelBuilder.Entity("Squidex.Assets.EntityFramework.EFAssetKeyValueEntity", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Expires") + .HasColumnType("datetimeoffset"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key"); + + b.HasIndex("Expires"); + + b.ToTable("AssetKeyValueStore_TusMetadata", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Core.Scripting.ScriptLogRecord", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Entries") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("TotalEntries") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AppId", "Timestamp"); + + b.ToTable("ScriptLogs", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Apps.EFAppEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("IndexedCreated") + .HasColumnType("datetimeoffset") + .HasColumnName("Created"); + + b.Property("IndexedDeleted") + .HasColumnType("bit") + .HasColumnName("Deleted"); + + b.Property("IndexedName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("Name"); + + b.Property("IndexedTeamId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("TeamId"); + + b.Property("IndexedUserIds") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("UserIds"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_App", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Assets.EFAssetEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FileHash") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileVersion") + .HasColumnType("bigint"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsProtected") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ParentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Slug") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TotalSize") + .HasColumnType("bigint"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.HasIndex("IndexedAppId", "Id"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Assets.EFAssetFolderEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FolderName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ParentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.HasIndex("IndexedAppId", "Id"); + + b.ToTable("AssetFolders"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentCompleteEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IndexedSchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NewData") + .HasColumnType("nvarchar(max)"); + + b.Property("NewStatus") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ScheduleJob") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduledAt") + .HasColumnType("datetimeoffset"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TranslationStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("ContentsAll", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentPublishedEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Id") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IndexedSchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModified") + .HasColumnType("datetimeoffset"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NewData") + .HasColumnType("nvarchar(max)"); + + b.Property("NewStatus") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ScheduleJob") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduledAt") + .HasColumnType("datetimeoffset"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TranslationStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("ContentsPublished", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFContentTableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("AppId", "SchemaId") + .IsUnique(); + + b.ToTable("ContentTables", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFReferenceCompleteEntity", b => + { + b.Property("AppId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FromKey") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ToId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FromSchema") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasDefaultValue("00000000-0000-0000-0000-000000000000"); + + b.HasKey("AppId", "FromKey", "ToId"); + + b.ToTable("ContentReferencesAll", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.EFReferencePublishedEntity", b => + { + b.Property("AppId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FromKey") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ToId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FromSchema") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasDefaultValue("00000000-0000-0000-0000-000000000000"); + + b.HasKey("AppId", "FromKey", "ToId"); + + b.ToTable("ContentReferencesPublished", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexGeoEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("GeoField") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("GeoObject") + .IsRequired() + .HasColumnType("geography"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ServeAll") + .HasColumnType("bit"); + + b.Property("ServePublished") + .HasColumnType("bit"); + + b.Property("Stage") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.ToTable("Geos"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ServeAll") + .HasColumnType("bit"); + + b.Property("ServePublished") + .HasColumnType("bit"); + + b.Property("Stage") + .HasColumnType("tinyint"); + + b.Property("Texts") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Texts"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexUserInfoEntity", b => + { + b.Property("Id") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ContentId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SchemaId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ServeAll") + .HasColumnType("bit"); + + b.Property("ServePublished") + .HasColumnType("bit"); + + b.Property("Stage") + .HasColumnType("tinyint"); + + b.Property("UserInfoApiKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("UserInfoRole") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserInfoApiKey"); + + b.ToTable("UserInfos"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b => + { + b.Property("UniqueContentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("UniqueContentId"); + + b.ToTable("TextState", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.History.HistoryEvent", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Channel") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("HistoryEvent"); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Rules.EFRuleEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("AppId"); + + b.Property("IndexedDeleted") + .HasColumnType("bit") + .HasColumnName("Deleted"); + + b.Property("IndexedId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("Id"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Rule", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Schemas.EFSchemaEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("IndexedAppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("AppId"); + + b.Property("IndexedDeleted") + .HasColumnType("bit") + .HasColumnName("Deleted"); + + b.Property("IndexedId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)") + .HasColumnName("Id"); + + b.Property("IndexedName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("Name"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Schema", (string)null); + }); + + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Teams.EFTeamEntity", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("IndexedAuthDomain") + .HasColumnType("nvarchar(max)") + .HasColumnName("AuthDomain"); + + b.Property("IndexedDeleted") + .HasColumnType("bit") + .HasColumnName("Deleted"); + + b.Property("IndexedUserIds") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("UserIds"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Team", (string)null); + }); + + modelBuilder.Entity("Squidex.Events.EntityFramework.EFEventCommit", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("EventStream") + .IsRequired() + .HasMaxLength(750) + .HasColumnType("nvarchar(750)"); + + b.Property("EventStreamOffset") + .HasColumnType("bigint"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventsCount") + .HasColumnType("bigint"); + + b.Property("Position") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EventStream", "EventStreamOffset") + .IsUnique(); + + b.HasIndex("EventStream", "Position"); + + b.HasIndex("EventStream", "Timestamp"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Squidex.Events.EntityFramework.EFPosition", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Position") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("EventPosition"); + }); + + modelBuilder.Entity("Squidex.Flows.EntityFramework.EFCronJobEntity", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DueTime") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DueTime"); + + b.ToTable("CronJobs", (string)null); + }); + + modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("DefinitionId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("DueTime") + .HasColumnType("datetimeoffset"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("SchedulePartition") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DueTime", "SchedulePartition"); + + b.ToTable("Flows", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Caching.EFCacheEntity", b => + { + b.Property("Key") + .HasColumnType("nvarchar(450)"); + + b.Property("Expires") + .HasColumnType("datetime2"); + + b.Property("Value") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.HasKey("Key"); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Log.EFRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Properties") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("Key"); + + b.ToTable("Requests", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.Migrations.EFMigrationEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Migrations", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UISettings", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Index_TagHistory", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UsageNotifications", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Counters", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_JobsState", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_UsageTracker", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Index_Tags", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Identity_Keys", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Identity_Xml", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_EventConsumerState", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.States.EFState", b => + { + b.Property("DocumentId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Document") + .HasColumnType("nvarchar(max)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("DocumentId"); + + b.ToTable("States_Names", (string)null); + }); + + modelBuilder.Entity("Squidex.Infrastructure.UsageTracking.EFUsageCounterEntity", b => + { + b.Property("Key") + .HasColumnType("nvarchar(450)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Category") + .HasColumnType("nvarchar(450)"); + + b.Property("CounterKey") + .HasColumnType("nvarchar(450)"); + + b.Property("CounterValue") + .HasColumnType("float"); + + b.HasKey("Key", "Date", "Category", "CounterKey"); + + b.ToTable("Counter", (string)null); + }); + + modelBuilder.Entity("Squidex.Messaging.EntityFramework.EFMessage", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ChannelName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("MessageData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("MessageHeaders") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("QueueName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("TimeHandled") + .HasColumnType("datetime2"); + + b.Property("TimeToLive") + .HasColumnType("datetime2"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ChannelName", "TimeHandled"); + + b.ToTable("Messages", (string)null); + }); + + modelBuilder.Entity("Squidex.Messaging.EntityFramework.EFMessagingDataEntity", b => + { + b.Property("Group") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Expiration") + .HasColumnType("datetime2"); + + b.Property("ValueData") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("ValueFormat") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("Group", "Key"); + + b.HasIndex("Expiration"); + + b.ToTable("MessagingData", (string)null); + }); + + modelBuilder.Entity("YDotNet.Server.EntityFramework.YDotNetDocument", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Expiration") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("YDotNetDocument", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.cs new file mode 100644 index 0000000000..c9e06384fa --- /dev/null +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260921120852_AddScriptLogs.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Squidex.Providers.SqlServer.App.Migrations +{ + /// + public partial class AddScriptLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ScriptLogs", + columns: table => new + { + Id = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + AppId = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + Name = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + Timestamp = table.Column(type: "datetimeoffset", nullable: false), + Entries = table.Column(type: "nvarchar(max)", nullable: false), + TotalEntries = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScriptLogs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ScriptLogs_AppId_Timestamp", + table: "ScriptLogs", + columns: new[] { "AppId", "Timestamp" }); + + migrationBuilder.CreateIndex( + name: "IX_ScriptLogs_Timestamp", + table: "ScriptLogs", + column: "Timestamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ScriptLogs"); + } + } +} diff --git a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs index feccd05207..63eab56a7a 100644 --- a/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs +++ b/backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs @@ -369,6 +369,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AssetKeyValueStore_TusMetadata", (string)null); }); + modelBuilder.Entity("Squidex.Domain.Apps.Core.Scripting.ScriptLogRecord", b => + { + b.Property("Id") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("AppId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Entries") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("TotalEntries") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AppId", "Timestamp"); + + b.ToTable("ScriptLogs", (string)null); + }); + modelBuilder.Entity("Squidex.Domain.Apps.Entities.Apps.EFAppEntity", b => { b.Property("DocumentId") diff --git a/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs b/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs index b550a36446..a738c596f8 100644 --- a/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs +++ b/backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs @@ -37,6 +37,8 @@ using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Repositories; +using Squidex.Domain.Apps.Entities.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; using Squidex.Domain.Apps.Entities.Teams; using Squidex.Domain.Apps.Entities.Teams.Repositories; using Squidex.Domain.Users; @@ -237,6 +239,9 @@ private static void AddSquidexEntityFramework(this IS services.AddSingletonAs>() .As().As(); + services.AddSingletonAs>() + .As(); + services.AddSingletonAs>() .As(); diff --git a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs new file mode 100644 index 0000000000..170e65cf3e --- /dev/null +++ b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using MongoDB.Bson.Serialization; +using Squidex.Domain.Apps.Core.Scripting; + +namespace Squidex.Domain.Apps.Entities.Scripting; + +internal static class MongoScriptLogClassMap +{ + public static void RegisterClassMap() + { + BsonClassMap.TryRegisterClassMap(cm => + { + cm.MapProperty(x => x.Level) + .SetElementName("l"); + + cm.MapProperty(x => x.Message) + .SetElementName("m"); + + cm.MapCreator(x => new ScriptLogEntry(x.Level, x.Message)); + }); + } +} diff --git a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogRepository.cs b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogRepository.cs new file mode 100644 index 0000000000..4f3062f7aa --- /dev/null +++ b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogRepository.cs @@ -0,0 +1,109 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text.RegularExpressions; +using MongoDB.Bson; +using MongoDB.Driver; +using NodaTime; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Scripting; + +public sealed class MongoScriptLogRepository(IMongoDatabase database) : MongoRepositoryBase(database), IScriptLogRepository +{ + protected override string CollectionName() + { + return "ScriptLogs"; + } + + protected override Task SetupCollectionAsync(IMongoCollection collection, + CancellationToken ct) + { + return collection.Indexes.CreateManyAsync( + [ + new CreateIndexModel( + Index + .Ascending(x => x.AppId) + .Descending(x => x.Timestamp)), + new CreateIndexModel( + Index + .Ascending(x => x.Timestamp)), + ], ct); + } + + public Task InsertManyAsync(IEnumerable records, + CancellationToken ct = default) + { + Guard.NotNull(records); + + var entities = records.ToList(); + + if (entities.Count == 0) + { + return Task.CompletedTask; + } + + return Collection.InsertManyAsync(entities, InsertUnordered, ct); + } + + public async Task TrimAsync(DomainId appId, int maxCount, + CancellationToken ct = default) + { + // Find the newest record that exceeds the limit. Everything that is not newer is deleted. + var boundary = + await Collection.Find(x => x.AppId == appId) + .SortByDescending(x => x.Timestamp) + .Skip(maxCount) + .Limit(1) + .Project(x => x.Timestamp) + .ToListAsync(ct); + + if (boundary.Count == 0) + { + return; + } + + var timestamp = boundary[0]; + + await Collection.DeleteManyAsync(x => x.AppId == appId && x.Timestamp <= timestamp, ct); + } + + public Task DeleteAsync(DomainId appId, + CancellationToken ct = default) + { + return Collection.DeleteManyAsync(x => x.AppId == appId, ct); + } + + public Task DeleteOlderThanAsync(Instant timestamp, + CancellationToken ct = default) + { + return Collection.DeleteManyAsync(x => x.Timestamp < timestamp, ct); + } + + public async Task> QueryAsync(DomainId appId, string? name, int skip, int take, + CancellationToken ct = default) + { + var filter = Filter.Eq(x => x.AppId, appId); + + if (!string.IsNullOrWhiteSpace(name)) + { + // The name is a path, therefore the filter matches all logs in the same group. + filter &= Filter.Regex(x => x.Name, new BsonRegularExpression($"^{Regex.Escape(name)}")); + } + + var result = + await Collection.Find(filter) + .SortByDescending(x => x.Timestamp) + .Skip(skip) + .Limit(take) + .ToListAsync(ct); + + return result; + } +} diff --git a/backend/src/Squidex.Data.MongoDb/MongoClientFactory.cs b/backend/src/Squidex.Data.MongoDb/MongoClientFactory.cs index 586f8f3237..2fbab841f9 100644 --- a/backend/src/Squidex.Data.MongoDb/MongoClientFactory.cs +++ b/backend/src/Squidex.Data.MongoDb/MongoClientFactory.cs @@ -16,6 +16,7 @@ using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.MongoDb; +using Squidex.Domain.Apps.Entities.Scripting; using Squidex.Domain.Users; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; @@ -48,6 +49,7 @@ public static void SetupSerializer(JsonSerializerOptions jsonSerializerOptions, MongoAssetFolderEntity.RegisterClassMap(); MongoContentEntity.RegisterClassMap(); MongoHistoryClassMap.RegisterClassMap(); + MongoScriptLogClassMap.RegisterClassMap(); MongoIdentityClassMap.RegisterClassMap(); MongoTextStateClassMap.RegisterClassMap(); diff --git a/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs b/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs index 335c436ec5..6b5da55e54 100644 --- a/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs +++ b/backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs @@ -41,6 +41,8 @@ using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Repositories; +using Squidex.Domain.Apps.Entities.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; using Squidex.Domain.Apps.Entities.Teams; using Squidex.Domain.Apps.Entities.Teams.Repositories; using Squidex.Domain.Users; @@ -156,6 +158,9 @@ public static void AddSquidexMongoStore(this IServiceCollection services, IConfi services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As>(); diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs index 379d5f4ffb..198b36d5dc 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs @@ -53,6 +53,7 @@ public sealed class Roles WithoutPrefix(PermissionIds.AppRolesRead), WithoutPrefix(PermissionIds.AppRules), WithoutPrefix(PermissionIds.AppSchemas), + WithoutPrefix(PermissionIds.AppScriptLogs), WithoutPrefix(PermissionIds.AppWorkflows)), JsonValue.Object()), }; diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs index 5685e196be..b858f9ec8d 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs @@ -75,6 +75,12 @@ internal static string ScriptingComplete { } } + internal static string ScriptingConsoleLog { + get { + return ResourceManager.GetString("ScriptingConsoleLog", resourceCulture); + } + } + internal static string ScriptingContentAction { get { return ResourceManager.GetString("ScriptingContentAction", resourceCulture); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx index 92545f9660..1402c73d8d 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx @@ -132,6 +132,9 @@ Completes the script when an async method is used. + + Writes the values to the script log. The log is shown in the script logs and in the rule execution log. + The status of the content. diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs new file mode 100644 index 0000000000..7f81292055 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text; +using Jint; +using Jint.Native; +using Jint.Native.Json; +using Jint.Runtime.Interop; +using Microsoft.Extensions.Logging; +using Squidex.Domain.Apps.Core.Properties; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Scripting.Extensions; + +public sealed class ConsoleJintExtension(ILogger log, IScriptLogStore? scriptLogStore = null) : IJintExtension, IScriptDescriptor +{ + private static readonly string[] Levels = ["log", "info", "warn", "error"]; + + public void Extend(Engine engine) + { + var console = new JsObject(engine); + + foreach (var level in Levels) + { + console.Set(level, new ClrFunction(engine, level, (_, args) => + { + Log(engine, level, args); + return JsValue.Undefined; + })); + } + + engine.SetValue("console", console); + } + + private void Log(Engine engine, string level, JsValue[] args) + { + var context = engine.GetContext(); + + var scriptLog = ScriptLog.Current; + if (scriptLog == null && !log.IsEnabled(LogLevel.Debug)) + { + return; + } + + var message = Format(engine, args); + + if (scriptLog != null) + { + // The log is written once when the scope is completed. + if (!scriptLog.HasEntries && scriptLog.Persist && scriptLogStore != null && context.TryGetValueIfExists("appId", out var appId)) + { + scriptLog.OnCompleted(completed => scriptLogStore.Log(appId, completed.Name, completed)); + } + + scriptLog.Add(level, message); + } + + if (log.IsEnabled(LogLevel.Debug)) + { + context.TryGetValueIfExists("appName", out var appName); + + log.LogDebug("Script {name} in app {appName} logged with console.{level}: {message}", + scriptLog?.Name, + appName, + level, + message); + } + } + + private static string Format(Engine engine, JsValue[] args) + { + var sb = new StringBuilder(); + + foreach (var arg in args) + { + if (sb.Length > 0) + { + sb.Append(' '); + } + + sb.Append(Format(engine, arg)); + + // Protect the memory, the log truncates the message anyway. + if (sb.Length > ScriptLog.MaxLength) + { + break; + } + } + + return sb.ToString(); + } + + private static string Format(Engine engine, JsValue value) + { + if (value.IsString() || value.IsUndefined() || value.IsNull() || value.IsNumber() || value.IsBoolean()) + { + return value.ToString(); + } + + try + { + var json = new JsonSerializer(engine).Serialize(value); + + return json.IsString() ? json.ToString() : value.ToString(); + } + catch + { + // Cyclic objects cannot be serialized, therefore we fall back to the default representation. + return value.ToString(); + } + } + + public void Describe(AddDescription describe, ScriptScope scope) + { + describe(JsonType.Function, "console.log(value)", + Resources.ScriptingConsoleLog); + + describe(JsonType.Function, "console.info(value)", + Resources.ScriptingConsoleLog); + + describe(JsonType.Function, "console.warn(value)", + Resources.ScriptingConsoleLog); + + describe(JsonType.Function, "console.error(value)", + Resources.ScriptingConsoleLog); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptLogStore.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptLogStore.cs new file mode 100644 index 0000000000..81e13d72b1 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptLogStore.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Scripting; + +public interface IScriptLogStore +{ + void Log(DomainId appId, string name, ScriptLog log); + + Task> QueryAsync(DomainId appId, string? name, int skip, int take, + CancellationToken ct = default); +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs new file mode 100644 index 0000000000..ebe049f6cc --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs @@ -0,0 +1,146 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Scripting; + +public sealed class ScriptLog : IDisposable +{ + public const int MaxEntries = 100; + public const int MaxLength = 1000; + private static readonly AsyncLocal CurrentLog = new AsyncLocal(); + private readonly List entries = []; + private readonly List> completedCallbacks = []; + private readonly ScriptLog? previous; + private int totalEntries; + + public string Name { get; } + + public bool Persist { get; } + + public static ScriptLog? Current + { + get => CurrentLog.Value; + } + + public bool HasEntries + { + get + { + lock (entries) + { + return entries.Count > 0; + } + } + } + + private ScriptLog(string name, bool persist, ScriptLog? previous) + { + Name = name; + Persist = persist; + + this.previous = previous; + } + + public static ScriptLog Begin(string name, bool persist = false) + { + Guard.NotNullOrEmpty(name); + + var log = new ScriptLog(name, persist, CurrentLog.Value); + + CurrentLog.Value = log; + return log; + } + + public void Dispose() + { + if (CurrentLog.Value == this) + { + CurrentLog.Value = previous; + } + + Action[] callbacks; + + lock (entries) + { + callbacks = completedCallbacks.ToArray(); + completedCallbacks.Clear(); + } + + foreach (var callback in callbacks) + { + callback(this); + } + } + + public void OnCompleted(Action callback) + { + lock (entries) + { + completedCallbacks.Add(callback); + } + } + + public void Add(string level, string message) + { + // Scripts can log in loops, therefore the log is limited to protect the memory. + if (message.Length > MaxLength) + { + message = $"{message[..MaxLength]}..."; + } + + lock (entries) + { + totalEntries++; + + if (entries.Count < MaxEntries) + { + entries.Add(new ScriptLogEntry(level, message)); + } + } + } + + public IReadOnlyList ToLines() + { + lock (entries) + { + var lines = entries.Select(x => x.ToString()).ToList(); + + if (totalEntries > entries.Count) + { + lines.Add($"... {totalEntries - entries.Count} more entries omitted."); + } + + return lines; + } + } + + public (IReadOnlyList Entries, int TotalEntries) Snapshot() + { + lock (entries) + { + return (entries.ToList(), totalEntries); + } + } + + public static async Task CollectAsync(string name, Func> action) + { + using var log = Begin(name, true); + + return await action(); + } + + public static Task CollectAsync(string name, Func action) + { + return CollectAsync(name, async () => + { + await action(); + return true; + }); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs new file mode 100644 index 0000000000..6ae5c00644 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Core.Scripting; + +public sealed record ScriptLogEntry(string Level, string Message) +{ + public override string ToString() + { + return $"console.{Level}: {Message}"; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogRecord.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogRecord.cs new file mode 100644 index 0000000000..5e7c1a1fe1 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogRecord.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Scripting; + +public sealed class ScriptLogRecord +{ + public DomainId Id { get; set; } = DomainId.NewGuid(); + + public DomainId AppId { get; set; } + + public string Name { get; set; } + + public Instant Timestamp { get; set; } + + public List Entries { get; set; } = []; + + public int TotalEntries { get; set; } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs index 517ed512a4..57cbbad4ab 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs @@ -70,7 +70,7 @@ public static async Task ExecuteCreateScriptAsync(this AssetOperation operation, Tags = create.Tags?.ToReadonlyList(), }; - await ExecuteScriptAsync(operation, script, vars, asset, ct); + await ExecuteScriptAsync(operation, "create", script, vars, asset, ct); } public static Task ExecuteUpdateScriptAsync(this AssetOperation operation, UpdateAsset update, @@ -100,7 +100,7 @@ public static Task ExecuteUpdateScriptAsync(this AssetOperation operation, Updat Operation = "Update", }; - return ExecuteScriptAsync(operation, script, vars, null, ct); + return ExecuteScriptAsync(operation, "update", script, vars, null, ct); } public static Task ExecuteAnnotateScriptAsync(this AssetOperation operation, AnnotateAsset annotate, @@ -128,7 +128,7 @@ public static Task ExecuteAnnotateScriptAsync(this AssetOperation operation, Ann Operation = "Annotate", }; - return ExecuteScriptAsync(operation, script, vars, null, ct); + return ExecuteScriptAsync(operation, "annotate", script, vars, null, ct); } public static async Task ExecuteMoveScriptAsync(this AssetOperation operation, MoveAsset move, @@ -154,7 +154,7 @@ public static async Task ExecuteMoveScriptAsync(this AssetOperation operation, M Operation = "Move", }; - await ExecuteScriptAsync(operation, script, vars, null, ct); + await ExecuteScriptAsync(operation, "move", script, vars, null, ct); } public static Task ExecuteDeleteScriptAsync(this AssetOperation operation, DeleteAsset delete, @@ -177,10 +177,10 @@ public static Task ExecuteDeleteScriptAsync(this AssetOperation operation, Delet Operation = "Delete", }; - return ExecuteScriptAsync(operation, script, vars, null, ct); + return ExecuteScriptAsync(operation, "delete", script, vars, null, ct); } - private static async Task ExecuteScriptAsync(AssetOperation operation, string script, AssetScriptVars vars, AssetEntityScriptVars? asset, + private static async Task ExecuteScriptAsync(AssetOperation operation, string name, string script, AssetScriptVars vars, AssetEntityScriptVars? asset, CancellationToken ct) { var snapshot = operation.Snapshot; @@ -210,7 +210,10 @@ private static async Task ExecuteScriptAsync(AssetOperation operation, string sc var scriptEngine = operation.Resolve(); - await scriptEngine.ExecuteAsync(vars, script, Options, ct); + await ScriptLog.CollectAsync($"assets/{name}", async () => + { + await scriptEngine.ExecuteAsync(vars, script, Options, ct); + }); } private static async Task GetPathAsync(AssetOperation operation, DomainId parentId, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs index 7406407009..36c8534334 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs @@ -41,7 +41,7 @@ public static Task ExecuteCreateScriptAsync(this ContentOperation o StatusOld = default, }); - return TransformAsync(operation, script, vars, ct); + return TransformAsync(operation, "create", script, vars, ct); } public static Task ExecuteUpdateScriptAsync(this ContentOperation operation, ContentData data, @@ -66,7 +66,7 @@ public static Task ExecuteUpdateScriptAsync(this ContentOperation o StatusOld = default, }); - return TransformAsync(operation, script, vars, ct); + return TransformAsync(operation, "update", script, vars, ct); } public static Task ExecuteChangeScriptAsync(this ContentOperation operation, Status status, StatusChange change, @@ -92,7 +92,7 @@ public static Task ExecuteChangeScriptAsync(this ContentOperation o Validate = Validate(operation, status), }); - return TransformAsync(operation, script, vars, ct); + return TransformAsync(operation, "change", script, vars, ct); } public static Task ExecuteDeleteScriptAsync(this ContentOperation operation, bool permanent, @@ -118,19 +118,25 @@ public static Task ExecuteDeleteScriptAsync(this ContentOperation operation, boo StatusOld = default, }); - return ExecuteAsync(operation, script, vars, ct); + return ExecuteAsync(operation, "delete", script, vars, ct); } - private static async Task TransformAsync(ContentOperation operation, string script, ContentScriptVars vars, + private static async Task TransformAsync(ContentOperation operation, string name, string script, ContentScriptVars vars, CancellationToken ct) { - return await operation.Resolve().TransformAsync(vars, script, Options, ct); + return await ScriptLog.CollectAsync($"contents/{operation.Schema.Name}/{name}", async () => + { + return await operation.Resolve().TransformAsync(vars, script, Options, ct); + }); } - private static async Task ExecuteAsync(ContentOperation operation, string script, ContentScriptVars vars, + private static async Task ExecuteAsync(ContentOperation operation, string name, string script, ContentScriptVars vars, CancellationToken ct) { - await operation.Resolve().ExecuteAsync(vars, script, Options, ct); + await ScriptLog.CollectAsync($"contents/{operation.Schema.Name}/{name}", async () => + { + await operation.Resolve().ExecuteAsync(vars, script, Options, ct); + }); } private static Action Validate(ContentOperation operation, Status status) diff --git a/backend/src/Squidex.Domain.Apps.Entities/LogMessages.cs b/backend/src/Squidex.Domain.Apps.Entities/LogMessages.cs index 44d7ed2a50..5359dff42b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/LogMessages.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/LogMessages.cs @@ -89,4 +89,13 @@ internal static partial class LogMessages [LoggerMessage(Level = LogLevel.Error, Message = "Failed to execute search from source {source} with query '{query}'.")] public static partial void LogFailedToExecuteSearch(ILogger logger, string source, string query, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to write script logs in background.")] + public static partial void LogFailedToWriteScriptLogs(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to clean up script logs.")] + public static partial void LogFailedToCleanupScriptLogs(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Dropped {count} script logs, because the pending queue is full.")] + public static partial void LogScriptLogsDropped(ILogger logger, int count); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Scripting/BackgroundScriptLogStore.cs b/backend/src/Squidex.Domain.Apps.Entities/Scripting/BackgroundScriptLogStore.cs new file mode 100644 index 0000000000..4384d3b10a --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Scripting/BackgroundScriptLogStore.cs @@ -0,0 +1,208 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NodaTime; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Timers; + +namespace Squidex.Domain.Apps.Entities.Scripting; + +public sealed class BackgroundScriptLogStore : DisposableObjectBase, IScriptLogStore, IDeleter +{ + private readonly IScriptLogRepository repository; + private readonly ILogger log; + private readonly CompletionTimer timer; + private readonly ScriptLogOptions options; + private readonly ConcurrentQueue jobs = new ConcurrentQueue(); + private Instant lastCleanup; + private int jobsCount; + private int jobsDropped; + private bool isUpdating; + + public IClock Clock { get; set; } = SystemClock.Instance; + + public bool HasPendingJobs => !jobs.IsEmpty || isUpdating; + + public BackgroundScriptLogStore(IOptions options, + IScriptLogRepository repository, ILogger log) + { + this.options = options.Value; + this.repository = repository; + this.timer = new CompletionTimer(options.Value.WriteIntervall, TrackAsync, options.Value.WriteIntervall); + this.log = log; + } + + protected override void DisposeObject(bool disposing) + { + if (disposing) + { + timer.StopAsync().Wait(); + } + } + + public void Next() + { + ThrowIfDisposed(); + + timer.SkipCurrentDelay(); + } + + Task IDeleter.DeleteAppAsync(App app, + CancellationToken ct) + { + return repository.DeleteAsync(app.Id, ct); + } + + public void Log(DomainId appId, string name, ScriptLog log) + { + Guard.NotNull(log); + Guard.NotNullOrEmpty(name); + + if (!options.Enabled || !log.HasEntries) + { + return; + } + + // The queue is only drained by a timer. If the repository is not available for a longer time + // the queue would grow until the process runs out of memory, so new entries are dropped. + if (Volatile.Read(ref jobsCount) >= options.MaxPendingItems) + { + Interlocked.Increment(ref jobsDropped); + return; + } + + var (entries, totalEntries) = log.Snapshot(); + + Interlocked.Increment(ref jobsCount); + + jobs.Enqueue(new ScriptLogRecord + { + AppId = appId, + Name = name, + Entries = entries.ToList(), + TotalEntries = totalEntries, + Timestamp = Clock.GetCurrentInstant(), + }); + } + + public Task> QueryAsync(DomainId appId, string? name, int skip, int take, + CancellationToken ct = default) + { + if (!options.Enabled) + { + return Task.FromResult>([]); + } + + // More items than the limit are never stored, therefore the query is also limited. + return repository.QueryAsync( + appId, + name, + Math.Max(0, skip), + Math.Clamp(take, 0, options.MaxItemsPerApp), + ct); + } + + private async Task TrackAsync( + CancellationToken ct) + { + if (!options.Enabled) + { + return; + } + + isUpdating = true; + try + { + await WriteAsync(ct); + await CleanupAsync(ct); + } + finally + { + isUpdating = false; + } + } + + private async Task WriteAsync( + CancellationToken ct) + { + if (jobs.IsEmpty) + { + return; + } + + try + { + // Report the entries that have been dropped since the last run, so that the gap is visible. + var dropped = Interlocked.Exchange(ref jobsDropped, 0); + + if (dropped > 0) + { + LogMessages.LogScriptLogsDropped(log, dropped); + } + + var batch = new List(options.BatchSize); + var apps = new HashSet(); + + while (jobs.TryDequeue(out var dequeued)) + { + Interlocked.Decrement(ref jobsCount); + + batch.Add(dequeued); + apps.Add(dequeued.AppId); + + if (batch.Count >= options.BatchSize) + { + await repository.InsertManyAsync(batch.ToList(), ct); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + await repository.InsertManyAsync(batch, ct); + } + + // Only the apps that got new logs can exceed the limit. + foreach (var appId in apps) + { + await repository.TrimAsync(appId, options.MaxItemsPerApp, ct); + } + } + catch (Exception ex) + { + LogMessages.LogFailedToWriteScriptLogs(log, ex); + } + } + + private async Task CleanupAsync( + CancellationToken ct) + { + var now = Clock.GetCurrentInstant(); + + if (now - lastCleanup < Duration.FromTimeSpan(options.CleanupIntervall)) + { + return; + } + + lastCleanup = now; + try + { + // Apps that do not log anymore are not trimmed, therefore old logs are removed separately. + await repository.DeleteOlderThanAsync(now.Minus(Duration.FromDays(options.RetentionInDays)), ct); + } + catch (Exception ex) + { + LogMessages.LogFailedToCleanupScriptLogs(log, ex); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Scripting/Repositories/IScriptLogRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Scripting/Repositories/IScriptLogRepository.cs new file mode 100644 index 0000000000..aacb1d08a6 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Scripting/Repositories/IScriptLogRepository.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Scripting.Repositories; + +public interface IScriptLogRepository +{ + Task InsertManyAsync(IEnumerable records, + CancellationToken ct = default); + + Task TrimAsync(DomainId appId, int maxCount, + CancellationToken ct = default); + + Task DeleteAsync(DomainId appId, + CancellationToken ct = default); + + Task DeleteOlderThanAsync(Instant timestamp, + CancellationToken ct = default); + + Task> QueryAsync(DomainId appId, string? name, int skip, int take, + CancellationToken ct = default); +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Scripting/ScriptLogOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Scripting/ScriptLogOptions.cs new file mode 100644 index 0000000000..ec086264e6 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Scripting/ScriptLogOptions.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Scripting; + +public sealed class ScriptLogOptions +{ + public bool Enabled { get; set; } = true; + + public int MaxItemsPerApp { get; set; } = 100; + + public int RetentionInDays { get; set; } = 7; + + public int BatchSize { get; set; } = 500; + + public int WriteIntervall { get; set; } = 1000; + + public TimeSpan CleanupIntervall { get; set; } = TimeSpan.FromMinutes(10); + + public int MaxPendingItems { get; set; } = 10_000; +} diff --git a/backend/src/Squidex.Shared/PermissionIds.cs b/backend/src/Squidex.Shared/PermissionIds.cs index 49b57cbe83..c341b724f0 100644 --- a/backend/src/Squidex.Shared/PermissionIds.cs +++ b/backend/src/Squidex.Shared/PermissionIds.cs @@ -182,6 +182,10 @@ public static class PermissionIds public const string AppAssetSScriptsRead = "squidex.apps.{app}.asset-scripts.read"; public const string AppAssetsScriptsUpdate = "squidex.apps.{app}.asset-scripts.update"; + // App Script Logs + public const string AppScriptLogs = "squidex.apps.{app}.script-logs"; + public const string AppScriptLogsRead = "squidex.apps.{app}.script-logs.read"; + // App Rules public const string AppRules = "squidex.apps.{app}.rules"; public const string AppRulesRead = "squidex.apps.{app}.rules.read"; diff --git a/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogDto.cs b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogDto.cs new file mode 100644 index 0000000000..3a2f0bbcbb --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogDto.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.ScriptLogs.Models; + +public sealed class ScriptLogDto +{ + /// + /// The ID of the log. + /// + public DomainId Id { get; set; } + + /// + /// The name of the script, for example 'contents/my-schema/create' or 'assets/annotate'. + /// + public string Name { get; set; } + + /// + /// The time when the script has been executed. + /// + public Instant Timestamp { get; set; } + + /// + /// The log entries. + /// + public ScriptLogEntryDto[] Entries { get; set; } + + /// + /// The total number of entries the script has logged. Can be greater than the number of stored entries, because only the first entries are kept. + /// + public int TotalEntries { get; set; } + + public static ScriptLogDto FromDomain(ScriptLogRecord record) + { + return SimpleMapper.Map(record, new ScriptLogDto + { + Entries = record.Entries.Select(ScriptLogEntryDto.FromDomain).ToArray(), + }); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs new file mode 100644 index 0000000000..0963617f84 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.ScriptLogs.Models; + +public sealed class ScriptLogEntryDto +{ + /// + /// The log level, for example 'log', 'info', 'warn' or 'error'. + /// + public string Level { get; set; } + + /// + /// The logged message. + /// + public string Message { get; set; } + + public static ScriptLogEntryDto FromDomain(ScriptLogEntry entry) + { + return SimpleMapper.Map(entry, new ScriptLogEntryDto()); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogsDto.cs new file mode 100644 index 0000000000..3d0fb177ce --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogsDto.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Scripting; + +namespace Squidex.Areas.Api.Controllers.ScriptLogs.Models; + +public sealed class ScriptLogsDto +{ + /// + /// The script logs, newest first. + /// + public ScriptLogDto[] Items { get; set; } + + public static ScriptLogsDto FromDomain(IReadOnlyList logs) + { + return new ScriptLogsDto { Items = logs.Select(ScriptLogDto.FromDomain).ToArray() }; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/ScriptLogsController.cs b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/ScriptLogsController.cs new file mode 100644 index 0000000000..be1013eb19 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/ScriptLogsController.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using Squidex.Areas.Api.Controllers.ScriptLogs.Models; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Infrastructure.Commands; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.ScriptLogs; + +/// +/// Readonly API to get the console output of scripts. +/// +[ApiExplorerSettings(GroupName = nameof(ScriptLogs))] +public sealed class ScriptLogsController(ICommandBus commandBus, IScriptLogStore scriptLogStore) : ApiController(commandBus) +{ + /// + /// Get the script logs. + /// + /// The name of the app. + /// The optional name or name prefix of the script, for example 'contents/my-schema'. + /// The number of logs to skip. + /// The number of logs to return. + /// Script logs returned. + /// App not found. + /// + /// Returns the console output of content and asset scripts, newest first. Only the latest logs are stored. + /// + [HttpGet] + [Route("apps/{app}/script-logs/")] + [ProducesResponseType(typeof(ScriptLogsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(PermissionIds.AppScriptLogsRead)] + [ApiCosts(0.1)] + public async Task GetScriptLogs(string app, [FromQuery] string? name = null, [FromQuery] int skip = 0, [FromQuery] int take = 20) + { + var logs = await scriptLogStore.QueryAsync(AppId, name, skip, take, HttpContext.RequestAborted); + + var response = ScriptLogsDto.FromDomain(logs); + + return Ok(response); + } +} diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index c256f7fe22..90e4ac525d 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -17,7 +17,9 @@ using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Core.Templates; using Squidex.Domain.Apps.Core.Templates.Extensions; +using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Contents.Counter; +using Squidex.Domain.Apps.Entities.Scripting; using Squidex.Domain.Apps.Entities.Tags; using Squidex.Hosting.Ssrf; using Squidex.Infrastructure; @@ -43,6 +45,9 @@ public static void AddSquidexInfrastructure(this IServiceCollection services, IC services.Configure(config, "scripting"); + services.Configure(config, + "scripting:logs"); + services.Configure(config, "diagnostics"); @@ -59,6 +64,9 @@ public static void AddSquidexInfrastructure(this IServiceCollection services, IC services.AddSingletonAs() .AsOptional(); + services.AddSingletonAs() + .As().As(); + services.AddSingletonAs() .AsSelf(); @@ -77,6 +85,9 @@ public static void AddSquidexInfrastructure(this IServiceCollection services, IC services.AddSingletonAs() .As().As(); + services.AddSingletonAs() + .As().As(); + services.AddSingletonAs() .As().As(); diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index 4bac0683fb..5994644db2 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -152,7 +152,18 @@ "timeoutScript": "00:00:00.200", // The timeout for the asynchronous promise of the script. - "timeoutPromise": "00:00:04" + "timeoutPromise": "00:00:04", + + "logs": { + // False to disable storing the console output of content and asset scripts. + "enabled": true, + + // The number of script logs that are kept per app. Older logs are deleted. + "maxItemsPerApp": 100, + + // The number of days script logs are kept. + "retentionInDays": 7 + } }, "languages": { diff --git a/backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Scripting/EFScriptLogRepositoryTests.cs b/backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Scripting/EFScriptLogRepositoryTests.cs new file mode 100644 index 0000000000..0289312dfb --- /dev/null +++ b/backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Scripting/EFScriptLogRepositoryTests.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.EntityFrameworkCore; +using Squidex.Domain.Apps.Entities.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; +using Squidex.EntityFramework.TestHelpers; +using Squidex.Shared; + +namespace Squidex.EntityFramework.Domain.Scripting; + +public abstract class EFScriptLogRepositoryTests(ISqlFixture fixture) : ScriptLogRepositoryTests where TContext : DbContext +{ + protected override Task CreateSutAsync() + { + var sut = new EFScriptLogRepository(fixture.DbContextFactory); + + return Task.FromResult(sut); + } +} diff --git a/backend/tests/Squidex.Data.Tests/MongoDb/Domain/Scripting/MongoScriptLogRepositoryTests.cs b/backend/tests/Squidex.Data.Tests/MongoDb/Domain/Scripting/MongoScriptLogRepositoryTests.cs new file mode 100644 index 0000000000..c3d1c22022 --- /dev/null +++ b/backend/tests/Squidex.Data.Tests/MongoDb/Domain/Scripting/MongoScriptLogRepositoryTests.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; +using Squidex.MongoDb.TestHelpers; +using Squidex.Shared; + +namespace Squidex.MongoDb.Domain.Scripting; + +[Trait("Category", "TestContainer")] +[Collection(MongoFixtureCollection.Name)] +public class MongoScriptLogRepositoryTests(MongoFixture fixture) : ScriptLogRepositoryTests +{ + protected override async Task CreateSutAsync() + { + var sut = new MongoScriptLogRepository(fixture.Database); + + await sut.InitializeAsync(default); + return sut; + } +} diff --git a/backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs b/backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs new file mode 100644 index 0000000000..2780e6b23c --- /dev/null +++ b/backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs @@ -0,0 +1,167 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; +using Squidex.Infrastructure; + +namespace Squidex.Shared; + +public abstract class ScriptLogRepositoryTests +{ + private readonly Instant now = Instant.FromUnixTimeSeconds(SystemClock.Instance.GetCurrentInstant().ToUnixTimeSeconds()); + + protected abstract Task CreateSutAsync(); + + [Fact] + public async Task Should_insert_and_query_logs() + { + var sut = await CreateSutAsync(); + + var appId = DomainId.NewGuid(); + + var record = CreateRecord(appId, "contents/my-schema/create", 0); + + await sut.InsertManyAsync([record]); + + var actual = await sut.QueryAsync(appId, null, 0, 10); + + actual.Should().BeEquivalentTo([record]); + } + + [Fact] + public async Task Should_query_newest_first() + { + var sut = await CreateSutAsync(); + + var appId = DomainId.NewGuid(); + + await sut.InsertManyAsync( + [ + CreateRecord(appId, "assets/create", 1), + CreateRecord(appId, "assets/create", 3), + CreateRecord(appId, "assets/create", 2), + ]); + + var actual = await sut.QueryAsync(appId, null, 1, 10); + + Assert.Equal([now.Plus(Duration.FromSeconds(2)), now.Plus(Duration.FromSeconds(1))], actual.Select(x => x.Timestamp)); + } + + [Fact] + public async Task Should_query_by_name_prefix() + { + var sut = await CreateSutAsync(); + + var appId = DomainId.NewGuid(); + + await sut.InsertManyAsync( + [ + CreateRecord(appId, "contents/schema1/create", 1), + CreateRecord(appId, "contents/schema1/update", 2), + CreateRecord(appId, "contents/schema2/create", 3), + CreateRecord(appId, "assets/create", 4), + ]); + + var actual = await sut.QueryAsync(appId, "contents/schema1", 0, 10); + + Assert.Equal(["contents/schema1/update", "contents/schema1/create"], actual.Select(x => x.Name)); + } + + [Fact] + public async Task Should_trim_to_max_count() + { + var sut = await CreateSutAsync(); + + var appId = DomainId.NewGuid(); + var appIdOther = DomainId.NewGuid(); + + await sut.InsertManyAsync(Enumerable.Range(0, 10).Select(i => CreateRecord(appId, "assets/create", i))); + await sut.InsertManyAsync(Enumerable.Range(0, 10).Select(i => CreateRecord(appIdOther, "assets/create", i))); + + await sut.TrimAsync(appId, 3); + + var actual = await sut.QueryAsync(appId, null, 0, 100); + + Assert.Equal( + [ + now.Plus(Duration.FromSeconds(9)), + now.Plus(Duration.FromSeconds(8)), + now.Plus(Duration.FromSeconds(7)), + ], actual.Select(x => x.Timestamp)); + + var other = await sut.QueryAsync(appIdOther, null, 0, 100); + + Assert.Equal(10, other.Count); + } + + [Fact] + public async Task Should_not_trim_if_below_max_count() + { + var sut = await CreateSutAsync(); + + var appId = DomainId.NewGuid(); + + await sut.InsertManyAsync(Enumerable.Range(0, 3).Select(i => CreateRecord(appId, "assets/create", i))); + + await sut.TrimAsync(appId, 3); + + var actual = await sut.QueryAsync(appId, null, 0, 100); + + Assert.Equal(3, actual.Count); + } + + [Fact] + public async Task Should_delete_old_logs() + { + var sut = await CreateSutAsync(); + + var appId = DomainId.NewGuid(); + + await sut.InsertManyAsync(Enumerable.Range(0, 5).Select(i => CreateRecord(appId, "assets/create", i))); + + await sut.DeleteOlderThanAsync(now.Plus(Duration.FromSeconds(3))); + + var actual = await sut.QueryAsync(appId, null, 0, 100); + + Assert.Equal(2, actual.Count); + } + + [Fact] + public async Task Should_delete_logs_of_app() + { + var sut = await CreateSutAsync(); + + var appId = DomainId.NewGuid(); + + await sut.InsertManyAsync(Enumerable.Range(0, 5).Select(i => CreateRecord(appId, "assets/create", i))); + + await sut.DeleteAsync(appId); + + var actual = await sut.QueryAsync(appId, null, 0, 100); + + Assert.Empty(actual); + } + + private ScriptLogRecord CreateRecord(DomainId appId, string name, int seconds) + { + return new ScriptLogRecord + { + AppId = appId, + Name = name, + Entries = + [ + new ScriptLogEntry("log", "Hello"), + new ScriptLogEntry("warn", "World"), + ], + TotalEntries = 5, + Timestamp = now.Plus(Duration.FromSeconds(seconds)), + }; + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs index a474b2caff..be7ee568a3 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/RolesTests.cs @@ -147,7 +147,7 @@ public void Should_check_for_non_default_role() Assert.False(Roles.IsDefault(firstRole)); } - [InlineData("Developer", 6)] + [InlineData("Developer", 7)] [InlineData("Editor", 4)] [InlineData("Reader", 2)] [InlineData("Owner", 1)] @@ -169,7 +169,7 @@ public void Should_get_default_roles(string name, int permissionCount) Assert.Equal(permissionCount, actual!.Permissions.Count); } - [InlineData("Developer", 15)] + [InlineData("Developer", 16)] [InlineData("Editor", 13)] [InlineData("Reader", 12)] [InlineData("Owner", 1)] diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs new file mode 100644 index 0000000000..fd15a8d66f --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs @@ -0,0 +1,246 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Scripting.Extensions; +using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Domain.Apps.Core.Operations.Scripting; + +public class ConsoleJintExtensionTests : IClassFixture +{ + private readonly IScriptLogStore scriptLogStore = A.Fake(); + private readonly DomainId appId = DomainId.NewGuid(); + private readonly IScriptEngine sut; + + public ConsoleJintExtensionTests() + { + var extensions = new IJintExtension[] + { + new ConsoleJintExtension(NullLogger.Instance, scriptLogStore), + }; + + sut = new JintScriptEngine(new MemoryCache(Options.Create(new MemoryCacheOptions())), + Options.Create(new JintScriptOptions + { + TimeoutScript = TimeSpan.FromSeconds(2), + TimeoutExecution = TimeSpan.FromSeconds(10), + }), + extensions); + } + + [Fact] + public void Should_collect_log_entries() + { + const string script = @" + console.log('Hello', 42, true, null, undefined, { a: 1 }, [1, 2]); + console.info('Info'); + console.warn('Warn'); + console.error('Error'); + "; + + using var log = ScriptLog.Begin("my-script"); + + sut.Execute(new ScriptVars(), script); + + Assert.Equal( + [ + "console.log: Hello 42 true null undefined {\"a\":1} [1,2]", + "console.info: Info", + "console.warn: Warn", + "console.error: Error", + ], log.ToLines()); + } + + [Fact] + public async Task Should_collect_log_entries_from_async_script() + { + var vars = new ScriptVars + { + ["value"] = "Hello", + }; + + const string script = @" + console.log(value); + "; + + using var log = ScriptLog.Begin("my-script"); + + await sut.ExecuteAsync(vars, script); + + Assert.Equal(["console.log: Hello"], log.ToLines()); + } + + [Fact] + public void Should_not_fail_without_scope() + { + const string script = @" + console.log('Hello'); + 42; + "; + + var actual = sut.Execute(new ScriptVars(), script).AsNumber; + + Assert.Equal(42, actual); + } + + [Fact] + public void Should_limit_number_of_entries() + { + const string script = @" + for (var i = 0; i < 150; i++) { + console.log(i); + } + "; + + using var log = ScriptLog.Begin("my-script"); + + sut.Execute(new ScriptVars(), script); + + var lines = log.ToLines(); + + Assert.Equal(ScriptLog.MaxEntries + 1, lines.Count); + Assert.Equal("... 50 more entries omitted.", lines[^1]); + Assert.Equal(150, log.Snapshot().TotalEntries); + } + + [Fact] + public void Should_truncate_long_entries() + { + const string script = @" + console.log('a'.repeat(2000)); + "; + + using var log = ScriptLog.Begin("my-script"); + + sut.Execute(new ScriptVars(), script); + + var line = Assert.Single(log.ToLines()); + + Assert.Equal($"console.log: {new string('a', ScriptLog.MaxLength)}...", line); + } + + [Fact] + public void Should_restore_previous_scope() + { + using var outer = ScriptLog.Begin("my-script"); + + using (var inner = ScriptLog.Begin("my-script")) + { + Assert.Same(inner, ScriptLog.Current); + } + + Assert.Same(outer, ScriptLog.Current); + } + + [Fact] + public async Task Should_not_add_log_entries_to_validation_error() + { + const string script = @" + console.log('Hello'); + reject('Failed'); + "; + + var ex = await Assert.ThrowsAsync(() => ScriptLog.CollectAsync("my-script", async () => + { + await sut.ExecuteAsync(new ScriptVars(), script, new ScriptOptions { CanReject = true }); + })); + + Assert.Equal(["Failed"], ex.Errors.Select(x => x.Message)); + } + + [Fact] + public async Task Should_store_log_of_named_scope() + { + var vars = new ScriptVars + { + ["appId"] = appId, + }; + + const string script = @" + console.log('Hello'); + console.log('World'); + "; + + await ScriptLog.CollectAsync("my-script", async () => + { + await sut.ExecuteAsync(vars, script); + }); + + A.CallTo(() => scriptLogStore.Log(appId, "my-script", A.That.Matches(x => x.ToLines().Count == 2))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_store_log_if_script_failed() + { + var vars = new ScriptVars + { + ["appId"] = appId, + }; + + const string script = @" + console.log('Hello'); + reject('Failed'); + "; + + await Assert.ThrowsAsync(() => ScriptLog.CollectAsync("my-script", async () => + { + await sut.ExecuteAsync(vars, script, new ScriptOptions { CanReject = true }); + })); + + A.CallTo(() => scriptLogStore.Log(appId, "my-script", A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_not_store_log_without_entries() + { + var vars = new ScriptVars + { + ["appId"] = appId, + }; + + const string script = @" + 42; + "; + + await ScriptLog.CollectAsync("my-script", async () => + { + await sut.ExecuteAsync(vars, script); + }); + + A.CallTo(() => scriptLogStore.Log(A._, A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public void Should_not_store_log_if_not_persisted() + { + var vars = new ScriptVars + { + ["appId"] = appId, + }; + + const string script = @" + console.log('Hello'); + "; + + using (ScriptLog.Begin("my-script")) + { + sut.Execute(vars, script); + } + + A.CallTo(() => scriptLogStore.Log(A._, A._, A._)) + .MustNotHaveHappened(); + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs new file mode 100644 index 0000000000..b621b714a2 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs @@ -0,0 +1,201 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NodaTime; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Scripting.Repositories; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Scripting; + +public class BackgroundScriptLogStoreTests : GivenContext +{ + private readonly IScriptLogRepository repository = A.Fake(); + private readonly IClock clock = A.Fake(); + private readonly ScriptLogOptions options = new ScriptLogOptions(); + private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); + private readonly BackgroundScriptLogStore sut; + + public BackgroundScriptLogStoreTests() + { + A.CallTo(() => clock.GetCurrentInstant()) + .Returns(now); + + sut = new BackgroundScriptLogStore(Options.Create(options), repository, A.Fake>()) + { + Clock = clock, + }; + } + + [Fact] + public async Task Should_write_log_with_context() + { + var records = new List(); + + A.CallTo(() => repository.InsertManyAsync(A>._, A._)) + .Invokes(x => records.AddRange(x.GetArgument>(0)!)); + + sut.Log(AppId.Id, "contents/my-schema/create", CreateLog("Hello")); + + await WaitForCompletion(); + + var record = Assert.Single(records); + + Assert.Equal(AppId.Id, record.AppId); + Assert.Equal("contents/my-schema/create", record.Name); + Assert.Equal(now, record.Timestamp); + Assert.Equal([new ScriptLogEntry("log", "Hello")], record.Entries); + Assert.Equal(1, record.TotalEntries); + } + + [Fact] + public async Task Should_trim_logs_of_written_apps() + { + var otherAppId = DomainId.NewGuid(); + + sut.Log(AppId.Id, "assets/create", CreateLog("Hello")); + sut.Log(AppId.Id, "assets/create", CreateLog("Hello")); + sut.Log(otherAppId, "assets/create", CreateLog("Hello")); + + await WaitForCompletion(); + + A.CallTo(() => repository.TrimAsync(AppId.Id, options.MaxItemsPerApp, A._)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => repository.TrimAsync(otherAppId, options.MaxItemsPerApp, A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_delete_old_logs() + { + sut.Log(AppId.Id, "assets/create", CreateLog("Hello")); + + await WaitForCompletion(); + + A.CallTo(() => repository.DeleteOlderThanAsync(now.Minus(Duration.FromDays(options.RetentionInDays)), A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_not_write_log_without_entries() + { + using var log = ScriptLog.Begin("my-script"); + + sut.Log(AppId.Id, "assets/create", log); + + await WaitForCompletion(); + + A.CallTo(() => repository.InsertManyAsync(A>._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_write_log_if_disabled() + { + options.Enabled = false; + + sut.Log(AppId.Id, "assets/create", CreateLog("Hello")); + + await WaitForCompletion(); + + A.CallTo(() => repository.InsertManyAsync(A>._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_drop_logs_if_queue_is_full() + { + options.MaxPendingItems = 2; + + var records = new List(); + + A.CallTo(() => repository.InsertManyAsync(A>._, A._)) + .Invokes(x => records.AddRange(x.GetArgument>(0)!)); + + for (var i = 0; i < 5; i++) + { + sut.Log(AppId.Id, "assets/create", CreateLog("Hello")); + } + + await WaitForCompletion(); + + Assert.Equal(2, records.Count); + } + + [Fact] + public async Task Should_query_logs_from_repository() + { + var records = new List { new ScriptLogRecord() }; + + A.CallTo(() => repository.QueryAsync(AppId.Id, "contents", 10, 20, CancellationToken)) + .Returns(records); + + var actual = await sut.QueryAsync(AppId.Id, "contents", 10, 20, CancellationToken); + + Assert.Same(records, actual); + } + + [Fact] + public async Task Should_limit_query_to_max_items() + { + await sut.QueryAsync(AppId.Id, null, -5, 1000, CancellationToken); + + A.CallTo(() => repository.QueryAsync(AppId.Id, null, 0, options.MaxItemsPerApp, CancellationToken)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_query_logs_if_disabled() + { + options.Enabled = false; + + var actual = await sut.QueryAsync(AppId.Id, null, 0, 20, CancellationToken); + + Assert.Empty(actual); + + A.CallTo(() => repository.QueryAsync(A._, A._, A._, A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_delete_logs_if_app_is_deleted() + { + await ((IDeleter)sut).DeleteAppAsync(App, CancellationToken); + + A.CallTo(() => repository.DeleteAsync(AppId.Id, CancellationToken)) + .MustHaveHappened(); + } + + private static ScriptLog CreateLog(string message) + { + using var log = ScriptLog.Begin("my-script"); + + log.Add("log", message); + + return log; + } + + private async Task WaitForCompletion() + { + sut.Next(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + while (sut.HasPendingJobs) + { + cts.Token.ThrowIfCancellationRequested(); + + await Task.Delay(20, cts.Token); + } + + sut.Dispose(); + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddLanguage_should_create_events_and_add_language.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddLanguage_should_create_events_and_add_language.verified.txt index ac040795d7..43cdd8da38 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddLanguage_should_create_events_and_add_language.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddLanguage_should_create_events_and_add_language.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddRole_should_create_events_and_add_role.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddRole_should_create_events_and_add_role.verified.txt index a5d20fd2f7..dc8c4e5053 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddRole_should_create_events_and_add_role.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddRole_should_create_events_and_add_role.verified.txt @@ -82,6 +82,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddWorkflow_should_create_events_and_add_workflow.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddWorkflow_should_create_events_and_add_workflow.verified.txt index 7a7d5096ca..57ab507715 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddWorkflow_should_create_events_and_add_workflow.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AddWorkflow_should_create_events_and_add_workflow.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_events_and_add_contributor.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_events_and_add_contributor.verified.txt index c3c4e65aec..a8622eab35 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_events_and_add_contributor.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_events_and_add_contributor.verified.txt @@ -72,6 +72,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_update_events_and_update_contributor.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_update_events_and_update_contributor.verified.txt index 8066bf2981..569bda08f9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_update_events_and_update_contributor.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AssignContributor_should_create_update_events_and_update_contributor.verified.txt @@ -72,6 +72,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AttachClient_should_create_events_and_add_client.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AttachClient_should_create_events_and_add_client.verified.txt index 74e8c18a3d..a1632a80b3 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AttachClient_should_create_events_and_add_client.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.AttachClient_should_create_events_and_add_client.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_create_events_and_update_plan.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_create_events_and_update_plan.verified.txt index 1426eefd7c..d962be566f 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_create_events_and_update_plan.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_create_events_and_update_plan.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_reset_plan_for_free_plan.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_reset_plan_for_free_plan.verified.txt index 6089f817d5..3bb0569a21 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_reset_plan_for_free_plan.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_from_callback_should_reset_plan_for_free_plan.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_create_events_and_update_plan.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_create_events_and_update_plan.verified.txt index 1426eefd7c..d962be566f 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_create_events_and_update_plan.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_create_events_and_update_plan.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_call_billing_manager_for_callback.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_call_billing_manager_for_callback.verified.txt index 1426eefd7c..d962be566f 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_call_billing_manager_for_callback.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_call_billing_manager_for_callback.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_make_update_for_redirect.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_make_update_for_redirect.verified.txt index 9301c32f81..136e43e73d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_make_update_for_redirect.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_not_make_update_for_redirect.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_reset_plan_for_free_plan.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_reset_plan_for_free_plan.verified.txt index 6089f817d5..3bb0569a21 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_reset_plan_for_free_plan.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.ChangePlan_should_reset_plan_for_free_plan.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_create_events_and_set_intitial_state.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_create_events_and_set_intitial_state.verified.txt index e6b6b94720..19aa1c7fa1 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_create_events_and_set_intitial_state.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_create_events_and_set_intitial_state.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_not_assign_client_as_contributor.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_not_assign_client_as_contributor.verified.txt index e0a1d371f0..d2d3025949 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_not_assign_client_as_contributor.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Create_should_not_assign_client_as_contributor.verified.txt @@ -68,6 +68,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteRole_should_create_events_and_delete_role.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteRole_should_create_events_and_delete_role.verified.txt index d6305ce14e..2ce1a6d4db 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteRole_should_create_events_and_delete_role.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteRole_should_create_events_and_delete_role.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteWorkflow_should_create_events_and_remove_workflow.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteWorkflow_should_create_events_and_remove_workflow.verified.txt index 37b40b78ce..b8ab55eebc 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteWorkflow_should_create_events_and_remove_workflow.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.DeleteWorkflow_should_create_events_and_remove_workflow.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_and_update_deleted_flag.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_and_update_deleted_flag.verified.txt index 644c1917a7..5e0a07dd2d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_and_update_deleted_flag.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_and_update_deleted_flag.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_with_permanent_flag.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_with_permanent_flag.verified.txt index 959f48fcbf..310de6ca08 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_with_permanent_flag.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Delete_should_create_events_with_permanent_flag.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt index daee59acf2..4121231e1a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RegenerateClientSecret_should_create_events_and_update_secret.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveContributor_should_create_events_and_remove_contributor.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveContributor_should_create_events_and_remove_contributor.verified.txt index b565806104..a2c904d3e6 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveContributor_should_create_events_and_remove_contributor.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveContributor_should_create_events_and_remove_contributor.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveImage_should_create_events_and_update_image.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveImage_should_create_events_and_update_image.verified.txt index 6089f817d5..3bb0569a21 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveImage_should_create_events_and_update_image.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveImage_should_create_events_and_update_image.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveLanguage_should_create_events_and_remove_language.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveLanguage_should_create_events_and_remove_language.verified.txt index 6e6ebcceb4..dc23b8f142 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveLanguage_should_create_events_and_remove_language.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RemoveLanguage_should_create_events_and_remove_language.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RevokeClient_should_create_events_and_remove_client.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RevokeClient_should_create_events_and_remove_client.verified.txt index abba6bb5c8..9cc90a23e9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RevokeClient_should_create_events_and_remove_client.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.RevokeClient_should_create_events_and_remove_client.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_from_team_should_create_events_and_set_team.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_from_team_should_create_events_and_set_team.verified.txt index 6089f817d5..3bb0569a21 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_from_team_should_create_events_and_set_team.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_from_team_should_create_events_and_set_team.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_should_create_events_and_set_team.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_should_create_events_and_set_team.verified.txt index 10238f91eb..3579053221 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_should_create_events_and_set_team.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Transfer_should_create_events_and_set_team.verified.txt @@ -72,6 +72,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateClient_should_create_events_and_update_client.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateClient_should_create_events_and_update_client.verified.txt index 275ae2e341..050ec53585 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateClient_should_create_events_and_update_client.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateClient_should_create_events_and_update_client.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateLanguage_should_create_events_and_update_language.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateLanguage_should_create_events_and_update_language.verified.txt index 4aa3570348..aeded89f57 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateLanguage_should_create_events_and_update_language.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateLanguage_should_create_events_and_update_language.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateRole_should_create_events_and_update_role.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateRole_should_create_events_and_update_role.verified.txt index 96894bbe45..9c585e7802 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateRole_should_create_events_and_update_role.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateRole_should_create_events_and_update_role.verified.txt @@ -92,6 +92,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateSettings_should_create_event_and_update_settings.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateSettings_should_create_event_and_update_settings.verified.txt index bf8386aaf7..dc9e6ae5c3 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateSettings_should_create_event_and_update_settings.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateSettings_should_create_event_and_update_settings.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateWorkflow_should_create_events_and_update_workflow.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateWorkflow_should_create_events_and_update_workflow.verified.txt index 65a5ac74ce..739ff4fb66 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateWorkflow_should_create_events_and_update_workflow.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UpdateWorkflow_should_create_events_and_update_workflow.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Update_should_create_events_and_update_label_and_description.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Update_should_create_events_and_update_label_and_description.verified.txt index 227c534beb..cfc82aa5be 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Update_should_create_events_and_update_label_and_description.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.Update_should_create_events_and_update_label_and_description.verified.txt @@ -73,6 +73,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UploadImage_should_create_events_and_update_image.verified.txt b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UploadImage_should_create_events_and_update_image.verified.txt index 6d075d669b..1057901b3a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UploadImage_should_create_events_and_update_image.verified.txt +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Verify/AppDomainObjectTests.UploadImage_should_create_events_and_update_image.verified.txt @@ -71,6 +71,9 @@ { Id: schemas }, + { + Id: script-logs + }, { Id: workflows } diff --git a/todo.md b/todo.md index 3ade678cb9..fcd1fdd340 100644 --- a/todo.md +++ b/todo.md @@ -36,7 +36,7 @@ being postponed and declined. See | [3](#3-scope-asset-permissions-to-folders-5921-5676-1229) | Scope asset permissions to folders | Open ×2 + Declined | 3857 | large | | [4](#4-dark-mode-and-per-app-theming-5720-816) | Dark mode and per-app theming | Open + Declined | 2102 | large | | [5](#5-azure-service-bus-flow-step-4522) | ✅ **Done** — Azure Service Bus flow step | Postponed | 1838 | medium | -| [6](#6-give-scripts-a-way-to-log-2822) | Give scripts a way to log | Unresolved | 2575 | medium | +| [6](#6-give-scripts-a-way-to-log-2822) | ✅ **Done** — Give scripts a way to log | Unresolved | 2575 | medium | | [7](#7-upsert-mode-for-the-create-content-flow-step-5616) | ✅ **Done** — Upsert mode for "Create content" step | Unresolved | 786 | small | | [8](#8-regenerate-a-client-secret-from-the-ui-5807) | ✅ **Done** — Regenerate a client secret from the UI | Open | 1383 | small | | [9](#9-show-which-fields-are-required-when-publishing-4375) | ✅ **Done** — Mark "required when publishing" fields | Declined | 1707 | tiny | @@ -285,6 +285,7 @@ actions keep a thin `IConvertibleToAction` shim for migration only. ## 6. Give scripts a way to log (#2822) +> ✅ **Implemented.** Scripts get `console.log/info/warn/error` (`ConsoleJintExtension`). Each run collects its output in a named `ScriptLog` scope (max 100 stored entries of 1000 chars, plus the total count). The Script flow step writes it to the step log. Content and asset scripts persist it via `IScriptLogStore` in the background (latest 100 logs per app, 7 days), readable with `GET apps/{app}/script-logs` and the `script-logs.read` permission. Logs are never added to API errors, because they can contain sensitive data. Everything also goes to `ILogger` at debug level. **Asked:** `console.log` in scripting, and documentation of what `ctx` holds. From 0f45b404769428770f4ea70589787583c7a9d2cc Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 21 Sep 2026 15:34:10 +0200 Subject: [PATCH 6/8] Backend and UI --- .../Actions/Script/ScriptFlowStep.cs | 15 +- backend/i18n/frontend_de.json | 8 + backend/i18n/frontend_en.json | 8 + backend/i18n/frontend_fr.json | 8 + backend/i18n/frontend_it.json | 8 + backend/i18n/frontend_nl.json | 8 + backend/i18n/frontend_pt.json | 8 + backend/i18n/frontend_zh.json | 8 + backend/i18n/source/frontend_en.json | 8 + .../Scripting/MongoScriptLogClassMap.cs | 5 +- .../Extensions/EventJintExtension.cs | 49 ---- .../Properties/Resources.Designer.cs | 6 + .../Properties/Resources.resx | 3 + .../Extensions/ConsoleJintExtension.cs | 19 +- .../Scripting/ScriptLog.cs | 3 +- .../Scripting/ScriptLogEntry.cs | 4 +- .../Scripting/ScriptingCompleter.cs | 3 +- .../Api/Controllers/Apps/Models/AppDto.cs | 7 + .../ScriptLogs/Models/ScriptLogEntryDto.cs | 6 + .../Shared/ScriptLogRepositoryTests.cs | 4 +- .../Squidex.Data.Tests.csproj | 8 +- .../Scripting/ConsoleJintExtensionTests.cs | 60 +++++ .../Scripting/ScriptingCompleterTests.cs | 16 ++ .../BackgroundScriptLogStoreTests.cs | 2 +- .../Squidex.Domain.Apps.Entities.Tests.csproj | 2 +- .../script-logs/script-log.component.html | 36 +++ .../script-logs/script-log.component.scss | 68 +++++ .../pages/script-logs/script-log.component.ts | 46 ++++ .../script-logs-page.component.html | 33 +++ .../script-logs-page.component.scss | 6 + .../script-logs/script-logs-page.component.ts | 47 ++++ frontend/src/app/features/settings/routes.ts | 5 + .../settings/settings-menu.component.html | 6 + .../settings/settings-menu.component.scss | 8 + .../teams/shared/settings-area.component.scss | 8 +- frontend/src/app/shared/internal.ts | 2 + frontend/src/app/shared/model/custom.ts | 4 + frontend/src/app/shared/model/generated.ts | 239 ++++++++++++++++++ .../services/script-logs.service.spec.ts | 89 +++++++ .../shared/services/script-logs.service.ts | 38 +++ .../shared/state/script-logs.state.spec.ts | 95 +++++++ .../src/app/shared/state/script-logs.state.ts | 88 +++++++ todo.md | 2 +- 43 files changed, 1014 insertions(+), 82 deletions(-) create mode 100644 frontend/src/app/features/settings/pages/script-logs/script-log.component.html create mode 100644 frontend/src/app/features/settings/pages/script-logs/script-log.component.scss create mode 100644 frontend/src/app/features/settings/pages/script-logs/script-log.component.ts create mode 100644 frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.html create mode 100644 frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.scss create mode 100644 frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.ts create mode 100644 frontend/src/app/shared/services/script-logs.service.spec.ts create mode 100644 frontend/src/app/shared/services/script-logs.service.ts create mode 100644 frontend/src/app/shared/state/script-logs.state.spec.ts create mode 100644 frontend/src/app/shared/state/script-logs.state.ts diff --git a/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs b/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs index 76ba1a540a..68101d18de 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Script/ScriptFlowStep.cs @@ -7,7 +7,6 @@ using System.ComponentModel.DataAnnotations; using Squidex.Domain.Apps.Core.Rules.Deprecated; -using Squidex.Domain.Apps.Core.Scripting; using Squidex.Flows; using Squidex.Infrastructure.Reflection; @@ -34,19 +33,7 @@ public override async ValueTask ExecuteAsync(FlowExecutionContex { if (!string.IsNullOrWhiteSpace(Script)) { - using var log = ScriptLog.Begin("flows/script"); - try - { - await executionContext.RenderAsync($"Script({Script})", executionContext.Context); - } - finally - { - // Also write the log when the script fails, because this is when it is needed the most. - foreach (var line in log.ToLines()) - { - executionContext.Log(line); - } - } + await executionContext.RenderAsync($"Script({Script})", executionContext.Context); } return Next(); diff --git a/backend/i18n/frontend_de.json b/backend/i18n/frontend_de.json index fdc2b27411..2b033dea4f 100644 --- a/backend/i18n/frontend_de.json +++ b/backend/i18n/frontend_de.json @@ -394,6 +394,7 @@ "common.saveEdit": "Speichern & bearbeiten", "common.schema": "Schema", "common.schemas": "Schemas", + "common.scriptLogs": "Script Logs", "common.search": "Suchen", "common.searchGoogleMaps": "Google Maps durchsuchen", "common.searchResults": "Suchergebnisse", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "Fehler beim Aktualisieren der UI-Felder. Bitte neu laden.", "schemas.validateOnPublish": "Beim Veröffentlichen validieren", "schemas.validateOnPublishHint": "Verwenden Sie benutzerdefinierte Workflows, wenn Sie mehr Kontrolle darüber benötigen, für welche Status ein Inhalt validiert werden soll.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "Filter hinzufügen", "search.addGroup": "Gruppe hinzufügen", "search.addSorting": "Sortierung hinzufügen", diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index c1794543b5..dd054103c7 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -394,6 +394,7 @@ "common.saveEdit": "Save & editor", "common.schema": "Schema", "common.schemas": "Schemas", + "common.scriptLogs": "Script Logs", "common.search": "Search", "common.searchGoogleMaps": "Search Google Maps", "common.searchResults": "Search Results", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "Failed to update UI fields. Please reload.", "schemas.validateOnPublish": "Validate when publishing", "schemas.validateOnPublishHint": "Use custom workflows when you need more control for which statuses a content should be validated.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "Add Filter", "search.addGroup": "Add Group", "search.addSorting": "Add Sorting", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index 9a5d681086..9eb2ce5bd7 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -394,6 +394,7 @@ "common.saveEdit": "Save & editor", "common.schema": "Schéma", "common.schemas": "Schémas", + "common.scriptLogs": "Script Logs", "common.search": "Recherche", "common.searchGoogleMaps": "Rechercher Google Maps", "common.searchResults": "Résultats de recherche", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "Échec de la mise à jour des champs de l'interface utilisateur. Veuillez recharger.", "schemas.validateOnPublish": "Valider lors de la publication", "schemas.validateOnPublishHint": "Utilisez des flux de travail personnalisés lorsque vous avez besoin de plus de contrôle sur les statuts pour lesquels un contenu doit être validé.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "Ajouter un filtre", "search.addGroup": "Ajouter un groupe", "search.addSorting": "Ajouter un tri", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 7bc544b05c..c7d2f25357 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -394,6 +394,7 @@ "common.saveEdit": "Save & editor", "common.schema": "Schema", "common.schemas": "Schemi", + "common.scriptLogs": "Script Logs", "common.search": "Search", "common.searchGoogleMaps": "Cerca su Google Maps", "common.searchResults": "Risultati di ricerca", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "Non è stato possibile aggiornare i campi della UI. Per favore ricarica.", "schemas.validateOnPublish": "Valida quando pubblichi", "schemas.validateOnPublishHint": "Use custom workflows when you need more control for which statuses a content should be validated.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "Aggiungi un Filtro", "search.addGroup": "Aggiungi un Gruppo", "search.addSorting": "Aggiungi ordinamento", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 7fed3fd00f..2b6f9072ca 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -394,6 +394,7 @@ "common.saveEdit": "Save & editor", "common.schema": "Schema", "common.schemas": "Schema's", + "common.scriptLogs": "Script Logs", "common.search": "Zoeken", "common.searchGoogleMaps": "Zoeken in Google Maps", "common.searchResults": "Zoekresultaten", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "Bijwerken van UI-velden is mislukt. Laad opnieuw.", "schemas.validateOnPublish": "Valideren bij het publiceren", "schemas.validateOnPublishHint": "Use custom workflows when you need more control for which statuses a content should be validated.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "Filter toevoegen", "search.addGroup": "Groep toevoegen", "search.addSorting": "Sortering toevoegen", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 32e692c65d..58c72caf85 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -394,6 +394,7 @@ "common.saveEdit": "Save & editor", "common.schema": "Esquema", "common.schemas": "Esquemas", + "common.scriptLogs": "Script Logs", "common.search": "Pesquisar", "common.searchGoogleMaps": "Pesquisar Google Maps", "common.searchResults": "Resultados da pesquisa", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "Falhou na atualização dos campos de UI. Por favor, recarregue.", "schemas.validateOnPublish": "Validar ao publicar", "schemas.validateOnPublishHint": "Utilize fluxos de trabalho personalizados quando necessitar de mais controlo para os estatutos que um conteúdo deve ser validado.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "Adicionar filtro", "search.addGroup": "Adicionar Grupo", "search.addSorting": "Adicionar Classificação", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 6fe983cae3..aaa8640d7f 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -394,6 +394,7 @@ "common.saveEdit": "Save & editor", "common.schema": "Schema", "common.schemas": "Schemas", + "common.scriptLogs": "Script Logs", "common.search": "搜索", "common.searchGoogleMaps": "搜索谷歌地图", "common.searchResults": "搜索结果", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "无法更新 UI 字段。请重新加载。", "schemas.validateOnPublish": "发布时验证", "schemas.validateOnPublishHint": "Use custom workflows when you need more control for which statuses a content should be validated.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "添加过滤器", "search.addGroup": "添加组", "search.addSorting": "添加排序", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index c1794543b5..dd054103c7 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -394,6 +394,7 @@ "common.saveEdit": "Save & editor", "common.schema": "Schema", "common.schemas": "Schemas", + "common.scriptLogs": "Script Logs", "common.search": "Search", "common.searchGoogleMaps": "Search Google Maps", "common.searchResults": "Search Results", @@ -1135,6 +1136,13 @@ "schemas.updateUIFieldsFailed": "Failed to update UI fields. Please reload.", "schemas.validateOnPublish": "Validate when publishing", "schemas.validateOnPublishHint": "Use custom workflows when you need more control for which statuses a content should be validated.", + "scriptLogs.description": "The console output of content and asset scripts. Only the latest logs are kept.", + "scriptLogs.empty": "No script logs found.", + "scriptLogs.filterPlaceholder": "Filter by script, e.g. contents/my-schema", + "scriptLogs.loadFailed": "Failed to load script logs. Please reload.", + "scriptLogs.omitted": "{count} more entries have been omitted.", + "scriptLogs.refreshTooltip": "Refresh script logs", + "scriptLogs.reloaded": "Script logs reloaded.", "search.addFilter": "Add Filter", "search.addGroup": "Add Group", "search.addSorting": "Add Sorting", diff --git a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs index 170e65cf3e..afea4f207f 100644 --- a/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs +++ b/backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Scripting/MongoScriptLogClassMap.cs @@ -16,13 +16,16 @@ public static void RegisterClassMap() { BsonClassMap.TryRegisterClassMap(cm => { + cm.MapProperty(x => x.Timestamp) + .SetElementName("t"); + cm.MapProperty(x => x.Level) .SetElementName("l"); cm.MapProperty(x => x.Message) .SetElementName("m"); - cm.MapCreator(x => new ScriptLogEntry(x.Level, x.Message)); + cm.MapCreator(x => new ScriptLogEntry(x.Timestamp, x.Level, x.Message)); }); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs index a58160614a..1af6cd9533 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs @@ -19,57 +19,8 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens { private delegate JsValue EventDelegate(); - private sealed class FlowConsoleWrapper - { - public static readonly FlowConsoleWrapper Instance = new FlowConsoleWrapper(); - -#pragma warning disable CA1822 // Mark members as static - private void LogCore(string? message, string prefix) -#pragma warning restore CA1822 // Mark members as static - { - if (string.IsNullOrWhiteSpace(message)) - { - return; - } - - if (!string.IsNullOrWhiteSpace(prefix)) - { - message = $"{prefix}: {message}"; - } - - FlowConsole.Out(message); - } - - public void Log(string message) - { - LogCore(message, string.Empty); - } - - public void Info(string message) - { - LogCore(message, "INFO"); - } - - public void Warn(string message) - { - LogCore(message, "WARN"); - } - - public void Error(string message) - { - LogCore(message, "ERROR"); - } - - public void Debug(string message) - { - LogCore(message, "DEBUG"); - } - } - public void Extend(Engine engine) { - engine.SetValue("console", FlowConsoleWrapper.Instance); - engine.SetValue("contentAction", new EventDelegate(() => { if (engine.TryGetVar("event", out var contentEvent)) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs index b858f9ec8d..9a8e0bb626 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs @@ -75,6 +75,12 @@ internal static string ScriptingComplete { } } + internal static string ScriptingConsole { + get { + return ResourceManager.GetString("ScriptingConsole", resourceCulture); + } + } + internal static string ScriptingConsoleLog { get { return ResourceManager.GetString("ScriptingConsoleLog", resourceCulture); diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx index 1402c73d8d..ec52f0bc4f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx @@ -132,6 +132,9 @@ Completes the script when an async method is used. + + Writes values to the script log. + Writes the values to the script log. The log is shown in the script logs and in the rule execution log. diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs index 7f81292055..8e6fdb7ad2 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/ConsoleJintExtension.cs @@ -12,13 +12,14 @@ using Jint.Runtime.Interop; using Microsoft.Extensions.Logging; using Squidex.Domain.Apps.Core.Properties; +using Squidex.Flows; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting.Extensions; public sealed class ConsoleJintExtension(ILogger log, IScriptLogStore? scriptLogStore = null) : IJintExtension, IScriptDescriptor { - private static readonly string[] Levels = ["log", "info", "warn", "error"]; + private static readonly string[] Levels = ["log", "info", "warn", "error", "debug"]; public void Extend(Engine engine) { @@ -40,13 +41,15 @@ private void Log(Engine engine, string level, JsValue[] args) { var context = engine.GetContext(); - var scriptLog = ScriptLog.Current; - if (scriptLog == null && !log.IsEnabled(LogLevel.Debug)) + var message = Format(engine, args); + + // Rules capture the output per flow step, the output is ignored outside of flows. + if (!string.IsNullOrWhiteSpace(message)) { - return; + FlowConsole.Out(level == "log" ? message : $"{level.ToUpperInvariant()}: {message}"); } - var message = Format(engine, args); + var scriptLog = ScriptLog.Current; if (scriptLog != null) { @@ -116,6 +119,9 @@ private static string Format(Engine engine, JsValue value) public void Describe(AddDescription describe, ScriptScope scope) { + describe(JsonType.Object, "console", + Resources.ScriptingConsole); + describe(JsonType.Function, "console.log(value)", Resources.ScriptingConsoleLog); @@ -127,5 +133,8 @@ public void Describe(AddDescription describe, ScriptScope scope) describe(JsonType.Function, "console.error(value)", Resources.ScriptingConsoleLog); + + describe(JsonType.Function, "console.debug(value)", + Resources.ScriptingConsoleLog); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs index ebe049f6cc..620cf71c7c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLog.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using NodaTime; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting; @@ -100,7 +101,7 @@ public void Add(string level, string message) if (entries.Count < MaxEntries) { - entries.Add(new ScriptLogEntry(level, message)); + entries.Add(new ScriptLogEntry(SystemClock.Instance.GetCurrentInstant(), level, message)); } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs index 6ae5c00644..40c4a23640 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptLogEntry.cs @@ -7,9 +7,11 @@ #pragma warning disable SA1313 // Parameter names should begin with lower-case letter +using NodaTime; + namespace Squidex.Domain.Apps.Core.Scripting; -public sealed record ScriptLogEntry(string Level, string Message) +public sealed record ScriptLogEntry(Instant Timestamp, string Level, string Message) { public override string ToString() { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs index 0b4d6b57a8..b6e546c59b 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompleter.cs @@ -534,7 +534,8 @@ private void PushPrefix(string name) } } - [GeneratedRegex("^(?!\\d)[\\w$]+$", RegexOptions.Compiled)] + // Functions of objects are described with their parameters, e.g. 'console.log(value)'. + [GeneratedRegex("^(?!\\d)[\\w$]+(?:\\(.*\\))?$", RegexOptions.Compiled)] private static partial Regex BuildPropertyRegex(); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 58bb138d1c..a35550f6fb 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -12,6 +12,7 @@ using Squidex.Areas.Api.Controllers.Plans; using Squidex.Areas.Api.Controllers.Rules; using Squidex.Areas.Api.Controllers.Schemas; +using Squidex.Areas.Api.Controllers.ScriptLogs; using Squidex.Domain.Apps.Core.Apps; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; @@ -187,6 +188,12 @@ private AppDto CreateLinks(App app, Resources resources, PermissionSet permissio resources.Url(x => nameof(x.GetJobs), values)); } + if (resources.IsAllowed(PermissionIds.AppScriptLogsRead, Name, additional: permissions)) + { + AddGetLink("script-logs", + resources.Url(x => nameof(x.GetScriptLogs), values)); + } + if (resources.IsAllowed(PermissionIds.AppClientsRead, Name, additional: permissions)) { AddGetLink("clients", diff --git a/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs index 0963617f84..832190d5ed 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/ScriptLogs/Models/ScriptLogEntryDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using NodaTime; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Infrastructure.Reflection; @@ -12,6 +13,11 @@ namespace Squidex.Areas.Api.Controllers.ScriptLogs.Models; public sealed class ScriptLogEntryDto { + /// + /// The time when the entry has been logged. + /// + public Instant Timestamp { get; set; } + /// /// The log level, for example 'log', 'info', 'warn' or 'error'. /// diff --git a/backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs b/backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs index 2780e6b23c..b2c9bb2b05 100644 --- a/backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs +++ b/backend/tests/Squidex.Data.Tests/Shared/ScriptLogRepositoryTests.cs @@ -157,8 +157,8 @@ private ScriptLogRecord CreateRecord(DomainId appId, string name, int seconds) Name = name, Entries = [ - new ScriptLogEntry("log", "Hello"), - new ScriptLogEntry("warn", "World"), + new ScriptLogEntry(now, "log", "Hello"), + new ScriptLogEntry(now.Plus(Duration.FromMilliseconds(5)), "warn", "World"), ], TotalEntries = 5, Timestamp = now.Plus(Duration.FromSeconds(seconds)), diff --git a/backend/tests/Squidex.Data.Tests/Squidex.Data.Tests.csproj b/backend/tests/Squidex.Data.Tests/Squidex.Data.Tests.csproj index d0c5c0e655..bcc57ee1c6 100644 --- a/backend/tests/Squidex.Data.Tests/Squidex.Data.Tests.csproj +++ b/backend/tests/Squidex.Data.Tests/Squidex.Data.Tests.csproj @@ -37,10 +37,10 @@ - - - - + + + + all diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs index fd15a8d66f..5875e536a8 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ConsoleJintExtensionTests.cs @@ -8,9 +8,11 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core.HandleRules.Extensions; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting.Extensions; using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Flows; using Squidex.Infrastructure; using Squidex.Infrastructure.Validation; @@ -158,6 +160,64 @@ public async Task Should_not_add_log_entries_to_validation_error() Assert.Equal(["Failed"], ex.Errors.Select(x => x.Message)); } + [Fact] + public void Should_write_to_flow_console() + { + const string script = @" + console.log('Log'); + console.info('Info'); + console.warn('Warn'); + console.error('Error'); + console.debug('Debug'); + "; + + var lines = new List(); + + FlowConsole.Output = (message, _) => lines.Add(message); + try + { + sut.Execute(new ScriptVars(), script); + } + finally + { + FlowConsole.Output = null!; + } + + Assert.Equal(["Log", "INFO: Info", "WARN: Warn", "ERROR: Error", "DEBUG: Debug"], lines); + } + + [Fact] + public async Task Should_not_be_replaced_by_rule_extension() + { + IScriptEngine engine = new JintScriptEngine(new MemoryCache(Options.Create(new MemoryCacheOptions())), + Options.Create(new JintScriptOptions + { + TimeoutScript = TimeSpan.FromSeconds(2), + TimeoutExecution = TimeSpan.FromSeconds(10), + }), + [ + new ConsoleJintExtension(NullLogger.Instance, scriptLogStore), + new EventJintExtension(A.Fake()), + ]); + + var vars = new ScriptVars + { + ["appId"] = appId, + }; + + const string script = @" + console.log('Hello'); + "; + + await ScriptLog.CollectAsync("my-script", async () => + { + await engine.ExecuteAsync(vars, script); + }); + + A.CallTo(() => scriptLogStore.Log(appId, "my-script", A._)) + .MustHaveHappenedOnceExactly(); + } + [Fact] public async Task Should_store_log_of_named_scope() { diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ScriptingCompleterTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ScriptingCompleterTests.cs index e911ac8faa..253c44c762 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ScriptingCompleterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ScriptingCompleterTests.cs @@ -5,10 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Microsoft.Extensions.Logging.Abstractions; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.GenerateFilters; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Scripting.Extensions; using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Core.Operations.Scripting; @@ -355,6 +357,20 @@ public void Should_describe_dynamic_usage_trigger() AssertUsageTrigger(actual); } + [Fact] + public void Should_describe_functions_of_objects_with_property_syntax() + { + var completer = new ScriptingCompleter([new ConsoleJintExtension(NullLogger.Instance)]); + + var actual = completer.ContentScript(dataSchema).Select(x => x.Path).ToArray(); + + Assert.Contains("console", actual); + Assert.Contains("console.log(value)", actual); + Assert.Contains("console.info(value)", actual); + Assert.Contains("console.warn(value)", actual); + Assert.Contains("console.error(value)", actual); + } + private static void AssertUsageTrigger(IReadOnlyList actual) { AssertCompletion(actual, diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs index b621b714a2..c05527530d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Scripting/BackgroundScriptLogStoreTests.cs @@ -51,7 +51,7 @@ public async Task Should_write_log_with_context() Assert.Equal(AppId.Id, record.AppId); Assert.Equal("contents/my-schema/create", record.Name); Assert.Equal(now, record.Timestamp); - Assert.Equal([new ScriptLogEntry("log", "Hello")], record.Entries); + Assert.Equal([("log", "Hello")], record.Entries.Select(x => (x.Level, x.Message))); Assert.Equal(1, record.TotalEntries); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index d3a7c1b1c3..d27e77d3bb 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -38,7 +38,7 @@ - + diff --git a/frontend/src/app/features/settings/pages/script-logs/script-log.component.html b/frontend/src/app/features/settings/pages/script-logs/script-log.component.html new file mode 100644 index 0000000000..aa031cf50d --- /dev/null +++ b/frontend/src/app/features/settings/pages/script-logs/script-log.component.html @@ -0,0 +1,36 @@ +
+
+
+
+

{{ scriptLog.name }}

+ +
+ {{ scriptLog.timestamp | sqxFromNow }} + {{ scriptLog.totalEntries }} +
+
+ +
+ +
+
+
+ + @if (isExpanded) { +
+
+ @for (line of lines; track $index) { +
+ {{ line.time }} [{{ line.level }}] {{ line.message }} +
+ } + + @if (omitted > 0) { +
{{ "scriptLogs.omitted" | sqxTranslate: { count: omitted } }}
+ } +
+
+ } +
diff --git a/frontend/src/app/features/settings/pages/script-logs/script-log.component.scss b/frontend/src/app/features/settings/pages/script-logs/script-log.component.scss new file mode 100644 index 0000000000..a82d2592dc --- /dev/null +++ b/frontend/src/app/features/settings/pages/script-logs/script-log.component.scss @@ -0,0 +1,68 @@ +@import 'mixins'; +@import 'vars'; + +$console-background: #1e1f22; +$console-text: #d4d4d4; +$console-muted: #7f848e; +$console-info: #4fc1ff; +$console-warn: #e5c07b; +$console-error: #f48771; + +h4 { + font-size: 1rem; + font-weight: 500; + margin: 0; +} + +.console { + background: $console-background; + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + color: $console-text; + font-family: monospace; + font-size: .85rem; + line-height: 1.5; + max-height: 30rem; + overflow-y: hidden; + overflow-y: auto; + padding: .75rem 1rem; + + &-line { + white-space: pre-wrap; + word-break: break-word; + } + + &-level { + display: inline-block; + margin-left: 1rem; + margin-right: 1rem; + min-width: 7ch; + } + + &-time, + &-debug { + color: $console-muted; + } + + &-info { + color: $console-info; + } + + &-warn { + color: $console-warn; + } + + &-error { + color: $console-error; + } + + &-omitted { + color: $console-muted; + font-style: italic; + font-weight: normal; + } +} + +.btn-expand.expanded::before { + bottom: -1.35rem; +} diff --git a/frontend/src/app/features/settings/pages/script-logs/script-log.component.ts b/frontend/src/app/features/settings/pages/script-logs/script-log.component.ts new file mode 100644 index 0000000000..b4d4b1f877 --- /dev/null +++ b/frontend/src/app/features/settings/pages/script-logs/script-log.component.ts @@ -0,0 +1,46 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { FromNowPipe, ScriptLogDto, TranslatePipe, TypedSimpleChanges } from '@app/shared'; + +@Component({ + selector: 'sqx-script-log', + styleUrls: ['./script-log.component.scss'], + templateUrl: './script-log.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FromNowPipe, + TranslatePipe, + ], +}) +export class ScriptLogComponent { + @Input({ required: true }) + public scriptLog!: ScriptLogDto; + + public lines: ReadonlyArray<{ time: string; level: string; message: string }> = []; + + public omitted = 0; + + public isExpanded = false; + + public ngOnChanges(changes: TypedSimpleChanges) { + if (changes.scriptLog) { + this.lines = this.scriptLog.entries.map(x => ({ + time: x.timestamp?.toStringFormat('yyyy-MM-dd HH:mm:ss.SSS') ?? '', + level: x.level, + message: x.message, + })); + + this.omitted = Math.max(0, this.scriptLog.totalEntries - this.scriptLog.entries.length); + } + } + + public toggleExpanded() { + this.isExpanded = !this.isExpanded; + } +} diff --git a/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.html b/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.html new file mode 100644 index 0000000000..84868e2785 --- /dev/null +++ b/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.html @@ -0,0 +1,33 @@ + + + + +
+ +
+
+ + + {{ "scriptLogs.description" | sqxTranslate }} + + @if ((scriptLogsState.isLoaded | async) && (scriptLogsState.scriptLogs | async); as scriptLogs) { + @if (scriptLogs.length === 0) { +
{{ "scriptLogs.empty" | sqxTranslate }}
+ } + + @for (scriptLog of scriptLogs; track scriptLog.id) { + + } + } +
+
+
diff --git a/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.scss b/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.scss new file mode 100644 index 0000000000..6fbfef1194 --- /dev/null +++ b/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.scss @@ -0,0 +1,6 @@ +@import 'mixins'; +@import 'vars'; + +.form-control { + width: 20rem; +} diff --git a/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.ts b/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.ts new file mode 100644 index 0000000000..6ea60fdcb1 --- /dev/null +++ b/frontend/src/app/features/settings/pages/script-logs/script-logs-page.component.ts @@ -0,0 +1,47 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { AsyncPipe } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormAlertComponent, LayoutComponent, ListViewComponent, ScriptLogsState, ShortcutDirective, TitleComponent, TranslatePipe } from '@app/shared'; +import { ScriptLogComponent } from './script-log.component'; + +@Component({ + selector: 'sqx-script-logs-page', + styleUrls: ['./script-logs-page.component.scss'], + templateUrl: './script-logs-page.component.html', + imports: [ + AsyncPipe, + FormAlertComponent, + FormsModule, + LayoutComponent, + ListViewComponent, + ScriptLogComponent, + ShortcutDirective, + TitleComponent, + TranslatePipe, + ], +}) +export class ScriptLogsPageComponent implements OnInit { + constructor( + public readonly scriptLogsState: ScriptLogsState, + ) { + } + + public ngOnInit() { + this.scriptLogsState.load(); + } + + public reload() { + this.scriptLogsState.load(true); + } + + public filter(name: string) { + this.scriptLogsState.filter(name); + } +} diff --git a/frontend/src/app/features/settings/routes.ts b/frontend/src/app/features/settings/routes.ts index 7dfb6a2137..465d03bdbe 100644 --- a/frontend/src/app/features/settings/routes.ts +++ b/frontend/src/app/features/settings/routes.ts @@ -15,6 +15,7 @@ import { LanguagesPageComponent } from './pages/languages/languages-page.compone import { MorePageComponent } from './pages/more/more-page.component'; import { PlansPageComponent } from './pages/plans/plans-page.component'; import { RolesPageComponent } from './pages/roles/roles-page.component'; +import { ScriptLogsPageComponent } from './pages/script-logs/script-logs-page.component'; import { SettingsPageComponent } from './pages/settings/settings-page.component'; import { WorkflowsPageComponent } from './pages/workflows/workflows-page.component'; import { SettingsAreaComponent } from './settings-area.component'; @@ -221,6 +222,10 @@ export const SETTINGS_ROUTES: Routes = [ }, ], }, + { + path: 'script-logs', + component: ScriptLogsPageComponent, + }, ], }, ]; \ No newline at end of file diff --git a/frontend/src/app/features/settings/settings-menu.component.html b/frontend/src/app/features/settings/settings-menu.component.html index a487a6ad1e..c3a63bb127 100644 --- a/frontend/src/app/features/settings/settings-menu.component.html +++ b/frontend/src/app/features/settings/settings-menu.component.html @@ -59,6 +59,12 @@ } + @if (app.canReadScriptLogs) { + + } + @if (app.canReadPlans) {