@cosmicdrift/kumiko-samples 0.338.0 → 0.339.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +21 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
- package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
- package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
- package/packages/bundled-features/src/billing-foundation/changes.json +34 -0
- package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
- package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
- package/packages/bundled-features/src/channel-discord/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
- package/packages/bundled-features/src/channel-slack/index.ts +2 -0
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
- package/packages/bundled-features/src/channel-teams/index.ts +2 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
- package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
- package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
- package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
- package/packages/bundled-features/src/data-retention/feature.ts +2 -3
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
- package/packages/bundled-features/src/delivery/index.ts +15 -0
- package/packages/bundled-features/src/delivery/jobs.ts +1 -1
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +8 -3
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
- package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
- package/packages/bundled-features/src/form-draft/feature.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
- package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
- package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
- package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
- package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
- package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
- package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
- package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
- package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
- package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
- package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
- package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
- package/samples/apps/use-all-bundled/tsconfig.json +1 -0
- package/samples/recipes/chat-channels/README.md +19 -0
- package/samples/recipes/chat-channels/package.json +9 -0
- package/samples/recipes/chat-channels/src/feature.ts +40 -0
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
- package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
- package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
|
@@ -16,16 +16,19 @@
|
|
|
16
16
|
// otherwise (wait already wrote its effect; resume past it).
|
|
17
17
|
//
|
|
18
18
|
// The run's ORIGINAL trigger event (the one that started the whole run) is
|
|
19
|
-
// always
|
|
20
|
-
// never from the pending row
|
|
21
|
-
// WorkflowRunStartedPayload in ./runner).
|
|
22
|
-
//
|
|
23
|
-
//
|
|
19
|
+
// always re-read from the event store via the triggerEventRef on the run's
|
|
20
|
+
// own WORKFLOW_RUN_STARTED_TYPE event, never from the pending row (see
|
|
21
|
+
// WorkflowRunStartedPayload in ./runner). Neither the run stream nor the
|
|
22
|
+
// pending row holds a copy of a foreign payload, so an erased source event
|
|
23
|
+
// shows up here in its shredded form, and an unloadable one (archived
|
|
24
|
+
// stream) fails the run. The pending row's own
|
|
25
|
+
// triggerEventType/triggerEventRef columns are a DIFFERENT thing: for a
|
|
26
|
+
// waitForEvent suspension they point at the AWAITED event the Phase 3b
|
|
24
27
|
// event-subscriber matched (NULL until then, and NULL forever on a
|
|
25
|
-
// timeout-without-a-match). That payload becomes the resumed
|
|
26
|
-
// result for the skipped waitForEvent step itself (see
|
|
27
|
-
// steps/wait-for-event.ts) — pre-seeded into stepsAcc below so
|
|
28
|
-
// subsequent step's resolver can read `ctx.steps[awaits.someKey]`.
|
|
28
|
+
// timeout-without-a-match). That event's payload becomes the resumed
|
|
29
|
+
// pipeline's result for the skipped waitForEvent step itself (see
|
|
30
|
+
// resultKey on steps/wait-for-event.ts) — pre-seeded into stepsAcc below so
|
|
31
|
+
// a subsequent step's resolver can read `ctx.steps[awaits.someKey]`.
|
|
29
32
|
|
|
30
33
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
31
34
|
import {
|
|
@@ -53,9 +56,9 @@ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
|
53
56
|
import * as z from "zod";
|
|
54
57
|
import {
|
|
55
58
|
isResumableSuspension,
|
|
59
|
+
type TriggerEventRef,
|
|
56
60
|
type WorkflowRunCompletedPayload,
|
|
57
61
|
type WorkflowRunFailedPayload,
|
|
58
|
-
type WorkflowRunStartedPayload,
|
|
59
62
|
WorkflowSuspensionUnsupportedError,
|
|
60
63
|
} from "../runner.js";
|
|
61
64
|
import { workflowRunPendingTable } from "../tables.js";
|
|
@@ -182,50 +185,218 @@ function isRunAlreadySettled(
|
|
|
182
185
|
});
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
|
|
186
|
-
|
|
188
|
+
type LoadedEvents = Awaited<ReturnType<HandlerContext["loadAggregate"]>>;
|
|
189
|
+
|
|
190
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
191
|
+
return typeof value === "object" && value !== null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isTriggerEventRef(value: unknown): value is TriggerEventRef {
|
|
195
|
+
return (
|
|
196
|
+
isRecord(value) &&
|
|
197
|
+
typeof value["eventId"] === "string" &&
|
|
198
|
+
typeof value["aggregateId"] === "string" &&
|
|
199
|
+
typeof value["version"] === "number"
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// run-started events written before the reference existed carry a copy of the
|
|
204
|
+
// trigger payload instead; they stay readable for runs still in flight.
|
|
205
|
+
type TriggerSource =
|
|
206
|
+
| { readonly kind: "ref"; readonly eventType: string; readonly ref: TriggerEventRef }
|
|
207
|
+
| { readonly kind: "legacy-copy"; readonly eventType: string; readonly payload: unknown };
|
|
208
|
+
|
|
209
|
+
function readTriggerSource(startedPayload: unknown): TriggerSource | null {
|
|
210
|
+
if (!isRecord(startedPayload) || typeof startedPayload["triggerEventType"] !== "string") {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const eventType = startedPayload["triggerEventType"];
|
|
214
|
+
const ref = startedPayload["triggerEventRef"];
|
|
215
|
+
if (isTriggerEventRef(ref)) {
|
|
216
|
+
return { kind: "ref", eventType, ref };
|
|
217
|
+
}
|
|
218
|
+
if ("triggerPayload" in startedPayload) {
|
|
219
|
+
return { kind: "legacy-copy", eventType, payload: startedPayload["triggerPayload"] };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function loadReferencedEvent(ctx: HandlerContext, ref: TriggerEventRef) {
|
|
225
|
+
const events = await ctx.loadAggregate(ref.aggregateId);
|
|
226
|
+
return events.find((e) => e.id === ref.eventId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
type RecoveredTrigger =
|
|
230
|
+
| { readonly event: WriteEvent; readonly unavailable: false }
|
|
231
|
+
| { readonly event: null; readonly unavailable: true };
|
|
232
|
+
|
|
233
|
+
async function recoverTriggerEvent(
|
|
234
|
+
ctx: HandlerContext,
|
|
235
|
+
runEvents: LoadedEvents,
|
|
187
236
|
runId: string,
|
|
188
237
|
user: WriteEvent["user"],
|
|
189
|
-
):
|
|
238
|
+
): Promise<RecoveredTrigger> {
|
|
190
239
|
const started = runEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
|
|
191
240
|
if (!started) {
|
|
192
241
|
throw new InternalError({
|
|
193
242
|
message: `workflow-runner:write:resume-run: run ${runId} has no ${WORKFLOW_RUN_STARTED_TYPE} event — cannot recover its trigger event.`,
|
|
194
243
|
});
|
|
195
244
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
245
|
+
const source = readTriggerSource(started.payload);
|
|
246
|
+
if (!source) {
|
|
247
|
+
throw new InternalError({
|
|
248
|
+
message: `workflow-runner:write:resume-run: run ${runId} has a malformed ${WORKFLOW_RUN_STARTED_TYPE} event — neither triggerEventRef nor a legacy triggerPayload.`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (source.kind === "legacy-copy") {
|
|
252
|
+
return {
|
|
253
|
+
event: { type: source.eventType, payload: source.payload, user },
|
|
254
|
+
unavailable: false,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const triggerEvent = await loadReferencedEvent(ctx, source.ref);
|
|
258
|
+
if (!triggerEvent) {
|
|
259
|
+
return { event: null, unavailable: true };
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
event: { type: source.eventType, payload: triggerEvent.payload, user },
|
|
263
|
+
unavailable: false,
|
|
201
264
|
};
|
|
202
|
-
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type PendingTriggerColumns = {
|
|
268
|
+
readonly triggerEventRef: unknown | null;
|
|
269
|
+
readonly triggerPayload: unknown | null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
type AwaitedPayload =
|
|
273
|
+
| { readonly unavailable: false; readonly payload: unknown }
|
|
274
|
+
| { readonly unavailable: true };
|
|
275
|
+
|
|
276
|
+
// NULL ref + NULL payload = timeout without a match: the step result stays
|
|
277
|
+
// undefined, same as before. A matched row whose event can no longer be loaded
|
|
278
|
+
// is NOT folded into that case: a later step could not tell "nothing arrived"
|
|
279
|
+
// from "arrived but gone" and would run on missing data, so the run fails
|
|
280
|
+
// instead. Rows matched before the reference existed still carry the copy.
|
|
281
|
+
async function loadAwaitedPayload(
|
|
282
|
+
ctx: HandlerContext,
|
|
283
|
+
pending: PendingTriggerColumns,
|
|
284
|
+
): Promise<AwaitedPayload> {
|
|
285
|
+
if (!isTriggerEventRef(pending.triggerEventRef)) {
|
|
286
|
+
return { unavailable: false, payload: pending.triggerPayload ?? undefined };
|
|
287
|
+
}
|
|
288
|
+
const awaited = await loadReferencedEvent(ctx, pending.triggerEventRef);
|
|
289
|
+
return awaited ? { unavailable: false, payload: awaited.payload } : { unavailable: true };
|
|
203
290
|
}
|
|
204
291
|
|
|
205
292
|
// The suspended waitForEvent step itself is skipped on resume
|
|
206
293
|
// (resumeFrom = stepIndex + 1) — its run() never re-executes, so its
|
|
207
294
|
// resultKey (args.event, see steps/wait-for-event.ts) never gets
|
|
208
|
-
// populated by the normal runStepList loop. Seed it here from the
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// other step result. NULL on a timeout-without-a-match — a later
|
|
212
|
-
// resolver reading it just sees `undefined`, no special-casing needed.
|
|
295
|
+
// populated by the normal runStepList loop. Seed it here from the awaited
|
|
296
|
+
// event's payload so a subsequent step's resolver sees the matched event via
|
|
297
|
+
// `ctx.steps[awaits.someKey]`, same as any other step result.
|
|
213
298
|
function seedResumedStepResults(
|
|
214
|
-
|
|
299
|
+
suspensionEventType: string,
|
|
300
|
+
awaitedPayload: unknown,
|
|
215
301
|
steps: readonly StepInstance[],
|
|
216
302
|
stepIndex: number,
|
|
217
303
|
): Record<string, unknown> {
|
|
218
304
|
const stepsAcc: Record<string, unknown> = {};
|
|
219
|
-
if (
|
|
305
|
+
if (suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE) {
|
|
220
306
|
const suspendedStep = steps[stepIndex];
|
|
221
307
|
const key = suspendedStep && getStep(suspendedStep.kind)?.resultKey?.(suspendedStep.args);
|
|
222
308
|
if (key !== undefined) {
|
|
223
|
-
stepsAcc[key] =
|
|
309
|
+
stepsAcc[key] = awaitedPayload;
|
|
224
310
|
}
|
|
225
311
|
}
|
|
226
312
|
return stepsAcc;
|
|
227
313
|
}
|
|
228
314
|
|
|
315
|
+
type ResumedPipelineInput = {
|
|
316
|
+
readonly workflow: WorkflowDefinition;
|
|
317
|
+
readonly workflowName: string;
|
|
318
|
+
readonly runId: string;
|
|
319
|
+
readonly stepIndex: number;
|
|
320
|
+
readonly resumeFrom: number;
|
|
321
|
+
readonly triggerEvent: WriteEvent;
|
|
322
|
+
readonly awaitedPayload: unknown;
|
|
323
|
+
readonly pending: {
|
|
324
|
+
readonly suspensionEventType: string;
|
|
325
|
+
readonly retryAttempt: number | null;
|
|
326
|
+
readonly definitionFingerprint: string | null;
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
async function runResumedPipeline(ctx: HandlerContext, input: ResumedPipelineInput) {
|
|
331
|
+
const {
|
|
332
|
+
workflow,
|
|
333
|
+
workflowName,
|
|
334
|
+
runId,
|
|
335
|
+
stepIndex,
|
|
336
|
+
resumeFrom,
|
|
337
|
+
triggerEvent,
|
|
338
|
+
awaitedPayload,
|
|
339
|
+
pending,
|
|
340
|
+
} = input;
|
|
341
|
+
try {
|
|
342
|
+
const steps = buildPipelineSteps(workflow.pipelineDef, triggerEvent);
|
|
343
|
+
const workflowCtx = {
|
|
344
|
+
runId,
|
|
345
|
+
workflowName,
|
|
346
|
+
stepIndex,
|
|
347
|
+
definitionFingerprint: pending.definitionFingerprint ?? undefined,
|
|
348
|
+
...(pending.retryAttempt !== null && { retryAttempt: pending.retryAttempt + 1 }),
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const stepsAcc = seedResumedStepResults(
|
|
352
|
+
pending.suspensionEventType,
|
|
353
|
+
awaitedPayload,
|
|
354
|
+
steps,
|
|
355
|
+
stepIndex,
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const outcome = await runStepList(
|
|
359
|
+
steps,
|
|
360
|
+
triggerEvent,
|
|
361
|
+
ctx,
|
|
362
|
+
stepsAcc,
|
|
363
|
+
{},
|
|
364
|
+
workflowCtx,
|
|
365
|
+
resumeFrom,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
if (outcome.kind === "suspended") {
|
|
369
|
+
if (!isResumableSuspension(steps, outcome.stepIndex)) {
|
|
370
|
+
throw new WorkflowSuspensionUnsupportedError(workflowName, outcome.stepIndex);
|
|
371
|
+
}
|
|
372
|
+
// Another suspension further down the pipeline — pending-projection.ts
|
|
373
|
+
// already materialised the new row; nothing more to do this pass.
|
|
374
|
+
return { isSuccess: true as const, data: { outcome: "suspended" as const } };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const completedPayload: WorkflowRunCompletedPayload = {
|
|
378
|
+
workflowName,
|
|
379
|
+
stepIndex: steps.length,
|
|
380
|
+
};
|
|
381
|
+
await ctx.unsafeAppendEvent({
|
|
382
|
+
aggregateId: runId,
|
|
383
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
384
|
+
type: WORKFLOW_RUN_COMPLETED_TYPE,
|
|
385
|
+
payload: completedPayload,
|
|
386
|
+
});
|
|
387
|
+
return { isSuccess: true as const, data: { outcome: "completed" as const } };
|
|
388
|
+
} catch (error) {
|
|
389
|
+
log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
|
|
390
|
+
const failedPayload: WorkflowRunFailedPayload = {
|
|
391
|
+
workflowName,
|
|
392
|
+
stepIndex,
|
|
393
|
+
error: describeWorkflowStepError(error),
|
|
394
|
+
};
|
|
395
|
+
await appendRunFailed(ctx, runId, failedPayload);
|
|
396
|
+
return { isSuccess: true as const, data: { outcome: "failed" as const } };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
229
400
|
export const resumeRunHandler: WriteHandlerDef = {
|
|
230
401
|
name: "resume-run",
|
|
231
402
|
agent: { expose: false },
|
|
@@ -248,6 +419,7 @@ export const resumeRunHandler: WriteHandlerDef = {
|
|
|
248
419
|
suspensionEventType: string;
|
|
249
420
|
retryAttempt: number | null;
|
|
250
421
|
definitionFingerprint: string | null;
|
|
422
|
+
triggerEventRef: unknown | null;
|
|
251
423
|
triggerPayload: unknown | null;
|
|
252
424
|
}>(db, workflowRunPendingTable, { runId, stepIndex, tenantId });
|
|
253
425
|
|
|
@@ -297,62 +469,44 @@ export const resumeRunHandler: WriteHandlerDef = {
|
|
|
297
469
|
return { isSuccess: true, data: { outcome: "already-resumed" as const } };
|
|
298
470
|
}
|
|
299
471
|
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
const steps = buildPipelineSteps(workflow.pipelineDef, triggerEvent);
|
|
307
|
-
const workflowCtx = {
|
|
308
|
-
runId,
|
|
472
|
+
const recovered = await recoverTriggerEvent(ctx, runEvents, runId, event.user);
|
|
473
|
+
if (recovered.unavailable) {
|
|
474
|
+
await appendRunFailed(ctx, runId, {
|
|
309
475
|
workflowName,
|
|
310
476
|
stepIndex,
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
const stepsAcc = seedResumedStepResults(pending, steps, stepIndex);
|
|
316
|
-
|
|
317
|
-
const outcome = await runStepList(
|
|
318
|
-
steps,
|
|
319
|
-
triggerEvent,
|
|
320
|
-
ctx,
|
|
321
|
-
stepsAcc,
|
|
322
|
-
{},
|
|
323
|
-
workflowCtx,
|
|
324
|
-
resumeFrom,
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
if (outcome.kind === "suspended") {
|
|
328
|
-
if (!isResumableSuspension(steps, outcome.stepIndex)) {
|
|
329
|
-
throw new WorkflowSuspensionUnsupportedError(workflowName, outcome.stepIndex);
|
|
330
|
-
}
|
|
331
|
-
// Another suspension further down the pipeline — pending-projection.ts
|
|
332
|
-
// already materialised the new row; nothing more to do this pass.
|
|
333
|
-
return { isSuccess: true, data: { outcome: "suspended" as const } };
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const completedPayload: WorkflowRunCompletedPayload = {
|
|
337
|
-
workflowName,
|
|
338
|
-
stepIndex: steps.length,
|
|
339
|
-
};
|
|
340
|
-
await ctx.unsafeAppendEvent({
|
|
341
|
-
aggregateId: runId,
|
|
342
|
-
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
343
|
-
type: WORKFLOW_RUN_COMPLETED_TYPE,
|
|
344
|
-
payload: completedPayload,
|
|
477
|
+
error: `Trigger event of run ${runId} is no longer available — cannot resume.`,
|
|
478
|
+
reason: "trigger_event_unavailable",
|
|
345
479
|
});
|
|
346
|
-
return { isSuccess: true, data: { outcome: "
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
480
|
+
return { isSuccess: true, data: { outcome: "failed" as const } };
|
|
481
|
+
}
|
|
482
|
+
const triggerEvent = recovered.event;
|
|
483
|
+
|
|
484
|
+
const awaited =
|
|
485
|
+
pending.suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE
|
|
486
|
+
? await loadAwaitedPayload(ctx, pending)
|
|
487
|
+
: ({ unavailable: false, payload: undefined } as const);
|
|
488
|
+
if (awaited.unavailable) {
|
|
489
|
+
await appendRunFailed(ctx, runId, {
|
|
350
490
|
workflowName,
|
|
351
491
|
stepIndex,
|
|
352
|
-
error:
|
|
353
|
-
|
|
354
|
-
|
|
492
|
+
error: `Awaited event of run ${runId} is no longer available — cannot resume.`,
|
|
493
|
+
reason: "awaited_event_unavailable",
|
|
494
|
+
});
|
|
355
495
|
return { isSuccess: true, data: { outcome: "failed" as const } };
|
|
356
496
|
}
|
|
497
|
+
|
|
498
|
+
const resumeFrom =
|
|
499
|
+
pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;
|
|
500
|
+
|
|
501
|
+
return runResumedPipeline(ctx, {
|
|
502
|
+
workflow,
|
|
503
|
+
workflowName,
|
|
504
|
+
runId,
|
|
505
|
+
stepIndex,
|
|
506
|
+
resumeFrom,
|
|
507
|
+
triggerEvent,
|
|
508
|
+
awaitedPayload: awaited.payload,
|
|
509
|
+
pending,
|
|
510
|
+
});
|
|
357
511
|
},
|
|
358
512
|
};
|
|
@@ -84,6 +84,7 @@ async function upsertPending(tx: DbRunner, row: PendingRow): Promise<void> {
|
|
|
84
84
|
// Phase 3's event-subscriber writes these when the awaited event
|
|
85
85
|
// arrives — Phase 1 never touches them, insert or update.
|
|
86
86
|
triggerEventType: null,
|
|
87
|
+
triggerEventRef: null,
|
|
87
88
|
triggerPayload: null,
|
|
88
89
|
},
|
|
89
90
|
{ conflictKeys: ["tenantId", "runId", "stepIndex"] },
|
|
@@ -35,10 +35,19 @@ export function isResumableSuspension(steps: readonly StepInstance[], stepIndex:
|
|
|
35
35
|
return RESUMABLE_STEP_KINDS.has(steps[stepIndex]?.kind ?? "");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Points at the trigger event in the event store. The run stream never holds a
|
|
39
|
+
// copy of a foreign payload: erasing or archiving the source event must also
|
|
40
|
+
// cover the workflow, so a resume re-reads the event through this reference.
|
|
41
|
+
export type TriggerEventRef = {
|
|
42
|
+
readonly eventId: string;
|
|
43
|
+
readonly aggregateId: string;
|
|
44
|
+
readonly version: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
38
47
|
export type WorkflowRunStartedPayload = {
|
|
39
48
|
readonly workflowName: string;
|
|
40
49
|
readonly triggerEventType: string;
|
|
41
|
-
readonly
|
|
50
|
+
readonly triggerEventRef: TriggerEventRef;
|
|
42
51
|
readonly definitionFingerprint: string;
|
|
43
52
|
readonly idempotencyKey?: string;
|
|
44
53
|
};
|
|
@@ -91,6 +100,7 @@ export async function startAndRunWorkflow(args: {
|
|
|
91
100
|
readonly runId: string;
|
|
92
101
|
readonly workflow: WorkflowDefinition;
|
|
93
102
|
readonly triggerEvent: WriteEvent;
|
|
103
|
+
readonly triggerEventRef: TriggerEventRef;
|
|
94
104
|
readonly idempotencyKey?: string;
|
|
95
105
|
readonly handlerCtx: HandlerContext;
|
|
96
106
|
}): Promise<{ readonly outcome: "completed" | "suspended" }> {
|
|
@@ -99,7 +109,7 @@ export async function startAndRunWorkflow(args: {
|
|
|
99
109
|
const startedPayload: WorkflowRunStartedPayload = {
|
|
100
110
|
workflowName: args.workflow.name,
|
|
101
111
|
triggerEventType: args.triggerEvent.type,
|
|
102
|
-
|
|
112
|
+
triggerEventRef: args.triggerEventRef,
|
|
103
113
|
definitionFingerprint: fingerprint,
|
|
104
114
|
...(args.idempotencyKey && { idempotencyKey: args.idempotencyKey }),
|
|
105
115
|
};
|
|
@@ -52,8 +52,14 @@ export const workflowRunPendingTable = pgTable(
|
|
|
52
52
|
waitEventType: text("wait_event_type"),
|
|
53
53
|
matchExpr: jsonb("match_expr"),
|
|
54
54
|
// Written by the Phase 3 event-subscriber when the awaited event
|
|
55
|
-
// arrives — always NULL in Phase 1/2.
|
|
55
|
+
// arrives — always NULL in Phase 1/2. The row points at the awaited event
|
|
56
|
+
// ({ eventId, aggregateId, version }); resume-run re-reads it from the
|
|
57
|
+
// event store, so erasing the event also covers the workflow.
|
|
56
58
|
triggerEventType: text("trigger_event_type"),
|
|
59
|
+
triggerEventRef: jsonb("trigger_event_ref"),
|
|
60
|
+
// Legacy copy of the awaited payload. No longer written; only rows
|
|
61
|
+
// matched before the reference existed still carry it, and resume-run
|
|
62
|
+
// falls back to it for those.
|
|
57
63
|
triggerPayload: jsonb("trigger_payload"),
|
|
58
64
|
},
|
|
59
65
|
(t) => [
|
|
@@ -82,6 +88,7 @@ export const workflowRunPendingTableMeta: EntityTableMeta = defineUnmanagedTable
|
|
|
82
88
|
{ name: "wait_event_type", pgType: "text", notNull: false },
|
|
83
89
|
{ name: "match_expr", pgType: "jsonb", notNull: false },
|
|
84
90
|
{ name: "trigger_event_type", pgType: "text", notNull: false },
|
|
91
|
+
{ name: "trigger_event_ref", pgType: "jsonb", notNull: false },
|
|
85
92
|
{ name: "trigger_payload", pgType: "jsonb", notNull: false },
|
|
86
93
|
],
|
|
87
94
|
indexes: [
|
|
@@ -55,6 +55,11 @@ export const WIDGETS_I18N = {
|
|
|
55
55
|
de: "Antwortzeit-Verlauf",
|
|
56
56
|
en: "Response time history",
|
|
57
57
|
},
|
|
58
|
+
"widgets:catalog:response-time-p95": { de: "p95: 180 ms", en: "p95: 180 ms" },
|
|
59
|
+
"widgets:catalog:response-time-p95-aria": {
|
|
60
|
+
de: "p95-Schwelle der letzten 24 Stunden",
|
|
61
|
+
en: "p95 threshold over the last 24 hours",
|
|
62
|
+
},
|
|
58
63
|
"widgets:catalog:24h-ago": { de: "vor 24h", en: "24h ago" },
|
|
59
64
|
"widgets:catalog:12h-ago": { de: "vor 12h", en: "12h ago" },
|
|
60
65
|
"widgets:catalog:now": { de: "jetzt", en: "now" },
|
|
@@ -120,6 +120,17 @@ export function Widgets(): ReactNode {
|
|
|
120
120
|
end: t("widgets:catalog:now"),
|
|
121
121
|
}}
|
|
122
122
|
/>
|
|
123
|
+
<div className="mt-4">
|
|
124
|
+
<TimeseriesChart
|
|
125
|
+
points={RESPONSE_TIMES}
|
|
126
|
+
windowStartMs={0}
|
|
127
|
+
windowEndMs={24 * 60 * 60 * 1000}
|
|
128
|
+
ariaLabel={t("widgets:catalog:response-time-p95-aria")}
|
|
129
|
+
referenceLines={[
|
|
130
|
+
{ value: 180, label: t("widgets:catalog:response-time-p95"), tone: "warn" },
|
|
131
|
+
]}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
123
134
|
</SectionCard>
|
|
124
135
|
|
|
125
136
|
<SectionCard
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @runtime test
|
|
2
|
+
//
|
|
3
|
+
// Framework-free constants shared by the gallery server and spec (the spec
|
|
4
|
+
// runs in a Playwright worker and must not pull the server import chain).
|
|
5
|
+
|
|
6
|
+
export const GALLERY_PROVIDER = "gallery-mock-provider";
|
|
7
|
+
export const GALLERY_TERMS_SLUG = "gallery-terms";
|
|
8
|
+
export const GALLERY_OPERATOR_EMAIL = "billing@gallery.example";
|
|
9
|
+
export const GALLERY_PLAN_PATH = "/tenant-admin/billing-plans";
|
|
10
|
+
|
|
11
|
+
// "localhost" resolves the SYSTEM tenant like the stock dev app; the numeric
|
|
12
|
+
// loopback host resolves none and plays the platform apex.
|
|
13
|
+
export const GALLERY_TENANT_HOST = "localhost";
|
|
14
|
+
export const GALLERY_APEX_HOST = "127.0.0.1";
|
|
15
|
+
|
|
16
|
+
export const GALLERY_PRICE_IDS = { starter: "price_gallery_starter", pro: "price_gallery_pro" };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @runtime test
|
|
2
|
+
//
|
|
3
|
+
// E2E-only wiring for the consent/termination review gallery: a mock billing
|
|
4
|
+
// provider (the only fake — real handlers, real dispatcher), the catalog and
|
|
5
|
+
// the nav entry that makes billing-foundation's dormant plans screen reachable.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createBillingFoundationFeature,
|
|
9
|
+
type ProviderPrice,
|
|
10
|
+
type SubscriptionProviderPlugin,
|
|
11
|
+
SubscriptionStatuses,
|
|
12
|
+
subscriptionAggregateId,
|
|
13
|
+
subscriptionsProjectionTable,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
15
|
+
import {
|
|
16
|
+
createChannelEmailFeature,
|
|
17
|
+
createInMemoryTransport,
|
|
18
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
19
|
+
import { simpleRenderer } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
|
|
20
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import {
|
|
23
|
+
GALLERY_OPERATOR_EMAIL,
|
|
24
|
+
GALLERY_PLAN_PATH,
|
|
25
|
+
GALLERY_PRICE_IDS,
|
|
26
|
+
GALLERY_PROVIDER,
|
|
27
|
+
GALLERY_TERMS_SLUG,
|
|
28
|
+
} from "./gallery-constants";
|
|
29
|
+
|
|
30
|
+
export const galleryMailTransport = createInMemoryTransport();
|
|
31
|
+
|
|
32
|
+
const PRICES: readonly ProviderPrice[] = [
|
|
33
|
+
{
|
|
34
|
+
priceId: GALLERY_PRICE_IDS.starter,
|
|
35
|
+
unitAmount: 900,
|
|
36
|
+
currency: "eur",
|
|
37
|
+
interval: "month",
|
|
38
|
+
intervalCount: 1,
|
|
39
|
+
active: true,
|
|
40
|
+
metadata: {},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
priceId: GALLERY_PRICE_IDS.pro,
|
|
44
|
+
unitAmount: 1900,
|
|
45
|
+
currency: "eur",
|
|
46
|
+
interval: "month",
|
|
47
|
+
intervalCount: 1,
|
|
48
|
+
active: true,
|
|
49
|
+
metadata: {},
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
async function resolveCurrentTier(db: TenantDb, tenantId: string): Promise<string> {
|
|
54
|
+
const rows = await db.selectMany(
|
|
55
|
+
subscriptionsProjectionTable,
|
|
56
|
+
{ id: subscriptionAggregateId(tenantId) },
|
|
57
|
+
{ limit: 1 },
|
|
58
|
+
);
|
|
59
|
+
const row = rows[0];
|
|
60
|
+
if (!row) return "free";
|
|
61
|
+
const status = row["status"] as string;
|
|
62
|
+
return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing
|
|
63
|
+
? (row["tier"] as string)
|
|
64
|
+
: "free";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition {
|
|
68
|
+
return createBillingFoundationFeature({
|
|
69
|
+
baseUrl,
|
|
70
|
+
catalog: {
|
|
71
|
+
plans: ["starter", "pro"],
|
|
72
|
+
tierLabelKey: (tier) => `gallery.plan.${tier}.label`,
|
|
73
|
+
benefits: (tier) => [{ labelKey: `gallery.plan.${tier}.benefit` }],
|
|
74
|
+
resolveCurrentTier,
|
|
75
|
+
viewRoles: ["TenantAdmin", "SystemAdmin"],
|
|
76
|
+
successPath: GALLERY_PLAN_PATH,
|
|
77
|
+
cancelPath: GALLERY_PLAN_PATH,
|
|
78
|
+
providerName: GALLERY_PROVIDER,
|
|
79
|
+
},
|
|
80
|
+
consumerProtection: {
|
|
81
|
+
termsTextBlock: GALLERY_TERMS_SLUG,
|
|
82
|
+
vatNote: { de: "Preise inkl. USt.", en: "Prices include VAT." },
|
|
83
|
+
operatorEmail: GALLERY_OPERATOR_EMAIL,
|
|
84
|
+
terminationScope: "platform",
|
|
85
|
+
legalLinks: {
|
|
86
|
+
terms: "/legal/terms",
|
|
87
|
+
withdrawal: "/legal/withdrawal",
|
|
88
|
+
privacy: "/legal/privacy",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createGalleryEmailFeature(): FeatureDefinition {
|
|
95
|
+
return createChannelEmailFeature({
|
|
96
|
+
transport: galleryMailTransport,
|
|
97
|
+
renderer: simpleRenderer,
|
|
98
|
+
resolveEmail: async () => "unused@gallery.example",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The page URL carries the consent id so the spec can echo it on the webhook,
|
|
103
|
+
// exactly as a real provider echoes the checkout metadata.
|
|
104
|
+
const galleryProviderPlugin: SubscriptionProviderPlugin = {
|
|
105
|
+
verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody),
|
|
106
|
+
priceToTier: {
|
|
107
|
+
[GALLERY_PRICE_IDS.starter]: "starter",
|
|
108
|
+
[GALLERY_PRICE_IDS.pro]: "pro",
|
|
109
|
+
},
|
|
110
|
+
retrievePrices: async (_ctx, priceIds) => PRICES.filter((p) => priceIds.includes(p.priceId)),
|
|
111
|
+
createCheckoutSession: async (_ctx, options) => {
|
|
112
|
+
const url = new URL(options.successUrl);
|
|
113
|
+
if (options.consentId !== undefined) url.searchParams.set("consentId", options.consentId);
|
|
114
|
+
return { url: url.toString() };
|
|
115
|
+
},
|
|
116
|
+
cancelSubscription: async () => {},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const galleryProviderFeature: FeatureDefinition = defineFeature(
|
|
120
|
+
"gallery-mock-provider",
|
|
121
|
+
(r) => {
|
|
122
|
+
r.describe("E2E-only mock subscription provider for the consent/termination gallery.");
|
|
123
|
+
r.requires("billing-foundation");
|
|
124
|
+
r.useExtension("subscriptionProvider", GALLERY_PROVIDER, galleryProviderPlugin);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
export const galleryScreensFeature: FeatureDefinition = defineFeature("gallery-screens", (r) => {
|
|
129
|
+
r.describe("E2E-only nav entry for the billing plans screen plus plan labels.");
|
|
130
|
+
r.requires("billing-foundation", "admin-shell");
|
|
131
|
+
r.nav({
|
|
132
|
+
id: "billing",
|
|
133
|
+
label: "gallery:nav.billing",
|
|
134
|
+
icon: "file",
|
|
135
|
+
screen: "billing-foundation:screen:billing-plans",
|
|
136
|
+
order: 20,
|
|
137
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
138
|
+
workspaces: ["admin-shell:workspace:tenant-admin"],
|
|
139
|
+
});
|
|
140
|
+
r.translations({
|
|
141
|
+
keys: {
|
|
142
|
+
"gallery:nav.billing": { de: "Abrechnung", en: "Billing" },
|
|
143
|
+
"gallery.plan.free.label": { de: "Free", en: "Free" },
|
|
144
|
+
"gallery.plan.starter.label": { de: "Starter", en: "Starter" },
|
|
145
|
+
"gallery.plan.pro.label": { de: "Pro", en: "Pro" },
|
|
146
|
+
"gallery.plan.starter.benefit": { de: "Für kleine Teams", en: "For small teams" },
|
|
147
|
+
"gallery.plan.pro.benefit": { de: "Für wachsende Teams", en: "For growing teams" },
|
|
148
|
+
"gallery.plan.free.benefit": { de: "Zum Ausprobieren", en: "To try things out" },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
return {};
|
|
152
|
+
});
|