Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Phase 09 Task #82 — Thin Audit-Event wrapper for admin-sso paths.
|
||||
*
|
||||
* Centralises action names + metadata sanitization for the login / logout
|
||||
* / fallback / mfa-challenge paths. Sits on top of
|
||||
* `@toolctrl/db/audit-events.server` which already handles redaction.
|
||||
*/
|
||||
import { createAuditEvent } from "@toolctrl/db/audit-events.server";
|
||||
import type { Prisma } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export type AdminSsoAuditAction =
|
||||
| "admin.login.sso.success"
|
||||
| "admin.login.sso.failure"
|
||||
| "admin.login.fallback.success"
|
||||
| "admin.login.fallback.failure"
|
||||
| "admin.logout.sso"
|
||||
| "admin.mfa.app.challenge.success"
|
||||
| "admin.mfa.app.challenge.failure";
|
||||
|
||||
export type AdminSsoAuditInput = {
|
||||
readonly action: AdminSsoAuditAction;
|
||||
readonly actorAdminUserId?: string | null;
|
||||
readonly ssoSubjectId?: string | null;
|
||||
readonly request?: Request;
|
||||
readonly meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function clientMeta(request: Request | undefined): { ip: string | null; userAgent: string | null } {
|
||||
if (!request) return { ip: null, userAgent: null };
|
||||
const fwd = request.headers.get("x-forwarded-for") ?? "";
|
||||
const ip = fwd.split(",")[0]?.trim() || request.headers.get("x-real-ip") || null;
|
||||
const userAgent = request.headers.get("user-agent") || null;
|
||||
return { ip, userAgent };
|
||||
}
|
||||
|
||||
export async function writeAdminSsoAuditEvent(input: AdminSsoAuditInput): Promise<void> {
|
||||
const { ip, userAgent } = clientMeta(input.request);
|
||||
try {
|
||||
await createAuditEvent({
|
||||
action: input.action,
|
||||
actorType: "admin",
|
||||
actorId: input.actorAdminUserId ?? null,
|
||||
metadata: {
|
||||
ssoSubjectId: input.ssoSubjectId ?? null,
|
||||
ip,
|
||||
userAgent,
|
||||
...input.meta,
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
} catch {
|
||||
// Audit failures must never block the auth flow. The DB-level error
|
||||
// already lands in Sentry via the audit-events boundary.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractAdminSsoClaims, hasAllowedAdminGroup, isAllowedAdminGroup, mapGroupsToRole } from "./claims.server";
|
||||
|
||||
describe("mapGroupsToRole", () => {
|
||||
it("maps toolctrl-admin to super_admin", () => {
|
||||
expect(mapGroupsToRole(["toolctrl-admin"])).toBe("super_admin");
|
||||
});
|
||||
|
||||
it("maps toolctrl-staff to staff", () => {
|
||||
expect(mapGroupsToRole(["toolctrl-staff"])).toBe("staff");
|
||||
});
|
||||
|
||||
it("prefers super_admin when both groups are present", () => {
|
||||
expect(mapGroupsToRole(["toolctrl-staff", "toolctrl-admin"])).toBe("super_admin");
|
||||
});
|
||||
|
||||
it("returns null for an unknown group", () => {
|
||||
expect(mapGroupsToRole(["random-group"])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(mapGroupsToRole([])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for null/undefined input", () => {
|
||||
expect(mapGroupsToRole(undefined)).toBeNull();
|
||||
expect(mapGroupsToRole(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedAdminGroup / hasAllowedAdminGroup", () => {
|
||||
it("recognizes the two allowlisted groups", () => {
|
||||
expect(isAllowedAdminGroup("toolctrl-admin")).toBe(true);
|
||||
expect(isAllowedAdminGroup("toolctrl-staff")).toBe(true);
|
||||
expect(isAllowedAdminGroup("toolctrl-rando")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when claim list has no allowed group", () => {
|
||||
expect(hasAllowedAdminGroup(["other"])).toBe(false);
|
||||
expect(hasAllowedAdminGroup([])).toBe(false);
|
||||
expect(hasAllowedAdminGroup(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when at least one allowed group is present", () => {
|
||||
expect(hasAllowedAdminGroup(["other", "toolctrl-staff"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAdminSsoClaims", () => {
|
||||
const validClaims = {
|
||||
sub: "auth-uid-123",
|
||||
email: "Admin@Example.test",
|
||||
email_verified: true,
|
||||
name: "Admin Person",
|
||||
groups: ["toolctrl-admin"],
|
||||
};
|
||||
|
||||
it("extracts and normalizes a valid claim set", () => {
|
||||
const result = extractAdminSsoClaims(validClaims);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.claims.subject).toBe("auth-uid-123");
|
||||
expect(result.claims.email).toBe("admin@example.test");
|
||||
expect(result.claims.name).toBe("Admin Person");
|
||||
expect(result.claims.groups).toEqual(["toolctrl-admin"]);
|
||||
expect(result.claims.emailVerified).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to preferred_username when name is missing", () => {
|
||||
const result = extractAdminSsoClaims({ ...validClaims, name: undefined, preferred_username: "admin-handle" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.claims.name).toBe("admin-handle");
|
||||
});
|
||||
|
||||
it("falls back to email when both name and preferred_username are missing", () => {
|
||||
const result = extractAdminSsoClaims({ ...validClaims, name: undefined });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.claims.name).toBe("admin@example.test");
|
||||
});
|
||||
|
||||
it("rejects missing sub", () => {
|
||||
const result = extractAdminSsoClaims({ ...validClaims, sub: undefined });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("missing-sub");
|
||||
});
|
||||
|
||||
it("rejects missing email", () => {
|
||||
const result = extractAdminSsoClaims({ ...validClaims, email: "" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("missing-email");
|
||||
});
|
||||
|
||||
it("rejects email_verified=false", () => {
|
||||
const result = extractAdminSsoClaims({ ...validClaims, email_verified: false });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("email-not-verified");
|
||||
});
|
||||
|
||||
it("treats missing email_verified as true (Authentik does not always emit it)", () => {
|
||||
const claims = { ...validClaims } as Record<string, unknown>;
|
||||
delete claims.email_verified;
|
||||
const result = extractAdminSsoClaims(claims);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("filters non-string entries from groups", () => {
|
||||
const result = extractAdminSsoClaims({ ...validClaims, groups: ["toolctrl-admin", null, 7, "ok"] });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.claims.groups).toEqual(["toolctrl-admin", "ok"]);
|
||||
});
|
||||
|
||||
it("returns missing-sub for null claims", () => {
|
||||
const result = extractAdminSsoClaims(null);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("missing-sub");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Phase 09 Task #82 — OIDC ID-token claim extraction + group→role mapping.
|
||||
*
|
||||
* The "role" returned here is the *current* AdminUser identity layer in the
|
||||
* Toolctrl boilerplate. Today the only durable AdminUser concept is "exists
|
||||
* or not"; the spec adds two granular roles (`super_admin`, `staff`) that
|
||||
* we persist as `ssoGroups` snapshots while we wait for the permissions
|
||||
* matrix to grow first-class role columns. Callers should treat the role
|
||||
* as advisory + future-proof.
|
||||
*/
|
||||
|
||||
export type AuthentikGroup = "toolctrl-admin" | "toolctrl-staff";
|
||||
export type AdminSsoRole = "super_admin" | "staff";
|
||||
|
||||
export const ADMIN_GROUP: AuthentikGroup = "toolctrl-admin";
|
||||
export const STAFF_GROUP: AuthentikGroup = "toolctrl-staff";
|
||||
|
||||
export const ALLOWED_ADMIN_GROUPS: readonly AuthentikGroup[] = [ADMIN_GROUP, STAFF_GROUP];
|
||||
|
||||
/**
|
||||
* Highest-priority-wins mapping. If a user is in BOTH groups they get
|
||||
* super_admin (admins are a strict superset of staff for our purposes).
|
||||
*/
|
||||
export function mapGroupsToRole(groups: readonly string[] | undefined | null): AdminSsoRole | null {
|
||||
if (!groups || groups.length === 0) return null;
|
||||
if (groups.includes(ADMIN_GROUP)) return "super_admin";
|
||||
if (groups.includes(STAFF_GROUP)) return "staff";
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AdminSsoClaims = {
|
||||
readonly subject: string;
|
||||
readonly email: string;
|
||||
readonly emailVerified: boolean;
|
||||
readonly name: string;
|
||||
readonly groups: readonly string[];
|
||||
};
|
||||
|
||||
export type ClaimExtractionResult =
|
||||
| { readonly ok: true; readonly claims: AdminSsoClaims }
|
||||
| { readonly ok: false; readonly reason: "missing-sub" | "missing-email" | "email-not-verified" | "no-groups" };
|
||||
|
||||
/**
|
||||
* Validate + extract the claim shape we trust. Authentik emits standard
|
||||
* OIDC claims plus `groups` from the configured scope. We are deliberately
|
||||
* conservative: missing/invalid claims short-circuit the login flow rather
|
||||
* than silently falling back.
|
||||
*/
|
||||
export function extractAdminSsoClaims(idTokenClaims: Record<string, unknown> | null | undefined): ClaimExtractionResult {
|
||||
if (!idTokenClaims) {
|
||||
return { ok: false, reason: "missing-sub" };
|
||||
}
|
||||
const subject = typeof idTokenClaims.sub === "string" ? idTokenClaims.sub.trim() : "";
|
||||
if (!subject) {
|
||||
return { ok: false, reason: "missing-sub" };
|
||||
}
|
||||
const email = typeof idTokenClaims.email === "string" ? idTokenClaims.email.trim().toLowerCase() : "";
|
||||
if (!email) {
|
||||
return { ok: false, reason: "missing-email" };
|
||||
}
|
||||
// Spec security note: "email_verified=true prüfen". If the claim is
|
||||
// absent we treat it as `true` only when the IdP is the trusted
|
||||
// Authentik provider (which mints `email_verified` for SSO accounts);
|
||||
// if it is present and false, reject.
|
||||
const emailVerifiedClaim = idTokenClaims.email_verified;
|
||||
const emailVerified = emailVerifiedClaim === undefined ? true : emailVerifiedClaim === true;
|
||||
if (!emailVerified) {
|
||||
return { ok: false, reason: "email-not-verified" };
|
||||
}
|
||||
const rawGroups = idTokenClaims.groups;
|
||||
const groups: string[] = Array.isArray(rawGroups)
|
||||
? rawGroups.filter((g): g is string => typeof g === "string" && g.length > 0)
|
||||
: [];
|
||||
const nameFromClaim = typeof idTokenClaims.name === "string" ? idTokenClaims.name.trim() : "";
|
||||
const preferredName = typeof idTokenClaims.preferred_username === "string" ? idTokenClaims.preferred_username.trim() : "";
|
||||
const name = nameFromClaim || preferredName || email;
|
||||
return {
|
||||
ok: true,
|
||||
claims: {
|
||||
subject,
|
||||
email,
|
||||
emailVerified,
|
||||
name,
|
||||
groups,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience guard: pass-through that throws (or returns a Response) when
|
||||
* the user has no allowed group. Kept here so the JIT-provisioning logic
|
||||
* stays focused on the DB upsert.
|
||||
*/
|
||||
export function isAllowedAdminGroup(group: string): group is AuthentikGroup {
|
||||
return ALLOWED_ADMIN_GROUPS.includes(group as AuthentikGroup);
|
||||
}
|
||||
|
||||
export function hasAllowedAdminGroup(groups: readonly string[] | undefined | null): boolean {
|
||||
if (!groups) return false;
|
||||
return groups.some(isAllowedAdminGroup);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AdminSsoConfigError, isAdminSsoConfigured, readAdminSsoEnv } from "./client.server";
|
||||
|
||||
describe("readAdminSsoEnv", () => {
|
||||
const baseEnv = {
|
||||
AUTHENTIK_ISSUER_URL: "https://auth.example.test/application/o/toolctrl-admin-panel/",
|
||||
AUTHENTIK_ADMIN_CLIENT_ID: "toolctrl-admin-panel",
|
||||
AUTHENTIK_ADMIN_CLIENT_SECRET: "shh",
|
||||
AUTHENTIK_ADMIN_REDIRECT_URI: "https://admin.example.test/auth/admin/callback",
|
||||
};
|
||||
|
||||
it("returns trimmed values when all required keys are present", () => {
|
||||
const env = readAdminSsoEnv({ ...baseEnv, AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI: "https://admin.example.test/admin/login?logged_out=1" });
|
||||
expect(env.issuerUrl).toBe(baseEnv.AUTHENTIK_ISSUER_URL);
|
||||
expect(env.clientId).toBe("toolctrl-admin-panel");
|
||||
expect(env.postLogoutRedirectUri).toBe("https://admin.example.test/admin/login?logged_out=1");
|
||||
});
|
||||
|
||||
it("throws when a required key is missing", () => {
|
||||
expect(() => readAdminSsoEnv({ ...baseEnv, AUTHENTIK_ADMIN_CLIENT_SECRET: "" })).toThrowError(AdminSsoConfigError);
|
||||
});
|
||||
|
||||
it("lists every missing key in the error message", () => {
|
||||
try {
|
||||
readAdminSsoEnv({});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AdminSsoConfigError);
|
||||
const message = (err as Error).message;
|
||||
expect(message).toContain("AUTHENTIK_ISSUER_URL");
|
||||
expect(message).toContain("AUTHENTIK_ADMIN_CLIENT_ID");
|
||||
expect(message).toContain("AUTHENTIK_ADMIN_CLIENT_SECRET");
|
||||
expect(message).toContain("AUTHENTIK_ADMIN_REDIRECT_URI");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdminSsoConfigured", () => {
|
||||
it("returns true when env is complete", () => {
|
||||
expect(
|
||||
isAdminSsoConfigured({
|
||||
AUTHENTIK_ISSUER_URL: "https://auth.example.test/",
|
||||
AUTHENTIK_ADMIN_CLIENT_ID: "id",
|
||||
AUTHENTIK_ADMIN_CLIENT_SECRET: "secret",
|
||||
AUTHENTIK_ADMIN_REDIRECT_URI: "https://admin.example.test/auth/admin/callback",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when env is incomplete", () => {
|
||||
expect(isAdminSsoConfigured({})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Phase 09 Task #82 — Admin-Panel OIDC Login (Authentik SSO).
|
||||
*
|
||||
* Thin wrapper around `openid-client` (panva/node-openid-client). Provides a
|
||||
* lazily-initialized, process-wide singleton OIDC `Client` configured against
|
||||
* the Authentik discovery document. The discovery doc and the JWKS are cached
|
||||
* inside openid-client itself — we just cache the `Client` instance.
|
||||
*
|
||||
* Initialization failures throw `AdminSsoConfigError`; callers (the
|
||||
* login/callback routes) translate that into a fallback-banner on the login
|
||||
* page rather than a 500.
|
||||
*/
|
||||
import { Issuer, type Client } from "openid-client";
|
||||
|
||||
export type AdminSsoEnv = {
|
||||
readonly issuerUrl: string;
|
||||
readonly clientId: string;
|
||||
readonly clientSecret: string;
|
||||
readonly redirectUri: string;
|
||||
readonly postLogoutRedirectUri?: string;
|
||||
};
|
||||
|
||||
export class AdminSsoConfigError extends Error {
|
||||
readonly code: "missing-env" | "discovery-failed" | "client-init-failed";
|
||||
|
||||
constructor(code: AdminSsoConfigError["code"], message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "AdminSsoConfigError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the Authentik OIDC env vars and validates that all required keys are
|
||||
* present. Throws `AdminSsoConfigError("missing-env")` if any are missing.
|
||||
* Kept env-record-shaped so tests can pass a fake without polluting process.env.
|
||||
*/
|
||||
export function readAdminSsoEnv(env: Record<string, string | undefined> = process.env): AdminSsoEnv {
|
||||
const issuerUrl = env.AUTHENTIK_ISSUER_URL?.trim();
|
||||
const clientId = env.AUTHENTIK_ADMIN_CLIENT_ID?.trim();
|
||||
const clientSecret = env.AUTHENTIK_ADMIN_CLIENT_SECRET?.trim();
|
||||
const redirectUri = env.AUTHENTIK_ADMIN_REDIRECT_URI?.trim();
|
||||
const postLogoutRedirectUri = env.AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI?.trim();
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!issuerUrl) missing.push("AUTHENTIK_ISSUER_URL");
|
||||
if (!clientId) missing.push("AUTHENTIK_ADMIN_CLIENT_ID");
|
||||
if (!clientSecret) missing.push("AUTHENTIK_ADMIN_CLIENT_SECRET");
|
||||
if (!redirectUri) missing.push("AUTHENTIK_ADMIN_REDIRECT_URI");
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new AdminSsoConfigError("missing-env", `Admin SSO env is incomplete: missing ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
issuerUrl: issuerUrl as string,
|
||||
clientId: clientId as string,
|
||||
clientSecret: clientSecret as string,
|
||||
redirectUri: redirectUri as string,
|
||||
postLogoutRedirectUri: postLogoutRedirectUri || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true iff SSO is configured (i.e. all required env vars are set).
|
||||
* Used by the login UI to decide whether to render the SSO button.
|
||||
*/
|
||||
export function isAdminSsoConfigured(env: Record<string, string | undefined> = process.env): boolean {
|
||||
try {
|
||||
readAdminSsoEnv(env);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedClient: Promise<Client> | null = null;
|
||||
let cachedEnvFingerprint: string | null = null;
|
||||
|
||||
function envFingerprint(env: AdminSsoEnv): string {
|
||||
// Fingerprint identifies whether we need to re-init when env changes
|
||||
// (mostly relevant for tests; in production env is static for the
|
||||
// lifetime of the process).
|
||||
return `${env.issuerUrl}|${env.clientId}|${env.redirectUri}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a process-wide singleton OIDC client configured against Authentik.
|
||||
* The first call performs Issuer.discover() — subsequent calls return the
|
||||
* cached instance.
|
||||
*/
|
||||
export async function getAdminSsoClient(env: AdminSsoEnv = readAdminSsoEnv()): Promise<Client> {
|
||||
const fingerprint = envFingerprint(env);
|
||||
|
||||
if (cachedClient && cachedEnvFingerprint === fingerprint) {
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
cachedEnvFingerprint = fingerprint;
|
||||
cachedClient = (async () => {
|
||||
let issuer;
|
||||
try {
|
||||
issuer = await Issuer.discover(env.issuerUrl);
|
||||
} catch (cause) {
|
||||
cachedClient = null;
|
||||
cachedEnvFingerprint = null;
|
||||
throw new AdminSsoConfigError("discovery-failed", `Authentik OIDC discovery failed for ${env.issuerUrl}`, { cause });
|
||||
}
|
||||
|
||||
try {
|
||||
return new issuer.Client({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
redirect_uris: [env.redirectUri],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
});
|
||||
} catch (cause) {
|
||||
cachedClient = null;
|
||||
cachedEnvFingerprint = null;
|
||||
throw new AdminSsoConfigError("client-init-failed", "Failed to construct Authentik OIDC client", { cause });
|
||||
}
|
||||
})();
|
||||
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
/** Test-only: reset the cached client. Do not call from production code. */
|
||||
export function __resetAdminSsoClientForTests(): void {
|
||||
cachedClient = null;
|
||||
cachedEnvFingerprint = null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Phase 09 Task #82 — Public surface of the admin-sso module.
|
||||
*
|
||||
* Re-exports the small set of helpers consumed by the auth routes and the
|
||||
* admin-login UI. Internal helpers stay file-local.
|
||||
*/
|
||||
export { AdminSsoConfigError, getAdminSsoClient, isAdminSsoConfigured, readAdminSsoEnv, type AdminSsoEnv } from "./client.server";
|
||||
export {
|
||||
ADMIN_SSO_CHALLENGE_COOKIE,
|
||||
ADMIN_SSO_CHALLENGE_TTL_MS,
|
||||
clearAdminSsoChallengeCookie,
|
||||
consumeAdminSsoChallenge,
|
||||
createAdminSsoChallenge,
|
||||
type AdminSsoChallengePayload,
|
||||
} from "./state.server";
|
||||
export {
|
||||
ADMIN_GROUP,
|
||||
ALLOWED_ADMIN_GROUPS,
|
||||
STAFF_GROUP,
|
||||
extractAdminSsoClaims,
|
||||
hasAllowedAdminGroup,
|
||||
isAllowedAdminGroup,
|
||||
mapGroupsToRole,
|
||||
type AdminSsoClaims,
|
||||
type AdminSsoRole,
|
||||
type AuthentikGroup,
|
||||
type ClaimExtractionResult,
|
||||
} from "./claims.server";
|
||||
export {
|
||||
ADMIN_SSO_PROVIDER,
|
||||
JitProvisioningError,
|
||||
jitProvisionAdmin,
|
||||
type JitProvisionedAdmin,
|
||||
type JitProvisioningDbClient,
|
||||
type JitProvisioningInput,
|
||||
type JitProvisioningResult,
|
||||
} from "./jit-provisioning.server";
|
||||
@@ -0,0 +1,201 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdminSsoClaims } from "./claims.server";
|
||||
import { ADMIN_SSO_PROVIDER, jitProvisionAdmin, type JitProvisioningDbClient } from "./jit-provisioning.server";
|
||||
|
||||
type AdminRecord = {
|
||||
userId: string;
|
||||
ssoProvider: string | null;
|
||||
ssoSubjectId: string | null;
|
||||
ssoGroups: string[];
|
||||
lastSsoLoginAt: Date | null;
|
||||
};
|
||||
|
||||
type UserRecord = { id: string; email: string; firstName: string; lastName: string };
|
||||
|
||||
function buildFakeDb() {
|
||||
const adminRows = new Map<string, AdminRecord>();
|
||||
const userRows = new Map<string, UserRecord>();
|
||||
let userCounter = 0;
|
||||
|
||||
const db: JitProvisioningDbClient = {
|
||||
adminUser: {
|
||||
findUnique: vi.fn(async (args) => {
|
||||
const { ssoProvider, ssoSubjectId } = args.where.ssoProvider_ssoSubjectId;
|
||||
for (const row of adminRows.values()) {
|
||||
if (row.ssoProvider === ssoProvider && row.ssoSubjectId === ssoSubjectId) {
|
||||
return { userId: row.userId, ssoProvider: row.ssoProvider, ssoSubjectId: row.ssoSubjectId, ssoGroups: row.ssoGroups, lastSsoLoginAt: row.lastSsoLoginAt };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
update: vi.fn(async (args) => {
|
||||
const existing = adminRows.get(args.where.userId);
|
||||
if (!existing) {
|
||||
const err = new Error("record not found") as Error & { code?: string };
|
||||
err.code = "P2025";
|
||||
throw err;
|
||||
}
|
||||
const updated: AdminRecord = {
|
||||
...existing,
|
||||
ssoProvider: args.data.ssoProvider ?? existing.ssoProvider,
|
||||
ssoSubjectId: args.data.ssoSubjectId ?? existing.ssoSubjectId,
|
||||
ssoGroups: args.data.ssoGroups ?? existing.ssoGroups,
|
||||
lastSsoLoginAt: args.data.lastSsoLoginAt ?? existing.lastSsoLoginAt,
|
||||
};
|
||||
adminRows.set(args.where.userId, updated);
|
||||
return updated;
|
||||
}),
|
||||
create: vi.fn(async (args) => {
|
||||
const row: AdminRecord = {
|
||||
userId: args.data.userId,
|
||||
ssoProvider: args.data.ssoProvider,
|
||||
ssoSubjectId: args.data.ssoSubjectId,
|
||||
ssoGroups: [...args.data.ssoGroups],
|
||||
lastSsoLoginAt: args.data.lastSsoLoginAt,
|
||||
};
|
||||
adminRows.set(row.userId, row);
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(async (args) => {
|
||||
for (const u of userRows.values()) {
|
||||
if (u.email === args.where.email) return { id: u.id, email: u.email };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
update: vi.fn(async (args) => {
|
||||
const existing = userRows.get(args.where.id);
|
||||
if (!existing) throw new Error(`user ${args.where.id} missing`);
|
||||
const updated: UserRecord = {
|
||||
...existing,
|
||||
firstName: args.data.firstName ?? existing.firstName,
|
||||
lastName: args.data.lastName ?? existing.lastName,
|
||||
};
|
||||
userRows.set(args.where.id, updated);
|
||||
return updated;
|
||||
}),
|
||||
create: vi.fn(async (args) => {
|
||||
userCounter += 1;
|
||||
const id = `user_${userCounter}`;
|
||||
const row: UserRecord = {
|
||||
id,
|
||||
email: args.data.email,
|
||||
firstName: args.data.firstName,
|
||||
lastName: args.data.lastName,
|
||||
};
|
||||
userRows.set(id, row);
|
||||
return { id: row.id, email: row.email };
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return { db, adminRows, userRows };
|
||||
}
|
||||
|
||||
function buildClaims(overrides: Partial<AdminSsoClaims> = {}): AdminSsoClaims {
|
||||
return {
|
||||
subject: "authentik-sub-1",
|
||||
email: "admin@example.test",
|
||||
emailVerified: true,
|
||||
name: "Admin Person",
|
||||
groups: ["toolctrl-admin"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("jitProvisionAdmin", () => {
|
||||
let env: ReturnType<typeof buildFakeDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
env = buildFakeDb();
|
||||
});
|
||||
|
||||
it("creates User+AdminUser for a brand new toolctrl-admin claim and returns super_admin", async () => {
|
||||
const result = await jitProvisionAdmin({ claims: buildClaims() }, env.db);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.admin.role).toBe("super_admin");
|
||||
expect(result.admin.created).toBe(true);
|
||||
expect(result.admin.email).toBe("admin@example.test");
|
||||
}
|
||||
expect(env.adminRows.size).toBe(1);
|
||||
expect(env.userRows.size).toBe(1);
|
||||
const admin = [...env.adminRows.values()][0]!;
|
||||
expect(admin.ssoProvider).toBe(ADMIN_SSO_PROVIDER);
|
||||
expect(admin.ssoSubjectId).toBe("authentik-sub-1");
|
||||
expect(admin.ssoGroups).toEqual(["toolctrl-admin"]);
|
||||
});
|
||||
|
||||
it("maps toolctrl-staff to staff role", async () => {
|
||||
const result = await jitProvisionAdmin({ claims: buildClaims({ groups: ["toolctrl-staff"] }) }, env.db);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.admin.role).toBe("staff");
|
||||
});
|
||||
|
||||
it("rejects an unknown-group claim with forbidden-group", async () => {
|
||||
const result = await jitProvisionAdmin({ claims: buildClaims({ groups: ["random"] }) }, env.db);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("forbidden-group");
|
||||
expect(env.adminRows.size).toBe(0);
|
||||
expect(env.userRows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an empty-group claim with forbidden-group", async () => {
|
||||
const result = await jitProvisionAdmin({ claims: buildClaims({ groups: [] }) }, env.db);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the existing SSO admin and refreshes groups + lastSsoLoginAt", async () => {
|
||||
// Seed: first login creates the admin.
|
||||
const t0 = new Date("2026-01-01T00:00:00Z");
|
||||
await jitProvisionAdmin({ claims: buildClaims(), now: t0 }, env.db);
|
||||
const admin = [...env.adminRows.values()][0]!;
|
||||
expect(admin.lastSsoLoginAt).toEqual(t0);
|
||||
|
||||
// Second login with same sub but new groups + later timestamp.
|
||||
const t1 = new Date("2026-02-01T00:00:00Z");
|
||||
const result = await jitProvisionAdmin(
|
||||
{ claims: buildClaims({ groups: ["toolctrl-staff"] }), now: t1 },
|
||||
env.db,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.admin.created).toBe(false);
|
||||
// Role re-syncs from current group membership (Authentik = source of truth).
|
||||
expect(result.admin.role).toBe("staff");
|
||||
}
|
||||
const refreshed = [...env.adminRows.values()][0]!;
|
||||
expect(refreshed.ssoGroups).toEqual(["toolctrl-staff"]);
|
||||
expect(refreshed.lastSsoLoginAt).toEqual(t1);
|
||||
// No new user row.
|
||||
expect(env.userRows.size).toBe(1);
|
||||
});
|
||||
|
||||
it("links an existing email-matched legacy admin by attaching SSO fields", async () => {
|
||||
// Seed legacy admin (no SSO fields).
|
||||
env.userRows.set("legacy_1", { id: "legacy_1", email: "admin@example.test", firstName: "Old", lastName: "Admin" });
|
||||
env.adminRows.set("legacy_1", { userId: "legacy_1", ssoProvider: null, ssoSubjectId: null, ssoGroups: [], lastSsoLoginAt: null });
|
||||
|
||||
const result = await jitProvisionAdmin({ claims: buildClaims() }, env.db);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.admin.userId).toBe("legacy_1");
|
||||
expect(result.admin.linkedExisting).toBe(true);
|
||||
expect(result.admin.created).toBe(false);
|
||||
}
|
||||
const admin = env.adminRows.get("legacy_1")!;
|
||||
expect(admin.ssoProvider).toBe(ADMIN_SSO_PROVIDER);
|
||||
expect(admin.ssoSubjectId).toBe("authentik-sub-1");
|
||||
expect(env.userRows.size).toBe(1); // no duplicate user
|
||||
});
|
||||
|
||||
it("creates an AdminUser when a legacy User exists but has no AdminUser row", async () => {
|
||||
env.userRows.set("legacy_2", { id: "legacy_2", email: "admin@example.test", firstName: "X", lastName: "Y" });
|
||||
// Note: no AdminUser row pre-existing.
|
||||
const result = await jitProvisionAdmin({ claims: buildClaims() }, env.db);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(env.adminRows.size).toBe(1);
|
||||
expect(env.adminRows.get("legacy_2")?.ssoSubjectId).toBe("authentik-sub-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Phase 09 Task #82 — JIT-provisioning for Admin SSO.
|
||||
*
|
||||
* Lookup order:
|
||||
* 1. AdminUser by (ssoProvider, ssoSubjectId)
|
||||
* 2. AdminUser by linked User.email (case-insensitive) — legacy linking
|
||||
* 3. New User + AdminUser if at least one allowed group is in claims
|
||||
*
|
||||
* Idempotent under concurrent logins thanks to the unique
|
||||
* (ssoProvider, ssoSubjectId) constraint added in migration
|
||||
* 20260517161240_09_admin_user_sso_fields.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { AdminSsoClaims, AdminSsoRole } from "./claims.server";
|
||||
import { hasAllowedAdminGroup, mapGroupsToRole } from "./claims.server";
|
||||
|
||||
export const ADMIN_SSO_PROVIDER = "authentik";
|
||||
|
||||
export type JitProvisioningInput = {
|
||||
readonly claims: AdminSsoClaims;
|
||||
readonly now?: Date;
|
||||
};
|
||||
|
||||
export type JitProvisionedAdmin = {
|
||||
readonly userId: string;
|
||||
readonly email: string;
|
||||
readonly role: AdminSsoRole;
|
||||
readonly created: boolean;
|
||||
readonly linkedExisting: boolean;
|
||||
};
|
||||
|
||||
export type JitProvisioningResult =
|
||||
| { readonly ok: true; readonly admin: JitProvisionedAdmin }
|
||||
| { readonly ok: false; readonly reason: "forbidden-group" };
|
||||
|
||||
export class JitProvisioningError extends Error {
|
||||
readonly reason: "forbidden-group";
|
||||
constructor(reason: "forbidden-group", message: string) {
|
||||
super(message);
|
||||
this.name = "JitProvisioningError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow Prisma-shaped surface used by jitProvisionAdmin. Defining it as a
|
||||
* structural type rather than importing PrismaClient keeps this module
|
||||
* unit-testable with a hand-rolled fake.
|
||||
*/
|
||||
export type JitProvisioningDbClient = {
|
||||
adminUser: {
|
||||
findUnique: (args: { where: { ssoProvider_ssoSubjectId: { ssoProvider: string; ssoSubjectId: string } } }) =>
|
||||
| Promise<({ userId: string } & AdminSsoUserRecord) | null>;
|
||||
update: (args: {
|
||||
where: { userId: string };
|
||||
data: {
|
||||
ssoProvider?: string;
|
||||
ssoSubjectId?: string;
|
||||
ssoGroups?: string[];
|
||||
lastSsoLoginAt?: Date;
|
||||
};
|
||||
}) => Promise<{ userId: string } & AdminSsoUserRecord>;
|
||||
create: (args: {
|
||||
data: {
|
||||
userId: string;
|
||||
ssoProvider: string;
|
||||
ssoSubjectId: string;
|
||||
ssoGroups: string[];
|
||||
lastSsoLoginAt: Date;
|
||||
};
|
||||
}) => Promise<{ userId: string } & AdminSsoUserRecord>;
|
||||
};
|
||||
user: {
|
||||
findUnique: (args: { where: { email: string } }) => Promise<{ id: string; email: string } | null>;
|
||||
update: (args: { where: { id: string }; data: { firstName?: string; lastName?: string } }) => Promise<unknown>;
|
||||
create: (args: {
|
||||
data: {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
};
|
||||
}) => Promise<{ id: string; email: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
type AdminSsoUserRecord = {
|
||||
ssoProvider: string | null;
|
||||
ssoSubjectId: string | null;
|
||||
ssoGroups: string[];
|
||||
lastSsoLoginAt: Date | null;
|
||||
};
|
||||
|
||||
function splitName(fullName: string): { firstName: string; lastName: string } {
|
||||
const cleaned = fullName.trim();
|
||||
if (!cleaned) return { firstName: "", lastName: "" };
|
||||
const parts = cleaned.split(/\s+/);
|
||||
if (parts.length === 1) return { firstName: parts[0]!, lastName: "" };
|
||||
return { firstName: parts[0]!, lastName: parts.slice(1).join(" ") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Random throw-away password hash — placeholder so the User row can satisfy
|
||||
* the existing non-null `passwordHash` constraint. SSO-only users never use
|
||||
* it; they always authenticate through Authentik. Stored as an opaque
|
||||
* 32-byte token prefixed to make the source obvious in audits.
|
||||
*/
|
||||
function generateSsoOnlyPasswordHash(): string {
|
||||
return `sso-only!${randomBytes(32).toString("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* JIT-provision (or refresh) an AdminUser based on a verified Authentik
|
||||
* claim bundle.
|
||||
*/
|
||||
export async function jitProvisionAdmin(
|
||||
input: JitProvisioningInput,
|
||||
db: JitProvisioningDbClient
|
||||
): Promise<JitProvisioningResult> {
|
||||
const { claims } = input;
|
||||
const role = mapGroupsToRole(claims.groups);
|
||||
if (!role || !hasAllowedAdminGroup(claims.groups)) {
|
||||
return { ok: false, reason: "forbidden-group" };
|
||||
}
|
||||
const now = input.now ?? new Date();
|
||||
const email = claims.email.toLowerCase();
|
||||
|
||||
// (1) Existing SSO-linked admin?
|
||||
const existingBySso = await db.adminUser.findUnique({
|
||||
where: { ssoProvider_ssoSubjectId: { ssoProvider: ADMIN_SSO_PROVIDER, ssoSubjectId: claims.subject } },
|
||||
});
|
||||
if (existingBySso) {
|
||||
await db.adminUser.update({
|
||||
where: { userId: existingBySso.userId },
|
||||
data: {
|
||||
ssoGroups: [...claims.groups],
|
||||
lastSsoLoginAt: now,
|
||||
},
|
||||
});
|
||||
const { firstName, lastName } = splitName(claims.name);
|
||||
if (firstName || lastName) {
|
||||
await db.user.update({ where: { id: existingBySso.userId }, data: { firstName, lastName } });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
admin: {
|
||||
userId: existingBySso.userId,
|
||||
email,
|
||||
role,
|
||||
created: false,
|
||||
linkedExisting: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// (2) Legacy admin matched by email — attach SSO fields.
|
||||
const existingUserByEmail = await db.user.findUnique({ where: { email } });
|
||||
if (existingUserByEmail) {
|
||||
const updated = await db.adminUser.update({
|
||||
where: { userId: existingUserByEmail.id },
|
||||
data: {
|
||||
ssoProvider: ADMIN_SSO_PROVIDER,
|
||||
ssoSubjectId: claims.subject,
|
||||
ssoGroups: [...claims.groups],
|
||||
lastSsoLoginAt: now,
|
||||
},
|
||||
}).catch(async (err) => {
|
||||
// If the user exists but has no AdminUser row, create one.
|
||||
// Prisma's `update` throws P2025 for record-not-found.
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code !== "P2025") throw err;
|
||||
return db.adminUser.create({
|
||||
data: {
|
||||
userId: existingUserByEmail.id,
|
||||
ssoProvider: ADMIN_SSO_PROVIDER,
|
||||
ssoSubjectId: claims.subject,
|
||||
ssoGroups: [...claims.groups],
|
||||
lastSsoLoginAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
admin: {
|
||||
userId: updated.userId,
|
||||
email,
|
||||
role,
|
||||
created: false,
|
||||
linkedExisting: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// (3) Brand-new user + admin from SSO.
|
||||
const { firstName, lastName } = splitName(claims.name);
|
||||
const newUser = await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: generateSsoOnlyPasswordHash(),
|
||||
firstName: firstName || "Admin",
|
||||
lastName: lastName || "User",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
await db.adminUser.create({
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
ssoProvider: ADMIN_SSO_PROVIDER,
|
||||
ssoSubjectId: claims.subject,
|
||||
ssoGroups: [...claims.groups],
|
||||
lastSsoLoginAt: now,
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
admin: {
|
||||
userId: newUser.id,
|
||||
email,
|
||||
role,
|
||||
created: true,
|
||||
linkedExisting: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Phase 09 Task #82 — Admin-SSO logout helpers.
|
||||
*
|
||||
* Stores the raw OIDC `id_token` in a separate HMAC-signed HttpOnly
|
||||
* cookie (scoped to /auth/admin) so that /auth/admin/logout can supply
|
||||
* `id_token_hint` for RP-initiated logout without bloating the main
|
||||
* session cookie or persisting tokens in the DB.
|
||||
*/
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
|
||||
export const ADMIN_SSO_ID_TOKEN_COOKIE = "admin_sso_id_token";
|
||||
export const ADMIN_SSO_ID_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // mirror app-session lifetime
|
||||
const COOKIE_PATH = "/auth/admin";
|
||||
|
||||
function getSigningSecret(override?: string): string {
|
||||
if (override) return override;
|
||||
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
|
||||
const secret = cryptoSecret || sessionSecret;
|
||||
if (!secret) {
|
||||
throw new Error("CRYPTO_SECRET or SESSION_SECRET is required for admin-sso id-token cookie.");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function sign(payload: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
export function serializeIdTokenCookie(idToken: string, opts: { secret?: string } = {}): string {
|
||||
const secret = getSigningSecret(opts.secret);
|
||||
const sig = sign(idToken, secret);
|
||||
const value = `${Buffer.from(idToken, "utf8").toString("base64url")}.${sig}`;
|
||||
const maxAge = Math.ceil(ADMIN_SSO_ID_TOKEN_TTL_MS / 1000);
|
||||
const attrs = [`${ADMIN_SSO_ID_TOKEN_COOKIE}=${value}`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`];
|
||||
if (process.env.NODE_ENV !== "test") attrs.push("Secure");
|
||||
return attrs.join("; ");
|
||||
}
|
||||
|
||||
export function clearIdTokenCookie(): string {
|
||||
const attrs = [`${ADMIN_SSO_ID_TOKEN_COOKIE}=`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", "Max-Age=0"];
|
||||
if (process.env.NODE_ENV !== "test") attrs.push("Secure");
|
||||
return attrs.join("; ");
|
||||
}
|
||||
|
||||
function readCookie(request: Request, name: string): string | null {
|
||||
const header = request.headers.get("cookie");
|
||||
if (!header) return null;
|
||||
for (const part of header.split(/;\s*/)) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readIdTokenFromRequest(request: Request, opts: { secret?: string } = {}): string | null {
|
||||
const value = readCookie(request, ADMIN_SSO_ID_TOKEN_COOKIE);
|
||||
if (!value) return null;
|
||||
const dot = value.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const encoded = value.slice(0, dot);
|
||||
const sig = value.slice(dot + 1);
|
||||
let secret: string;
|
||||
try {
|
||||
secret = getSigningSecret(opts.secret);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const expectedSig = sign(Buffer.from(encoded, "base64url").toString("utf8"), secret);
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expectedSig);
|
||||
if (a.length !== b.length) return null;
|
||||
if (!timingSafeEqual(a, b)) return null;
|
||||
return Buffer.from(encoded, "base64url").toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_SSO_CHALLENGE_COOKIE,
|
||||
ADMIN_SSO_CHALLENGE_TTL_MS,
|
||||
consumeAdminSsoChallenge,
|
||||
createAdminSsoChallenge,
|
||||
serializeAdminSsoChallengeCookie,
|
||||
} from "./state.server";
|
||||
|
||||
const SECRET = "test-secret-do-not-use-in-prod";
|
||||
|
||||
function cookieValue(cookie: string): string {
|
||||
const head = cookie.split(";")[0] ?? "";
|
||||
return head.slice(`${ADMIN_SSO_CHALLENGE_COOKIE}=`.length);
|
||||
}
|
||||
|
||||
function requestWithCookie(value: string): Request {
|
||||
return new Request("https://admin.example.test/auth/admin/callback", {
|
||||
headers: { cookie: `${ADMIN_SSO_CHALLENGE_COOKIE}=${value}` },
|
||||
});
|
||||
}
|
||||
|
||||
describe("createAdminSsoChallenge", () => {
|
||||
it("produces distinct state, nonce and codeVerifier for each invocation", () => {
|
||||
const a = createAdminSsoChallenge({ secret: SECRET });
|
||||
const b = createAdminSsoChallenge({ secret: SECRET });
|
||||
expect(a.payload.state).not.toBe(b.payload.state);
|
||||
expect(a.payload.nonce).not.toBe(b.payload.nonce);
|
||||
expect(a.payload.codeVerifier).not.toBe(b.payload.codeVerifier);
|
||||
// state should be 32 random bytes hex-encoded -> 64 chars
|
||||
expect(a.payload.state).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(a.payload.nonce).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("derives codeChallenge from codeVerifier (PKCE S256)", () => {
|
||||
const { payload } = createAdminSsoChallenge({ secret: SECRET });
|
||||
expect(payload.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(payload.codeChallenge).not.toBe(payload.codeVerifier);
|
||||
});
|
||||
|
||||
it("emits an HttpOnly, Path-scoped cookie", () => {
|
||||
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
||||
expect(cookie).toMatch(/HttpOnly/);
|
||||
expect(cookie).toMatch(/Path=\/auth\/admin/);
|
||||
expect(cookie).toMatch(/SameSite=Lax/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeAdminSsoChallenge", () => {
|
||||
it("round-trips a fresh challenge and returns a clear-cookie header", () => {
|
||||
const { payload, cookie } = createAdminSsoChallenge({ secret: SECRET, returnTo: "/admin/dashboard" });
|
||||
const value = cookieValue(cookie);
|
||||
const req = requestWithCookie(value);
|
||||
const result = consumeAdminSsoChallenge(req, { secret: SECRET });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.payload.state).toBe(payload.state);
|
||||
expect(result.payload.nonce).toBe(payload.nonce);
|
||||
expect(result.payload.codeVerifier).toBe(payload.codeVerifier);
|
||||
expect(result.payload.returnTo).toBe("/admin/dashboard");
|
||||
expect(result.clearCookie).toContain(`${ADMIN_SSO_CHALLENGE_COOKIE}=`);
|
||||
expect(result.clearCookie).toContain("Max-Age=0");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports `missing` when no cookie is attached", () => {
|
||||
const req = new Request("https://admin.example.test/auth/admin/callback");
|
||||
const result = consumeAdminSsoChallenge(req, { secret: SECRET });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("missing");
|
||||
});
|
||||
|
||||
it("rejects a cookie with a tampered signature", () => {
|
||||
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
||||
const value = cookieValue(cookie);
|
||||
const dot = value.lastIndexOf(".");
|
||||
const tampered = value.slice(0, dot) + ".AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const result = consumeAdminSsoChallenge(requestWithCookie(tampered), { secret: SECRET });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("bad-signature");
|
||||
});
|
||||
|
||||
it("rejects a cookie signed under a different secret", () => {
|
||||
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
||||
const result = consumeAdminSsoChallenge(requestWithCookie(cookieValue(cookie)), { secret: "different-secret" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("bad-signature");
|
||||
});
|
||||
|
||||
it("rejects an expired cookie (older than TTL)", () => {
|
||||
const past = Date.now() - ADMIN_SSO_CHALLENGE_TTL_MS - 1000;
|
||||
const { payload } = createAdminSsoChallenge({ secret: SECRET, now: () => past });
|
||||
const cookie = serializeAdminSsoChallengeCookie(payload, SECRET);
|
||||
const result = consumeAdminSsoChallenge(requestWithCookie(cookieValue(cookie)), { secret: SECRET });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("expired");
|
||||
});
|
||||
|
||||
it("rejects a malformed cookie value", () => {
|
||||
const result = consumeAdminSsoChallenge(requestWithCookie("garbage"), { secret: SECRET });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(["malformed", "bad-signature"]).toContain(result.reason);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Phase 09 Task #82 — PKCE / state / nonce challenge persistence.
|
||||
*
|
||||
* Stores the tuple { state, nonce, codeVerifier, createdAt } in an
|
||||
* HMAC-signed HttpOnly cookie scoped to /auth/admin. The cookie is
|
||||
* single-use: `consumeAdminSsoChallenge()` returns the payload and emits a
|
||||
* `Set-Cookie` header that clears it.
|
||||
*
|
||||
* We sign with HMAC-SHA256 over the JSON payload using
|
||||
* `CRYPTO_SECRET || SESSION_SECRET` (mirroring the magic-link convention),
|
||||
* and we encode payload+signature with URL-safe base64. The payload itself
|
||||
* is NOT encrypted — none of the values are user-secret per spec, and
|
||||
* encryption would just hide bugs.
|
||||
*
|
||||
* TTL is 10 minutes which is generous for Authentik UI redirects but short
|
||||
* enough that stolen cookies have a tiny replay window. The TTL is checked
|
||||
* server-side (cookie Max-Age is best-effort; clients lie).
|
||||
*/
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
import { generators } from "openid-client";
|
||||
|
||||
export const ADMIN_SSO_CHALLENGE_COOKIE = "admin_sso_challenge";
|
||||
export const ADMIN_SSO_CHALLENGE_TTL_MS = 10 * 60 * 1000;
|
||||
const COOKIE_PATH = "/auth/admin";
|
||||
const PAYLOAD_VERSION = "v1";
|
||||
|
||||
export type AdminSsoChallengePayload = {
|
||||
readonly state: string;
|
||||
readonly nonce: string;
|
||||
readonly codeVerifier: string;
|
||||
readonly codeChallenge: string;
|
||||
/** Epoch milliseconds. */
|
||||
readonly createdAt: number;
|
||||
/** Optional safe `returnTo` path captured at /auth/admin/login. */
|
||||
readonly returnTo?: string;
|
||||
};
|
||||
|
||||
export type ConsumeResult =
|
||||
| { readonly ok: true; readonly payload: AdminSsoChallengePayload; readonly clearCookie: string }
|
||||
| { readonly ok: false; readonly reason: "missing" | "malformed" | "bad-signature" | "expired" };
|
||||
|
||||
export type CreateOptions = {
|
||||
readonly returnTo?: string;
|
||||
/** Injectable clock for tests. */
|
||||
readonly now?: () => number;
|
||||
/** Override secret (tests). */
|
||||
readonly secret?: string;
|
||||
};
|
||||
|
||||
function getSigningSecret(override?: string): string {
|
||||
if (override) return override;
|
||||
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
|
||||
const secret = cryptoSecret || sessionSecret;
|
||||
if (!secret) {
|
||||
throw new Error("CRYPTO_SECRET or SESSION_SECRET is required for admin-sso challenge cookies.");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function base64UrlEncode(buf: Buffer | string): string {
|
||||
const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf;
|
||||
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): Buffer {
|
||||
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
||||
const standard = value.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
||||
return Buffer.from(standard, "base64");
|
||||
}
|
||||
|
||||
function sign(payload: string, secret: string): string {
|
||||
return base64UrlEncode(createHmac("sha256", secret).update(payload).digest());
|
||||
}
|
||||
|
||||
function verifySignature(payload: string, signature: string, secret: string): boolean {
|
||||
const expected = sign(payload, secret);
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh challenge tuple. Caller is responsible for setting
|
||||
* `Set-Cookie` from `cookie` on the redirect response.
|
||||
*/
|
||||
export function createAdminSsoChallenge(options: CreateOptions = {}): { payload: AdminSsoChallengePayload; cookie: string } {
|
||||
const codeVerifier = generators.codeVerifier();
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
const payload: AdminSsoChallengePayload = {
|
||||
state: randomBytes(32).toString("hex"),
|
||||
nonce: randomBytes(32).toString("hex"),
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
createdAt: options.now ? options.now() : Date.now(),
|
||||
returnTo: options.returnTo,
|
||||
};
|
||||
const cookie = serializeAdminSsoChallengeCookie(payload, options.secret);
|
||||
return { payload, cookie };
|
||||
}
|
||||
|
||||
export function serializeAdminSsoChallengeCookie(payload: AdminSsoChallengePayload, secretOverride?: string): string {
|
||||
const secret = getSigningSecret(secretOverride);
|
||||
const body = base64UrlEncode(JSON.stringify({ v: PAYLOAD_VERSION, p: payload }));
|
||||
const sig = sign(body, secret);
|
||||
const value = `${body}.${sig}`;
|
||||
const maxAgeSeconds = Math.ceil(ADMIN_SSO_CHALLENGE_TTL_MS / 1000);
|
||||
const attributes = [
|
||||
`${ADMIN_SSO_CHALLENGE_COOKIE}=${value}`,
|
||||
`Path=${COOKIE_PATH}`,
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
];
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
attributes.push("Secure");
|
||||
}
|
||||
return attributes.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `Set-Cookie` header that clears the challenge cookie.
|
||||
*/
|
||||
export function clearAdminSsoChallengeCookie(): string {
|
||||
const attributes = [
|
||||
`${ADMIN_SSO_CHALLENGE_COOKIE}=`,
|
||||
`Path=${COOKIE_PATH}`,
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Max-Age=0",
|
||||
];
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
attributes.push("Secure");
|
||||
}
|
||||
return attributes.join("; ");
|
||||
}
|
||||
|
||||
function readCookieValue(request: Request, name: string): string | null {
|
||||
const header = request.headers.get("cookie");
|
||||
if (!header) return null;
|
||||
for (const part of header.split(/;\s*/)) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = part.slice(0, eq).trim();
|
||||
if (key === name) return part.slice(eq + 1).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + verify + invalidate the challenge cookie attached to `request`.
|
||||
* Always returns a `clearCookie` header that the caller must include on
|
||||
* the redirect response so the cookie cannot be replayed.
|
||||
*/
|
||||
export function consumeAdminSsoChallenge(
|
||||
request: Request,
|
||||
options: { now?: () => number; secret?: string } = {}
|
||||
): ConsumeResult {
|
||||
const value = readCookieValue(request, ADMIN_SSO_CHALLENGE_COOKIE);
|
||||
if (!value) return { ok: false, reason: "missing" };
|
||||
const dot = value.lastIndexOf(".");
|
||||
if (dot <= 0) return { ok: false, reason: "malformed" };
|
||||
const body = value.slice(0, dot);
|
||||
const sig = value.slice(dot + 1);
|
||||
let secret: string;
|
||||
try {
|
||||
secret = getSigningSecret(options.secret);
|
||||
} catch {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
if (!verifySignature(body, sig, secret)) {
|
||||
return { ok: false, reason: "bad-signature" };
|
||||
}
|
||||
let decoded: { v: string; p: AdminSsoChallengePayload };
|
||||
try {
|
||||
decoded = JSON.parse(base64UrlDecode(body).toString("utf8"));
|
||||
} catch {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
if (decoded.v !== PAYLOAD_VERSION || !decoded.p) {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
const now = options.now ? options.now() : Date.now();
|
||||
if (typeof decoded.p.createdAt !== "number" || now - decoded.p.createdAt > ADMIN_SSO_CHALLENGE_TTL_MS) {
|
||||
return { ok: false, reason: "expired" };
|
||||
}
|
||||
return { ok: true, payload: decoded.p, clearCookie: clearAdminSsoChallengeCookie() };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import { dbRead } from "~/utils/db.server";
|
||||
|
||||
export async function groupUniqueVisitorsBy(by: Prisma.AnalyticsUniqueVisitorScalarFieldEnum) {
|
||||
// Pure aggregation for analytics dashboards — eventually-consistent is fine.
|
||||
return (
|
||||
await dbRead.analyticsUniqueVisitor.groupBy({
|
||||
by: [by],
|
||||
_count: true,
|
||||
})
|
||||
).map((f) => ({ name: f[by]?.toString() ?? ("" as string), count: f._count }));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Fragment } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ApiCallSpeedBadge({ duration }: { duration: number }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{duration < 10 && <SimpleBadge title={t("metrics.speed.lightningFast")} color={Colors.GREEN} />}
|
||||
{duration >= 10 && duration < 100 && <SimpleBadge title={t("metrics.speed.fast")} color={Colors.TEAL} />}
|
||||
{duration >= 100 && duration < 1000 && <SimpleBadge title={t("metrics.speed.average")} color={Colors.YELLOW} />}
|
||||
{duration >= 1000 && duration < 2000 && <SimpleBadge title={t("metrics.speed.slow")} color={Colors.ORANGE} />}
|
||||
{duration >= 2000 && <SimpleBadge title={t("metrics.speed.verySlow")} color={Colors.RED} />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Fragment } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { ApiKeyLogDto } from "../dtos/ApiKeyLogDto";
|
||||
|
||||
export default function ApiCallStatusBadge({ item, underline }: { item: ApiKeyLogDto; underline?: boolean }) {
|
||||
const title = item.status?.toString() ?? "?";
|
||||
const color =
|
||||
item.status === null
|
||||
? Colors.YELLOW
|
||||
: item.status >= 200 && item.status < 300
|
||||
? Colors.GREEN
|
||||
: item.status >= 300 && item.status < 400
|
||||
? Colors.ORANGE
|
||||
: item.status >= 400
|
||||
? Colors.RED
|
||||
: Colors.UNDEFINED;
|
||||
return (
|
||||
<Fragment>
|
||||
<SimpleBadge title={title} color={color} underline={underline} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CollapsibleRow from "~/components/ui/tables/CollapsibleRow";
|
||||
import ApiEndpointTitle from "./ApiEndpointTitle";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
|
||||
export default function ApiEndpoint({ item }: { item: ApiEndpointDto }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CollapsibleRow className="bg-secondary" value={<ApiEndpointTitle item={item} />} title={<ApiEndpointTitle item={item} />}>
|
||||
<div className="space-y-1 overflow-hidden pt-2">
|
||||
<div className="space-y-2">
|
||||
{item.responseSchema && (
|
||||
<>
|
||||
<div className="text-foreground text-sm font-bold">{t("api.docs.responseSchema")}</div>
|
||||
<div className="h-96 overflow-auto p-2">
|
||||
<MonacoEditor value={item.responseSchema} onChange={() => {}} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{item.bodyExample && (
|
||||
<>
|
||||
<div className="text-foreground text-sm font-bold">{t("api.docs.bodyExample")}</div>
|
||||
<div className="h-96 overflow-auto p-2">
|
||||
<MonacoEditor value={item.bodyExample} onChange={() => {}} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import ApiUtils from "~/utils/app/ApiUtils";
|
||||
import { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
|
||||
export default function ApiEndpointTitle({ item }: { item: ApiEndpointDto }) {
|
||||
return (
|
||||
<div className="flex flex-col truncate">
|
||||
<div className="flex items-center space-x-2">
|
||||
<SimpleBadge title={item.method} color={ApiUtils.getMethodColor(item.method)} />
|
||||
<h3 className="text-foreground text-sm font-bold">{item.route}</h3>
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs italic">{item.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import InputSearch from "~/components/ui/input/InputSearch";
|
||||
import type { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmptyState from "~/components/ui/emptyState/EmptyState";
|
||||
import ApiEndpoint from "./ApiEndpoint";
|
||||
|
||||
export default function ApiEndpointsTable({ items }: { items: ApiEndpointDto[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalizedSearch = searchInput.trim().toLowerCase();
|
||||
if (!normalizedSearch) return items;
|
||||
return items.filter((f) => {
|
||||
return (
|
||||
f.route.toLowerCase().includes(normalizedSearch) ||
|
||||
f.method.toLowerCase().includes(normalizedSearch) ||
|
||||
f.description.toLowerCase().includes(normalizedSearch) ||
|
||||
f.entity.name.toLowerCase().includes(normalizedSearch) ||
|
||||
f.entity.title.toLowerCase().includes(normalizedSearch) ||
|
||||
f.entity.titlePlural.toLowerCase().includes(normalizedSearch) ||
|
||||
t(f.entity.title).toLowerCase().includes(normalizedSearch) ||
|
||||
t(f.entity.titlePlural).toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
});
|
||||
}, [items, searchInput, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InputSearch value={searchInput} setValue={setSearchInput} />
|
||||
{filteredItems.length === 0 && (
|
||||
<EmptyState
|
||||
captions={{
|
||||
thereAreNo: t("api.endpoints.empty"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filteredItems.map((endpoint, idx) => {
|
||||
return <ApiEndpoint key={idx} item={endpoint} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useSubmit, useSearchParams, useNavigation, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import RefreshIcon from "~/components/ui/icons/RefreshIcon";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import InputSearchWithURL from "~/components/ui/input/InputSearchWithURL";
|
||||
import ShowPayloadModalButton from "~/components/ui/json/ShowPayloadModalButton";
|
||||
import { FilterableValueLink } from "~/components/ui/links/FilterableValueLink";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import ApiUtils from "~/utils/app/ApiUtils";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import { ApiKeyLogDto } from "../dtos/ApiKeyLogDto";
|
||||
import ApiCallSpeedBadge from "./ApiCallSpeedBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ApiCallStatusBadge from "./ApiCallStatusBadge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
items: ApiKeyLogDto[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: PaginationDto;
|
||||
};
|
||||
}
|
||||
export default function ApiKeyLogsDetails({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const params = useParams();
|
||||
|
||||
const canDelete = !params.tenant;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<ApiKeyLogDto[]>([]);
|
||||
function onDelete(ids: string[]) {
|
||||
if (!canDelete) {
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("action", "delete");
|
||||
form.set("ids", ids.join(","));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="grow">
|
||||
<InputSearchWithURL size="sm" />
|
||||
</div>
|
||||
{canDelete && selectedRows.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!getUserHasPermission(appOrAdminData, "admin.apiKeys.delete")}
|
||||
onClick={() => {
|
||||
onDelete(selectedRows.map((x) => x.id));
|
||||
setSelectedRows([]);
|
||||
}}
|
||||
>
|
||||
{t("shared.delete")} {selectedRows.length}
|
||||
</Button>
|
||||
)}
|
||||
{/* <Button type="button" variant="outline" size="sm" onClick={() => setSearchParams(searchParams)} isLoading={navigation.state === "loading"}>
|
||||
<RefreshIcon className="size-3.5" />
|
||||
</Button> */}
|
||||
<InputFilters size="sm" filters={data.filterableProperties} />
|
||||
</div>
|
||||
<TableSimple
|
||||
selectedRows={canDelete ? selectedRows : undefined}
|
||||
onSelected={canDelete ? setSelectedRows : undefined}
|
||||
items={data.items}
|
||||
pagination={data.pagination}
|
||||
actions={[
|
||||
{
|
||||
title: t("shared.delete"),
|
||||
onClick: (_, item) => onDelete([item.id]),
|
||||
hidden: () => !canDelete,
|
||||
disabled: () => !canDelete,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
headers={[
|
||||
{
|
||||
name: "createdAt",
|
||||
title: t("shared.createdAt"),
|
||||
value: (item) => formatters.dateTime(item.createdAt),
|
||||
formattedValue: (item) => (
|
||||
<div className="text-muted-foreground text-xs">{item.createdAt && <span>{formatters.dateTime(item.createdAt)}</span>}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
title: t("models.tenant.object"),
|
||||
value: (item) => {
|
||||
const tenantName = item.apiKey?.tenant?.name ?? item.tenant?.name;
|
||||
const tenantId = item.apiKey?.tenant?.id ?? item.tenant?.id;
|
||||
return <FilterableValueLink name="tenantId" value={tenantName} param={tenantId} />;
|
||||
},
|
||||
hidden: !!params.tenant,
|
||||
},
|
||||
{
|
||||
name: "apiKeyId",
|
||||
title: t("models.apiKey.alias"),
|
||||
value: (item) => <FilterableValueLink name="apiKeyId" value={item.apiKey?.alias ?? t("shared.notSet")} param={item.apiKeyId ?? "{null}"} />,
|
||||
},
|
||||
{
|
||||
name: "ip",
|
||||
title: t("models.apiKeyLog.ip"),
|
||||
value: (item) => item.ip,
|
||||
formattedValue: (item) => (
|
||||
<div>{item.ip.length > 0 ? <FilterableValueLink name="ip" value={item.ip} /> : <span className="text-gray-300">{t("shared.notSet")}</span>}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "endpoint",
|
||||
title: t("models.apiKeyLog.endpoint"),
|
||||
value: (item) => <FilterableValueLink name="endpoint" value={item.endpoint} />,
|
||||
},
|
||||
{
|
||||
name: "method",
|
||||
title: t("models.apiKeyLog.method"),
|
||||
value: (item) => item.method,
|
||||
formattedValue: (item) => (
|
||||
<div>
|
||||
<FilterableValueLink name="method" value={item.method}>
|
||||
<SimpleBadge title={item.method} color={ApiUtils.getMethodColor(item.method)} underline />
|
||||
</FilterableValueLink>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
title: t("models.apiKeyLog.status"),
|
||||
value: (item) => item.status,
|
||||
formattedValue: (item) => (
|
||||
<div>
|
||||
{item.status ? (
|
||||
<span>
|
||||
<FilterableValueLink name="status" value={item.status.toString()}>
|
||||
<ApiCallStatusBadge item={item} underline />
|
||||
</FilterableValueLink>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">{t("shared.notSet")}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "duration",
|
||||
title: t("models.apiKeyLog.duration"),
|
||||
value: (item) =>
|
||||
item.duration === null ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notSet")}</span>
|
||||
) : (
|
||||
<div>{formatters.number(Number(item.duration), { style: "unit", unit: "millisecond", unitDisplay: "short", maximumFractionDigits: 3 })}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "speed",
|
||||
title: t("models.apiKeyLog.speed"),
|
||||
value: (item) =>
|
||||
item.duration === null ? <span className="text-muted-foreground text-xs italic">{t("shared.notSet")}</span> : <ApiCallSpeedBadge duration={Number(item.duration)} />,
|
||||
},
|
||||
{
|
||||
name: "params",
|
||||
title: t("models.apiKeyLog.params"),
|
||||
value: (item) =>
|
||||
item.params === "{}" ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notSet")}</span>
|
||||
) : (
|
||||
<ShowPayloadModalButton description={item.params} payload={item.params} />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "error",
|
||||
title: t("models.apiKeyLog.error"),
|
||||
value: (item) => <div className="text-red-500">{item.error}</div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
describe("ApiKeyLogsSummary i18n", () => {
|
||||
it("keeps visible summary controls behind semantic API log keys", () => {
|
||||
const source = [
|
||||
"apps/web/app/modules/api/components/ApiKeyLogsSummary.tsx",
|
||||
"apps/web/app/modules/api/components/ApiCallSpeedBadge.tsx",
|
||||
"apps/web/app/modules/metrics/components/SpeedBadge.tsx",
|
||||
"apps/web/app/modules/api/components/ApiEndpoint.tsx",
|
||||
"apps/web/app/modules/api/services/ApiSpecsService.ts",
|
||||
]
|
||||
.map((file) => readRepoFile(file))
|
||||
.join("\n");
|
||||
|
||||
expect(source).toContain("models.apiKeyLog.groupBy.placeholder");
|
||||
expect(source).toContain("models.apiKeyLog.calls");
|
||||
expect(source).toContain("models.apiKeyLog.avgDuration");
|
||||
expect(source).toContain("metrics.speed.lightningFast");
|
||||
expect(source).toContain("metrics.speed.verySlow");
|
||||
expect(source).toContain("api.docs.responseSchema");
|
||||
expect(source).toContain("api.docs.bodyExample");
|
||||
expect(source).toContain("api.specs.hostDescription");
|
||||
expect(source).toContain("api.specs.operations.getAll");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
expect(source).not.toContain("lightning_fast");
|
||||
expect(source).not.toContain("response_schema");
|
||||
expect(source).not.toContain("body_example");
|
||||
expect(source).not.toContain('"Group by"');
|
||||
expect(source).not.toContain('"Select group by"');
|
||||
expect(source).not.toContain('"Please select at least one group by"');
|
||||
expect(source).not.toContain('"Speed"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Link, useParams, useSearchParams } from "react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import ApiKeyLogUtils from "../utils/ApiKeyLogUtils";
|
||||
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
|
||||
import { ApiCallSummaryDto } from "../dtos/ApiCallSummaryDto";
|
||||
import ApiKeyLogsConstants from "../utils/ApiKeyLogsConstants";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { FilterableValueLink } from "~/components/ui/links/FilterableValueLink";
|
||||
import WarningBanner from "~/components/ui/banners/WarningBanner";
|
||||
import InputCombobox from "~/components/ui/input/InputCombobox";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import ApiUtils from "~/utils/app/ApiUtils";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import ApiCallSpeedBadge from "./ApiCallSpeedBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
items: ApiCallSummaryDto[];
|
||||
allTenants: { id: string; name: string }[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
};
|
||||
}
|
||||
export default function ApiKeyLogsSummary({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const params = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [groupBy, setGroupBy] = useState<string[]>(ApiKeyLogUtils.getGroupByValues(searchParams));
|
||||
|
||||
const [headers, setHeaders] = useState<RowHeaderDisplayDto<ApiCallSummaryDto>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ApiKeyLogUtils.getGroupByValues(searchParams).sort().join(",") !== groupBy.sort().join(",")) {
|
||||
searchParams.delete("groupBy");
|
||||
groupBy.forEach((by) => {
|
||||
searchParams.append("groupBy", by);
|
||||
});
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [groupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
const headers: RowHeaderDisplayDto<ApiCallSummaryDto>[] = [];
|
||||
let currentGroupBy = groupBy;
|
||||
if (currentGroupBy.length === 0) {
|
||||
currentGroupBy = ApiKeyLogsConstants.DEFAULT_GROUP_BY;
|
||||
}
|
||||
const getTenant = (item: ApiCallSummaryDto) => {
|
||||
return data.allTenants.find((x) => x.id === item.tenantId);
|
||||
};
|
||||
const getCountLink = (item: ApiCallSummaryDto) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
// searchParams.set("pageSize", "100");
|
||||
currentGroupBy.forEach((groupBy) => {
|
||||
if (groupBy === "method") {
|
||||
searchParams.append("method", item.method);
|
||||
} else if (groupBy === "endpoint") {
|
||||
searchParams.append("endpoint", item.endpoint);
|
||||
} else if (groupBy === "params") {
|
||||
searchParams.append("params", item.params);
|
||||
} else if (groupBy === "status") {
|
||||
searchParams.append("status", item.status?.toString() ?? "{null}");
|
||||
}
|
||||
});
|
||||
return UrlUtils.getModulePath(params, `api/logs?${searchParams.toString()}`);
|
||||
};
|
||||
if (groupBy.includes("method")) {
|
||||
headers.push({
|
||||
name: "method",
|
||||
title: t("models.apiKeyLog.method"),
|
||||
value: (item) => (
|
||||
<FilterableValueLink name="method" value={item.method}>
|
||||
<SimpleBadge title={item.method} color={ApiUtils.getMethodColor(item.method)} underline />
|
||||
</FilterableValueLink>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("endpoint")) {
|
||||
headers.push({
|
||||
name: "endpoint",
|
||||
title: t("models.apiKeyLog.endpoint"),
|
||||
value: (item) => <FilterableValueLink name="endpoint" value={item.endpoint} />,
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("params")) {
|
||||
headers.push({
|
||||
name: "params",
|
||||
title: t("models.apiKeyLog.params"),
|
||||
value: (item) => <FilterableValueLink name="params" value={item.params} />,
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("status")) {
|
||||
headers.push({
|
||||
name: "status",
|
||||
title: t("models.apiKeyLog.status"),
|
||||
value: (item) => (
|
||||
<FilterableValueLink name="status" value={item.status?.toString() ?? "{null}"}>
|
||||
<SimpleBadge title={item.status?.toString() ?? t("shared.unknown")} color={item.status?.toString().startsWith("4") ? Colors.RED : Colors.GREEN} />
|
||||
</FilterableValueLink>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("tenantId")) {
|
||||
headers.push({
|
||||
name: "tenantId",
|
||||
title: t("models.apiKeyLog.tenant"),
|
||||
value: (item) => <FilterableValueLink name="tenantId" value={getTenant(item)?.name ?? ""} />,
|
||||
});
|
||||
}
|
||||
headers.push({
|
||||
name: "apiCalls",
|
||||
title: t("models.apiKeyLog.apiCalls"),
|
||||
align: "right",
|
||||
value: (item) => (
|
||||
<div className="flex justify-end text-right">
|
||||
<Link to={getCountLink(item)} className="hover:underline">
|
||||
{t("models.apiKeyLog.calls", { count: Number(item._count._all), formattedCount: formatters.number(Number(item._count._all)) })}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
headers.push({
|
||||
name: "duration",
|
||||
title: t("models.apiKeyLog.avgDuration"),
|
||||
value: (item) =>
|
||||
item._avg.duration === null ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notAvailable")}</span>
|
||||
) : (
|
||||
<div>{formatters.number(Number(item._avg.duration), { style: "unit", unit: "millisecond", unitDisplay: "short", maximumFractionDigits: 3 })}</div>
|
||||
),
|
||||
});
|
||||
headers.push({
|
||||
name: "speed",
|
||||
title: t("models.apiKeyLog.speed"),
|
||||
value: (item) =>
|
||||
item._avg.duration === null ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notAvailable")}</span>
|
||||
) : (
|
||||
<ApiCallSpeedBadge duration={Number(item._avg.duration)} />
|
||||
),
|
||||
});
|
||||
headers.push({
|
||||
name: "actions",
|
||||
title: "",
|
||||
value: (item) => (
|
||||
<Link to={getCountLink(item)} className="hover:underline">
|
||||
{t("shared.details")} <span className="ml-1">→</span>
|
||||
</Link>
|
||||
),
|
||||
});
|
||||
|
||||
setHeaders(headers);
|
||||
}, [data, groupBy, params, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<InputCombobox
|
||||
name="groupBy"
|
||||
prefix={t("models.apiKeyLog.groupBy.prefix")}
|
||||
className="bg-background h-8"
|
||||
selectPlaceholder={t("models.apiKeyLog.groupBy.placeholder")}
|
||||
options={[
|
||||
{ value: "method", name: t("models.apiKeyLog.method") },
|
||||
{ value: "endpoint", name: t("models.apiKeyLog.endpoint") },
|
||||
{ value: "params", name: t("models.apiKeyLog.params") },
|
||||
{ value: "status", name: t("models.apiKeyLog.status") },
|
||||
{ value: "tenantId", name: t("models.apiKeyLog.tenant") },
|
||||
]}
|
||||
value={groupBy}
|
||||
onChange={(value) => {
|
||||
setGroupBy(value as string[]);
|
||||
}}
|
||||
/>
|
||||
<div className="grow">{/* <InputSearchWithURL /> */}</div>
|
||||
<InputFilters size="sm" filters={data.filterableProperties} />
|
||||
</div>
|
||||
{groupBy.length === 0 ? (
|
||||
<WarningBanner title={t("models.apiKeyLog.groupBy.title")} text={t("models.apiKeyLog.groupBy.required")} />
|
||||
) : (
|
||||
<TableSimple items={data.items} headers={headers} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ApiSpecsDto } from "../services/ApiSpecsService";
|
||||
import ApiSpecs from "./ApiSpecs";
|
||||
|
||||
vi.mock("~/components/editors/MonacoEditor", () => ({ default: ({ value }: { value: string }) => <pre data-testid="monaco">{value}</pre> }));
|
||||
vi.mock("~/components/ui/icons/ExternalLinkEmptyIcon", () => ({ default: () => null }));
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
const item = {
|
||||
openApi: { openapi: "3.1.0" },
|
||||
postmanCollection: { info: { name: "API" }, item: [] },
|
||||
apiDocsUrl: "https://api.example.com/v1/docs",
|
||||
} as unknown as ApiSpecsDto;
|
||||
|
||||
describe("ApiSpecs", () => {
|
||||
it("can hide the admin-only Postman download while still rendering the collection preview", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ApiSpecs item={item} postmanCollectionUrl={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("docs.openApi")).toBeInTheDocument();
|
||||
expect(screen.queryByText("docs.downloadPostmanCollection")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /postman/i })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("monaco")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("can hide the full Postman collection preview on tenant-facing docs", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ApiSpecs item={item} postmanCollectionUrl={null} showPostmanCollection={false} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("docs.downloadPostmanCollection")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("monaco")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import ExternalLinkEmptyIcon from "~/components/ui/icons/ExternalLinkEmptyIcon";
|
||||
import { ApiSpecsDto } from "../services/ApiSpecsService";
|
||||
import { Link } from "react-router";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ApiSpecs({
|
||||
item,
|
||||
className = "h-96 overflow-auto",
|
||||
withLinkToDocs = true,
|
||||
postmanCollectionUrl = "/postman_collection.json",
|
||||
showPostmanCollection = true,
|
||||
}: {
|
||||
item: ApiSpecsDto;
|
||||
className?: string;
|
||||
withLinkToDocs?: boolean;
|
||||
postmanCollectionUrl?: string | null;
|
||||
showPostmanCollection?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const shouldShowPostmanCollection = showPostmanCollection && Boolean(item.postmanCollection);
|
||||
return (
|
||||
<div className={clsx("grid gap-4", shouldShowPostmanCollection ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div className="space-y-2">
|
||||
{withLinkToDocs && (
|
||||
<Link to={item.apiDocsUrl} target="_blank">
|
||||
<div className="hover:border-border hover:bg-background rounded-md border border-transparent px-2 py-1.5">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M127.06 255.997C58.85 255.527-.457 198.918.003 126.998.445 57.796 57.952-.469 129.11.003c69.157.46 127.503 57.864 126.885 128.191.573 69.69-58.06 128.295-128.935 127.803zm0 0C58.85 255.527-.457 198.918.003 126.998.445 57.796 57.952-.469 129.11.003c69.157.46 127.503 57.864 126.885 128.191.573 69.69-58.06 128.295-128.935 127.803z"
|
||||
/>
|
||||
<path
|
||||
fill="#49a32b"
|
||||
d="M127.185 238.997C68.032 238.59 16.604 189.5 17.002 127.131c.384-60.012 50.253-110.54 111.961-110.129 59.972.399 110.569 50.18 110.033 111.167.496 60.433-50.349 111.255-111.811 110.828zm0 0C68.032 238.59 16.604 189.5 17.002 127.131c.384-60.012 50.253-110.54 111.961-110.129 59.972.399 110.569 50.18 110.033 111.167.496 60.433-50.349 111.255-111.811 110.828z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M169.327 127.956c-.284 5.29-4.906 9.683-9.46 8.917h-.069a9.133 9.133 0 0 1-9.145-9.124 9.276 9.276 0 0 1 9.427-8.96c5.045.025 9.52 4.288 9.247 9.167zM88.201 179.22c1.902.056 3.806.015 6 .015v13.786c-13.635 2.305-24.866-1.566-27.622-13.091a76.501 76.501 0 0 1-1.736-12.886c-.293-4.592.213-9.235-.137-13.819-.97-12.612-2.603-16.918-14.706-17.514v-15.696a22.665 22.665 0 0 1 2.632-.452c6.636-.327 9.433-2.362 10.917-8.898a74.57 74.57 0 0 0 1.194-11.122c.526-7.217.34-14.551 1.542-21.651 1.737-10.267 8.111-15.255 18.64-15.814 2.996-.16 6.001-.024 9.396-.024v14.09c-1.397.1-2.677.303-3.95.265-8.58-.263-9.023 2.66-9.65 9.762-.39 4.454.149 8.984-.155 13.454a107.113 107.113 0 0 1-1.781 13.245c-1.238 6.339-5.135 11.053-10.535 15.053 10.483 6.823 11.676 17.423 12.357 28.188.366 5.784.199 11.61.786 17.366.457 4.467 2.195 5.607 6.808 5.743zm8.836-60.43h.162a9.078 9.078 0 0 1 8.928 9.226 8.884 8.884 0 0 1-9.396 8.851 9.046 9.046 0 1 1 .306-18.078zm31.236 0c5.483-.043 9.124 3.51 9.152 8.93.03 5.565-3.422 9.125-8.868 9.149-5.54.024-9.186-3.48-9.216-8.867a8.671 8.671 0 0 1 8.933-9.213zm65.4-6.867c1.459 5.447 4.298 7.362 10.032 7.623.94.044 1.875.203 3.163.346v15.692c-.697.23-1.412.4-2.138.512-7.684.478-11.186 3.63-11.962 11.335-.496 4.919-.455 9.892-.796 14.828a125.806 125.806 0 0 1-1.476 16.18c-1.96 9.703-8.019 14.544-18.029 15.135-3.221.19-6.466.03-9.94.03v-14.026c1.87-.115 3.52-.275 5.174-.314 5.981-.143 8.096-2.071 8.389-8.011.324-6.525.465-13.059.757-19.586.423-9.433 3.006-17.861 11.795-23.745-5.03-3.585-9.067-7.928-10.112-13.783-1.265-7.097-1.674-14.351-2.355-21.548-.336-3.597-.32-7.226-.671-10.821-.379-3.88-3.045-5.224-6.577-5.31-2.024-.05-4.055-.01-6.642-.01V62.754c16.509-2.741 27.913 2.752 28.973 18.548.443 6.632.378 13.297.803 19.93.186 3.608.726 7.19 1.612 10.691z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-foreground truncate text-sm font-medium">{t("docs.openApi")}</div>
|
||||
</div>
|
||||
<ExternalLinkEmptyIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<div className={clsx(className)}>
|
||||
<MonacoEditor value={JSON.stringify(item.openApi, null, 2)} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldShowPostmanCollection && (
|
||||
<div className="space-y-2">
|
||||
{withLinkToDocs && postmanCollectionUrl && (
|
||||
<Link to={postmanCollectionUrl} target="_blank">
|
||||
<div className="hover:border-border hover:bg-background rounded-md border border-transparent px-2 py-1.5">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="800" height="800" preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M254.953 144.253c8.959-70.131-40.569-134.248-110.572-143.206C74.378-7.912 10.005 41.616 1.047 111.619c-8.959 70.003 40.569 134.248 110.572 143.334 70.131 8.959 134.248-40.569 143.334-110.7Z"
|
||||
/>
|
||||
<path fill="#FFF" d="m174.2 82.184-54.007 54.007-15.229-15.23c53.11-53.11 58.358-48.503 69.236-38.777Z" />
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M120.193 137.47c-.384 0-.64-.128-.895-.384l-15.358-15.229a1.237 1.237 0 0 1 0-1.792c54.007-54.006 59.638-48.887 71.028-38.649.255.256.383.512.383.896s-.128.64-.383.896l-54.007 53.878c-.128.256-.512.384-.768.384Zm-13.437-16.509 13.437 13.438 52.087-52.087c-9.47-8.446-15.87-11.006-65.524 38.65Z"
|
||||
/>
|
||||
<path fill="#FFF" d="m135.679 151.676-14.718-14.718 54.007-54.006c14.46 14.59-7.167 38.265-39.29 68.724Z" />
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M135.679 152.956c-.384 0-.64-.128-.896-.384l-14.718-14.718c-.256-.256-.256-.512-.256-.896s.128-.64.384-.895L174.2 82.056a1.237 1.237 0 0 1 1.791 0 15.58 15.58 0 0 1 4.991 11.902c-.256 14.206-16.38 32.25-44.28 58.614-.383.256-.767.384-1.023.384Zm-12.926-15.998c8.19 8.319 11.646 11.646 12.926 12.926 21.5-20.476 42.36-41.464 42.488-55.926.128-3.327-1.152-6.655-3.327-9.214l-52.087 52.214Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m105.22 121.345 10.878 10.878c.256.256.256.512 0 .768-.128.128-.128.128-.256.128l-22.524 4.863c-1.152.128-2.175-.64-2.431-1.791-.128-.64.128-1.28.512-1.664l13.053-13.054c.256-.256.64-.384.768-.128Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M92.934 139.262c-1.92 0-3.327-1.536-3.327-3.455 0-.896.384-1.792 1.024-2.432l13.053-13.054c.768-.64 1.792-.64 2.56 0l10.878 10.878c.768.64.768 1.792 0 2.56-.256.256-.512.384-.896.512l-22.524 4.863c-.256 0-.512.128-.768.128Zm11.902-16.51-12.542 12.543c-.256.256-.383.64-.128 1.024.128.383.512.511.896.383l21.116-4.607-9.342-9.342Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="M202.739 52.238c-8.191-7.935-21.373-7.679-29.307.64-7.935 8.318-7.679 21.372.64 29.306A20.678 20.678 0 0 0 199.155 85l-14.59-14.59 18.174-18.172Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M188.405 89.223c-12.158 0-22.012-9.854-22.012-22.012 0-12.158 9.854-22.012 22.012-22.012 5.631 0 11.134 2.176 15.23 6.143.255.256.383.512.383.896s-.128.64-.384.895L186.357 70.41l13.566 13.566c.512.512.512 1.28 0 1.792l-.256.256c-3.327 2.047-7.295 3.199-11.262 3.199Zm0-41.337c-10.75 0-19.452 8.703-19.324 19.453 0 10.75 8.702 19.452 19.452 19.324 2.944 0 5.887-.64 8.575-2.047l-13.438-13.31c-.256-.256-.384-.512-.384-.896s.128-.64.384-.895l17.149-17.15c-3.456-2.943-7.807-4.479-12.414-4.479Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m203.122 52.622-.255-.256-18.301 18.044 14.461 14.462c1.408-.896 2.816-1.92 3.967-3.072a20.51 20.51 0 0 0 .128-29.178Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M199.155 86.28c-.384 0-.64-.128-.896-.384l-14.589-14.59c-.256-.256-.384-.512-.384-.896s.128-.64.384-.895l18.173-18.173a1.237 1.237 0 0 1 1.791 0l.384.256c8.575 8.574 8.575 22.396.128 31.098-1.28 1.28-2.687 2.432-4.223 3.328-.384.128-.64.256-.768.256Zm-12.798-15.87 12.926 12.926c1.024-.64 2.048-1.536 2.816-2.304 7.294-7.294 7.678-19.196.64-26.875L186.357 70.41Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="M176.375 84.488a7.879 7.879 0 0 0-11.134 0l-48.247 48.247 8.063 8.063 51.062-44.792c3.328-2.816 3.584-7.807.768-11.134-.256-.128-.384-.256-.512-.384Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M124.929 142.077c-.384 0-.64-.128-.896-.383l-8.063-8.063a1.237 1.237 0 0 1 0-1.792l48.247-48.247a9.115 9.115 0 0 1 12.926 0 9.115 9.115 0 0 1 0 12.926l-.384.384-51.063 44.792c-.128.255-.384.383-.767.383Zm-6.143-9.342 6.27 6.271 50.167-44.024c2.816-2.304 3.072-6.527.768-9.342-2.303-2.816-6.526-3.072-9.342-.768-.128.128-.256.256-.512.384l-47.351 47.48Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="M80.009 187.637c-.512.256-.768.768-.64 1.28l2.175 9.214c.512 1.28-.256 2.816-1.663 3.2-1.024.384-2.176 0-2.816-.768l-14.077-13.95 45.943-45.943 15.87.256 10.75 10.75c-2.56 2.175-18.045 17.149-55.542 35.961Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M78.985 202.61c-1.024 0-2.048-.383-2.688-1.151l-13.95-13.95c-.255-.256-.383-.512-.383-.896 0-.383.128-.64.384-.895l45.944-45.944c.256-.256.64-.384.895-.384l15.87.256c.383 0 .64.128.895.384l10.75 10.75c.256.256.384.64.384 1.024s-.128.64-.512.896l-.895.767c-13.566 11.902-31.995 23.804-54.902 35.194l2.175 9.086c.384 1.664-.384 3.456-1.92 4.352-.767.384-1.407.512-2.047.512Zm-14.078-15.997 13.182 13.054c.384.64 1.152.896 1.792.512.64-.384.896-1.152.512-1.792l-2.176-9.214c-.256-1.152.256-2.176 1.28-2.688 22.652-11.39 40.952-23.163 54.39-34.81l-9.47-9.47-14.718-.256-44.792 44.664Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m52.11 197.62 11.006-11.007 16.38 16.381-26.107-1.791c-1.151-.128-1.92-1.152-1.791-2.304 0-.512.128-1.024.512-1.28Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="m79.497 204.146-26.236-1.791c-1.92-.128-3.199-1.792-3.071-3.712.128-.768.384-1.535 1.024-2.047L62.22 185.59a1.237 1.237 0 0 1 1.792 0l16.38 16.38c.385.385.512.897.257 1.408-.256.512-.64.768-1.152.768Zm-16.381-15.74-10.11 10.11c-.384.255-.384.895 0 1.151.127.128.255.256.511.256l22.652 1.536-13.053-13.054ZM104.452 146.557c-.768 0-1.28-.64-1.28-1.28 0-.384.128-.64.384-.896l12.414-12.414a1.237 1.237 0 0 1 1.792 0l8.062 8.063c.384.384.512.768.384 1.28-.128.384-.512.767-1.023.895l-20.477 4.352h-.256Zm12.414-11.902-8.446 8.446 13.821-2.943-5.375-5.503Z"
|
||||
/>
|
||||
<path fill="#FFF" d="m124.8 140.926-14.077 3.071c-1.024.256-2.048-.384-2.303-1.408-.128-.64 0-1.28.511-1.791l7.807-7.807 8.063 7.935Z" />
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M110.467 145.277a3.168 3.168 0 0 1-3.2-3.2c0-.895.385-1.663.897-2.303l7.806-7.807a1.237 1.237 0 0 1 1.792 0l8.062 8.063c.384.384.512.768.384 1.28-.128.384-.512.767-1.023.895l-14.078 3.072h-.64Zm6.399-10.622-6.91 6.91c-.257.257-.257.512-.129.768s.384.384.768.384l11.774-2.56-5.503-5.502ZM203.25 64.907c-.256-.767-1.151-1.151-1.92-.895-.767.255-1.151 1.151-.895 1.92 0 .127.128.255.128.383.768 1.536.512 3.455-.512 4.863-.512.64-.384 1.536.128 2.048.64.512 1.536.384 2.048-.256 1.92-2.432 2.303-5.503 1.023-8.063Z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-foreground truncate text-sm font-medium">{t("docs.downloadPostmanCollection")}</div>
|
||||
</div>
|
||||
<ExternalLinkEmptyIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className={clsx(className)}>
|
||||
<MonacoEditor value={JSON.stringify(item.postmanCollection, null, 2)} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type ApiCallSummaryDto = {
|
||||
tenantId: string | null;
|
||||
method: string;
|
||||
endpoint: string;
|
||||
params: string;
|
||||
status: number;
|
||||
_avg: { duration: number | null };
|
||||
_count: { _all: number };
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LegacyEntityWithDetails } from "@toolctrl/db/entities.server";
|
||||
|
||||
export type ApiEndpointDto = {
|
||||
entity: LegacyEntityWithDetails;
|
||||
route: string;
|
||||
method: string;
|
||||
description: string;
|
||||
responseSchema: string;
|
||||
bodyExample: string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ApiKeyLog } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export type ApiKeyLogDto = ApiKeyLog & {
|
||||
apiKey: { alias: string; tenant: { id: string; name: string } } | null;
|
||||
tenant?: { id: string; name: string; slug: string } | null | undefined;
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { adminGetAllLegacyTenantSummaries } from "@toolctrl/db/accounts.server";
|
||||
import {
|
||||
adminDeleteLegacyApiKeyLogsByIds,
|
||||
countLegacyApiKeyLogs,
|
||||
deleteTenantLegacyApiKeyLogsByIds,
|
||||
getLegacyApiKeyLogAdminDetails,
|
||||
groupLegacyApiKeyLogs,
|
||||
tenantLegacyApiKeyLogScope,
|
||||
type LegacyApiKeyLogOrderByInput,
|
||||
type LegacyApiKeyLogWhereInput,
|
||||
} from "@toolctrl/db/api-keys.server";
|
||||
import { Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import { Params } from "react-router";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { getFiltersFromCurrentUrl, getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { ApiCallSummaryDto } from "../dtos/ApiCallSummaryDto";
|
||||
import ApiKeyLogUtils from "../utils/ApiKeyLogUtils";
|
||||
import { ApiKeyLogDto } from "../dtos/ApiKeyLogDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
|
||||
async function getSummary({ request, params }: { request: Request; params: Params }) {
|
||||
const { filterableProperties, whereFilters, allTenants } = await getFilters({ request, params });
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
let groupBy = ApiKeyLogUtils.getGroupByValues(searchParams);
|
||||
let items: ApiCallSummaryDto[] = [];
|
||||
if (groupBy.length > 0) {
|
||||
try {
|
||||
const data = await groupLegacyApiKeyLogs({
|
||||
by: groupBy,
|
||||
where: whereFilters,
|
||||
});
|
||||
items = data.map((x) => x as ApiCallSummaryDto);
|
||||
} catch {
|
||||
throw Error("Invalid group by: " + groupBy.join(", "));
|
||||
}
|
||||
}
|
||||
return { items, allTenants, filterableProperties };
|
||||
}
|
||||
|
||||
async function getDetails({ request, params }: { request: Request; params: Params }): Promise<{
|
||||
items: ApiKeyLogDto[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: PaginationDto;
|
||||
}> {
|
||||
const { filterableProperties, whereFilters } = await getFilters({ request, params });
|
||||
|
||||
const urlSearchParams = new URL(request.url).searchParams;
|
||||
const pagination = getPaginationFromCurrentUrl(urlSearchParams);
|
||||
|
||||
const orderBy: LegacyApiKeyLogOrderByInput | LegacyApiKeyLogOrderByInput[] = !pagination.sortedBy.length
|
||||
? { createdAt: "desc" }
|
||||
: pagination.sortedBy.map((x) => ({
|
||||
[x.name]: x.direction,
|
||||
}));
|
||||
const items = await getLegacyApiKeyLogAdminDetails({
|
||||
take: pagination.pageSize,
|
||||
skip: pagination.pageSize * (pagination.page - 1),
|
||||
where: whereFilters,
|
||||
includeTenant: !whereFilters.tenantId,
|
||||
orderBy,
|
||||
});
|
||||
const totalItems = await countLegacyApiKeyLogs(whereFilters);
|
||||
|
||||
return {
|
||||
items: items as ApiKeyLogDto[],
|
||||
filterableProperties,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / pagination.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getGroupBys() {
|
||||
const allMethods = [
|
||||
{ name: "GET", value: "GET" },
|
||||
{ name: "POST", value: "POST" },
|
||||
{ name: "PUT", value: "PUT" },
|
||||
{ name: "PATCH", value: "PATCH" },
|
||||
{ name: "DELETE", value: "DELETE" },
|
||||
];
|
||||
|
||||
return {
|
||||
allMethods,
|
||||
};
|
||||
}
|
||||
async function getFilterableProperties(tenantId: string | null) {
|
||||
const { allMethods } = await getGroupBys();
|
||||
const filterableProperties: FilterablePropertyDto[] = [
|
||||
{ name: "method", title: "models.apiKeyLog.method", options: allMethods },
|
||||
{
|
||||
name: "endpoint",
|
||||
title: "models.apiKeyLog.endpoint",
|
||||
},
|
||||
{
|
||||
name: "params",
|
||||
title: "models.apiKeyLog.params",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
title: "models.apiKeyLog.status",
|
||||
},
|
||||
{
|
||||
name: "apiKeyId",
|
||||
title: "models.apiKey.object",
|
||||
},
|
||||
];
|
||||
let allTenants: { id: string; name: string; slug: string }[] = [];
|
||||
if (tenantId === null) {
|
||||
allTenants = await adminGetAllLegacyTenantSummaries();
|
||||
filterableProperties.unshift({
|
||||
name: "tenantId",
|
||||
title: "models.tenant.object",
|
||||
options: [
|
||||
{ value: "null", name: "{null}" },
|
||||
...allTenants.map((item) => {
|
||||
return {
|
||||
value: item.id,
|
||||
name: item.name,
|
||||
};
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
return { filterableProperties, allTenants };
|
||||
}
|
||||
|
||||
async function getFilters({ request, params }: { request: Request; params: Params }) {
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { filterableProperties, allTenants } = await getFilterableProperties(tenantId);
|
||||
const filters = getFiltersFromCurrentUrl(request, filterableProperties);
|
||||
const q = filters.query || "";
|
||||
|
||||
const AND_filters: Prisma.ApiKeyLogWhereInput[] = [];
|
||||
filterableProperties.forEach((filter) => {
|
||||
const value = filters.properties.find((f) => f.name === filter.name)?.value;
|
||||
if (value) {
|
||||
let formattedValue = filter.name === "status" ? Number(value) : value;
|
||||
AND_filters.push({
|
||||
[filter.name]: value === "null" ? null : formattedValue,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const OR_filters: Prisma.ApiKeyLogWhereInput[] = [];
|
||||
if (q) {
|
||||
OR_filters.push(
|
||||
{ ip: { contains: q, mode: "insensitive" } },
|
||||
{ endpoint: { contains: q, mode: "insensitive" } },
|
||||
{ method: { contains: q, mode: "insensitive" } },
|
||||
{ params: { contains: q, mode: "insensitive" } },
|
||||
{ error: { contains: q, mode: "insensitive" } }
|
||||
);
|
||||
}
|
||||
const whereFilters: LegacyApiKeyLogWhereInput = {};
|
||||
if (OR_filters.length > 0) {
|
||||
whereFilters.OR = OR_filters;
|
||||
}
|
||||
if (AND_filters.length > 0) {
|
||||
whereFilters.AND = AND_filters;
|
||||
}
|
||||
if (tenantId) {
|
||||
const existingAndFilters = Array.isArray(whereFilters.AND) ? whereFilters.AND : whereFilters.AND ? [whereFilters.AND] : [];
|
||||
whereFilters.AND = [...existingAndFilters, tenantLegacyApiKeyLogScope(tenantId)];
|
||||
}
|
||||
|
||||
return { filterableProperties, whereFilters, allTenants };
|
||||
}
|
||||
|
||||
async function adminDeleteMany(ids: string[]) {
|
||||
return await adminDeleteLegacyApiKeyLogsByIds(ids);
|
||||
}
|
||||
|
||||
async function deleteManyForTenant(ids: string[], tenantId: string | null) {
|
||||
return await deleteTenantLegacyApiKeyLogsByIds({ ids, tenantId });
|
||||
}
|
||||
|
||||
export default {
|
||||
getSummary,
|
||||
getDetails,
|
||||
adminDeleteMany,
|
||||
deleteManyForTenant,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@toolctrl/db/entities.server", () => ({
|
||||
getAllLegacyEntities: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: vi.fn(async () => ({ t: (value: string) => value, translations: {}, locale: "en" })),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/url.server", () => ({
|
||||
getAbsoluteApiUrl: vi.fn((_request: Request, path: string) => `https://api.example.com${path.startsWith("/") ? path : `/${path}`}`),
|
||||
}));
|
||||
|
||||
import { getAllLegacyEntities } from "@toolctrl/db/entities.server";
|
||||
import ApiSpecsService from "./ApiSpecsService";
|
||||
|
||||
const entity = {
|
||||
id: "entity_1",
|
||||
slug: "contacts",
|
||||
title: "Contact",
|
||||
titlePlural: "Contacts",
|
||||
hasApi: true,
|
||||
active: true,
|
||||
properties: [],
|
||||
childEntities: [{ id: "child_relation" }],
|
||||
parentEntities: [],
|
||||
};
|
||||
|
||||
describe("ApiSpecsService", () => {
|
||||
it("documents canonical API auth and idempotency for generated specs and Postman requests", async () => {
|
||||
vi.mocked(getAllLegacyEntities).mockResolvedValue([entity] as never);
|
||||
|
||||
const specs = await ApiSpecsService.generateSpecs({ request: new Request("https://admin.example.com/api/docs") });
|
||||
const openApi = specs.openApi;
|
||||
|
||||
expect(openApi.servers).toEqual([{ url: "https://api.example.com", description: "API host" }]);
|
||||
expect(openApi.components.securitySchemes.bearerAuth).toMatchObject({
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "service-token",
|
||||
});
|
||||
expect(openApi.paths["/v1/entities/{entity}/rows"].post.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/entities/{entity}/rows/{rowId}"].patch.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/entities/{entity}/rows/{rowId}"].delete.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/relationships"].post.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/relationships/{id}"].delete.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
|
||||
const postmanItems = specs.postmanCollection.item as Array<{
|
||||
name: string;
|
||||
request: { header: Array<{ key: string; value?: string }>; url: { raw: string; query?: Array<{ key: string; value: string; disabled?: boolean }> } };
|
||||
}>;
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/session/current-user")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "Authorization", value: "Bearer <token>" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/api-keys/validate")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key-or-service-token>" }),
|
||||
]);
|
||||
expect(postmanItems.some((item) => item.name.startsWith("POST /v1/workflows") || item.name.startsWith("GET /v1/workflows"))).toBe(false);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.url.query).toContainEqual(
|
||||
expect.objectContaining({ key: "tenantId", value: "<tenantId>" })
|
||||
);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.url.raw).toContain("tenantId=%3CtenantId%3E");
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/entities/:entity/rows")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
|
||||
expect.objectContaining({ key: "Idempotency-Key" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/ai/upload")?.request.header).toContainEqual(
|
||||
expect.objectContaining({ key: "x-tenant-id", value: "<x-tenant-id>" })
|
||||
);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/analytics/events")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-analytics-token" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/exports/:exportJobId/download-url")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/billing/stripe/webhooks")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "stripe-signature" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/events/webhooks/attempts/:id")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "Authorization", value: "Basic <base64(mailjet:webhook-secret)>" }),
|
||||
]);
|
||||
|
||||
expect((specs.endpoints ?? []).map((endpoint) => endpoint.route)).toEqual([
|
||||
"https://api.example.com/v1/entities/contacts/rows",
|
||||
"https://api.example.com/v1/entities/contacts/rows/:rowId",
|
||||
"https://api.example.com/v1/entities/contacts/rows",
|
||||
"https://api.example.com/v1/entities/contacts/rows/:rowId",
|
||||
"https://api.example.com/v1/entities/contacts/rows/:rowId",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { openApiDocument } from "@toolctrl/api/openapi";
|
||||
import { getAllLegacyEntities, type LegacyEntityWithDetails } from "@toolctrl/db/entities.server";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import ApiHelper from "~/utils/helpers/ApiHelper";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getAbsoluteApiUrl } from "~/utils/url.server";
|
||||
import { apiPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export type ApiSpecsDto = {
|
||||
endpoints?: ApiEndpointDto[];
|
||||
openApi: CanonicalOpenApiDocument;
|
||||
postmanCollection: PostmanCollection;
|
||||
apiDocsUrl: string;
|
||||
};
|
||||
|
||||
type CanonicalOpenApiDocument = typeof openApiDocument & {
|
||||
servers: Array<{ url: string; description: string }>;
|
||||
};
|
||||
|
||||
type PostmanCollection = {
|
||||
info: {
|
||||
_postman_id: string;
|
||||
name: string;
|
||||
schema: string;
|
||||
};
|
||||
item: Array<ReturnType<typeof toPostmanItem>>;
|
||||
};
|
||||
|
||||
type PostmanHeader = { key: string; value: string; type: "text"; description?: string };
|
||||
type PostmanQueryParam = { key: string; value: string; disabled?: boolean; description?: string };
|
||||
|
||||
async function generateSpecs({ request }: { request: Request }): Promise<ApiSpecsDto> {
|
||||
const { t } = await getTranslations(request);
|
||||
const entities = (await getAllLegacyEntities({ tenantId: null, active: true })).filter((entity) => entity.hasApi);
|
||||
const openApi = generateCanonicalOpenApiSpecs({ request, t });
|
||||
|
||||
return {
|
||||
endpoints: entities.flatMap((entity) => getEntityEndpoints({ entity, t, apiOrigin: getApiOrigin(request) })),
|
||||
openApi,
|
||||
postmanCollection: generatePostmanCollection({ request, openApi }),
|
||||
apiDocsUrl: getAbsoluteApiUrl(request, apiPath("docs", { mode: "host-aware" })),
|
||||
};
|
||||
}
|
||||
|
||||
function getApiOrigin(request: Request) {
|
||||
return new URL(getAbsoluteApiUrl(request, "/")).origin;
|
||||
}
|
||||
|
||||
function generateCanonicalOpenApiSpecs({ request, t }: { request: Request; t: TFunction }): CanonicalOpenApiDocument {
|
||||
return {
|
||||
...openApiDocument,
|
||||
servers: [
|
||||
{
|
||||
url: getApiOrigin(request),
|
||||
description: translateApiSpec(t, "api.specs.hostDescription", "API host"),
|
||||
},
|
||||
],
|
||||
} as CanonicalOpenApiDocument;
|
||||
}
|
||||
|
||||
function getEntityApiRoute(apiOrigin: string, path: string) {
|
||||
return new URL(apiPath(path, { mode: "host-aware" }), apiOrigin).toString();
|
||||
}
|
||||
|
||||
function getEntityEndpoints({ entity, t, apiOrigin }: { entity: LegacyEntityWithDetails; t: TFunction; apiOrigin: string }) {
|
||||
const body = Object.fromEntries(
|
||||
entity.properties
|
||||
.filter((property) => !property.isDefault)
|
||||
.map((property) => [property.name, ApiHelper.getSampleValue({ property: property as never }) ?? ""])
|
||||
);
|
||||
const schema = Object.fromEntries(
|
||||
entity.properties
|
||||
.filter((property) => !property.isDefault)
|
||||
.map((property) => [property.name, ApiHelper.getSchemaType({ property: property as never }) ?? ""])
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`),
|
||||
method: "GET",
|
||||
description: translateApiSpec(t, "api.specs.operations.getAll", `Get all ${t(entity.titlePlural)}`, { name: t(entity.titlePlural) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: "",
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
|
||||
method: "GET",
|
||||
description: translateApiSpec(t, "api.specs.operations.get", `Get ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: "",
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`),
|
||||
method: "POST",
|
||||
description: translateApiSpec(t, "api.specs.operations.create", `Create ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: JSON.stringify(body, null, 2),
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
|
||||
method: "PATCH",
|
||||
description: translateApiSpec(t, "api.specs.operations.update", `Update ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: JSON.stringify(body, null, 2),
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
|
||||
method: "DELETE",
|
||||
description: translateApiSpec(t, "api.specs.operations.delete", `Delete ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: "",
|
||||
bodyExample: "",
|
||||
},
|
||||
] satisfies ApiEndpointDto[];
|
||||
}
|
||||
|
||||
function translateApiSpec(t: TFunction, key: string, fallback: string, options?: Record<string, unknown>) {
|
||||
const translated = t(key, options);
|
||||
return translated === key ? fallback : translated;
|
||||
}
|
||||
|
||||
function generatePostmanCollection({ request, openApi }: { request: Request; openApi: CanonicalOpenApiDocument }): PostmanCollection {
|
||||
const serverUrl = getApiOrigin(request);
|
||||
const paths = openApi.paths as Record<string, Record<string, unknown>>;
|
||||
|
||||
return {
|
||||
info: {
|
||||
_postman_id: "canonical-api",
|
||||
name: `${process.env.APP_NAME ?? "Boilerplate"} API`,
|
||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
item: Object.entries(paths).flatMap(([path, operations]) =>
|
||||
Object.entries(operations)
|
||||
.filter(([method]) => isHttpMethod(method))
|
||||
.map(([method, operation]) => toPostmanItem({ serverUrl, path, method: method.toUpperCase(), operation }))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function toPostmanItem({ serverUrl, path, method, operation }: { serverUrl: string; path: string; method: string; operation: unknown }) {
|
||||
const description = getOperationString(operation, "description") ?? getOperationString(operation, "summary") ?? "";
|
||||
const postmanPath = path.replace(/\{([^}]+)\}/g, ":$1");
|
||||
const request: Record<string, unknown> = {
|
||||
url: getPostmanUrl(serverUrl, postmanPath, operation),
|
||||
method,
|
||||
header: getPostmanHeaders(operation),
|
||||
description,
|
||||
};
|
||||
|
||||
if (hasRequestBody(operation)) {
|
||||
request.body = {
|
||||
mode: "raw",
|
||||
raw: "{}",
|
||||
options: { raw: { language: "json" } },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${method} ${postmanPath}`,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
function getPostmanUrl(serverUrl: string, route: string, operation: unknown) {
|
||||
const url = new URL(route, serverUrl);
|
||||
const query = getPostmanQueryParams(operation);
|
||||
for (const parameter of query) {
|
||||
if (!parameter.disabled) {
|
||||
url.searchParams.set(parameter.key, parameter.value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw: url.toString(),
|
||||
protocol: url.protocol.replace(":", ""),
|
||||
host: [url.hostname],
|
||||
path: url.pathname.replace(/^\//, "").split("/"),
|
||||
port: url.port,
|
||||
...(query.length > 0 ? { query } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getPostmanHeaders(operation: unknown) {
|
||||
const headers: PostmanHeader[] = [];
|
||||
const securityScheme = getPreferredPostmanSecurityScheme(getOperationArray(operation, "security"));
|
||||
const parameters = getOperationArray(operation, "parameters");
|
||||
|
||||
if (securityScheme === "apiKeyHeader") {
|
||||
headers.push({ key: "x-api-key", value: getApiKeyHeaderPlaceholder(operation), type: "text" });
|
||||
}
|
||||
if (securityScheme === "analyticsTokenHeader") {
|
||||
headers.push({ key: "x-analytics-token", value: "<analytics-token>", type: "text" });
|
||||
}
|
||||
if (securityScheme === "stripeWebhookSignature") {
|
||||
headers.push({ key: "stripe-signature", value: "<stripe-signature>", type: "text" });
|
||||
}
|
||||
if (securityScheme === "mailjetWebhookAuth") {
|
||||
headers.push({ key: "Authorization", value: "Basic <base64(mailjet:webhook-secret)>", type: "text" });
|
||||
}
|
||||
if (securityScheme === "bearerAuth" || securityScheme === "apiSessionBearer") {
|
||||
headers.push({ key: "Authorization", value: "Bearer <token>", type: "text" });
|
||||
}
|
||||
if (parameters.some((entry) => isParameterNamed(entry, "Idempotency-Key"))) {
|
||||
headers.push({ key: "Idempotency-Key", value: "<unique-operation-key>", type: "text" });
|
||||
}
|
||||
|
||||
for (const parameter of getPostmanHeaderParams(parameters)) {
|
||||
if (!headers.some((header) => header.key.toLowerCase() === parameter.key.toLowerCase())) {
|
||||
headers.push(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getPostmanQueryParams(operation: unknown): PostmanQueryParam[] {
|
||||
return getOperationArray(operation, "parameters")
|
||||
.filter((parameter) => getParameterLocation(parameter) === "query")
|
||||
.map((parameter) => {
|
||||
const key = getParameterName(parameter);
|
||||
return {
|
||||
key,
|
||||
value: `<${key}>`,
|
||||
...(getParameterRequired(parameter) ? {} : { disabled: true }),
|
||||
...getParameterDescription(parameter),
|
||||
};
|
||||
})
|
||||
.filter((parameter) => parameter.key.length > 0);
|
||||
}
|
||||
|
||||
function getPostmanHeaderParams(parameters: unknown[]): PostmanHeader[] {
|
||||
return parameters
|
||||
.filter((parameter) => getParameterLocation(parameter) === "header")
|
||||
.map((parameter) => {
|
||||
const key = getParameterName(parameter);
|
||||
return {
|
||||
key,
|
||||
value: `<${key}>`,
|
||||
type: "text" as const,
|
||||
...getParameterDescription(parameter),
|
||||
};
|
||||
})
|
||||
.filter((parameter) => parameter.key.length > 0);
|
||||
}
|
||||
|
||||
function getPreferredPostmanSecurityScheme(security: unknown[]) {
|
||||
if (security.some((entry) => isSecurityScheme(entry, "apiKeyHeader"))) {
|
||||
return "apiKeyHeader";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "analyticsTokenHeader"))) {
|
||||
return "analyticsTokenHeader";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "stripeWebhookSignature"))) {
|
||||
return "stripeWebhookSignature";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "mailjetWebhookAuth"))) {
|
||||
return "mailjetWebhookAuth";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "bearerAuth"))) {
|
||||
return "bearerAuth";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "apiSessionBearer"))) {
|
||||
return "apiSessionBearer";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOperationString(operation: unknown, key: string) {
|
||||
return operation && typeof operation === "object" && typeof (operation as Record<string, unknown>)[key] === "string"
|
||||
? (operation as Record<string, string>)[key]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getOperationArray(operation: unknown, key: string) {
|
||||
const value = operation && typeof operation === "object" ? (operation as Record<string, unknown>)[key] : undefined;
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function hasRequestBody(operation: unknown) {
|
||||
return Boolean(operation && typeof operation === "object" && "requestBody" in operation);
|
||||
}
|
||||
|
||||
function hasScopedApiKeyPermissions(operation: unknown) {
|
||||
return Boolean(operation && typeof operation === "object" && "x-api-key-permissions" in operation);
|
||||
}
|
||||
|
||||
function getApiKeyHeaderPlaceholder(operation: unknown) {
|
||||
if (operation && typeof operation === "object" && "x-api-key-placeholder" in operation) {
|
||||
const placeholder = (operation as { "x-api-key-placeholder"?: unknown })["x-api-key-placeholder"];
|
||||
if (typeof placeholder === "string" && placeholder.length > 0) {
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
return hasScopedApiKeyPermissions(operation) ? "<api-key>" : "<service-token>";
|
||||
}
|
||||
|
||||
function isSecurityScheme(value: unknown, scheme: string) {
|
||||
return Boolean(value && typeof value === "object" && scheme in value);
|
||||
}
|
||||
|
||||
function isParameterNamed(value: unknown, name: string) {
|
||||
return Boolean(value && typeof value === "object" && (value as Record<string, unknown>).name === name);
|
||||
}
|
||||
|
||||
function getParameterName(value: unknown) {
|
||||
return value && typeof value === "object" && typeof (value as Record<string, unknown>).name === "string"
|
||||
? ((value as Record<string, string>).name as string)
|
||||
: "";
|
||||
}
|
||||
|
||||
function getParameterLocation(value: unknown) {
|
||||
return value && typeof value === "object" && typeof (value as Record<string, unknown>).in === "string"
|
||||
? ((value as Record<string, string>).in as string)
|
||||
: "";
|
||||
}
|
||||
|
||||
function getParameterRequired(value: unknown) {
|
||||
return Boolean(value && typeof value === "object" && (value as Record<string, unknown>).required === true);
|
||||
}
|
||||
|
||||
function getParameterDescription(value: unknown) {
|
||||
if (value && typeof value === "object" && typeof (value as Record<string, unknown>).description === "string") {
|
||||
return { description: (value as Record<string, string>).description };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function isHttpMethod(value: string) {
|
||||
return ["get", "post", "put", "patch", "delete", "options", "head"].includes(value);
|
||||
}
|
||||
|
||||
export default {
|
||||
generateSpecs,
|
||||
getEntityEndpoints,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@toolctrl/db/logs.server", () => ({
|
||||
createLegacyEntityWebhookLog: vi.fn(async (data) => data),
|
||||
createLegacyRowLog: vi.fn(async () => ({ id: "log_1" })),
|
||||
getLegacyEntityWebhooksByAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/url.server", () => ({
|
||||
getBaseURL: vi.fn((request: Request) => new URL(request.url).origin),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/ApiHelper", () => ({
|
||||
default: {
|
||||
getApiFormat: vi.fn(() => ({ formatted: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server";
|
||||
import { createApiRowLog } from "./RowLogService.server";
|
||||
|
||||
describe("RowLogService webhook delivery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 202 })));
|
||||
});
|
||||
|
||||
it("resolves relative webhook URLs against the request base before validation", async () => {
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_relative", method: "POST", endpoint: "/webhooks/row" }]);
|
||||
|
||||
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://tenant.example.com/webhooks/row",
|
||||
expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) })
|
||||
);
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative", logId: "log_1", status: 202 });
|
||||
});
|
||||
|
||||
it("follows safe redirects after validating the redirect target", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: "https://hooks.example.com/row-next" } }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 202 }))
|
||||
);
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect", method: "POST", endpoint: "https://hooks.example.com/row" }]);
|
||||
|
||||
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, "https://hooks.example.com/row", expect.objectContaining({ method: "POST", redirect: "manual" }));
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, "https://hooks.example.com/row-next", expect.objectContaining({ method: "POST", redirect: "manual" }));
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect", logId: "log_1", status: 202 });
|
||||
});
|
||||
|
||||
it("blocks unsafe redirect targets without following them", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/webhook" } })));
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect_blocked", method: "POST", endpoint: "https://hooks.example.com/row" }]);
|
||||
|
||||
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith("https://hooks.example.com/row", expect.objectContaining({ redirect: "manual" }));
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect_blocked", logId: "log_1", status: 0 });
|
||||
});
|
||||
|
||||
it("blocks unsafe webhook URLs and records failed delivery status without fetching", async () => {
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([
|
||||
{ id: "webhook_loopback", method: "POST", endpoint: "http://127.0.0.1/webhook" },
|
||||
{ id: "webhook_relative_localhost", method: "POST", endpoint: "/webhooks/row" },
|
||||
]);
|
||||
|
||||
await createApiRowLog(new Request("https://localhost/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(createLegacyRowLog).toHaveBeenCalledTimes(1);
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_loopback", logId: "log_1", status: 0 });
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative_localhost", logId: "log_1", status: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server";
|
||||
import { getBaseURL } from "~/utils/url.server";
|
||||
import ApiHelper from "~/utils/helpers/ApiHelper";
|
||||
|
||||
const LEGACY_WEBHOOK_FETCH_TIMEOUT_MS = 10_000;
|
||||
const BLOCKED_WEBHOOK_STATUS = 0;
|
||||
const LEGACY_WEBHOOK_REDIRECT_LIMIT = 5;
|
||||
const INTERNAL_WEBHOOK_HOST_SUFFIXES = [".localhost", ".local", ".internal", ".lan", ".home", ".corp", ".intranet", ".svc"] as const;
|
||||
|
||||
type ApiRowLogEntity = {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ApiRowLogItem = {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export async function createApiRowLog(
|
||||
request: Request,
|
||||
data: {
|
||||
tenantId: string | null;
|
||||
createdByUserId?: string | null;
|
||||
createdByApiKey?: string | null;
|
||||
action: string;
|
||||
entity: ApiRowLogEntity;
|
||||
item: ApiRowLogItem;
|
||||
commentId?: string | null;
|
||||
details?: string;
|
||||
},
|
||||
) {
|
||||
const log = await createLegacyRowLog({
|
||||
tenantId: data.tenantId,
|
||||
userId: data.createdByUserId ?? null,
|
||||
apiKeyId: data.createdByApiKey ?? null,
|
||||
url: new URL(request.url).pathname,
|
||||
rowId: data.item?.id ?? null,
|
||||
action: data.action,
|
||||
details: data.details ?? null,
|
||||
commentId: data.commentId ?? null,
|
||||
});
|
||||
|
||||
const apiFormat = ApiHelper.getApiFormat(data.entity as never, data.item as never);
|
||||
await callApiEntityWebhooks({
|
||||
logId: log.id,
|
||||
entityId: data.entity.id,
|
||||
action: data.action,
|
||||
body: apiFormat,
|
||||
request,
|
||||
});
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
async function callApiEntityWebhooks({
|
||||
logId,
|
||||
entityId,
|
||||
action,
|
||||
body,
|
||||
request,
|
||||
}: {
|
||||
logId: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
body: string;
|
||||
request: Request;
|
||||
}) {
|
||||
const webhooks = await getLegacyEntityWebhooksByAction(entityId, action);
|
||||
return Promise.all(
|
||||
webhooks.map(async (webhook) => {
|
||||
if (!webhook.endpoint) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const status = await fetchLegacyWebhookStatus(webhook.endpoint, request, {
|
||||
method: webhook.method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return createLegacyEntityWebhookLog({
|
||||
webhookId: webhook.id,
|
||||
logId,
|
||||
status,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWebhookEndpoint(endpoint: string, request: Request): string {
|
||||
const resolvedEndpoint = endpoint.startsWith("/") ? getBaseURL(request) + endpoint : endpoint;
|
||||
return validateOutboundWebhookUrl(resolvedEndpoint);
|
||||
}
|
||||
|
||||
async function fetchLegacyWebhookStatus(endpoint: string, request: Request, init: RequestInit): Promise<number> {
|
||||
try {
|
||||
const response = await fetchWebhookWithRedirects(resolveWebhookEndpoint(endpoint, request), init);
|
||||
return response.status;
|
||||
} catch {
|
||||
return BLOCKED_WEBHOOK_STATUS;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: Parameters<typeof fetch>[0], init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), LEGACY_WEBHOOK_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await fetch(input, { ...init, redirect: "manual", signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWebhookWithRedirects(input: string, init: RequestInit): Promise<Response> {
|
||||
let currentUrl = input;
|
||||
let currentInit = init;
|
||||
|
||||
for (let redirects = 0; redirects <= LEGACY_WEBHOOK_REDIRECT_LIMIT; redirects += 1) {
|
||||
const response = await fetchWithTimeout(currentUrl, currentInit);
|
||||
if (!isRedirectResponse(response.status)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redirects === LEGACY_WEBHOOK_REDIRECT_LIMIT) {
|
||||
throw new Error("Webhook redirect limit exceeded.");
|
||||
}
|
||||
|
||||
currentUrl = resolveOutboundWebhookRedirect(currentUrl, location);
|
||||
currentInit = buildRedirectRequestInit(currentInit, response.status);
|
||||
}
|
||||
|
||||
throw new Error("Webhook redirect limit exceeded.");
|
||||
}
|
||||
|
||||
function resolveOutboundWebhookRedirect(currentUrl: string, location: string): string {
|
||||
let redirectUrl: URL;
|
||||
try {
|
||||
redirectUrl = new URL(location, currentUrl);
|
||||
} catch {
|
||||
throw new Error("Webhook redirect location must be a valid URL.");
|
||||
}
|
||||
return validateOutboundWebhookUrl(redirectUrl.toString());
|
||||
}
|
||||
|
||||
function isRedirectResponse(status: number): boolean {
|
||||
return status >= 300 && status < 400;
|
||||
}
|
||||
|
||||
function buildRedirectRequestInit(init: RequestInit, status: number): RequestInit {
|
||||
const method = typeof init.method === "string" ? init.method.toUpperCase() : undefined;
|
||||
if (status === 303 || ((status === 301 || status === 302) && method === "POST")) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
return { ...init, method: "GET", body: undefined, headers };
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
function validateOutboundWebhookUrl(endpoint: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
throw new Error("Webhook endpoint must be an absolute URL.");
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Webhook endpoint must use http or https.");
|
||||
}
|
||||
|
||||
if (isBlockedWebhookHostname(url.hostname)) {
|
||||
throw new Error("Webhook endpoint host is not allowed.");
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isBlockedWebhookHostname(hostname: string): boolean {
|
||||
const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
if (!normalizedHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipv4 = parseIpv4Address(normalizedHostname);
|
||||
if (ipv4) {
|
||||
return isBlockedIpv4Address(ipv4);
|
||||
}
|
||||
|
||||
const ipv6 = parseIpv6Address(normalizedHostname);
|
||||
if (ipv6) {
|
||||
return isBlockedIpv6Address(ipv6);
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedHostname === "localhost" ||
|
||||
!normalizedHostname.includes(".") ||
|
||||
INTERNAL_WEBHOOK_HOST_SUFFIXES.some((suffix) => normalizedHostname.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
function parseIpv4Address(hostname: string): [number, number, number, number] | undefined {
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const octets = parts.map((part) => Number(part));
|
||||
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return octets as [number, number, number, number];
|
||||
}
|
||||
|
||||
function isBlockedIpv4Address([first, second]: [number, number, number, number]): boolean {
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
first >= 224 ||
|
||||
(first === 100 && second >= 64 && second <= 127) ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168) ||
|
||||
(first === 198 && (second === 18 || second === 19))
|
||||
);
|
||||
}
|
||||
|
||||
function parseIpv6Address(hostname: string): number[] | undefined {
|
||||
if (!hostname.includes(":")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [head = "", tail = "", extra] = hostname.split("::");
|
||||
if (extra !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headParts = head ? head.split(":") : [];
|
||||
const tailParts = tail ? tail.split(":") : [];
|
||||
const missingPartCount = 8 - headParts.length - tailParts.length;
|
||||
if (missingPartCount < 0 || (!hostname.includes("::") && missingPartCount !== 0)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = [...headParts, ...Array(missingPartCount).fill("0"), ...tailParts];
|
||||
if (parts.length !== 8) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hextets = parts.map((part) => Number.parseInt(part, 16));
|
||||
if (hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hextets;
|
||||
}
|
||||
|
||||
function isBlockedIpv6Address(hextets: number[]): boolean {
|
||||
const first = hextets[0] ?? 0;
|
||||
const isUnspecified = hextets.every((part) => part === 0);
|
||||
const isLoopback = hextets.slice(0, 7).every((part) => part === 0) && hextets[7] === 1;
|
||||
const isUniqueLocal = (first & 0xfe00) === 0xfc00;
|
||||
const isLinkLocal = (first & 0xffc0) === 0xfe80;
|
||||
const isIpv4Mapped = hextets.slice(0, 5).every((part) => part === 0) && hextets[5] === 0xffff;
|
||||
const isIpv4Compatible = hextets.slice(0, 6).every((part) => part === 0);
|
||||
|
||||
if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isIpv4Mapped || isIpv4Compatible) {
|
||||
const ipv4: [number, number, number, number] = [hextets[6] >> 8, hextets[6] & 0xff, hextets[7] >> 8, hextets[7] & 0xff];
|
||||
return isBlockedIpv4Address(ipv4);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import ApiKeyLogsConstants from "./ApiKeyLogsConstants";
|
||||
|
||||
function getGroupByValues(searchParams: URLSearchParams) {
|
||||
const groupByValues = searchParams
|
||||
.getAll("groupBy")
|
||||
.filter((x) => x)
|
||||
.sort();
|
||||
const groupBy: Prisma.ApiKeyLogScalarFieldEnum[] = [];
|
||||
for (const param of groupByValues) {
|
||||
if (Object.keys(Prisma.ApiKeyLogScalarFieldEnum).includes(param)) {
|
||||
groupBy.push(param as Prisma.ApiKeyLogScalarFieldEnum);
|
||||
}
|
||||
}
|
||||
return groupBy.length > 0 ? groupBy.map((x) => x.toString()) : ApiKeyLogsConstants.DEFAULT_GROUP_BY;
|
||||
}
|
||||
|
||||
export default {
|
||||
getGroupByValues,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const DEFAULT_GROUP_BY = ["method", "endpoint"];
|
||||
|
||||
export default {
|
||||
DEFAULT_GROUP_BY,
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
import CodeTemplateForm from "../templates/components/CodeTemplate.Form";
|
||||
import CodeTemplateDto from "../templates/dtos/CodeTemplate.Dto";
|
||||
import CodeTemplateDtoCreate from "../templates/dtos/CodeTemplate.Dto.Create";
|
||||
import CodeTemplateHelper from "../templates/helpers/CodeTemplate.Helper";
|
||||
import CodeTemplateRoutesActivityApi from "../templates/routes/api/CodeTemplate.Routes.Activity.Api";
|
||||
import CodeTemplateRoutesEditApi from "../templates/routes/api/CodeTemplate.Routes.Edit.Api";
|
||||
import CodeTemplateRoutesIndexApi from "../templates/routes/api/CodeTemplate.Routes.Index.Api";
|
||||
import CodeTemplateRoutesNewApi from "../templates/routes/api/CodeTemplate.Routes.New.Api";
|
||||
import CodeTemplateRoutesShareApi from "../templates/routes/api/CodeTemplate.Routes.Share.Api";
|
||||
import CodeTemplateRoutesTagsApi from "../templates/routes/api/CodeTemplate.Routes.Tags.Api";
|
||||
import CodeTemplateRoutesActivityComponent from "../templates/routes/components/CodeTemplate.Routes.Activity.Component";
|
||||
import CodeTemplateRoutesEditComponent from "../templates/routes/components/CodeTemplate.Routes.Edit.Component";
|
||||
import CodeTemplateRoutesIndexComponent from "../templates/routes/components/CodeTemplate.Routes.Index.Component";
|
||||
import CodeTemplateRoutesNewComponent from "../templates/routes/components/CodeTemplate.Routes.New.Component";
|
||||
import CodeTemplateRoutesShareComponent from "../templates/routes/components/CodeTemplate.Routes.Share.Component";
|
||||
import CodeTemplateRoutesTagsComponent from "../templates/routes/components/CodeTemplate.Routes.Tags.Component";
|
||||
import CodeTemplateRoutesActivityView from "../templates/routes/views/CodeTemplate.Routes.Activity.View";
|
||||
import CodeTemplateRoutesEditView from "../templates/routes/views/CodeTemplate.Routes.Edit.View";
|
||||
import CodeTemplateRoutesIndexView from "../templates/routes/views/CodeTemplate.Routes.Index.View";
|
||||
import CodeTemplateRoutesNewView from "../templates/routes/views/CodeTemplate.Routes.New.View";
|
||||
import CodeTemplateRoutesShareView from "../templates/routes/views/CodeTemplate.Routes.Share.View";
|
||||
import CodeTemplateRoutesTagsView from "../templates/routes/views/CodeTemplate.Routes.Tags.View";
|
||||
import CodeTemplateModelSchemaPrisma from "../templates/schemas/CodeTemplate.Model.Schema.Prisma";
|
||||
import CodeTemplateService from "../templates/services/CodeTemplate.Service";
|
||||
import CodeGeneratorHelper from "../utils/CodeGeneratorHelper";
|
||||
import Constants from "~/application/Constants";
|
||||
|
||||
export type CodeGeneratorOptions = {
|
||||
entity: EntityWithDetails;
|
||||
type: string;
|
||||
moduleDirectory: string;
|
||||
routesDirectory: string;
|
||||
deleteFilesOnFinish: boolean;
|
||||
};
|
||||
|
||||
export type CodeGeneratorFileDto = {
|
||||
type: "module" | "route" | "model";
|
||||
directory?: string;
|
||||
file: string;
|
||||
content: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
function getModuleFiles(options: CodeGeneratorOptions): CodeGeneratorFileDto[] {
|
||||
const { entity } = options;
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
|
||||
const files: CodeGeneratorFileDto[] = [
|
||||
{
|
||||
type: "module",
|
||||
directory: `dtos`,
|
||||
file: `${capitalized}Dto.ts`,
|
||||
content: CodeTemplateDto.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.dto.rowInterface"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `dtos`,
|
||||
file: `${capitalized}CreateDto.ts`,
|
||||
content: CodeTemplateDtoCreate.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.dto.createInput"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `components`,
|
||||
file: `${capitalized}Form.tsx`,
|
||||
content: CodeTemplateForm.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.components.form"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `helpers`,
|
||||
file: `${capitalized}Helpers.ts`,
|
||||
content: CodeTemplateHelper.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.helpers.formDataTransformers"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `services`,
|
||||
file: `${capitalized}Service.ts`,
|
||||
content: CodeTemplateService.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.services.crud"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/api`,
|
||||
file: `${capitalized}Routes.Index.Api.ts`,
|
||||
content: CodeTemplateRoutesIndexApi.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.api.index"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/api`,
|
||||
file: `${capitalized}Routes.New.Api.ts`,
|
||||
content: CodeTemplateRoutesNewApi.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.api.new"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/api`,
|
||||
file: `${capitalized}Routes.Edit.Api.ts`,
|
||||
content: CodeTemplateRoutesEditApi.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.api.edit"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/api`,
|
||||
file: `${capitalized}Routes.Activity.Api.ts`,
|
||||
content: CodeTemplateRoutesActivityApi.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.api.activity"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/api`,
|
||||
file: `${capitalized}Routes.Share.Api.ts`,
|
||||
content: CodeTemplateRoutesShareApi.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.api.share"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/api`,
|
||||
file: `${capitalized}Routes.Tags.Api.ts`,
|
||||
content: CodeTemplateRoutesTagsApi.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.api.tags"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/views`,
|
||||
file: `${capitalized}Routes.Index.View.tsx`,
|
||||
content: CodeTemplateRoutesIndexView.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.views.index"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/views`,
|
||||
file: `${capitalized}Routes.New.View.tsx`,
|
||||
content: CodeTemplateRoutesNewView.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.views.new"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/views`,
|
||||
file: `${capitalized}Routes.Edit.View.tsx`,
|
||||
content: CodeTemplateRoutesEditView.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.views.edit"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/views`,
|
||||
file: `${capitalized}Routes.Activity.View.tsx`,
|
||||
content: CodeTemplateRoutesActivityView.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.views.activity"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/views`,
|
||||
file: `${capitalized}Routes.Share.View.tsx`,
|
||||
content: CodeTemplateRoutesShareView.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.views.share"),
|
||||
},
|
||||
{
|
||||
type: "module",
|
||||
directory: `routes/views`,
|
||||
file: `${capitalized}Routes.Tags.View.tsx`,
|
||||
content: CodeTemplateRoutesTagsView.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.views.tags"),
|
||||
},
|
||||
];
|
||||
|
||||
if (options.type === "custom") {
|
||||
files.unshift({
|
||||
type: "model",
|
||||
file: `schema.prisma`,
|
||||
content: CodeTemplateModelSchemaPrisma.generate({ entity }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.schema.prisma"),
|
||||
});
|
||||
}
|
||||
|
||||
addTopComments(files, entity);
|
||||
return files;
|
||||
}
|
||||
|
||||
function getRouteFiles({ type, entity, moduleDirectory }: CodeGeneratorOptions) {
|
||||
let moduleLocation = moduleDirectory.replace("./app", "");
|
||||
if (moduleLocation.startsWith("/")) {
|
||||
moduleLocation = moduleLocation.substring(1);
|
||||
}
|
||||
const files: CodeGeneratorFileDto[] = [
|
||||
{
|
||||
type: "route",
|
||||
file: `index.tsx`,
|
||||
content: CodeTemplateRoutesIndexComponent.generate({ entity, moduleLocation }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.routeShells.index"),
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
file: `new.tsx`,
|
||||
content: CodeTemplateRoutesNewComponent.generate({ entity, moduleLocation }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.routeShells.new"),
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
file: `$id.tsx`,
|
||||
content: CodeTemplateRoutesEditComponent.generate({ entity, moduleLocation }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.routeShells.edit"),
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
file: `activity.tsx`,
|
||||
directory: "$id",
|
||||
content: CodeTemplateRoutesActivityComponent.generate({ entity, moduleLocation }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.routeShells.activity"),
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
file: `share.tsx`,
|
||||
directory: "$id",
|
||||
content: CodeTemplateRoutesShareComponent.generate({ entity, moduleLocation }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.routeShells.share"),
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
file: `tags.tsx`,
|
||||
directory: "$id",
|
||||
content: CodeTemplateRoutesTagsComponent.generate({ entity, moduleLocation }),
|
||||
description: i18nT("models.entity.codeGenerator.fileDescriptions.routeShells.tags"),
|
||||
},
|
||||
];
|
||||
|
||||
addTopComments(files, entity);
|
||||
return files;
|
||||
}
|
||||
|
||||
function addTopComments(files: CodeGeneratorFileDto[], entity: EntityWithDetails) {
|
||||
const rawLabelWarnings = CodeGeneratorHelper.getRawLabelWarnings(entity);
|
||||
files.forEach((file) => {
|
||||
const comments: string[] = [];
|
||||
comments.push(file.description);
|
||||
comments.push(...rawLabelWarnings);
|
||||
comments.push("Date: " + new Date().toISOString().slice(0, 10));
|
||||
comments.push("Version: App v" + Constants.VERSION);
|
||||
file.content = comments.map((comment) => `// ${comment}`).join("\n") + "\n\n" + file.content;
|
||||
});
|
||||
}
|
||||
|
||||
function moduleDirectoryOptions(entity: EntityWithDetails) {
|
||||
let tests = `./apps/web/app/modules/codeGeneratorTests/` + entity.slug;
|
||||
let app = `./apps/web/app/modules/` + entity.slug;
|
||||
return [
|
||||
{ name: tests, value: tests },
|
||||
{ name: app, value: app },
|
||||
];
|
||||
}
|
||||
|
||||
function routeDirectoryOptions(entity: EntityWithDetails) {
|
||||
let tests = `./apps/web/app/routes/admin/entities/code-generator/tests/` + entity.slug;
|
||||
let app = `./apps/web/app/routes/app.$tenant/` + entity.slug;
|
||||
return [
|
||||
{ name: tests, value: tests },
|
||||
{ name: app, value: app },
|
||||
];
|
||||
}
|
||||
|
||||
export default {
|
||||
getModuleFiles,
|
||||
getRouteFiles,
|
||||
moduleDirectoryOptions,
|
||||
routeDirectoryOptions,
|
||||
};
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import ActivityApiTemplate from "./routes/api/CodeTemplate.Routes.Activity.Api";
|
||||
import EditApiTemplate from "./routes/api/CodeTemplate.Routes.Edit.Api";
|
||||
import ShareApiTemplate from "./routes/api/CodeTemplate.Routes.Share.Api";
|
||||
import TagsApiTemplate from "./routes/api/CodeTemplate.Routes.Tags.Api";
|
||||
import ServiceTemplate from "./services/CodeTemplate.Service";
|
||||
import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
const entity = {
|
||||
name: "tickets",
|
||||
title: "Tickets",
|
||||
titlePlural: "Tickets",
|
||||
prefix: "TCK",
|
||||
hasTasks: true,
|
||||
properties: [],
|
||||
} as unknown as EntityWithDetails;
|
||||
|
||||
describe("generated entity route tenant scope", () => {
|
||||
it("generates edit actions through scoped services and validates task row ownership", () => {
|
||||
const source = EditApiTemplate.generate({ entity });
|
||||
|
||||
expect(source).not.toContain('import { getRowById } from "~/utils/db/entities/rows.db.server"');
|
||||
expect(source).toContain("const item = await TicketsService.get(params.id!, {");
|
||||
expect(source).toContain("tenantId,");
|
||||
expect(source).toContain("userId,");
|
||||
expect(source).toContain("getRowTaskInRow({ id: taskId, rowId: item.row.id })");
|
||||
expect(source).toContain("updateRowTaskInRow({ id: taskId, rowId: item.row.id }");
|
||||
expect(source).toContain("deleteRowTaskInRow({ id: taskId, rowId: item.row.id })");
|
||||
expect(source).toContain("if (!task)");
|
||||
expect(source).not.toContain("getRowTask(taskId)");
|
||||
expect(source).toContain("to: item.row.createdByUser");
|
||||
expect(source).toContain("tenantId,");
|
||||
expect(source.match(/if \(!rowPermissions\.canUpdate\)/g)).toHaveLength(3);
|
||||
expect(source).toContain('return Response.json({ error: t("shared.unauthorized") }, { status: 403 });');
|
||||
expect(source).not.toContain('Response.json({ error: t("shared.notFound"), status: 404 })');
|
||||
expect(source).not.toContain('Response.json({ error: t("shared.unauthorized"), status: 404 })');
|
||||
});
|
||||
|
||||
it("generates update services through RowsApi tenant and permission checks", () => {
|
||||
const source = ServiceTemplate.generate({ entity });
|
||||
|
||||
expect(source).not.toContain("getRowById");
|
||||
expect(source).toContain("const { item: row, rowPermissions } = await RowsApi.get(id, {");
|
||||
expect(source).toContain("tenantId: session.tenantId");
|
||||
expect(source).toContain("userId: session.userId");
|
||||
expect(source).toContain("if (!row || !rowPermissions.canUpdate)");
|
||||
});
|
||||
|
||||
it("generates activity comment mutations with row-scoped lookups", () => {
|
||||
const source = ActivityApiTemplate.generate({ entity });
|
||||
|
||||
expect(source).toContain("tenantId,");
|
||||
expect(source).toContain("getRowCommentInRow({ id: rowCommentId, rowId: rowData.item.id })");
|
||||
expect(source).toContain("updateRowCommentInRow({ id: rowCommentId, rowId: rowData.item.id }");
|
||||
expect(source).toContain('t("models.row.activity.errors.commentForbidden")');
|
||||
expect(source).toContain('t("models.row.activity.errors.updateCommentForbidden")');
|
||||
expect(source).toContain('t("models.row.activity.errors.deleteCommentForbidden")');
|
||||
expect(source).toContain('t("notifications.messages.row.commented"');
|
||||
expect(source).not.toContain("getRowComment(rowCommentId)");
|
||||
expect(source).not.toContain("You don't have access");
|
||||
expect(source).not.toContain("commented on");
|
||||
});
|
||||
|
||||
it("generates missing-record responses with HTTP status init", () => {
|
||||
const sources = [
|
||||
EditApiTemplate.generate({ entity }),
|
||||
TagsApiTemplate.generate({ entity }),
|
||||
ShareApiTemplate.generate({ entity }),
|
||||
ActivityApiTemplate.generate({ entity }),
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
expect(source).not.toContain('Response.json({ error: t("shared.notFound"), status: 404 })');
|
||||
expect(source).not.toContain('Response.json({ error: t("shared.unauthorized"), status: 403 })');
|
||||
}
|
||||
});
|
||||
});
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "../../utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
const imports: string[] = [];
|
||||
imports.push(`import { useNavigation, useSubmit, Form } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRef } from "react";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import InputGroup from "~/components/ui/forms/InputGroup";
|
||||
import ActionResultModal from "~/components/ui/modals/ActionResultModal";
|
||||
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
||||
import { useRootData } from "~/utils/data/useRootData";`);
|
||||
imports.push(`import { ${capitalized}Dto } from "../dtos/${capitalized}Dto";`);
|
||||
|
||||
let template = `
|
||||
export default function ${capitalized}Form({
|
||||
item,
|
||||
actionData,
|
||||
isUpdating,
|
||||
isCreating,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
onCancel,
|
||||
}: {
|
||||
item?: ${capitalized}Dto;
|
||||
actionData: { success?: string; error?: string } | undefined;
|
||||
isUpdating?: boolean;
|
||||
isCreating?: boolean;
|
||||
canUpdate?: boolean;
|
||||
canDelete?: boolean;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const rootData = useRootData();
|
||||
|
||||
const confirmDelete = useRef<RefConfirmModal>(null);
|
||||
|
||||
function onDelete() {
|
||||
confirmDelete.current?.show(t("shared.confirmDelete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
|
||||
}
|
||||
function onDeleteConfirmed() {
|
||||
const form = new FormData();
|
||||
form.set("action", "delete");
|
||||
if (rootData.csrf) {
|
||||
form.set("csrf", rootData.csrf);
|
||||
}
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
function isDisabled() {
|
||||
if (isUpdating && !canUpdate) {
|
||||
return true;
|
||||
}
|
||||
if (!isUpdating && !isCreating) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Form key={!isDisabled() ? "enabled" : "disabled"} method="post" className="space-y-4">
|
||||
{item ? <input name="action" value="edit" hidden readOnly /> : <input name="action" value="create" hidden readOnly />}
|
||||
{rootData.csrf && <input type="hidden" name="csrf" value={rootData.csrf} />}
|
||||
|
||||
<InputGroup title={t("shared.details")}>
|
||||
<div className="space-y-2">
|
||||
{PROPERTIES_UI_CONTROLS}
|
||||
</div>
|
||||
</InputGroup>
|
||||
|
||||
{(isCreating || (isUpdating && canUpdate)) && (
|
||||
<div className="flex justify-between space-x-2">
|
||||
<div>
|
||||
{canDelete && (
|
||||
<ButtonSecondary disabled={navigation.state !== "idle"} destructive onClick={onDelete}>
|
||||
{t("shared.delete")}
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{onCancel && <ButtonSecondary onClick={onCancel}>{t("shared.cancel")}</ButtonSecondary>}
|
||||
<ButtonPrimary disabled={navigation.state !== "idle"} type="submit">
|
||||
{item ? t("shared.save") : t("shared.create")}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirmed} />
|
||||
<ActionResultModal actionData={actionData} showSuccess={false} />
|
||||
</Form>
|
||||
);
|
||||
}`;
|
||||
|
||||
const uiFormControls: string[] = [];
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault)
|
||||
.forEach((property, index) => {
|
||||
CodeGeneratorPropertiesHelper.uiForm({ code: uiFormControls, imports, property, index });
|
||||
});
|
||||
template = template.replace("{PROPERTIES_UI_CONTROLS}", uiFormControls.join("\n "));
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "../../utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
const imports: string[] = [];
|
||||
const code: string[] = [];
|
||||
|
||||
code.push("");
|
||||
code.push(`export type ${capitalized}CreateDto = {`);
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault && f.showInCreate && CodeGeneratorPropertiesHelper.isWritableProperty(f))
|
||||
.forEach((property) => {
|
||||
CodeGeneratorPropertiesHelper.type({ code, imports, property });
|
||||
});
|
||||
code.push("};");
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, ...code].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "../../utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
const imports: string[] = [];
|
||||
const code: string[] = [];
|
||||
imports.push(`import { RowWithDetails } from "~/utils/db/entities/rows.db.server";`);
|
||||
|
||||
code.push("");
|
||||
code.push(`export type ${capitalized}Dto = {`);
|
||||
code.push(` row: RowWithDetails;`);
|
||||
code.push(` prefix: string;`);
|
||||
code.push(` // Custom Row Properties:`);
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault)
|
||||
.forEach((property) => {
|
||||
CodeGeneratorPropertiesHelper.type({ code, imports, property });
|
||||
});
|
||||
code.push("};");
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, ...code].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "../../utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
const imports: string[] = [];
|
||||
imports.push(`import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import FormHelper from "~/utils/helpers/FormHelper";
|
||||
import RowValueHelper from "~/utils/helpers/RowValueHelper";`);
|
||||
imports.push(`import { ${capitalized}Dto } from "../dtos/${capitalized}Dto";`);
|
||||
// if (entity.properties.some((p) => p.type === PropertyType.MEDIA)) {
|
||||
// imports.push(`import { MediaDto } from "~/application/dtos/entities/MediaDto";`);
|
||||
// }
|
||||
|
||||
let template = `
|
||||
function rowToDto({ entity, row }: { entity: EntityWithDetails; row: RowWithDetails }): ${capitalized}Dto {
|
||||
return {
|
||||
row,
|
||||
prefix: entity.prefix,
|
||||
{PROPERTIES_ROW_TO_DTO}
|
||||
};
|
||||
}
|
||||
|
||||
function formToDto(form: FormData): Partial<${capitalized}Dto> {
|
||||
return {
|
||||
{PROPERTIES_FORM_TO_DTO}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
rowToDto,
|
||||
formToDto,
|
||||
};`;
|
||||
|
||||
const rowToDto: string[] = [];
|
||||
const formToDto: string[] = [];
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault)
|
||||
.forEach((property, index) => {
|
||||
CodeGeneratorPropertiesHelper.rowToDto({ code: rowToDto, property });
|
||||
CodeGeneratorPropertiesHelper.formToDto({ code: formToDto, property });
|
||||
});
|
||||
template = template.replace("{PROPERTIES_ROW_TO_DTO}", rowToDto.join("\n "));
|
||||
template = template.replace("{PROPERTIES_FORM_TO_DTO}", formToDto.join("\n "));
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized, name } = CodeGeneratorHelper.getNames(entity);
|
||||
let template = `import { LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import { RowPermissionsDto } from "~/application/dtos/entities/RowPermissionsDto";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import { RowCommentsApi } from "~/utils/api/.server/RowCommentsApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { getEntityByName } from "~/utils/db/entities/entities.db.server";
|
||||
import { setRowCommentReaction } from "~/utils/db/entities/rowCommentReaction.db.server";
|
||||
import { getRowCommentInRow, updateRowCommentInRow } from "~/utils/db/entities/rowComments.db.server";
|
||||
import { LogWithDetails, getRowLogsById } from "~/utils/db/logs.db.server";
|
||||
import { getUser } from "~/utils/db/users.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { requireAuth, validateCSRFToken } from "~/utils/session.server";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
|
||||
export namespace ${capitalized}RoutesActivityApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
logs: LogWithDetails[];
|
||||
permissions: RowPermissionsDto;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const entity = await getEntityByName({ tenantId, name: "${name}" });
|
||||
const rowData = await RowsApi.get(params.id!, { entity, tenantId, userId });
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: t("shared.activity") + " | " + process.env.APP_NAME }],
|
||||
logs: await getRowLogsById(rowData.item.id),
|
||||
permissions: rowData.rowPermissions,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const user = await getUser(userId);
|
||||
const entity = await getEntityByName({ tenantId, name: "${name}" });
|
||||
const rowData = await RowsApi.get(params.id!, { entity, tenantId, userId });
|
||||
if (action === "comment") {
|
||||
let comment = form.get("comment")?.toString();
|
||||
if (!comment) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
if (!rowData.rowPermissions.canComment) {
|
||||
return Response.json({ error: t("models.row.activity.errors.commentForbidden") }, { status: 403 });
|
||||
}
|
||||
await RowCommentsApi.create(params.id!, {
|
||||
comment,
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
const item = rowData.item;
|
||||
if (item!.createdByUser) {
|
||||
await NotificationService.send({
|
||||
channel: "my-rows",
|
||||
to: item!.createdByUser,
|
||||
tenantId,
|
||||
notification: {
|
||||
from: { user },
|
||||
message: t("notifications.messages.row.commented", { email: user?.email, row: RowHelper.getRowFolio(entity, item!) }),
|
||||
// action: {
|
||||
// title: t("shared.view"),
|
||||
// url: "",
|
||||
// },
|
||||
},
|
||||
});
|
||||
}
|
||||
return Response.json({ newComment: comment });
|
||||
} else if (action === "comment-reaction") {
|
||||
const rowCommentId = form.get("comment-id")?.toString();
|
||||
const reaction = form.get("reaction")?.toString();
|
||||
if (!rowCommentId || !reaction) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
const rowComment = await getRowCommentInRow({ id: rowCommentId, rowId: rowData.item.id });
|
||||
if (!rowComment || !rowData.rowPermissions.canComment) {
|
||||
return Response.json({ error: t("models.row.activity.errors.updateCommentForbidden") }, { status: 403 });
|
||||
}
|
||||
await setRowCommentReaction({
|
||||
createdByUserId: userId,
|
||||
rowCommentId,
|
||||
reaction,
|
||||
});
|
||||
return Response.json({ newCommentReaction: reaction });
|
||||
} else if (action === "comment-delete") {
|
||||
const rowCommentId = form.get("comment-id")?.toString();
|
||||
if (!rowCommentId) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
const rowComment = await getRowCommentInRow({ id: rowCommentId, rowId: rowData.item.id });
|
||||
if (!rowComment || (!rowData.rowPermissions.canDelete && rowComment.createdByUserId !== userId)) {
|
||||
return Response.json({ error: t("models.row.activity.errors.deleteCommentForbidden") }, { status: 403 });
|
||||
}
|
||||
await updateRowCommentInRow({ id: rowCommentId, rowId: rowData.item.id }, { isDeleted: true });
|
||||
return Response.json({ deletedComment: rowCommentId });
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "~/modules/codeGenerator/utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized, name } = CodeGeneratorHelper.getNames(entity);
|
||||
const imports: string[] = [
|
||||
`import { LoaderFunctionArgs, ActionFunction, redirect } from "react-router";
|
||||
import { RowPermissionsDto } from "~/application/dtos/entities/RowPermissionsDto";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getEntityByName } from "~/utils/db/entities/entities.db.server";
|
||||
import { getUser } from "~/utils/db/users.db.server";
|
||||
import { getUserRowPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";`,
|
||||
];
|
||||
|
||||
if (entity.hasTasks) {
|
||||
imports.push(
|
||||
`import { RowTaskWithDetails, getRowTasks, createRowTask, getRowTaskInRow, updateRowTaskInRow, deleteRowTaskInRow } from "~/utils/db/entities/rowTasks.db.server";`
|
||||
);
|
||||
}
|
||||
|
||||
imports.push(`import { ${capitalized}Dto } from "../../dtos/${capitalized}Dto";
|
||||
import ${capitalized}Helpers from "../../helpers/${capitalized}Helpers";
|
||||
import { ${capitalized}Service } from "../../services/${capitalized}Service";`);
|
||||
|
||||
let template = `
|
||||
export namespace ${capitalized}RoutesEditApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
item: ${capitalized}Dto;
|
||||
permissions: RowPermissionsDto;
|
||||
{TASKS_LOADER_INTERFACE}
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
const item = await ${capitalized}Service.get(params.id!, {
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!item) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 404 });
|
||||
}
|
||||
const permissions = await getUserRowPermission(item.row, tenantId, userId);
|
||||
if (!permissions.canRead) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: item.prefix + "-" + String(item.row.folio ?? 0).padStart(4, "0") + " | " + process.env.APP_NAME }],
|
||||
item,
|
||||
permissions,
|
||||
{TASKS_LOADER_DATA}
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const user = await getUser(userId);
|
||||
const entity = await getEntityByName({ tenantId, name: "${name}" });
|
||||
const item = await ${capitalized}Service.get(params.id!, {
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!item) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 404 });
|
||||
}
|
||||
if (action === "edit") {
|
||||
try {
|
||||
const { {PROPERTIES_UPDATE_NAMES} } = ${capitalized}Helpers.formToDto(form);
|
||||
await ${capitalized}Service.update(
|
||||
params.id!,
|
||||
{ {PROPERTIES_UPDATE_NAMES} },
|
||||
{ tenantId, userId }
|
||||
);
|
||||
if (item.row.createdByUser) {
|
||||
await NotificationService.send({
|
||||
channel: "my-rows",
|
||||
to: item.row.createdByUser,
|
||||
tenantId,
|
||||
notification: {
|
||||
from: { user },
|
||||
message: ${"`${user?.email} updated ${RowHelper.getRowFolio(entity, item.row)}`"},
|
||||
// action: {
|
||||
// title: t("shared.view"),
|
||||
// url: "",
|
||||
// },
|
||||
},
|
||||
});
|
||||
}
|
||||
return Response.json({ success: t("shared.updated") });
|
||||
} catch (error: any) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
} else if (action === "delete") {
|
||||
try {
|
||||
await ${capitalized}Service.del(params.id!, {
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (item.row.createdByUser) {
|
||||
await NotificationService.send({
|
||||
channel: "my-rows",
|
||||
to: item.row.createdByUser,
|
||||
tenantId,
|
||||
notification: {
|
||||
from: { user },
|
||||
message: ${"`${user?.email} deleted ${RowHelper.getRowFolio(entity, item.row)}`"},
|
||||
},
|
||||
});
|
||||
}
|
||||
return redirect(UrlUtils.getParentRoute(new URL(request.url).pathname));
|
||||
} catch (error: any) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
} {TASKS_ACTIONS}
|
||||
else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}`;
|
||||
|
||||
const propertiesUpdateNames: string[] = [];
|
||||
entity.properties
|
||||
.filter((f) => f.showInCreate && !f.isDefault && CodeGeneratorPropertiesHelper.isWritableProperty(f))
|
||||
.forEach((property) => {
|
||||
propertiesUpdateNames.push(property.name);
|
||||
});
|
||||
template = template.split("{PROPERTIES_UPDATE_NAMES}").join(propertiesUpdateNames.join(", "));
|
||||
|
||||
const tasksLoaderInterface: string[] = [];
|
||||
const tasksLoaderData: string[] = [];
|
||||
if (entity.hasTasks) {
|
||||
tasksLoaderInterface.push("tasks: RowTaskWithDetails[]");
|
||||
tasksLoaderData.push(`tasks: await getRowTasks(params.id!),`);
|
||||
}
|
||||
template = template.split("{TASKS_LOADER_INTERFACE}").join(tasksLoaderInterface.join("\n "));
|
||||
template = template.split("{TASKS_LOADER_DATA}").join(tasksLoaderData.join("\n "));
|
||||
|
||||
let tasksActions = "";
|
||||
if (entity.hasTasks) {
|
||||
tasksActions = `else if (action === "task-new") {
|
||||
const rowPermissions = await getUserRowPermission(item.row, tenantId, userId);
|
||||
if (!rowPermissions.canUpdate) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
const taskTitle = form.get("task-title")?.toString();
|
||||
if (!taskTitle) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
const task = await createRowTask({
|
||||
createdByUserId: userId,
|
||||
rowId: item.row.id,
|
||||
title: taskTitle,
|
||||
});
|
||||
return Response.json({ newTask: task });
|
||||
} else if (action === "task-complete-toggle") {
|
||||
const rowPermissions = await getUserRowPermission(item.row, tenantId, userId);
|
||||
if (!rowPermissions.canUpdate) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
const taskId = form.get("task-id")?.toString() ?? "";
|
||||
const task = await getRowTaskInRow({ id: taskId, rowId: item.row.id });
|
||||
if (!task) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
if (task) {
|
||||
if (task.completed) {
|
||||
await updateRowTaskInRow({ id: taskId, rowId: item.row.id }, {
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
completedByUserId: null,
|
||||
});
|
||||
} else {
|
||||
await updateRowTaskInRow({ id: taskId, rowId: item.row.id }, {
|
||||
completed: true,
|
||||
completedAt: new Date(),
|
||||
completedByUserId: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Response.json({ completedTask: taskId });
|
||||
} else if (action === "task-delete") {
|
||||
const rowPermissions = await getUserRowPermission(item.row, tenantId, userId);
|
||||
if (!rowPermissions.canUpdate) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
const taskId = form.get("task-id")?.toString() ?? "";
|
||||
const task = await getRowTaskInRow({ id: taskId, rowId: item.row.id });
|
||||
if (!task) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
if (task) {
|
||||
await deleteRowTaskInRow({ id: taskId, rowId: item.row.id });
|
||||
}
|
||||
return Response.json({ deletedTask: taskId });
|
||||
}`;
|
||||
}
|
||||
template = template.split("{TASKS_ACTIONS}").join(tasksActions);
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized, name } = CodeGeneratorHelper.getNames(entity);
|
||||
const titleExpression = CodeGeneratorHelper.getEntityTitleExpression(entity);
|
||||
const imports: string[] = [];
|
||||
imports.push(`import { LoaderFunctionArgs } from "react-router";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getEntityByName } from "~/utils/db/entities/entities.db.server";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { requireAuth } from "~/utils/session.server";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";`);
|
||||
imports.push(`import { ${capitalized}Dto } from "../../dtos/${capitalized}Dto";
|
||||
import { ${capitalized}Service } from "../../services/${capitalized}Service";`);
|
||||
|
||||
let template = `
|
||||
export namespace ${capitalized}RoutesIndexApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
items: ${capitalized}Dto[];
|
||||
pagination: PaginationDto;
|
||||
filterableProperties?: FilterablePropertyDto[];
|
||||
overviewItem?: ${capitalized}Dto | null;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
const urlSearchParams = new URL(request.url).searchParams;
|
||||
const { items, pagination } = await ${capitalized}Service.getAll({
|
||||
tenantId,
|
||||
userId,
|
||||
urlSearchParams,
|
||||
});
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: ${titleExpression} + " | " + process.env.APP_NAME }],
|
||||
items,
|
||||
pagination,
|
||||
filterableProperties: EntityHelper.getFilters({ t, entity: await getEntityByName({ tenantId, name: "${name}" }) }),
|
||||
};
|
||||
const overviewId = urlSearchParams.get("overview") ?? "";
|
||||
if (overviewId) {
|
||||
data.overviewItem = await ${capitalized}Service.get(overviewId, {
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
}`;
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "~/modules/codeGenerator/utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized, name } = CodeGeneratorHelper.getNames(entity);
|
||||
const titleExpression = CodeGeneratorHelper.getEntityTitleExpression(entity);
|
||||
const imports: string[] = [];
|
||||
imports.push(`import { LoaderFunctionArgs, ActionFunction, redirect } from "react-router";
|
||||
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getPlanFeatureUsage } from "~/utils/services/.server/subscriptionService";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";`);
|
||||
imports.push(`import ${capitalized}Helpers from "../../helpers/${capitalized}Helpers";
|
||||
import { ${capitalized}Service } from "../../services/${capitalized}Service";`);
|
||||
|
||||
let template = `
|
||||
export namespace ${capitalized}RoutesNewApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
featureUsage: PlanFeatureUsageDto | undefined;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t } = await getTranslations(request);
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: t("shared.create") + " " + ${titleExpression} + " | " + process.env.APP_NAME }],
|
||||
featureUsage: tenantId ? await getPlanFeatureUsage(tenantId, "${name}") : undefined,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString();
|
||||
if (action === "create") {
|
||||
try {
|
||||
const { {PROPERTIES_CREATE_NAMES} } = ${capitalized}Helpers.formToDto(form);
|
||||
{PROPERTIES_CREATE_VALIDATION_REQUIRED}
|
||||
const item = await ${capitalized}Service.create(
|
||||
{ {PROPERTIES_CREATE_NAMES} },
|
||||
{ userId, tenantId }
|
||||
);
|
||||
return redirect(UrlUtils.getParentRoute(new URL(request.url).pathname) + "/" + item.row.id);
|
||||
} catch (error: any) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}`;
|
||||
|
||||
const propertiesCreateNames: string[] = [];
|
||||
const propertiesCreateValidationRequired: string[] = [];
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault && f.showInCreate && CodeGeneratorPropertiesHelper.isWritableProperty(f))
|
||||
.forEach((property) => {
|
||||
propertiesCreateNames.push(property.name);
|
||||
if (property.isRequired) {
|
||||
propertiesCreateValidationRequired.push(
|
||||
`if (${property.name} === undefined) throw new Error(${CodeGeneratorHelper.getPropertyTitleExpression(property)} + " is required");`
|
||||
);
|
||||
}
|
||||
});
|
||||
template = template.split("{PROPERTIES_CREATE_NAMES}").join(propertiesCreateNames.join(", "));
|
||||
template = template.split("{PROPERTIES_CREATE_VALIDATION_REQUIRED}").join(propertiesCreateValidationRequired.join("\n "));
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
let template = `import { Tenant } from "@toolctrl/db/prisma-browser";
|
||||
import { LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import { RowAccess } from "~/application/enums/entities/RowAccess";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { RowPermissionsApi } from "~/utils/api/.server/RowPermissionsApi";
|
||||
import { getUserRowPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import { getTenant, adminGetAllTenants } from "~/utils/db/tenants.db.server";
|
||||
import { UserWithDetails, getUsersByTenant } from "~/utils/db/users.db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { ${capitalized}Dto } from "../../dtos/${capitalized}Dto";
|
||||
import { ${capitalized}Service } from "../../services/${capitalized}Service";
|
||||
|
||||
export namespace ${capitalized}RoutesShareApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
item: ${capitalized}Dto;
|
||||
tenants: Tenant[];
|
||||
users: UserWithDetails[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
const item = await ${capitalized}Service.get(params.id!, {
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!item) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 404 });
|
||||
}
|
||||
const rowPermissions = await getUserRowPermission(item.row, tenantId, userId);
|
||||
if (!rowPermissions.canUpdate) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
let tenants: Tenant[] = [];
|
||||
if (tenantId) {
|
||||
tenants.push((await getTenant(tenantId))!);
|
||||
} else {
|
||||
tenants = await adminGetAllTenants();
|
||||
}
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: t("shared.share") + " | " + process.env.APP_NAME }],
|
||||
item,
|
||||
tenants,
|
||||
users: (await getUsersByTenant(tenantId)).filter((f) => f.id !== userId),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const item = await ${capitalized}Service.get(params.id!, {
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!item) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 404 });
|
||||
}
|
||||
const rowPermissions = await getUserRowPermission(item.row, tenantId, userId);
|
||||
if (!rowPermissions.canUpdate) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
|
||||
if (action === "share") {
|
||||
const allowedShareTypes = ["tenant", "user", "role", "group"] as const;
|
||||
type ShareType = (typeof allowedShareTypes)[number];
|
||||
const maybeType = form.get("type")?.toString();
|
||||
const type = allowedShareTypes.includes(maybeType as ShareType) ? (maybeType as ShareType) : null;
|
||||
if (!type) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const access = form.get("access")?.toString() as RowAccess;
|
||||
try {
|
||||
await RowPermissionsApi.share(item.row, {
|
||||
type,
|
||||
id,
|
||||
access,
|
||||
}, { currentTenantId: tenantId });
|
||||
return Response.json({ success: t("shared.saved") });
|
||||
} catch (error: any) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
} else if (action === "set-access") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const access = form.get("access")?.toString() as RowAccess;
|
||||
await RowPermissionsApi.setAccessForRow(item.row.id, id, access);
|
||||
return Response.json({ success: t("shared.saved") });
|
||||
} else if (action === "remove") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
await RowPermissionsApi.delForRow(item.row.id, id);
|
||||
return Response.json({ success: t("shared.deleted") });
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized, name } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import { EntityTag } from "@toolctrl/db/prisma-browser";
|
||||
import { LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getEntityByName } from "~/utils/db/entities/entities.db.server";
|
||||
import { getEntityTags, getEntityTag, createEntityTag, updateEntityTag, deleteEntityTag, getEntityTagById } from "~/utils/db/entities/entityTags.db.server";
|
||||
import { getRowTag, createRowTag, deleteRowTags } from "~/utils/db/entities/rowTags.db.server";
|
||||
import { getUserRowPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { ${capitalized}Dto } from "../../dtos/${capitalized}Dto";
|
||||
import { ${capitalized}Service } from "../../services/${capitalized}Service";
|
||||
|
||||
export namespace ${capitalized}RoutesTagsApi {
|
||||
export type LoaderData = {
|
||||
metatags: MetaTagsDto;
|
||||
item: ${capitalized}Dto;
|
||||
tags: EntityTag[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
const entity = await getEntityByName({ tenantId, name: "${name}" });
|
||||
const item = await ${capitalized}Service.get(params.id!, {
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!item) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 404 });
|
||||
}
|
||||
const data: LoaderData = {
|
||||
metatags: [{ title: t("models.row.tags") + " | " + process.env.APP_NAME }],
|
||||
item,
|
||||
tags: await getEntityTags(entity.id),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { userId } = await requireAuth({ request, params });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const entity = await getEntityByName({ tenantId, name: "${name}" });
|
||||
const item = await ${capitalized}Service.get(params.id!, {
|
||||
tenantId: await getTenantIdOrNull({ request, params }),
|
||||
userId,
|
||||
});
|
||||
if (!item) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 404 });
|
||||
}
|
||||
const rowPermissions = await getUserRowPermission(item.row, tenantId, userId);
|
||||
if (!rowPermissions.canUpdate) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
const canMutateTagCatalog = !tenantId;
|
||||
if (action === "new-tag") {
|
||||
const value = form.get("tag-name")?.toString() ?? "";
|
||||
const color = Number(form.get("tag-color") ?? Colors.INDIGO);
|
||||
let tag = await getEntityTag(entity.id, value);
|
||||
if (!tag) {
|
||||
if (!canMutateTagCatalog) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 400 });
|
||||
}
|
||||
tag = await createEntityTag({
|
||||
entityId: entity.id,
|
||||
value,
|
||||
color,
|
||||
});
|
||||
}
|
||||
const existingTag = await getRowTag(params.id!, value);
|
||||
if (tag && !existingTag) {
|
||||
await createRowTag({
|
||||
rowId: params.id!,
|
||||
tagId: tag.id,
|
||||
});
|
||||
}
|
||||
return Response.json({});
|
||||
} else if (action === "edit-tag") {
|
||||
if (!canMutateTagCatalog) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
const id = form.get("tag-id")?.toString() ?? "";
|
||||
const value = form.get("tag-name")?.toString() ?? "";
|
||||
const color = Number(form.get("tag-color"));
|
||||
const tag = await getEntityTagById(id, entity.id);
|
||||
if (!tag) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 400 });
|
||||
}
|
||||
await updateEntityTag({ id: tag.id, entityId: entity.id }, {
|
||||
value,
|
||||
color,
|
||||
});
|
||||
return Response.json({});
|
||||
} else if (action === "set-tag") {
|
||||
const id = form.get("tag-id")?.toString() ?? "";
|
||||
const tagAction = form.get("tag-action")?.toString() ?? "";
|
||||
const tag = await getEntityTagById(id, entity.id);
|
||||
if (!tag) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 400 });
|
||||
}
|
||||
if (tagAction === "add") {
|
||||
await createRowTag({
|
||||
rowId: params.id!,
|
||||
tagId: tag.id,
|
||||
});
|
||||
} else {
|
||||
await deleteRowTags(params.id!, tag.id);
|
||||
}
|
||||
return Response.json({});
|
||||
} else if (action === "delete-tag") {
|
||||
if (!canMutateTagCatalog) {
|
||||
return Response.json({ error: t("shared.unauthorized") }, { status: 403 });
|
||||
}
|
||||
const id = form.get("tag-id")?.toString() ?? "";
|
||||
const tag = await getEntityTagById(id, entity.id);
|
||||
if (tag) {
|
||||
await deleteEntityTag({ id: tag.id, entityId: entity.id });
|
||||
}
|
||||
return Response.json({});
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity, moduleLocation }: { entity: EntityWithDetails; moduleLocation: string }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import { MetaFunction, LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import ServerError from "~/components/ui/errors/ServerError";
|
||||
import { ${capitalized}RoutesActivityApi } from "~/${moduleLocation}/routes/api/${capitalized}Routes.Activity.Api";
|
||||
import ${capitalized}RoutesActivityView from "~/${moduleLocation}/routes/views/${capitalized}Routes.Activity.View";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => (data && "metatags" in data ? data.metatags : []);
|
||||
export const loader = (args: LoaderFunctionArgs) => ${capitalized}RoutesActivityApi.loader(args);
|
||||
export const action: ActionFunction = (args) => ${capitalized}RoutesActivityApi.action(args);
|
||||
|
||||
export default () => <${capitalized}RoutesActivityView />;
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ServerError />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity, moduleLocation }: { entity: EntityWithDetails; moduleLocation: string }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import { MetaFunction, LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import ServerError from "~/components/ui/errors/ServerError";
|
||||
import { ${capitalized}RoutesEditApi } from "~/${moduleLocation}/routes/api/${capitalized}Routes.Edit.Api";
|
||||
import ${capitalized}RoutesEditView from "~/${moduleLocation}/routes/views/${capitalized}Routes.Edit.View";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => (data && "metatags" in data ? data.metatags : []);
|
||||
export const loader = (args: LoaderFunctionArgs) => ${capitalized}RoutesEditApi.loader(args);
|
||||
export const action: ActionFunction = (args) => ${capitalized}RoutesEditApi.action(args);
|
||||
|
||||
export default () => <${capitalized}RoutesEditView />;
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ServerError />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity, moduleLocation }: { entity: EntityWithDetails; moduleLocation: string }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import { MetaFunction, LoaderFunctionArgs } from "react-router";
|
||||
import ServerError from "~/components/ui/errors/ServerError";
|
||||
import { ${capitalized}RoutesIndexApi } from "~/${moduleLocation}/routes/api/${capitalized}Routes.Index.Api";
|
||||
import ${capitalized}RoutesIndexView from "~/${moduleLocation}/routes/views/${capitalized}Routes.Index.View";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => (data && "metatags" in data ? data.metatags : []);
|
||||
export const loader = (args: LoaderFunctionArgs) => ${capitalized}RoutesIndexApi.loader(args);
|
||||
|
||||
export default () => <${capitalized}RoutesIndexView />;
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ServerError />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity, moduleLocation }: { entity: EntityWithDetails; moduleLocation: string }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import { MetaFunction, LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import ServerError from "~/components/ui/errors/ServerError";
|
||||
import { ${capitalized}RoutesNewApi } from "~/${moduleLocation}/routes/api/${capitalized}Routes.New.Api";
|
||||
import ${capitalized}RoutesNewView from "~/${moduleLocation}/routes/views/${capitalized}Routes.New.View";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => (data && "metatags" in data ? data.metatags : []);
|
||||
export const loader = (args: LoaderFunctionArgs) => ${capitalized}RoutesNewApi.loader(args);
|
||||
export const action: ActionFunction = (args) => ${capitalized}RoutesNewApi.action(args);
|
||||
|
||||
export default () => <${capitalized}RoutesNewView />;
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ServerError />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity, moduleLocation }: { entity: EntityWithDetails; moduleLocation: string }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import { MetaFunction, LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import ServerError from "~/components/ui/errors/ServerError";
|
||||
import { ${capitalized}RoutesShareApi } from "~/${moduleLocation}/routes/api/${capitalized}Routes.Share.Api";
|
||||
import ${capitalized}RoutesShareView from "~/${moduleLocation}/routes/views/${capitalized}Routes.Share.View";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => (data && "metatags" in data ? data.metatags : []);
|
||||
export const loader = (args: LoaderFunctionArgs) => ${capitalized}RoutesShareApi.loader(args);
|
||||
export const action: ActionFunction = (args) => ${capitalized}RoutesShareApi.action(args);
|
||||
|
||||
export default () => <${capitalized}RoutesShareView />;
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ServerError />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity, moduleLocation }: { entity: EntityWithDetails; moduleLocation: string }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import { MetaFunction, LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import ServerError from "~/components/ui/errors/ServerError";
|
||||
import { ${capitalized}RoutesTagsApi } from "~/${moduleLocation}/routes/api/${capitalized}Routes.Tags.Api";
|
||||
import ${capitalized}RoutesTagsView from "~/${moduleLocation}/routes/views/${capitalized}Routes.Tags.View";
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = ({ data }) => (data && "metatags" in data ? data.metatags : []);
|
||||
export const loader = (args: LoaderFunctionArgs) => ${capitalized}RoutesTagsApi.loader(args);
|
||||
export const action: ActionFunction = (args) => ${capitalized}RoutesTagsApi.action(args);
|
||||
|
||||
export default () => <${capitalized}RoutesTagsView />;
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ServerError />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import RowActivity from "~/components/entities/rows/RowActivity";
|
||||
import { ${capitalized}RoutesActivityApi } from "../api/${capitalized}Routes.Activity.Api";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
export default function ${capitalized}RoutesActivityView() {
|
||||
const data = useLoaderData<${capitalized}RoutesActivityApi.LoaderData>();
|
||||
return <RowActivity items={data.logs} withTitle={false} hasActivity={true} hasComments={data.permissions.canComment} />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
const titleExpression = CodeGeneratorHelper.getEntityTitleExpression(entity);
|
||||
const pluralExpression = CodeGeneratorHelper.getEntityPluralExpression(entity);
|
||||
const imports: string[] = [
|
||||
`import { useActionData, useLoaderData, useOutlet, useNavigate, useLocation, useSearchParams, useSubmit } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RowSettingsTabs from "~/components/entities/rows/RowSettingsTabs";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import ShareIcon from "~/components/ui/icons/ShareIcon";
|
||||
import ClockIcon from "~/components/ui/icons/ClockIcon";
|
||||
import PencilIcon from "~/components/ui/icons/PencilEmptyIcon";`,
|
||||
];
|
||||
|
||||
if (entity.hasTasks) {
|
||||
imports.push(`import RowTasks from "~/components/entities/rows/RowTasks";`);
|
||||
}
|
||||
if (entity.hasTags) {
|
||||
imports.push(`import RowTags from "~/components/entities/rows/RowTags";`);
|
||||
}
|
||||
|
||||
imports.push(`import ${capitalized}Form from "../../components/${capitalized}Form";
|
||||
import { ${capitalized}RoutesEditApi } from "../api/${capitalized}Routes.Edit.Api";`);
|
||||
|
||||
let template = `
|
||||
export default function ${capitalized}RoutesEditView() {
|
||||
const { t } = useTranslation();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const data = useLoaderData<${capitalized}RoutesEditApi.LoaderData>();
|
||||
const actionData = useActionData<${capitalized}RoutesEditApi.ActionData>();
|
||||
const outlet = useOutlet();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const submit = useSubmit();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
function canUpdate() {
|
||||
return data.permissions.canUpdate;
|
||||
}
|
||||
function canDelete() {
|
||||
return data.permissions.canDelete;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditPageLayout
|
||||
title={t("shared.edit") + " " + ${titleExpression}}
|
||||
menu={[
|
||||
{
|
||||
title: ${pluralExpression},
|
||||
routePath: UrlUtils.getParentRoute(location?.pathname),
|
||||
},
|
||||
{
|
||||
title: t("shared.edit"),
|
||||
routePath: "",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="relative items-center justify-between space-y-2 border-b border-border pb-4 sm:flex sm:space-y-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xl font-bold uppercase">
|
||||
{data.item.prefix}-{String(data.item.row.folio ?? 0).padStart(4, "0")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<ButtonSecondary to="activity">
|
||||
<ClockIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</ButtonSecondary>
|
||||
{data.permissions.isOwner && (
|
||||
<ButtonSecondary to="share">
|
||||
<ShareIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
{canUpdate() && (
|
||||
<ButtonSecondary
|
||||
onClick={() => {
|
||||
if (searchParams.get("view") === "edit") {
|
||||
setSearchParams({});
|
||||
} else {
|
||||
searchParams.set("view", "edit");
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!data.item ? (
|
||||
<div>{t("shared.loading")}...</div>
|
||||
) : (
|
||||
<div className="mx-auto space-y-2 pt-2 lg:flex lg:space-x-4 lg:space-y-0 pb-10">
|
||||
<div className="space-y-4 lg:w-4/6">
|
||||
<${capitalized}Form
|
||||
item={data.item}
|
||||
actionData={actionData}
|
||||
canUpdate={canUpdate()}
|
||||
canDelete={canDelete()}
|
||||
isUpdating={searchParams.get("view") === "edit"}
|
||||
onCancel={() => {
|
||||
setSearchParams({});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:w-2/6">
|
||||
${entity.hasTags ? `<RowTags items={data.item.row.tags} onSetTagsRoute={canUpdate() ? "tags" : undefined} />` : ""}
|
||||
${entity.hasTasks ? `<RowTasks items={data.tasks} />` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SlideOverWideEmpty
|
||||
withTitle={false}
|
||||
withClose={false}
|
||||
title={""}
|
||||
open={!!outlet}
|
||||
onClose={() => {
|
||||
navigate(".", { replace: true });
|
||||
}}
|
||||
className="sm:max-w-sm"
|
||||
overflowYScroll={true}
|
||||
>
|
||||
<div className="-mx-1 -mt-3">
|
||||
<div className="space-y-4">
|
||||
<RowSettingsTabs canUpdate={canUpdate()} isOwner={data.permissions.isOwner} hasTags={${entity.hasTags ? "true" : "false"}} />
|
||||
{outlet}
|
||||
</div>
|
||||
</div>
|
||||
</SlideOverWideEmpty>
|
||||
</EditPageLayout>
|
||||
);
|
||||
}`;
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import CodeGeneratorPropertiesHelper from "~/modules/codeGenerator/utils/CodeGeneratorPropertiesHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
const titleExpression = CodeGeneratorHelper.getEntityTitleExpression(entity);
|
||||
const pluralExpression = CodeGeneratorHelper.getEntityPluralExpression(entity);
|
||||
const imports: string[] = [];
|
||||
imports.push(`import { useActionData, useLoaderData, useSearchParams, Link } from "react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RowCreatedByCell from "~/components/entities/rows/cells/RowCreatedByCell";
|
||||
import RowFolioCell from "~/components/entities/rows/cells/RowFolioCell";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import DateCell from "~/components/ui/dates/DateCell";
|
||||
import ExternalLinkEmptyIcon from "~/components/ui/icons/ExternalLinkEmptyIcon";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import IndexPageLayout from "~/components/ui/layouts/IndexPageLayout";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";`);
|
||||
imports.push(`import ${capitalized}Form from "../../components/${capitalized}Form";
|
||||
import { ${capitalized}Dto } from "../../dtos/${capitalized}Dto";
|
||||
import { ${capitalized}RoutesIndexApi } from "../api/${capitalized}Routes.Index.Api";`);
|
||||
|
||||
let template = `
|
||||
export default function ${capitalized}RoutesIndexView() {
|
||||
const { t } = useTranslation();
|
||||
const data = useLoaderData<${capitalized}RoutesIndexApi.LoaderData>();
|
||||
const actionData = useActionData<{ error?: string; success?: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [overviewItem, setOverviewItem] = useState<${capitalized}Dto>();
|
||||
|
||||
useEffect(() => {
|
||||
setOverviewItem(data.overviewItem ?? undefined);
|
||||
}, [data.overviewItem]);
|
||||
|
||||
return (
|
||||
<IndexPageLayout
|
||||
title={${pluralExpression}}
|
||||
buttons={
|
||||
<>
|
||||
{!!data.filterableProperties?.length && <InputFilters filters={data.filterableProperties} />}
|
||||
<ButtonPrimary to="new">{t("shared.new")}</ButtonPrimary>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TableSimple
|
||||
items={data.items}
|
||||
pagination={data.pagination}
|
||||
actions={[
|
||||
{
|
||||
title: t("shared.overview"),
|
||||
onClick: (_, item) => {
|
||||
searchParams.set("overview", item.row.id);
|
||||
setSearchParams(searchParams);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("shared.edit"),
|
||||
onClickRoute: (_, item) => item.row.id,
|
||||
},
|
||||
]}
|
||||
headers={[
|
||||
{
|
||||
name: "folio",
|
||||
title: t("models.row.folio"),
|
||||
value: (item) => <RowFolioCell prefix={item.prefix} folio={item.row.folio} href={item.row.id} />,
|
||||
},
|
||||
{PROPERTIES_UI_CELLS}
|
||||
{
|
||||
name: "createdAt",
|
||||
title: t("shared.createdAt"),
|
||||
value: (item) => <DateCell date={item.row.createdAt} />,
|
||||
className: "text-muted-foreground text-xs",
|
||||
breakpoint: "sm",
|
||||
},
|
||||
{
|
||||
name: "createdByUser",
|
||||
title: t("shared.createdBy"),
|
||||
value: (item) => <RowCreatedByCell user={item.row.createdByUser} apiKey={item.row.createdByApiKey} />,
|
||||
className: "text-muted-foreground text-xs",
|
||||
breakpoint: "sm",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SlideOverWideEmpty
|
||||
withTitle={false}
|
||||
withClose={false}
|
||||
title={${titleExpression}}
|
||||
open={!!searchParams.get("overview")?.toString()}
|
||||
onClose={() => {
|
||||
searchParams.delete("overview");
|
||||
setSearchParams(searchParams);
|
||||
setTimeout(() => {
|
||||
setOverviewItem(undefined);
|
||||
}, 100);
|
||||
}}
|
||||
className="sm:max-w-md"
|
||||
buttons={
|
||||
<>
|
||||
<Link
|
||||
to={overviewItem?.row.id ?? ""}
|
||||
className="rounded-md bg-background text-muted-foreground hover:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">{t("shared.openDetails")}</span>
|
||||
<ExternalLinkEmptyIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!overviewItem ? <div>{t("shared.loading")}...</div> : <${capitalized}Form item={overviewItem} actionData={actionData} />}
|
||||
</SlideOverWideEmpty>
|
||||
</IndexPageLayout>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const uiCellControls: string[] = [];
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault)
|
||||
.forEach((property) => {
|
||||
CodeGeneratorPropertiesHelper.uiCell({ code: uiCellControls, imports, property });
|
||||
});
|
||||
template = template.replace("{PROPERTIES_UI_CELLS}", uiCellControls.join("\n "));
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
const titleExpression = CodeGeneratorHelper.getEntityTitleExpression(entity);
|
||||
const pluralExpression = CodeGeneratorHelper.getEntityPluralExpression(entity);
|
||||
return `import { useActionData, useLoaderData, useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckPlanFeatureLimit from "~/components/core/settings/subscription/CheckPlanFeatureLimit";
|
||||
import NewPageLayout from "~/components/ui/layouts/NewPageLayout";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import ${capitalized}Form from "../../components/${capitalized}Form";
|
||||
import { ${capitalized}RoutesNewApi } from "../api/${capitalized}Routes.New.Api";
|
||||
|
||||
export default function ${capitalized}RoutesNewView() {
|
||||
const { t } = useTranslation();
|
||||
const data = useLoaderData<${capitalized}RoutesNewApi.LoaderData>();
|
||||
const actionData = useActionData<${capitalized}RoutesNewApi.ActionData>();
|
||||
const location = useLocation();
|
||||
return (
|
||||
<NewPageLayout
|
||||
title={t("shared.create") + " " + ${titleExpression}}
|
||||
menu={[
|
||||
{
|
||||
title: ${pluralExpression},
|
||||
routePath: UrlUtils.getParentRoute(location.pathname),
|
||||
},
|
||||
{
|
||||
title: t("shared.create"),
|
||||
routePath: "",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<CheckPlanFeatureLimit item={data.featureUsage}>
|
||||
<${capitalized}Form isCreating={true} actionData={actionData} />
|
||||
</CheckPlanFeatureLimit>
|
||||
</div>
|
||||
</NewPageLayout>
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import RowSettingsPermissions from "~/components/entities/rows/RowSettingsPermissions";
|
||||
import { ${capitalized}RoutesShareApi } from "../api/${capitalized}Routes.Share.Api";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
export default function ${capitalized}RoutesShareView() {
|
||||
const data = useLoaderData<${capitalized}RoutesShareApi.LoaderData>();
|
||||
return <RowSettingsPermissions item={data.item.row} items={data.item.row.permissions} tenants={data.tenants} users={data.users} />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized } = CodeGeneratorHelper.getNames(entity);
|
||||
return `import RowSettingsTags from "~/components/entities/rows/RowSettingsTags";
|
||||
import { ${capitalized}RoutesTagsApi } from "../api/${capitalized}Routes.Tags.Api";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
export default function ${capitalized}RoutesTagsView() {
|
||||
const data = useLoaderData<${capitalized}RoutesTagsApi.LoaderData>();
|
||||
return <RowSettingsTags item={data.item.row} tags={data.tags} />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "../../utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized, name } = CodeGeneratorHelper.getNames(entity);
|
||||
|
||||
const code: string[] = [
|
||||
`model ${capitalized} {
|
||||
id String @id @unique
|
||||
row Row @relation(fields: [id], references: [id], onDelete: Cascade)`,
|
||||
];
|
||||
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault)
|
||||
.forEach((property) => {
|
||||
CodeGeneratorPropertiesHelper.schema({ code, property });
|
||||
});
|
||||
|
||||
code.push("}");
|
||||
|
||||
code.push("");
|
||||
|
||||
code.push(`// Add at the end of the Row model
|
||||
model Row {
|
||||
// ...
|
||||
${name} ${capitalized}?
|
||||
}`);
|
||||
|
||||
return [...code].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { PropertyType } from "~/application/enums/entities/PropertyType";
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "../../utils/CodeGeneratorPropertiesHelper";
|
||||
|
||||
function generate({ entity }: { entity: EntityWithDetails }): string {
|
||||
const { capitalized, name } = CodeGeneratorHelper.getNames(entity);
|
||||
const imports: string[] = [];
|
||||
imports.push(`import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { getEntityByName } from "~/utils/db/entities/entities.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowValueService, { RowValueUpdateDto } from "~/utils/helpers/.server/RowValueService";`);
|
||||
|
||||
if (entity.properties.some((p) => p.type === PropertyType.MEDIA)) {
|
||||
imports.push(`import { RowMedia } from "@toolctrl/db/prisma-browser";`);
|
||||
}
|
||||
|
||||
imports.push(`import { ${capitalized}Dto } from "../dtos/${capitalized}Dto";
|
||||
import { ${capitalized}CreateDto } from "../dtos/${capitalized}CreateDto";
|
||||
import ${capitalized}Helpers from "../helpers/${capitalized}Helpers";`);
|
||||
|
||||
let template = `
|
||||
export namespace ${capitalized}Service {
|
||||
async function getEntity(tenantId: string | null) {
|
||||
let entityName = "${name}";
|
||||
const entity = await getEntityByName({ tenantId, name: entityName });
|
||||
if (!entity) {
|
||||
throw Error("Entity not found: " + entityName);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
export async function getAll({
|
||||
tenantId,
|
||||
userId,
|
||||
urlSearchParams,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
userId?: string;
|
||||
urlSearchParams?: URLSearchParams;
|
||||
}): Promise<{ items: ${capitalized}Dto[]; pagination: PaginationDto }> {
|
||||
const entity = await getEntity(tenantId);
|
||||
const data = await RowsApi.getAll({
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
urlSearchParams,
|
||||
});
|
||||
return {
|
||||
items: data.items.map((row) => ${capitalized}Helpers.rowToDto({ entity, row })),
|
||||
pagination: data.pagination,
|
||||
};
|
||||
}
|
||||
export async function get(id: string, session: { tenantId: string | null; userId?: string }): Promise<${capitalized}Dto | null> {
|
||||
const entity = await getEntity(session.tenantId);
|
||||
const { item, rowPermissions } = await RowsApi.get(id, {
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId,
|
||||
entity,
|
||||
});
|
||||
if (!rowPermissions.canRead) {
|
||||
return null;
|
||||
}
|
||||
return ${capitalized}Helpers.rowToDto({ entity, row: item });
|
||||
}
|
||||
export async function create(data: ${capitalized}CreateDto, session: { tenantId: string | null; userId?: string }): Promise<${capitalized}Dto> {
|
||||
const entity = await getEntity(session.tenantId);
|
||||
const rowValues = RowHelper.getRowPropertiesFromForm({
|
||||
entity,
|
||||
values: [
|
||||
{PROPERTIES_CREATE_VALUES}
|
||||
],
|
||||
});
|
||||
const item = await RowsApi.create({
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId,
|
||||
entity,
|
||||
rowValues,
|
||||
});
|
||||
return ${capitalized}Helpers.rowToDto({ entity, row: item });
|
||||
}
|
||||
export async function update(id: string, data: Partial<${capitalized}Dto>, session: { tenantId: string | null; userId?: string }): Promise<${capitalized}Dto> {
|
||||
const entity = await getEntity(session.tenantId);
|
||||
const { item: row, rowPermissions } = await RowsApi.get(id, {
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId,
|
||||
entity,
|
||||
});
|
||||
if (!row || !rowPermissions.canUpdate) {
|
||||
throw Error("Not found");
|
||||
}
|
||||
const values: RowValueUpdateDto[] = [];
|
||||
{PROPERTIES_UPDATE_VALUES}
|
||||
const item = await RowValueService.update({
|
||||
entity,
|
||||
row,
|
||||
values,
|
||||
session,
|
||||
});
|
||||
return ${capitalized}Helpers.rowToDto({ entity, row: item });
|
||||
}
|
||||
export async function del(id: string, session: { tenantId: string | null; userId?: string }): Promise<void> {
|
||||
const entity = await getEntity(session.tenantId);
|
||||
await RowsApi.del(id, {
|
||||
entity,
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId,
|
||||
});
|
||||
}
|
||||
}`;
|
||||
|
||||
const createValues: string[] = [];
|
||||
const updateValues: string[] = [];
|
||||
entity.properties
|
||||
.filter((f) => !f.isDefault)
|
||||
.forEach((property) => {
|
||||
if (property.showInCreate) {
|
||||
CodeGeneratorPropertiesHelper.createDtoToRow({ code: createValues, property });
|
||||
}
|
||||
CodeGeneratorPropertiesHelper.updateDtoToRow({ code: updateValues, property });
|
||||
});
|
||||
template = template.replace("{PROPERTIES_CREATE_VALUES}", createValues.join("\n "));
|
||||
template = template.replace("{PROPERTIES_UPDATE_VALUES}", updateValues.join("\n "));
|
||||
|
||||
const uniqueImports = [...new Set(imports)];
|
||||
return [...uniqueImports, template].join("\n");
|
||||
}
|
||||
|
||||
export default {
|
||||
generate,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
const TRANSLATION_KEY_PATTERN = /^[a-z0-9]+(?:\.[A-Za-z0-9_-]+)+$/;
|
||||
|
||||
function isTranslationKey(value: string) {
|
||||
return TRANSLATION_KEY_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function toGeneratedString(value: string) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function getTranslationExpression(value: string, fallbackKey: string) {
|
||||
if (isTranslationKey(value)) {
|
||||
return `t(${toGeneratedString(value)})`;
|
||||
}
|
||||
return `t(${toGeneratedString(fallbackKey)}, { defaultValue: ${toGeneratedString(value)} })`;
|
||||
}
|
||||
|
||||
function getEntityTitleExpression(entity: EntityWithDetails) {
|
||||
return getTranslationExpression(entity.title, `generated.entities.${entity.name}.title`);
|
||||
}
|
||||
|
||||
function getEntityPluralExpression(entity: EntityWithDetails) {
|
||||
return getTranslationExpression(entity.titlePlural, `generated.entities.${entity.name}.titlePlural`);
|
||||
}
|
||||
|
||||
function getPropertyTitleExpression(property: { name: string; title: string }) {
|
||||
return getTranslationExpression(property.title, `generated.fields.${property.name}`);
|
||||
}
|
||||
|
||||
function formatRawLabel(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function getRawLabelWarnings(entity: EntityWithDetails) {
|
||||
const rawLabels = [
|
||||
!isTranslationKey(entity.title) ? `entity title "${formatRawLabel(entity.title)}"` : undefined,
|
||||
!isTranslationKey(entity.titlePlural) ? `entity plural "${formatRawLabel(entity.titlePlural)}"` : undefined,
|
||||
...entity.properties.map((property) => (!isTranslationKey(property.title) ? `property ${property.name} title "${formatRawLabel(property.title)}"` : undefined)),
|
||||
].filter((label): label is string => Boolean(label));
|
||||
|
||||
if (rawLabels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [`Generator warning: raw labels are emitted with stable fallback i18n keys; add locale keys for ${rawLabels.join(", ")}.`];
|
||||
}
|
||||
|
||||
function getNames(entity: EntityWithDetails) {
|
||||
return {
|
||||
capitalized: entity.name.charAt(0).toUpperCase() + entity.name.slice(1),
|
||||
name: entity.name,
|
||||
title: entity.title,
|
||||
plural: entity.titlePlural,
|
||||
slug: entity.slug,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
getNames,
|
||||
getEntityTitleExpression,
|
||||
getEntityPluralExpression,
|
||||
getPropertyTitleExpression,
|
||||
getRawLabelWarnings,
|
||||
getTranslationExpression,
|
||||
isTranslationKey,
|
||||
};
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PropertyType } from "~/application/enums/entities/PropertyType";
|
||||
import type { PropertyWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import CodeGeneratorPropertiesHelper from "./CodeGeneratorPropertiesHelper";
|
||||
|
||||
function createProperty(type: PropertyType, patch: Partial<PropertyWithDetails> = {}): PropertyWithDetails {
|
||||
return {
|
||||
name: `field${type}`,
|
||||
title: `entities.fields.field${type}`,
|
||||
type,
|
||||
subtype: null,
|
||||
isDefault: false,
|
||||
isRequired: false,
|
||||
showInCreate: true,
|
||||
attributes: [],
|
||||
options: [],
|
||||
formula: null,
|
||||
...patch,
|
||||
} as unknown as PropertyWithDetails;
|
||||
}
|
||||
|
||||
describe("CodeGeneratorPropertiesHelper", () => {
|
||||
it("does not emit unresolved placeholder markers for any declared property type", () => {
|
||||
const propertyTypes = Object.values(PropertyType).filter((value): value is PropertyType => typeof value === "number");
|
||||
|
||||
for (const propertyType of propertyTypes) {
|
||||
const property = createProperty(propertyType);
|
||||
const imports: string[] = [];
|
||||
const code: string[] = [];
|
||||
|
||||
CodeGeneratorPropertiesHelper.type({ code, imports, property });
|
||||
CodeGeneratorPropertiesHelper.rowToDto({ code, property });
|
||||
CodeGeneratorPropertiesHelper.formToDto({ code, property });
|
||||
CodeGeneratorPropertiesHelper.createDtoToRow({ code, property });
|
||||
CodeGeneratorPropertiesHelper.updateDtoToRow({ code, property });
|
||||
CodeGeneratorPropertiesHelper.uiForm({ code, imports, property, index: 0 });
|
||||
CodeGeneratorPropertiesHelper.uiCell({ code, imports, property });
|
||||
CodeGeneratorPropertiesHelper.schema({ code, property });
|
||||
|
||||
const generated = code.join("\n");
|
||||
expect(generated).not.toContain("TODO");
|
||||
expect(generated).not.toContain("not implemented");
|
||||
expect(generated.trim()).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps complex generated schema properties to valid Prisma scalar types", () => {
|
||||
const complexTypes = [
|
||||
PropertyType.MEDIA,
|
||||
PropertyType.MULTI_SELECT,
|
||||
PropertyType.MULTI_TEXT,
|
||||
PropertyType.RANGE_NUMBER,
|
||||
PropertyType.RANGE_DATE,
|
||||
];
|
||||
|
||||
for (const propertyType of complexTypes) {
|
||||
const code: string[] = [];
|
||||
CodeGeneratorPropertiesHelper.schema({ code, property: createProperty(propertyType) });
|
||||
|
||||
expect(code.join("\n")).toContain(" Json?");
|
||||
expect(code.join("\n")).not.toContain("TODO");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat raw English labels as direct i18n keys", () => {
|
||||
const property = createProperty(PropertyType.TEXT, {
|
||||
name: "firstName",
|
||||
title: "First Name",
|
||||
});
|
||||
const imports: string[] = [];
|
||||
const formCode: string[] = [];
|
||||
const cellCode: string[] = [];
|
||||
|
||||
CodeGeneratorPropertiesHelper.uiForm({ code: formCode, imports, property, index: 0 });
|
||||
CodeGeneratorPropertiesHelper.uiCell({ code: cellCode, imports, property });
|
||||
|
||||
expect(formCode.join("\n")).toContain('t("generated.fields.firstName", { defaultValue: "First Name" })');
|
||||
expect(cellCode.join("\n")).toContain('t("generated.fields.firstName", { defaultValue: "First Name" })');
|
||||
expect(formCode.join("\n")).not.toContain('t("First Name")');
|
||||
expect(cellCode.join("\n")).not.toContain('t("First Name")');
|
||||
});
|
||||
|
||||
it("keeps explicit i18n keys as translation keys", () => {
|
||||
const property = createProperty(PropertyType.TEXT, {
|
||||
name: "firstName",
|
||||
title: "models.employee.firstName",
|
||||
});
|
||||
const imports: string[] = [];
|
||||
const formCode: string[] = [];
|
||||
|
||||
CodeGeneratorPropertiesHelper.uiForm({ code: formCode, imports, property, index: 0 });
|
||||
|
||||
expect(formCode.join("\n")).toContain('t("models.employee.firstName")');
|
||||
expect(formCode.join("\n")).not.toContain("{ defaultValue:");
|
||||
});
|
||||
|
||||
it("treats formula as a read-only computed field in generated surfaces", () => {
|
||||
const property = createProperty(PropertyType.FORMULA, {
|
||||
name: "computedScore",
|
||||
title: "entities.fields.computedScore",
|
||||
formula: { name: "Score", resultAs: "number", calculationTrigger: "manual" },
|
||||
});
|
||||
const imports: string[] = [];
|
||||
const typeCode: string[] = [];
|
||||
const rowCode: string[] = [];
|
||||
const formCode: string[] = [];
|
||||
const createCode: string[] = [];
|
||||
const updateCode: string[] = [];
|
||||
const formUiCode: string[] = [];
|
||||
const cellCode: string[] = [];
|
||||
const schemaCode: string[] = [];
|
||||
|
||||
CodeGeneratorPropertiesHelper.type({ code: typeCode, imports, property });
|
||||
CodeGeneratorPropertiesHelper.rowToDto({ code: rowCode, property });
|
||||
CodeGeneratorPropertiesHelper.formToDto({ code: formCode, property });
|
||||
CodeGeneratorPropertiesHelper.createDtoToRow({ code: createCode, property });
|
||||
CodeGeneratorPropertiesHelper.updateDtoToRow({ code: updateCode, property });
|
||||
CodeGeneratorPropertiesHelper.uiForm({ code: formUiCode, imports, property, index: 0 });
|
||||
CodeGeneratorPropertiesHelper.uiCell({ code: cellCode, imports, property });
|
||||
CodeGeneratorPropertiesHelper.schema({ code: schemaCode, property });
|
||||
|
||||
expect(typeCode.join("\n")).toContain("computedScore: number | undefined");
|
||||
expect(rowCode.join("\n")).toContain('RowValueHelper.getNumber({ entity, row, name: "computedScore" })');
|
||||
expect(formCode).toEqual([]);
|
||||
expect(createCode).toEqual([]);
|
||||
expect(updateCode).toEqual([]);
|
||||
expect(formUiCode.join("\n")).not.toContain('name="computedScore"');
|
||||
expect(cellCode.join("\n")).toContain("RowNumberCell");
|
||||
expect(schemaCode.join("\n")).toContain("computed Formula field");
|
||||
expect(schemaCode.join("\n")).not.toContain("Json?");
|
||||
});
|
||||
|
||||
it("maps formula resultAs variants to stable DTO display types", () => {
|
||||
const cases = [
|
||||
["number", "number | undefined", "getNumber", "RowNumberCell"],
|
||||
["string", "string | undefined", "getText", "truncate"],
|
||||
["date", "Date | undefined", "getDate", "RowDateCell"],
|
||||
["boolean", "boolean | undefined", "getBoolean", "RowBooleanCell"],
|
||||
] as const;
|
||||
|
||||
for (const [resultAs, dtoType, rowHelper, cellMarker] of cases) {
|
||||
const property = createProperty(PropertyType.FORMULA, {
|
||||
name: `computed${resultAs}`,
|
||||
formula: { name: resultAs, resultAs, calculationTrigger: "manual" },
|
||||
});
|
||||
const imports: string[] = [];
|
||||
const typeCode: string[] = [];
|
||||
const rowCode: string[] = [];
|
||||
const cellCode: string[] = [];
|
||||
|
||||
CodeGeneratorPropertiesHelper.type({ code: typeCode, imports, property });
|
||||
CodeGeneratorPropertiesHelper.rowToDto({ code: rowCode, property });
|
||||
CodeGeneratorPropertiesHelper.uiCell({ code: cellCode, imports, property });
|
||||
|
||||
expect(typeCode.join("\n")).toContain(dtoType);
|
||||
expect(rowCode.join("\n")).toContain(`RowValueHelper.${rowHelper}`);
|
||||
expect(cellCode.join("\n")).toContain(cellMarker);
|
||||
}
|
||||
});
|
||||
});
|
||||
+689
@@ -0,0 +1,689 @@
|
||||
import { PropertyAttributeName } from "~/application/enums/entities/PropertyAttributeName";
|
||||
import { PropertyType } from "~/application/enums/entities/PropertyType";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import CodeGeneratorHelper from "~/modules/codeGenerator/utils/CodeGeneratorHelper";
|
||||
import { PropertyWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import PropertyAttributeHelper from "~/utils/helpers/PropertyAttributeHelper";
|
||||
import { SelectOptionsDisplay } from "~/utils/shared/SelectOptionsUtils";
|
||||
|
||||
function getPropertyTypeLabel(property: PropertyWithDetails) {
|
||||
return PropertyType[property.type] ?? `UNKNOWN_${property.type}`;
|
||||
}
|
||||
|
||||
function unsupportedGeneratedComment(property: PropertyWithDetails, surface: string) {
|
||||
return `Unsupported generated ${surface} for ${property.name} (${getPropertyTypeLabel(property)})`;
|
||||
}
|
||||
|
||||
function isWritableProperty(property: PropertyWithDetails) {
|
||||
return property.type !== PropertyType.FORMULA;
|
||||
}
|
||||
|
||||
function getFormulaResultAs(property: PropertyWithDetails) {
|
||||
const resultAs = property.formula?.resultAs;
|
||||
if (resultAs === "number" || resultAs === "string" || resultAs === "date" || resultAs === "boolean") {
|
||||
return resultAs;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function getFormulaType(property: PropertyWithDetails) {
|
||||
const resultAs = getFormulaResultAs(property);
|
||||
if (resultAs === "number") {
|
||||
return "number";
|
||||
}
|
||||
if (resultAs === "string") {
|
||||
return "string";
|
||||
}
|
||||
if (resultAs === "date") {
|
||||
return "Date";
|
||||
}
|
||||
if (resultAs === "boolean") {
|
||||
return "boolean";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function type({ code, imports, property }: { code: string[]; imports: string[]; property: PropertyWithDetails }) {
|
||||
let type = "";
|
||||
if (property.type === PropertyType.TEXT) {
|
||||
type = property.isRequired ? "string" : "string | undefined";
|
||||
} else if (property.type === PropertyType.NUMBER) {
|
||||
type = property.isRequired ? "number" : "number | undefined";
|
||||
} else if (property.type === PropertyType.DATE) {
|
||||
type = property.isRequired ? "Date" : "Date | undefined";
|
||||
} else if (property.type === PropertyType.SELECT) {
|
||||
type = property.isRequired ? "string" : "string | undefined";
|
||||
} else if (property.type === PropertyType.BOOLEAN) {
|
||||
type = property.isRequired ? "boolean" : "boolean | undefined";
|
||||
} else if (property.type === PropertyType.MEDIA) {
|
||||
imports.push(`import { MediaDto } from "~/application/dtos/entities/MediaDto";`);
|
||||
if (PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) === 1) {
|
||||
type = property.isRequired ? "MediaDto" : "MediaDto | undefined";
|
||||
} else {
|
||||
type = property.isRequired ? "MediaDto[]" : "MediaDto[] | undefined";
|
||||
}
|
||||
} else if (property.type === PropertyType.MULTI_SELECT) {
|
||||
imports.push(`import { RowValueMultipleDto } from "~/application/dtos/entities/RowValueMultipleDto";`);
|
||||
type = "RowValueMultipleDto[]";
|
||||
} else if (property.type === PropertyType.RANGE_NUMBER) {
|
||||
imports.push(`import { RowValueRangeDto } from "~/application/dtos/entities/RowValueRangeDto";`);
|
||||
type = property.isRequired ? "RowValueRangeDto" : "RowValueRangeDto | undefined";
|
||||
} else if (property.type === PropertyType.RANGE_DATE) {
|
||||
imports.push(`import { RowValueRangeDto } from "~/application/dtos/entities/RowValueRangeDto";`);
|
||||
type = property.isRequired ? "RowValueRangeDto" : "RowValueRangeDto | undefined";
|
||||
} else if (property.type === PropertyType.MULTI_TEXT) {
|
||||
imports.push(`import { RowValueMultipleDto } from "~/application/dtos/entities/RowValueMultipleDto";`);
|
||||
type = "RowValueMultipleDto[]";
|
||||
} else if (property.type === PropertyType.FORMULA) {
|
||||
const formulaType = getFormulaType(property);
|
||||
type = property.isRequired ? formulaType : `${formulaType} | undefined`;
|
||||
} else {
|
||||
type = "unknown";
|
||||
}
|
||||
|
||||
let comments: string[] = [];
|
||||
comments.push(property.isRequired ? "required" : "optional");
|
||||
property.attributes
|
||||
.filter((f) => f.value)
|
||||
.forEach((attribute) => {
|
||||
comments.push(`${attribute.name}: ${attribute.value}`);
|
||||
});
|
||||
|
||||
code.push(` ${property.name}: ${type}; // ${comments.join(", ")}`);
|
||||
}
|
||||
|
||||
function rowToDto({ code, property }: { code: string[]; property: PropertyWithDetails }) {
|
||||
let propertyCode = "";
|
||||
let comments: string[] = [];
|
||||
comments.push(property.isRequired ? "required" : "optional");
|
||||
if (property.type === PropertyType.TEXT) {
|
||||
let fallbackValue = property.isRequired ? ` ?? ""` : ``;
|
||||
propertyCode = `${property.name}: RowValueHelper.getText({ entity, row, name: "${property.name}" })${fallbackValue},`;
|
||||
} else if (property.type === PropertyType.NUMBER) {
|
||||
let fallbackValue = property.isRequired ? ` ?? 0` : ``;
|
||||
propertyCode = `${property.name}: RowValueHelper.getNumber({ entity, row, name: "${property.name}" })${fallbackValue},`;
|
||||
} else if (property.type === PropertyType.DATE) {
|
||||
let fallbackValue = property.isRequired ? ` ?? new Date()` : ``;
|
||||
propertyCode = `${property.name}: RowValueHelper.getDate({ entity, row, name: "${property.name}" })${fallbackValue},`;
|
||||
} else if (property.type === PropertyType.SELECT) {
|
||||
let fallbackValue = property.isRequired ? ` ?? ""` : ``;
|
||||
propertyCode = `${property.name}: RowValueHelper.getText({ entity, row, name: "${property.name}" })${fallbackValue},`;
|
||||
} else if (property.type === PropertyType.BOOLEAN) {
|
||||
propertyCode = `${property.name}: RowValueHelper.getBoolean({ entity, row, name: "${property.name}" }) ?? false,`;
|
||||
} else if (property.type === PropertyType.MEDIA) {
|
||||
if (PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) === 1) {
|
||||
if (property.isRequired) {
|
||||
propertyCode = `${property.name}: RowValueHelper.getFirstMedia({ entity, row, name: "${property.name}" }) as MediaDto,`;
|
||||
} else {
|
||||
propertyCode = `${property.name}: RowValueHelper.getFirstMedia({ entity, row, name: "${property.name}" }),`;
|
||||
}
|
||||
} else {
|
||||
propertyCode = `${property.name}: RowValueHelper.getMedia({ entity, row, name: "${property.name}" }),`;
|
||||
}
|
||||
} else if (property.type === PropertyType.MULTI_SELECT) {
|
||||
propertyCode = `${property.name}: RowValueHelper.getMultiple({ entity, row, name: "${property.name}" }),`;
|
||||
} else if (property.type === PropertyType.MULTI_TEXT) {
|
||||
propertyCode = `${property.name}: RowValueHelper.getMultiple({ entity, row, name: "${property.name}" }),`;
|
||||
} else if (property.type === PropertyType.RANGE_NUMBER) {
|
||||
propertyCode = `${property.name}: RowValueHelper.getNumberRange({ entity, row, name: "${property.name}" }),`;
|
||||
} else if (property.type === PropertyType.RANGE_DATE) {
|
||||
propertyCode = `${property.name}: RowValueHelper.getDateRange({ entity, row, name: "${property.name}" }),`;
|
||||
} else if (property.type === PropertyType.FORMULA) {
|
||||
const resultAs = getFormulaResultAs(property);
|
||||
if (resultAs === "number") {
|
||||
let fallbackValue = property.isRequired ? ` ?? 0` : ``;
|
||||
propertyCode = `${property.name}: RowValueHelper.getNumber({ entity, row, name: "${property.name}" })${fallbackValue},`;
|
||||
} else if (resultAs === "string") {
|
||||
let fallbackValue = property.isRequired ? ` ?? ""` : ``;
|
||||
propertyCode = `${property.name}: RowValueHelper.getText({ entity, row, name: "${property.name}" })${fallbackValue},`;
|
||||
} else if (resultAs === "date") {
|
||||
let fallbackValue = property.isRequired ? ` ?? new Date()` : ``;
|
||||
propertyCode = `${property.name}: RowValueHelper.getDate({ entity, row, name: "${property.name}" })${fallbackValue},`;
|
||||
} else if (resultAs === "boolean") {
|
||||
propertyCode = `${property.name}: RowValueHelper.getBoolean({ entity, row, name: "${property.name}" }) ?? false,`;
|
||||
} else {
|
||||
propertyCode = `${property.name}: undefined, // ${unsupportedGeneratedComment(property, "formula row mapping")}`;
|
||||
}
|
||||
} else {
|
||||
propertyCode = `${property.name}: undefined, // ${unsupportedGeneratedComment(property, "row mapping")}`;
|
||||
}
|
||||
|
||||
if (comments.length > 0) {
|
||||
propertyCode = propertyCode + ` // ${comments.join(", ")}`;
|
||||
}
|
||||
code.push(propertyCode);
|
||||
}
|
||||
|
||||
function formToDto({ code, property }: { code: string[]; property: PropertyWithDetails }) {
|
||||
if (!isWritableProperty(property)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let propertyCode = "";
|
||||
let comments: string[] = [];
|
||||
comments.push(property.isRequired ? "required" : "optional");
|
||||
if (property.type === PropertyType.TEXT) {
|
||||
propertyCode = `${property.name}: FormHelper.getText(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.NUMBER) {
|
||||
propertyCode = `${property.name}: FormHelper.getNumber(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.DATE) {
|
||||
propertyCode = `${property.name}: FormHelper.getDate(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.SELECT) {
|
||||
propertyCode = `${property.name}: FormHelper.getText(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.BOOLEAN) {
|
||||
propertyCode = `${property.name}: FormHelper.getBoolean(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.MEDIA) {
|
||||
if (PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) === 1) {
|
||||
propertyCode = `${property.name}: FormHelper.getFormFirstMedia(form, "${property.name}"),`;
|
||||
} else {
|
||||
propertyCode = `${property.name}: FormHelper.getFormMedia(form, "${property.name}"),`;
|
||||
}
|
||||
} else if (property.type === PropertyType.MULTI_SELECT) {
|
||||
propertyCode = `${property.name}: FormHelper.getMultiple(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.RANGE_NUMBER) {
|
||||
propertyCode = `${property.name}: FormHelper.getNumberRange(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.RANGE_DATE) {
|
||||
propertyCode = `${property.name}: FormHelper.getDateRange(form, "${property.name}"),`;
|
||||
} else if (property.type === PropertyType.MULTI_TEXT) {
|
||||
propertyCode = `${property.name}: FormHelper.getMultiple(form, "${property.name}"),`;
|
||||
} else {
|
||||
propertyCode = `${property.name}: undefined, // ${unsupportedGeneratedComment(property, "form mapping")}`;
|
||||
}
|
||||
|
||||
if (comments.length > 0) {
|
||||
propertyCode = propertyCode + ` // ${comments.join(", ")}`;
|
||||
}
|
||||
code.push(propertyCode);
|
||||
}
|
||||
|
||||
function createDtoToRow({ code, property }: { code: string[]; property: PropertyWithDetails }) {
|
||||
if (!isWritableProperty(property)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionalChaining = property.isRequired ? "" : "?";
|
||||
let propertyCode = ``;
|
||||
if (property.type === PropertyType.TEXT) {
|
||||
propertyCode = `{ name: "${property.name}", value: data.${property.name} },`;
|
||||
} else if (property.type === PropertyType.NUMBER) {
|
||||
propertyCode = `{ name: "${property.name}", value: data.${property.name}${optionalChaining}.toString() },`;
|
||||
} else if (property.type === PropertyType.DATE) {
|
||||
propertyCode = `{ name: "${property.name}", value: data.${property.name}${optionalChaining}.toISOString() },`;
|
||||
} else if (property.type === PropertyType.SELECT) {
|
||||
propertyCode = `{ name: "${property.name}", value: data.${property.name} },`;
|
||||
} else if (property.type === PropertyType.BOOLEAN) {
|
||||
propertyCode = `{ name: "${property.name}", value: data.${property.name}${optionalChaining}.toString() },`;
|
||||
} else if (property.type === PropertyType.MEDIA) {
|
||||
if (PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) === 1) {
|
||||
propertyCode = `{ name: "${property.name}", media: data.${property.name} ? [data.${property.name}] : [] },`;
|
||||
} else {
|
||||
propertyCode = `{ name: "${property.name}", media: data.${property.name} },`;
|
||||
}
|
||||
} else if (property.type === PropertyType.MULTI_SELECT) {
|
||||
propertyCode = `{ name: "${property.name}", multiple: data.${property.name} },`;
|
||||
} else if (property.type === PropertyType.RANGE_NUMBER) {
|
||||
propertyCode = `{ name: "${property.name}", range: data.${property.name} },`;
|
||||
} else if (property.type === PropertyType.RANGE_DATE) {
|
||||
propertyCode = `{ name: "${property.name}", range: data.${property.name} },`;
|
||||
} else if (property.type === PropertyType.MULTI_TEXT) {
|
||||
propertyCode = `{ name: "${property.name}", multiple: data.${property.name} },`;
|
||||
} else {
|
||||
propertyCode = `// ${unsupportedGeneratedComment(property, "create mapping")}`;
|
||||
}
|
||||
code.push(propertyCode);
|
||||
}
|
||||
|
||||
function updateDtoToRow({ code, property }: { code: string[]; property: PropertyWithDetails }) {
|
||||
if (!isWritableProperty(property)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let propertyCode = `if (data.${property.name} !== undefined) {\n`;
|
||||
|
||||
if (property.type === PropertyType.TEXT) {
|
||||
propertyCode += `values.push({ name: "${property.name}", textValue: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.NUMBER) {
|
||||
propertyCode += `values.push({ name: "${property.name}", numberValue: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.DATE) {
|
||||
propertyCode += `values.push({ name: "${property.name}", dateValue: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.SELECT) {
|
||||
propertyCode += `values.push({ name: "${property.name}", textValue: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.BOOLEAN) {
|
||||
propertyCode += `values.push({ name: "${property.name}", booleanValue: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.MEDIA) {
|
||||
if (PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) === 1) {
|
||||
propertyCode += `values.push({ name: "${property.name}", media: data.${property.name} ? [data.${property.name} as RowMedia] : [] });`;
|
||||
} else {
|
||||
propertyCode += `values.push({ name: "${property.name}", media: data.${property.name} ? data.${property.name} as RowMedia[] : [] });`;
|
||||
}
|
||||
} else if (property.type === PropertyType.MULTI_SELECT) {
|
||||
propertyCode += `values.push({ name: "${property.name}", multiple: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.RANGE_NUMBER) {
|
||||
propertyCode += `values.push({ name: "${property.name}", range: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.RANGE_DATE) {
|
||||
propertyCode += `values.push({ name: "${property.name}", range: data.${property.name} });`;
|
||||
} else if (property.type === PropertyType.MULTI_TEXT) {
|
||||
propertyCode += `values.push({ name: "${property.name}", multiple: data.${property.name} });`;
|
||||
} else {
|
||||
propertyCode += `// ${unsupportedGeneratedComment(property, "update mapping")}`;
|
||||
}
|
||||
|
||||
propertyCode = propertyCode + `\n}`;
|
||||
|
||||
code.push(propertyCode);
|
||||
}
|
||||
|
||||
function getColor(color: number) {
|
||||
return "Colors." + Colors[color];
|
||||
}
|
||||
|
||||
function uiForm({ code, imports, property, index }: { code: string[]; imports: string[]; property: PropertyWithDetails; index: number }) {
|
||||
const titleExpression = CodeGeneratorHelper.getPropertyTitleExpression(property);
|
||||
if (property.type === PropertyType.FORMULA) {
|
||||
code.push(`{item && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">{${titleExpression}}</div>
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">{item.${property.name}?.toString() ?? ""}</div>
|
||||
</div>
|
||||
)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!property.showInCreate) {
|
||||
// render if row exists only
|
||||
code.push(`{item && (`);
|
||||
}
|
||||
|
||||
const props: string[] = [];
|
||||
if (property.isRequired) {
|
||||
props.push(`required`);
|
||||
}
|
||||
if (index === 0) {
|
||||
props.push(`autoFocus`);
|
||||
}
|
||||
props.push("disabled={isDisabled()}");
|
||||
|
||||
let min = PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Min) ?? 0;
|
||||
let max = PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) ?? 0;
|
||||
|
||||
if (property.type === PropertyType.TEXT) {
|
||||
let defaultValue = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.DefaultValue);
|
||||
let value = `item?.${property.name}`;
|
||||
if (defaultValue) {
|
||||
value = `item?.${property.name} ?? (!item ? "${defaultValue}" : undefined)`;
|
||||
}
|
||||
props.push(`defaultValue={${value}}`);
|
||||
let placeholder = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.Placeholder);
|
||||
if (placeholder) {
|
||||
props.push(`placeholder="${placeholder}"`);
|
||||
}
|
||||
if (min > 0) {
|
||||
props.push(`minLength={${min}}`);
|
||||
}
|
||||
if (max > 0) {
|
||||
props.push(`maxLength={${max}}`);
|
||||
}
|
||||
let rows = property.attributes.find((f) => f.name === PropertyAttributeName.Rows);
|
||||
if (rows) {
|
||||
props.push(`rows={${PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Rows)}}`);
|
||||
}
|
||||
let pattern = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.Pattern);
|
||||
if (pattern) {
|
||||
props.push(`pattern="${pattern}"`);
|
||||
}
|
||||
let editor = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.Editor);
|
||||
if (editor === "monaco") {
|
||||
props.push(`editor="monaco" editorLanguage="markdown"`);
|
||||
} else if (editor === "wysiwyg") {
|
||||
props.push(`editor="wysiwyg" autoFocus={${index === 0}}`);
|
||||
}
|
||||
if (!property.subtype || property.subtype === "singleLine") {
|
||||
imports.push(`import InputText from "~/components/ui/input/InputText";`);
|
||||
code.push(`<InputText name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
} else {
|
||||
imports.push(`import InputTextSubtype from "~/components/ui/input/subtypes/InputTextSubtype";`);
|
||||
code.push(`<InputTextSubtype subtype="${property.subtype}" name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
}
|
||||
} else if (property.type === PropertyType.NUMBER) {
|
||||
imports.push(`import InputNumber from "~/components/ui/input/InputNumber";`);
|
||||
let defaultValue = PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.DefaultValue);
|
||||
let value = `item?.${property.name}`;
|
||||
if (defaultValue) {
|
||||
value = `item?.${property.name} ?? (!item ? ${defaultValue} : undefined)`;
|
||||
}
|
||||
props.push(`defaultValue={${value}}`);
|
||||
if (min > 0) {
|
||||
props.push(`min={${min}}`);
|
||||
}
|
||||
if (max > 0) {
|
||||
props.push(`max={${max}}`);
|
||||
}
|
||||
let step = PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Step);
|
||||
if (step) {
|
||||
props.push(`step="${step}"`);
|
||||
}
|
||||
code.push(`<InputNumber name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
} else if (property.type === PropertyType.DATE) {
|
||||
imports.push(`import InputDate from "~/components/ui/input/InputDate";`);
|
||||
code.push(`<InputDate name="${property.name}" title={${titleExpression}} defaultValue={item?.${property.name}} ${props.join(" ")} />`);
|
||||
} else if (property.type === PropertyType.SELECT) {
|
||||
let defaultValue = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.DefaultValue);
|
||||
let value = `item?.${property.name}`;
|
||||
if (defaultValue) {
|
||||
value = `item?.${property.name} ?? (!item ? "${defaultValue}" : undefined)`;
|
||||
}
|
||||
props.push(`defaultValue={${value}}`);
|
||||
const withColors = property.options.filter((f) => f.color).length > 0;
|
||||
|
||||
imports.push(`import { Colors } from "~/application/enums/shared/Colors";`);
|
||||
props.push(
|
||||
`options={[${property.options.map((option) => {
|
||||
return `{ name: ${option.name === null ? `"${option.value}"` : `"${option.name}"`}, value: "${option.value}", color: ${getColor(option.color)} }`;
|
||||
})}]}`
|
||||
);
|
||||
if (!property.subtype || property.subtype === "dropdown") {
|
||||
// props.push(`withSearch={false}`);
|
||||
props.push(`withColors={${withColors ? "true" : "false"}}`);
|
||||
imports.push(`import InputSelect from "~/components/ui/input/InputSelect";`);
|
||||
code.push(`<InputSelect name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
} else if (property.subtype === "radioGroupCards") {
|
||||
imports.push(`import InputRadioGroupCards from "~/components/ui/input/InputRadioGroupCards";`);
|
||||
code.push(`<InputRadioGroupCards name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
}
|
||||
} else if (property.type === PropertyType.BOOLEAN) {
|
||||
imports.push(`import InputCheckbox from "~/components/ui/input/InputCheckbox";`);
|
||||
let defaultValue = PropertyAttributeHelper.getPropertyAttributeValue_Boolean(property, PropertyAttributeName.DefaultValue);
|
||||
let value = `item?.${property.name}`;
|
||||
if (defaultValue) {
|
||||
value = `item?.${property.name} ?? (!item ? ${defaultValue} : undefined)`;
|
||||
}
|
||||
props.push(`value={${value}}`);
|
||||
code.push(`<InputCheckbox name="${property.name}" title={${titleExpression}} asToggle ${props.join(" ")} />`);
|
||||
} else if (property.type === PropertyType.MEDIA) {
|
||||
imports.push(`import InputMedia from "~/components/ui/input/InputMedia";`);
|
||||
if (PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) === 1) {
|
||||
props.push(`initialMedia={item?.${property.name} ? [item?.${property.name}] : []}`);
|
||||
} else {
|
||||
props.push(`initialMedia={item?.${property.name}}`);
|
||||
}
|
||||
let acceptFileTypes = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.AcceptFileTypes);
|
||||
if (min > 0) {
|
||||
props.push(`min={${min}}`);
|
||||
}
|
||||
if (max > 0) {
|
||||
props.push(`max={${max}}`);
|
||||
}
|
||||
if (acceptFileTypes) {
|
||||
props.push(`accept="${acceptFileTypes}"`);
|
||||
}
|
||||
let maxSize = PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.MaxSize) ?? 0;
|
||||
if (maxSize > 0) {
|
||||
props.push(`maxSize={${maxSize}}`);
|
||||
}
|
||||
code.push(`<InputMedia name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
} else if (property.type === PropertyType.MULTI_SELECT) {
|
||||
props.push(`value={item?.${property.name}}`);
|
||||
|
||||
imports.push(`import { Colors } from "~/application/enums/shared/Colors";`);
|
||||
props.push(
|
||||
`options={[${property.options.map((option) => {
|
||||
return `{ name: ${option.name === null ? `"${option.value}"` : `"${option.name}"`}, value: "${option.value}", color: ${getColor(option.color)} }`;
|
||||
})}]}`
|
||||
);
|
||||
if (!property.subtype || property.subtype === "combobox") {
|
||||
// imports.push(`import InputCombobox from "~/components/ui/input/InputCombobox";`);
|
||||
imports.push(`import PropertyMultiSelector from "~/components/entities/properties/PropertyMultiSelector";`);
|
||||
code.push(`<PropertyMultiSelector name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
} else if (property.subtype === "checkboxCards") {
|
||||
imports.push(`import PropertyMultiSelector from "~/components/entities/properties/PropertyMultiSelector";`);
|
||||
code.push(`<PropertyMultiSelector subtype="checkboxCards" name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
}
|
||||
} else if (property.type === PropertyType.RANGE_NUMBER) {
|
||||
imports.push(`import InputRangeNumber from "~/components/ui/input/ranges/InputRangeNumber";`);
|
||||
code.push(
|
||||
`<InputRangeNumber name="${property.name}" title={${titleExpression}} ${props.join(" ")} defaultValueMin={item?.${
|
||||
property.name
|
||||
}?.numberMin ? Number(item.${property.name}.numberMin) : undefined} defaultValueMax={item?.${property.name}?.numberMax ? Number(item.${
|
||||
property.name
|
||||
}.numberMax) : undefined} />`
|
||||
);
|
||||
} else if (property.type === PropertyType.RANGE_DATE) {
|
||||
imports.push(`import InputRangeDate from "~/components/ui/input/ranges/InputRangeDate";`);
|
||||
code.push(
|
||||
`<InputRangeDate name="${property.name}" title={${titleExpression}} ${props.join(" ")} defaultValueMin={item?.${
|
||||
property.name
|
||||
}?.dateMin} defaultValueMax={item?.${property.name}?.dateMax} />`
|
||||
);
|
||||
} else if (property.type === PropertyType.MULTI_TEXT) {
|
||||
imports.push(`import InputMultiText from "~/components/ui/input/InputMultiText";`);
|
||||
let separator = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.Separator);
|
||||
if (separator) {
|
||||
props.push(`separator="${separator}"`);
|
||||
}
|
||||
props.push(`value={item?.${property.name}}`);
|
||||
code.push(`<InputMultiText name="${property.name}" title={${titleExpression}} ${props.join(" ")} />`);
|
||||
} else {
|
||||
code.push(`{/* ${unsupportedGeneratedComment(property, "input")} */}`);
|
||||
}
|
||||
|
||||
if (!property.showInCreate) {
|
||||
// render if row exists only
|
||||
code.push(`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function uiCell({ code, imports, property }: { code: string[]; imports: string[]; property: PropertyWithDetails }) {
|
||||
const titleExpression = CodeGeneratorHelper.getPropertyTitleExpression(property);
|
||||
const props: string[] = [];
|
||||
if (property.type === PropertyType.TEXT) {
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <div className="max-w-sm truncate">{item.${property.name}}</div>,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.NUMBER) {
|
||||
imports.push(`import RowNumberCell from "~/components/entities/rows/cells/RowNumberCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatNumber);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowNumberCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.DATE) {
|
||||
imports.push(`import RowDateCell from "~/components/entities/rows/cells/RowDateCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatDate);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowDateCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.SELECT) {
|
||||
imports.push(`import RowSelectedOptionCell from "~/components/entities/rows/cells/RowSelectedOptionCell";`);
|
||||
// const withColors = property.options.filter((f) => f.color).length > 0;
|
||||
// if (withColors) {
|
||||
imports.push(`import { Colors } from "~/application/enums/shared/Colors";`);
|
||||
// }
|
||||
props.push(
|
||||
`options={[${property.options.map((option) => {
|
||||
return `{ name: ${option.name === null ? "null" : `"${option.name}"`}, value: "${option.value}", color: ${getColor(option.color)} }`;
|
||||
})}]}`
|
||||
);
|
||||
let display: SelectOptionsDisplay = "Value";
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.SelectOptions);
|
||||
if (format) {
|
||||
display = format as SelectOptionsDisplay;
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowSelectedOptionCell value={item?.${property.name}} display="${display}" ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.BOOLEAN) {
|
||||
imports.push(`import RowBooleanCell from "~/components/entities/rows/cells/RowBooleanCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatBoolean);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowBooleanCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.MEDIA) {
|
||||
imports.push(`import RowMediaCell from "~/components/entities/rows/cells/RowMediaCell";`);
|
||||
if (PropertyAttributeHelper.getPropertyAttributeValue_Number(property, PropertyAttributeName.Max) === 1) {
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowMediaCell media={item.${property.name} ? [item.${property.name}] : []} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else {
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowMediaCell media={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
}
|
||||
} else if (property.type === PropertyType.MULTI_SELECT) {
|
||||
imports.push(`import PropertyMultipleValueBadge from "~/components/entities/properties/PropertyMultipleValueBadge";`);
|
||||
props.push(
|
||||
`options={[${property.options.map((option) => {
|
||||
return `{ name: ${option.name === null ? "null" : `"${option.name}"`}, value: "${option.value}" }`;
|
||||
})}]}`
|
||||
);
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <PropertyMultipleValueBadge values={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.RANGE_NUMBER) {
|
||||
imports.push(`import RowRangeNumberCell from "~/components/entities/rows/cells/RowRangeNumberCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatNumber);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowRangeNumberCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.RANGE_DATE) {
|
||||
imports.push(`import RowRangeDateCell from "~/components/entities/rows/cells/RowRangeDateCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatNumber);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowRangeDateCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.MULTI_TEXT) {
|
||||
imports.push(`import PropertyMultipleValueBadge from "~/components/entities/properties/PropertyMultipleValueBadge";`);
|
||||
props.push(
|
||||
`options={[${property.options.map((option) => {
|
||||
return `{ name: ${option.name === null ? "null" : `"${option.name}"`}, value: "${option.value}" }`;
|
||||
})}]}`
|
||||
);
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <PropertyMultipleValueBadge values={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (property.type === PropertyType.FORMULA) {
|
||||
const resultAs = getFormulaResultAs(property);
|
||||
if (resultAs === "number") {
|
||||
imports.push(`import RowNumberCell from "~/components/entities/rows/cells/RowNumberCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatNumber);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowNumberCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (resultAs === "date") {
|
||||
imports.push(`import RowDateCell from "~/components/entities/rows/cells/RowDateCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatDate);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowDateCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else if (resultAs === "boolean") {
|
||||
imports.push(`import RowBooleanCell from "~/components/entities/rows/cells/RowBooleanCell";`);
|
||||
let format = PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.FormatBoolean);
|
||||
if (format) {
|
||||
props.push(`format="${format}"`);
|
||||
}
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <RowBooleanCell value={item.${property.name}} ${props.join(" ")} />,
|
||||
},`);
|
||||
} else {
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: (item) => <div className="max-w-sm truncate">{item.${property.name}}</div>,
|
||||
},`);
|
||||
}
|
||||
} else {
|
||||
code.push(`{
|
||||
name: "${property.name}",
|
||||
title: ${titleExpression},
|
||||
value: () => null,
|
||||
},`);
|
||||
}
|
||||
}
|
||||
|
||||
function schema({ code, property }: { code: string[]; property: PropertyWithDetails }) {
|
||||
const typeMap: Record<PropertyType, string> = {
|
||||
[PropertyType.TEXT]: "String",
|
||||
[PropertyType.NUMBER]: "Decimal",
|
||||
[PropertyType.DATE]: "DateTime",
|
||||
[PropertyType.SELECT]: "String",
|
||||
[PropertyType.BOOLEAN]: "Boolean",
|
||||
[PropertyType.MEDIA]: "Json",
|
||||
[PropertyType.MULTI_SELECT]: "Json",
|
||||
[PropertyType.MULTI_TEXT]: "Json",
|
||||
[PropertyType.RANGE_NUMBER]: "Json",
|
||||
[PropertyType.RANGE_DATE]: "Json",
|
||||
[PropertyType.FORMULA]: "",
|
||||
};
|
||||
|
||||
const type = typeMap[property.type as PropertyType];
|
||||
if (type === undefined) {
|
||||
return;
|
||||
}
|
||||
if (property.type === PropertyType.FORMULA) {
|
||||
code.push(` // ${property.name} is a computed Formula field; no editable Prisma column is generated by default.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = property.isRequired ? "" : "?";
|
||||
const schemaString = ` ${property.name} ${type}${suffix}`;
|
||||
code.push(schemaString);
|
||||
}
|
||||
|
||||
export default {
|
||||
type,
|
||||
rowToDto,
|
||||
formToDto,
|
||||
createDtoToRow,
|
||||
updateDtoToRow,
|
||||
uiForm,
|
||||
uiCell,
|
||||
schema,
|
||||
isWritableProperty,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "CrmService.ts"), "utf8");
|
||||
|
||||
describe("CrmService env boundary", () => {
|
||||
test("uses typed web config for Formspree and stays free of US newsletter providers", () => {
|
||||
expect(source).toContain("loadWebConfig().config.contactFormspreeEndpoint");
|
||||
expect(source).not.toContain("process.env.INTEGRATIONS_CONTACT_FORMSPREE");
|
||||
// EU-Sterilisation (R-P1a): ConvertKit-Provider vollständig entfernt.
|
||||
expect(source).not.toContain("process.env.CONVERTKIT_");
|
||||
expect(source).not.toContain("api.convertkit.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* PII-protection regression test for CrmService.
|
||||
*
|
||||
* Newsletter + ConvertKit code paths historically logged raw `response`
|
||||
* objects (containing subscriber email) and raw exceptions. We tightened
|
||||
* those to log only the operation tag, an ok-boolean, and a sanitised
|
||||
* `err`. Never log: email, firstName, lastName, phone, response body,
|
||||
* api key, contact object.
|
||||
*/
|
||||
describe("CrmService PII discipline", () => {
|
||||
const SOURCE = readFileSync(resolve(__dirname, "CrmService.ts"), "utf8");
|
||||
|
||||
it("contains no live console.* call sites", () => {
|
||||
const live = SOURCE.split("\n").filter((line, idx, all) => {
|
||||
if (!/console\.(log|error|warn|info|debug)\s*\(/.test(line)) return false;
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return false;
|
||||
const prev = idx > 0 ? all[idx - 1].trim() : "";
|
||||
if (/eslint-disable-next-line[^\n]*no-console/.test(prev)) return false;
|
||||
return true;
|
||||
});
|
||||
expect(live).toEqual([]);
|
||||
});
|
||||
|
||||
it("never passes contact PII keys as own crmLogger fields", () => {
|
||||
const pattern = /crmLogger\.(info|warn|error|debug)\s*\(\s*\{([\s\S]*?)\}/g;
|
||||
const forbiddenKeys = [
|
||||
"email",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"phone",
|
||||
"contact",
|
||||
"response",
|
||||
"responseBody",
|
||||
"apiKey",
|
||||
"API_KEY",
|
||||
"api_key",
|
||||
"api_secret",
|
||||
];
|
||||
const offenders: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(SOURCE)) !== null) {
|
||||
const objContent = match[2];
|
||||
for (const key of forbiddenKeys) {
|
||||
const keyRe = new RegExp(`(^|[\\s,{])${key}\\s*:`, "m");
|
||||
if (keyRe.test(objContent)) offenders.push(`key "${key}" in ${match[0].slice(0, 80)}...`);
|
||||
}
|
||||
}
|
||||
expect(offenders, `crmLogger must not log PII / secret fields: ${offenders.join("; ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses createScopedLogger('crm')", () => {
|
||||
expect(SOURCE).toMatch(/createScopedLogger\(\s*["']crm["']\s*\)/);
|
||||
});
|
||||
|
||||
it("does not expose CRM template or entity details in configuration errors", () => {
|
||||
expect(SOURCE).toContain("class CrmConfigurationError");
|
||||
expect(SOURCE).not.toContain("CRM is not configured:");
|
||||
expect(SOURCE).not.toContain('Entity "');
|
||||
expect(SOURCE).not.toContain("load the template");
|
||||
expect(SOURCE).not.toContain('must be "All" or');
|
||||
expect(SOURCE).not.toContain("ConvertKit error:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { RowRelationshipsApi } from "~/utils/api/.server/RowRelationshipsApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { dbRead } from "~/utils/db.server";
|
||||
import { EntityWithDetails, findEntityByName, getEntitiesByName, getEntityByName } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails, countRows, getRowById, getRows, getRowsInIds } from "~/utils/db/entities/rows.db.server";
|
||||
import RowValueService, { RowValueUpdateDto } from "~/utils/helpers/.server/RowValueService";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowValueHelper from "~/utils/helpers/RowValueHelper";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { createScopedLogger } from "~/utils/logger.server";
|
||||
import { ensureProductCoreCrmEntities } from "./ProductCoreCrmEntities.server";
|
||||
|
||||
// EU-Sterilisation (R-P1a): ConvertKit (US-Newsletter-Provider) entfernt. Das CRM bleibt
|
||||
// (Contacts/Companies/Opportunities/Submissions); Newsletter laufen ausschließlich über das
|
||||
// eigene CRM. ConvertKit-Subscriber-Sync, -Fetch und -Config sind gestrichen.
|
||||
const crmLogger = createScopedLogger("crm");
|
||||
|
||||
export class CrmConfigurationError extends Error {
|
||||
readonly code = "CRM_CONFIGURATION_INVALID";
|
||||
|
||||
constructor() {
|
||||
super("CRM_CONFIGURATION_INVALID");
|
||||
this.name = "CrmConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CrmProviderError extends Error {
|
||||
readonly code = "CRM_PROVIDER_UNAVAILABLE";
|
||||
|
||||
constructor() {
|
||||
super("CRM_PROVIDER_UNAVAILABLE");
|
||||
this.name = "CrmProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(tenantId: string | null = null) {
|
||||
if (tenantId !== null) {
|
||||
await ensureProductCoreCrmEntities();
|
||||
}
|
||||
const crmEntities = ["company", "opportunity", "contact"];
|
||||
if (tenantId === null) {
|
||||
crmEntities.push("submission");
|
||||
}
|
||||
const entities = await getEntitiesByName(crmEntities);
|
||||
await Promise.all(
|
||||
crmEntities.map(async (crmEntity) => {
|
||||
const entity = entities.find((i) => i.name === crmEntity);
|
||||
if (!entity) {
|
||||
throw new CrmConfigurationError();
|
||||
}
|
||||
if (tenantId === null && ![DefaultEntityTypes.All, DefaultEntityTypes.AdminOnly].find((f) => f.toString() === entity.type)) {
|
||||
throw new CrmConfigurationError();
|
||||
} else if (tenantId !== null && ![DefaultEntityTypes.All, DefaultEntityTypes.AppOnly].find((f) => f.toString() === entity.type)) {
|
||||
throw new CrmConfigurationError();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function getContactsInRowIds(rowIds: string[], tenantId: string | null): Promise<ContactDto[]> {
|
||||
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
|
||||
const rows = await getRowsInIds(rowIds, tenantId);
|
||||
return await Promise.all(
|
||||
rowIds
|
||||
.filter((f) => f)
|
||||
.map(async (rowId) => {
|
||||
const row = rows.find((i) => i.id === rowId);
|
||||
if (!row) {
|
||||
throw new Error("Contact not found: " + rowId);
|
||||
}
|
||||
const email = RowValueHelper.getText({ entity: contactsEntity, row, name: "email" }) ?? "";
|
||||
const firstName = RowValueHelper.getText({ entity: contactsEntity, row, name: "firstName" }) ?? "";
|
||||
const lastName = RowValueHelper.getText({ entity: contactsEntity, row, name: "lastName" }) ?? "";
|
||||
const jobTitle = RowValueHelper.getText({ entity: contactsEntity, row, name: "jobTitle" }) ?? "";
|
||||
const marketingSubscriber = RowValueHelper.getBoolean({ entity: contactsEntity, row, name: "marketingSubscriber" }) ?? false;
|
||||
const contact: ContactDto = {
|
||||
id: row.id,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
jobTitle,
|
||||
marketingSubscriber,
|
||||
};
|
||||
return contact;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export type ContactDto = {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle: string;
|
||||
marketingSubscriber: boolean;
|
||||
company?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
async function getContact(rowId: string, tenantId: string | null): Promise<ContactDto | null> {
|
||||
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
|
||||
const row = await getRowById(rowId, { entityId: contactsEntity.id, tenantId });
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return rowToContact({ row, entity: contactsEntity });
|
||||
}
|
||||
|
||||
async function getContacts(tenantId: string | null): Promise<ContactDto[]> {
|
||||
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
|
||||
const rows = await getRows({ entityName: "contact", tenantId });
|
||||
return rows.map((row) => rowToContact({ row, entity: contactsEntity }));
|
||||
}
|
||||
|
||||
export type ContactFormSettings = { crm: boolean; actionUrl?: string; error?: string };
|
||||
async function getContactFormSettings() {
|
||||
const settings: ContactFormSettings = {
|
||||
crm: false,
|
||||
actionUrl: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
try {
|
||||
await validate();
|
||||
settings.crm = true;
|
||||
} catch {
|
||||
const formspreeEndpoint = loadWebConfig().config.contactFormspreeEndpoint;
|
||||
if (formspreeEndpoint) {
|
||||
settings.actionUrl = formspreeEndpoint;
|
||||
} else {
|
||||
settings.error = i18nT("crm.contact.errors.formNotConfigured");
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export type NewsletterFormSettings = {
|
||||
crm: boolean;
|
||||
error?: string;
|
||||
};
|
||||
async function getNewsletterFormSettings() {
|
||||
const settings: NewsletterFormSettings = {
|
||||
crm: false,
|
||||
error: undefined,
|
||||
};
|
||||
try {
|
||||
await validate();
|
||||
settings.crm = true;
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
if (!settings.crm) {
|
||||
settings.error = i18nT("crm.newsletter.errors.formNotConfigured");
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export type CrmSummaryDto = {
|
||||
companies: number;
|
||||
contacts: number;
|
||||
opportunities: { value: number; count: number };
|
||||
submissions: number;
|
||||
data: {
|
||||
openOpportunities: RowWithDetails[];
|
||||
submissions: RowWithDetails[];
|
||||
};
|
||||
};
|
||||
async function getSummary(tenantId: string | null): Promise<CrmSummaryDto> {
|
||||
const openOpportunities = await getRows({
|
||||
tenantId,
|
||||
entityName: "opportunity",
|
||||
});
|
||||
const opportunities = { value: 0, count: 0 };
|
||||
const opportunitiesEntity = await getEntityByName({ tenantId, name: "opportunity" });
|
||||
openOpportunities.forEach((row) => {
|
||||
const value = RowValueHelper.getNumber({ entity: opportunitiesEntity, row, name: "value" });
|
||||
opportunities.value += value ?? 0;
|
||||
if ((value ?? 0) > 0) {
|
||||
opportunities.count++;
|
||||
}
|
||||
});
|
||||
const submissionsEntity = await findEntityByName({ name: "submission", tenantId });
|
||||
const submissions = submissionsEntity ? await getRows({ entityName: "submission", tenantId }) : [];
|
||||
return {
|
||||
companies: await countRows({ entityName: "company", tenantId }),
|
||||
contacts: await countRows({ entityName: "contact", tenantId }),
|
||||
opportunities,
|
||||
submissions: submissions.length,
|
||||
data: {
|
||||
openOpportunities,
|
||||
submissions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createCompany({ tenantId, name, request }: { tenantId: string | null; name: string; request: Request }) {
|
||||
const entity = await getEntityByName({ tenantId, name: "company" });
|
||||
const rowValues = RowHelper.getRowPropertiesFromForm({
|
||||
entity,
|
||||
values: [{ name: "name", value: name }],
|
||||
});
|
||||
const row = await RowsApi.create({
|
||||
entity,
|
||||
tenantId,
|
||||
rowValues,
|
||||
request,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async function createContact({
|
||||
tenantId,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
jobTitle,
|
||||
status,
|
||||
marketingSubscriber,
|
||||
options,
|
||||
request,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
jobTitle?: string;
|
||||
status: string;
|
||||
marketingSubscriber: boolean;
|
||||
options?: {
|
||||
checkUsage: boolean;
|
||||
createLog: boolean;
|
||||
createEvent: boolean;
|
||||
storeMedia: boolean;
|
||||
reportUsage: boolean;
|
||||
};
|
||||
request: Request;
|
||||
}) {
|
||||
const entity = await getEntityByName({ tenantId, name: "contact" });
|
||||
const rowValues = RowHelper.getRowPropertiesFromForm({
|
||||
entity,
|
||||
values: [
|
||||
{ name: "firstName", value: firstName },
|
||||
{ name: "lastName", value: lastName },
|
||||
{ name: "email", value: email },
|
||||
{ name: "jobTitle", value: jobTitle ?? "" },
|
||||
{ name: "status", value: status },
|
||||
{ name: "marketingSubscriber", value: marketingSubscriber ? "true" : "false" },
|
||||
],
|
||||
options: {
|
||||
skipValidation: true,
|
||||
},
|
||||
});
|
||||
return await RowsApi.create({
|
||||
entity,
|
||||
tenantId,
|
||||
rowValues,
|
||||
options,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
async function createSubmission({ tenantId, users, message, request }: { tenantId: string | null; users: string; message: string; request: Request }) {
|
||||
const entity = await getEntityByName({ tenantId, name: "submission" });
|
||||
const rowValues = RowHelper.getRowPropertiesFromForm({
|
||||
entity,
|
||||
values: [
|
||||
{ name: "users", value: users },
|
||||
{ name: "message", value: message },
|
||||
],
|
||||
});
|
||||
const row = await RowsApi.create({
|
||||
entity,
|
||||
tenantId,
|
||||
rowValues,
|
||||
request,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
async function createContactSubmission(
|
||||
submission: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
jobTitle?: string;
|
||||
company?: string;
|
||||
users: string;
|
||||
message: string;
|
||||
},
|
||||
request: Request
|
||||
) {
|
||||
let contact = await RowsApi.find({
|
||||
entity: { name: "contact" },
|
||||
tenantId: null,
|
||||
properties: [{ name: "email", value: submission.email }],
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
contact = await createContact({
|
||||
tenantId: null,
|
||||
firstName: submission.firstName,
|
||||
lastName: submission.lastName,
|
||||
email: submission.email,
|
||||
jobTitle: submission.jobTitle,
|
||||
status: "contact",
|
||||
marketingSubscriber: false,
|
||||
request,
|
||||
});
|
||||
await RowsApi.addTag({ row: contact, tag: { value: "form", color: Colors.BLUE } });
|
||||
}
|
||||
const companyEntity = await getEntityByName({ tenantId: null, name: "company" });
|
||||
const companies = contact.parentRows.filter((f) => f.parent.entityId === companyEntity.id);
|
||||
if (companies.length === 0 && submission.company) {
|
||||
const company = await createCompany({
|
||||
tenantId: null,
|
||||
name: submission.company,
|
||||
request,
|
||||
});
|
||||
await RowRelationshipsApi.createRelationship({
|
||||
parent: company,
|
||||
child: contact,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const newSubmission = await createSubmission({
|
||||
tenantId: null,
|
||||
users: submission.users,
|
||||
message: submission.message,
|
||||
request,
|
||||
});
|
||||
await RowRelationshipsApi.createRelationship({
|
||||
parent: contact,
|
||||
child: newSubmission,
|
||||
tenantId: null,
|
||||
});
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async function updateContact(
|
||||
rowId: string,
|
||||
data: {
|
||||
marketingSubscriber?: boolean;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
jobTitle?: string;
|
||||
},
|
||||
options?: {
|
||||
createEvent?: boolean;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
) {
|
||||
const tenantId = options?.tenantId ?? null;
|
||||
const contactsEntity = await getEntityByName({ tenantId, name: "contact" });
|
||||
const row = await getRowById(rowId, { entityId: contactsEntity.id, tenantId });
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const contact = rowToContact({ row, entity: contactsEntity });
|
||||
let changes: RowValueUpdateDto[] = [];
|
||||
if (data.firstName !== undefined && data.firstName !== contact.firstName) {
|
||||
changes.push({ name: "firstName", textValue: data.firstName });
|
||||
} else if (data.lastName !== undefined && data.lastName !== contact.lastName) {
|
||||
changes.push({ name: "lastName", textValue: data.lastName });
|
||||
} else if (data.jobTitle !== undefined && data.jobTitle !== contact.jobTitle) {
|
||||
changes.push({ name: "jobTitle", textValue: data.jobTitle });
|
||||
} else if (data.marketingSubscriber !== undefined && data.marketingSubscriber !== contact.marketingSubscriber) {
|
||||
changes.push({ name: "marketingSubscriber", booleanValue: data.marketingSubscriber });
|
||||
}
|
||||
if (changes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
await RowValueService.update({
|
||||
entity: contactsEntity,
|
||||
row,
|
||||
values: changes,
|
||||
session: { tenantId: row.tenantId },
|
||||
checkPermissions: false,
|
||||
options: {
|
||||
createEvent: options?.createEvent,
|
||||
},
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function subscribeToNewsletter({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
source,
|
||||
request,
|
||||
}: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
source?: string;
|
||||
request: Request;
|
||||
}) {
|
||||
const settings = await getNewsletterFormSettings();
|
||||
if (!settings.crm) {
|
||||
return { error: i18nT("crm.newsletter.errors.integrationMissing") };
|
||||
}
|
||||
|
||||
if (settings.crm) {
|
||||
// Note: never log raw email/firstName/lastName here — those are PII.
|
||||
crmLogger.info({ op: "newsletter.createContact", hasSource: Boolean(source) }, "Creating newsletter contact in CRM");
|
||||
let contact = await RowsApi.find({
|
||||
entity: { name: "contact" },
|
||||
tenantId: null,
|
||||
properties: [{ name: "email", value: email }],
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
contact = await createContact({
|
||||
tenantId: null,
|
||||
firstName,
|
||||
lastName,
|
||||
email: email,
|
||||
jobTitle: "",
|
||||
status: "contact",
|
||||
marketingSubscriber: true,
|
||||
request,
|
||||
});
|
||||
if (source) {
|
||||
await RowsApi.addTag({ row: contact, tag: { value: source, color: Colors.ORANGE } });
|
||||
} else {
|
||||
await RowsApi.addTag({ row: contact, tag: { value: "newsletter", color: Colors.ORANGE } });
|
||||
}
|
||||
} else {
|
||||
await updateContact(contact.id, { marketingSubscriber: true });
|
||||
}
|
||||
}
|
||||
return { success: i18nT("crm.newsletter.subscribed") };
|
||||
}
|
||||
|
||||
function rowToContact({ row, entity }: { row: RowWithDetails; entity: EntityWithDetails }) {
|
||||
const email = RowValueHelper.getText({ entity, row, name: "email" }) ?? "";
|
||||
const firstName = RowValueHelper.getText({ entity, row, name: "firstName" }) ?? "";
|
||||
const lastName = RowValueHelper.getText({ entity, row, name: "lastName" }) ?? "";
|
||||
const jobTitle = RowValueHelper.getText({ entity, row, name: "jobTitle" }) ?? "";
|
||||
const marketingSubscriber = RowValueHelper.getBoolean({ entity, row, name: "marketingSubscriber" }) ?? false;
|
||||
|
||||
const companyRow = row.parentRows.find((f) => f.parent.entityId === entity.id)?.parent;
|
||||
let company: { name: string } | undefined = undefined;
|
||||
if (companyRow) {
|
||||
company = {
|
||||
name: RowValueHelper.getText({ entity, row: companyRow, name: "name" }) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
jobTitle,
|
||||
marketingSubscriber,
|
||||
company,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserInCrmDto = {
|
||||
status: "synced" | "to-create" | "to-update";
|
||||
email: string;
|
||||
source: "user";
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isContact: boolean;
|
||||
contact: ContactDto | null;
|
||||
updatedAt: Date | null;
|
||||
};
|
||||
// EU-Sterilisation (R-P1a): invalidateCache wurde nur vom ConvertKit-Sync genutzt und bleibt als
|
||||
// no-op-Parameter in der Signatur, damit Aufrufer (Admin-CRM-Sync) unverändert funktionieren.
|
||||
async function getUsersInCrm(_options: { invalidateCache?: boolean } = {}): Promise<UserInCrmDto[]> {
|
||||
const contacts = await getContacts(null);
|
||||
|
||||
let usersInCrm: UserInCrmDto[] = [];
|
||||
|
||||
usersInCrm.push(...(await loadFromUsers({ contacts })));
|
||||
|
||||
// filter by not contacts first, then by updated date desc
|
||||
|
||||
usersInCrm = usersInCrm.sort((a, b) => {
|
||||
if (a.isContact && !b.isContact) {
|
||||
return 1;
|
||||
} else if (!a.isContact && b.isContact) {
|
||||
return -1;
|
||||
} else if (a.updatedAt && b.updatedAt) {
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||
} else if (a.updatedAt) {
|
||||
return -1;
|
||||
} else if (b.updatedAt) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return usersInCrm;
|
||||
}
|
||||
|
||||
async function loadFromUsers({ contacts }: { contacts: ContactDto[] }) {
|
||||
let usersInCrm: UserInCrmDto[] = [];
|
||||
// Read-only enumeration of users for CRM sync display — eventually-consistent
|
||||
// is fine here. The sync action itself runs in a separate request that uses
|
||||
// the write client.
|
||||
const users = await dbRead.user.findMany({});
|
||||
users.forEach((user) => {
|
||||
const contact = contacts.find((f) => f.email === user.email) ?? null;
|
||||
let status: "synced" | "to-create" | "to-update" = "synced";
|
||||
if (!contact) {
|
||||
status = "to-create";
|
||||
} else if (contact.firstName !== user.firstName || contact.lastName !== user.lastName) {
|
||||
status = "to-update";
|
||||
}
|
||||
const userInCrm: UserInCrmDto = {
|
||||
status,
|
||||
email: user.email,
|
||||
source: "user",
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
updatedAt: user.updatedAt,
|
||||
isContact: !!contact,
|
||||
contact,
|
||||
};
|
||||
usersInCrm.push(userInCrm);
|
||||
});
|
||||
return usersInCrm;
|
||||
}
|
||||
|
||||
export default {
|
||||
validate,
|
||||
getContactsInRowIds,
|
||||
getContact,
|
||||
getContacts,
|
||||
getContactFormSettings,
|
||||
getNewsletterFormSettings,
|
||||
getSummary,
|
||||
createCompany,
|
||||
createContact,
|
||||
createSubmission,
|
||||
createContactSubmission,
|
||||
updateContact,
|
||||
subscribeToNewsletter,
|
||||
getUsersInCrm,
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
|
||||
import { PropertyType } from "~/application/enums/entities/PropertyType";
|
||||
import type { TemplateEntityPropertyTypeDto } from "~/modules/templates/EntityTemplateDto";
|
||||
import { CRM_ENTITIES_TEMPLATE } from "~/modules/templates/defaultEntityTemplates";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { createEntity } from "~/utils/db/entities/entities.db.server";
|
||||
import { createProperties, type CreatePropertyDto } from "~/utils/db/entities/properties.db.server";
|
||||
import { DefaultAdminRoles } from "~/application/dtos/shared/DefaultAdminRoles";
|
||||
import { DefaultAppRoles } from "~/application/dtos/shared/DefaultAppRoles";
|
||||
import { DefaultPermission } from "~/application/dtos/shared/DefaultPermissions";
|
||||
import { getEntityPermissions } from "~/utils/helpers/PermissionsHelper";
|
||||
|
||||
export const productCoreCrmEntityNames = ["company", "contact", "opportunity"] as const;
|
||||
|
||||
export async function ensureProductCoreCrmEntities(options: { ensurePermissions?: boolean; ensureViews?: boolean } = {}) {
|
||||
assertProductCoreCrmTemplateLanguage();
|
||||
const ensurePermissions = options.ensurePermissions ?? false;
|
||||
const ensureViews = options.ensureViews ?? false;
|
||||
const existingEntities = await db.entity.findMany({
|
||||
where: { name: { in: [...productCoreCrmEntityNames] } },
|
||||
select: { id: true, name: true, type: true },
|
||||
});
|
||||
const existingNames = new Set(existingEntities.map((entity) => entity.name));
|
||||
const missingNames = productCoreCrmEntityNames.filter((name) => !existingNames.has(name));
|
||||
|
||||
if (missingNames.length > 0) {
|
||||
for (const entityTemplate of CRM_ENTITIES_TEMPLATE.entities.filter((entity) => missingNames.includes(entity.name as (typeof productCoreCrmEntityNames)[number]))) {
|
||||
const entity = await createEntity({
|
||||
name: entityTemplate.name,
|
||||
slug: entityTemplate.slug,
|
||||
prefix: entityTemplate.prefix,
|
||||
title: entityTemplate.title,
|
||||
titlePlural: entityTemplate.titlePlural,
|
||||
isAutogenerated: entityTemplate.isAutogenerated ?? true,
|
||||
hasApi: entityTemplate.hasApi ?? true,
|
||||
icon: entityTemplate.icon ?? "",
|
||||
active: entityTemplate.active ?? true,
|
||||
type: DefaultEntityTypes.All,
|
||||
showInSidebar: false,
|
||||
hasTags: entityTemplate.hasTags ?? true,
|
||||
hasComments: entityTemplate.hasComments ?? true,
|
||||
hasTasks: entityTemplate.hasTasks ?? false,
|
||||
hasActivity: entityTemplate.hasActivity ?? true,
|
||||
hasBulkDelete: entityTemplate.hasBulkDelete ?? false,
|
||||
hasViews: entityTemplate.hasViews ?? true,
|
||||
defaultVisibility: entityTemplate.defaultVisibility ?? "tenant",
|
||||
onCreated: entityTemplate.onCreated ?? "redirectToOverview",
|
||||
onEdit: entityTemplate.onEdit ?? "editRoute",
|
||||
});
|
||||
await createProperties(
|
||||
entity.id,
|
||||
entityTemplate.properties.map((property): CreatePropertyDto => ({
|
||||
name: property.name,
|
||||
title: property.title,
|
||||
type: toProductCorePropertyType(property.type),
|
||||
subtype: property.subtype ?? null,
|
||||
isRequired: property.isRequired,
|
||||
isDisplay: property.isDisplay,
|
||||
isReadOnly: property.isReadOnly,
|
||||
canUpdate: property.canUpdate,
|
||||
showInCreate: property.showInCreate,
|
||||
tenantId: property.tenantId ?? null,
|
||||
options: property.options?.map((option, index) => ({
|
||||
order: index + 1,
|
||||
value: option.value,
|
||||
name: option.name,
|
||||
color: option.color,
|
||||
})),
|
||||
attributes: property.attributes,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.entity.updateMany({
|
||||
where: { name: { in: [...productCoreCrmEntityNames] }, type: DefaultEntityTypes.AdminOnly },
|
||||
data: {
|
||||
type: DefaultEntityTypes.All,
|
||||
active: true,
|
||||
showInSidebar: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (ensureViews) {
|
||||
await ensureProductCoreCrmViewsAndRelationships();
|
||||
}
|
||||
|
||||
if (ensurePermissions) {
|
||||
await ensureProductCoreCrmPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
function assertProductCoreCrmTemplateLanguage() {
|
||||
if (CRM_ENTITIES_TEMPLATE.defaultLanguage !== "en" || !CRM_ENTITIES_TEMPLATE.languages.includes("en")) {
|
||||
throw new Error("Product core CRM defaults are language-scoped English seed data.");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProductCoreCrmViewsAndRelationships() {
|
||||
const entities = await db.entity.findMany({
|
||||
where: { name: { in: [...productCoreCrmEntityNames] }, active: true },
|
||||
include: {
|
||||
properties: true,
|
||||
views: {
|
||||
where: { tenantId: null, userId: null },
|
||||
include: { properties: true },
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const entityByName = new Map(entities.map((entity) => [entity.name, entity]));
|
||||
|
||||
for (const entity of entities) {
|
||||
const entityTemplate = CRM_ENTITIES_TEMPLATE.entities.find((template) => template.name === entity.name);
|
||||
if (!entityTemplate) {
|
||||
continue;
|
||||
}
|
||||
const existingViewNames = new Set(entity.views.map((view) => view.name));
|
||||
const hasDefaultView = entity.views.some((view) => view.isDefault);
|
||||
const viewTemplates =
|
||||
entityTemplate.views && entityTemplate.views.length > 0
|
||||
? entityTemplate.views
|
||||
: [
|
||||
{
|
||||
layout: "table" as const,
|
||||
name: "all",
|
||||
title: entity.titlePlural,
|
||||
isDefault: true,
|
||||
properties: getDefaultViewPropertyNames(entityTemplate),
|
||||
},
|
||||
];
|
||||
|
||||
let nextOrder = entity.views.length + 1;
|
||||
for (const [index, viewTemplate] of viewTemplates.entries()) {
|
||||
if (existingViewNames.has(viewTemplate.name)) {
|
||||
continue;
|
||||
}
|
||||
const groupByPropertyId = viewTemplate.groupByProperty
|
||||
? (entity.properties.find((property) => property.name === viewTemplate.groupByProperty)?.id ?? null)
|
||||
: null;
|
||||
const view = await db.entityView.create({
|
||||
data: {
|
||||
entityId: entity.id,
|
||||
tenantId: null,
|
||||
userId: null,
|
||||
createdByUserId: null,
|
||||
layout: viewTemplate.layout,
|
||||
order: viewTemplate.order ?? nextOrder++,
|
||||
name: viewTemplate.name,
|
||||
title: viewTemplate.title,
|
||||
isDefault: viewTemplate.isDefault ?? (!hasDefaultView && index === 0),
|
||||
isSystem: viewTemplate.isSystem ?? false,
|
||||
pageSize: viewTemplate.pageSize ?? 100,
|
||||
groupByPropertyId,
|
||||
},
|
||||
});
|
||||
await Promise.all(
|
||||
viewTemplate.properties.map((propertyName, propertyIndex) =>
|
||||
db.entityViewProperty.create({
|
||||
data: {
|
||||
entityViewId: view.id,
|
||||
propertyId: entity.properties.find((property) => property.name === propertyName)?.id ?? null,
|
||||
name: propertyName,
|
||||
order: propertyIndex + 1,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
(viewTemplate.filters ?? []).map((filter) =>
|
||||
db.entityViewFilter.create({
|
||||
data: {
|
||||
entityViewId: view.id,
|
||||
name: filter.name,
|
||||
condition: filter.condition,
|
||||
value: filter.value,
|
||||
match: filter.match ?? "and",
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
(viewTemplate.sort ?? []).map((sort, sortIndex) =>
|
||||
db.entityViewSort.create({
|
||||
data: {
|
||||
entityViewId: view.id,
|
||||
name: sort.name,
|
||||
asc: sort.asc,
|
||||
order: sortIndex + 1,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relationship of CRM_ENTITIES_TEMPLATE.relationships) {
|
||||
const parent = entityByName.get(relationship.parent);
|
||||
const child = entityByName.get(relationship.child);
|
||||
if (!parent || !child) {
|
||||
continue;
|
||||
}
|
||||
const existing = await db.entityRelationship.findFirst({
|
||||
where: { parentId: parent.id, childId: child.id, title: relationship.title ?? null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
await db.entityRelationship.create({
|
||||
data: {
|
||||
parentId: parent.id,
|
||||
childId: child.id,
|
||||
order: relationship.order,
|
||||
title: relationship.title ?? null,
|
||||
type: relationship.type,
|
||||
required: relationship.required,
|
||||
cascade: relationship.cascade ?? false,
|
||||
readOnly: relationship.readOnly ?? false,
|
||||
hiddenIfEmpty: relationship.hiddenIfEmpty ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultViewPropertyNames(entityTemplate: (typeof CRM_ENTITIES_TEMPLATE.entities)[number]) {
|
||||
const displayProperties = entityTemplate.properties.filter((property) => property.isDisplay).map((property) => property.name);
|
||||
if (displayProperties.length > 0) {
|
||||
return displayProperties;
|
||||
}
|
||||
return entityTemplate.properties.slice(0, 4).map((property) => property.name);
|
||||
}
|
||||
|
||||
async function ensureProductCoreCrmPermissions() {
|
||||
const { createPermissions } = await import("~/utils/db/permissions/permissions.db.server");
|
||||
const entities = await db.entity.findMany({
|
||||
where: { name: { in: [...productCoreCrmEntityNames] }, active: true },
|
||||
});
|
||||
let order = 0;
|
||||
for (const entity of entities) {
|
||||
const entityPermissions = getEntityPermissions(entity);
|
||||
await createPermissions(
|
||||
entityPermissions.map((permission) => ({
|
||||
inRoles: [DefaultAdminRoles.SuperAdmin, DefaultAppRoles.SuperUser, DefaultAppRoles.Admin, DefaultAppRoles.User].map((role) => role.toString()),
|
||||
name: permission.name as DefaultPermission,
|
||||
description: permission.description,
|
||||
type: entity.type,
|
||||
})),
|
||||
order
|
||||
);
|
||||
order += entityPermissions.length;
|
||||
}
|
||||
}
|
||||
|
||||
function toProductCorePropertyType(type: TemplateEntityPropertyTypeDto): PropertyType {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return PropertyType.TEXT;
|
||||
case "number":
|
||||
return PropertyType.NUMBER;
|
||||
case "boolean":
|
||||
return PropertyType.BOOLEAN;
|
||||
case "date":
|
||||
return PropertyType.DATE;
|
||||
case "media":
|
||||
return PropertyType.MEDIA;
|
||||
case "select":
|
||||
return PropertyType.SELECT;
|
||||
case "multiSelect":
|
||||
return PropertyType.MULTI_SELECT;
|
||||
case "multiText":
|
||||
return PropertyType.MULTI_TEXT;
|
||||
case "rangeNumber":
|
||||
return PropertyType.RANGE_NUMBER;
|
||||
case "rangeDate":
|
||||
return PropertyType.RANGE_DATE;
|
||||
case "formula":
|
||||
return PropertyType.FORMULA;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* CRM-contacts public search — hot-path migration to Meilisearch.
|
||||
*
|
||||
* Wraps `searchWithFallback({ index: 'crm-contacts', tenantId, query,
|
||||
* fallback })` so consumers of the contact list / contact filter can
|
||||
* adopt the typed client without further refactors. When MEILI_URL is
|
||||
* unset, or SEARCH_BACKEND=sql is forced, or Meilisearch raises a
|
||||
* connectivity error, the helper transparently falls back to a
|
||||
* SQL ILIKE-against-RowValue scan over the contact entity.
|
||||
*
|
||||
* Contacts are stored as rows with `entityName = "contact"`; the SQL
|
||||
* fallback therefore reuses the legacy row-search code-path so the
|
||||
* shape returned from both backends is the same set of `Row` IDs that
|
||||
* the caller can hydrate via the existing rows.db.server helpers.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSearchClient,
|
||||
crmContactsIndex,
|
||||
searchWithFallback,
|
||||
type SearchClient,
|
||||
type SearchFallback,
|
||||
type SearchHits,
|
||||
} from "@toolctrl/search";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
let cachedClient: SearchClient | undefined;
|
||||
let cachedUrl: string | undefined;
|
||||
|
||||
function getSearchClient(): SearchClient | undefined {
|
||||
const url = process.env.MEILI_URL;
|
||||
if (!url || process.env.SEARCH_BACKEND === "sql") return undefined;
|
||||
if (cachedClient && cachedUrl === url) return cachedClient;
|
||||
cachedClient = createSearchClient({ url, apiKey: process.env.MEILI_API_KEY ?? undefined });
|
||||
cachedUrl = url;
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
export interface SearchCrmContactsInput {
|
||||
/** Tenant scope — REQUIRED for the Meilisearch path. The SQL
|
||||
* fallback also scopes by tenantId. */
|
||||
readonly tenantId: string;
|
||||
/** User-supplied query string. Empty string returns the first page. */
|
||||
readonly query: string;
|
||||
/** Optional cap on result count. Defaults to 50. */
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface CrmContactSearchHit {
|
||||
readonly id: string;
|
||||
readonly firstName: string;
|
||||
readonly lastName: string;
|
||||
readonly email: string;
|
||||
readonly company: string;
|
||||
readonly jobTitle: string;
|
||||
}
|
||||
|
||||
export interface CrmContactSearchResult {
|
||||
readonly hits: readonly CrmContactSearchHit[];
|
||||
readonly total: number;
|
||||
readonly fellBack: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL fallback — uses RowValue text-search joined to the `contact`
|
||||
* entity. Returns the same hit-shape Meilisearch returns so the caller
|
||||
* doesn't have to branch.
|
||||
*/
|
||||
async function sqlFallback(input: SearchCrmContactsInput): Promise<SearchHits> {
|
||||
const limit = input.limit ?? 50;
|
||||
const q = input.query.trim();
|
||||
const where: Record<string, unknown> = {
|
||||
tenantId: input.tenantId,
|
||||
deletedAt: null,
|
||||
entity: { name: "contact" },
|
||||
};
|
||||
if (q.length > 0) {
|
||||
where.values = {
|
||||
some: {
|
||||
textValue: { contains: q, mode: "insensitive" },
|
||||
},
|
||||
};
|
||||
}
|
||||
const rows = await db.row.findMany({
|
||||
where: where as never,
|
||||
take: limit,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
values: {
|
||||
select: {
|
||||
textValue: true,
|
||||
property: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
hits: rows.map((row) => {
|
||||
const get = (name: string): string => {
|
||||
const v = row.values.find((vv) => vv.property?.name === name);
|
||||
return v?.textValue ?? "";
|
||||
};
|
||||
return {
|
||||
document: {
|
||||
id: row.id,
|
||||
tenantId: input.tenantId,
|
||||
firstName: get("firstName"),
|
||||
lastName: get("lastName"),
|
||||
email: get("email"),
|
||||
company: get("company"),
|
||||
jobTitle: get("jobTitle"),
|
||||
} as Record<string, unknown> & { id: string; tenantId: string },
|
||||
};
|
||||
}),
|
||||
estimatedTotalHits: rows.length,
|
||||
processingTimeMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchCrmContacts(input: SearchCrmContactsInput): Promise<CrmContactSearchResult> {
|
||||
const fallback: SearchFallback = async () => sqlFallback(input);
|
||||
|
||||
const client = getSearchClient();
|
||||
if (!client) {
|
||||
const sql = await sqlFallback(input);
|
||||
return {
|
||||
hits: sql.hits.map((hit) => toContactHit(hit.document)),
|
||||
total: sql.estimatedTotalHits ?? sql.hits.length,
|
||||
fellBack: true,
|
||||
};
|
||||
}
|
||||
|
||||
let fellBack = false;
|
||||
const hits = await searchWithFallback({
|
||||
client,
|
||||
schema: crmContactsIndex,
|
||||
tenantId: input.tenantId,
|
||||
query: input.query,
|
||||
fallback,
|
||||
onFallback: () => {
|
||||
fellBack = true;
|
||||
},
|
||||
options: { limit: input.limit ?? 50 },
|
||||
});
|
||||
|
||||
return {
|
||||
hits: hits.hits.map((hit) => toContactHit(hit.document)),
|
||||
total: hits.estimatedTotalHits ?? hits.hits.length,
|
||||
fellBack,
|
||||
};
|
||||
}
|
||||
|
||||
function toContactHit(document: Record<string, unknown>): CrmContactSearchHit {
|
||||
return {
|
||||
id: String(document.id ?? ""),
|
||||
firstName: typeof document.firstName === "string" ? document.firstName : "",
|
||||
lastName: typeof document.lastName === "string" ? document.lastName : "",
|
||||
email: typeof document.email === "string" ? document.email : "",
|
||||
company: typeof document.company === "string" ? document.company : "",
|
||||
jobTitle: typeof document.jobTitle === "string" ? document.jobTitle : "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Unit tests for searchCrmContacts — the public CRM-contacts search
|
||||
* entry-point. These tests cover the SQL-fallback path (when MEILI_URL
|
||||
* is unset) so the legacy SQL contract is exercised without requiring
|
||||
* a live Meilisearch container.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
row: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import { searchCrmContacts } from "./crmContactSearch.server";
|
||||
|
||||
const dbMock = db as unknown as {
|
||||
row: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("searchCrmContacts (sql fallback)", () => {
|
||||
const originalMeili = process.env.MEILI_URL;
|
||||
const originalBackend = process.env.SEARCH_BACKEND;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.MEILI_URL;
|
||||
delete process.env.SEARCH_BACKEND;
|
||||
dbMock.row.findMany.mockReset();
|
||||
});
|
||||
|
||||
afterEachRestore(originalMeili, originalBackend);
|
||||
|
||||
it("scopes by tenant + entity=contact and maps row values to contact-hit fields", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-1",
|
||||
values: [
|
||||
{ textValue: "Alice", property: { name: "firstName" } },
|
||||
{ textValue: "Anderson", property: { name: "lastName" } },
|
||||
{ textValue: "alice@example.com", property: { name: "email" } },
|
||||
{ textValue: "Acme", property: { name: "company" } },
|
||||
{ textValue: "CTO", property: { name: "jobTitle" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await searchCrmContacts({ tenantId: "tenant-1", query: "alice" });
|
||||
|
||||
expect(dbMock.row.findMany).toHaveBeenCalledTimes(1);
|
||||
const where = dbMock.row.findMany.mock.calls[0][0].where as Record<string, unknown>;
|
||||
expect(where.tenantId).toBe("tenant-1");
|
||||
expect((where.entity as { name: string }).name).toBe("contact");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(where.values).toBeDefined();
|
||||
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.hits).toHaveLength(1);
|
||||
expect(result.hits[0]).toEqual({
|
||||
id: "row-1",
|
||||
firstName: "Alice",
|
||||
lastName: "Anderson",
|
||||
email: "alice@example.com",
|
||||
company: "Acme",
|
||||
jobTitle: "CTO",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the value-text predicate when the query is empty", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
await searchCrmContacts({ tenantId: "tenant-1", query: "" });
|
||||
|
||||
const where = dbMock.row.findMany.mock.calls[0][0].where as Record<string, unknown>;
|
||||
expect(where.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honours the limit option", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([]);
|
||||
await searchCrmContacts({ tenantId: "tenant-1", query: "x", limit: 5 });
|
||||
expect(dbMock.row.findMany.mock.calls[0][0].take).toBe(5);
|
||||
});
|
||||
|
||||
it("returns total = number of fallback hits when Meilisearch is unavailable", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([
|
||||
{ id: "r1", values: [] },
|
||||
{ id: "r2", values: [] },
|
||||
{ id: "r3", values: [] },
|
||||
]);
|
||||
const result = await searchCrmContacts({ tenantId: "tenant-1", query: "" });
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.hits).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
function afterEachRestore(originalMeili: string | undefined, originalBackend: string | undefined) {
|
||||
// restore env vars after the file completes (vitest doesn't gather them
|
||||
// via beforeEach, so we centralize the cleanup here)
|
||||
if (typeof afterAll === "function") {
|
||||
afterAll(() => {
|
||||
if (originalMeili === undefined) delete process.env.MEILI_URL;
|
||||
else process.env.MEILI_URL = originalMeili;
|
||||
if (originalBackend === undefined) delete process.env.SEARCH_BACKEND;
|
||||
else process.env.SEARCH_BACKEND = originalBackend;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type GetCertDto = {
|
||||
configured: boolean;
|
||||
records?: {
|
||||
A?: { name: string; value: string };
|
||||
AAAA?: { name: string; value: string };
|
||||
CNAME?: { name: string; value: string };
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { AppConfiguration, getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
import FlyDomainsServer from "./FlyDomains.server";
|
||||
import { GetCertDto } from "../dtos/GetCertDto";
|
||||
|
||||
async function getConfig({ appConfiguration, request }: { appConfiguration?: AppConfiguration; request: Request }) {
|
||||
if (!appConfiguration) {
|
||||
appConfiguration = await getAppConfiguration({ request });
|
||||
}
|
||||
const domains = appConfiguration.portals?.domains;
|
||||
if (!domains?.enabled) {
|
||||
throw new Error("Custom domains are not enabled");
|
||||
}
|
||||
switch (domains.provider) {
|
||||
case "fly": {
|
||||
if (!loadWebConfig().config.portalServer.flyToken) {
|
||||
throw new Error("PORTAL_SERVER_FLY_TOKEN is not defined");
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown custom domain provider: ${domains.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addCert({ hostname, request }: { hostname: string; request: Request }) {
|
||||
const domainsConfig = await getConfig({ request });
|
||||
const flyToken = loadWebConfig().config.portalServer.flyToken ?? "";
|
||||
switch (domainsConfig.provider) {
|
||||
case "fly": {
|
||||
await FlyDomainsServer.addCert({
|
||||
appId: domainsConfig.portalAppId,
|
||||
hostname,
|
||||
apiToken: flyToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getCert({ hostname, request }: { hostname: string | null; request: Request }) {
|
||||
if (!hostname) {
|
||||
return null;
|
||||
}
|
||||
const domainsConfig = await getConfig({ request });
|
||||
const flyToken = loadWebConfig().config.portalServer.flyToken ?? "";
|
||||
switch (domainsConfig.provider) {
|
||||
case "fly": {
|
||||
const cer = await FlyDomainsServer.getCert({
|
||||
appId: domainsConfig.portalAppId,
|
||||
hostname,
|
||||
apiToken: flyToken,
|
||||
}).catch(() => null);
|
||||
if (!cer) {
|
||||
return null;
|
||||
}
|
||||
const certificate: GetCertDto = {
|
||||
configured: cer.data.app.certificate.configured,
|
||||
records: {
|
||||
A: {
|
||||
name: getApexDomain(hostname),
|
||||
value: domainsConfig.records?.A ?? "",
|
||||
},
|
||||
AAAA: {
|
||||
name: getApexDomain(hostname),
|
||||
value: domainsConfig.records?.AAAA ?? "",
|
||||
},
|
||||
CNAME: {
|
||||
name: cer.data.app.certificate.dnsValidationHostname,
|
||||
value: cer.data.app.certificate.dnsValidationTarget,
|
||||
},
|
||||
},
|
||||
};
|
||||
return certificate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getApexDomain(hostname: string) {
|
||||
// if root domain, return @ otherwise remove the root domain
|
||||
return hostname === getRootDomain(hostname) ? "@" : hostname.replace("." + getRootDomain(hostname), "");
|
||||
}
|
||||
function getRootDomain(hostname: string) {
|
||||
const parts = hostname.split(".");
|
||||
return parts.slice(-2).join(".");
|
||||
}
|
||||
|
||||
async function delCert({ hostname, request }: { hostname: string; request: Request }) {
|
||||
const domainsConfig = await getConfig({ request });
|
||||
const flyToken = loadWebConfig().config.portalServer.flyToken ?? "";
|
||||
switch (domainsConfig.provider) {
|
||||
case "fly": {
|
||||
return FlyDomainsServer.delCert({
|
||||
appId: domainsConfig.portalAppId,
|
||||
hostname,
|
||||
apiToken: flyToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getConfig,
|
||||
addCert,
|
||||
getCert,
|
||||
delCert,
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createScopedLogger } from "~/utils/logger.server";
|
||||
|
||||
const flyDomainsLogger = createScopedLogger("domains.fly");
|
||||
|
||||
const endpoint = "https://api.fly.io/graphql";
|
||||
|
||||
async function addCert({ appId, hostname, apiToken }: { appId: string; hostname: string; apiToken: string }) {
|
||||
flyDomainsLogger.info({ appId, hostname }, "FlyApi.addCert");
|
||||
const query = `
|
||||
mutation($appId: ID!, $hostname: String!) {
|
||||
addCertificate(appId: $appId, hostname: $hostname) {
|
||||
certificate {
|
||||
configured
|
||||
acmeDnsConfigured
|
||||
acmeAlpnConfigured
|
||||
certificateAuthority
|
||||
certificateRequestedAt
|
||||
dnsProvider
|
||||
dnsValidationInstructions
|
||||
dnsValidationHostname
|
||||
dnsValidationTarget
|
||||
hostname
|
||||
id
|
||||
source
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = { appId, hostname };
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
const data = await response.json();
|
||||
flyDomainsLogger.debug({ data }, "FlyApi.addCert response");
|
||||
return data;
|
||||
}
|
||||
|
||||
export type FlyGetCertDto = {
|
||||
data: {
|
||||
app: {
|
||||
certificate: {
|
||||
configured: boolean;
|
||||
acmeDnsConfigured: boolean;
|
||||
acmeAlpnConfigured: boolean;
|
||||
certificateAuthority: string;
|
||||
createdAt: string;
|
||||
dnsProvider: string;
|
||||
dnsValidationInstructions: string;
|
||||
dnsValidationHostname: string;
|
||||
dnsValidationTarget: string;
|
||||
hostname: string;
|
||||
id: string;
|
||||
source: string;
|
||||
clientStatus: string;
|
||||
issued: {
|
||||
nodes: Array<any>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
async function getCert({ appId, hostname, apiToken }: { appId: string; hostname: string; apiToken: string }): Promise<FlyGetCertDto> {
|
||||
const query = `
|
||||
query($appName: String!, $hostname: String!) {
|
||||
app(name: $appName) {
|
||||
certificate(hostname: $hostname) {
|
||||
configured
|
||||
acmeDnsConfigured
|
||||
acmeAlpnConfigured
|
||||
certificateAuthority
|
||||
createdAt
|
||||
dnsProvider
|
||||
dnsValidationInstructions
|
||||
dnsValidationHostname
|
||||
dnsValidationTarget
|
||||
hostname
|
||||
id
|
||||
source
|
||||
clientStatus
|
||||
issued {
|
||||
nodes {
|
||||
type
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = { appName: appId, hostname };
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.errors && data.errors.length) {
|
||||
throw new Error(data.errors[0].message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// async function checkCert({ appId, hostname, apiToken }: { appId: string; hostname: string; apiToken: string }) {
|
||||
// const query = `
|
||||
// query($appName: String!, $hostname: String!) {
|
||||
// app(name: $appName) {
|
||||
// certificate(hostname: $hostname) {
|
||||
// check
|
||||
// configured
|
||||
// acmeDnsConfigured
|
||||
// acmeAlpnConfigured
|
||||
// certificateAuthority
|
||||
// createdAt
|
||||
// dnsProvider
|
||||
// dnsValidationInstructions
|
||||
// dnsValidationHostname
|
||||
// dnsValidationTarget
|
||||
// hostname
|
||||
// id
|
||||
// source
|
||||
// clientStatus
|
||||
// issued {
|
||||
// nodes {
|
||||
// type
|
||||
// expiresAt
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }`;
|
||||
|
||||
// const variables = { appName: appId, hostname };
|
||||
|
||||
// const response = await fetch(endpoint, {
|
||||
// method: "POST",
|
||||
// headers: {
|
||||
// "Content-Type": "application/json",
|
||||
// Authorization: `Bearer ${apiToken}`,
|
||||
// },
|
||||
// body: JSON.stringify({ query, variables }),
|
||||
// });
|
||||
// if (response.status !== 200) {
|
||||
// return null;
|
||||
// }
|
||||
// const data = await response.json();
|
||||
// return data;
|
||||
// }
|
||||
|
||||
async function delCert({ appId, hostname, apiToken }: { appId: string; hostname: string; apiToken: string }) {
|
||||
const query = `
|
||||
mutation($appId: ID!, $hostname: String!) {
|
||||
deleteCertificate(appId: $appId, hostname: $hostname) {
|
||||
app {
|
||||
name
|
||||
}
|
||||
certificate {
|
||||
hostname
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = { appId: appId, hostname: hostname };
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
const data = await response.json();
|
||||
flyDomainsLogger.debug({ data }, "FlyApi.delCert response");
|
||||
return data;
|
||||
}
|
||||
|
||||
export default {
|
||||
addCert,
|
||||
getCert,
|
||||
// checkCert,
|
||||
delCert,
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { useLoaderData } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import ColorBadge from "~/components/ui/badges/ColorBadge";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import { EmailMarketing_Summary } from "../routes/EmailMarketing_Summary";
|
||||
|
||||
export default function EmailMarketingSummaryRoute() {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const data = useLoaderData<EmailMarketing_Summary.LoaderData>();
|
||||
return (
|
||||
<div className="mx-auto mb-12 max-w-5xl space-y-5 px-4 py-4 sm:px-6 lg:px-8 xl:max-w-7xl">
|
||||
<div className="border-border border-b pb-5">
|
||||
<h3 className="text-foreground text-lg leading-6 font-medium">{t("shared.overview")}</h3>
|
||||
</div>
|
||||
<dl className="grid gap-2 sm:grid-cols-3">
|
||||
<div className="bg-card border-border overflow-hidden rounded-lg border px-4 py-3 shadow-xs">
|
||||
<dt className="text-muted-foreground truncate text-xs font-medium uppercase">
|
||||
<div>{t("emailMarketing:overview.avgOpenRate")}</div>
|
||||
</dt>
|
||||
<dd className="text-foreground mt-1 truncate text-2xl font-semibold">
|
||||
{formatters.percent(data.summary.avgOpenRate / 100, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="bg-card border-border overflow-hidden rounded-lg border px-4 py-3 shadow-xs">
|
||||
<dt className="text-muted-foreground truncate text-xs font-medium uppercase">
|
||||
<div>{t("emailMarketing:overview.avgClickRate")}</div>
|
||||
</dt>
|
||||
<dd className="text-foreground mt-1 truncate text-2xl font-semibold">
|
||||
{formatters.percent(data.summary.avgClickRate / 100, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="bg-card border-border overflow-hidden rounded-lg border px-4 py-3 shadow-xs">
|
||||
<dt className="text-muted-foreground flex items-center space-x-2 truncate text-xs font-medium uppercase">
|
||||
<ColorBadge color={Colors.GREEN} />
|
||||
<div>{t("emailMarketing:overview.totalSent")}</div>
|
||||
</dt>
|
||||
<dd className="text-foreground mt-1 flex items-baseline space-x-1 truncate text-2xl font-semibold">
|
||||
<div>
|
||||
{formatters.number(data.summary.outboundEmails.delivered)}/{formatters.number(data.summary.outboundEmails.sent)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs font-normal lowercase">{t("emails.delivered")}</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FormGroup from "~/components/ui/forms/FormGroup";
|
||||
import InputSelect from "~/components/ui/input/InputSelect";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import { useRootData } from "~/utils/data/useRootData";
|
||||
import { EmailSenderWithoutApiKey } from "~/modules/emailMarketing/db/emailSender";
|
||||
|
||||
interface Props {
|
||||
item?: EmailSenderWithoutApiKey;
|
||||
}
|
||||
|
||||
export default function EmailSenderForm({ item }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootData = useRootData();
|
||||
const [provider, setProvider] = useState<string>(item?.provider ?? "mailjet");
|
||||
const [stream, setStream] = useState<string>(item?.stream ?? "default");
|
||||
const [apiKey, setApiKey] = useState<string>("*******************");
|
||||
const [secretKey, setSecretKey] = useState<string>("*******************");
|
||||
const [fromEmail, setFromEmail] = useState<string>(item?.fromEmail ?? "marketing@" + rootData.appConfiguration.app.domain);
|
||||
// const [fromName, setFromName] = useState<string>(item?.fromName ?? "");
|
||||
// const [replyToEmail, setReplyToEmail] = useState<string>(item?.replyToEmail ?? "");
|
||||
|
||||
return (
|
||||
<FormGroup id={item?.id} editing={true}>
|
||||
{/* <input type="hidden" name="order" value={order} hidden readOnly /> */}
|
||||
<InputSelect
|
||||
name="provider"
|
||||
title={t("emailMarketing:senders.provider")}
|
||||
value={provider}
|
||||
setValue={(e) => setProvider(e?.toString() ?? "")}
|
||||
options={[
|
||||
{
|
||||
name: "Mailjet",
|
||||
value: "mailjet",
|
||||
},
|
||||
]}
|
||||
required
|
||||
/>
|
||||
<InputText name="stream" title={t("emailMarketing:senders.stream") + " / Domain ID"} value={stream} setValue={setStream} required />
|
||||
<InputText type="password" name="apiKey" title={t("emailMarketing:senders.apiKey")} value={apiKey} setValue={setApiKey} required disabled={!!item} />
|
||||
<InputText type="password" name="secretKey" title={t("emailMarketing:senders.secretKey")} value={secretKey} setValue={setSecretKey} required disabled={!!item} />
|
||||
<InputText name="fromEmail" title={t("emailMarketing:senders.fromEmail")} value={fromEmail} setValue={setFromEmail} required />
|
||||
{/* <InputText name="fromName" title={t("emailMarketing:senders.fromName")} value={fromName} setValue={setFromName} />
|
||||
<InputText name="replyToEmail" title={t("emailMarketing:senders.replyToEmail")} value={replyToEmail} setValue={setReplyToEmail} /> */}
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
import { Link, useParams } from "react-router";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { OutboundEmailWithDetails } from "~/modules/emailMarketing/db/outboundEmails.db.server";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import CheckIcon from "~/components/ui/icons/CheckIcon";
|
||||
import XIcon from "~/components/ui/icons/XIcon";
|
||||
import Modal from "~/components/ui/modals/Modal";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { createLocaleFormatters, useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export default function OutboundEmailsTable({
|
||||
items,
|
||||
pagination,
|
||||
withCampaign,
|
||||
allEntities,
|
||||
}: {
|
||||
items: OutboundEmailWithDetails[];
|
||||
pagination?: PaginationDto;
|
||||
withCampaign?: boolean;
|
||||
allEntities: EntityWithDetails[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const formatters = useMemo(() => createLocaleFormatters(i18n.language), [i18n.language]);
|
||||
const params = useParams();
|
||||
const [headers, setHeaders] = useState<RowHeaderDisplayDto<OutboundEmailWithDetails>[]>([]);
|
||||
const [contactEntity, setContactEntity] = useState<EntityWithDetails>();
|
||||
const [selectedEmail, setSelectedEmail] = useState<OutboundEmailWithDetails>();
|
||||
|
||||
useEffect(() => {
|
||||
setContactEntity(allEntities.find((e) => e.name === "contact"));
|
||||
}, [allEntities]);
|
||||
|
||||
useEffect(() => {
|
||||
let headers: RowHeaderDisplayDto<OutboundEmailWithDetails>[] = [
|
||||
{
|
||||
name: "email",
|
||||
title: t("emails.to"),
|
||||
value: (i) => (
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
{i.contactRow && contactEntity ? (
|
||||
<div>{RowHelper.getTextDescription({ entity: contactEntity, item: i.contactRow, t })}</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs italic">
|
||||
<div>{i.email}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "sentAt",
|
||||
title: t("emails.sentAt"),
|
||||
value: (i) => (
|
||||
<div className="flex justify-center">{i.sentAt ? <OutboundEmailDateFormat date={i.sentAt} /> : <XIcon className="h-4 w-4 text-red-500" />}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "delivered",
|
||||
title: t("emails.delivered"),
|
||||
value: (i) => (
|
||||
<div className="flex justify-center">
|
||||
{i.deliveredAt ? <CheckIcon className="h-4 w-4 text-teal-500" /> : <XIcon className="h-4 w-4 text-red-500" />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "activity",
|
||||
title: t("emailMarketing:activity"),
|
||||
value: (i) => (
|
||||
<div className="flex flex-col justify-start space-y-1 text-left">
|
||||
<button
|
||||
type="button"
|
||||
disabled={i.opens.length === 0}
|
||||
onClick={() => setSelectedEmail(i)}
|
||||
className={clsx(
|
||||
i.opens.length === 0 ? "text-muted-foreground cursor-not-allowed text-left" : "text-blue-600 underline",
|
||||
"text-muted-foreground lowercase"
|
||||
)}
|
||||
>
|
||||
{formatters.number(i.opens.length)} {t("emails.opens")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={i.clicks.length === 0}
|
||||
onClick={() => setSelectedEmail(i)}
|
||||
className={clsx(
|
||||
i.clicks.length === 0 ? "text-muted-foreground cursor-not-allowed text-left" : "text-blue-600 underline",
|
||||
"text-muted-foreground lowercase"
|
||||
)}
|
||||
>
|
||||
{formatters.number(i.clicks.length)} {t("emails.clicks")}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "error",
|
||||
title: t("shared.error"),
|
||||
value: (i) => <div className="text-red-500">{i.error}</div>,
|
||||
},
|
||||
{
|
||||
name: "unsubscribedAt",
|
||||
title: t("emails.unsubscribedAt"),
|
||||
value: (i) => <OutboundEmailDateFormat date={i.unsubscribedAt} />,
|
||||
},
|
||||
{
|
||||
name: "bouncedAt",
|
||||
title: t("emails.bouncedAt"),
|
||||
value: (i) => <OutboundEmailDateFormat date={i.bouncedAt} />,
|
||||
},
|
||||
{
|
||||
name: "spamComplainedAt",
|
||||
title: t("emails.spamComplainedAt"),
|
||||
value: (i) => <OutboundEmailDateFormat date={i.spamComplainedAt} />,
|
||||
},
|
||||
];
|
||||
if (withCampaign) {
|
||||
headers = [
|
||||
{
|
||||
name: "campaignId",
|
||||
title: t("emailMarketing:campaign"),
|
||||
value: (item) => (
|
||||
<div>
|
||||
{item.campaignId ? (
|
||||
<Link
|
||||
to={
|
||||
params.tenant
|
||||
? appPath({ tenantSlug: params.tenant, path: `email-marketing/campaigns/${item.campaignId}`, mode: "host-aware" })
|
||||
: adminPath(`email-marketing/campaigns/${item.campaignId}`, { mode: "host-aware" })
|
||||
}
|
||||
className="focus:bg-secondary/90 hover:border-border rounded-md border-b border-dashed border-transparent"
|
||||
>
|
||||
{item.campaign?.name}
|
||||
</Link>
|
||||
) : (
|
||||
<div>{item.isPreview ? <SimpleBadge title={t("shared.preview")} color={Colors.GRAY} /> : <div>-</div>}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...headers,
|
||||
];
|
||||
}
|
||||
setHeaders(headers);
|
||||
}, [contactEntity, formatters, params.tenant, t, withCampaign]);
|
||||
return (
|
||||
<div>
|
||||
<TableSimple headers={headers} items={items} pagination={pagination} />
|
||||
<Modal className="sm:max-w-md" open={!!selectedEmail} setOpen={() => setSelectedEmail(undefined)}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-foreground text-lg font-medium leading-6">{t("emails.emailActivity")}</h3>
|
||||
</div>
|
||||
<div className="border-border overflow-hidden rounded-md border-2 border-dashed">
|
||||
<div className="h-64 overflow-y-auto">{selectedEmail && <OutboundEmailActivity email={selectedEmail} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutboundEmailDateFormat({ date }: { date: Date | null }) {
|
||||
const formatters = useLocaleFormatters();
|
||||
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div title={formatters.dateTime(date, { timeStyle: "medium" })} className="flex flex-col">
|
||||
<div>{formatters.date(date, { dateStyle: "medium" })}</div>
|
||||
<div className="text-xs">{formatters.relativeDate(date)}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OutboundEmailActivity({ email }: { email: OutboundEmailWithDetails }) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const [items, setItems] = useState<{ type: "click" | "open"; createdAt: Date; description: string }[]>([]);
|
||||
useEffect(() => {
|
||||
const items: { type: "click" | "open"; createdAt: Date; description: string }[] = [];
|
||||
email.opens.forEach((o) => {
|
||||
items.push({
|
||||
type: "open",
|
||||
createdAt: o.createdAt,
|
||||
description: o.firstOpen ? t("emailMarketing:activityLog.firstOpen") : "",
|
||||
});
|
||||
});
|
||||
email.clicks.forEach((c) => {
|
||||
items.push({
|
||||
type: "click",
|
||||
createdAt: c.createdAt,
|
||||
description: c.link,
|
||||
});
|
||||
});
|
||||
setItems(items.sort((a, b) => (b.createdAt > a.createdAt ? -1 : 1)));
|
||||
}, [email, t]);
|
||||
return (
|
||||
<TableSimple
|
||||
items={items}
|
||||
headers={[
|
||||
{
|
||||
name: "createdAt",
|
||||
title: t("emailMarketing:activityLog.date"),
|
||||
value: (i) => <div className="text-muted-foreground">{formatters.dateTime(i.createdAt, { timeStyle: "medium" })}</div>,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
title: t("emailMarketing:activityLog.type"),
|
||||
value: (i) => (i.type === "open" ? t("emailMarketing:activityLog.open") : t("emailMarketing:activityLog.click")),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
title: t("shared.description"),
|
||||
value: (i) => i.description,
|
||||
className: "w-full",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
import { useParams, useSubmit, Form, useActionData, useNavigation, useLoaderData } from "react-router";
|
||||
import clsx from "clsx";
|
||||
import { useState, useRef, useEffect, FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
|
||||
import InputSearch from "~/components/ui/input/InputSearch";
|
||||
import InputSelector from "~/components/ui/input/InputSelector";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
||||
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import { Campaigns_Edit } from "../../routes/Campaigns_Edit";
|
||||
import OutboundEmailsTable from "../OutboundEmailsTable";
|
||||
import toast from "react-hot-toast";
|
||||
import InputContent from "~/components/ui/input/InputContent";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export default function CampaignsEditRoute() {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const params = useParams();
|
||||
const data = useLoaderData<Campaigns_Edit.LoaderData>();
|
||||
const actionData = useActionData<Campaigns_Edit.ActionData>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const submit = useSubmit();
|
||||
|
||||
const confirmDelete = useRef<RefConfirmModal>(null);
|
||||
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [maxDisplayedEmails, setMaxDisplayedEmails] = useState(1000000);
|
||||
|
||||
const [name, setName] = useState(data.item.name);
|
||||
const [subject, setSubject] = useState(data.item.subject);
|
||||
const [senderId, setSenderId] = useState<string | number | undefined>(data.item.emailSenderId);
|
||||
const [contentType, setContentType] = useState<"wysiwyg" | "markdown">("wysiwyg");
|
||||
const [htmlBody, setHtmlBody] = useState(data.item.htmlBody);
|
||||
const recipientCount = data.item.recipients.length;
|
||||
const formattedRecipientCount = formatters.number(recipientCount);
|
||||
|
||||
const modalConfirm = useRef<RefConfirmModal>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
} else if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
submit(formData, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function sendTest(i?: RowWithDetails) {
|
||||
const email = window.prompt(t("emails.object"), appOrAdminData.user?.email);
|
||||
if (!email || email.trim() === "") {
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("action", "send-preview");
|
||||
form.set("email", email);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function send() {
|
||||
modalConfirm.current?.show(
|
||||
t("emailMarketing:confirmSend"),
|
||||
t("shared.send"),
|
||||
t("shared.back"),
|
||||
t("emailMarketing:sendingToContacts", { count: recipientCount, formattedCount: formattedRecipientCount })
|
||||
);
|
||||
}
|
||||
|
||||
function onConfirmSend() {
|
||||
const form = new FormData();
|
||||
form.set("action", "send");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
confirmDelete.current?.show(t("shared.delete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
|
||||
}
|
||||
function onDeleteConfirm() {
|
||||
const form = new FormData();
|
||||
form.set("action", "delete");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
// function saveEmail() {
|
||||
// const form = new FormData();
|
||||
// form.set("action", "update");
|
||||
// form.set("htmlBody", htmlBody);
|
||||
// submit(form, {
|
||||
// method: "post",
|
||||
// });
|
||||
// }
|
||||
function canUpdate() {
|
||||
if (data.item.status !== "draft") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
name.trim() === "" ||
|
||||
name !== data.item.name ||
|
||||
subject.trim() === "" ||
|
||||
subject !== data.item.subject ||
|
||||
senderId !== data.item.emailSenderId ||
|
||||
htmlBody.trim() !== data.item.htmlBody.trim()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function filteredRecipients() {
|
||||
let emails = data.item.recipients;
|
||||
// if (maxDisplayedEmails === 10) {
|
||||
// emails = emails.slice(0, 10);
|
||||
// }
|
||||
if (!searchInput.trim()) {
|
||||
return emails;
|
||||
}
|
||||
return emails.filter((f) => f.email?.toString().toUpperCase().includes(searchInput.toUpperCase()));
|
||||
}
|
||||
return (
|
||||
<EditPageLayout
|
||||
title={t("emailMarketing:campaignDetails")}
|
||||
menu={[
|
||||
{
|
||||
title: t("emailMarketing:campaigns"),
|
||||
routePath: UrlUtils.getModulePath(params, "email-marketing/campaigns"),
|
||||
},
|
||||
{
|
||||
title: t("shared.edit"),
|
||||
routePath: UrlUtils.getModulePath(params, `email-marketing/campaigns/${params.id}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Form onSubmit={handleSubmit} className="pb-20">
|
||||
{/* <input type="hidden" name="action" value="create" hidden readOnly /> */}
|
||||
<input type="hidden" name="action" value="update" hidden readOnly />
|
||||
|
||||
<div className="relative space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* <InputGroup title="Email" className="bg-secondary"> */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<InputSelector
|
||||
name="status"
|
||||
title={t("shared.status")}
|
||||
value={data.item.status}
|
||||
withColors={true}
|
||||
disabled={true}
|
||||
options={[
|
||||
{
|
||||
value: "draft",
|
||||
name: t("emailMarketing:campaignList.status.draft"),
|
||||
color: Colors.YELLOW,
|
||||
},
|
||||
{
|
||||
value: "sending",
|
||||
name: t("emailMarketing:campaignList.status.sending"),
|
||||
color: Colors.ORANGE,
|
||||
},
|
||||
{
|
||||
value: "incomplete",
|
||||
name: t("emailMarketing:campaignList.status.incomplete"),
|
||||
color: Colors.RED,
|
||||
},
|
||||
{
|
||||
value: "completed",
|
||||
name: t("emailMarketing:campaignList.status.completed"),
|
||||
color: Colors.GREEN,
|
||||
},
|
||||
].map((i) => {
|
||||
return {
|
||||
value: i.value,
|
||||
name: i.name,
|
||||
color: i.color,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<InputText
|
||||
name="name"
|
||||
title={t("shared.name")}
|
||||
value={name}
|
||||
setValue={setName}
|
||||
placeholder={t("emailMarketing:campaignList.form.namePlaceholder")}
|
||||
required
|
||||
disabled={data.item.status !== "draft"}
|
||||
/>
|
||||
<InputSelector
|
||||
name="senderId"
|
||||
title={t("emails.sender")}
|
||||
value={senderId}
|
||||
setValue={setSenderId}
|
||||
disabled={data.item.status !== "draft"}
|
||||
withSearch={false}
|
||||
options={data.emailSenders.map((s) => {
|
||||
return {
|
||||
name: s.fromEmail,
|
||||
value: s.id,
|
||||
};
|
||||
})}
|
||||
required
|
||||
/>
|
||||
<InputText
|
||||
className="sm:col-span-3"
|
||||
name="subject"
|
||||
title={t("emails.subject")}
|
||||
value={subject}
|
||||
setValue={setSubject}
|
||||
required
|
||||
disabled={data.item.status !== "draft"}
|
||||
/>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<div className="w-full space-y-2">
|
||||
{/* <Tabs
|
||||
asLinks={false}
|
||||
onSelected={(selected) => {
|
||||
setEmailBodyTab(selected === 0 ? "html" : "text");
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
tabs={[{ name: "HTML" }, { name: "Preview" }]}
|
||||
/> */}
|
||||
<div>
|
||||
{data.item.status === "draft" && <input type="hidden" name="htmlBody" value={htmlBody} hidden readOnly />}
|
||||
{/* {emailBodyTab === "html" && ( */}
|
||||
<div className={clsx(data.item.status === "draft" ? "bg-background" : "bg-secondary/90")}>
|
||||
<InputContent
|
||||
name="htmlBody"
|
||||
value={htmlBody}
|
||||
onChangeValue={setHtmlBody}
|
||||
contentType={contentType}
|
||||
onChangeContentType={setContentType}
|
||||
disabled={data.item.status === "completed"}
|
||||
/>
|
||||
</div>
|
||||
{data.item.status === "draft" && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<ButtonSecondary type="submit" disabled={!canUpdate()}>
|
||||
{t("shared.saveChanges")}
|
||||
</ButtonSecondary>
|
||||
</div>
|
||||
)}
|
||||
{/* )} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* </InputGroup> */}
|
||||
|
||||
<div className="">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-foreground text-sm font-medium">
|
||||
{t("emails.recipientsCount", { count: recipientCount, formattedCount: formattedRecipientCount })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputSearch value={searchInput} setValue={setSearchInput} />
|
||||
<div className="bg-background max-h-96 overflow-auto rounded-md shadow-md">
|
||||
<OutboundEmailsTable allEntities={data.allEntities} items={filteredRecipients()} />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
{maxDisplayedEmails === 10 && (
|
||||
<ButtonTertiary onClick={() => setMaxDisplayedEmails(100000)}>
|
||||
{t("emailMarketing:campaignList.actions.showRecipients", { count: recipientCount, formattedCount: formattedRecipientCount })}
|
||||
</ButtonTertiary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.item.status === "draft" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{data.item.status === "draft" && (
|
||||
<ButtonSecondary
|
||||
destructive
|
||||
onClick={onDelete}
|
||||
disabled={data.item.status !== "draft" || navigation.state === "submitting"}
|
||||
className="truncate"
|
||||
>
|
||||
{t("shared.delete")}
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between space-x-2">
|
||||
<ButtonSecondary disabled={navigation.state === "submitting"} onClick={() => sendTest()} className="truncate">
|
||||
{t("emailMarketing:sendPreview")}
|
||||
</ButtonSecondary>
|
||||
<ButtonPrimary disabled={navigation.state === "submitting"} onClick={send} className="truncate">
|
||||
{t("emailMarketing:sendCampaignToContacts", { count: recipientCount, formattedCount: formattedRecipientCount })}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<ConfirmModal ref={modalConfirm} onYes={onConfirmSend} />
|
||||
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
|
||||
</EditPageLayout>
|
||||
);
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { useParams, useLoaderData, useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import WarningBanner from "~/components/ui/banners/WarningBanner";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import SentIconFilled from "~/components/ui/icons/emails/SentIconFilled";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import TabsWithIcons from "~/components/ui/tabs/TabsWithIcons";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { Campaigns_List } from "../../routes/Campaigns_List";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export default function CampaignsListRoute() {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const data = useLoaderData<Campaigns_List.LoaderData>();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
function countStatus(status?: string) {
|
||||
if (!status) {
|
||||
let total = 0;
|
||||
data.groupByStatus.forEach((item) => {
|
||||
total += item.count;
|
||||
});
|
||||
return total;
|
||||
}
|
||||
const item = data.groupByStatus.find((item) => item.status === status);
|
||||
return item ? item.count : 0;
|
||||
}
|
||||
function tabLabel(key: "all" | "draft" | "sending" | "incomplete" | "completed", count: number) {
|
||||
return t(`emailMarketing:campaignList.tabs.${key}`, { count, formattedCount: formatters.number(count) });
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto mb-12 max-w-5xl space-y-5 px-4 py-4 sm:px-6 lg:px-8 xl:max-w-7xl">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex-grow">
|
||||
<TabsWithIcons
|
||||
tabs={[
|
||||
{
|
||||
name: tabLabel("all", countStatus()),
|
||||
href: "?",
|
||||
current: !searchParams.get("status") || searchParams.get("status") === "all",
|
||||
},
|
||||
{
|
||||
name: tabLabel("draft", countStatus("draft")),
|
||||
href: "?status=draft",
|
||||
current: searchParams.get("status") === "draft",
|
||||
},
|
||||
{
|
||||
name: tabLabel("sending", countStatus("sending")),
|
||||
href: "?status=sending",
|
||||
current: searchParams.get("status") === "sending",
|
||||
},
|
||||
{
|
||||
name: tabLabel("incomplete", countStatus("incomplete")),
|
||||
href: "?status=incomplete",
|
||||
current: searchParams.get("status") === "incomplete",
|
||||
},
|
||||
{
|
||||
name: tabLabel("completed", countStatus("completed")),
|
||||
href: "?status=completed",
|
||||
current: searchParams.get("status") === "completed",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ButtonPrimary to="new">
|
||||
<div>{t("shared.new")}</div>
|
||||
<SentIconFilled className="h-5 w-5" />
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.emailSenders.length === 0 && (
|
||||
<WarningBanner
|
||||
title={t("shared.warning")}
|
||||
text={t("emailMarketing:senders.requiredForCampaign")}
|
||||
redirect={UrlUtils.getModulePath(params, "email-marketing/senders")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TableSimple
|
||||
items={data.items}
|
||||
actions={[
|
||||
{
|
||||
title: t("shared.overview"),
|
||||
onClickRoute: (_, i) => `${i.id}`,
|
||||
},
|
||||
]}
|
||||
headers={[
|
||||
{
|
||||
name: "campaign",
|
||||
title: t("emailMarketing:campaign"),
|
||||
value: (i) => (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-base font-bold">{i.name}</div>
|
||||
<div>
|
||||
<CampaignStatusBadge status={i.status} />
|
||||
</div>
|
||||
{i.sentAt ? (
|
||||
<>
|
||||
<div>•</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<span>{formatters.date(i.sentAt, { dateStyle: "medium" })}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{(() => {
|
||||
const recipientCount = i.recipients.length;
|
||||
const deliveredCount = i.recipients.filter((f) => f.deliveredAt).length;
|
||||
const openedCount = i.recipients.filter((f) => f.opens.length > 0).length;
|
||||
const clickedCount = i.recipients.filter((f) => f.clicks.length > 0).length;
|
||||
const unsubscribedCount = i.recipients.filter((f) => f.unsubscribedAt).length;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{t("emailMarketing:campaignList.metrics.recipients", {
|
||||
count: recipientCount,
|
||||
formattedCount: formatters.number(recipientCount),
|
||||
})}
|
||||
</div>
|
||||
{i.track && (
|
||||
<>
|
||||
<div>•</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatters.number(deliveredCount)}/{formatters.number(recipientCount)} {t("emails.delivered")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{i.track && (
|
||||
<>
|
||||
<div>•</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatters.percent(recipientCount > 0 ? openedCount / recipientCount : 0, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{" "}
|
||||
{t("emailMarketing:campaignList.metrics.openRate")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{i.track && (
|
||||
<>
|
||||
<div>•</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatters.percent(recipientCount > 0 ? clickedCount / recipientCount : 0, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{" "}
|
||||
{t("emailMarketing:campaignList.metrics.clickRate")}
|
||||
</div>
|
||||
<div>•</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("emailMarketing:campaignList.metrics.clicks", {
|
||||
count: clickedCount,
|
||||
formattedCount: formatters.number(clickedCount),
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{i.track && (
|
||||
<>
|
||||
<div>•</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("emailMarketing:campaignList.metrics.unsubscribers", {
|
||||
count: unsubscribedCount,
|
||||
formattedCount: formatters.number(unsubscribedCount),
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
noRecords={
|
||||
<div className="p-12 text-center">
|
||||
<h3 className="mt-1 text-sm font-medium text-gray-900">{t("emailMarketing:campaignList.empty.title")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{t("emailMarketing:campaignList.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CampaignStatusBadge({ status }: { status: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{status === "draft" && <SimpleBadge title={t("emailMarketing:campaignList.status.draft")} color={Colors.YELLOW} />}
|
||||
{status === "sending" && <SimpleBadge title={t("emailMarketing:campaignList.status.sending")} color={Colors.ORANGE} />}
|
||||
{status === "incomplete" && <SimpleBadge title={t("emailMarketing:campaignList.status.incomplete")} color={Colors.RED} />}
|
||||
{status === "completed" && <SimpleBadge title={t("emailMarketing:campaignList.status.completed")} color={Colors.GREEN} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { useParams, useSubmit, Form, Link, useActionData, useLoaderData } from "react-router";
|
||||
import { useState, useEffect, FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import InputGroup from "~/components/ui/forms/InputGroup";
|
||||
import InputSelector from "~/components/ui/input/InputSelector";
|
||||
import InputText from "~/components/ui/input/InputText";
|
||||
import { EntityViewsApi } from "~/utils/api/.server/EntityViewsApi";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import { Campaigns_New } from "../../routes/Campaigns_New";
|
||||
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
||||
import toast from "react-hot-toast";
|
||||
import InputContent from "~/components/ui/input/InputContent";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getDefaultCampaignHtmlBody } from "./campaignDefaults";
|
||||
|
||||
export default function CampaignsNewRoute() {
|
||||
const { t } = useTranslation();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const data = useLoaderData<Campaigns_New.LoaderData>();
|
||||
const actionData = useActionData<Campaigns_New.ActionData>();
|
||||
const params = useParams();
|
||||
|
||||
const submit = useSubmit();
|
||||
|
||||
const [name, setName] = useState(() => t("emailMarketing:defaults.campaignName"));
|
||||
const [subject, setSubject] = useState(() => t("emailMarketing:defaults.subject"));
|
||||
const [senderId, setSenderId] = useState<string | number | undefined>(data.emailSenders.length > 0 ? data.emailSenders[0].id : "");
|
||||
const [contentType, setContentType] = useState<"wysiwyg" | "markdown">("wysiwyg");
|
||||
const [htmlBody, setHtmlBody] = useState(() => getDefaultCampaignHtmlBody(t));
|
||||
const [selectedContactsViewId, setSelectedContactsViewId] = useState<string>(data.contactsViews.length > 0 ? (data.contactsViews[0].view?.id ?? "") : "");
|
||||
const [, setSelectedContactsView] = useState<EntityViewsApi.GetEntityViewsWithRows>();
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
} else if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedContactsView(data.contactsViews.find((v) => v.view?.id === selectedContactsViewId));
|
||||
}, [data.contactsViews, selectedContactsViewId]);
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
submit(formData, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function sendTest(i?: RowWithDetails) {
|
||||
const email = window.prompt(t("emails.object"), appOrAdminData.user?.email);
|
||||
if (!email || email.trim() === "") {
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("action", i ? "send-contact-preview" : "send-preview");
|
||||
form.set("senderId", senderId?.toString() ?? "");
|
||||
form.set("contactRowId", i?.id.toString() ?? "");
|
||||
form.set("email", email);
|
||||
form.set("subject", subject);
|
||||
form.set("htmlBody", htmlBody);
|
||||
form.set("textBody", "");
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EditPageLayout
|
||||
title={t("emailMarketing:newCampaign")}
|
||||
menu={[
|
||||
{
|
||||
title: t("emailMarketing:campaigns"),
|
||||
routePath: UrlUtils.getModulePath(params, "email-marketing/campaigns"),
|
||||
},
|
||||
{ title: t("shared.new") },
|
||||
]}
|
||||
>
|
||||
<Form onSubmit={handleSubmit} className="pb-10">
|
||||
<input type="hidden" name="action" value="create" hidden readOnly />
|
||||
|
||||
<div className="relative space-y-4">
|
||||
<div className="sticky top-0 right-0 left-0 z-10">
|
||||
<div className="border-border bg-background rounded-lg border border-dashed p-4">
|
||||
<div className="space-y-2 lg:flex lg:justify-between lg:space-y-0 lg:space-x-2">
|
||||
<div className="grow">
|
||||
<InputText name="name" title={t("shared.name")} value={name} setValue={setName} withLabel={false} placeholder={t("emailMarketing:campaignList.form.namePlaceholder")} required />
|
||||
</div>
|
||||
<div className="flex justify-between space-x-2">
|
||||
<ButtonSecondary onClick={() => sendTest()}>{t("emailMarketing:sendPreview")}</ButtonSecondary>
|
||||
<ButtonPrimary type="submit">{t("emailMarketing:saveDraft")}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="">
|
||||
<InputGroup title={t("emails.object")}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<InputSelector
|
||||
name="senderId"
|
||||
title={t("emails.sender")}
|
||||
value={senderId}
|
||||
setValue={setSenderId}
|
||||
withSearch={false}
|
||||
hint={
|
||||
data.emailSenders.length === 0 ? (
|
||||
<>
|
||||
<Link to={params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/senders", mode: "host-aware" }) : adminPath(`email-marketing/senders`, { mode: "host-aware" })}>
|
||||
<span className="text-xs hover:underline">{t("emailMarketing:senders.manage")}</span>
|
||||
</Link>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
options={data.emailSenders.map((s) => {
|
||||
return {
|
||||
name: s.fromEmail,
|
||||
value: s.id,
|
||||
};
|
||||
})}
|
||||
required
|
||||
/>
|
||||
<InputSelector
|
||||
required
|
||||
name="contactViewId"
|
||||
title={t("emails.recipientList")}
|
||||
value={selectedContactsViewId}
|
||||
setValue={(e) => setSelectedContactsViewId(e?.toString() ?? "")}
|
||||
options={data.contactsViews.map((v) => {
|
||||
return {
|
||||
value: v.view?.id,
|
||||
name: (v.view ? t(v.view.title) : t("shared.default")) + " (" + v.rowsCount + ")",
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<InputText name="subject" title={t("emails.subject")} value={subject} setValue={setSubject} required placeholder={t("emails.subject")} />
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
{/* <input type="hidden" name="htmlBody" value={htmlBody} hidden readOnly /> */}
|
||||
<InputContent name="htmlBody" value={htmlBody} onChangeValue={setHtmlBody} contentType={contentType} onChangeContentType={setContentType} />
|
||||
</div>
|
||||
</div>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</EditPageLayout>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDefaultCampaignHtmlBody } from "./campaignDefaults";
|
||||
|
||||
const defaultCopy: Record<string, string> = {
|
||||
"emailMarketing:defaults.body.testEmail": "This is a test email.",
|
||||
"emailMarketing:defaults.body.richTextIntro": "It includes",
|
||||
"emailMarketing:defaults.body.bold": "bold",
|
||||
"emailMarketing:defaults.body.italics": "italics",
|
||||
"emailMarketing:defaults.body.combinedFormatting": "and combined formatting with",
|
||||
"emailMarketing:defaults.body.strikethroughIntro": "It also includes",
|
||||
"emailMarketing:defaults.body.strikethrough": "strikethrough",
|
||||
"emailMarketing:defaults.body.exampleLink": "A link",
|
||||
"emailMarketing:defaults.body.end": "The end.",
|
||||
"emailMarketing:defaults.body.managePreferences": "Manage preferences",
|
||||
};
|
||||
|
||||
function createT(copy: Record<string, string> = defaultCopy) {
|
||||
return ((key: string) => copy[key] ?? key) as TFunction;
|
||||
}
|
||||
|
||||
describe("campaign defaults", () => {
|
||||
it("builds translated campaign HTML without leaking i18n expressions", () => {
|
||||
const html = getDefaultCampaignHtmlBody(createT());
|
||||
|
||||
expect(html).toContain("<p>This is a test email.</p>");
|
||||
expect(html).toContain(">A link</a");
|
||||
expect(html).toContain(">Manage preferences</a");
|
||||
expect(html).not.toContain("i18nT(");
|
||||
expect(html).not.toContain("ui.autogenerated");
|
||||
});
|
||||
|
||||
it("escapes translated text before injecting it into the default HTML", () => {
|
||||
const html = getDefaultCampaignHtmlBody(createT({ ...defaultCopy, "emailMarketing:defaults.body.testEmail": "<script>alert('x')</script>" }));
|
||||
|
||||
expect(html).toContain("<script>alert('x')</script>");
|
||||
expect(html).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export function getDefaultCampaignHtmlBody(t: TFunction) {
|
||||
return `<p>${escapeHtml(t("emailMarketing:defaults.body.testEmail"))}</p>
|
||||
<p>${escapeHtml(t("emailMarketing:defaults.body.richTextIntro"))} <strong>${escapeHtml(t("emailMarketing:defaults.body.bold"))}</strong>, <em>${escapeHtml(t("emailMarketing:defaults.body.italics"))}</em>, ${escapeHtml(t("emailMarketing:defaults.body.combinedFormatting"))} <strong><em>${escapeHtml(t("emailMarketing:defaults.body.bold"))}</em></strong
|
||||
>. ${escapeHtml(t("emailMarketing:defaults.body.strikethroughIntro"))} <s>${escapeHtml(t("emailMarketing:defaults.body.strikethrough"))}</s>.
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline text-blue-500"
|
||||
href="https://www.example.com"
|
||||
>${escapeHtml(t("emailMarketing:defaults.body.exampleLink"))}</a
|
||||
>.
|
||||
</p>
|
||||
<p>-</p>
|
||||
<p>${escapeHtml(t("emailMarketing:defaults.body.end"))}</p>
|
||||
<p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline text-blue-500"
|
||||
href="https://www.example.com/preferences"
|
||||
>${escapeHtml(t("emailMarketing:defaults.body.managePreferences"))}</a
|
||||
>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function read(relativePath: string) {
|
||||
return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("email marketing i18n", () => {
|
||||
const files = [
|
||||
"apps/web/app/modules/emailMarketing/components/campaigns/CampaignsNewRoute.tsx",
|
||||
"apps/web/app/modules/emailMarketing/components/campaigns/CampaignsEditRoute.tsx",
|
||||
"apps/web/app/modules/emailMarketing/components/campaigns/CampaignsListRoute.tsx",
|
||||
"apps/web/app/modules/emailMarketing/components/senders/SendersListRoute.tsx",
|
||||
"apps/web/app/modules/emailMarketing/components/EmailMarketingSummaryRoute.tsx",
|
||||
"apps/web/app/modules/emailMarketing/components/OutboundEmailsTable.tsx",
|
||||
"apps/web/app/modules/emailMarketing/routes/Campaigns_List.ts",
|
||||
"apps/web/app/modules/emailMarketing/routes/Campaigns_New.ts",
|
||||
"apps/web/app/modules/emailMarketing/routes/OutboundEmails_List.ts",
|
||||
"apps/web/app/modules/emailMarketing/routes/Senders_List.ts",
|
||||
"apps/web/app/modules/emailMarketing/services/EmailMarketingService.ts",
|
||||
];
|
||||
|
||||
it("keeps campaign and activity UI copy behind emailMarketing keys", () => {
|
||||
const source = files.map(read).join("\n");
|
||||
|
||||
expect(source).toContain("emailMarketing:campaignList.status.draft");
|
||||
expect(source).toContain("emailMarketing:campaignList.metrics.openRate");
|
||||
expect(source).toContain("emailMarketing:senders.requiredForCampaign");
|
||||
expect(source).toContain("emailMarketing:senders.setup.mailjetWebhookRequired");
|
||||
expect(source).toContain("emailMarketing:senders.testEmail.subject");
|
||||
expect(source).toContain("emailMarketing:campaignPreview.testSubject");
|
||||
expect(source).toContain("emailMarketing:activityFilters.sender");
|
||||
expect(source).toContain("emailMarketing:activityFilters.campaign");
|
||||
expect(source).toContain("emailMarketing:activityFilters.preview");
|
||||
expect(source).toContain("emailMarketing:activityLog.firstOpen");
|
||||
expect(source).toContain("useLocaleFormatters");
|
||||
expect(source).toContain("formatters.number(deliveredCount)");
|
||||
expect(source).toContain("formatters.number(recipientCount)");
|
||||
expect(source).toContain("formatters.number(i.opens.length)");
|
||||
expect(source).toContain("formatters.number(i.clicks.length)");
|
||||
expect(source).toContain("emails.recipientsCount");
|
||||
expect(source).toContain("emailMarketing:campaignList.actions.showRecipients");
|
||||
expect(source).toContain("formattedRecipientCount");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
expect(source).not.toContain('from "~/utils/shared/NumberUtils"');
|
||||
expect(source).not.toContain('from "~/utils/shared/DateUtils"');
|
||||
expect(source).not.toContain('window.prompt("Email"');
|
||||
expect(source).not.toContain('"Draft"');
|
||||
expect(source).not.toContain('"No campaigns"');
|
||||
expect(source).not.toContain("open rate");
|
||||
expect(source).not.toContain("First open");
|
||||
expect(source).not.toContain("{i.opens.length} {t(\"emails.opens\")}");
|
||||
expect(source).not.toContain("{i.clicks.length} {t(\"emails.clicks\")}");
|
||||
expect(source).not.toContain("{ 0:");
|
||||
expect(source).not.toContain("{{0}}");
|
||||
expect(source).not.toContain("sendCampaignPreviewToContact");
|
||||
expect(source).not.toContain("sendCampaignPreviewToContacts");
|
||||
expect(source).not.toMatch(/sendCampaignToContact["')]/);
|
||||
expect(source).not.toContain("{data.item.recipients.length}");
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import { OutboundEmails_List } from "../../routes/OutboundEmails_List";
|
||||
import OutboundEmailsTable from "../OutboundEmailsTable";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
export default function OutboundEmailsListRoute() {
|
||||
const { t } = useTranslation();
|
||||
const data = useLoaderData<OutboundEmails_List.LoaderData>();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-3 px-4 py-2 pb-6 sm:px-6 sm:pt-3 lg:px-8 xl:max-w-full">
|
||||
<div className="md:border-border md:border-b md:py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-foreground text-lg font-medium leading-6">{t("emailMarketing:activity")}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<InputFilters withSearch={false} filters={data.filterableProperties} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OutboundEmailsTable allEntities={data.allEntities} items={data.items} pagination={data.pagination} withCampaign={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { useLoaderData, useNavigate, useParams } from "react-router";
|
||||
import OpenModal from "~/components/ui/modals/OpenModal";
|
||||
import { Senders_Edit } from "../../routes/Senders_Edit";
|
||||
import EmailSenderForm from "../EmailSenderForm";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export default function SendersEditRoute() {
|
||||
const data = useLoaderData<Senders_Edit.LoaderData>();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
function close() {
|
||||
navigate(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/senders", mode: "host-aware" }) : adminPath("email-marketing/senders", { mode: "host-aware" }));
|
||||
}
|
||||
return (
|
||||
<OpenModal className="sm:max-w-sm" onClose={close}>
|
||||
<EmailSenderForm item={data.item} />
|
||||
</OpenModal>
|
||||
);
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { useSubmit, Outlet, useLoaderData, useActionData } from "react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InfoBanner from "~/components/ui/banners/InfoBanner";
|
||||
import WarningBanner from "~/components/ui/banners/WarningBanner";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { EmailSenderWithoutApiKey } from "../../db/emailSender";
|
||||
import { Senders_List } from "../../routes/Senders_List";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export default function SendersListRoute() {
|
||||
const data = useLoaderData<Senders_List.LoaderData>();
|
||||
const { t } = useTranslation();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const submit = useSubmit();
|
||||
const actionData = useActionData<Senders_List.ActionData>();
|
||||
const hasMailjetSenders = data.items.some((item) => item.provider === "mailjet");
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.success) {
|
||||
toast.success(actionData.success);
|
||||
} else if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
function sendTest(item: EmailSenderWithoutApiKey) {
|
||||
const email = window.prompt(t("emailMarketing:senders.testEmail.prompt"), appOrAdminData.user?.email);
|
||||
if (!email || email.trim() === "") {
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("action", "send-test");
|
||||
form.set("senderId", item.id);
|
||||
form.set("email", email);
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto w-full max-w-5xl space-y-3 px-4 py-2 pb-6 sm:px-6 sm:pt-3 lg:px-8 xl:max-w-full">
|
||||
<div className="md:border-border md:border-b md:py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-foreground text-lg leading-6 font-medium">{t("emailMarketing:senders.plural")}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonPrimary to="new">{t("shared.new")}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.items.length === 0 ? (
|
||||
<InfoBanner title={t("shared.note")} text="">
|
||||
{t("emailMarketing:senders.setup.mailjetApiTokenPrefix")}{" "}
|
||||
<a className="underline" target="_blank" rel="noreferrer" href="https://app.mailjet.com/account/apikeys">
|
||||
{t("emailMarketing:senders.setup.apiTokensLink")}
|
||||
</a>
|
||||
{t("emailMarketing:senders.setup.mailjetApiTokenSuffix")}
|
||||
</InfoBanner>
|
||||
) : !data.hasSetWebhooks && hasMailjetSenders ? (
|
||||
<WarningBanner title={t("shared.warning")} text="">
|
||||
{t("emailMarketing:senders.setup.mailjetWebhookRequired")}
|
||||
</WarningBanner>
|
||||
) : null}
|
||||
|
||||
<TableSimple
|
||||
headers={[
|
||||
{
|
||||
name: "provider",
|
||||
title: t("emailMarketing:senders.provider"),
|
||||
value: (item) => item.provider,
|
||||
},
|
||||
{
|
||||
name: "stream",
|
||||
title: t("emailMarketing:senders.stream"),
|
||||
value: (item) => item.stream,
|
||||
},
|
||||
{
|
||||
name: "fromEmail",
|
||||
title: t("emailMarketing:senders.fromEmail"),
|
||||
value: (item) => item.fromEmail,
|
||||
},
|
||||
{
|
||||
name: "fromName",
|
||||
title: t("emailMarketing:senders.fromName"),
|
||||
value: (item) => item.fromName,
|
||||
},
|
||||
{
|
||||
name: "replyToEmail",
|
||||
title: t("emailMarketing:senders.replyToEmail"),
|
||||
value: (item) => item.replyToEmail,
|
||||
},
|
||||
]}
|
||||
items={data.items}
|
||||
actions={[
|
||||
{
|
||||
title: t("admin.emails.sendTest"),
|
||||
onClick: (_, item) => sendTest(item),
|
||||
},
|
||||
{
|
||||
title: t("shared.edit"),
|
||||
onClickRoute: (_, item) => item.id,
|
||||
},
|
||||
]}
|
||||
></TableSimple>
|
||||
</div>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import OpenModal from "~/components/ui/modals/OpenModal";
|
||||
import EmailSenderForm from "../EmailSenderForm";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export default function SendersNewRoute() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
function close() {
|
||||
navigate(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/senders", mode: "host-aware" }) : adminPath("email-marketing/senders", { mode: "host-aware" }));
|
||||
}
|
||||
return (
|
||||
<OpenModal className="sm:max-w-sm" onClose={close}>
|
||||
<EmailSenderForm />
|
||||
</OpenModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
const source = readRepoFile("apps/web/app/modules/emailMarketing/db/campaigns.db.server.ts");
|
||||
|
||||
describe("campaign db tenant scope", () => {
|
||||
test("updates campaigns and recipient replacement through tenant scope", () => {
|
||||
expect(source).toContain("export async function updateCampaign(");
|
||||
expect(source).toContain("tenantId: string | null,");
|
||||
expect(source).toContain("db.campaign.updateMany");
|
||||
expect(source).toContain("where: {\n id,\n tenantId,");
|
||||
expect(source).toContain('throw new Error("Campaign not found for tenant scope.")');
|
||||
expect(source).toContain("db.outboundEmail.deleteMany");
|
||||
expect(source).toContain("campaignId: id,\n tenantId,");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Campaign, EmailSender, OutboundEmail, OutboundEmailClick, OutboundEmailOpen } from "@toolctrl/db/prisma-browser";
|
||||
import UserModelHelper from "~/utils/helpers/models/UserModelHelper";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { RowWithValues } from "~/utils/db/entities/rows.db.server";
|
||||
|
||||
export type CampaignWithDetails = Campaign & {
|
||||
emailSender: EmailSender;
|
||||
recipients: (OutboundEmail & {
|
||||
campaign: Campaign | null;
|
||||
opens: OutboundEmailOpen[];
|
||||
clicks: OutboundEmailClick[];
|
||||
contactRow: RowWithValues | null;
|
||||
})[];
|
||||
};
|
||||
export async function getAllCampaigns(tenantId: string | null, status?: string): Promise<CampaignWithDetails[]> {
|
||||
return await db.campaign.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status,
|
||||
},
|
||||
include: {
|
||||
emailSender: true,
|
||||
recipients: {
|
||||
include: {
|
||||
campaign: true,
|
||||
opens: true,
|
||||
clicks: true,
|
||||
contactRow: {
|
||||
include: {
|
||||
createdByUser: { select: UserModelHelper.selectSimpleUserProperties },
|
||||
createdByApiKey: true,
|
||||
values: { include: { media: true, multiple: true, range: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sentAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaign(id: string, tenantId: string | null): Promise<CampaignWithDetails | null> {
|
||||
return await db.campaign.findFirst({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
include: {
|
||||
emailSender: true,
|
||||
recipients: {
|
||||
include: {
|
||||
campaign: true,
|
||||
opens: true,
|
||||
clicks: true,
|
||||
contactRow: {
|
||||
include: {
|
||||
createdByUser: { select: UserModelHelper.selectSimpleUserProperties },
|
||||
createdByApiKey: true,
|
||||
values: { include: { media: true, multiple: true, range: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCampaign(
|
||||
data: {
|
||||
emailSenderId: string;
|
||||
tenantId: string | null;
|
||||
name: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string | undefined;
|
||||
track: boolean;
|
||||
},
|
||||
recipients: { fromSenderId: string; email: string; contactRowId: string }[]
|
||||
): Promise<Campaign> {
|
||||
const item = await db.campaign.create({
|
||||
data: {
|
||||
...data,
|
||||
recipients: {
|
||||
create: recipients.map((recipient) => {
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
contactRowId: recipient.contactRowId,
|
||||
email: recipient.email,
|
||||
fromSenderId: recipient.fromSenderId,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function updateCampaign(
|
||||
id: string,
|
||||
tenantId: string | null,
|
||||
data: {
|
||||
name?: string;
|
||||
subject?: string;
|
||||
htmlBody?: string;
|
||||
textBody?: string | undefined;
|
||||
status?: string;
|
||||
track?: boolean;
|
||||
sentAt?: Date;
|
||||
},
|
||||
recipients?: { fromSenderId: string; email: string; contactRowId: string }[]
|
||||
): Promise<Campaign> {
|
||||
const result = await db.campaign.updateMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
throw new Error("Campaign not found for tenant scope.");
|
||||
}
|
||||
const item = await db.campaign.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (recipients) {
|
||||
await db.outboundEmail.deleteMany({
|
||||
where: {
|
||||
campaignId: id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
await db.outboundEmail.createMany({
|
||||
data: recipients.map((recipient) => {
|
||||
return {
|
||||
tenantId: item.tenantId,
|
||||
campaignId: id,
|
||||
contactRowId: recipient.contactRowId,
|
||||
email: recipient.email,
|
||||
fromSenderId: recipient.fromSenderId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function deleteCampaign(id: string, tenantId: string | null) {
|
||||
return await db.campaign.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function groupCampaigns(tenantId: string | null) {
|
||||
return await db.campaign.groupBy({
|
||||
by: ["status"],
|
||||
where: {
|
||||
tenantId,
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { EmailSender } from "@toolctrl/db/prisma-browser";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export type EmailSenderWithoutApiKey = Omit<EmailSender, "apiKey" | "apiSecret">;
|
||||
export async function getAllEmailSenders(tenantId: string | null): Promise<EmailSenderWithoutApiKey[]> {
|
||||
return await db.emailSender.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
provider: true,
|
||||
stream: true,
|
||||
fromName: true,
|
||||
fromEmail: true,
|
||||
replyToEmail: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
fromName: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEmailSender(id: string, tenantId: string | null): Promise<EmailSender | null> {
|
||||
return await db.emailSender.findFirst({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEmailSenderWithoutApiKey(id: string, tenantId: string | null): Promise<EmailSenderWithoutApiKey | null> {
|
||||
return await db.emailSender.findFirst({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
provider: true,
|
||||
stream: true,
|
||||
fromName: true,
|
||||
fromEmail: true,
|
||||
replyToEmail: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createEmailSender(data: {
|
||||
tenantId: string | null;
|
||||
provider: string;
|
||||
stream: string;
|
||||
apiKey: string;
|
||||
apiSecret?: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
replyToEmail?: string;
|
||||
}): Promise<EmailSender> {
|
||||
const item = await db.emailSender.create({
|
||||
data,
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function updateEmailSender(
|
||||
id: string,
|
||||
tenantId: string | null,
|
||||
data: {
|
||||
provider: string;
|
||||
stream: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
replyToEmail?: string;
|
||||
}
|
||||
): Promise<EmailSender> {
|
||||
const result = await db.emailSender.updateMany({
|
||||
where: { id, tenantId },
|
||||
data,
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
throw new Error("Email sender not found");
|
||||
}
|
||||
|
||||
const item = await getEmailSender(id, tenantId);
|
||||
if (!item) {
|
||||
throw new Error("Email sender not found");
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function deleteEmailSender(id: string, tenantId: string | null): Promise<EmailSender> {
|
||||
const existing = await getEmailSender(id, tenantId);
|
||||
if (!existing) {
|
||||
throw new Error("Email sender not found");
|
||||
}
|
||||
|
||||
await db.outboundEmail.deleteMany({
|
||||
where: { fromSenderId: id, tenantId },
|
||||
});
|
||||
const result = await db.emailSender.deleteMany({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
throw new Error("Email sender not found");
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { Campaign, OutboundEmail, OutboundEmailClick, OutboundEmailOpen, Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import Constants from "~/application/Constants";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import UserModelHelper from "~/utils/helpers/models/UserModelHelper";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { RowWithValues } from "~/utils/db/entities/rows.db.server";
|
||||
|
||||
export type OutboundEmailWithDetails = OutboundEmail & {
|
||||
campaign: Campaign | null;
|
||||
opens: OutboundEmailOpen[];
|
||||
clicks: OutboundEmailClick[];
|
||||
contactRow: RowWithValues | null;
|
||||
};
|
||||
|
||||
export async function getAllOutboundEmails(
|
||||
tenantId: string | null,
|
||||
options: {
|
||||
where?: Prisma.OutboundEmailWhereInput;
|
||||
pagination: { page: number; pageSize: number };
|
||||
}
|
||||
): Promise<{ items: OutboundEmailWithDetails[]; pagination: PaginationDto }> {
|
||||
const items = await db.outboundEmail.findMany({
|
||||
take: options.pagination.pageSize,
|
||||
skip: options.pagination.pageSize * (options.pagination.page - 1),
|
||||
where: {
|
||||
tenantId,
|
||||
...options.where,
|
||||
},
|
||||
include: {
|
||||
campaign: true,
|
||||
opens: true,
|
||||
clicks: true,
|
||||
contactRow: {
|
||||
include: {
|
||||
createdByUser: { select: UserModelHelper.selectSimpleUserProperties },
|
||||
createdByApiKey: true,
|
||||
values: { include: { media: true, multiple: true, range: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sentAt: "desc" }],
|
||||
});
|
||||
const totalItems = await db.outboundEmail.count({
|
||||
where: {
|
||||
tenantId,
|
||||
...options.where,
|
||||
},
|
||||
});
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: options.pagination?.page ?? 1,
|
||||
pageSize: options.pagination.pageSize ?? Constants.DEFAULT_PAGE_SIZE,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / (options.pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOutboundEmails(
|
||||
tenantId: string | null,
|
||||
options: {
|
||||
where?: Prisma.OutboundEmailWhereInput;
|
||||
}
|
||||
) {
|
||||
return await db.outboundEmail.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...options.where,
|
||||
},
|
||||
include: {
|
||||
campaign: true,
|
||||
opens: true,
|
||||
clicks: true,
|
||||
contactRow: {
|
||||
include: {
|
||||
createdByUser: { select: { id: true, email: true, firstName: true, lastName: true, phone: true } },
|
||||
values: { include: { media: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sentAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutboundEmail(id: string) {
|
||||
return await db.outboundEmail.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
campaign: true,
|
||||
opens: true,
|
||||
clicks: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOutboundEmailTest(data: {
|
||||
tenantId: string | null;
|
||||
contactRowId?: string;
|
||||
email: string;
|
||||
fromSenderId: string;
|
||||
}): Promise<OutboundEmail> {
|
||||
const item = await db.outboundEmail.create({
|
||||
data: {
|
||||
...data,
|
||||
isPreview: true,
|
||||
},
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function createOutboundEmail(data: {
|
||||
tenantId: string | null;
|
||||
contactRowId: string;
|
||||
email: string;
|
||||
fromSenderId: string;
|
||||
campaignId?: string;
|
||||
}): Promise<OutboundEmail> {
|
||||
const item = await db.outboundEmail.create({
|
||||
data,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function updateOutboundEmail(
|
||||
id: string,
|
||||
data: {
|
||||
error?: string;
|
||||
sentAt?: Date;
|
||||
deliveredAt?: Date;
|
||||
bouncedAt?: Date;
|
||||
spamComplainedAt?: Date;
|
||||
unsubscribedAt?: Date;
|
||||
}
|
||||
) {
|
||||
const item = await db.outboundEmail.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function openedOutboundEmail(outboundEmailId: string, data: { firstOpen: boolean }) {
|
||||
const item = await db.outboundEmailOpen.create({
|
||||
data: {
|
||||
outboundEmailId,
|
||||
firstOpen: data.firstOpen,
|
||||
},
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function clickedOutboundEmail(
|
||||
outboundEmailId: string,
|
||||
data: {
|
||||
link: string;
|
||||
}
|
||||
) {
|
||||
const item = await db.outboundEmailClick.create({
|
||||
data: {
|
||||
outboundEmailId,
|
||||
link: data.link,
|
||||
},
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function unsubscribeCampaignRecipient(
|
||||
id: string,
|
||||
data: {
|
||||
unsubscribedAt: Date;
|
||||
}
|
||||
) {
|
||||
const item = await db.outboundEmail.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function countOutboundEmails(
|
||||
tenantId: string | null,
|
||||
options?: {
|
||||
where: Prisma.OutboundEmailWhereInput;
|
||||
}
|
||||
): Promise<number> {
|
||||
return await db.outboundEmail.count({
|
||||
where: {
|
||||
tenantId,
|
||||
...options?.where,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { CampaignWithDetails, getCampaign, deleteCampaign, updateCampaign } from "../db/campaigns.db.server";
|
||||
import { EmailSenderWithoutApiKey, getAllEmailSenders } from "../db/emailSender";
|
||||
import EmailMarketingService from "../services/EmailMarketingService";
|
||||
import { EntityWithDetails, getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireEmailMarketingMutationAccess } from "./emailMarketingAccess.server";
|
||||
|
||||
export namespace Campaigns_Edit {
|
||||
export type LoaderData = {
|
||||
title: string;
|
||||
item: CampaignWithDetails;
|
||||
emailSenders: EmailSenderWithoutApiKey[];
|
||||
allEntities: EntityWithDetails[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const item = await getCampaign(params.id!, tenantId);
|
||||
if (!item) {
|
||||
return redirect(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/campaigns", mode: "host-aware" }) : adminPath("email-marketing/campaigns", { mode: "host-aware" }));
|
||||
}
|
||||
const emailSenders = await getAllEmailSenders(tenantId);
|
||||
const data: LoaderData = {
|
||||
title: `${item.name} | ${process.env.APP_NAME}`,
|
||||
item,
|
||||
emailSenders,
|
||||
allEntities: await getAllEntities({ tenantId, active: true }),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireEmailMarketingMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const item = await getCampaign(params.id!, tenantId);
|
||||
if (!item) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 404 });
|
||||
}
|
||||
if (action === "delete") {
|
||||
try {
|
||||
await deleteCampaign(params.id!, tenantId);
|
||||
return redirect(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/campaigns", mode: "host-aware" }) : adminPath("email-marketing/campaigns", { mode: "host-aware" }));
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else if (action === "update") {
|
||||
try {
|
||||
await updateCampaign(params.id!, tenantId, {
|
||||
name: form.get("name")?.toString(),
|
||||
subject: form.get("subject")?.toString(),
|
||||
htmlBody: form.get("htmlBody")?.toString(),
|
||||
textBody: form.get("textBody")?.toString(),
|
||||
// status: form.get("status")?.toString(),
|
||||
});
|
||||
return Response.json({ success: t("shared.saved") });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else if (action === "send") {
|
||||
try {
|
||||
await EmailMarketingService.sendCampaign(item);
|
||||
return Response.json({ success: t("shared.sent") }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else if (action === "send-preview") {
|
||||
try {
|
||||
const email = form.get("email")?.toString() ?? "";
|
||||
await EmailMarketingService.sendCampaignTest(item, email);
|
||||
return Response.json({ success: t("shared.sent") }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment node
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getAllCampaigns: vi.fn(),
|
||||
groupCampaigns: vi.fn(),
|
||||
getAllEmailSenders: vi.fn(),
|
||||
getTenantIdOrNull: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
t: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../db/campaigns.db.server", () => ({
|
||||
getAllCampaigns: mocks.getAllCampaigns,
|
||||
groupCampaigns: mocks.groupCampaigns,
|
||||
}));
|
||||
|
||||
vi.mock("../db/emailSender", () => ({
|
||||
getAllEmailSenders: mocks.getAllEmailSenders,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({
|
||||
getTenantIdOrNull: mocks.getTenantIdOrNull,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/loaders.middleware", () => ({
|
||||
requireAuth: mocks.requireAuth,
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: mocks.t,
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("email marketing campaigns loader performance", () => {
|
||||
it("starts independent campaign collections before awaiting any collection result", async () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireAuth.mockResolvedValue(undefined);
|
||||
mocks.getTenantIdOrNull.mockResolvedValue("tenant_1");
|
||||
mocks.t.mockReturnValue("Campaigns | ToolCtrl");
|
||||
|
||||
const campaigns = deferred<unknown[]>();
|
||||
const campaignGroups = deferred<{ status: string; _count: { _all: number } }[]>();
|
||||
const emailSenders = deferred<unknown[]>();
|
||||
const startedCalls: string[] = [];
|
||||
|
||||
mocks.getAllCampaigns.mockImplementation((tenantId: string | null, status: string | undefined) => {
|
||||
startedCalls.push("campaigns");
|
||||
expect(tenantId).toBe("tenant_1");
|
||||
expect(status).toBe("draft");
|
||||
return campaigns.promise;
|
||||
});
|
||||
mocks.groupCampaigns.mockImplementation((tenantId: string | null) => {
|
||||
startedCalls.push("groups");
|
||||
expect(tenantId).toBe("tenant_1");
|
||||
return campaignGroups.promise;
|
||||
});
|
||||
mocks.getAllEmailSenders.mockImplementation((tenantId: string | null) => {
|
||||
startedCalls.push("senders");
|
||||
expect(tenantId).toBe("tenant_1");
|
||||
return emailSenders.promise;
|
||||
});
|
||||
|
||||
const { Campaigns_List } = await import("./Campaigns_List");
|
||||
const loaderPromise = Campaigns_List.loader({
|
||||
request: new Request("https://app.example.test/app/acme/email-marketing/campaigns?status=draft"),
|
||||
params: { tenant: "acme" },
|
||||
context: {},
|
||||
} as LoaderFunctionArgs);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(startedCalls).toEqual(["campaigns", "groups", "senders"]);
|
||||
|
||||
campaigns.resolve([{ id: "campaign_1" }]);
|
||||
campaignGroups.resolve([{ status: "draft", _count: { _all: 2 } }]);
|
||||
emailSenders.resolve([{ id: "sender_1" }]);
|
||||
|
||||
await expect(loaderPromise).resolves.toEqual({
|
||||
title: "Campaigns | ToolCtrl",
|
||||
items: [{ id: "campaign_1" }],
|
||||
groupByStatus: [{ status: "draft", count: 2 }],
|
||||
emailSenders: [{ id: "sender_1" }],
|
||||
});
|
||||
expect(mocks.requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getTenantIdOrNull).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.t).toHaveBeenCalledWith("emailMarketing:meta.campaigns", { appName: process.env.APP_NAME });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { CampaignWithDetails, getAllCampaigns, groupCampaigns } from "../db/campaigns.db.server";
|
||||
import { EmailSenderWithoutApiKey, getAllEmailSenders } from "../db/emailSender";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export namespace Campaigns_List {
|
||||
export type LoaderData = {
|
||||
title: string;
|
||||
items: CampaignWithDetails[];
|
||||
groupByStatus: {
|
||||
status: string;
|
||||
count: number;
|
||||
}[];
|
||||
emailSenders: EmailSenderWithoutApiKey[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const status = searchParams.get("status")?.toString();
|
||||
const [items, campaignsCount, emailSenders] = await Promise.all([
|
||||
getAllCampaigns(tenantId, status),
|
||||
groupCampaigns(tenantId),
|
||||
getAllEmailSenders(tenantId),
|
||||
]);
|
||||
const groupByStatus = campaignsCount.map((item) => {
|
||||
return {
|
||||
status: item.status,
|
||||
count: item._count._all,
|
||||
};
|
||||
});
|
||||
const data: LoaderData = {
|
||||
title: i18nT("emailMarketing:meta.campaigns", { appName: process.env.APP_NAME }),
|
||||
items,
|
||||
groupByStatus,
|
||||
emailSenders,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { LoaderFunctionArgs, ActionFunction, redirect } from "react-router";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { EntityViewsApi } from "~/utils/api/.server/EntityViewsApi";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { EmailSenderWithoutApiKey, getAllEmailSenders, getEmailSender } from "../db/emailSender";
|
||||
import EmailMarketingService from "../services/EmailMarketingService";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireEmailMarketingMutationAccess } from "./emailMarketingAccess.server";
|
||||
import { t as i18nT } from "i18next";
|
||||
|
||||
export namespace Campaigns_New {
|
||||
export type LoaderData = {
|
||||
title: string;
|
||||
contactsViews: EntityViewsApi.GetEntityViewsWithRows[];
|
||||
emailSenders: EmailSenderWithoutApiKey[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const emailSenders = await getAllEmailSenders(tenantId);
|
||||
const data: LoaderData = {
|
||||
title: i18nT("emailMarketing:meta.newCampaign", { appName: process.env.APP_NAME }),
|
||||
emailSenders,
|
||||
contactsViews: await EntityViewsApi.getAll({
|
||||
entityName: "contact",
|
||||
tenantId,
|
||||
withDefault: false,
|
||||
withRows: false,
|
||||
}),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireEmailMarketingMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const email = form.get("email")?.toString() ?? "";
|
||||
const senderId = form.get("senderId")?.toString() ?? "";
|
||||
let subject = form.get("subject")?.toString() ?? "";
|
||||
let htmlBody = form.get("htmlBody")?.toString() ?? "";
|
||||
let textBody = form.get("textBody")?.toString() ?? "";
|
||||
const sender = await getEmailSender(senderId, tenantId);
|
||||
if (!sender) {
|
||||
return Response.json({ error: t("emailMarketing:errors.invalidSender") }, { status: 400 });
|
||||
}
|
||||
if (!htmlBody && !textBody) {
|
||||
return Response.json({ error: t("emailMarketing:errors.emailBodyRequired") }, { status: 400 });
|
||||
}
|
||||
if (action === "send-preview") {
|
||||
try {
|
||||
await EmailMarketingService.sendPreview({
|
||||
from: { sender, tenantId },
|
||||
email: { to: email, subject, htmlBody, textBody, track: true },
|
||||
});
|
||||
return Response.json({ success: t("shared.sent") }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else if (action === "send-contact-preview") {
|
||||
try {
|
||||
const contactRowId = form.get("contactRowId")?.toString() ?? "";
|
||||
await EmailMarketingService.sendContactPreview({
|
||||
contactRowId,
|
||||
from: { sender, tenantId },
|
||||
email: { to: email, subject, htmlBody, textBody, track: true },
|
||||
});
|
||||
return Response.json({ success: t("shared.sent") }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else if (action === "create") {
|
||||
const name = form.get("name")?.toString() ?? "";
|
||||
if (!name) {
|
||||
return Response.json({ error: t("emailMarketing:errors.invalidName") }, { status: 400 });
|
||||
}
|
||||
const contactViewId = form.get("contactViewId")?.toString();
|
||||
if (!contactViewId) {
|
||||
return Response.json({ error: t("emailMarketing:errors.invalidRecipientList") }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const campaign = await EmailMarketingService.createCampaignDraft({
|
||||
name: form.get("name")?.toString() ?? "",
|
||||
contactViewId,
|
||||
from: { sender, tenantId },
|
||||
email: { subject, htmlBody, textBody, track: true },
|
||||
});
|
||||
return redirect(
|
||||
params.tenant
|
||||
? appPath({ tenantSlug: params.tenant, path: `email-marketing/campaigns/${campaign.id}`, mode: "host-aware" })
|
||||
: adminPath(`email-marketing/campaigns/${campaign.id}`, { mode: "host-aware" })
|
||||
);
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import EmailMarketingService, { EmailMarketingSummaryDto } from "../services/EmailMarketingService";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
|
||||
export namespace EmailMarketing_Summary {
|
||||
export type LoaderData = {
|
||||
title: string;
|
||||
summary: EmailMarketingSummaryDto;
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const data: LoaderData = {
|
||||
title: `${t("emailMarketing:title")} | ${process.env.APP_NAME}`,
|
||||
summary: await EmailMarketingService.getSummary(tenantId),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { LoaderFunctionArgs } from "react-router";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getFiltersFromCurrentUrl, getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getAllCampaigns } from "../db/campaigns.db.server";
|
||||
import { getAllEmailSenders } from "../db/emailSender";
|
||||
import { OutboundEmailWithDetails, getAllOutboundEmails } from "../db/outboundEmails.db.server";
|
||||
import { EntityWithDetails, getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
|
||||
export namespace OutboundEmails_List {
|
||||
export type LoaderData = {
|
||||
items: OutboundEmailWithDetails[];
|
||||
pagination: PaginationDto;
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
allEntities: EntityWithDetails[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const emailSenders = await getAllEmailSenders(tenantId);
|
||||
const campaigns = await getAllCampaigns(tenantId);
|
||||
const filterableProperties: FilterablePropertyDto[] = [
|
||||
{
|
||||
name: "senderId",
|
||||
title: t("emailMarketing:activityFilters.sender"),
|
||||
options: [
|
||||
...emailSenders.map((i) => {
|
||||
return {
|
||||
value: i.id,
|
||||
name: i.fromEmail + " (" + i.provider + ")",
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "campaignId",
|
||||
title: t("emailMarketing:activityFilters.campaign"),
|
||||
options: [
|
||||
{ name: t("emailMarketing:activityFilters.preview"), value: "null" },
|
||||
...campaigns.map((i) => {
|
||||
return {
|
||||
value: i.id,
|
||||
name: i.name,
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters = getFiltersFromCurrentUrl(request, filterableProperties);
|
||||
const urlSearchParams = new URL(request.url).searchParams;
|
||||
const currentPagination = getPaginationFromCurrentUrl(urlSearchParams);
|
||||
const fromSenderId = filters.properties.find((f) => f.name === "senderId")?.value ?? undefined;
|
||||
const campaignId = filters.properties.find((f) => f.name === "campaignId")?.value;
|
||||
const { items, pagination } = await getAllOutboundEmails(tenantId, {
|
||||
where: {
|
||||
fromSenderId,
|
||||
campaignId: campaignId === "null" ? null : campaignId ?? undefined,
|
||||
},
|
||||
pagination: currentPagination,
|
||||
});
|
||||
const data: LoaderData = {
|
||||
items,
|
||||
pagination,
|
||||
filterableProperties,
|
||||
allEntities: await getAllEntities({ tenantId, active: true }),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { EmailSenderWithoutApiKey, getEmailSenderWithoutApiKey, getEmailSender, updateEmailSender, deleteEmailSender } from "../db/emailSender";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireEmailMarketingMutationAccess } from "./emailMarketingAccess.server";
|
||||
|
||||
export namespace Senders_Edit {
|
||||
export type LoaderData = {
|
||||
item: EmailSenderWithoutApiKey;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const item = await getEmailSenderWithoutApiKey(params.id!, tenantId);
|
||||
if (!item) {
|
||||
return redirect(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/senders", mode: "host-aware" }) : adminPath("email-marketing/senders", { mode: "host-aware" }));
|
||||
}
|
||||
const data: LoaderData = {
|
||||
item,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
};
|
||||
const badRequest = (data: ActionData) => Response.json(data, { status: 400 });
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireEmailMarketingMutationAccess({ request, params, tenantId });
|
||||
|
||||
const existing = await getEmailSender(params.id ?? "", tenantId);
|
||||
if (!existing) {
|
||||
return badRequest({ error: t("shared.notFound") });
|
||||
}
|
||||
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
|
||||
if (action === "edit") {
|
||||
try {
|
||||
const provider = form.get("provider")?.toString() ?? "";
|
||||
const stream = form.get("stream")?.toString() ?? "";
|
||||
const fromEmail = form.get("fromEmail")?.toString() ?? "";
|
||||
const fromName = form.get("fromName")?.toString() ?? "";
|
||||
const replyToEmail = form.get("replyToEmail")?.toString() ?? "";
|
||||
|
||||
await updateEmailSender(existing.id, tenantId, {
|
||||
provider,
|
||||
stream,
|
||||
fromEmail,
|
||||
fromName,
|
||||
replyToEmail,
|
||||
});
|
||||
return redirect(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/senders", mode: "host-aware" }) : adminPath("email-marketing/senders", { mode: "host-aware" }));
|
||||
} catch (e: any) {
|
||||
return badRequest({ error: e.message });
|
||||
}
|
||||
} else if (action === "delete") {
|
||||
await deleteEmailSender(existing.id, tenantId);
|
||||
return redirect(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/senders", mode: "host-aware" }) : adminPath("email-marketing/senders", { mode: "host-aware" }));
|
||||
}
|
||||
return badRequest({ error: t("shared.invalidForm") });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { LoaderFunctionArgs, ActionFunction } from "react-router";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { EmailSenderWithoutApiKey, getAllEmailSenders, getEmailSender } from "../db/emailSender";
|
||||
import { createOutboundEmailTest, findOutboundEmails, updateOutboundEmail } from "../db/outboundEmails.db.server";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { sendBroadcast } from "../services/EmailMarketingSenderService.server";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireEmailMarketingMutationAccess } from "./emailMarketingAccess.server";
|
||||
export namespace Senders_List {
|
||||
export type LoaderData = {
|
||||
items: EmailSenderWithoutApiKey[];
|
||||
hasSetWebhooks: boolean;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const items = await getAllEmailSenders(tenantId);
|
||||
const deliveredEmails = await findOutboundEmails(tenantId, {
|
||||
where: {
|
||||
deliveredAt: { not: null },
|
||||
},
|
||||
});
|
||||
const data: LoaderData = {
|
||||
items,
|
||||
hasSetWebhooks: deliveredEmails.length > 0,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireEmailMarketingMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const email = form.get("email")?.toString() ?? "";
|
||||
const senderId = form.get("senderId")?.toString() ?? "";
|
||||
if (action === "send-test") {
|
||||
const sender = await getEmailSender(senderId, tenantId);
|
||||
if (!sender) {
|
||||
return Response.json({ error: t("emailMarketing:errors.invalidSender") }, { status: 400 });
|
||||
}
|
||||
const outbountEmail = await createOutboundEmailTest({
|
||||
tenantId,
|
||||
email,
|
||||
fromSenderId: senderId,
|
||||
});
|
||||
try {
|
||||
await sendBroadcast({
|
||||
sender,
|
||||
to: email,
|
||||
subject: t("emailMarketing:senders.testEmail.subject"),
|
||||
htmlBody: getTestHtmlBody(t),
|
||||
textBody: `${t("emailMarketing:defaults.body.testEmailWithLink")}: https://www.example.com.\n`,
|
||||
track: true,
|
||||
metadata: {
|
||||
outboundEmailId: outbountEmail.id,
|
||||
},
|
||||
});
|
||||
await updateOutboundEmail(outbountEmail.id, {
|
||||
sentAt: new Date(),
|
||||
});
|
||||
return Response.json({ success: t("shared.sent") }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
await updateOutboundEmail(outbountEmail.id, {
|
||||
error: e.message,
|
||||
});
|
||||
return Response.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getTestHtmlBody(t: (key: string) => string) {
|
||||
return `${t("emailMarketing:defaults.body.testEmailWithLink")}:
|
||||
${t("emailMarketing:defaults.body.linkLabel")}: <a href="https://www.google.com">Google</a><br/>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { createEmailSender } from "../db/emailSender";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { validateCSRFToken } from "~/utils/session.server";
|
||||
import { requireEmailMarketingMutationAccess } from "./emailMarketingAccess.server";
|
||||
|
||||
export namespace Senders_New {
|
||||
export type LoaderData = {
|
||||
title: string;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const data: LoaderData = {
|
||||
title: `${t("emailMarketing:senders.plural")} | ${process.env.APP_NAME}`,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
};
|
||||
const badRequest = (data: ActionData) => Response.json(data, { status: 400 });
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
await requireAuth({ request, params });
|
||||
const { t } = await getTranslations(request);
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
await requireEmailMarketingMutationAccess({ request, params, tenantId });
|
||||
await validateCSRFToken(request);
|
||||
|
||||
const form = await request.formData();
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
if (action === "create") {
|
||||
try {
|
||||
const provider = form.get("provider")?.toString() ?? "";
|
||||
const stream = form.get("stream")?.toString() ?? "";
|
||||
const apiKey = form.get("apiKey")?.toString() ?? "";
|
||||
const apiSecret = form.get("secretKey")?.toString() ?? "";
|
||||
const fromEmail = form.get("fromEmail")?.toString() ?? "";
|
||||
const fromName = form.get("fromName")?.toString() ?? "";
|
||||
const replyToEmail = form.get("replyToEmail")?.toString() ?? "";
|
||||
|
||||
await createEmailSender({
|
||||
tenantId,
|
||||
provider,
|
||||
stream,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
fromEmail,
|
||||
fromName,
|
||||
replyToEmail,
|
||||
});
|
||||
return redirect(params.tenant ? appPath({ tenantSlug: params.tenant, path: "email-marketing/senders", mode: "host-aware" }) : adminPath("email-marketing/senders", { mode: "host-aware" }));
|
||||
} catch (e: any) {
|
||||
return badRequest({ error: e.message });
|
||||
}
|
||||
} else {
|
||||
return badRequest({ error: t("shared.invalidForm") });
|
||||
}
|
||||
};
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { resolveRepoPath } from "~/utils/testing/repoPaths";
|
||||
|
||||
const appRoot = resolveRepoPath("apps/web/app");
|
||||
|
||||
const emailMarketingMutationHandlers = [
|
||||
"modules/emailMarketing/routes/Campaigns_Edit.ts",
|
||||
"modules/emailMarketing/routes/Campaigns_New.ts",
|
||||
"modules/emailMarketing/routes/Senders_Edit.ts",
|
||||
"modules/emailMarketing/routes/Senders_List.ts",
|
||||
"modules/emailMarketing/routes/Senders_New.ts",
|
||||
];
|
||||
|
||||
describe("email marketing route authorization boundary", () => {
|
||||
test("requires tenant admin access and csrf before parsing mutating forms", () => {
|
||||
for (const relativePath of emailMarketingMutationHandlers) {
|
||||
const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8");
|
||||
expect(source.indexOf("await requireEmailMarketingMutationAccess({ request, params, tenantId })"), relativePath).toBeGreaterThanOrEqual(0);
|
||||
expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeGreaterThanOrEqual(0);
|
||||
expect(source.indexOf("await requireEmailMarketingMutationAccess({ request, params, tenantId })"), relativePath).toBeLessThan(
|
||||
source.indexOf("await request.formData()")
|
||||
);
|
||||
expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeLessThan(source.indexOf("await request.formData()"));
|
||||
}
|
||||
});
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { Params } from "react-router";
|
||||
import { requireSuperUserOrAdmin } from "~/utils/loaders.middleware";
|
||||
|
||||
export async function requireEmailMarketingMutationAccess({
|
||||
request,
|
||||
params,
|
||||
tenantId,
|
||||
}: {
|
||||
request: Request;
|
||||
params: Params;
|
||||
tenantId: string | null;
|
||||
}) {
|
||||
if (tenantId) {
|
||||
await requireSuperUserOrAdmin({ request, params });
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import type { EmailSender } from "@toolctrl/db/prisma-browser";
|
||||
import { createMailjetProvider } from "@toolctrl/mail";
|
||||
|
||||
type BroadcastEmail = {
|
||||
sender: EmailSender;
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
track: boolean;
|
||||
metadata: { [key: string]: string };
|
||||
};
|
||||
|
||||
async function sendMailjetBroadcast({ sender, to, subject, htmlBody, textBody, track }: BroadcastEmail) {
|
||||
if (!sender.apiKey || !sender.apiSecret) {
|
||||
throw Error("Invalid Mailjet API credentials");
|
||||
}
|
||||
|
||||
const mailjet = createMailjetProvider({
|
||||
apiKey: sender.apiKey,
|
||||
secretKey: sender.apiSecret,
|
||||
fromEmail: sender.fromEmail,
|
||||
fromName: sender.fromName ?? undefined,
|
||||
});
|
||||
|
||||
return await mailjet.send({
|
||||
from: { email: sender.fromEmail, name: sender.fromName ?? undefined },
|
||||
to: [to],
|
||||
replyTo: sender.replyToEmail ? { email: sender.replyToEmail, name: sender.fromName ?? undefined } : undefined,
|
||||
subject,
|
||||
html: htmlBody,
|
||||
text: textBody,
|
||||
tags: ["email-marketing"],
|
||||
settings: {
|
||||
trackClicks: track,
|
||||
trackOpens: track,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBroadcast(email: BroadcastEmail) {
|
||||
switch (email.sender.provider) {
|
||||
case "mailjet":
|
||||
return await sendMailjetBroadcast(email);
|
||||
default:
|
||||
throw Error(`Unsupported email marketing sender provider: ${email.sender.provider}`);
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getContact: vi.fn(),
|
||||
getContactsInRowIds: vi.fn(),
|
||||
countOutboundEmails: vi.fn(),
|
||||
getAllOutboundEmails: vi.fn(),
|
||||
updateCampaign: vi.fn(),
|
||||
updateOutboundEmail: vi.fn(),
|
||||
sendBroadcast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/crm/services/CrmService", () => ({
|
||||
default: {
|
||||
getContact: mocks.getContact,
|
||||
getContactsInRowIds: mocks.getContactsInRowIds,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../db/campaigns.db.server", () => ({
|
||||
createCampaign: vi.fn(),
|
||||
updateCampaign: mocks.updateCampaign,
|
||||
}));
|
||||
|
||||
vi.mock("../db/outboundEmails.db.server", () => ({
|
||||
countOutboundEmails: mocks.countOutboundEmails,
|
||||
getAllOutboundEmails: mocks.getAllOutboundEmails,
|
||||
createOutboundEmailTest: vi.fn(),
|
||||
updateOutboundEmail: mocks.updateOutboundEmail,
|
||||
createOutboundEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./EmailMarketingSenderService.server", () => ({
|
||||
sendBroadcast: mocks.sendBroadcast,
|
||||
}));
|
||||
|
||||
function campaignWithRecipients(count: number) {
|
||||
return {
|
||||
id: "campaign_1",
|
||||
tenantId: "tenant_1",
|
||||
emailSender: { id: "sender_1", email: "marketing@example.com" },
|
||||
subject: "Hello {{firstName}}",
|
||||
htmlBody: "<p>{{email}}</p>",
|
||||
textBody: "Hi {{firstName}}",
|
||||
track: true,
|
||||
recipients: Array.from({ length: count }, (_, index) => ({
|
||||
id: `outbound_${index + 1}`,
|
||||
email: `recipient-${index + 1}@example.com`,
|
||||
contactRowId: `row_${index + 1}`,
|
||||
contactRow: { id: `row_${index + 1}` },
|
||||
opens: [],
|
||||
clicks: [],
|
||||
campaign: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("EmailMarketingService.sendCampaign", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getContact.mockImplementation(async (rowId: string) => ({
|
||||
id: rowId,
|
||||
email: `${rowId}@example.com`,
|
||||
firstName: rowId,
|
||||
lastName: "Recipient",
|
||||
jobTitle: "Buyer",
|
||||
company: { name: "Acme" },
|
||||
marketingSubscriber: true,
|
||||
}));
|
||||
mocks.getContactsInRowIds.mockImplementation(async (rowIds: string[]) =>
|
||||
rowIds.map((rowId) => ({
|
||||
id: rowId,
|
||||
email: `${rowId}@example.com`,
|
||||
firstName: rowId,
|
||||
lastName: "Recipient",
|
||||
jobTitle: "Buyer",
|
||||
company: { name: "Acme" },
|
||||
marketingSubscriber: true,
|
||||
}))
|
||||
);
|
||||
mocks.updateCampaign.mockResolvedValue({});
|
||||
mocks.updateOutboundEmail.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("limits concurrent broadcasts while completing a campaign", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
mocks.sendBroadcast.mockImplementation(async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
const EmailMarketingService = (await import("./EmailMarketingService")).default;
|
||||
const campaign = campaignWithRecipients(6) as Parameters<typeof EmailMarketingService.sendCampaign>[0];
|
||||
await EmailMarketingService.sendCampaign(campaign, { concurrency: 2 });
|
||||
|
||||
expect(mocks.sendBroadcast).toHaveBeenCalledTimes(6);
|
||||
expect(mocks.getContact).not.toHaveBeenCalled();
|
||||
expect(mocks.getContactsInRowIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getContactsInRowIds).toHaveBeenCalledWith(
|
||||
["row_1", "row_2", "row_3", "row_4", "row_5", "row_6"],
|
||||
"tenant_1"
|
||||
);
|
||||
expect(maxActive).toBeLessThanOrEqual(2);
|
||||
expect(mocks.updateCampaign).toHaveBeenNthCalledWith(1, "campaign_1", "tenant_1", { status: "sending" });
|
||||
expect(mocks.updateCampaign).toHaveBeenLastCalledWith("campaign_1", "tenant_1", { status: "completed" });
|
||||
}, 10000);
|
||||
|
||||
it("marks missing prefetched contacts as incomplete without sending to them", async () => {
|
||||
mocks.getContactsInRowIds.mockImplementation(async (rowIds: string[]) =>
|
||||
rowIds
|
||||
.filter((rowId) => rowId !== "row_2")
|
||||
.map((rowId) => ({
|
||||
id: rowId,
|
||||
email: `${rowId}@example.com`,
|
||||
firstName: rowId,
|
||||
lastName: "Recipient",
|
||||
jobTitle: "Buyer",
|
||||
company: { name: "Acme" },
|
||||
marketingSubscriber: true,
|
||||
}))
|
||||
);
|
||||
|
||||
const EmailMarketingService = (await import("./EmailMarketingService")).default;
|
||||
const campaign = campaignWithRecipients(3) as Parameters<typeof EmailMarketingService.sendCampaign>[0];
|
||||
await EmailMarketingService.sendCampaign(campaign, { concurrency: 2 });
|
||||
|
||||
expect(mocks.getContact).not.toHaveBeenCalled();
|
||||
expect(mocks.getContactsInRowIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sendBroadcast).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.updateOutboundEmail).toHaveBeenCalledWith("outbound_2", {
|
||||
error: "Could not parse contact not found: recipient-2@example.com",
|
||||
});
|
||||
expect(mocks.updateCampaign).toHaveBeenLastCalledWith("campaign_1", "tenant_1", { status: "incomplete" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmailMarketingService.getSummary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses aggregate counts instead of loading detailed outbound email rows", async () => {
|
||||
mocks.countOutboundEmails
|
||||
.mockResolvedValueOnce(10)
|
||||
.mockResolvedValueOnce(8)
|
||||
.mockResolvedValueOnce(6)
|
||||
.mockResolvedValueOnce(5)
|
||||
.mockResolvedValueOnce(2);
|
||||
|
||||
const EmailMarketingService = (await import("./EmailMarketingService")).default;
|
||||
|
||||
await expect(EmailMarketingService.getSummary("tenant_1")).resolves.toEqual({
|
||||
avgOpenRate: 50,
|
||||
avgClickRate: 20,
|
||||
outboundEmails: {
|
||||
sent: 8,
|
||||
delivered: 6,
|
||||
},
|
||||
});
|
||||
expect(mocks.getAllOutboundEmails).not.toHaveBeenCalled();
|
||||
expect(mocks.countOutboundEmails).toHaveBeenNthCalledWith(1, "tenant_1");
|
||||
expect(mocks.countOutboundEmails).toHaveBeenNthCalledWith(2, "tenant_1", { where: { sentAt: { not: null } } });
|
||||
expect(mocks.countOutboundEmails).toHaveBeenNthCalledWith(3, "tenant_1", { where: { deliveredAt: { not: null } } });
|
||||
expect(mocks.countOutboundEmails).toHaveBeenNthCalledWith(4, "tenant_1", { where: { opens: { some: {} } } });
|
||||
expect(mocks.countOutboundEmails).toHaveBeenNthCalledWith(5, "tenant_1", { where: { clicks: { some: {} } } });
|
||||
});
|
||||
});
|
||||
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user