src/app/features/reporting/report-config.ts

Index

Properties

Properties

groupTitle
groupTitle: string
Type : string
Optional

title (human-readable) for a set of hierarchically grouped sub-items

items
items: ReportDefinitionDto[]
Type : ReportDefinitionDto[]
Optional

hierarchical child items, building a recursive set of report groups display in an indented way

query
query: string
Type : string
Optional

an SQL query

import { Entity, EntityConstructor } from "../../core/entity/model/entity";
import { DatabaseEntity } from "../../core/entity/database-entity.decorator";
import { Aggregation } from "./data-aggregation.service";
import { ExportColumnConfig } from "../../core/export/data-transformation-service/export-column-config";
import { DatabaseField } from "../../core/entity/database-field.decorator";
import { LongTextDatatype } from "../../core/basic-datatypes/string/long-text.datatype";
import { IconName } from "@fortawesome/fontawesome-svg-core";

/**
 * A report can be accessed by users to generate aggregated statistics or customized exports calculated from available data.
 * "read" permission for a ReportConfig entity is also used to control which users can generate the report's results.
 *
 * This is the class which is saved to the database.
 * However, when using this in code, use the {@link ReportEntity} instead which provides better type safety.
 */
@DatabaseEntity("ReportConfig")
class ReportConfig extends Entity {
  static override isInternalEntity = true;
  static override readonly label = $localize`:ReportConfig:Report`;
  static override readonly labelPlural = $localize`:ReportConfig:Reports`;
  static override readonly toStringAttributes = ["title"];
  static override readonly route = "admin/report-config";
  static override readonly icon: IconName = "chart-line";

  /** human-readable title of the report */
  @DatabaseField({
    label: $localize`:ReportConfig:Title`,
    validators: { required: true },
  })
  title: string;

  /** longer description documenting the purpose and usage of this report */
  @DatabaseField({
    label: $localize`:ReportConfig:Description`,
    description: $localize`:ReportConfig:Document the purpose and usage of this report. This is also shown to users above the results when the report is run.`,
    dataType: LongTextDatatype.dataType,
  })
  description?: string;

  /**
   * (optional) mode of export.
   * The {@link ReportEntity} holds the restriction on valid report modes.
   * Default is "reporting"
   */
  @DatabaseField({
    label: $localize`:ReportConfig:Mode`,
    description: $localize`:ReportConfig:How the report is calculated: "reporting" (in-browser aggregations), "exporting" (data export) or "sql" (server-side SQL queries). Defaults to "reporting".`,
    editComponent: "EditReportMode",
    validators: { required: true },
  })
  mode?: string;

  /**
   * @deprecated (will be removed completely after server-side migration)
   * Omitted for canonical configs (backend normalizes legacy v1 docs on read).
   */
  @DatabaseField() version?: number;

  /**
   * @deprecated (will be removed completely after server-side migration)
   * (sql v1 only) list of arguments needed for the sql query. Placeholder "?" will be replaced.
   */
  @DatabaseField() neededArgs?: string[] = [];

  /**
   * @deprecated Consolidated into {@link reportDefinition} by the one-time CLI migration
   * (consolidate-report-definition), which copies this into `reportDefinition` without deleting
   * it. Kept during the coexistence period so legacy docs still load and old code keeps working;
   * a follow-up migration removes it once every environment runs the new code.
   */
  @DatabaseField() aggregationDefinitions?: any[];

  /**
   * @deprecated (will be removed completely after server-side migration)
   * (sql v1 only) the definition to calculate the report
   */
  @DatabaseField() aggregationDefinition?: string;

  /**
   *  (sql v2 only) transformations that are applied to input variables (e.g. startDate, endDate)
   *  example: {startDate: ["SQL_FROM_DATE"], endDate: ["SQL_TO_DATE"]}
   */
  @DatabaseField({
    label: $localize`:ReportConfig:Use report period (start & end date)`,
    description: $localize`:ReportConfig:When you use time filters in your report, users see a date range selector to choose the start and end date for the report when calculating results.`,
    editComponent: "EditReportPeriodToggle",
  })
  transformations: {
    [key: string]: string[];
  };

