Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# @toolctrl/web
Runtime owner for the React Router web application.
This package owns the web lifecycle commands (`dev`, `build`, `typegen`, `typecheck`, `test`, `start`), the React Router source tree under `apps/web/app`, and static web assets under `apps/web/public`.
New runtime code should treat `apps/web` as the project boundary. Root-level `app` and `public` paths are no longer the active source tree.
Ownership:
- Authenticated web UI.
- Host-aware app and admin routes.
- Tenant-facing browser workflows.
- Web-specific deployment entrypoint.
@@ -0,0 +1,6 @@
!.eslintrc
!utils/services/.server/
!utils/services/.server/urlService.test.ts
!utils/services/apiService.entity-resolver.test.ts
!test/isolation/fixtures/user-session-isolation.test.ts
!test/isolation/fixtures/user-session-isolation.ts
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import Constants from "./Constants";
describe("Constants", () => {
it("Constants exports the correct values", () => {
expect(Constants.DEFAULT_PAGE_SIZE).toEqual(10);
expect(Constants.DEFAULT_ROW_VISIBILITY).toEqual("tenant");
expect(Constants.MAX_PAGE_SIZE).toEqual(100);
expect(Constants.MAX_EXPORT_PAGE_SIZE).toEqual(100000);
expect(Constants.PAGE_SIZE_OPTIONS).toEqual([5, 10, 25, 50, 100]);
});
});
@@ -0,0 +1,27 @@
import { DefaultVisibility } from "./dtos/shared/DefaultVisibility";
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_ROW_VISIBILITY = DefaultVisibility.Tenant;
const MAX_PAGE_SIZE = 100;
const MAX_EXPORT_PAGE_SIZE = 100000;
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50, 100];
const VERSION = "1.7.0";
export const DARK_MODE_IN_APP = true;
// export const DARK_SIDEBAR_IN_LIGHT_MODE = true;
export default {
DEFAULT_PAGE_SIZE,
DEFAULT_ROW_VISIBILITY,
MAX_PAGE_SIZE,
MAX_EXPORT_PAGE_SIZE,
PAGE_SIZE_OPTIONS,
VERSION,
};
export const DARK_SIDEBAR_IN_LIGHT_MODE = ({ layout }: { layout?: "app" | "admin" | "docs" }) => {
if (layout === "admin") {
return true;
}
return false;
};
@@ -0,0 +1,133 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { repoPathExists } from "~/utils/testing/repoPaths";
type FeatureManifest = {
id: string;
classification: string;
pruning: {
action: string;
};
};
const rootDir = process.cwd();
const docs = {
migrationStatus: "docs/architecture-migration-status.md",
featureInventory: "docs/feature-inventory.md",
manifestReadme: "docs/feature-manifests/README.md",
agentEntrypoints: "docs/agent-entrypoints.md",
} as const;
const requiredArchitectureClosureSources = [
"docs/architecture-migration-status.md",
"docs/feature-inventory.md",
"docs/feature-manifests/",
];
const documentedS04Evidence = ["apps/web/app/utils/build/bundleScan.ts", "apps/web/app/utils/build/manualChunks.ts"];
const requiredS04Evidence = [...documentedS04Evidence, "apps/web/app/utils/build/viteConfigIntegration.test.ts"];
function readProjectFile(filePath: string) {
return fs.readFileSync(path.join(rootDir, filePath), "utf8");
}
function readManifest(fileName: string) {
return JSON.parse(readProjectFile(path.join("docs/feature-manifests", fileName))) as FeatureManifest;
}
describe("Architecture Closure closure documentation", () => {
it("keeps architecture status anchored to boundaries, pruning manifests and S04 evidence", () => {
const status = readProjectFile(docs.migrationStatus);
expect(status).toContain("## Architecture Closure final closure status");
expect(status).toMatch(/handoff contract/i);
expect(status).toContain("apps/api");
expect(status).toContain("apps/worker");
expect(status).toContain("packages/db");
expect(status).toContain("packages/mail");
expect(status).toContain("docs/feature-manifests/");
expect(status).toContain("docs/feature-inventory.md");
expect(status).toContain("core`, `plugin-candidate`, `remove-candidate");
documentedS04Evidence.forEach((evidencePath) => {
expect(status, evidencePath).toContain(evidencePath);
});
});
it("keeps the feature inventory aligned with Architecture Closure status and pruning actions", () => {
const inventory = readProjectFile(docs.featureInventory);
expect(inventory).toContain("docs/architecture-migration-status.md");
expect(inventory).toContain("docs/feature-manifests/");
expect(inventory).toContain("`core` bleibt die aktive Boilerplate-Basis");
expect(inventory).toContain("`product-core` beschreibt Blueprint-, Plan-, Custom-Plan-, Tenant-Override- und Tenant-Rollen-Gates");
expect(inventory).toContain("`plugin-candidate` beschreibt nur noch Extraktionsvertraege");
expect(inventory).toContain("`remove-candidate` beschreibt Loesch- oder Review-Vertraege");
expect(inventory).toContain("API-Split");
expect(inventory).toContain("Worker/API/Mail-Split");
});
it("documents the executor-facing manifest contract and source alignment", () => {
const readme = readProjectFile(docs.manifestReadme);
requiredArchitectureClosureSources.forEach((sourcePath) => {
expect(readme, sourcePath).toContain(sourcePath);
});
expect(readme).toContain("current API, worker, package, pruning, and bundle handoff status");
expect(readme).toContain("machine-readable Core/Plugin/Removal contract");
expect(readme).toContain("ownership, dependencies, configuration, provider, worker, UAT, and pruning boundaries");
expect(readme).toContain("pruning.action");
expect(readme).toContain("keep`, `extract-plugin`, `remove`, or `review");
});
it("points agents at every canonical Architecture Closure planning source before feature work", () => {
const entrypoints = readProjectFile(docs.agentEntrypoints);
expect(entrypoints).toContain("Architecture Closure closure/status contract");
expect(entrypoints).toContain("docs/architecture-migration-status.md");
expect(entrypoints).toContain("docs/feature-inventory.md");
expect(entrypoints).toContain("docs/feature-manifests/*.json");
expect(entrypoints).toContain("before planning implementation, extraction, or removal work");
});
it("keeps the complete S04 bundle evidence set present for future verification", () => {
requiredS04Evidence.forEach((evidencePath) => {
expect(repoPathExists(evidencePath), evidencePath).toBe(true);
});
});
});
describe("Architecture Closure feature manifest pruning contract", () => {
const manifestDir = path.join(rootDir, "docs/feature-manifests");
const manifestFiles = fs
.readdirSync(manifestDir)
.filter((file) => file.endsWith(".json") && file !== "schema.json")
.sort();
it("keeps every non-schema feature manifest JSON-parseable", () => {
expect(manifestFiles.length).toBeGreaterThan(0);
manifestFiles.forEach((file) => {
expect(() => JSON.parse(readProjectFile(path.join("docs/feature-manifests", file))), file).not.toThrow();
});
});
it("exposes the canonical core/plugin/removal pruning actions", () => {
expect(readManifest("core.json")).toMatchObject({
id: "core",
classification: "core",
pruning: { action: "keep" },
});
expect(readManifest("plugin-candidates.json")).toMatchObject({
id: "plugin-candidates",
classification: "plugin-candidate",
pruning: { action: "extract-plugin" },
});
expect(readManifest("remove-candidates.json")).toMatchObject({
id: "remove-candidates",
classification: "remove-candidate",
pruning: { action: "remove" },
});
});
});
@@ -0,0 +1,71 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const rootDir = process.cwd();
const docs = {
handoffSummary: "docs/archive/architecture-handoff-summary.md",
agentEntrypoints: "docs/agent-entrypoints.md",
} as const;
const requiredCanonicalSources = [
"docs/architecture-migration-status.md",
"docs/feature-inventory.md",
"docs/feature-manifests/README.md",
"docs/feature-manifests/*.json",
"docs/agent-entrypoints.md",
"apps/web/app/application/architectureClosureDocs.test.ts",
"apps/web/app/application/featureManifests.test.ts",
];
const requiredGateCommands = [
"pnpm exec vitest run apps/web/app/application/architectureClosureDocs.test.ts apps/web/app/application/featureManifests.test.ts",
"pnpm exec eslint --ext .tsx,.ts",
"pnpm typecheck",
];
function readProjectFile(filePath: string) {
return fs.readFileSync(path.join(rootDir, filePath), "utf8");
}
describe("Architecture Closure handoff summary contract", () => {
it("keeps the executor-facing handoff summary present", () => {
expect(fs.existsSync(path.join(rootDir, docs.handoffSummary))).toBe(true);
});
it("anchors the handoff to every canonical closure source", () => {
const handoff = readProjectFile(docs.handoffSummary);
requiredCanonicalSources.forEach((sourcePath) => {
expect(handoff, sourcePath).toContain(sourcePath);
});
});
it("documents Architecture Closure requirement coverage and prior-slice closure continuity", () => {
const handoff = readProjectFile(docs.handoffSummary);
expect(handoff).toContain("R001");
expect(handoff).toContain("R002");
expect(handoff).toMatch(/S04 bundle\/performance continuity/i);
expect(handoff).toMatch(/sequential follow-up order/i);
expect(handoff).toMatch(/avoiding any assumption that downstream feature slices can run in parallel/i);
});
it("preserves the non-E2E full gate and manual build follow-up wording", () => {
const handoff = readProjectFile(docs.handoffSummary);
requiredGateCommands.forEach((command) => {
expect(handoff, command).toContain(command);
});
expect(handoff).toMatch(/No E2E, browser, Cypress, or Playwright execution/i);
expect(handoff).toMatch(/pnpm build.*optional manual follow-up/i);
expect(handoff).toMatch(/not an Auto-Mode completion gate/i);
});
it("keeps the agent entrypoint pointed at the Architecture Closure handoff in closure/status context", () => {
const entrypoints = readProjectFile(docs.agentEntrypoints);
expect(entrypoints).toContain("Architecture Closure closure/status contract");
expect(entrypoints).toContain(docs.handoffSummary);
});
});
@@ -0,0 +1,13 @@
import { CookieCategory } from "./CookieCategory";
export type ApplicationCookie = {
category: CookieCategory;
name: string;
domain?: string;
expiry?: string;
expiryKey?: string;
description?: string;
descriptionKey?: string;
type?: "http" | "https";
href?: string;
};
@@ -0,0 +1,33 @@
import {describe, it, expect} from 'vitest'
import { CookieCategory } from "./CookieCategory";
import { allApplicationCookies, allCookieCategories } from "./ApplicationCookies";
describe("CookieData", () => {
describe("allApplicationCookies", () => {
it("should contain at least one cookie", () => {
expect(allApplicationCookies.length).toBeGreaterThan(0);
});
it("should have unique cookie names", () => {
const cookieNames = allApplicationCookies.map((cookie) => cookie.name);
const uniqueCookieNames = new Set(cookieNames);
expect(uniqueCookieNames.size).toEqual(cookieNames.length);
});
it("should provide translation keys for rendered expiry values", () => {
expect(allApplicationCookies.filter((cookie) => cookie.expiry && !cookie.expiryKey)).toEqual([]);
});
});
describe("allCookieCategories", () => {
it("should contain all categories present in allApplicationCookies", () => {
const expectedCategories = [
CookieCategory.REQUIRED,
CookieCategory.FUNCTIONAL,
CookieCategory.ANALYTICS,
CookieCategory.ADVERTISEMENT,
];
expect(allCookieCategories).toEqual(expectedCategories);
});
});
});
@@ -0,0 +1,84 @@
import { ApplicationCookie } from "./ApplicationCookie";
import { CookieCategory } from "./CookieCategory";
// I used https://www.cookieserve.com/report
const allApplicationCookies: ApplicationCookie[] = [
{
category: CookieCategory.REQUIRED,
name: "RJ_session",
expiry: "1 month",
expiryKey: "cookies.expiry.oneMonth",
descriptionKey: "cookies.items.session.description",
},
{
category: CookieCategory.FUNCTIONAL,
name: "crisp-client/*",
expiry: "6 months",
expiryKey: "cookies.expiry.sixMonths",
descriptionKey: "cookies.items.crisp.description",
href: "https://help.crisp.chat/en/article/crisp-chatbox-cookie-ip-policy-1147xor/",
},
{
category: CookieCategory.ANALYTICS,
name: "app_analytics",
expiry: "1 month",
expiryKey: "cookies.expiry.oneMonth",
type: "https",
descriptionKey: "cookies.items.analytics.description",
},
{
category: CookieCategory.ANALYTICS,
name: "CONSENT",
domain: ".youtube.com",
expiry: "2 years",
expiryKey: "cookies.expiry.twoYears",
descriptionKey: "cookies.items.youtubeConsent.description",
href: "https://policies.google.com/technologies/cookies",
},
{
category: CookieCategory.ADVERTISEMENT,
name: "VISITOR_INFO1_LIVE",
domain: ".youtube.com",
expiry: "5 months 27 days",
expiryKey: "cookies.expiry.fiveMonths27Days",
descriptionKey: "cookies.items.youtubeVisitor.description",
href: "https://policies.google.com/technologies/cookies",
},
{
category: CookieCategory.ADVERTISEMENT,
name: "YSC",
domain: ".youtube.com",
expiry: "Session",
expiryKey: "cookies.expiry.session",
descriptionKey: "cookies.items.youtubeYsc.description",
href: "https://policies.google.com/technologies/cookies",
},
{
category: CookieCategory.ADVERTISEMENT,
name: "yt-remote-device-id",
domain: ".youtube.com",
expiry: "Never",
expiryKey: "cookies.expiry.never",
descriptionKey: "cookies.items.youtubeRemoteDevice.description",
href: "https://policies.google.com/technologies/cookies",
},
{
category: CookieCategory.ADVERTISEMENT,
name: "yt-remote-connected-devices",
domain: ".youtube.com",
expiry: "Never",
expiryKey: "cookies.expiry.never",
descriptionKey: "cookies.items.youtubeRemoteConnected.description",
href: "https://policies.google.com/technologies/cookies",
},
];
const allCookieCategories: CookieCategory[] = [];
allApplicationCookies.forEach((cookie) => {
if (!allCookieCategories.includes(cookie.category)) {
allCookieCategories.push(cookie.category);
}
});
export { allCookieCategories, allApplicationCookies };
@@ -0,0 +1,6 @@
export enum CookieCategory {
REQUIRED, // userId, lightOrDarkMode, lng, csrf, consent
FUNCTIONAL, // chat, feedback
ANALYTICS, // analytics
ADVERTISEMENT, // ads
}
@@ -0,0 +1,8 @@
export type ApiKeyEntityPermissionDto = {
id: string;
name: string;
create: boolean;
read: boolean;
update: boolean;
delete: boolean;
};
@@ -0,0 +1,6 @@
export enum ContactStatus {
Lead = "Lead",
Prospect = "Prospect",
Customer = "Customer",
Partner = "Partner",
}
@@ -0,0 +1,5 @@
export type ColumnDto = {
name: string;
title: string;
visible: boolean;
};
@@ -0,0 +1,13 @@
import { Colors } from "~/application/enums/shared/Colors";
export interface FilterablePropertyDto {
name: string;
title: string;
manual?: boolean;
condition?: string;
value?: string | null;
options?: { name: string; value: string; color?: Colors }[];
isNumber?: boolean;
isBoolean?: boolean;
hideSearch?: boolean;
}
@@ -0,0 +1,6 @@
import { FilterablePropertyDto } from "./FilterablePropertyDto";
export interface FiltersDto {
properties: FilterablePropertyDto[];
query?: string | null;
}
@@ -0,0 +1,15 @@
import { SortedByDto } from "./SortedByDto";
export interface PaginationDto {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
sortedBy?: SortedByDto[];
query?: string | undefined;
}
export interface PaginationData<T> {
items: T[];
pagination: PaginationDto | undefined;
}
@@ -0,0 +1,8 @@
export interface PaginationResultDto {
totalItems: number;
totalPages: number;
page: number;
pageSize: number;
sortedBy: any;
query: any;
}
@@ -0,0 +1,17 @@
import { Entity, Property } from "@toolctrl/db/prisma-browser";
import { EntityRelationshipWithDetails } from "~/utils/db/entities/entityRelationships.db.server";
export interface RowFiltersDto {
customRow: boolean;
entity: Entity;
query: string | null;
properties: {
property?: Property;
name?: string;
value: string | string[] | null;
condition?: string;
match?: "and" | "or";
parentEntity?: EntityRelationshipWithDetails;
}[];
tags: string[];
}
@@ -0,0 +1,19 @@
import { ReactNode } from "react";
export interface RowHeaderActionDto<T> {
title?: string | ReactNode;
onClick?: (idx: number, item: T) => void;
onClickRoute?: (idx: number, item: T) => string | undefined;
onClickRouteTarget?: undefined | "_blank";
disabled?: (item: T) => boolean | boolean;
hidden?: (item: T) => boolean;
destructive?: boolean;
firstColumn?: boolean;
renderTitle?: (item: T) => ReactNode;
renderIsDestructive?: (item: T) => boolean;
prefetch?: "intent" | "render" | "none";
confirmation?: (item: T) => {
title: string;
description: string;
};
}
@@ -0,0 +1,23 @@
import { ReactNode } from "react";
import { InputType } from "~/application/enums/shared/InputType";
export type RowHeaderDisplayDto<T> = {
title: string;
name?: string;
type?: InputType;
value: (item: T, idx: number) => any;
href?: (item: T) => string | undefined;
formattedValue?: (item: T, idx?: number) => string | ReactNode;
options?: { name: string; value: number | string; disabled?: boolean }[];
setValue?: (value: any, idx: number) => void;
editable?: (item: T, idx?: number) => boolean;
className?: string;
sortable?: boolean;
breakpoint?: "sm" | "md" | "lg" | "xl" | "2xl";
inputNumberStep?: string;
inputBorderless?: boolean;
inputOptional?: boolean;
sortBy?: string;
align?: "left" | "center" | "right";
hidden?: boolean;
};
@@ -0,0 +1,8 @@
import { Entity, Property } from "@toolctrl/db/prisma-browser";
export interface SortedByDto {
name: string;
direction: "asc" | "desc";
property?: Property;
entity?: Entity;
}
@@ -0,0 +1,10 @@
export type EmailTemplate = {
type: "standard" | "layout";
name: string;
alias: string;
subject: string;
htmlBody: string;
active: boolean;
associatedServerId: number;
templateId: number;
};
@@ -0,0 +1,10 @@
export type MediaDto = {
id?: string;
title: string;
name: string;
file: string;
type: string;
publicUrl?: string | null;
storageBucket?: string | null;
storageProvider?: string | null;
};
@@ -0,0 +1,9 @@
import { Entity, Property, RowValue } from "@toolctrl/db/prisma-browser";
export interface RowDynamicValuesDto {
form: Entity | undefined;
name: string;
total: number;
headers: (RowValue & { property: Property })[];
// details: RequestDetailDto[];
}
@@ -0,0 +1,7 @@
export type RowPermissionsDto = {
canRead?: boolean;
canComment: boolean;
canUpdate: boolean;
canDelete: boolean;
isOwner: boolean;
};
@@ -0,0 +1,16 @@
import { RowMedia, RowValueMultiple, RowValueRange } from "@toolctrl/db/prisma-browser";
import { PropertyWithDetails } from "~/utils/db/entities/entities.db.server";
export type RowValueDto = {
id?: string | null;
property: PropertyWithDetails;
propertyId: string;
textValue?: string | undefined;
numberValue?: number | undefined;
dateValue?: Date | undefined;
booleanValue?: boolean | undefined;
selectedOption?: string | undefined;
media?: RowMedia[];
multiple?: RowValueMultiple[];
range?: RowValueRange | undefined;
};
@@ -0,0 +1,5 @@
export type RowValueMultipleDto = {
id?: string;
order: number;
value: string;
};
@@ -0,0 +1,8 @@
import { Decimal } from "decimal.js";
export type RowValueRangeDto = {
numberMin: Decimal | number | null;
numberMax: Decimal | number | null;
dateMin: Date | null;
dateMax: Date | null;
};
@@ -0,0 +1,9 @@
export type GridLayoutDto = {
columns?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
xl2?: number;
gap: "xs" | "sm" | "md" | "lg" | "xl";
};
@@ -0,0 +1,31 @@
export interface AnalyticsInfoDto {
ip?: string;
userAgent?: {
type: string | undefined;
os: {
family: string | undefined;
version: string | undefined;
};
browser: {
name: string | undefined;
version: string | undefined;
};
};
referrer?: {
via?: string;
http?: string;
source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
utm_term?: string;
};
region?: {
country?: string;
city?: string;
};
userId: string | null;
portalId?: string | null;
portalUserId?: string | null;
userAnalyticsId?: string;
}
@@ -0,0 +1,37 @@
type ServerRuntimeMetaDescriptor =
| {
charSet: "utf-8";
}
| {
title: string;
}
| {
name: string;
content: string;
}
| {
property: string;
content: string;
}
| {
httpEquiv: string;
content: string;
}
| {
"script:ld+json": LdJsonObject;
}
| {
tagName: "meta" | "link";
[name: string]: string;
}
| any;
type LdJsonObject = {
[Key in string]: LdJsonValue;
} & {
[Key in string]?: LdJsonValue | undefined;
};
type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
type LdJsonPrimitive = string | number | boolean | null;
type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;
export type MetaTagsDto = ServerRuntimeMetaDescriptor[];
@@ -0,0 +1,6 @@
import { UserWithoutPassword } from "~/utils/db/users.db.server";
export type ImpersonatingSessionDto = {
fromUser: UserWithoutPassword;
toUser: UserWithoutPassword;
};
@@ -0,0 +1,6 @@
export type SetupItem = {
title: string;
description: string;
completed: boolean;
path: string | undefined;
};
@@ -0,0 +1,6 @@
export type CreatedByDto = {
byUserId?: string | null;
byApiKeyId?: string | null;
byEmailId?: string | null;
byEventWebhookAttemptId?: string | null;
};
@@ -0,0 +1,8 @@
export class CustomError {
public message: string;
public details?: Record<string, any>;
public constructor(message: string, details?: Record<string, any>) {
this.message = message;
this.details = details;
}
}
@@ -0,0 +1,3 @@
export enum DefaultAdminRoles {
SuperAdmin = "SuperAdmin",
}
@@ -0,0 +1,6 @@
export enum DefaultAppRoles {
SuperUser = "SuperUser",
Admin = "Admin",
BillingAdmin = "Billing Admin",
User = "User",
}
@@ -0,0 +1,6 @@
export enum DefaultEntityTypes {
All = "all",
AppOnly = "app",
AdminOnly = "admin",
System = "system",
}
@@ -0,0 +1,9 @@
export enum DefaultFeatures {
Users = "users",
API = "api",
RateLimitPerMinute = "rate-limit-per-minute",
RateLimitPerSecond = "rate-limit-per-second",
PrioritySupport = "priority-support",
Credits = "ai-credits",
PrivateGenerations = "private-generations",
}
@@ -0,0 +1,11 @@
export enum DefaultLogActions {
Login = "Login",
Created = "Created",
Updated = "Updated",
Deleted = "Deleted",
Commented = "Commented",
Replied = "Replied",
Reacted = "Reacted",
RemovedReaction = "Removed Reaction",
Logout = "Logout",
}
@@ -0,0 +1,140 @@
export const DefaultPermissions = [
"admin.dashboard.view",
"admin.accounts.view",
"admin.accounts.create",
"admin.account.view",
"admin.account.settings.update",
"admin.account.users",
"admin.account.subscription",
"admin.account.delete",
"admin.accountTypes.view",
"admin.accountTypes.create",
"admin.accountTypes.update",
"admin.accountTypes.delete",
"admin.users.view",
"admin.users.impersonate",
"admin.users.changePassword",
"admin.users.delete",
"admin.relationships.view",
"admin.relationships.create",
"admin.relationships.update",
"admin.relationships.delete",
"admin.blacklist.view",
"admin.tenantIpAddress.view",
"admin.blacklist.manage",
"admin.roles.view",
"admin.roles.create",
"admin.roles.update",
"admin.roles.delete",
"admin.roles.set",
"admin.analytics.view",
"admin.analytics.delete",
"admin.entities.view",
"admin.entities.create",
"admin.entities.update",
"admin.entities.delete",
// "admin.workflows.view",
// "admin.workflows.create",
// "admin.workflows.update",
// "admin.workflows.delete",
"admin.apiKeys.view",
"admin.apiKeys.create",
"admin.apiKeys.update",
"admin.apiKeys.delete",
"admin.auditTrails.view",
"admin.events.view",
"admin.pricing.view",
"admin.pricing.create",
"admin.pricing.update",
"admin.pricing.delete",
"admin.emails.view",
"admin.emails.create",
"admin.emails.update",
"admin.emails.delete",
"admin.onboarding.view",
"admin.onboarding.create",
"admin.onboarding.update",
"admin.onboarding.delete",
"admin.notifications.view",
"admin.notifications.manage",
"admin.featureFlags.view",
"admin.featureFlags.manage",
"admin.marketing.leads.view",
"admin.marketing.leads.manage",
"admin.marketing.integrations.view",
"admin.marketing.integrations.manage",
"admin.marketing.funnels.view",
"admin.prompts.view",
"admin.prompts.create",
"admin.prompts.update",
"admin.prompts.delete",
"admin.formulas.view",
"admin.formulas.create",
"admin.formulas.update",
"admin.formulas.delete",
"admin.metrics.view",
"admin.metrics.update",
"admin.metrics.delete",
"admin.kb.view",
"admin.kb.create",
"admin.kb.update",
"admin.kb.delete",
"admin.settings.cookies.view",
"admin.settings.cookies.create",
"admin.settings.cookies.update",
"admin.settings.general.view",
"admin.settings.general.update",
"admin.settings.authentication.view",
"admin.settings.authentication.update",
"admin.settings.analytics.view",
"admin.settings.analytics.update",
"admin.settings.seo.update",
"admin.settings.internationalization.view",
"admin.settings.internationalization.update",
"admin.settings.danger.reset",
"app.dashboard.view",
"app.settings.members.view",
"app.settings.members.create",
"app.settings.members.update",
"app.settings.members.delete",
"app.settings.members.impersonate",
"app.settings.roles.view",
"app.settings.roles.set",
"app.settings.groups.full",
"app.settings.subscription.view",
"app.settings.subscription.update",
"app.settings.subscription.delete",
"app.settings.subscription.invoices.view",
"app.settings.account.view",
"app.settings.account.update",
"app.settings.account.delete",
"app.settings.accounts.view",
"app.settings.accounts.create",
"app.settings.accounts.update",
"app.settings.accounts.delete",
"app.settings.apiKeys.view",
"app.settings.apiKeys.create",
"app.settings.apiKeys.update",
"app.settings.apiKeys.delete",
"app.settings.auditTrails.view",
"app.plugins.use",
"app.plugins.manage",
// P4 Files (AK-P4a) — see packages/db permissions-seed (same list).
"files.read_own",
"files.read_all",
"files.write",
"files.delete",
"files.admin",
// P4 Tasks (AK-P4b) — see packages/db permissions-seed (same list).
"tasks.read_own",
"tasks.read_all",
"tasks.write",
"tasks.delete",
"tasks.admin",
// "entity.{name}.view",
// "entity.{name}.read",
// "entity.{name}.create",
// "entity.{name}.update",
// "entity.{name}.delete",
] as const;
export type DefaultPermission = (typeof DefaultPermissions)[number];
@@ -0,0 +1,5 @@
export enum DefaultVisibility {
Private = "private",
Tenant = "tenant",
Public = "public",
}
@@ -0,0 +1,4 @@
export interface FileBase64 {
file: File;
base64: any;
}
@@ -0,0 +1,14 @@
import { StatChange } from "./StatChange";
export type Stat = {
name: string;
hint: string;
stat: string;
previousStat?: string;
change?: string;
changeType?: StatChange;
path?: string;
entity?: {
slug: string;
};
};
@@ -0,0 +1,5 @@
export enum StatChange {
Decrease,
Equal,
Increase,
}
@@ -0,0 +1,8 @@
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
export interface BillingPeriodDto {
value: SubscriptionBillingPeriod;
default?: boolean;
disabled?: boolean;
recurring: boolean;
}
@@ -0,0 +1,11 @@
export interface CurrencyDto {
name: string;
value: string;
symbol: string;
default?: boolean;
disabled?: boolean;
parities?: { from: string; parity: number }[];
symbolRight?: boolean;
thousandSeparator?: string;
decimalSeparator?: string;
}
@@ -0,0 +1,8 @@
export interface FlatPriceDto {
currency: string;
monthlyPrice?: number;
yearlyPrice?: number;
oneTimePrice?: number;
quarterlyPrice?: number;
semiAnnualPrice?: number;
}
@@ -0,0 +1,18 @@
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
export interface PlanFeatureUsageDto {
order: number;
title: string;
name: string;
type: SubscriptionFeatureLimitType;
value: number;
used: number;
remaining: number | "unlimited";
enabled: boolean;
message: string;
entity?: { id: string; title: string; titlePlural: string; slug: string };
period?: {
firstDay: Date | null;
lastDay: Date | null;
};
}
@@ -0,0 +1,12 @@
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
export interface SubscriptionFeatureDto {
order: number;
title: string;
name: string;
type: SubscriptionFeatureLimitType;
value: number;
href?: string | null;
badge?: string | null;
accumulate?: boolean;
}
@@ -0,0 +1,16 @@
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
export interface SubscriptionFeatureInPlansDto {
id: string;
order: number;
name: string;
href?: string | null;
badge?: string | null;
accumulate?: boolean;
plans: {
id: string;
title: string;
type: SubscriptionFeatureLimitType;
value: number;
}[];
}
@@ -0,0 +1,20 @@
import { SubscriptionProductDto } from "./SubscriptionProductDto";
import { SubscriptionPriceType } from "~/application/enums/subscriptions/SubscriptionPriceType";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { SubscriptionUsageBasedTier } from "@toolctrl/db/prisma-browser";
import { Decimal } from "decimal.js";
export interface SubscriptionPriceDto {
id?: string;
stripeId: string;
type: SubscriptionPriceType;
billingPeriod: SubscriptionBillingPeriod;
price: number | Decimal;
currency: string;
trialDays: number;
active: boolean;
priceBefore?: number;
subscriptionProductId: string;
subscriptionProduct?: SubscriptionProductDto;
usageBasedPrices?: SubscriptionUsageBasedTier[];
}
@@ -0,0 +1,28 @@
import { SubscriptionPriceDto } from "./SubscriptionPriceDto";
import { SubscriptionFeatureDto } from "./SubscriptionFeatureDto";
import { PricingModel } from "~/application/enums/subscriptions/PricingModel";
import { SubscriptionUsageBasedPriceDto } from "./SubscriptionUsageBasedPriceDto";
import { TenantSubscriptionProduct, TenantType } from "@toolctrl/db/prisma-browser";
export interface SubscriptionProductDto {
id?: string;
stripeId: string;
order: number;
title: string;
description: string | null;
groupTitle?: string | null;
groupDescription?: string | null;
badge: string | null;
active: boolean;
model: PricingModel;
public: boolean;
prices: SubscriptionPriceDto[];
features: SubscriptionFeatureDto[];
translatedTitle?: string;
usageBasedPrices?: SubscriptionUsageBasedPriceDto[];
tenantProducts?: TenantSubscriptionProduct[];
assignsTenantTypes?: TenantType[];
billingAddressCollection?: string | null;
hasQuantity?: boolean;
canBuyAgain?: boolean;
}
@@ -0,0 +1,18 @@
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { SubscriptionUsageBasedTierDto } from "./SubscriptionUsageBasedTierDto";
export interface SubscriptionUsageBasedPriceDto {
id: string;
subscriptionProductId: string;
stripeId: string;
billingPeriod: SubscriptionBillingPeriod;
currency: string;
unit: string;
unitTitle: string;
unitTitlePlural: string;
usageType: string;
aggregateUsage: string;
tiersMode: string;
billingScheme: string;
tiers: SubscriptionUsageBasedTierDto[];
}
@@ -0,0 +1,10 @@
import { Decimal } from "decimal.js";
export interface SubscriptionUsageBasedTierDto {
id: string;
subscriptionUsageBasedPriceId: string;
from: number;
to?: number | null;
perUnitPrice?: number | Decimal | null;
flatFeePrice?: number | Decimal | null;
}
@@ -0,0 +1,17 @@
export type SubscriptionUsageBasedUnitDto = {
unit: string;
unitTitle: string;
unitTitlePlural: string;
usageType: string;
aggregateUsage: string;
tiersMode: string;
billingScheme: string;
tiers: { from: number; to?: number }[];
prices: {
currency: string;
from: number;
to: number | undefined;
perUnitPrice?: number | undefined;
flatFeePrice?: number | undefined;
}[];
};
@@ -0,0 +1,7 @@
export interface UsageBasedPriceDto {
from: number;
to?: number | undefined;
perUnitPrice?: number | undefined;
flatFeePrice?: number | undefined;
currency: string;
}
@@ -0,0 +1,5 @@
export type UsageBasedUnitDto = {
name: string;
title: string;
titlePlural: string;
};
@@ -0,0 +1,9 @@
export type TenantTypeDto = {
id?: string;
title: string;
titlePlural: string;
description: string | null;
isDefault?: boolean;
subscriptionProducts?: { id?: string; title: string }[];
_count?: { tenants: number };
};
@@ -0,0 +1,35 @@
import { describe, it, vi, expect } from 'vitest';
import emailTemplates from "./emailTemplates.server";
import { EmailTemplate } from "../dtos/email/EmailTemplate";
import fs from "fs";
const mockEmailTemplates: EmailTemplate[] = [
{
type: "standard",
name: "template-1",
alias: "template-1-alias",
subject: "template-1-subject",
htmlBody: "<p>template-1-body</p>",
active: false,
associatedServerId: -1,
templateId: -1,
},
];
// Mock fs
fs.readdirSync = vi.fn().mockReturnValueOnce(["template-1.md", ]);
fs.readFileSync = vi.fn().mockReturnValueOnce(`
alias: \`template-1-alias\`
subject: \`template-1-subject\`
body: \`<p>template-1-body</p>\`
`)
describe("emailTemplates", () => {
it("should return an array of email templates", async () => {
const result = await emailTemplates();
expect(result).toEqual(mockEmailTemplates);
});
});
@@ -0,0 +1,38 @@
import { EmailTemplate } from "../dtos/email/EmailTemplate";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "node:url";
const emailTemplatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "./templates");
export default async function emailTemplates(): Promise<EmailTemplate[]> {
const items: EmailTemplate[] = [];
const dir = emailTemplatesDir;
const fileNames = fs.readdirSync(dir);
fileNames.forEach((file) => {
if (path.parse(file).ext === ".md") {
const fileName = path.parse(file).name;
const content = fs.readFileSync(path.join(dir, file), "utf8").split("`");
const alias = content[1].replace(/(\r\n|\n|\r)/gm, "");
const subject = content[3].replace(/(\r\n|\n|\r)/gm, "");
const body = content[5];
items.push({
type: alias.startsWith("layout-") ? "layout" : "standard",
name: fileName,
alias,
subject,
htmlBody: body,
active: false,
associatedServerId: -1,
templateId: -1,
});
}
});
function cmp(a: any, b: any) {
if (a > b) return +1;
if (a < b) return -1;
return 0;
}
return items.sort((x, y) => cmp(x.type, y.type) || cmp(Number(x.name.split("-")[0]), Number(y.name.split("-")[0])));
}
@@ -0,0 +1,488 @@
alias: `layout-basic`
subject: ``
body:
`
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title></title>
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="{{ product_url }}" class="f-fallback email-masthead_name">
{{ product_name }}
</a>
</td>
</tr>
<!-- Email Body -->
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
<div class="f-fallback">
{{{ @content }}}
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; 2021 {{ product_name }}. All rights reserved.</p>
<p class="f-fallback sub align-center">
{{ company_name }}
<br>{{ company_address }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`
@@ -0,0 +1,50 @@
alias: `welcome`
subject: `Welcome to {{product_name}} {{name}}!`
body:
`
<h1>Welcome, {{name}}!</h1>
<p>Thanks for trying {{ product_name }}. We’re thrilled to have you on board.</p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<a href="{{action_url}}" class="button button--" target="_blank">Log in</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End -->
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
<p>Thanks,
<br>{{ sender_name }}</p>
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
<!-- Sub copy -->
<table class="body-sub">
<tr>
<td>
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="sub">{{action_url}}</p>
</td>
</tr>
</table>
`
@@ -0,0 +1,51 @@
alias: `password-reset`
subject: `Set up a new password for {{product_name}}`
body:
`
<h1>Hi {{name}},</h1>
<p>You recently requested to reset your password for your {{ product_name }} account. Use the button below to reset it.</p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<a href="{{action_url}}" class="button button--" target="_blank">Reset your password</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>If you did not request a password reset, please ignore this email or <a href="mailto:{{support_email}}">contact support</a> if you have questions.</p>
<!-- End -->
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
<p>Thanks,
<br>{{ sender_name }}</p>
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
<!-- Sub copy -->
<table class="body-sub">
<tr>
<td>
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="sub">{{action_url}}</p>
</td>
</tr>
</table>
`
@@ -0,0 +1,50 @@
alias: `user-invitation`
subject: `{{ invite_sender_name }} invited you to {{ product_name }}`
body:
`
<h1>Hi, {{name}}!</h1>
<p>{{ invite_sender_name }} with {{invite_sender_organization}} has invited you to use {{ product_name }} to collaborate with them. Use the button below to set up your account and get started:</p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<a href="{{action_url}}" class="button button--" target="_blank">Set up account</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End -->
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
<p>Thanks,
<br>{{ sender_name }}</p>
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
<!-- Sub copy -->
<table class="body-sub">
<tr>
<td>
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="sub">{{action_url}}</p>
</td>
</tr>
</table>
`
@@ -0,0 +1,25 @@
alias: `comment-notification`
subject: `{{commenter_name}} made a comment on {{ folio }}`
body:
`
<p>{{ body }}</p>
<p>By {{commenter_name}} ({{commenter_email}}) at {{timestamp}}</p>
<p class="sub"><a href="{{action_url}}">View the comment</a></p>
<!-- End -->
<!-- Sub copy -->
<table class="body-sub">
<tr>
<td>
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="sub">{{action_url}}</p>
</td>
</tr>
</table>
`
@@ -0,0 +1,50 @@
alias: `email-verification`
subject: `Verify your email address with {{product_name}}`
body:
`
<p>Click the button below to verify your email address for {{product_name}}.</p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<a href="{{action_url}}" class="button button--" target="_blank">Verify</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End -->
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
<p>Thanks,
<br>{{ sender_name }}</p>
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
<!-- Sub copy -->
<table class="body-sub">
<tr>
<td>
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="sub">{{action_url}}</p>
</td>
</tr>
</table>
`
@@ -0,0 +1,50 @@
alias: `account-setup`
subject: `Set up your account for {{product_name}}`
body:
`
<p>Thank you for subscribing to <b>{{product_name}}</b>, click the button below to set up your account.</p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<a href="{{action_url}}" class="button button--" target="_blank">Set up account</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- End -->
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
<p>Thanks,
<br>{{ sender_name }}</p>
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
<!-- Sub copy -->
<table class="body-sub">
<tr>
<td>
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="sub">{{action_url}}</p>
</td>
</tr>
</table>
`
@@ -0,0 +1,4 @@
export enum EntityLimitType {
MAX,
MONTHLY,
}
@@ -0,0 +1,27 @@
export enum PropertyAttributeName {
HintText = "HintText", // Common
HelpText = "HelpText", // Common
Icon = "Icon", // Common
Pattern = "pattern", // Text
Min = "Min",
Max = "Max",
Step = "Step",
Rows = "Rows",
DefaultValue = "DefaultValue",
Uppercase = "Uppercase",
Lowercase = "Lowercase",
Placeholder = "Placeholder",
AcceptFileTypes = "AcceptFileTypes",
MaxSize = "MaxSize",
Editor = "editor",
EditorLanguage = "EditorLanguage",
EditorSize = "EditorSize",
Columns = "Columns",
Group = "Group",
FormatNumber = "FormatNumber",
FormatDate = "FormatDate",
FormatBoolean = "FormatBoolean",
Separator = "Separator",
SelectOptions = "SelectOptions",
Password = "Password",
}
@@ -0,0 +1,8 @@
export enum PropertyCondition {
EQUAL, // =
GREATER_THAN, // >
EQUAL_OR_GREATER_THAN, // >=
LESS_THAN, // <
EQUAL_OR_LESS_THAN, // <=,
// CONTAINS, // (),
}
@@ -0,0 +1,18 @@
export enum PropertyType {
NUMBER = 0,
TEXT = 1,
DATE = 2,
// USER = 3,
// ROLE = 4,
// ENTITY = 5,
MEDIA = 6,
// ID = 7,
SELECT = 8,
// FORMULA = 9,
BOOLEAN = 10,
MULTI_SELECT = 11,
MULTI_TEXT = 12,
RANGE_NUMBER = 13,
RANGE_DATE = 14,
FORMULA = 15,
}
@@ -0,0 +1,6 @@
export enum PropertyValueType {
ID,
NUMBER,
TEXT,
DATE,
}
@@ -0,0 +1,2 @@
export const RowAccessTypes = ["none", "view", "comment", "edit", "delete"] as const;
export type RowAccess = (typeof RowAccessTypes)[number];
@@ -0,0 +1,12 @@
export enum ViewFilterCondition {
equals = "equals",
contains = "contains",
lt = "lt",
lte = "lte",
gt = "gt",
gte = "gte",
startsWith = "startsWith",
endsWith = "endsWith",
in = "in",
notIn = "notIn",
}
@@ -0,0 +1,4 @@
export enum ApplicationLayout {
SIDEBAR,
STACKED,
}
@@ -0,0 +1,24 @@
export enum Colors {
UNDEFINED,
SLATE,
GRAY,
NEUTRAL,
STONE,
RED,
ORANGE,
AMBER,
YELLOW,
LIME,
GREEN,
EMERALD,
TEAL,
CYAN,
SKY,
BLUE,
INDIGO,
VIOLET,
PURPLE,
FUCHSIA,
PINK,
ROSE,
}
@@ -0,0 +1,6 @@
export enum InputType {
TEXT,
NUMBER,
SELECT,
DECIMAL,
}
@@ -0,0 +1,7 @@
export enum Periodicity {
ONCE,
MONTHLY,
BIMONTHLY,
QUARTERLY,
YEARLY,
}
@@ -0,0 +1,4 @@
export enum Theme {
LIGHT,
DARK,
}
@@ -0,0 +1,8 @@
export enum PricingModel {
FLAT_RATE,
PER_SEAT,
USAGE_BASED,
FLAT_RATE_USAGE_BASED,
ONCE,
ALL,
}
@@ -0,0 +1,9 @@
export enum SubscriptionBillingPeriod {
ONCE, // o
DAILY, // d
WEEKLY, // w
MONTHLY, // m
YEARLY, // y
QUARTERLY, // q
SEMI_ANNUAL, // s
}
@@ -0,0 +1,7 @@
export enum SubscriptionFeatureLimitType {
NOT_INCLUDED,
INCLUDED,
MONTHLY,
MAX,
UNLIMITED,
}
@@ -0,0 +1,4 @@
export enum SubscriptionPriceType {
ONE_TIME,
RECURRING,
}
@@ -0,0 +1,6 @@
export enum TenantUserJoined {
CREATOR,
JOINED_BY_INVITATION,
JOINED_BY_LINK,
JOINED_BY_PUBLIC_URL,
}
@@ -0,0 +1,6 @@
export enum TenantUserStatus {
PENDING_INVITATION,
PENDING_ACCEPTANCE,
ACTIVE,
INACTIVE,
}
@@ -0,0 +1,5 @@
export enum TenantUserType {
OWNER,
ADMIN,
MEMBER,
}
@@ -0,0 +1,170 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { getEnvVariableDefinition } from "@toolctrl/config";
import { loadFeatureManifests } from "@toolctrl/plugins";
import { queueNames } from "@toolctrl/queue";
const manifestLoad = await loadFeatureManifests();
const manifests = manifestLoad.manifests;
const requiredPlaybooks = [
"docs/agent-entrypoints.md",
"docs/agent-playbooks/feature-integration.md",
"docs/agent-playbooks/mvp-intake.md",
"docs/agent-playbooks/admin-managed-features.md",
"docs/agent-playbooks/worker-backed-features.md",
];
function expectStringArray(value: unknown) {
expect(Array.isArray(value)).toBe(true);
expect((value as unknown[]).every((item) => typeof item === "string")).toBe(true);
}
describe("agent feature planning docs", () => {
it("provides the required agent entrypoint and playbooks", () => {
requiredPlaybooks.forEach((file) => {
expect(fs.existsSync(path.join(process.cwd(), file)), file).toBe(true);
});
});
it("documents the key concepts agents need before implementation", () => {
const entrypoint = fs.readFileSync(path.join(process.cwd(), "docs/agent-entrypoints.md"), "utf8");
const featureIntegration = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/feature-integration.md"), "utf8");
const adminManaged = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/admin-managed-features.md"), "utf8");
const workerBacked = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/worker-backed-features.md"), "utf8");
expect(entrypoint).toContain("MVP import");
expect(featureIntegration).toContain("Feature Integration Playbook");
expect(featureIntegration).toContain("Do not expose provider secrets");
expect(adminManaged).toContain("admin-managed");
expect(adminManaged).toContain("Provider keys must not be sent to tenant loaders");
expect(workerBacked).toContain("Browser-backed features require extra constraints");
});
});
describe("feature manifests", () => {
it("keeps every manifest on the integration-aware schema", () => {
manifests.forEach((manifest) => {
expect(manifest.schemaVersion).toBe("1.0");
expect(manifest.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
expect(manifest.title).toEqual(expect.any(String));
expect(["core", "plugin-candidate", "remove-candidate", "mixed"]).toContain(manifest.classification);
expect(["active", "proposed", "review-needed"]).toContain(manifest.status);
expectStringArray(manifest.rationale);
expectStringArray(manifest.codeAreas);
expectStringArray(manifest.dependencies);
expectStringArray(manifest.consumers);
expectStringArray(manifest.boundaries.mustKeep);
expectStringArray(manifest.boundaries.mayMoveToPlugin);
expectStringArray(manifest.boundaries.mustNotTouch);
expectStringArray(manifest.pruning.notes);
expectStringArray(manifest.validation.source);
expect(manifest.validation.lastReviewed).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
it("requires explicit ownership, surfaces, configuration, workers and UAT", () => {
manifests.forEach((manifest) => {
expect(["tenant-owned", "admin-managed", "hybrid", "system-only"]).toContain(manifest.integration.ownership);
expectStringArray(manifest.integration.surfaces.adminRoutes);
expectStringArray(manifest.integration.surfaces.tenantRoutes);
expectStringArray(manifest.integration.surfaces.publicRoutes);
expectStringArray(manifest.integration.surfaces.apiRoutes);
expectStringArray(manifest.integration.configuration.admin);
expectStringArray(manifest.integration.configuration.tenant);
expectStringArray(manifest.integration.configuration.environment);
expectStringArray(manifest.integration.configuration.secrets);
expect(typeof manifest.integration.workers.required).toBe("boolean");
expectStringArray(manifest.integration.workers.queues);
expectStringArray(manifest.integration.workers.jobs);
expectStringArray(manifest.integration.workers.notes);
expectStringArray(manifest.integration.externalProviders);
expectStringArray(manifest.integration.permissions);
expectStringArray(manifest.integration.auditEvents);
expectStringArray(manifest.integration.billingImpacts);
expectStringArray(manifest.integration.uat);
expect(manifest.integration.uat.length, manifest.id).toBeGreaterThan(0);
});
});
it("loads real docs manifests without loader diagnostics", () => {
expect(manifestLoad.ok).toBe(true);
expect(manifestLoad.diagnostics).toEqual([]);
});
it("guards the Feature Manifest manifest decision contract in docs", () => {
const inventory = fs.readFileSync(path.join(process.cwd(), "docs/feature-inventory.md"), "utf8");
const manifestReadme = fs.readFileSync(path.join(process.cwd(), "docs/feature-manifests/README.md"), "utf8");
["Manifest-Decision", "Pruned Core", "Full-Boilerplate", "plugin-candidate", "remove-candidate"].forEach((anchor) => {
expect(inventory, `inventory missing ${anchor}`).toContain(anchor);
expect(manifestReadme, `manifest README missing ${anchor}`).toContain(anchor);
});
["core", "plugin-candidates", "remove-candidates"].forEach((manifestIdAnchor) => {
expect(manifestReadme, `manifest README missing ${manifestIdAnchor}`).toContain(manifestIdAnchor);
});
});
it("guards admin-managed secret ownership and worker-backed declarations", () => {
manifests
.filter((manifest) => manifest.integration.ownership === "admin-managed")
.forEach((manifest) => {
expect(manifest.integration.configuration.admin.length, manifest.id).toBeGreaterThan(0);
expect(manifest.integration.configuration.secrets.join(" ").toLowerCase(), manifest.id).toMatch(/admin|platform/);
expect(manifest.integration.permissions.some((permission) => permission.startsWith("admin.")), manifest.id).toBe(true);
expect(manifest.integration.auditEvents.length, manifest.id).toBeGreaterThan(0);
});
manifests
.filter((manifest) => manifest.integration.workers.required)
.forEach((manifest) => {
expect(manifest.integration.workers.queues.length, manifest.id).toBeGreaterThan(0);
expect(manifest.integration.workers.jobs.length, manifest.id).toBeGreaterThan(0);
});
});
it("includes the AI chatbot reference pattern for admin-managed worker-backed features", () => {
const chatbot = manifests.find((manifest) => manifest.id === "ai-chatbot-admin-managed");
expect(chatbot).toBeDefined();
expect(chatbot?.integration.ownership).toBe("admin-managed");
expect(chatbot?.integration.externalProviders).toContain("deployment-region-ai-provider");
expect(chatbot?.integration.workers.required).toBe(true);
expect(chatbot?.integration.workers.queues).toContain(queueNames.aiChatbot);
expect(chatbot?.integration.workers.queues).toContain(queueNames.browserAutomation);
expect(chatbot?.integration.workers.jobs).toEqual(expect.arrayContaining(["chat-completion", "browser-session-run", "browser-session-cleanup", "usage-flush"]));
expect(chatbot?.integration.configuration.environment).toEqual(
expect.arrayContaining([
"MISTRAL_API_KEY",
"MISTRAL_MODEL",
"SCALEWAY_API_KEY",
"SCALEWAY_MODEL",
"SCALEWAY_BASE_URL",
"ALEPH_ALPHA_API_KEY",
"ALEPH_ALPHA_MODEL",
"ALEPH_ALPHA_BASE_URL",
"BULLMQ_REDIS_URL",
"BROWSER_RUNTIME_URL",
])
);
expect(chatbot?.integration.configuration.secrets.join(" ")).toContain("never be exposed");
});
it("keeps admin-managed AI/browser feature contracts backed by config and queue packages", () => {
[
"BULLMQ_REDIS_URL",
"BROWSER_RUNTIME_URL",
"BROWSER_RUNTIME_API_KEY",
"BROWSER_SESSION_TIMEOUT_MS",
].forEach((name) => {
const definition = getEnvVariableDefinition(name);
expect(definition?.classification, name).toBe("optional-provider");
expect(definition?.targets.some((target) => target === "api" || target === "worker"), name).toBe(true);
});
expect(queueNames.aiChatbot).toBe("toolctrl.ai-chatbot");
expect(queueNames.browserAutomation).toBe("toolctrl.browser-automation");
});
});
@@ -0,0 +1,39 @@
import { BillingPeriodDto } from "../dtos/subscriptions/BillingPeriodDto";
import { SubscriptionBillingPeriod } from "../enums/subscriptions/SubscriptionBillingPeriod";
const billingPeriods: BillingPeriodDto[] = [
{
value: SubscriptionBillingPeriod.ONCE,
recurring: false,
},
{
value: SubscriptionBillingPeriod.MONTHLY,
default: true,
recurring: true,
},
{
value: SubscriptionBillingPeriod.QUARTERLY,
disabled: false,
recurring: true,
},
{
value: SubscriptionBillingPeriod.SEMI_ANNUAL,
disabled: false,
recurring: true,
},
{
value: SubscriptionBillingPeriod.YEARLY,
recurring: true,
},
{
value: SubscriptionBillingPeriod.DAILY,
disabled: true,
recurring: true,
},
{
value: SubscriptionBillingPeriod.WEEKLY,
disabled: true,
recurring: true,
},
];
export default billingPeriods;
@@ -0,0 +1,158 @@
import { describe, it, expect } from "vitest";
import currencies from "./currencies";
import { CurrencyDto } from "../dtos/subscriptions/CurrencyDto";
describe("currencies", () => {
it("should contain the correct list of currencies", () => {
const expectedCurrencies: CurrencyDto[] = [
{
name: "United States Dollar",
value: "usd",
symbol: "$",
default: true,
disabled: false,
parities: [{ from: "usd", parity: 1 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Euro",
value: "eur",
symbol: "€",
disabled: true,
parities: [{ from: "usd", parity: 1 }],
symbolRight: true,
thousandSeparator: ".",
decimalSeparator: ",",
},
{
name: "Great Britain Pound",
value: "gbp",
symbol: "£",
disabled: true,
parities: [{ from: "usd", parity: 0.85 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Indian Rupee",
value: "inr",
symbol: "₹",
disabled: true,
parities: [{ from: "usd", parity: 79.96 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Mexican Peso",
value: "mxn",
symbol: "$",
disabled: true,
parities: [{ from: "usd", parity: 16.98 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Canadian Dollar",
value: "cad",
symbol: "C$",
disabled: true,
parities: [{ from: "usd", parity: 1.3 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Japanese Yen",
value: "jpy",
symbol: "¥",
disabled: true,
parities: [{ from: "usd", parity: 110 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Australian Dollar",
value: "aud",
symbol: "A$",
disabled: true,
parities: [{ from: "usd", parity: 1.4 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Swiss Franc",
value: "chf",
symbol: "CHF",
disabled: true,
parities: [{ from: "usd", parity: 0.92 }],
symbolRight: true,
thousandSeparator: "'",
decimalSeparator: ".",
},
{
name: "Chinese Yuan",
value: "cny",
symbol: "¥",
disabled: true,
parities: [{ from: "usd", parity: 6.36 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "South Korean Won",
value: "krw",
symbol: "₩",
disabled: true,
parities: [{ from: "usd", parity: 1190 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Russian Ruble",
value: "rub",
symbol: "₽",
disabled: true,
parities: [{ from: "usd", parity: 76 }],
symbolRight: false,
thousandSeparator: " ",
decimalSeparator: ",",
},
{
name: "Brazilian Real",
value: "brl",
symbol: "R$",
disabled: true,
parities: [{ from: "usd", parity: 5.2 }],
symbolRight: false,
thousandSeparator: ".",
decimalSeparator: ",",
},
{
name: "South African Rand",
value: "zar",
symbol: "R",
disabled: true,
parities: [{ from: "usd", parity: 15 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
];
expect(currencies).toHaveLength(expectedCurrencies.length);
currencies.forEach((currency, index) => {
const expectedCurrency = expectedCurrencies[index];
expect(currency).toMatchObject(expectedCurrency);
});
});
});
@@ -0,0 +1,146 @@
import { CurrencyDto } from "../dtos/subscriptions/CurrencyDto";
const currencies: CurrencyDto[] = [
{
name: "United States Dollar",
value: "usd",
symbol: "$",
default: true,
disabled: false,
parities: [{ from: "usd", parity: 1 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Euro",
value: "eur",
symbol: "€",
disabled: true,
parities: [{ from: "usd", parity: 1 }],
symbolRight: true,
thousandSeparator: ".",
decimalSeparator: ",",
},
{
name: "Great Britain Pound",
value: "gbp",
symbol: "£",
disabled: true,
parities: [{ from: "usd", parity: 0.85 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Indian Rupee",
value: "inr",
symbol: "₹",
disabled: true,
parities: [{ from: "usd", parity: 79.96 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Mexican Peso",
value: "mxn",
symbol: "$",
disabled: true,
parities: [{ from: "usd", parity: 16.98 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Canadian Dollar",
value: "cad",
symbol: "C$",
disabled: true,
parities: [{ from: "usd", parity: 1.3 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Japanese Yen",
value: "jpy",
symbol: "¥",
disabled: true,
parities: [{ from: "usd", parity: 110 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Australian Dollar",
value: "aud",
symbol: "A$",
disabled: true,
parities: [{ from: "usd", parity: 1.4 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Swiss Franc",
value: "chf",
symbol: "CHF",
disabled: true,
parities: [{ from: "usd", parity: 0.92 }],
symbolRight: true,
thousandSeparator: "'",
decimalSeparator: ".",
},
{
name: "Chinese Yuan",
value: "cny",
symbol: "¥",
disabled: true,
parities: [{ from: "usd", parity: 6.36 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "South Korean Won",
value: "krw",
symbol: "₩",
disabled: true,
parities: [{ from: "usd", parity: 1190 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
{
name: "Russian Ruble",
value: "rub",
symbol: "₽",
disabled: true,
parities: [{ from: "usd", parity: 76 }],
symbolRight: false,
thousandSeparator: " ",
decimalSeparator: ",",
},
{
name: "Brazilian Real",
value: "brl",
symbol: "R$",
disabled: true,
parities: [{ from: "usd", parity: 5.2 }],
symbolRight: false,
thousandSeparator: ".",
decimalSeparator: ",",
},
{
name: "South African Rand",
value: "zar",
symbol: "R",
disabled: true,
parities: [{ from: "usd", parity: 15 }],
symbolRight: false,
thousandSeparator: ",",
decimalSeparator: ".",
},
];
export default currencies;
@@ -0,0 +1,331 @@
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { SubscriptionPriceType } from "~/application/enums/subscriptions/SubscriptionPriceType";
import { DefaultFeatures } from "../dtos/shared/DefaultFeatures";
import { SubscriptionPriceDto } from "../dtos/subscriptions/SubscriptionPriceDto";
import { SubscriptionProductDto } from "../dtos/subscriptions/SubscriptionProductDto";
import { SubscriptionUsageBasedPriceDto } from "../dtos/subscriptions/SubscriptionUsageBasedPriceDto";
import { SubscriptionUsageBasedTierDto } from "../dtos/subscriptions/SubscriptionUsageBasedTierDto";
import { PricingModel } from "../enums/subscriptions/PricingModel";
import { SubscriptionFeatureLimitType } from "../enums/subscriptions/SubscriptionFeatureLimitType";
const UNIT_API_CALL = {
name: "api",
title: "API Call",
titlePlural: "API Calls",
};
function generatePrice(currency: string, price: number, billingPeriod: SubscriptionBillingPeriod): SubscriptionPriceDto {
return {
stripeId: "",
subscriptionProductId: "",
type: billingPeriod === SubscriptionBillingPeriod.ONCE ? SubscriptionPriceType.ONE_TIME : SubscriptionPriceType.RECURRING,
billingPeriod,
price: Math.round(price * 100) / 100,
currency,
trialDays: 0,
active: true,
usageBasedPrices: [],
};
}
function generateUsageBasedPrice(
currency: string,
unit: {
name: string;
title: string;
titlePlural: string;
},
usageType: "licensed" | "metered",
aggregateUsage: "last_during_period" | "last_ever" | "max" | "sum",
tiersMode: "graduated" | "volume",
billingScheme: "per_unit" | "tiered",
tiers: {
from: number;
to: undefined | number;
perUnitPrice: number | undefined;
flatFeePrice: number | undefined;
}[]
): SubscriptionUsageBasedPriceDto {
const usageBasedPriceTiers: SubscriptionUsageBasedTierDto[] = [];
tiers.forEach((tier) => {
usageBasedPriceTiers.push({
id: "",
subscriptionUsageBasedPriceId: "",
...tier,
});
});
return {
id: "",
stripeId: "",
subscriptionProductId: "",
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
currency,
unit: unit.name,
unitTitle: unit.title,
unitTitlePlural: unit.titlePlural,
usageType,
aggregateUsage,
tiersMode,
billingScheme,
tiers: usageBasedPriceTiers,
};
}
function generateCommonPlan(order: number, model: PricingModel) {
if (order === 1) {
return {
stripeId: "",
order,
title: "pricing.products.plan1.title",
description: "pricing.products.plan1.description",
model,
public: true,
features: [
{
order: 1,
title: "pricing.features.users.max",
name: DefaultFeatures.Users,
value: 2,
type: SubscriptionFeatureLimitType.MAX,
accumulate: false,
},
{
order: 3,
title: "pricing.features.apiCalls.units",
name: DefaultFeatures.API,
value: 0,
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
accumulate: false,
},
{
order: 4,
title: "pricing.features.prioritySupport.none",
name: DefaultFeatures.PrioritySupport,
value: 0,
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
accumulate: false,
},
],
badge: "",
active: true,
prices: [],
};
} else if (order === 2) {
return {
stripeId: "",
order,
title: "pricing.products.plan2.title",
description: "pricing.products.plan2.description",
public: true,
model,
features: [
{
order: 1,
title: "pricing.features.users.max",
name: DefaultFeatures.Users,
value: 5,
type: SubscriptionFeatureLimitType.MAX,
accumulate: false,
},
{
order: 3,
title: "pricing.features.apiCalls.monthly",
name: DefaultFeatures.API,
value: 100,
type: SubscriptionFeatureLimitType.MONTHLY,
accumulate: false,
},
{
order: 4,
title: "pricing.features.prioritySupport.basic",
name: DefaultFeatures.PrioritySupport,
value: 0,
type: SubscriptionFeatureLimitType.INCLUDED,
accumulate: false,
},
],
badge: "pricing.recommended",
active: true,
};
} else if (order === 3) {
return {
stripeId: "",
order,
title: "pricing.products.plan3.title",
description: "pricing.products.plan3.description",
public: true,
model,
features: [
{
order: 1,
title: "pricing.features.users.max",
name: DefaultFeatures.Users,
value: 12,
type: SubscriptionFeatureLimitType.MAX,
accumulate: false,
},
{
order: 3,
title: "pricing.features.apiCalls.unlimited",
name: DefaultFeatures.API,
value: 0,
type: SubscriptionFeatureLimitType.UNLIMITED,
accumulate: false,
},
{
order: 4,
title: "pricing.features.prioritySupport.priority",
name: DefaultFeatures.PrioritySupport,
value: 0,
type: SubscriptionFeatureLimitType.INCLUDED,
accumulate: false,
},
],
badge: "",
active: true,
};
}
return {
stripeId: "",
order,
title: "pricing.products.plan1.title",
description: "pricing.products.plan1.description",
model,
public: true,
features: [
{
order: 1,
title: "pricing.features.users.one",
name: DefaultFeatures.Users,
value: 1,
type: SubscriptionFeatureLimitType.MAX,
accumulate: false,
},
],
badge: "",
active: true,
};
}
const FLAT_RATE_PRICES: SubscriptionProductDto[] = [
{
...generateCommonPlan(1, PricingModel.FLAT_RATE),
prices: [
generatePrice("usd", 9, SubscriptionBillingPeriod.MONTHLY),
generatePrice("usd", 90, SubscriptionBillingPeriod.YEARLY),
generatePrice("mxn", 199, SubscriptionBillingPeriod.MONTHLY),
generatePrice("mxn", 1990, SubscriptionBillingPeriod.YEARLY),
],
usageBasedPrices: [],
},
{
...generateCommonPlan(2, PricingModel.FLAT_RATE),
prices: [
generatePrice("usd", 199, SubscriptionBillingPeriod.MONTHLY),
generatePrice("usd", 1990, SubscriptionBillingPeriod.YEARLY),
generatePrice("mxn", 3999, SubscriptionBillingPeriod.MONTHLY),
generatePrice("mxn", 39990, SubscriptionBillingPeriod.YEARLY),
],
usageBasedPrices: [],
},
{
...generateCommonPlan(3, PricingModel.FLAT_RATE),
prices: [
generatePrice("usd", 399, SubscriptionBillingPeriod.MONTHLY),
generatePrice("usd", 3990, SubscriptionBillingPeriod.YEARLY),
generatePrice("mxn", 7999, SubscriptionBillingPeriod.MONTHLY),
generatePrice("mxn", 79990, SubscriptionBillingPeriod.YEARLY),
],
usageBasedPrices: [],
},
];
const PER_SEAT_PRICES: SubscriptionProductDto[] = [
...FLAT_RATE_PRICES.map((item) => {
return { ...item, model: PricingModel.PER_SEAT };
}),
];
const USAGE_BASED_PRICES: SubscriptionProductDto[] = [
{
...generateCommonPlan(1, PricingModel.USAGE_BASED),
prices: [],
usageBasedPrices: [
generateUsageBasedPrice("usd", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
{ from: 11, to: 20, perUnitPrice: 0.02, flatFeePrice: 5 },
{ from: 21, to: undefined, perUnitPrice: 0.01, flatFeePrice: 10 },
]),
generateUsageBasedPrice("mxn", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
{ from: 11, to: 20, perUnitPrice: 0.4, flatFeePrice: 100 },
{ from: 21, to: undefined, perUnitPrice: 0.2, flatFeePrice: 200 },
]),
],
},
{
...generateCommonPlan(2, PricingModel.USAGE_BASED),
prices: [],
usageBasedPrices: [
generateUsageBasedPrice("usd", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
{ from: 11, to: 20, perUnitPrice: 0.04, flatFeePrice: 10 },
{ from: 21, to: undefined, perUnitPrice: 0.02, flatFeePrice: 20 },
]),
generateUsageBasedPrice("mxn", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
{ from: 11, to: 20, perUnitPrice: 0.8, flatFeePrice: 200 },
{ from: 21, to: undefined, perUnitPrice: 0.4, flatFeePrice: 400 },
]),
],
},
{
...generateCommonPlan(3, PricingModel.USAGE_BASED),
prices: [],
usageBasedPrices: [
generateUsageBasedPrice("usd", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
{ from: 11, to: 20, perUnitPrice: 0.06, flatFeePrice: 15 },
{ from: 21, to: undefined, perUnitPrice: 0.03, flatFeePrice: 30 },
]),
generateUsageBasedPrice("mxn", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
{ from: 11, to: 20, perUnitPrice: 1.2, flatFeePrice: 300 },
{ from: 21, to: undefined, perUnitPrice: 0.6, flatFeePrice: 600 },
]),
],
},
];
const FLAT_RATE_PLUS_USAGE_BASED_PRICES: SubscriptionProductDto[] = [
...FLAT_RATE_PRICES.map((flatRate) => {
const usageBasedPrices = USAGE_BASED_PRICES.find((f) => f.order === flatRate.order)?.usageBasedPrices ?? [];
return {
...flatRate,
usageBasedPrices,
model: PricingModel.FLAT_RATE_USAGE_BASED,
};
}),
];
const ONE_TIME_PRICES: SubscriptionProductDto[] = [
{
...generateCommonPlan(1, PricingModel.ONCE),
prices: [generatePrice("usd", 99 * 2, SubscriptionBillingPeriod.ONCE), generatePrice("mxn", 1999 * 2, SubscriptionBillingPeriod.ONCE)],
usageBasedPrices: [],
},
{
...generateCommonPlan(2, PricingModel.ONCE),
prices: [generatePrice("usd", 499 * 2, SubscriptionBillingPeriod.ONCE), generatePrice("mxn", 9999 * 2, SubscriptionBillingPeriod.ONCE)],
usageBasedPrices: [],
},
{
...generateCommonPlan(3, PricingModel.ONCE),
prices: [generatePrice("usd", 1999 * 2, SubscriptionBillingPeriod.ONCE), generatePrice("mxn", 4999 * 2, SubscriptionBillingPeriod.ONCE)],
usageBasedPrices: [],
},
];
const plans = [...FLAT_RATE_PRICES, ...PER_SEAT_PRICES, ...USAGE_BASED_PRICES, ...FLAT_RATE_PLUS_USAGE_BASED_PRICES, ...ONE_TIME_PRICES];
export default plans;
@@ -0,0 +1,166 @@
import { readdirSync, statSync } from "node:fs";
import * as path from "node:path";
import { describe, it, expect, vi } from "vitest";
import { AdminSidebar } from "./AdminSidebar";
import type { SideBarItem } from "./SidebarItem";
import { canonicalAdminRouteSlugs, disabledPluginRouteSlugs } from "~/utils/app/PublicRouteSegments";
import { resolveRepoPath } from "~/utils/testing/repoPaths";
function flattenSidebarItems(items: SideBarItem[]): SideBarItem[] {
return items.flatMap((item) => [item, ...flattenSidebarItems(item.items ?? [])]);
}
function walkRouteFiles(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const absolutePath = path.join(directory, entry);
const stats = statSync(absolutePath);
if (stats.isDirectory()) {
return walkRouteFiles(absolutePath);
}
if (!/\.(tsx?|jsx?)$/.test(entry) || /\.(test|spec)\.(tsx?|jsx?)$/.test(entry) || entry.endsWith(".server.ts")) {
return [];
}
return [absolutePath];
});
}
function routePathFromAdminRouteFile(absolutePath: string): string | null {
const routeRoot = resolveRepoPath("apps/web/app/routes/admin");
const relativePath = path.relative(routeRoot, absolutePath).replace(/\\/g, "/");
if (relativePath === "404.tsx" || relativePath.startsWith("internal/")) {
return null;
}
const withoutExtension = relativePath.replace(/\.(tsx?|jsx?)$/, "");
const parts = withoutExtension
.split("/")
.flatMap((part) => part.split("."))
.filter((part) => part && part !== "index" && !part.startsWith("__"));
return parts.join("/");
}
function collectAdminRoutePaths(): string[] {
return walkRouteFiles(resolveRepoPath("apps/web/app/routes/admin"))
.map(routePathFromAdminRouteFile)
.filter((routePath): routePath is string => routePath !== null);
}
function targetMatchesRoute(target: string, routePath: string): boolean {
const targetParts = target.split("/").filter(Boolean);
const routeParts = routePath.split("/").filter(Boolean);
if (targetParts.length !== routeParts.length) {
return false;
}
return routeParts.every((routePart, index) => routePart.startsWith("$") || routePart === targetParts[index]);
}
function normalizeSidebarPath(pathname: string): string | null {
if (!pathname.startsWith("/") || pathname === "/") {
return null;
}
return pathname.split(/[?#]/)[0]?.replace(/^\/+|\/+$/g, "") || null;
}
describe("AdminSidebar", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
it("returns an array of sidebar items", () => {
const sidebar = AdminSidebar({ t, appConfiguration: null });
const serialized = JSON.stringify(sidebar);
const allPaths = flattenSidebarItems(sidebar)
.map((item) => item.path)
.filter((path): path is string => Boolean(path) && path !== "/" && path.startsWith("/"));
expect(Array.isArray(sidebar)).toBe(true);
expect(serialized).toContain("/dashboard");
expect(serialized).toContain("/accounts");
expect(serialized).toContain("/analytics/overview");
expect(serialized).toContain("/audit-trails");
expect(serialized).toContain("/notifications/messages");
expect(serialized).toContain("/feature-flags/flags");
expect(serialized).toContain("/onboarding/onboardings");
expect(serialized).toContain("/metrics/summary");
expect(serialized).toContain("/settings/profile");
expect(sidebar).toEqual(
expect.arrayContaining([
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ id: "ui.nav.admin.dashboard", path: "/dashboard" }),
]),
}),
])
);
expect(sidebar.at(-1)?.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "ui.nav.admin.switchToApp", path: "/", exact: true }),
])
);
const emittedPaths = serialized.match(/"path":"[^"]*"/g) ?? [];
expect(emittedPaths.filter((path) => path.includes("/admin/"))).toEqual([]);
expect(serialized).not.toContain("/admin/pages");
expect(serialized).not.toContain("/admin/blog");
expect(serialized).not.toContain("/admin/crons");
expect(serialized).not.toContain("/email-marketing");
expect(serialized).not.toContain("/forms");
expect(serialized).not.toContain("/help-desk");
expect(serialized).not.toContain("/knowledge-base");
expect(serialized).not.toContain("/portals");
expect(serialized).not.toContain("/prompts");
expect(serialized).not.toContain("/workflow-engine");
expect(serialized).not.toContain("\"path\":\"/crm\"");
expect(serialized).not.toContain("ui.nav.admin.crm");
expect(allPaths.map((path) => path.split("/").filter(Boolean)[0])).not.toEqual(expect.arrayContaining([...disabledPluginRouteSlugs]));
expect(allPaths.map((path) => path.split("/").filter(Boolean)[0])).toEqual(expect.arrayContaining(["dashboard", "accounts", "settings"]));
allPaths.forEach((path) => {
expect(canonicalAdminRouteSlugs).toContain(path.split("/").filter(Boolean)[0] as any);
});
sidebar.forEach((item) => {
expect(typeof item.title).toBe("string");
expect(typeof item.path).toBe("string");
if (item.items) {
expect(Array.isArray(item.items)).toBe(true);
item.items.forEach((subItem) => {
expect(typeof subItem.title).toBe("string");
expect(typeof subItem.path).toBe("string");
});
}
});
});
it("only emits canonical admin sidebar paths backed by route files", () => {
const routePaths = collectAdminRoutePaths();
const sidebar = AdminSidebar({
t,
appConfiguration: {
app: {
features: {
tenantApiKeys: true,
},
},
} as any,
});
const sidebarTargets = flattenSidebarItems(sidebar)
.map((item) => item.path)
.filter((pathname): pathname is string => Boolean(pathname))
.map(normalizeSidebarPath)
.filter((pathname): pathname is string => pathname !== null);
expect(sidebarTargets).not.toContain("admin");
expect(sidebarTargets.map((pathname) => pathname.split("/")[0])).not.toEqual(expect.arrayContaining([...disabledPluginRouteSlugs]));
const missingTargets = sidebarTargets.filter((target) => !routePaths.some((routePath) => targetMatchesRoute(target, routePath)));
expect(missingTargets).toEqual([]);
});
});
@@ -0,0 +1,354 @@
import { SideBarItem } from "./SidebarItem";
import { TFunction } from "i18next";
import IconDashboard from "~/components/layouts/icons/IconDashboard";
import MembershipCardIcon from "~/components/ui/icons/MembershipCardIcon";
import { AppConfiguration } from "~/utils/db/appConfiguration.db.server";
import IconSettings from "~/components/layouts/icons/IconSettings";
import IconApp from "~/components/layouts/icons/IconApp";
import IconKeys from "~/components/layouts/icons/IconKeys";
import IconEntities from "~/components/layouts/icons/IconEntities";
import IconAnalytics from "~/components/layouts/icons/IconAnalytics";
import IconDocs from "~/components/layouts/icons/IconDocs";
import IconEmails from "~/components/layouts/icons/IconEmails";
import IconEvents from "~/components/layouts/icons/IconEvents";
import IconFeatureFlags from "~/components/layouts/icons/IconFeatureFlags";
import IconHelpDesk from "~/components/layouts/icons/IconHelpDesk";
import IconMetrics from "~/components/layouts/icons/IconMetrics";
import IconNavigation from "~/components/layouts/icons/IconNavigation";
import IconNotifications from "~/components/layouts/icons/IconNotifications";
import IconOnboarding from "~/components/layouts/icons/IconOnboarding";
import IconPortals from "~/components/layouts/icons/IconPortals";
import IconWorkflows from "~/components/layouts/icons/IconWorkflows";
import { UI_NAV_IDS } from "~/application/ui/uiRegistry";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { absoluteAppRootPath, type PublicDomainRoutingConfig } from "~/utils/app/CrossHostUrls";
import { disabledPluginRouteSlugs } from "~/utils/app/PublicRouteSegments";
const adminSidebarPath = (path?: string) => adminPath(path, { mode: "host-aware" });
const rootAppFallbackPath = "/";
const disabledAdminRouteSlugs = new Set<string>(disabledPluginRouteSlugs);
function isAdminRouteEnabled(slug: string) {
return !disabledAdminRouteSlugs.has(slug);
}
function enabledAdminRouteItem(slug: string, item: SideBarItem): SideBarItem[] {
return isAdminRouteEnabled(slug) ? [item] : [];
}
type Props = {
t: TFunction;
appConfiguration: AppConfiguration | null;
routing?: PublicDomainRoutingConfig;
};
export const AdminSidebar = ({ t, appConfiguration, routing }: Props): SideBarItem[] => [
{
id: "ui.nav.admin.primary",
title: "",
path: "",
items: [
{
id: UI_NAV_IDS.adminDashboard,
title: t("app.sidebar.dashboard"),
path: adminSidebarPath("dashboard"),
icon: <IconDashboard />,
},
],
},
{
id: "ui.nav.admin.manage",
title: t("segments.manage"),
icon: <IconDashboard />,
path: "",
items: [
{
id: UI_NAV_IDS.adminAccounts,
title: t("app.sidebar.accountsAndUsers"),
path: adminSidebarPath("accounts"),
icon: <MembershipCardIcon />,
isCollapsible: true,
isActive: false,
items: [
{
id: "ui.nav.admin.accounts.tenants",
title: t("admin.tenants.title"),
path: adminSidebarPath("accounts"),
// icon: SvgIcon.TENANTS,
items: [],
permission: "admin.accounts.view",
},
{
id: "ui.nav.admin.accounts.users",
title: t("models.user.plural"),
path: adminSidebarPath("accounts/users"),
items: [],
permission: "admin.users.view",
},
{
id: "ui.nav.admin.accounts.usageFeatures",
title: t("admin.accounts.usageFeatures"),
path: adminSidebarPath("accounts/subscriptions"),
items: [],
permission: "admin.accounts.view",
},
{
id: "ui.nav.admin.accounts.rolesAndPermissions",
title: t("app.sidebar.rolesAndPermissions"),
path: adminSidebarPath("accounts/roles-and-permissions"),
items: [],
permission: "admin.roles.view",
},
{
id: "ui.nav.admin.accounts.blacklist",
title: t("models.blacklist.object"),
path: adminSidebarPath("accounts/blacklist"),
items: [],
permission: "admin.blacklist.view",
},
{
id: "ui.nav.admin.accounts.ipAddresses",
title: t("models.tenantIpAddress.plural"),
path: adminSidebarPath("accounts/ip-addresses"),
items: [],
permission: "admin.tenantIpAddress.view",
},
],
permission: "admin.accounts.view",
},
{
id: UI_NAV_IDS.adminAnalytics,
title: t("app.sidebar.analytics"),
path: adminSidebarPath("analytics"),
icon: <IconAnalytics />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.analytics.overview", title: t("app.sidebar.overview"), path: adminSidebarPath("analytics/overview"), permission: "admin.analytics.view" },
{ id: "ui.nav.admin.analytics.pageViews", title: t("app.sidebar.pageViews"), path: adminSidebarPath("analytics/page-views"), permission: "admin.analytics.view" },
{ id: "ui.nav.admin.analytics.visitors", title: t("app.sidebar.visitors"), path: adminSidebarPath("analytics/visitors"), permission: "admin.analytics.view" },
{ id: "ui.nav.admin.analytics.events", title: t("models.event.plural"), path: adminSidebarPath("analytics/events"), permission: "admin.analytics.view" },
{ id: "ui.nav.admin.analytics.settings", title: t("app.sidebar.settings"), path: adminSidebarPath("analytics/settings"), permission: "admin.settings.analytics.view" },
],
permission: "admin.analytics.view",
},
{
id: UI_NAV_IDS.adminEvents,
title: t("models.event.plural"),
path: adminSidebarPath("events"),
icon: <IconEvents />,
permission: "admin.events.view",
},
{
id: UI_NAV_IDS.adminAuditTrails,
title: t("app.sidebar.auditTrails"),
path: adminSidebarPath("audit-trails"),
icon: <IconEvents />,
permission: "admin.auditTrails.view",
},
{
id: UI_NAV_IDS.adminNotifications,
title: t("app.sidebar.notifications"),
path: adminSidebarPath("notifications"),
icon: <IconNotifications />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.notifications.messages", title: t("app.sidebar.messages"), path: adminSidebarPath("notifications/messages"), permission: "admin.notifications.view" },
{ id: "ui.nav.admin.notifications.channels", title: t("app.sidebar.channels"), path: adminSidebarPath("notifications/channels"), permission: "admin.notifications.view" },
{ id: "ui.nav.admin.notifications.subscribers", title: t("app.sidebar.subscribers"), path: adminSidebarPath("notifications/subscribers"), permission: "admin.notifications.view" },
],
permission: "admin.notifications.view",
},
],
},
{
id: "ui.nav.admin.build",
title: t("segments.build"),
path: "",
items: [
{
id: UI_NAV_IDS.adminEntities,
title: t("models.entity.plural"),
path: adminSidebarPath("entities"),
icon: <IconEntities />,
permission: "admin.entities.view",
},
...enabledAdminRouteItem("workflow-engine", {
id: UI_NAV_IDS.adminWorkflows,
title: t("models.workflow.plural"),
path: adminSidebarPath("workflow-engine"),
icon: <IconWorkflows />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.workflows.workflows", title: t("models.workflow.plural"), path: adminSidebarPath("workflow-engine/workflows") },
{ id: "ui.nav.admin.workflows.executions", title: t("app.sidebar.executions"), path: adminSidebarPath("workflow-engine/executions") },
{ id: "ui.nav.admin.workflows.templates", title: t("models.entity.templates"), path: adminSidebarPath("workflow-engine/templates") },
{ id: "ui.nav.admin.workflows.credentials", title: t("app.sidebar.credentials"), path: adminSidebarPath("workflow-engine/credentials") },
{ id: "ui.nav.admin.workflows.variables", title: t("app.sidebar.variables"), path: adminSidebarPath("workflow-engine/variables") },
],
}),
{
id: UI_NAV_IDS.adminApi,
title: t("settings.api.title"),
path: adminSidebarPath("api"),
icon: <IconKeys />,
permission: "admin.apiKeys.view",
hidden: !appConfiguration?.app.features.tenantApiKeys,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.api.keys", title: t("app.sidebar.apiKeys"), path: adminSidebarPath("api/keys"), permission: "admin.apiKeys.view" },
{ id: "ui.nav.admin.api.logs", title: t("app.sidebar.logs"), path: adminSidebarPath("api/logs"), permission: "admin.apiKeys.view" },
{ id: "ui.nav.admin.api.docs", title: t("app.sidebar.docs"), path: adminSidebarPath("api/docs"), permission: "admin.apiKeys.view" },
{ id: "ui.nav.admin.api.credits", title: t("app.sidebar.credits"), path: adminSidebarPath("api/credits"), permission: "admin.apiKeys.view" },
],
},
{
id: UI_NAV_IDS.adminFeatureFlags,
title: t("app.sidebar.featureFlags"),
path: adminSidebarPath("feature-flags"),
icon: <IconFeatureFlags />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.featureFlags.flags", title: t("app.sidebar.flags"), path: adminSidebarPath("feature-flags/flags"), permission: "admin.featureFlags.view" },
{ id: "ui.nav.admin.featureFlags.settings", title: t("app.sidebar.settings"), path: adminSidebarPath("feature-flags/settings"), permission: "admin.featureFlags.view" },
],
permission: "admin.featureFlags.view",
},
{
id: UI_NAV_IDS.adminNavigation,
title: t("admin.navigation.title"),
path: adminSidebarPath("navigation"),
icon: <IconNavigation className="size-4" />,
},
...enabledAdminRouteItem("portals", {
id: UI_NAV_IDS.adminPortals,
title: t("app.sidebar.portals"),
path: adminSidebarPath("portals"),
icon: <IconPortals />,
}),
],
},
{
id: "ui.nav.admin.market",
title: t("segments.market"),
path: "",
items: [
...enabledAdminRouteItem("email-marketing", {
id: UI_NAV_IDS.adminEmailMarketing,
title: t("app.sidebar.emailMarketing"),
path: adminSidebarPath("email-marketing"),
icon: <IconEmails />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.emailMarketing.campaigns", title: t("app.sidebar.campaigns"), path: adminSidebarPath("email-marketing/campaigns"), permission: "admin.emails.view" },
{ id: "ui.nav.admin.emailMarketing.senders", title: t("app.sidebar.senders"), path: adminSidebarPath("email-marketing/senders"), permission: "admin.emails.view" },
{ id: "ui.nav.admin.emailMarketing.activity", title: t("app.sidebar.activity"), path: adminSidebarPath("email-marketing/activity"), permission: "admin.emails.view" },
],
permission: "admin.emails.view",
}),
{
id: UI_NAV_IDS.adminMarketing,
title: t("app.sidebar.marketing"),
path: adminSidebarPath("marketing-leads"),
icon: <IconAnalytics />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.marketing.leads", title: t("app.sidebar.leads"), path: adminSidebarPath("marketing-leads"), permission: "admin.marketing.leads.view" },
{ id: "ui.nav.admin.marketing.funnels", title: t("app.sidebar.funnels"), path: adminSidebarPath("marketing-funnels"), permission: "admin.marketing.funnels.view" },
{ id: "ui.nav.admin.marketing.integrations", title: t("app.sidebar.integrations"), path: adminSidebarPath("marketing-integrations"), permission: "admin.marketing.integrations.view" },
{ id: "ui.nav.admin.marketing.emailSequences", title: t("app.sidebar.emailSequences"), path: adminSidebarPath("email-sequences"), permission: "admin.marketing.leads.view" },
],
permission: "admin.marketing.leads.view",
},
...enabledAdminRouteItem("knowledge-base", {
id: UI_NAV_IDS.adminKnowledgeBase,
title: t("app.sidebar.knowledgeBase"),
path: adminSidebarPath("knowledge-base"),
icon: <IconDocs className="size-4" />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.knowledgeBase.bases", title: t("app.sidebar.bases"), path: adminSidebarPath("knowledge-base/bases"), permission: "admin.kb.view" },
{ id: "ui.nav.admin.knowledgeBase.articles", title: t("app.sidebar.articles"), path: adminSidebarPath("knowledge-base/articles"), permission: "admin.kb.view" },
],
permission: "admin.kb.view",
}),
...enabledAdminRouteItem("help-desk", {
id: UI_NAV_IDS.adminHelpDesk,
title: t("app.sidebar.helpDesk"),
path: adminSidebarPath("help-desk"),
icon: <IconHelpDesk className="size-4" />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.helpDesk.feedback", title: t("app.sidebar.feedback"), path: adminSidebarPath("help-desk/feedback") },
],
}),
{
id: UI_NAV_IDS.adminOnboarding,
title: t("app.sidebar.onboarding"),
path: adminSidebarPath("onboarding"),
icon: <IconOnboarding />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.onboarding.onboardings", title: t("app.sidebar.onboardings"), path: adminSidebarPath("onboarding/onboardings"), permission: "admin.onboarding.view" },
{ id: "ui.nav.admin.onboarding.sessions", title: t("app.sidebar.sessions"), path: adminSidebarPath("onboarding/sessions"), permission: "admin.onboarding.view" },
],
permission: "admin.onboarding.view",
},
{
id: UI_NAV_IDS.adminMetrics,
title: t("app.sidebar.metrics"),
path: adminSidebarPath("metrics"),
icon: <IconMetrics />,
isCollapsible: true,
items: [
{ id: "ui.nav.admin.metrics.summary", title: t("app.sidebar.summary"), path: adminSidebarPath("metrics/summary"), permission: "admin.metrics.view" },
{ id: "ui.nav.admin.metrics.logs", title: t("app.sidebar.logs"), path: adminSidebarPath("metrics/logs"), permission: "admin.metrics.view" },
{ id: "ui.nav.admin.metrics.settings", title: t("app.sidebar.settings"), path: adminSidebarPath("metrics/settings"), permission: "admin.metrics.view" },
],
permission: "admin.metrics.view",
},
],
},
{
id: "ui.nav.admin.secondary",
title: "",
path: "",
// isSecondary: true,
items: [
{
id: UI_NAV_IDS.adminSettings,
title: t("app.sidebar.settings"),
icon: <IconSettings />,
path: adminSidebarPath("settings"),
isCollapsible: true,
items: [
{ id: "ui.nav.admin.settings.profile", title: t("settings.admin.profile.title"), path: adminSidebarPath("settings/profile") },
{ id: "ui.nav.admin.settings.general", title: t("settings.admin.general.title"), path: adminSidebarPath("settings/general"), permission: "admin.settings.general.view" },
{ id: "ui.nav.admin.settings.pricing", title: t("settings.admin.pricing.title"), path: adminSidebarPath("settings/pricing"), permission: "admin.pricing.view" },
{
id: "ui.nav.admin.settings.authentication",
title: t("settings.admin.authentication.title"),
path: adminSidebarPath("settings/authentication"),
permission: "admin.settings.authentication.view",
},
{ id: "ui.nav.admin.settings.analytics", title: t("settings.admin.analytics.title"), path: adminSidebarPath("settings/analytics"), permission: "admin.settings.analytics.view" },
{
id: "ui.nav.admin.settings.transactionalEmails",
title: t("settings.admin.transactionalEmails.title"),
path: adminSidebarPath("settings/transactional-emails"),
permission: "admin.emails.view",
},
{ id: "ui.nav.admin.settings.accountTypes", title: t("settings.admin.tenants.types.title"), path: adminSidebarPath("settings/accounts/types"), permission: "admin.accountTypes.view" },
{ id: "ui.nav.admin.settings.cookies", title: t("settings.admin.cookies.title"), path: adminSidebarPath("settings/cookies"), permission: "admin.settings.cookies.view" },
{ id: "ui.nav.admin.settings.cache", title: t("settings.admin.cache.title"), path: adminSidebarPath("settings/cache"), permission: "admin.settings.general.update" },
],
},
{
id: UI_NAV_IDS.adminSwitchToApp,
title: t("admin.switchToApp"),
path: routing ? absoluteAppRootPath(routing) : rootAppFallbackPath,
icon: <IconApp />,
exact: true,
},
],
},
];
@@ -0,0 +1,231 @@
import { existsSync } from "node:fs";
import { vi, describe, it, expect } from "vitest";
import { AppSidebar } from "./AppSidebar";
import { UI_NAV_IDS } from "~/application/ui/uiRegistry";
import { EntitySimple } from "~/utils/db/entities/entities.db.server";
import { resolveRepoPath } from "~/utils/testing/repoPaths";
describe("AppSidebar", () => {
const starterProductAccess = {
"crm-basic": { allowed: true },
"email-marketing": { allowed: false },
api: { allowed: false },
"workflow-engine": { allowed: false },
"ai-credits": { allowed: true },
};
const proProductAccess = {
"crm-basic": { allowed: true },
"email-marketing": { allowed: true },
api: { allowed: true },
"workflow-engine": { allowed: false },
"ai-credits": { allowed: true },
};
const businessProductAccess = {
"crm-basic": { allowed: true },
"email-marketing": { allowed: true },
api: { allowed: true },
"workflow-engine": { allowed: true },
"ai-credits": { allowed: true },
};
it("returns the expected sidebar items", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
const entities = [
{ name: "entity1", slug: "entity1", titlePlural: "Entity 1", showInSidebar: true, icon: "" },
{ name: "entity2", slug: "entity2", titlePlural: "Entity 2", showInSidebar: false, icon: "" },
{ name: "entity3", slug: "entity3", titlePlural: "Entity 3", showInSidebar: true, icon: "" },
] as EntitySimple[];
const result = AppSidebar({ t, i18n, tenantId: "test-user", entities, entityGroups: [], appConfiguration: null });
const serialized = JSON.stringify(result);
expect(result[0]).toMatchObject({
title: "",
path: "",
items: [
expect.objectContaining({
id: "ui.nav.app.dashboard",
title: "app.sidebar.dashboard",
path: "/test-user/dashboard",
}),
expect.objectContaining({
id: UI_NAV_IDS.appNotifications,
title: "app.sidebar.notifications",
path: "/test-user/notifications",
}),
expect.objectContaining({
id: "ui.nav.app.entity.entity1",
title: "Entity 1",
path: "/test-user/entity1",
permission: "entity.entity1.view",
}),
expect.objectContaining({
id: "ui.nav.app.entity.entity3",
title: "Entity 3",
path: "/test-user/entity3",
permission: "entity.entity3.view",
}),
],
});
expect(result[1]).toMatchObject({
title: "",
path: "",
isSecondary: true,
items: [
expect.objectContaining({
id: "ui.nav.app.settings",
title: "app.sidebar.settings",
path: "/test-user/settings",
redirectTo: "/test-user/settings/profile",
}),
expect.objectContaining({
id: "ui.nav.app.switchToAdmin",
title: "admin.switchToAdmin",
path: "/dashboard",
adminOnly: true,
}),
],
});
expect(serialized).not.toContain("/app/");
expect(serialized).not.toContain("/admin/");
expect(serialized).not.toContain("/blog");
expect(serialized).not.toContain("/pricing");
expect(serialized).not.toContain("/email-marketing");
expect(serialized).not.toContain("/workflow-engine");
expect(serialized).not.toContain("/widgets");
expect(serialized).not.toContain("/portals");
expect(serialized).not.toContain("/sites/");
});
it("renders plan-backed core feature links from product access decisions", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
const starter = JSON.stringify(
AppSidebar({ t, i18n, tenantId: "starter", entities: [], entityGroups: [], appConfiguration: null, productAccess: starterProductAccess })
);
const pro = JSON.stringify(
AppSidebar({ t, i18n, tenantId: "pro", entities: [], entityGroups: [], appConfiguration: null, productAccess: proProductAccess })
);
const business = JSON.stringify(
AppSidebar({ t, i18n, tenantId: "business", entities: [], entityGroups: [], appConfiguration: null, productAccess: businessProductAccess })
);
expect(starter).toContain("/starter/crm");
expect(starter).toContain("/starter/settings/credits");
expect(starter).not.toContain("/starter/email-marketing");
expect(starter).not.toContain("/starter/settings/api");
expect(starter).not.toContain("/starter/workflow-engine");
expect(pro).toContain("/pro/crm");
expect(pro).toContain("/pro/email-marketing");
expect(pro).toContain("/pro/settings/api");
expect(pro).not.toContain("/pro/workflow-engine");
expect(business).toContain("/business/crm");
expect(business).toContain("/business/email-marketing");
expect(business).toContain("/business/settings/api");
expect(business).toContain("/business/workflow-engine");
});
it("marks collapsible entity groups as expand-only parents", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
const entities = [
{ name: "company", slug: "companies", titlePlural: "Companies", showInSidebar: true, icon: "" },
] as EntitySimple[];
const result = AppSidebar({
t,
i18n,
tenantId: "acme",
entities: [],
entityGroups: [
{
slug: "sales",
title: "Sales",
icon: "",
collapsible: true,
section: null,
entities: [{ entity: entities[0] }],
},
] as any,
appConfiguration: null,
});
const group = result[0].items?.find((item) => item.id === "ui.nav.app.entityGroup.sales");
expect(group).toMatchObject({
isCollapsible: true,
path: "/acme/g/sales",
});
expect(group?.items?.[0]).toMatchObject({ title: "shared.overview", path: "/acme/g/sales" });
});
it("translates DB-backed labels only when locale keys exist", () => {
const t = vi.fn().mockImplementation((v: string) => `translated:${v}`) as any;
const i18n = { exists: vi.fn().mockImplementation((value: string) => value === "models.contact.plural" || value === "groups.sales") } as any;
const entities = [
{ name: "contact", slug: "contacts", titlePlural: "models.contact.plural", showInSidebar: true, icon: "" },
{ name: "custom", slug: "custom", titlePlural: "Custom records", showInSidebar: true, icon: "" },
] as EntitySimple[];
const result = AppSidebar({
t,
i18n,
tenantId: "acme",
entities,
entityGroups: [
{
slug: "sales",
title: "groups.sales",
icon: "",
collapsible: true,
section: null,
entities: [{ entity: entities[0] }],
},
] as any,
appConfiguration: null,
});
const serialized = JSON.stringify(result);
expect(serialized).toContain("translated:models.contact.plural");
expect(serialized).toContain("translated:groups.sales");
expect(serialized).toContain("Custom records");
expect(serialized).not.toContain('"models.contact.plural"');
expect(serialized).not.toContain('"groups.sales"');
});
it("keeps static tenant sidebar targets backed by registered route files", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
const result = AppSidebar({ t, i18n, tenantId: "acme", entities: [], entityGroups: [], appConfiguration: null, productAccess: businessProductAccess });
const serialized = JSON.stringify(result);
expect(serialized).toContain("/acme/dashboard");
expect(serialized).toContain("/acme/notifications");
expect(serialized).toContain("/acme/crm");
expect(serialized).toContain("/acme/email-marketing");
expect(serialized).toContain("/acme/workflow-engine");
expect(serialized).toContain("/acme/settings/api");
expect(serialized).toContain("/acme/settings/credits");
expect(serialized).toContain("/acme/settings");
expect(serialized).toContain("/acme/settings/profile");
expect(serialized).not.toContain("/app/");
expect(serialized).not.toContain("/admin/");
[
"apps/web/app/routes/app.$tenant/dashboard.tsx",
"apps/web/app/routes/app.$tenant/notifications.tsx",
"apps/web/app/routes/app.$tenant/crm.tsx",
"apps/web/app/routes/app.$tenant/email-marketing.tsx",
"apps/web/app/routes/app.$tenant/__workflows/workflow-engine.tsx",
"apps/web/app/routes/app.$tenant/settings/api/index.tsx",
"apps/web/app/routes/app.$tenant/settings/credits.tsx",
"apps/web/app/routes/app.$tenant/settings.tsx",
"apps/web/app/routes/app.$tenant/settings/profile.tsx",
].forEach((routeFile) => {
expect(existsSync(resolveRepoPath(routeFile))).toBe(true);
});
});
});
@@ -0,0 +1,269 @@
import { SideBarItem } from "./SidebarItem";
import { EntitySimple } from "~/utils/db/entities/entities.db.server";
import { TFunction, type i18n as I18n } from "i18next";
import { EntityGroupWithDetails } from "~/utils/db/entities/entityGroups.db.server";
import { AppConfiguration } from "~/utils/db/appConfiguration.db.server";
import IconSettings from "~/components/layouts/icons/IconSettings";
import IconDashboard from "~/components/layouts/icons/IconDashboard";
import IconAdmin from "~/components/layouts/icons/IconAdmin";
import IconNotifications from "~/components/layouts/icons/IconNotifications";
import IconEmails from "~/components/layouts/icons/IconEmails";
import IconKeys from "~/components/layouts/icons/IconKeys";
import IconWorkflows from "~/components/layouts/icons/IconWorkflows";
import CreditsIcon from "~/components/ui/icons/CreditsIcon";
import AddressBookIcon from "~/components/ui/icons/crm/AddressBookIcon";
import IconDocs from "~/components/layouts/icons/IconDocs";
import ViewListIcon from "~/components/ui/icons/ViewListIcon";
import { UI_ID_PATTERNS, UI_NAV_IDS, uiIdSegment } from "~/application/ui/uiRegistry";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import { absoluteAdminPath, type PublicDomainRoutingConfig } from "~/utils/app/CrossHostUrls";
import { getEntityPermission } from "~/utils/helpers/PermissionsHelper";
import { translateLabel } from "~/utils/i18n/translateLabel";
import { resolveConnectorNavState } from "~/utils/connector/connectorNavState";
import { WALKING_SKELETON_MODULE_KEY } from "~/modules/walkingSkeleton/constants";
type Props = {
t: TFunction;
i18n: I18n;
tenantId: string;
entities: EntitySimple[];
entityGroups: EntityGroupWithDetails[];
appConfiguration: AppConfiguration | null;
routing?: PublicDomainRoutingConfig;
productAccess?: Record<string, { allowed: boolean; reason?: string }>;
};
export const AppSidebar = ({ t, i18n, tenantId, entities, entityGroups, routing, productAccess }: Props): SideBarItem[] => {
const tenantPath = (path?: string) => appPath({ tenantSlug: tenantId, path, mode: "host-aware" });
const hasFeatureAccess = (featureKey: string) => productAccess?.[featureKey]?.allowed === true;
// R-P1c Konnektor-Demo: Walking-Skeleton-Eintrag zeigt die drei Gate-Zustaende (aktiv/gesperrt/ausgeblendet).
const walkingSkeletonDecision = productAccess?.[WALKING_SKELETON_MODULE_KEY];
const walkingSkeletonState = walkingSkeletonDecision ? resolveConnectorNavState(walkingSkeletonDecision) : "hidden";
const sectionItems: SideBarItem[] = [];
const entitiesItems: SideBarItem[] = [];
const productCoreItems: SideBarItem[] = [
...(hasFeatureAccess("crm-basic")
? [
{
id: UI_NAV_IDS.appCrm,
title: t("crm:title"),
path: tenantPath("crm"),
icon: <AddressBookIcon />,
},
]
: []),
...(hasFeatureAccess("files")
? [
{
id: "ui.nav.app.files",
title: t("files.nav.title"),
path: tenantPath("files"),
icon: <IconDocs />,
permission: "files.read_own" as const,
},
]
: []),
...(hasFeatureAccess("tasks")
? [
{
id: "ui.nav.app.tasks",
title: t("tasks.nav.title"),
path: tenantPath("tasks"),
icon: <ViewListIcon />,
permission: "tasks.read_own" as const,
},
]
: []),
...(hasFeatureAccess("email-marketing")
? [
{
id: UI_NAV_IDS.appEmailMarketing,
title: t("emailMarketing:title"),
path: tenantPath("email-marketing"),
icon: <IconEmails />,
},
]
: []),
...(hasFeatureAccess("workflow-engine")
? [
{
id: UI_NAV_IDS.appWorkflowEngine,
title: t("workflows.title"),
path: tenantPath("workflow-engine"),
icon: <IconWorkflows />,
},
]
: []),
...(hasFeatureAccess("api")
? [
{
id: UI_NAV_IDS.appApi,
title: t("models.apiKey.plural"),
path: tenantPath("settings/api"),
icon: <IconKeys />,
},
]
: []),
...(hasFeatureAccess("ai-credits")
? [
{
id: UI_NAV_IDS.appCredits,
title: t("models.credit.plural"),
path: tenantPath("settings/credits"),
icon: <CreditsIcon />,
},
]
: []),
...(walkingSkeletonState !== "hidden"
? [
{
id: "ui.nav.app.walkingSkeleton",
title: t("walkingSkeleton.nav"),
path: tenantPath("walking-skeleton"),
icon: <IconDashboard />,
disabled: walkingSkeletonState === "locked",
},
]
: []),
];
entityGroups.forEach((group) => {
const item: SideBarItem = {
id: `ui.nav.app.entityGroup.${uiIdSegment(group.slug)}`,
uiIdPattern: UI_ID_PATTERNS.appEntityGroup,
uiIdParams: { groupSlug: group.slug },
title: translateLabel(t, i18n, group.title),
// icon: group.icon,
path: tenantPath(`g/${group.slug}`),
icon: group.icon,
// isActive: true,
};
if (group.collapsible) {
item.isCollapsible = true;
item.items = [
{
id: `ui.nav.app.entityGroup.${uiIdSegment(group.slug)}.overview`,
title: t("shared.overview"),
path: tenantPath(`g/${group.slug}`),
},
...group.entities.map(({ entity }) => {
return {
id: `ui.nav.app.entityGroup.${uiIdSegment(group.slug)}.entity.${uiIdSegment(entity.slug)}`,
uiIdPattern: UI_ID_PATTERNS.appEntityGroupEntity,
uiIdParams: { groupSlug: group.slug, entityName: entity.name ?? entity.slug },
title: translateLabel(t, i18n, entity.titlePlural),
// icon: entity.icon,
path: tenantPath(`g/${group.slug}/${entity.slug}`),
permission: getEntityPermission(entity, "view"),
// entityIcon: entity.icon,
};
}),
];
}
if (group.section) {
const sectionTitle = translateLabel(t, i18n, group.section);
const section = sectionItems.find((f) => f.title === sectionTitle);
if (section) {
section.items = [...(section.items ?? []), item];
} else {
sectionItems.push({
id: `ui.nav.app.section.${uiIdSegment(group.section)}`,
uiIdPattern: UI_ID_PATTERNS.appSection,
uiIdParams: { sectionName: group.section },
title: sectionTitle,
path: ``,
items: [item],
});
}
} else {
entitiesItems.push(item);
}
});
entities
.filter((f) => f.showInSidebar)
.forEach((entity) => {
entitiesItems.push({
id: `ui.nav.app.entity.${uiIdSegment(entity.slug)}`,
uiIdPattern: UI_ID_PATTERNS.appEntity,
uiIdParams: { entityName: entity.name ?? entity.slug, entitySlug: entity.slug },
title: translateLabel(t, i18n, entity.titlePlural),
icon: entity.icon,
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN],
path: tenantPath(entity.slug),
permission: getEntityPermission(entity, "view"),
// side: isDebugMode && SideText({ text: modelProperties.length > 0 ? "DB Model" : "Custom" }),
});
});
const appItem: SideBarItem = {
id: "ui.nav.app.primary",
title: "",
path: "",
items: [
{
id: UI_NAV_IDS.appDashboard,
title: t("app.sidebar.dashboard"),
path: tenantPath("dashboard"),
icon: <IconDashboard />,
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN, TenantUserType.MEMBER],
},
{
id: UI_NAV_IDS.appNotifications,
title: t("app.sidebar.notifications"),
path: tenantPath("notifications"),
icon: <IconNotifications />,
},
...productCoreItems,
...entitiesItems,
// {
// title: t("models.email.plural"),
// path: `${currentTenantUrl}/emails`,
// icon: SvgIcon.EMAILS,
// },
],
};
return [
appItem,
...sectionItems,
{
id: "ui.nav.app.secondary",
title: "",
path: "",
isSecondary: true,
items: [
{
id: UI_NAV_IDS.appSettings,
title: t("app.sidebar.settings"),
icon: <IconSettings />,
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN],
path: tenantPath("settings"),
redirectTo: tenantPath("settings/profile"),
},
// {
// title: t("app.sidebar.logs"),
// icon: SvgIcon.LOGS,
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN],
// path: `${currentTenantUrl}/logs`,
// },
{
id: UI_NAV_IDS.appSwitchToAdmin,
title: t("admin.switchToAdmin"),
path: absoluteAdminPath(routing, "dashboard"),
icon: <IconAdmin />,
adminOnly: true,
},
],
},
];
};
// function SideText({ text }: { text: string }) {
// return (
// <div className="inline-flex justify-center space-x-1 items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-900 italic truncate w-20">
// <div className="truncate">{text}</div>
// </div>
// );
// }
@@ -0,0 +1,57 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DocsSidebar } from "./DocsSidebar";
import { SideBarItem } from "./SidebarItem";
describe("DocsSidebar", () => {
it("should return an array of SideBarItem objects", () => {
const docsSidebar: SideBarItem[] = DocsSidebar();
expect(Array.isArray(docsSidebar)).toBe(true);
docsSidebar.forEach((item) => {
expect(typeof item.title).toBe("string");
expect(typeof item.path).toBe("string");
if (item.items) {
expect(Array.isArray(item.items)).toBe(true);
item.items.forEach((subItem) => {
expect(typeof subItem.title).toBe("string");
expect(typeof subItem.path).toBe("string");
});
}
});
});
it("should return feature items with tab titles starting with capital letters", () => {
const docsSidebar: SideBarItem[] = DocsSidebar();
const pageBlockItem = docsSidebar[2];
const pageBlockTab = pageBlockItem.items?.find((item) => item.path === "/docs/features");
expect(pageBlockTab).toBeDefined();
expect(pageBlockTab?.title).toMatch(/^\w/);
expect(pageBlockTab?.title.charAt(0)).toMatch(/[A-Z]/);
});
it("does not expose removed marketing docs in the default navigation", () => {
const serialized = JSON.stringify(DocsSidebar());
expect(serialized).not.toContain(["/docs/page", "-blocks"].join(""));
expect(serialized).not.toContain("/docs/features/marketing");
expect(serialized).not.toContain("/docs/features/email-marketing");
expect(serialized).not.toContain("/docs/features/help-desk");
expect(serialized).not.toContain("/docs/features/knowledge-base");
expect(serialized).not.toContain("/docs/features/analytics");
expect(serialized).not.toContain("/docs/features/onboarding");
expect(serialized).not.toContain("/docs/learning-center/guides/branding/landing");
expect(serialized).not.toContain("🚀");
expect(serialized).not.toContain("🚧");
});
it("uses semantic docs sidebar translation keys", () => {
const source = readFileSync(join(process.cwd(), "apps/web/app/application/sidebar/DocsSidebar.ts"), "utf8");
expect(source).toContain("docs.sidebar.introduction");
expect(source).toContain("docs.sidebar.learningCenter");
expect(source).toContain("docs.sidebar.coreModels");
expect(source.match(/^\s*title:\s*"(?!")/gmu)).toBeNull();
expect(source).not.toContain("ui.autogenerated");
});
});
@@ -0,0 +1,407 @@
import { SideBarItem } from "./SidebarItem";
import { t as i18nT } from "i18next";
import { UI_ID_PATTERNS, UI_NAV_IDS, uiIdSegment } from "~/application/ui/uiRegistry";
function withDocsSidebarIds(items: SideBarItem[], seenIds = new Map<string, number>()): SideBarItem[] {
return items.map((item) => {
const baseId = item.id ?? (item.path ? `ui.nav.docs.${uiIdSegment(item.path.replace(/^\/docs\/?/, "") || "index")}` : `ui.nav.docs.group.${uiIdSegment(item.title)}`);
const seenCount = seenIds.get(baseId) ?? 0;
seenIds.set(baseId, seenCount + 1);
const id = seenCount === 0 ? baseId : `${baseId}.${seenCount + 1}`;
return {
...item,
id,
uiIdPattern: item.uiIdPattern ?? (item.path ? UI_ID_PATTERNS.docsRoute : undefined),
uiIdParams: item.uiIdParams ?? (item.path ? { routeSlug: item.path.replace(/^\/docs\/?/, "") || "index" } : undefined),
items: item.items ? withDocsSidebarIds(item.items, seenIds) : item.items,
};
});
}
export const DocsSidebar = (): SideBarItem[] => {
return withDocsSidebarIds([
{
id: "ui.nav.docs.primary",
title: "",
path: "/docs",
exact: true,
items: [
{
id: UI_NAV_IDS.docsIntroduction,
title: i18nT("docs.sidebar.introduction"),
path: "/docs",
exact: true,
},
],
},
{
id: "ui.nav.docs.gettingStarted",
title: i18nT("docs.sidebar.gettingStarted"),
path: "",
items: [
{
title: i18nT("docs.sidebar.stack"),
path: "/docs/stack",
},
// {
// title: "Code structure",
// path: "/docs/code-structure",
//
// },
{
title: i18nT("docs.sidebar.roadmap"),
path: "/docs/roadmap",
},
{
title: i18nT("docs.sidebar.license"),
path: "/docs/license",
},
{
title: i18nT("docs.sidebar.contributing"),
path: "/docs/contributing",
},
{
title: i18nT("docs.sidebar.community"),
path: "/docs/community",
},
],
},
{
id: "ui.nav.docs.product",
title: i18nT("docs.sidebar.product"),
path: "",
items: [
{
title: i18nT("docs.sidebar.features"),
path: "/docs/features",
items: [
{
title: i18nT("docs.sidebar.allFeatures"),
path: "/docs/features",
exact: true,
},
{
title: i18nT("docs.sidebar.adminPortal"),
path: "/docs/features/admin-portal",
},
{
title: i18nT("docs.sidebar.appPortal"),
path: "/docs/features/app-portal",
},
{
title: i18nT("docs.sidebar.authentication"),
path: "/docs/features/authentication",
},
{
title: i18nT("docs.sidebar.subscriptions"),
path: "/docs/features/subscriptions",
},
{
title: i18nT("docs.sidebar.entityBuilder"),
path: "/docs/features/entity-builder",
},
{
title: i18nT("docs.sidebar.api"),
path: "/docs/features/api",
},
{
title: i18nT("docs.sidebar.webhooks"),
path: "/docs/features/webhooks",
},
{
title: i18nT("docs.sidebar.auditTrails"),
path: "/docs/features/audit-trails",
},
{
title: i18nT("docs.sidebar.rolesPermissions"),
path: "/docs/features/roles-and-permissions",
},
{
title: i18nT("docs.sidebar.eventsAndWebhooks"),
path: "/docs/features/events-and-webhooks",
},
{
title: i18nT("docs.sidebar.notifications"),
path: "/docs/features/notifications",
},
],
},
{
title: i18nT("docs.sidebar.components"),
path: "/docs/components",
items: [
{
title: i18nT("docs.sidebar.buttons"),
path: "/docs/components/buttons",
},
{
title: i18nT("docs.sidebar.badges"),
path: "/docs/components/badges",
},
{
title: i18nT("docs.sidebar.banners"),
path: "/docs/components/banners",
},
{
title: i18nT("docs.sidebar.breadcrumbs"),
path: "/docs/components/breadcrumbs",
},
{
title: i18nT("docs.sidebar.commandPalette"),
path: "/docs/components/command-palette",
},
{
title: i18nT("docs.sidebar.dropdowns"),
path: "/docs/components/dropdowns",
},
{
title: i18nT("docs.sidebar.emptyStates"),
path: "/docs/components/empty-states",
},
{
title: i18nT("docs.sidebar.forms"),
path: "/docs/components/forms",
},
// {
// title: "Layouts",
// path: "/docs/components/layouts",
//
// },
{
title: i18nT("docs.sidebar.loaders"),
path: "/docs/components/loaders",
},
{
title: i18nT("docs.sidebar.modals"),
path: "/docs/components/modals",
},
{
title: i18nT("docs.sidebar.pdfViewer"),
path: "/docs/components/pdf-viewer",
},
{
title: i18nT("docs.sidebar.tables"),
path: "/docs/components/tables",
},
{
title: i18nT("docs.sidebar.tabs"),
path: "/docs/components/tabs",
},
{
title: i18nT("docs.sidebar.tooltips"),
path: "/docs/components/tooltips",
},
{
title: i18nT("docs.sidebar.uploaders"),
path: "/docs/components/uploaders",
},
{
title: i18nT("docs.sidebar.logoAndIcon"),
path: "/docs/components/logo-and-icon",
},
// {
// title: "Inputs",
// path: "/docs/components/inputs",
//
// items: [
{
title: i18nT("docs.sidebar.inputText"),
path: "/docs/components/inputs/text",
},
{
title: i18nT("docs.sidebar.inputNumber"),
path: "/docs/components/inputs/number",
},
{
title: i18nT("docs.sidebar.inputDate"),
path: "/docs/components/inputs/date",
},
{
title: i18nT("docs.sidebar.inputSelector"),
path: "/docs/components/inputs/selector",
},
{
title: i18nT("docs.sidebar.inputCheckbox"),
path: "/docs/components/inputs/checkbox",
},
{
title: i18nT("docs.sidebar.inputRadioGroup"),
path: "/docs/components/inputs/radio-group",
},
// ],
// },
],
},
],
},
{
id: "ui.nav.docs.learningCenter",
title: i18nT("docs.sidebar.learningCenter"),
path: "/docs/learning-center",
items: [
{ title: i18nT("docs.sidebar.learningCenter"), path: "/docs/learning-center", exact: true },
{
title: i18nT("docs.sidebar.quickStart"),
path: "/docs/learning-center/tutorials/quick-start",
exact: true,
},
{
title: i18nT("docs.sidebar.quickGuides"),
path: "",
items: [
{
title: i18nT("docs.sidebar.productReleases"),
path: "/docs/learning-center/guides/upgrade/product-releases",
},
{
title: i18nT("docs.sidebar.customizeTheme"),
path: "/docs/learning-center/guides/branding/theme",
},
{
title: i18nT("docs.sidebar.customizeLogoIcon"),
path: "/docs/learning-center/guides/branding/logo-and-icon",
},
{
title: i18nT("docs.sidebar.generateFavicon"),
path: "/docs/learning-center/guides/branding/favicon",
},
{
title: i18nT("docs.sidebar.supportLanguageI18n"),
path: "/docs/learning-center/guides/localization/support-a-language",
},
{
title: i18nT("docs.sidebar.createCustomEntity"),
path: "/docs/learning-center/guides/entities/create-a-custom-entity",
},
{
title: i18nT("docs.sidebar.useCustomEntityApi"),
path: "/docs/learning-center/guides/entities/use-the-custom-entity-api",
},
{
title: i18nT("docs.sidebar.sharedEntityRows"),
path: "/docs/learning-center/guides/entities/shared-entity-rows",
},
{
title: i18nT("docs.sidebar.planEntityLimits"),
path: "/docs/learning-center/guides/entities/plan-entity-limits",
},
{
title: i18nT("docs.sidebar.extendExistingModels"),
path: "/docs/learning-center/guides/entities/extend-existing-models",
},
{
title: i18nT("docs.sidebar.singleSignOn"),
path: "/docs/learning-center/guides/sso/",
},
{
title: i18nT("docs.sidebar.vercel"),
path: "/docs/learning-center/guides/hosting/vercel",
},
{
title: i18nT("docs.sidebar.supabase"),
path: "/docs/learning-center/guides/database/supabase",
},
],
},
],
},
{
id: "ui.nav.docs.coreModels",
title: i18nT("docs.sidebar.coreModels"),
path: "",
items: [
{
title: i18nT("docs.sidebar.prismaSchema"),
path: "/docs/core-models",
exact: true,
},
{
title: i18nT("docs.sidebar.account"),
path: "/docs/core-models/account",
items: [
// {
// title: "Diagram",
// path: "/docs/core-models/account/diagram",
//
// },
{
title: i18nT("docs.sidebar.user"),
path: "/docs/core-models/account/user",
},
{
title: i18nT("docs.sidebar.tenant"),
path: "/docs/core-models/account/tenant",
exact: true,
},
{
title: i18nT("docs.sidebar.tenantUser"),
path: "/docs/core-models/account/tenant-user",
},
{
title: i18nT("docs.sidebar.apiKey"),
path: "/docs/core-models/account/api-key",
},
],
},
{
title: i18nT("docs.sidebar.subscription"),
path: "/docs/core-models/subscription",
items: [
{
title: i18nT("docs.sidebar.product"),
path: "/docs/core-models/subscription/product",
},
{
title: i18nT("docs.sidebar.price"),
path: "/docs/core-models/subscription/price",
},
{
title: i18nT("docs.sidebar.feature"),
path: "/docs/core-models/subscription/feature",
},
{
title: i18nT("docs.sidebar.tenantSubscription"),
path: "/docs/core-models/subscription/tenant-subscription",
},
],
},
{
title: i18nT("docs.sidebar.customEntity"),
path: "/docs/core-models/custom-entity",
items: [
{
title: i18nT("docs.sidebar.entity"),
path: "/docs/core-models/custom-entity/entity",
},
{
title: i18nT("docs.sidebar.property"),
path: "/docs/core-models/custom-entity/property",
},
{
title: i18nT("docs.sidebar.row"),
path: "/docs/core-models/custom-entity/row",
exact: true,
},
{
title: i18nT("docs.sidebar.rowValue"),
path: "/docs/core-models/custom-entity/row-value",
},
{
title: i18nT("docs.sidebar.webhook"),
path: "/docs/core-models/custom-entity/webhook",
},
],
},
],
},
]);
};
@@ -0,0 +1,10 @@
import { SideBarItem } from "./SidebarItem";
export type SidebarGroupDto = {
id?: string;
uiIdPattern?: string;
uiIdParams?: Record<string, string | number | boolean | null | undefined>;
items: SideBarItem[];
title?: string;
type?: "main" | "secondary" | "quick-link";
};
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import { AdminSidebar } from "./AdminSidebar";
import { AppSidebar } from "./AppSidebar";
import { DocsSidebar } from "./DocsSidebar";
import type { SideBarItem } from "./SidebarItem";
import type { EntitySimple } from "~/utils/db/entities/entities.db.server";
import { UI_ID_PATTERNS, UI_NAV_IDS, UI_SURFACE_IDS } from "~/application/ui/uiRegistry";
function flattenSidebarItems(items: SideBarItem[]): SideBarItem[] {
return items.flatMap((item) => [item, ...flattenSidebarItems(item.items ?? [])]);
}
function expectCompleteUniqueIds(items: SideBarItem[]) {
const flattened = flattenSidebarItems(items);
const ids = flattened.map((item) => item.id);
expect(ids).not.toContain(undefined);
expect(ids.every((id) => id?.startsWith("ui."))).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
}
function expectStaticItemsHaveIds(items: SideBarItem[]) {
const staticItems = flattenSidebarItems(items).filter((item) => !item.uiIdPattern);
expect(staticItems.length).toBeGreaterThan(0);
expect(staticItems.every((item) => typeof item.id === "string" && item.id.length > 0)).toBe(true);
}
function expectDynamicItemsHavePatternMetadata(items: SideBarItem[]) {
const dynamicItems = flattenSidebarItems(items).filter((item) => item.uiIdPattern);
expect(dynamicItems.every((item) => item.id && item.uiIdPattern && item.uiIdParams && Object.keys(item.uiIdParams).length > 0)).toBe(true);
}
describe("sidebar UI IDs", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
it("defines the primary sidebar surface IDs", () => {
expect(UI_SURFACE_IDS.appSidebar).toBe("ui.surface.app.sidebar");
expect(UI_SURFACE_IDS.adminSidebar).toBe("ui.surface.admin.sidebar");
expect(UI_SURFACE_IDS.docsSidebar).toBe("ui.surface.docs.sidebar");
});
it("assigns unique IDs to admin sidebar items", () => {
const sidebar = AdminSidebar({ t, appConfiguration: null });
const ids = flattenSidebarItems(sidebar).map((item) => item.id);
expectCompleteUniqueIds(sidebar);
expectStaticItemsHaveIds(sidebar);
expect(ids).toContain(UI_NAV_IDS.adminDashboard);
expect(ids).toContain(UI_NAV_IDS.adminSettings);
expect(ids).toContain(UI_NAV_IDS.adminEntities);
});
it("assigns unique IDs to app sidebar static and dynamic items", () => {
const entities = [
{ id: "1", order: 1, name: "contacts", slug: "contacts", title: "Contact", titlePlural: "Contacts", active: true, type: "custom", hasApi: true, showInSidebar: true, icon: "", onEdit: null },
{ id: "2", order: 2, name: "hidden", slug: "hidden", title: "Hidden", titlePlural: "Hidden", active: true, type: "custom", hasApi: true, showInSidebar: false, icon: "", onEdit: null },
] as EntitySimple[];
const sidebar = AppSidebar({
t,
i18n,
tenantId: "acme",
entities,
entityGroups: [
{
slug: "sales",
title: "Sales",
icon: "",
collapsible: true,
section: "Operations",
entities: [{ entity: entities[0] }],
},
] as any,
appConfiguration: null,
});
const ids = flattenSidebarItems(sidebar).map((item) => item.id);
const dynamicEntity = flattenSidebarItems(sidebar).find((item) => item.id === "ui.nav.app.entity.contacts");
expectCompleteUniqueIds(sidebar);
expectStaticItemsHaveIds(sidebar);
expectDynamicItemsHavePatternMetadata(sidebar);
expect(ids).toContain(UI_NAV_IDS.appDashboard);
expect(ids).toContain(UI_NAV_IDS.appSettings);
expect(ids).toContain("ui.nav.app.entity.contacts");
expect(ids).toContain("ui.nav.app.entityGroup.sales");
expect(ids).toContain("ui.nav.app.section.operations");
expect(dynamicEntity?.uiIdPattern).toBe(UI_ID_PATTERNS.appEntity);
expect(dynamicEntity?.uiIdParams).toMatchObject({ entityName: "contacts", entitySlug: "contacts" });
});
it("assigns unique IDs to docs sidebar items", () => {
const sidebar = DocsSidebar();
const ids = flattenSidebarItems(sidebar).map((item) => item.id);
expectCompleteUniqueIds(sidebar);
expectStaticItemsHaveIds(sidebar);
expectDynamicItemsHavePatternMetadata(sidebar);
expect(ids).toContain(UI_NAV_IDS.docsIntroduction);
expect(ids).toContain("ui.nav.docs.componentsButtons");
const removedDocsPrefix = ["ui.nav.docs.page", "Blocks."].join("");
expect(ids.some((id) => id?.startsWith(removedDocsPrefix))).toBe(false);
});
});
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff. Show more