@cosmicdrift/kumiko-samples 0.339.0 → 0.341.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 +6 -1
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +17 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +41 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
- package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
|
@@ -63,6 +63,13 @@ import {
|
|
|
63
63
|
type TrialGate,
|
|
64
64
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
65
65
|
import { getUnscopedAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
66
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
67
|
+
import {
|
|
68
|
+
CACHE_SYNC_TOPICS,
|
|
69
|
+
type CacheSyncBus,
|
|
70
|
+
isTierAssignmentSyncMessage,
|
|
71
|
+
type TierAssignmentSyncMessage,
|
|
72
|
+
} from "@cosmicdrift/kumiko-framework/redis";
|
|
66
73
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
67
74
|
import * as z from "zod";
|
|
68
75
|
import { tenantTable } from "../tenant/index.js";
|
|
@@ -80,6 +87,7 @@ import { isTrialActive, type TrialPolicy } from "./trial.js";
|
|
|
80
87
|
// from the entity definition — same shape buildEntityTable would produce
|
|
81
88
|
// in the App's drizzle/schema.generated.ts.
|
|
82
89
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
90
|
+
const log = createFallbackLogger("tier-engine");
|
|
83
91
|
|
|
84
92
|
// Event-store-executor für direct-write aus dem auto-default-tier-hook.
|
|
85
93
|
// Pattern wie tenant/seeding.ts: hook sieht AppContext (kein ctx.write),
|
|
@@ -227,10 +235,9 @@ export function createTierEngineFeature<
|
|
|
227
235
|
// Cross-tenant set + read f\u00fcr den tier-admin-Screen. tier-options liefert
|
|
228
236
|
// dem Client die App-Tier-Namen aus der tierMap-Closure (sonst hartkodiert).
|
|
229
237
|
//
|
|
230
|
-
// onAssigned
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// die echte Cache-Update-Funktion eingeh\u00e4ngt \u2014 analog alwaysOnHolder.
|
|
238
|
+
// onAssigned runs after commit (the direct executor write fires no postSave
|
|
239
|
+
// hook). Late-bound: without tierMap it stays a no-op, the tierMap block
|
|
240
|
+
// below installs the real cache update + publish.
|
|
234
241
|
const onTierAssigned: { fn: (tenantId: TenantId, tier: string) => void } = { fn: () => {} };
|
|
235
242
|
r.writeHandler(
|
|
236
243
|
createSetTenantTierWrite({
|
|
@@ -312,6 +319,21 @@ export function createTierEngineFeature<
|
|
|
312
319
|
// mergeAlwaysOn-calls — Late-bind via mutable holder, gefüllt vor allen
|
|
313
320
|
// Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
|
|
314
321
|
const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
|
|
322
|
+
// Bus handed to build(); hooks fire outside build and publish through it.
|
|
323
|
+
const busHolder: { bus: CacheSyncBus | undefined } = { bus: undefined };
|
|
324
|
+
// Out-of-order guard: every cache mutation or reload start takes a ticket; a
|
|
325
|
+
// reload result is applied only while its ticket is still the tenant's latest.
|
|
326
|
+
let ticketCounter = 0;
|
|
327
|
+
const latestTicket = new Map<TenantId, number>();
|
|
328
|
+
const takeTicket = (tenantId: TenantId): number => {
|
|
329
|
+
ticketCounter += 1;
|
|
330
|
+
latestTicket.set(tenantId, ticketCounter);
|
|
331
|
+
return ticketCounter;
|
|
332
|
+
};
|
|
333
|
+
const publishAssignmentChange = (tenantId: TenantId): void => {
|
|
334
|
+
const message: TierAssignmentSyncMessage = { tenantId };
|
|
335
|
+
busHolder.bus?.publish(CACHE_SYNC_TOPICS.tierAssignment, message);
|
|
336
|
+
};
|
|
315
337
|
|
|
316
338
|
// Trial: zeit-abgeleitet aus tenant.inserted_at (≈ Signup), live am
|
|
317
339
|
// Feature-Gate geprüft — NICHT im Resolver-Cache. Der Resolver ist
|
|
@@ -330,16 +352,16 @@ export function createTierEngineFeature<
|
|
|
330
352
|
: new Set();
|
|
331
353
|
const nowMs = (): number => getTemporal().Now.instant().epochMilliseconds;
|
|
332
354
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
// Semantik wie der Hook.
|
|
338
|
-
onTierAssigned.fn = (tenantId, tier) => {
|
|
355
|
+
// Immediate local visibility on the writing pod; other pods learn via the
|
|
356
|
+
// bus and re-read the row.
|
|
357
|
+
const applyAssignment = (tenantId: TenantId, tier: string): void => {
|
|
358
|
+
takeTicket(tenantId);
|
|
339
359
|
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
|
|
360
|
+
publishAssignmentChange(tenantId);
|
|
340
361
|
};
|
|
362
|
+
onTierAssigned.fn = applyAssignment;
|
|
341
363
|
|
|
342
|
-
// Invalidation: tier-assignment events update the cache.
|
|
364
|
+
// Invalidation: tier-assignment CRUD events (e.g. the billing webhook) update the cache.
|
|
343
365
|
r.hook("postSave", { allOf: "tier-assignment" }, async (result) => {
|
|
344
366
|
// result.data has tenantId + tier (after entity-update merge)
|
|
345
367
|
const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
|
|
@@ -349,14 +371,16 @@ export function createTierEngineFeature<
|
|
|
349
371
|
// throwing — der lifecycle-pipeline darf nicht durch hook-fehler
|
|
350
372
|
// blocken (afterCommit-pattern, side-effect-best-effort).
|
|
351
373
|
if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
|
|
352
|
-
|
|
353
|
-
cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)));
|
|
374
|
+
applyAssignment(data.tenantId as TenantId, data.tier);
|
|
354
375
|
});
|
|
355
376
|
r.hook("postDelete", { allOf: "tier-assignment" }, async (payload) => {
|
|
356
377
|
const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
|
|
357
378
|
// skip: gleiche type-guard semantik wie postSave-hook oben.
|
|
358
379
|
if (typeof data.tenantId !== "string") return;
|
|
359
|
-
|
|
380
|
+
const tenantId = data.tenantId as TenantId;
|
|
381
|
+
takeTicket(tenantId);
|
|
382
|
+
cache.delete(tenantId);
|
|
383
|
+
publishAssignmentChange(tenantId);
|
|
360
384
|
});
|
|
361
385
|
|
|
362
386
|
// Auto-default-tier-on-tenant-signup: hook fires inTransaction (atomic
|
|
@@ -473,6 +497,60 @@ export function createTierEngineFeature<
|
|
|
473
497
|
);
|
|
474
498
|
}
|
|
475
499
|
|
|
500
|
+
// Read first, then set — never clear-then-refill: a cache miss falls back
|
|
501
|
+
// to the default tier, which would lock paying tenants out meanwhile.
|
|
502
|
+
const reloadTenant = async (tenantId: TenantId): Promise<void> => {
|
|
503
|
+
const ticket = takeTicket(tenantId);
|
|
504
|
+
const row = await fetchOne<AssignmentRow>(deps.db, tierAssignmentTable, { tenantId });
|
|
505
|
+
// skip: a newer write or reload for this tenant started meanwhile and owns the entry
|
|
506
|
+
if (latestTicket.get(tenantId) !== ticket) return;
|
|
507
|
+
if (row) {
|
|
508
|
+
cache.set(
|
|
509
|
+
tenantId,
|
|
510
|
+
mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
|
|
511
|
+
);
|
|
512
|
+
} else {
|
|
513
|
+
cache.delete(tenantId);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const reloadAllTenants = async (): Promise<void> => {
|
|
517
|
+
ticketCounter += 1;
|
|
518
|
+
const startedAtTicket = ticketCounter;
|
|
519
|
+
const current = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
|
|
520
|
+
const loaded = new Map<TenantId, ReadonlySet<string>>();
|
|
521
|
+
for (const row of current) {
|
|
522
|
+
loaded.set(
|
|
523
|
+
row.tenantId as TenantId,
|
|
524
|
+
mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const isNewerThanSnapshot = (tenantId: TenantId): boolean =>
|
|
528
|
+
(latestTicket.get(tenantId) ?? 0) > startedAtTicket;
|
|
529
|
+
for (const tenantId of [...cache.keys()]) {
|
|
530
|
+
if (!loaded.has(tenantId) && !isNewerThanSnapshot(tenantId)) cache.delete(tenantId);
|
|
531
|
+
}
|
|
532
|
+
for (const [tenantId, features] of loaded) {
|
|
533
|
+
if (!isNewerThanSnapshot(tenantId)) cache.set(tenantId, features);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
busHolder.bus = deps.cacheSync;
|
|
538
|
+
deps.cacheSync?.subscribe(CACHE_SYNC_TOPICS.tierAssignment, (message) => {
|
|
539
|
+
// skip: malformed message from another process
|
|
540
|
+
if (!isTierAssignmentSyncMessage(message)) return;
|
|
541
|
+
reloadTenant(message.tenantId).catch((err: unknown) => {
|
|
542
|
+
log.error("tier reload failed, keeping cached set", {
|
|
543
|
+
tenantId: message.tenantId,
|
|
544
|
+
err,
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
deps.cacheSync?.onResync(() => {
|
|
549
|
+
reloadAllTenants().catch((err: unknown) => {
|
|
550
|
+
log.error("tier resync failed, keeping cached sets", { err });
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
476
554
|
// Synchronous resolver-callback for dispatcher hot-path.
|
|
477
555
|
const resolver = (tenantId: TenantId): ReadonlySet<string> => {
|
|
478
556
|
// Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
|
|
@@ -24,14 +24,13 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
|
|
|
24
24
|
// `source: "manual"` markiert den Grant, damit ein späterer Stripe→Tier-Sync ihn
|
|
25
25
|
// nicht plättet. Upsert: ein Aggregat pro Tenant (deterministische aggregate-id).
|
|
26
26
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// (storage-only ohne tierMap = no-op).
|
|
27
|
+
// Effective-set invalidation: the executor write does not fire the
|
|
28
|
+
// `tier-assignment:postSave` entity hook, and a per-handler postSave would not
|
|
29
|
+
// fire either because this handler returns no lifecycle result (kind "save").
|
|
30
|
+
// Without an explicit update the feature gate would stay on the old tier, so
|
|
31
|
+
// `opts.onAssigned(tenantId, tier)` runs after commit (ctx.scheduleAfterCommit).
|
|
32
|
+
// feature.ts uses it to update its cache and notify the other processes; in
|
|
33
|
+
// storage-only mode without a tierMap it is a no-op.
|
|
35
34
|
|
|
36
35
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
37
36
|
const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEntity, {
|
|
@@ -41,9 +40,9 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
|
|
|
41
40
|
const SET_TENANT_TIER_REASON = "SystemAdmin assigns the tier of the tenant named in the payload";
|
|
42
41
|
|
|
43
42
|
export type SetTenantTierOptions = {
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
/** Runs after the write commits so feature.ts can update the resolver cache
|
|
44
|
+
* and notify the other processes. Without a tierMap there is no resolver
|
|
45
|
+
* and this is a no-op. */
|
|
47
46
|
readonly onAssigned?: (tenantId: TenantId, tier: string) => void;
|
|
48
47
|
/** Tier-Namen aus der tierMap-Closure — ohne sie (storage-only-Mode) bleibt
|
|
49
48
|
* `tier` unvalidiert außer Length/Non-Empty. Mit ihr rejected der Handler
|
|
@@ -72,6 +71,12 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
72
71
|
reason: SET_TENANT_TIER_REASON,
|
|
73
72
|
},
|
|
74
73
|
handler: async (event, ctx) => {
|
|
74
|
+
// Without a commit sink (unit harness) there is no transaction to wait for.
|
|
75
|
+
const afterCommit = (tenantId: TenantId, tier: string): void => {
|
|
76
|
+
const notify = async (): Promise<void> => opts.onAssigned?.(tenantId, tier);
|
|
77
|
+
if (ctx.scheduleAfterCommit) ctx.scheduleAfterCommit(notify);
|
|
78
|
+
else opts.onAssigned?.(tenantId, tier);
|
|
79
|
+
};
|
|
75
80
|
const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
|
|
76
81
|
const rawDb = ctx.db.unsafeRaw();
|
|
77
82
|
const tdb = createTenantDb(rawDb, tenantId, "system");
|
|
@@ -91,7 +96,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
91
96
|
tdb,
|
|
92
97
|
);
|
|
93
98
|
if (!result.isSuccess) return result;
|
|
94
|
-
|
|
99
|
+
afterCommit(tenantId, tier);
|
|
95
100
|
return { isSuccess: true as const, data: { tenantId, tier, isNew: false } };
|
|
96
101
|
}
|
|
97
102
|
|
|
@@ -101,7 +106,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
101
106
|
tdb,
|
|
102
107
|
);
|
|
103
108
|
if (!result.isSuccess) return result;
|
|
104
|
-
|
|
109
|
+
afterCommit(tenantId, tier);
|
|
105
110
|
return { isSuccess: true as const, data: { tenantId, tier, isNew: true } };
|
|
106
111
|
},
|
|
107
112
|
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The last-SystemAdmin check uses jsonb containment through selectMany",
|
|
6
|
+
"detail": "Removing the SystemAdmin role still refuses to drop the last active SystemAdmin; the count now comes from `selectMany` with a roles containment filter instead of raw SQL.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.271.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -8,7 +15,7 @@
|
|
|
8
15
|
{
|
|
9
16
|
"version": "0.218.1",
|
|
10
17
|
"type": "improvement",
|
|
11
|
-
"title": "user.email searchable
|
|
18
|
+
"title": "user.email searchable — reindex required after upgrade",
|
|
12
19
|
"detail": "email gained searchable for roster lookup. buildSearchDocument only runs on write; existing rows need reindexEntity(\"user\") (or the reindex job) plus Meili configure for the new attribute.",
|
|
13
20
|
"migration": "After deploying, run reindexEntity(\"user\") (or the existing reindex job) so pre-upgrade user rows appear in roster search."
|
|
14
21
|
},
|
|
@@ -16,28 +23,28 @@
|
|
|
16
23
|
"version": "0.218.0",
|
|
17
24
|
"type": "fix",
|
|
18
25
|
"title": "user-list: system-scope search; membership roles on tenants; emailVerified sort",
|
|
19
|
-
"detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant
|
|
26
|
+
"detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant → Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only — TenantAdmin/Admin live on membership (Team → Members)."
|
|
20
27
|
},
|
|
21
28
|
{
|
|
22
29
|
"version": "0.217.0",
|
|
23
30
|
"type": "improvement",
|
|
24
31
|
"title": "user-list: roles multiSelect + tenants column; roles editable as SystemAdmin (fw#2384 follow-up)",
|
|
25
|
-
"detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box
|
|
26
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
32
|
+
"detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box — SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
|
|
33
|
+
"migration": "Run `kumiko-schema generate` and apply — migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
|
|
27
34
|
},
|
|
28
35
|
{
|
|
29
36
|
"version": "0.167.1",
|
|
30
37
|
"type": "breaking",
|
|
31
38
|
"title": "user.locale no longer defaults to \"de\" (fw#1637).",
|
|
32
39
|
"detail": "The entity-level default contradicted tenant-settings' own \"en\" default and silently overrode any app or tenant locale configuration for every new user. locale now stays unset until the client or a resolution chain provides one. Consumers that already fall back with `user.locale ?? \"en\"` are unaffected in shape but now see null instead of \"de\".",
|
|
33
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
40
|
+
"migration": "Run `kumiko-schema generate` and apply — the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain — the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
|
|
34
41
|
},
|
|
35
42
|
{
|
|
36
43
|
"version": "0.167.1",
|
|
37
44
|
"type": "improvement",
|
|
38
45
|
"title": "user.timezone field, wired into ctx.tz (fw#1636).",
|
|
39
46
|
"detail": "The user entity gains a `timezone` field (createTzField, no default). buildHandlerContext passes it to ctx.tz.user; the fallback chain stays tenant.timezone, then \"UTC\". Before this, ctx.tz.tenant and ctx.tz.user both hardcoded \"UTC\" while the plumbing already accepted per-request values.",
|
|
40
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
47
|
+
"migration": "Run `kumiko-schema generate` and apply — the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
|
|
41
48
|
},
|
|
42
49
|
{
|
|
43
50
|
"version": "0.165.3",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
3
|
acquireNamespacedAdvisoryLock,
|
|
4
4
|
createEventStoreExecutor,
|
|
@@ -40,10 +40,6 @@ type UserRolesRow = {
|
|
|
40
40
|
isDeleted?: boolean;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
function quoteIdent(name: string): string {
|
|
44
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
43
|
function isActiveUserRow(row: UserRolesRow): boolean {
|
|
48
44
|
if (row.isDeleted === true) return false;
|
|
49
45
|
return row.status === USER_STATUS.Active || !row.status;
|
|
@@ -54,21 +50,11 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
|
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
async function countOtherActiveSystemAdmins(db: DbRunner, excludeUserId: string): Promise<number> {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const rows = (await asRawClient(db).unsafe(
|
|
63
|
-
`SELECT id, roles, status, is_deleted AS "isDeleted"
|
|
64
|
-
FROM ${quoteIdent(tableName)}
|
|
65
|
-
WHERE is_deleted = false
|
|
66
|
-
AND (status = $1 OR status IS NULL)
|
|
67
|
-
AND roles @> $2::jsonb`,
|
|
68
|
-
// Pass a JS array — JSON.stringify + $n::jsonb double-encodes to a jsonb string
|
|
69
|
-
// (postgres.js), so `@>` never matches a roles array.
|
|
70
|
-
[USER_STATUS.Active, ["SystemAdmin"]],
|
|
71
|
-
)) as UserRolesRow[];
|
|
53
|
+
// `roles` is a jsonb multiSelect column: a scalar filter value means `@>` containment.
|
|
54
|
+
const rows = await selectMany<UserRolesRow>(db, userTable, {
|
|
55
|
+
isDeleted: false,
|
|
56
|
+
roles: "SystemAdmin",
|
|
57
|
+
});
|
|
72
58
|
return rows.filter((u) => u.id !== excludeUserId && isActiveSystemAdminRow(u)).length;
|
|
73
59
|
}
|
|
74
60
|
|
|
@@ -138,9 +124,7 @@ export async function applyUserRolesUpdate(
|
|
|
138
124
|
// observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
|
|
139
125
|
await acquireNamespacedAdvisoryLock(lockRunner, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
|
|
140
126
|
const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(
|
|
141
|
-
ctx.systemDb.unsafeRaw(
|
|
142
|
-
"jsonb @> prefilter over the global users table to count remaining active SystemAdmins",
|
|
143
|
-
),
|
|
127
|
+
ctx.systemDb.unsafeRaw("count remaining active SystemAdmins across the global users table"),
|
|
144
128
|
event.payload.id,
|
|
145
129
|
);
|
|
146
130
|
if (otherActiveSystemAdmins === 0) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createEventStoreExecutor,
|
|
4
|
+
createTenantDb,
|
|
5
|
+
type DbRunner,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
|
+
import { GLOBAL_ROLE_OPTIONS } from "./constants.js";
|
|
11
|
+
import { userEntity, userTable } from "./schema/user.js";
|
|
12
|
+
|
|
13
|
+
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
14
|
+
|
|
15
|
+
type UserRolesRow = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly version: number;
|
|
18
|
+
readonly roles: readonly string[] | string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
|
|
22
|
+
|
|
23
|
+
function isGlobalRole(role: string): role is GlobalRole {
|
|
24
|
+
return GLOBAL_ROLE_OPTIONS.some((option) => option === role);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Reads the invitation's stored globalRoles column, keeping only assignable
|
|
28
|
+
// global roles — a hand-edited row cannot smuggle "system" into users.roles.
|
|
29
|
+
export function parseInvitedGlobalRoles(raw: unknown): readonly GlobalRole[] {
|
|
30
|
+
return parseRoles(raw).filter(isGlobalRole);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Add-only: an accepted invitation never removes a global role the user
|
|
34
|
+
// already holds. Users live on SYSTEM_TENANT_ID (tenancy "global").
|
|
35
|
+
export async function grantInvitedGlobalRoles(
|
|
36
|
+
db: DbRunner,
|
|
37
|
+
options: { readonly userId: string; readonly globalRoles: readonly string[] },
|
|
38
|
+
) {
|
|
39
|
+
const existing = await fetchOne<UserRolesRow>(db, userTable, { id: options.userId });
|
|
40
|
+
if (!existing) return writeFailure(new NotFoundError("user", options.userId));
|
|
41
|
+
|
|
42
|
+
const currentRoles = parseRoles(existing.roles);
|
|
43
|
+
const missingRoles = options.globalRoles.filter((role) => !currentRoles.includes(role));
|
|
44
|
+
if (missingRoles.length === 0) return grantedGlobalRoles(currentRoles);
|
|
45
|
+
|
|
46
|
+
const roles = [...currentRoles, ...missingRoles];
|
|
47
|
+
const updateResult = await userExecutor.update(
|
|
48
|
+
{ id: existing.id, version: existing.version, changes: { roles } },
|
|
49
|
+
createSystemUser(SYSTEM_TENANT_ID),
|
|
50
|
+
createTenantDb(db, SYSTEM_TENANT_ID, "system"),
|
|
51
|
+
);
|
|
52
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
53
|
+
return grantedGlobalRoles(roles);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function grantedGlobalRoles(roles: readonly string[]) {
|
|
57
|
+
return { isSuccess: true, data: { roles } } as const;
|
|
58
|
+
}
|
|
@@ -476,6 +476,9 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
476
476
|
method: "GET",
|
|
477
477
|
path: "/user-export/by-token",
|
|
478
478
|
anonymous: true,
|
|
479
|
+
// Entry point of the emailed download link; cheap static page, but
|
|
480
|
+
// anonymous and token-bearing, so bound per-IP probing.
|
|
481
|
+
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
|
|
479
482
|
handler: async (c) => {
|
|
480
483
|
return c.body(TOKEN_EXCHANGE_PAGE_HTML, 200, {
|
|
481
484
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Waitlist submit is limited per email address"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.341.0",
|
|
9
|
+
"type": "improvement",
|
|
10
|
+
"title": "Add waitlist bundled feature: public signup intake with admin invite and reject, GDPR export and erasure"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.341.0",
|
|
14
|
+
"type": "fix",
|
|
15
|
+
"title": "Waitlist re-invite and reject no longer touch an invitee who already accepted"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pure string constants, safe for browser code (see auth-email-password/constants.ts).
|
|
3
|
+
export const WAITLIST_FEATURE = "waitlist" as const;
|
|
4
|
+
|
|
5
|
+
export const WAITLIST_STATUS = {
|
|
6
|
+
Pending: "pending",
|
|
7
|
+
Invited: "invited",
|
|
8
|
+
Rejected: "rejected",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type WaitlistStatus = (typeof WAITLIST_STATUS)[keyof typeof WAITLIST_STATUS];
|
|
12
|
+
|
|
13
|
+
export const WaitlistHandlers = {
|
|
14
|
+
submit: "waitlist:write:submit",
|
|
15
|
+
invite: "waitlist:write:invite",
|
|
16
|
+
reject: "waitlist:write:reject",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
// Entity-convention list QN: entityList screens resolve their query by this shape.
|
|
20
|
+
export const WaitlistQueries = {
|
|
21
|
+
list: "waitlist:query:waitlist-entry:list",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export const WaitlistErrors = {
|
|
25
|
+
notInvitable: "waitlist_not_invitable",
|
|
26
|
+
notRejectable: "waitlist_not_rejectable",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export const WAITLIST_NOTIFICATION_TYPES = {
|
|
30
|
+
confirmation: "waitlist:notify:confirmation",
|
|
31
|
+
adminNotice: "waitlist:notify:admin-notice",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_OWN_TENANT_INVITE_ROLE = "TenantAdmin";
|
|
35
|
+
|
|
36
|
+
export const WAITLIST_FIELD_LIMITS = {
|
|
37
|
+
name: 120,
|
|
38
|
+
email: 254,
|
|
39
|
+
company: 200,
|
|
40
|
+
portfolio: 200,
|
|
41
|
+
message: 2000,
|
|
42
|
+
locale: 35,
|
|
43
|
+
honeypot: 500,
|
|
44
|
+
} as const;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createLongTextField,
|
|
5
|
+
createSelectField,
|
|
6
|
+
createTextField,
|
|
7
|
+
createTimestampField,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { WAITLIST_STATUS, type WaitlistStatus } from "./constants.js";
|
|
10
|
+
|
|
11
|
+
const WAITLIST_STATUS_OPTIONS = [
|
|
12
|
+
WAITLIST_STATUS.Pending,
|
|
13
|
+
WAITLIST_STATUS.Invited,
|
|
14
|
+
WAITLIST_STATUS.Rejected,
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
const OPEN_WAITLIST_STATUSES: readonly WaitlistStatus[] = [
|
|
18
|
+
WAITLIST_STATUS.Pending,
|
|
19
|
+
WAITLIST_STATUS.Invited,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Entries still awaiting a decision; rejected is terminal.
|
|
23
|
+
export function isOpenWaitlistStatus(status: unknown): boolean {
|
|
24
|
+
return OPEN_WAITLIST_STATUSES.some((open) => open === status);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// An entrant has no user account, so name/email/message are personal: "self" —
|
|
28
|
+
// the row's own id is its crypto-shredding subject. find: "fuzzy" keeps the
|
|
29
|
+
// blind-index equality lookup (dedupe, GDPR export by email) and full-text
|
|
30
|
+
// search alive; sortable stays off, the boot validator rejects it on subject fields.
|
|
31
|
+
// tenancy "global" + systemStream: entries are platform-wide and live on
|
|
32
|
+
// SYSTEM_TENANT_ID, which also lets the per-tenant GDPR hooks read them.
|
|
33
|
+
export const waitlistEntryEntity = createEntity({
|
|
34
|
+
table: "read_waitlist_entries",
|
|
35
|
+
description:
|
|
36
|
+
"A prospect's signup on the platform waitlist, tracked through pending, invited and rejected status as admins process it; name, email and message are the entry's own personal data.",
|
|
37
|
+
systemStream: true,
|
|
38
|
+
tenancy: "global",
|
|
39
|
+
fields: {
|
|
40
|
+
name: createTextField({ required: true, personal: "self", find: "fuzzy", searchable: true }),
|
|
41
|
+
email: createTextField({ required: true, personal: "self", find: "fuzzy", searchable: true }),
|
|
42
|
+
company: createTextField({
|
|
43
|
+
personal: false,
|
|
44
|
+
reason: "is_business_data",
|
|
45
|
+
searchable: true,
|
|
46
|
+
sortable: true,
|
|
47
|
+
}),
|
|
48
|
+
portfolio: createTextField({ personal: false, reason: "is_business_data" }),
|
|
49
|
+
message: createLongTextField({ personal: "self", find: "none" }),
|
|
50
|
+
locale: createTextField({
|
|
51
|
+
required: true,
|
|
52
|
+
personal: false,
|
|
53
|
+
reason: "is_ui_language_preference",
|
|
54
|
+
sortable: true,
|
|
55
|
+
}),
|
|
56
|
+
status: createSelectField({
|
|
57
|
+
options: WAITLIST_STATUS_OPTIONS,
|
|
58
|
+
required: true,
|
|
59
|
+
default: WAITLIST_STATUS.Pending,
|
|
60
|
+
filterable: true,
|
|
61
|
+
sortable: true,
|
|
62
|
+
}),
|
|
63
|
+
submittedAt: createTimestampField({
|
|
64
|
+
personal: false,
|
|
65
|
+
sortable: true,
|
|
66
|
+
required: true,
|
|
67
|
+
reason: "is_submission_timestamp",
|
|
68
|
+
}),
|
|
69
|
+
invitedAt: createTimestampField(),
|
|
70
|
+
invitedBy: createTextField({ personal: false, reason: "is_admin_audit_id" }),
|
|
71
|
+
// Not named tenantId: that is the framework's physical tenant column.
|
|
72
|
+
linkedTenantId: createTextField({ personal: false, reason: "is_tenant_reference" }),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const waitlistEntryTable = buildEntityTable("waitlistEntry", waitlistEntryEntity);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineFeature,
|
|
3
|
+
EXT_USER_DATA,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
i18nKey,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { WAITLIST_FEATURE } from "./constants.js";
|
|
8
|
+
import { waitlistEntryEntity } from "./entity.js";
|
|
9
|
+
import { createInviteHandler } from "./handlers/invite.write.js";
|
|
10
|
+
import { listQuery } from "./handlers/list.query.js";
|
|
11
|
+
import { rejectHandler } from "./handlers/reject.write.js";
|
|
12
|
+
import { createSubmitHandler } from "./handlers/submit.write.js";
|
|
13
|
+
import { WAITLIST_I18N } from "./i18n.js";
|
|
14
|
+
import { adminAccess } from "./lib.js";
|
|
15
|
+
import type { WaitlistOptions } from "./options.js";
|
|
16
|
+
import { waitlistListScreen } from "./screens.js";
|
|
17
|
+
import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-hooks.js";
|
|
18
|
+
|
|
19
|
+
export function createWaitlistFeature(opts: WaitlistOptions = {}): FeatureDefinition {
|
|
20
|
+
return defineFeature(WAITLIST_FEATURE, (r) => {
|
|
21
|
+
r.describe(
|
|
22
|
+
"Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response never reveals whether an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
|
|
23
|
+
);
|
|
24
|
+
r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
|
|
25
|
+
r.systemScope();
|
|
26
|
+
r.requires("tenant", "auth-email-password", "user-data-rights");
|
|
27
|
+
r.translations({ keys: WAITLIST_I18N });
|
|
28
|
+
|
|
29
|
+
r.entity("waitlistEntry", waitlistEntryEntity);
|
|
30
|
+
r.writeHandler(createSubmitHandler(opts));
|
|
31
|
+
r.writeHandler(createInviteHandler(opts.invite));
|
|
32
|
+
r.writeHandler(rejectHandler);
|
|
33
|
+
r.queryHandler(listQuery);
|
|
34
|
+
|
|
35
|
+
r.screen(waitlistListScreen);
|
|
36
|
+
r.nav({
|
|
37
|
+
id: "waitlist",
|
|
38
|
+
label: i18nKey("waitlist.nav.waitlist"),
|
|
39
|
+
screen: "waitlist:screen:waitlist-list",
|
|
40
|
+
icon: "users",
|
|
41
|
+
order: 15,
|
|
42
|
+
access: adminAccess,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
r.useExtension(EXT_USER_DATA, "waitlistEntry", {
|
|
46
|
+
export: waitlistEntryExportHook,
|
|
47
|
+
delete: waitlistEntryDeleteHook,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|