  /**
   * The single definition of what the report calculates. Its shape depends on {@link mode}:
   * - "sql":       {@link ReportDefinitionDto}[] — SQL queries and optional groups.
   * - "reporting": {@link Aggregation}[] — in-browser aggregation definitions.
   * - "exporting": {@link ExportColumnConfig}[] — export column definitions.
   *
   * Consolidated from the former `aggregationDefinitions` so all report modes share one field.
   */
  @DatabaseField({
    label: $localize`:ReportConfig:Report definition`,
    description: $localize`:ReportConfig:The definition of what the report calculates: SQL queries for "sql" mode, or aggregation/export definitions for "reporting"/"exporting" mode.`,
    // Mode-aware: structured syntax-highlighting SQL editor for "sql" mode, raw JSON editor otherwise.
    editComponent: "EditReportDefinition",
  })
  reportDefinition:
    ReportDefinitionDto[] | Aggregation[] | ExportColumnConfig[];
}

export interface ReportDefinitionDto {
  /** an SQL query */
  query?: string;

  /** title (human-readable) for a set of hierarchically grouped sub-items */
  groupTitle?: string;

  /** hierarchical child items, building a recursive set of report groups display in an indented way */
  items?: ReportDefinitionDto[];
}

/**
 * Union type to enable type safety for report configs.
 * Use this instead of the {@class ReportConfig}
 */
export type ReportEntity = AggregationReport | ExportingReport | SqlReport;
/**
 * This allows the `ReportEntity` to also be used as a constructor or in the `EntityMapper`
 */
export const ReportEntity = ReportConfig as EntityConstructor<ReportEntity>;

/**
 * Reports handles by the {@class DataAggregationService}
 */
export interface AggregationReport extends ReportConfig {
  mode: "reporting";
  reportDefinition: Aggregation[];
}

/**
 * Reports handles by the {@class DataTransformationService}
 */
export interface ExportingReport extends ReportConfig {
  /**
   * If no mode is set, it will default to 'exporting'
   */
  mode?: "exporting";
  reportDefinition: ExportColumnConfig[];
}

/**
 * Reports handles by the {@class SqlReportService}
 */
export interface SqlReport extends ReportConfig {
  mode: "sql";

  /**
   * @deprecated (will be removed completely after server-side migration)
   * Legacy version field. Omitted in canonical configs; backend normalizes on read.
   */
  version?: number;

  /**
   * @deprecated (will be removed completely after server-side migration)
   * (v1 only) a valid SQL SELECT statement, can contain "?" or "$name" placeholders
   */
  aggregationDefinition?: string;

  /**
   * @deprecated (will be removed completely after server-side migration)
   * (v1 only) list of argument names passed into the sql statement
   */
  neededArgs?: string[];

  /**
   * see ReportConfig docs
   */
  transformations: {
    [key: string]: string[];
  };

  /**
   *  see ReportConfig docs
   */
  reportDefinition: ReportDefinitionDto[];
}

/**
 * Whether a report's results should be rendered as a hierarchical group/count
 * table (`sql-v2-table`) rather than a flat tabular table (`object-table`).
 *
 * Derived purely from the canonical config structure (no version flag):
 * a report is hierarchical when its `reportDefinition` contains a group
 * (`groupTitle`) or more than one top-level item; otherwise it is tabular.
 * Legacy v1 configs (normalized to a single ungrouped query) resolve to tabular.
 */
export function isHierarchicalReport(
  report: ReportEntity | undefined,
): boolean {
  const reportDefinition = report?.reportDefinition as
    ReportDefinitionDto[] | undefined;
  if (!reportDefinition?.length) {
    return false;
  }
  return (
    reportDefinition.length > 1 ||
    reportDefinition.some((item) => !!item.groupTitle)
  );
}

/**
 * Whether running this report offers a date-range (start & end date) input, derived from the
 * date placeholders used in its queries so it stays in sync with the actual report definition:
 * - "sql": the query uses the `$startDate` / `$endDate` placeholders.
 * - "reporting"/"exporting": the in-browser query uses positional `?` placeholders (date args).
 */
export function reportUsesDateRange(
  report: { mode?: string; reportDefinition?: unknown } | undefined,
): boolean {
  if (!report) {
    return false;
  }
  const queries = collectQueryStrings(report.reportDefinition);
  if (report.mode === "sql") {
    return queries.some((query) => /\$startDate|\$endDate/.test(query));
  }
  return queries.some((query) => query.includes("?"));
}

/** Recursively collect every `query` string value from a report definition tree. */
function collectQueryStrings(node: unknown): string[] {
  if (Array.isArray(node)) {
    return node.flatMap(collectQueryStrings);
  }
  if (node && typeof node === "object") {
    const out: string[] = [];
    for (const [key, value] of Object.entries(node)) {
      if (key === "query" && typeof value === "string") {
        out.push(value);
      } else {
        out.push(...collectQueryStrings(value));
      }
    }
    return out;
  }
  return [];
}

results matching ""

    No results matching ""