Merge branch 'epic/v5' into chore/refactor-csv-ui

This commit is contained in:
pandeymangg
2026-05-14 15:21:33 +05:30
34 changed files with 756 additions and 343 deletions
+20 -5
View File
@@ -1665,22 +1665,37 @@ checksums:
workspace/analysis/charts/failed_to_load_dashboards: 876c54d9cc69ceda6f808231e2557eb2
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
workspace/analysis/charts/field_label_ces_average: 3bf598396ea490f3a2bdccf0c94b6aa0
workspace/analysis/charts/field_label_ces_count: 4dc90d50a8e05dd9ba4a9e356926e0cb
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
workspace/analysis/charts/field_label_created_at: 9ce495d7fc74e1a2ae86c07206a3e531
workspace/analysis/charts/field_label_csat_average: f7c43dac56267f832fbebd6d18efdef1
workspace/analysis/charts/field_label_csat_count: 30c1ab12748b503ffee399ed326e0562
workspace/analysis/charts/field_label_csat_dissatisfied_count: 7f68b2c8302bde5cd93ba86d2163f86d
workspace/analysis/charts/field_label_csat_neutral_count: 19edae275784e8d53dd45003a2e8971a
workspace/analysis/charts/field_label_csat_satisfied_count: 78fcabc88da4b22171be149b1be509bd
workspace/analysis/charts/field_label_csat_score: 89a87f1069641bba10607d5b407cb0aa
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
workspace/analysis/charts/field_label_language: 277fd1a41cc237a437cd1d5e4a80463b
workspace/analysis/charts/field_label_nps_average: 4a61877a06cb8e64a0a8375dd058a548
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
workspace/analysis/charts/field_label_question: 0576462ce60d4263d7c482463fcc9547
workspace/analysis/charts/field_label_question_group: b007e2cfd1262272de3260f8d14d5833
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
workspace/analysis/charts/field_label_unique_respondents: e340f09af176927f1ed16719ee304274
workspace/analysis/charts/field_label_unique_responses: d9ffcc58f72b9fdb143027703371f22b
workspace/analysis/charts/field_label_updated_at: a3730393cce5adfd9e50123d96640fd6
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
workspace/analysis/charts/field_label_value_boolean: bbdcd3f46954b6304b9069e94e1371ab
workspace/analysis/charts/field_label_value_date: c8d705d1975affc01c002324725fec3f
workspace/analysis/charts/field_label_value_number: 1f14da79d14bd7b1c2324141f4470675
workspace/analysis/charts/field_label_value_text: e097a597cc507c716401ad18255de578
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
+22
View File
@@ -114,6 +114,28 @@ describe("importHistoricalResponses", () => {
expect(result.failures).toBe(1);
});
test("counts 409 duplicates as skipped, not failures", async () => {
const mockResponses = [{ id: "r1" }, { id: "r2" }, { id: "r3" }];
getResponses.mockResolvedValueOnce(mockResponses as never);
getResponses.mockResolvedValueOnce([]);
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
createFeedbackRecordsBatch.mockResolvedValue({
results: [
{ data: { id: "fb1" }, error: null },
{ data: null, error: { status: 409, message: "Conflict", detail: "duplicate" } },
{ data: null, error: { status: 500, message: "Server error", detail: "boom" } },
],
} as never);
const result = await importHistoricalResponses(mockConnector, mockSurvey);
expect(result.successes).toBe(1);
expect(result.failures).toBe(1);
expect(result.skipped).toBe(1);
});
test("paginates through responses in batches", async () => {
const batch1 = Array.from({ length: 50 }, (_, i) => ({ id: `r${i}` }));
const batch2 = [{ id: "r50" }];
+5 -2
View File
@@ -18,6 +18,7 @@ const processBatch = async (
): Promise<TImportResult> => {
let successes = 0;
let failures = 0;
let duplicates = 0;
const expectedRecords = responses.length * mappings.length;
const allRecords = responses.flatMap((response) =>
@@ -27,10 +28,12 @@ const processBatch = async (
if (allRecords.length > 0) {
const { results } = await createFeedbackRecordsBatch(allRecords);
successes = results.filter((r) => r.data !== null).length;
failures = results.filter((r) => r.error !== null).length;
duplicates = results.filter((r) => r.error?.status === 409).length;
failures = results.filter((r) => r.error !== null && r.error.status !== 409).length;
}
return { successes, failures, skipped: expectedRecords - allRecords.length };
const unmappedSkipped = expectedRecords - allRecords.length;
return { successes, failures, skipped: unmappedSkipped + duplicates };
};
export const importHistoricalResponses = async (
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
"field": "Feld",
"field_label_average_score": "Durchschnittliche Bewertung",
"field_label_ces_average": "CES-Durchschnitt",
"field_label_ces_count": "CES-Anzahl",
"field_label_collected_at": "Erfasst am",
"field_label_count": "Anzahl",
"field_label_created_at": "Erstellt am",
"field_label_csat_average": "CSAT-Durchschnitt",
"field_label_csat_count": "CSAT-Anzahl",
"field_label_csat_dissatisfied_count": "CSAT Unzufriedene Anzahl",
"field_label_csat_neutral_count": "CSAT Neutrale Anzahl",
"field_label_csat_satisfied_count": "CSAT-Zufriedene Anzahl",
"field_label_csat_score": "CSAT-Score",
"field_label_detractor_count": "Anzahl Kritiker",
"field_label_emotion": "Emotion",
"field_label_field_type": "Feldtyp",
"field_label_language": "Sprache",
"field_label_nps_average": "NPS-Durchschnitt",
"field_label_nps_score": "NPS-Score",
"field_label_nps_value": "NPS-Wert",
"field_label_passive_count": "Anzahl Passive",
"field_label_promoter_count": "Anzahl Promoter",
"field_label_question": "Frage",
"field_label_question_group": "Fragengruppe",
"field_label_response_id": "Antwort-ID",
"field_label_sentiment": "Stimmung",
"field_label_source_name": "Quellenname",
"field_label_source_type": "Quellentyp",
"field_label_topic": "Thema",
"field_label_unique_respondents": "Eindeutige Teilnehmer",
"field_label_unique_responses": "Eindeutige Antworten",
"field_label_updated_at": "Aktualisiert am",
"field_label_user_identifier": "Benutzerkennung",
"field_label_value_boolean": "Wert (Boolean)",
"field_label_value_date": "Wert (Datum)",
"field_label_value_number": "Wert (Zahl)",
"field_label_value_text": "Wert (Text)",
"filter_data": "Daten filtern",
"filters": "Filter",
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Failed to save chart",
"field": "Field",
"field_label_average_score": "Average Score",
"field_label_ces_average": "CES Average",
"field_label_ces_count": "CES Count",
"field_label_collected_at": "Collected At",
"field_label_count": "Count",
"field_label_created_at": "Created At",
"field_label_csat_average": "CSAT Average",
"field_label_csat_count": "CSAT Count",
"field_label_csat_dissatisfied_count": "CSAT Dissatisfied Count",
"field_label_csat_neutral_count": "CSAT Neutral Count",
"field_label_csat_satisfied_count": "CSAT Satisfied Count",
"field_label_csat_score": "CSAT Score",
"field_label_detractor_count": "Detractor Count",
"field_label_emotion": "Emotion",
"field_label_field_type": "Field Type",
"field_label_language": "Language",
"field_label_nps_average": "NPS Average",
"field_label_nps_score": "NPS Score",
"field_label_nps_value": "NPS Value",
"field_label_passive_count": "Passive Count",
"field_label_promoter_count": "Promoter Count",
"field_label_question": "Question",
"field_label_question_group": "Question Group",
"field_label_response_id": "Response ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Source Name",
"field_label_source_type": "Source Type",
"field_label_topic": "Topic",
"field_label_unique_respondents": "Unique Respondents",
"field_label_unique_responses": "Unique Responses",
"field_label_updated_at": "Updated At",
"field_label_user_identifier": "User Identifier",
"field_label_value_boolean": "Value (Boolean)",
"field_label_value_date": "Value (Date)",
"field_label_value_number": "Value (Number)",
"field_label_value_text": "Value (Text)",
"filter_data": "Filter data",
"filters": "Filters",
"filters_toggle_description": "Only include data that meets the following conditions.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Error al guardar el gráfico",
"field": "Campo",
"field_label_average_score": "Puntuación media",
"field_label_ces_average": "Promedio CES",
"field_label_ces_count": "Recuento CES",
"field_label_collected_at": "Recopilado el",
"field_label_count": "Recuento",
"field_label_created_at": "Fecha de creación",
"field_label_csat_average": "Promedio CSAT",
"field_label_csat_count": "Recuento CSAT",
"field_label_csat_dissatisfied_count": "Recuento de insatisfechos CSAT",
"field_label_csat_neutral_count": "Recuento de neutros CSAT",
"field_label_csat_satisfied_count": "Recuento de Satisfechos CSAT",
"field_label_csat_score": "Puntuación CSAT",
"field_label_detractor_count": "Recuento de detractores",
"field_label_emotion": "Emoción",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Promedio NPS",
"field_label_nps_score": "Puntuación NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Recuento de pasivos",
"field_label_promoter_count": "Recuento de promotores",
"field_label_question": "Pregunta",
"field_label_question_group": "Grupo de preguntas",
"field_label_response_id": "ID de respuesta",
"field_label_sentiment": "Sentimiento",
"field_label_source_name": "Nombre de origen",
"field_label_source_type": "Tipo de origen",
"field_label_topic": "Tema",
"field_label_unique_respondents": "Encuestados Únicos",
"field_label_unique_responses": "Respuestas Únicas",
"field_label_updated_at": "Fecha de actualización",
"field_label_user_identifier": "Identificador de usuario",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Fecha)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar datos",
"filters": "Filtros",
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
"field": "Champ",
"field_label_average_score": "Score moyen",
"field_label_ces_average": "Moyenne CES",
"field_label_ces_count": "Nombre CES",
"field_label_collected_at": "Collecté le",
"field_label_count": "Nombre",
"field_label_created_at": "Créé le",
"field_label_csat_average": "Moyenne CSAT",
"field_label_csat_count": "Nombre CSAT",
"field_label_csat_dissatisfied_count": "Nombre d'insatisfaits CSAT",
"field_label_csat_neutral_count": "Nombre de neutres CSAT",
"field_label_csat_satisfied_count": "Nombre de clients satisfaits CSAT",
"field_label_csat_score": "Score CSAT",
"field_label_detractor_count": "Nombre de détracteurs",
"field_label_emotion": "Émotion",
"field_label_field_type": "Type de champ",
"field_label_language": "Langue",
"field_label_nps_average": "Moyenne NPS",
"field_label_nps_score": "Score NPS",
"field_label_nps_value": "Valeur NPS",
"field_label_passive_count": "Nombre de passifs",
"field_label_promoter_count": "Nombre de promoteurs",
"field_label_question": "Question",
"field_label_question_group": "Groupe de questions",
"field_label_response_id": "ID de réponse",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nom de la source",
"field_label_source_type": "Type de source",
"field_label_topic": "Sujet",
"field_label_unique_respondents": "Répondants uniques",
"field_label_unique_responses": "Réponses uniques",
"field_label_updated_at": "Mis à jour le",
"field_label_user_identifier": "Identifiant utilisateur",
"field_label_value_boolean": "Valeur (booléenne)",
"field_label_value_date": "Valeur (date)",
"field_label_value_number": "Valeur (Nombre)",
"field_label_value_text": "Valeur (Texte)",
"filter_data": "Filtrer les données",
"filters": "Filtres",
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "A diagram mentése sikertelen",
"field": "Mező",
"field_label_average_score": "Átlagos pontszám",
"field_label_ces_average": "CES átlag",
"field_label_ces_count": "CES darabszám",
"field_label_collected_at": "Gyűjtve",
"field_label_count": "Darabszám",
"field_label_created_at": "Létrehozás dátuma",
"field_label_csat_average": "CSAT átlag",
"field_label_csat_count": "CSAT darabszám",
"field_label_csat_dissatisfied_count": "CSAT elégedetlen válaszok száma",
"field_label_csat_neutral_count": "CSAT semleges válaszok száma",
"field_label_csat_satisfied_count": "CSAT elégedett válaszadók száma",
"field_label_csat_score": "CSAT pontszám",
"field_label_detractor_count": "Kritikusok száma",
"field_label_emotion": "Érzelem",
"field_label_field_type": "Mező típusa",
"field_label_language": "Nyelv",
"field_label_nps_average": "NPS átlag",
"field_label_nps_score": "NPS pontszám",
"field_label_nps_value": "NPS érték",
"field_label_passive_count": "Passzívak száma",
"field_label_promoter_count": "Támogatók száma",
"field_label_question": "Kérdés",
"field_label_question_group": "Kérdéscsoport",
"field_label_response_id": "Válaszazonosító",
"field_label_sentiment": "Hangulat",
"field_label_source_name": "Forrás neve",
"field_label_source_type": "Forrás típusa",
"field_label_topic": "Téma",
"field_label_unique_respondents": "Egyedi válaszadók",
"field_label_unique_responses": "Egyedi válaszok",
"field_label_updated_at": "Frissítés dátuma",
"field_label_user_identifier": "Felhasználóazonosító",
"field_label_value_boolean": "Érték (logikai)",
"field_label_value_date": "Érték (dátum)",
"field_label_value_number": "Érték (szám)",
"field_label_value_text": "Érték (szöveg)",
"filter_data": "Adatok szűrése",
"filters": "Szűrők",
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "チャートの保存に失敗しました",
"field": "フィールド",
"field_label_average_score": "平均スコア",
"field_label_ces_average": "CES平均",
"field_label_ces_count": "CES件数",
"field_label_collected_at": "収集日時",
"field_label_count": "カウント",
"field_label_created_at": "作成日時",
"field_label_csat_average": "CSAT平均",
"field_label_csat_count": "CSAT件数",
"field_label_csat_dissatisfied_count": "CSAT 不満足数",
"field_label_csat_neutral_count": "CSAT 中立数",
"field_label_csat_satisfied_count": "CSAT満足件数",
"field_label_csat_score": "CSATスコア",
"field_label_detractor_count": "批判者数",
"field_label_emotion": "感情",
"field_label_field_type": "フィールドタイプ",
"field_label_language": "言語",
"field_label_nps_average": "NPS平均",
"field_label_nps_score": "NPSスコア",
"field_label_nps_value": "NPS値",
"field_label_passive_count": "中立者数",
"field_label_promoter_count": "推奨者数",
"field_label_question": "質問",
"field_label_question_group": "質問グループ",
"field_label_response_id": "回答ID",
"field_label_sentiment": "感情分析",
"field_label_source_name": "ソース名",
"field_label_source_type": "ソースタイプ",
"field_label_topic": "トピック",
"field_label_unique_respondents": "ユニーク回答者数",
"field_label_unique_responses": "ユニーク回答数",
"field_label_updated_at": "更新日時",
"field_label_user_identifier": "ユーザー識別子",
"field_label_value_boolean": "値(ブール値)",
"field_label_value_date": "値(日付)",
"field_label_value_number": "値(数値)",
"field_label_value_text": "値(テキスト)",
"filter_data": "データをフィルター",
"filters": "フィルター",
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Opslaan van diagram mislukt",
"field": "Veld",
"field_label_average_score": "Gemiddelde score",
"field_label_ces_average": "CES Gemiddelde",
"field_label_ces_count": "CES Aantal",
"field_label_collected_at": "Verzameld op",
"field_label_count": "Aantal",
"field_label_created_at": "Aangemaakt op",
"field_label_csat_average": "CSAT Gemiddelde",
"field_label_csat_count": "CSAT Aantal",
"field_label_csat_dissatisfied_count": "CSAT Aantal ontevreden",
"field_label_csat_neutral_count": "CSAT Aantal neutraal",
"field_label_csat_satisfied_count": "CSAT Tevreden Aantal",
"field_label_csat_score": "CSAT Score",
"field_label_detractor_count": "Aantal detractors",
"field_label_emotion": "Emotie",
"field_label_field_type": "Veldtype",
"field_label_language": "Taal",
"field_label_nps_average": "NPS Gemiddelde",
"field_label_nps_score": "NPS-score",
"field_label_nps_value": "NPS-waarde",
"field_label_passive_count": "Aantal passieven",
"field_label_promoter_count": "Aantal promoters",
"field_label_question": "Vraag",
"field_label_question_group": "Vraaggroep",
"field_label_response_id": "Antwoord-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Bronnaam",
"field_label_source_type": "Brontype",
"field_label_topic": "Onderwerp",
"field_label_unique_respondents": "Unieke Respondenten",
"field_label_unique_responses": "Unieke Antwoorden",
"field_label_updated_at": "Bijgewerkt op",
"field_label_user_identifier": "Gebruikersidentificatie",
"field_label_value_boolean": "Waarde (Boolean)",
"field_label_value_date": "Waarde (Datum)",
"field_label_value_number": "Waarde (Getal)",
"field_label_value_text": "Waarde (Tekst)",
"filter_data": "Data filteren",
"filters": "Filters",
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao salvar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_ces_average": "Média CES",
"field_label_ces_count": "Contagem CES",
"field_label_collected_at": "Coletado em",
"field_label_count": "Contagem",
"field_label_created_at": "Criado em",
"field_label_csat_average": "Média CSAT",
"field_label_csat_count": "Contagem CSAT",
"field_label_csat_dissatisfied_count": "Contagem de Insatisfeitos CSAT",
"field_label_csat_neutral_count": "Contagem de Neutros CSAT",
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
"field_label_csat_score": "Pontuação CSAT",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Média NPS",
"field_label_nps_score": "Pontuação de NPS",
"field_label_nps_value": "Valor de NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_question": "Pergunta",
"field_label_question_group": "Grupo de Perguntas",
"field_label_response_id": "ID da resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da fonte",
"field_label_source_type": "Tipo de fonte",
"field_label_topic": "Tópico",
"field_label_unique_respondents": "Respondentes Únicos",
"field_label_unique_responses": "Respostas Únicas",
"field_label_updated_at": "Atualizado em",
"field_label_user_identifier": "Identificador do usuário",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Data)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar dados",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Falha ao guardar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_ces_average": "Média CES",
"field_label_ces_count": "Contagem CES",
"field_label_collected_at": "Recolhido em",
"field_label_count": "Contagem",
"field_label_created_at": "Criado em",
"field_label_csat_average": "Média CSAT",
"field_label_csat_count": "Contagem CSAT",
"field_label_csat_dissatisfied_count": "Contagem de CSAT Insatisfeito",
"field_label_csat_neutral_count": "Contagem de CSAT Neutro",
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
"field_label_csat_score": "Pontuação CSAT",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_language": "Idioma",
"field_label_nps_average": "Média NPS",
"field_label_nps_score": "Pontuação NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_question": "Pergunta",
"field_label_question_group": "Grupo de Perguntas",
"field_label_response_id": "ID de resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da origem",
"field_label_source_type": "Tipo de origem",
"field_label_topic": "Tópico",
"field_label_unique_respondents": "Inquiridos Únicos",
"field_label_unique_responses": "Respostas Únicas",
"field_label_updated_at": "Atualizado em",
"field_label_user_identifier": "Identificador de utilizador",
"field_label_value_boolean": "Valor (Booleano)",
"field_label_value_date": "Valor (Data)",
"field_label_value_number": "Valor (Número)",
"field_label_value_text": "Valor (Texto)",
"filter_data": "Filtrar dados",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Nu s-a putut salva graficul",
"field": "Câmp",
"field_label_average_score": "Scor mediu",
"field_label_ces_average": "Media CES",
"field_label_ces_count": "Număr CES",
"field_label_collected_at": "Colectat la",
"field_label_count": "Număr",
"field_label_created_at": "Creat la",
"field_label_csat_average": "Media CSAT",
"field_label_csat_count": "Număr CSAT",
"field_label_csat_dissatisfied_count": "Număr CSAT Nemulțumiți",
"field_label_csat_neutral_count": "Număr CSAT Neutri",
"field_label_csat_satisfied_count": "Număr clienți mulțumiți CSAT",
"field_label_csat_score": "Scor CSAT",
"field_label_detractor_count": "Număr de detractori",
"field_label_emotion": "Emoție",
"field_label_field_type": "Tip câmp",
"field_label_language": "Limbă",
"field_label_nps_average": "Media NPS",
"field_label_nps_score": "Scor NPS",
"field_label_nps_value": "Valoare NPS",
"field_label_passive_count": "Număr de pasivi",
"field_label_promoter_count": "Număr de promotori",
"field_label_question": "Întrebare",
"field_label_question_group": "Grup de întrebări",
"field_label_response_id": "ID răspuns",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nume sursă",
"field_label_source_type": "Tip sursă",
"field_label_topic": "Subiect",
"field_label_unique_respondents": "Respondenți unici",
"field_label_unique_responses": "Răspunsuri unice",
"field_label_updated_at": "Actualizat la",
"field_label_user_identifier": "Identificator utilizator",
"field_label_value_boolean": "Valoare (Boolean)",
"field_label_value_date": "Valoare (Dată)",
"field_label_value_number": "Valoare (Număr)",
"field_label_value_text": "Valoare (Text)",
"filter_data": "Filtrează datele",
"filters": "Filtre",
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Не удалось сохранить график",
"field": "Поле",
"field_label_average_score": "Средний балл",
"field_label_ces_average": "Средний CES",
"field_label_ces_count": "Количество CES",
"field_label_collected_at": "Дата сбора",
"field_label_count": "Количество",
"field_label_created_at": "Дата создания",
"field_label_csat_average": "Средний CSAT",
"field_label_csat_count": "Количество CSAT",
"field_label_csat_dissatisfied_count": "Количество недовольных (CSAT)",
"field_label_csat_neutral_count": "Количество нейтральных (CSAT)",
"field_label_csat_satisfied_count": "Количество удовлетворённых (CSAT)",
"field_label_csat_score": "Оценка CSAT",
"field_label_detractor_count": "Количество критиков",
"field_label_emotion": "Эмоция",
"field_label_field_type": "Тип поля",
"field_label_language": "Язык",
"field_label_nps_average": "Средний NPS",
"field_label_nps_score": "Оценка NPS",
"field_label_nps_value": "Значение NPS",
"field_label_passive_count": "Количество пассивных",
"field_label_promoter_count": "Количество промоутеров",
"field_label_question": "Вопрос",
"field_label_question_group": "Группа вопросов",
"field_label_response_id": "ID ответа",
"field_label_sentiment": "Тональность",
"field_label_source_name": "Название источника",
"field_label_source_type": "Тип источника",
"field_label_topic": "Тема",
"field_label_unique_respondents": "Уникальные респонденты",
"field_label_unique_responses": "Уникальные ответы",
"field_label_updated_at": "Дата обновления",
"field_label_user_identifier": "Идентификатор пользователя",
"field_label_value_boolean": "Значение (логическое)",
"field_label_value_date": "Значение (дата)",
"field_label_value_number": "Значение (число)",
"field_label_value_text": "Значение (текст)",
"filter_data": "Фильтровать данные",
"filters": "Фильтры",
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Det gick inte att spara diagrammet",
"field": "Fält",
"field_label_average_score": "Genomsnittligt betyg",
"field_label_ces_average": "CES-medelvärde",
"field_label_ces_count": "CES-antal",
"field_label_collected_at": "Insamlad",
"field_label_count": "Antal",
"field_label_created_at": "Skapad",
"field_label_csat_average": "CSAT-medelvärde",
"field_label_csat_count": "CSAT-antal",
"field_label_csat_dissatisfied_count": "CSAT-antal missnöjda",
"field_label_csat_neutral_count": "CSAT-antal neutrala",
"field_label_csat_satisfied_count": "CSAT antal nöjda",
"field_label_csat_score": "CSAT-poäng",
"field_label_detractor_count": "Antal kritiker",
"field_label_emotion": "Känsla",
"field_label_field_type": "Fälttyp",
"field_label_language": "Språk",
"field_label_nps_average": "NPS-medelvärde",
"field_label_nps_score": "NPS-poäng",
"field_label_nps_value": "NPS-värde",
"field_label_passive_count": "Antal passiva",
"field_label_promoter_count": "Antal förespråkare",
"field_label_question": "Fråga",
"field_label_question_group": "Frågegrupp",
"field_label_response_id": "Svar-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Källnamn",
"field_label_source_type": "Källtyp",
"field_label_topic": "Ämne",
"field_label_unique_respondents": "Unika respondenter",
"field_label_unique_responses": "Unika svar",
"field_label_updated_at": "Uppdaterad",
"field_label_user_identifier": "Användar-ID",
"field_label_value_boolean": "Värde (Boolean)",
"field_label_value_date": "Värde (Datum)",
"field_label_value_number": "Värde (Nummer)",
"field_label_value_text": "Värde (Text)",
"filter_data": "Filtrera data",
"filters": "Filter",
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "Grafik kaydedilemedi",
"field": "Alan",
"field_label_average_score": "Ortalama Puan",
"field_label_ces_average": "CES Ortalaması",
"field_label_ces_count": "CES Sayısı",
"field_label_collected_at": "Toplandığı Tarih",
"field_label_count": "Sayı",
"field_label_created_at": "Oluşturulma Tarihi",
"field_label_csat_average": "CSAT Ortalaması",
"field_label_csat_count": "CSAT Sayısı",
"field_label_csat_dissatisfied_count": "CSAT Memnuniyetsiz Sayısı",
"field_label_csat_neutral_count": "CSAT Nötr Sayısı",
"field_label_csat_satisfied_count": "CSAT Memnun Sayısı",
"field_label_csat_score": "CSAT Puanı",
"field_label_detractor_count": "Eleştirmen Sayısı",
"field_label_emotion": "Duygu",
"field_label_field_type": "Alan Türü",
"field_label_language": "Dil",
"field_label_nps_average": "NPS Ortalaması",
"field_label_nps_score": "NPS Puanı",
"field_label_nps_value": "NPS Değeri",
"field_label_passive_count": "Pasif Sayısı",
"field_label_promoter_count": "Tavsiye Eden Sayısı",
"field_label_question": "Soru",
"field_label_question_group": "Soru Grubu",
"field_label_response_id": "Yanıt Kimliği",
"field_label_sentiment": "Duygu Durumu",
"field_label_source_name": "Kaynak Adı",
"field_label_source_type": "Kaynak Türü",
"field_label_topic": "Konu",
"field_label_unique_respondents": "Benzersiz Katılımcılar",
"field_label_unique_responses": "Benzersiz Yanıtlar",
"field_label_updated_at": "Güncellenme Tarihi",
"field_label_user_identifier": "Kullanıcı Tanımlayıcısı",
"field_label_value_boolean": "Değer (Boolean)",
"field_label_value_date": "Değer (Tarih)",
"field_label_value_number": "Değer (Sayı)",
"field_label_value_text": "Değer (Metin)",
"filter_data": "Verileri filtrele",
"filters": "Filtreler",
"filters_toggle_description": "Yalnızca aşağıdaki koşulları karşılayan verileri dahil et.",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "图表保存失败",
"field": "字段",
"field_label_average_score": "平均",
"field_label_ces_average": "CES 平均",
"field_label_ces_count": "CES 数量",
"field_label_collected_at": "收集时间",
"field_label_count": "数量",
"field_label_created_at": "创建时间",
"field_label_csat_average": "CSAT 平均值",
"field_label_csat_count": "CSAT 数量",
"field_label_csat_dissatisfied_count": "CSAT 不满意数量",
"field_label_csat_neutral_count": "CSAT 中立数量",
"field_label_csat_satisfied_count": "CSAT 满意数量",
"field_label_csat_score": "CSAT 得分",
"field_label_detractor_count": "贬损者数量",
"field_label_emotion": "情感",
"field_label_field_type": "字段类型",
"field_label_language": "语言",
"field_label_nps_average": "NPS 平均值",
"field_label_nps_score": "NPS 得分",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者数量",
"field_label_promoter_count": "推荐者数量",
"field_label_question": "问题",
"field_label_question_group": "问题组",
"field_label_response_id": "响应 ID",
"field_label_sentiment": "情绪",
"field_label_source_name": "来源名称",
"field_label_source_type": "来源类型",
"field_label_topic": "主题",
"field_label_unique_respondents": "独立受访者",
"field_label_unique_responses": "独立回复",
"field_label_updated_at": "更新时间",
"field_label_user_identifier": "用户标识",
"field_label_value_boolean": "值(布尔型)",
"field_label_value_date": "值(日期)",
"field_label_value_number": "数值",
"field_label_value_text": "文本值",
"filter_data": "筛选数据",
"filters": "筛选条件",
"filters_toggle_description": "仅包含符合以下条件的数据。",
+20 -5
View File
@@ -1730,22 +1730,37 @@
"failed_to_load_dashboards": "Failed to load dashboards",
"failed_to_save_chart": "儲存圖表失敗",
"field": "欄位",
"field_label_average_score": "平均分數",
"field_label_ces_average": "CES 平均值",
"field_label_ces_count": "CES 數量",
"field_label_collected_at": "收集時間",
"field_label_count": "數量",
"field_label_created_at": "建立時間",
"field_label_csat_average": "CSAT 平均值",
"field_label_csat_count": "CSAT 數量",
"field_label_csat_dissatisfied_count": "CSAT 不滿意數量",
"field_label_csat_neutral_count": "CSAT 中立數量",
"field_label_csat_satisfied_count": "CSAT 滿意數量",
"field_label_csat_score": "CSAT 分數",
"field_label_detractor_count": "批評者數量",
"field_label_emotion": "情緒",
"field_label_field_type": "欄位類型",
"field_label_language": "語言",
"field_label_nps_average": "NPS 平均值",
"field_label_nps_score": "NPS 分數",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者數量",
"field_label_promoter_count": "推廣者數量",
"field_label_question": "問題",
"field_label_question_group": "問題群組",
"field_label_response_id": "回應 ID",
"field_label_sentiment": "情感",
"field_label_source_name": "來源名稱",
"field_label_source_type": "來源類型",
"field_label_topic": "主題",
"field_label_unique_respondents": "不重複受訪者",
"field_label_unique_responses": "不重複回應",
"field_label_updated_at": "更新時間",
"field_label_user_identifier": "使用者識別碼",
"field_label_value_boolean": "值(布林)",
"field_label_value_date": "值(日期)",
"field_label_value_number": "數值",
"field_label_value_text": "文字值",
"filter_data": "篩選資料",
"filters": "篩選條件",
"filters_toggle_description": "只包含符合下列條件的資料。",
@@ -187,7 +187,7 @@ describe("executeTenantScopedQuery", () => {
...scopedInput,
query: {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
},
});
@@ -197,7 +197,7 @@ describe("executeTenantScopedQuery", () => {
targetType: "cubeQuery",
newObject: expect.objectContaining({
query: expect.objectContaining({
filterMembers: ["FeedbackRecords.sentiment"],
filterMembers: ["FeedbackRecords.sourceType"],
filterCount: 1,
}),
}),
@@ -107,18 +107,6 @@ describe("cube queryRewrite", () => {
).toThrow(/tenant filters are enforced by Cube/);
});
test("rejects caller-supplied TopicsUnnested tenant filters", () => {
expect(() =>
queryRewrite(
{
measures: ["TopicsUnnested.count"],
filters: [{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["workspace-2"] }],
},
{ securityContext }
)
).toThrow(/tenant filters are enforced by Cube/);
});
test("logs sanitized failure audit metadata for rejected tenant filters", () => {
expect(() =>
queryRewrite(
@@ -169,7 +157,7 @@ describe("cube queryRewrite", () => {
filters: [
{
or: [
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] },
],
},
@@ -207,36 +195,23 @@ describe("cube queryRewrite", () => {
test("appends the mandatory tenant filter from security context", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }],
};
const rewrittenQuery = queryRewrite(query, { securityContext });
expect(rewrittenQuery.filters).toEqual([
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["frd-1"] },
]);
expect(query.filters).toHaveLength(1);
});
test("appends only the TopicsUnnested tenant filter for TopicsUnnested queries", () => {
const query = {
measures: ["TopicsUnnested.count"],
dimensions: ["TopicsUnnested.topic"],
};
const rewrittenQuery = queryRewrite(query, { securityContext });
expect(rewrittenQuery.filters).toEqual([
{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["frd-1"] },
]);
});
test("logs sanitized Cube audit metadata without raw filter values", () => {
queryRewrite(
{
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
},
{ securityContext }
);
@@ -256,7 +231,6 @@ describe("cube queryRewrite", () => {
source: "charts.executeQueryAction",
});
expect(parsed.members).toContain("FeedbackRecords.tenantId");
expect(parsed.members).not.toContain("TopicsUnnested.tenantId");
expect(logPayload).not.toContain("secret-value");
});
});
@@ -10,7 +10,7 @@ describe("cube-query", () => {
expect(() =>
validateCubeQueryMembers({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
order: { "FeedbackRecords.collectedAt": "desc" },
@@ -18,10 +18,6 @@ describe("cube-query", () => {
).not.toThrow();
});
test("allows TopicsUnnested dimensions from joined cube", () => {
expect(() => validateCubeQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
});
test("throws for invalid members across query sections", () => {
expect(() =>
validateCubeQueryMembers({
@@ -57,7 +53,7 @@ describe("cube-query", () => {
filters: [
{
or: [
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
{
and: [{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] }],
},
@@ -90,7 +86,7 @@ describe("cube-query", () => {
expect(() =>
validateCubeQueryMembers({
measures: ["FeedbackRecords.count", null],
dimensions: [{ member: "FeedbackRecords.sentiment" }],
dimensions: [{ member: "FeedbackRecords.sourceType" }],
segments: [0],
timeDimensions: [null, { dimension: null }],
filters: [null, { member: { name: "FeedbackRecords.sourceType" } }, { and: [0] }, { or: "bad" }],
@@ -103,7 +99,7 @@ describe("cube-query", () => {
test("summarizes query members without raw filter values", () => {
const summary = getCubeQueryAuditSummary({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
order: [["FeedbackRecords.collectedAt", "desc"]],
limit: 50,
@@ -111,7 +107,7 @@ describe("cube-query", () => {
expect(summary).toEqual({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
segments: [],
timeDimensions: [],
filterMembers: ["FeedbackRecords.sourceType"],
@@ -125,12 +121,12 @@ describe("cube-query", () => {
test("summarizes only valid member names from malformed query shapes", () => {
const summary = getCubeQueryAuditSummary({
measures: ["FeedbackRecords.count", null],
dimensions: [{ member: "FeedbackRecords.sentiment" }],
dimensions: [{ member: "FeedbackRecords.sourceType" }],
timeDimensions: [null, { dimension: "FeedbackRecords.collectedAt" }],
filters: [
null,
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] },
{ and: [0, { member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }] },
{ and: [0, { member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }] },
],
order: [
[null, "asc"],
@@ -143,7 +139,7 @@ describe("cube-query", () => {
dimensions: [],
segments: [],
timeDimensions: ["FeedbackRecords.collectedAt"],
filterMembers: ["FeedbackRecords.sentiment", "FeedbackRecords.sourceType"],
filterMembers: ["FeedbackRecords.sourceType"],
filterCount: 2,
orderMembers: ["FeedbackRecords.collectedAt"],
});
@@ -3,7 +3,7 @@ import type { TChartQuery } from "@formbricks/types/analysis";
export const TENANT_MEMBER = "FeedbackRecords.tenantId";
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords."];
const INVALID_MEMBER_REFERENCE = "invalid member reference";
type TQueryAuditSummary = {
@@ -310,9 +310,9 @@ export const validateCubeQueryMembers = (query: TChartQuery): void => {
if (result.invalidMembers.length > 0) {
throw new Error(
`Invalid query members (must start with FeedbackRecords. or TopicsUnnested.): ${uniqueSorted(
result.invalidMembers
).join(", ")}`
`Invalid query members (must start with FeedbackRecords.): ${uniqueSorted(result.invalidMembers).join(
", "
)}`
);
}
};
@@ -175,7 +175,7 @@ describe("chart Cube actions", () => {
mocks.generateText.mockResolvedValue({
output: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
timeDimensions: null,
chartType: "bar",
filters: null,
@@ -198,7 +198,7 @@ describe("chart Cube actions", () => {
expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
dimensions: ["FeedbackRecords.sourceType"],
},
feedbackDirectoryId: "frd-1",
workspaceId: "workspace-1",
+22 -4
View File
@@ -21,6 +21,11 @@ import {
} from "@/modules/ee/analysis/charts/lib/charts";
import { checkFeedbackDirectoryAccess, checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access";
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
import {
FEEDBACK_DIMENSION_IDS,
FEEDBACK_MEASURE_IDS,
FEEDBACK_TIME_DIMENSION_IDS,
} from "@/modules/ee/analysis/lib/schema-definition";
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsDashboardsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -287,13 +292,25 @@ export const executeQueryAction = authenticatedActionClient
const CUBE_NAME = "FeedbackRecords";
const toEnumTuple = (values: readonly string[]): [string, ...string[]] => {
if (values.length === 0) {
throw new Error("AI query schema requires at least one allowed id");
}
return [values[0], ...values.slice(1)];
};
const ZMeasureId = z.enum(toEnumTuple(FEEDBACK_MEASURE_IDS));
const ZDimensionId = z.enum(toEnumTuple(FEEDBACK_DIMENSION_IDS));
const ZTimeDimensionId = z.enum(toEnumTuple(FEEDBACK_TIME_DIMENSION_IDS));
const ZFilterMemberId = z.enum(toEnumTuple([...FEEDBACK_MEASURE_IDS, ...FEEDBACK_DIMENSION_IDS]));
const ZGenerateAIQueryResponse = z.object({
measures: z.array(z.string()),
dimensions: z.array(z.string()).nullable(),
measures: z.array(ZMeasureId),
dimensions: z.array(ZDimensionId).nullable(),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
dimension: ZTimeDimensionId,
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
dateRange: z.string().nullable(),
})
@@ -303,7 +320,7 @@ const ZGenerateAIQueryResponse = z.object({
filters: z
.array(
z.object({
member: z.string(),
member: ZFilterMemberId,
operator: z.enum([
"equals",
"notEquals",
@@ -364,6 +381,7 @@ export const generateAIChartAction = authenticatedActionClient
output: Output.object({ schema: ZGenerateAIQueryResponse }),
system: schemaContext,
prompt: `User request: "${parsedInput.prompt}"`,
temperature: 0,
});
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
@@ -52,7 +52,7 @@ ${operatorsText}
## Guidelines
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sourceType\`, \`FeedbackRecords.collectedAt\`).
- For time-based filtering (date range only, no time grouping): add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\` and dateRange. Do NOT include granularity (default is None / filter only).
- For time-series or trend questions (e.g. "over time", "by day", "weekly", "monthly"): add a timeDimension with dimension, granularity (hour/day/week/month/quarter/year), and dateRange.
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
@@ -22,13 +22,13 @@ describe("query-builder", () => {
test("adds dimensions when present", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
selectedDimensions: ["FeedbackRecords.userId"],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.dimensions).toEqual(["FeedbackRecords.sentiment"]);
expect(query.dimensions).toEqual(["FeedbackRecords.userId"]);
});
test("adds time dimension with string dateRange", () => {
@@ -93,7 +93,7 @@ describe("query-builder", () => {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [
{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
{ id: "f2", field: "FeedbackRecords.sourceType", operator: "set", values: null },
],
filterLogic: "and",
@@ -101,7 +101,7 @@ describe("query-builder", () => {
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "set" },
]);
});
@@ -110,14 +110,14 @@ describe("query-builder", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
filterLogic: "or",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
},
]);
});
@@ -136,12 +136,12 @@ describe("query-builder", () => {
test("parses AND member filters", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filters: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("and");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
]);
});
@@ -150,14 +150,14 @@ describe("query-builder", () => {
measures: ["FeedbackRecords.count"],
filters: [
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
},
],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("or");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
]);
});
@@ -202,7 +202,7 @@ describe("query-builder", () => {
test("buildCubeQuery then parseQueryToState restores state", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
selectedDimensions: ["FeedbackRecords.userId"],
filters: [{ id: "f1", field: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
filterLogic: "and",
timeDimension: {
@@ -32,9 +32,9 @@ describe("schema-definition", () => {
describe("getFieldById", () => {
test("returns dimension by id", () => {
const field = getFieldById("FeedbackRecords.sentiment");
const field = getFieldById("FeedbackRecords.sourceType");
expect(field).toBeDefined();
expect(field?.label).toBe("Sentiment");
expect(field?.label).toBe("Source Type");
expect(field?.type).toBe("string");
});
@@ -56,7 +56,7 @@ describe("schema-definition", () => {
});
test("returns field label for known dimension/measure", () => {
expect(formatCubeColumnHeader("FeedbackRecords.sentiment")).toBe("Sentiment");
expect(formatCubeColumnHeader("FeedbackRecords.sourceType")).toBe("Source Type");
expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Count");
});
@@ -74,5 +74,25 @@ describe("schema-definition", () => {
expect(FEEDBACK_FIELDS.dimensions.length).toBeGreaterThan(0);
expect(FEEDBACK_FIELDS.measures.length).toBeGreaterThan(0);
});
test("exposes CSAT, CES, NPS and universal measures", () => {
const ids = FEEDBACK_FIELDS.measures.map((m) => m.id);
expect(ids).toEqual(
expect.arrayContaining([
"FeedbackRecords.count",
"FeedbackRecords.uniqueRespondents",
"FeedbackRecords.uniqueResponses",
"FeedbackRecords.npsScore",
"FeedbackRecords.npsAverage",
"FeedbackRecords.csatScore",
"FeedbackRecords.csatAverage",
"FeedbackRecords.csatSatisfiedCount",
"FeedbackRecords.csatCount",
"FeedbackRecords.cesAverage",
"FeedbackRecords.cesCount",
])
);
expect(ids).not.toContain("FeedbackRecords.averageScore");
});
});
});
@@ -7,7 +7,7 @@ import type { TFunction } from "i18next";
export interface FieldDefinition {
id: string;
label: string;
type: "string" | "number" | "time";
type: "string" | "number" | "time" | "boolean";
description?: string;
}
@@ -20,12 +20,6 @@ export interface MeasureDefinition {
export const FEEDBACK_FIELDS = {
dimensions: [
{
id: "FeedbackRecords.sentiment",
label: "Sentiment",
type: "string",
description: "Sentiment extracted from feedback",
},
{
id: "FeedbackRecords.sourceType",
label: "Source Type",
@@ -45,10 +39,22 @@ export const FEEDBACK_FIELDS = {
description: "Type of feedback field (e.g., nps, text, rating)",
},
{
id: "FeedbackRecords.emotion",
label: "Emotion",
id: "FeedbackRecords.fieldLabel",
label: "Question",
type: "string",
description: "Emotion extracted from metadata JSONB field",
description: "Human-readable label of the question/field",
},
{
id: "FeedbackRecords.fieldGroupLabel",
label: "Question Group",
type: "string",
description: "Label of the parent composite question for matrix/ranking rows",
},
{
id: "FeedbackRecords.language",
label: "Language",
type: "string",
description: 'Response language code (e.g., "en", "de")',
},
{
id: "FeedbackRecords.userId",
@@ -63,10 +69,30 @@ export const FEEDBACK_FIELDS = {
description: "Unique identifier linking related feedback records",
},
{
id: "FeedbackRecords.npsValue",
label: "NPS Value",
id: "FeedbackRecords.valueNumber",
label: "Value (Number)",
type: "number",
description: "Raw NPS score value (0-10)",
description:
"Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, number). Pair with a fieldType filter to keep scales consistent.",
},
{
id: "FeedbackRecords.valueText",
label: "Value (Text)",
type: "string",
description:
"Text answer value (open text, or the label of a multiple-choice/categorical answer). Pair with a fieldType filter to keep types consistent.",
},
{
id: "FeedbackRecords.valueBoolean",
label: "Value (Boolean)",
type: "boolean",
description: "Boolean answer value (yes/no). Pair with a fieldType filter.",
},
{
id: "FeedbackRecords.valueDate",
label: "Value (Date)",
type: "time",
description: "Date answer value. Pair with a fieldType filter.",
},
{
id: "FeedbackRecords.collectedAt",
@@ -75,10 +101,16 @@ export const FEEDBACK_FIELDS = {
description: "Timestamp when the feedback was collected",
},
{
id: "TopicsUnnested.topic",
label: "Topic",
type: "string",
description: "Individual topic from the topics array",
id: "FeedbackRecords.createdAt",
label: "Created At",
type: "time",
description: "Timestamp when the feedback record was created in Hub",
},
{
id: "FeedbackRecords.updatedAt",
label: "Updated At",
type: "time",
description: "Timestamp when the feedback record was last updated in Hub",
},
] as FieldDefinition[],
measures: [
@@ -89,38 +121,106 @@ export const FEEDBACK_FIELDS = {
description: "Total number of feedback responses",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of promoters (NPS score 9-10)",
id: "FeedbackRecords.uniqueRespondents",
label: "Unique Respondents",
type: "number",
description: "Number of unique users who provided feedback",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of detractors (NPS score 0-6)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of passives (NPS score 7-8)",
id: "FeedbackRecords.uniqueResponses",
label: "Unique Responses",
type: "number",
description: "Number of unique survey submissions",
},
{
id: "FeedbackRecords.npsScore",
label: "NPS Score",
type: "number",
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
description: "Net Promoter Score: ((Promoters - Detractors) / Total NPS responses) * 100",
},
{
id: "FeedbackRecords.averageScore",
label: "Average Score",
id: "FeedbackRecords.npsAverage",
label: "NPS Average",
type: "number",
description: "Average NPS score",
description: "Average NPS rating (0-10)",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of NPS promoters (score 9-10)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of NPS passives (score 7-8)",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of NPS detractors (score 0-6)",
},
{
id: "FeedbackRecords.csatScore",
label: "CSAT Score",
type: "number",
description: "CSAT Score: % of CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatAverage",
label: "CSAT Average",
type: "number",
description: "Average CSAT rating (1-5)",
},
{
id: "FeedbackRecords.csatSatisfiedCount",
label: "CSAT Satisfied Count",
type: "count",
description: "Number of satisfied CSAT responses (top-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatDissatisfiedCount",
label: "CSAT Dissatisfied Count",
type: "count",
description: "Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatNeutralCount",
label: "CSAT Neutral Count",
type: "count",
description: "Number of neutral CSAT responses (middle box on the 1-5 scale)",
},
{
id: "FeedbackRecords.csatCount",
label: "CSAT Count",
type: "count",
description: "Number of CSAT responses",
},
{
id: "FeedbackRecords.cesAverage",
label: "CES Average",
type: "number",
description: "Average CES rating (scale is 1-5 or 1-7 depending on the question)",
},
{
id: "FeedbackRecords.cesCount",
label: "CES Count",
type: "count",
description: "Number of CES responses",
},
] as MeasureDefinition[],
};
export const FEEDBACK_MEASURE_IDS: string[] = FEEDBACK_FIELDS.measures.map((m) => m.id);
export const FEEDBACK_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions.map((d) => d.id);
export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions
.filter((d) => d.type === "time")
.map((d) => d.id);
export type FilterOperator =
| "equals"
| "notEquals"
@@ -137,6 +237,7 @@ export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
boolean: ["equals", "notEquals", "set", "notSet"],
};
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
@@ -166,7 +267,7 @@ export const DATE_PRESETS = [
/**
* Get filter operators for a given field type.
*/
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
export function getFilterOperatorsForType(type: "string" | "number" | "time" | "boolean"): FilterOperator[] {
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
}
@@ -184,22 +285,39 @@ export function getFieldById(id: string): FieldDefinition | MeasureDefinition |
*/
export function getTranslatedFieldLabel(id: string, t: TFunction): string {
const labels: Record<string, string> = {
"FeedbackRecords.sentiment": t("workspace.analysis.charts.field_label_sentiment"),
"FeedbackRecords.sourceType": t("workspace.analysis.charts.field_label_source_type"),
"FeedbackRecords.sourceName": t("workspace.analysis.charts.field_label_source_name"),
"FeedbackRecords.fieldType": t("workspace.analysis.charts.field_label_field_type"),
"FeedbackRecords.emotion": t("workspace.analysis.charts.field_label_emotion"),
"FeedbackRecords.fieldLabel": t("workspace.analysis.charts.field_label_question"),
"FeedbackRecords.fieldGroupLabel": t("workspace.analysis.charts.field_label_question_group"),
"FeedbackRecords.language": t("workspace.analysis.charts.field_label_language"),
"FeedbackRecords.userId": t("workspace.analysis.charts.field_label_user_identifier"),
"FeedbackRecords.responseId": t("workspace.analysis.charts.field_label_response_id"),
"FeedbackRecords.npsValue": t("workspace.analysis.charts.field_label_nps_value"),
"FeedbackRecords.valueNumber": t("workspace.analysis.charts.field_label_value_number"),
"FeedbackRecords.valueText": t("workspace.analysis.charts.field_label_value_text"),
"FeedbackRecords.valueBoolean": t("workspace.analysis.charts.field_label_value_boolean"),
"FeedbackRecords.valueDate": t("workspace.analysis.charts.field_label_value_date"),
"FeedbackRecords.collectedAt": t("workspace.analysis.charts.field_label_collected_at"),
"TopicsUnnested.topic": t("workspace.analysis.charts.field_label_topic"),
"FeedbackRecords.createdAt": t("workspace.analysis.charts.field_label_created_at"),
"FeedbackRecords.updatedAt": t("workspace.analysis.charts.field_label_updated_at"),
"FeedbackRecords.count": t("workspace.analysis.charts.field_label_count"),
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
"FeedbackRecords.uniqueRespondents": t("workspace.analysis.charts.field_label_unique_respondents"),
"FeedbackRecords.uniqueResponses": t("workspace.analysis.charts.field_label_unique_responses"),
"FeedbackRecords.npsScore": t("workspace.analysis.charts.field_label_nps_score"),
"FeedbackRecords.averageScore": t("workspace.analysis.charts.field_label_average_score"),
"FeedbackRecords.npsAverage": t("workspace.analysis.charts.field_label_nps_average"),
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
"FeedbackRecords.csatScore": t("workspace.analysis.charts.field_label_csat_score"),
"FeedbackRecords.csatAverage": t("workspace.analysis.charts.field_label_csat_average"),
"FeedbackRecords.csatSatisfiedCount": t("workspace.analysis.charts.field_label_csat_satisfied_count"),
"FeedbackRecords.csatDissatisfiedCount": t(
"workspace.analysis.charts.field_label_csat_dissatisfied_count"
),
"FeedbackRecords.csatNeutralCount": t("workspace.analysis.charts.field_label_csat_neutral_count"),
"FeedbackRecords.csatCount": t("workspace.analysis.charts.field_label_csat_count"),
"FeedbackRecords.cesAverage": t("workspace.analysis.charts.field_label_ces_average"),
"FeedbackRecords.cesCount": t("workspace.analysis.charts.field_label_ces_count"),
};
return labels[id] ?? getFieldById(id)?.label ?? id;
}
+18
View File
@@ -96,6 +96,24 @@ describe("hub service", () => {
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ message: "Network error" });
});
test("reads status from a foreign error class (simulates dual module scope)", async () => {
// Simulates the SDK being loaded into a different module scope under Next dev/Turbopack:
// the thrown error is NOT instanceof the FormbricksHub.APIError reference captured in service.ts.
class ForeignConflictError extends Error {
readonly status = 409;
}
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: {
create: vi.fn().mockRejectedValue(new ForeignConflictError("duplicate submission_id")),
},
} as any);
const result = await createFeedbackRecord(sampleInput);
expect(result.data).toBeNull();
expect(result.error).toMatchObject({ status: 409, message: "duplicate submission_id" });
});
});
describe("listFeedbackRecords", () => {
+11 -5
View File
@@ -1,6 +1,5 @@
import "server-only";
import { createCacheKey } from "@formbricks/cache";
import FormbricksHub from "@formbricks/hub";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { getHubClient } from "./hub-client";
@@ -33,8 +32,15 @@ const getErrorMessage = (err: unknown): string => {
return "Unknown error";
};
// Duck-typed: `instanceof` against the SDK error class breaks under Next dev/Turbopack
// when @formbricks/hub is loaded into more than one module scope.
const getErrorStatus = (err: unknown): number =>
err && typeof err === "object" && typeof (err as { status?: unknown }).status === "number"
? (err as { status: number }).status
: 0;
const createResultFromError = (err: unknown): HubFeedbackRecordResult => {
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
};
@@ -153,7 +159,7 @@ export const listFeedbackRecords = async (
return { data, error: null };
} catch (err) {
logger.warn({ err }, "Hub: listFeedbackRecords failed");
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
}
@@ -171,7 +177,7 @@ export const semanticSearchFeedbackRecords = async (
return { data, error: null };
} catch (err) {
logger.warn({ err, tenantId: input.tenant_id }, "Hub: semanticSearchFeedbackRecords failed");
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = getErrorMessage(err);
return { data: null, error: { status, message, detail: message } };
}
@@ -196,7 +202,7 @@ export const getFeedbackRecordTenant = async (recordId: string): Promise<Feedbac
return { data, error: null };
} catch (err) {
logger.warn({ err, recordId }, "Hub: getFeedbackRecordTenant failed");
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const status = getErrorStatus(err);
const message = err instanceof Error ? err.message : String(err);
return { data: null, error: { status, message, detail: message } };
}
+1 -1
View File
@@ -1,6 +1,6 @@
/* eslint-env es2022 */
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
@@ -1,6 +1,5 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
// If the Hub changes column names or types, this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
@@ -60,12 +59,6 @@ cube(`FeedbackRecords`, {
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
@@ -108,65 +101,10 @@ cube(`FeedbackRecords`, {
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID linking to FeedbackDirectory`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
fr.tenant_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `md5(feedback_record_id || '::' || topic)`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID for row-level security scoping`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
/* eslint-env es2022 */
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
const REQUIRED_SCOPE = "xm:cube:query";
function assertRequiredEnvironmentVariable(name) {
+138 -78
View File
@@ -1,6 +1,5 @@
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
// this schema must be updated to match.
// If the Hub changes column names or types, this schema must be updated to match.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
@@ -10,46 +9,120 @@ cube(`FeedbackRecords`, {
description: `Total number of feedback responses`,
},
uniqueRespondents: {
type: `countDistinct`,
sql: `${CUBE}.user_id`,
description: `Number of unique users who provided feedback`,
},
uniqueResponses: {
type: `countDistinct`,
sql: `${CUBE}.submission_id`,
description: `Number of unique survey submissions (a submission can produce multiple feedback records)`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
description: `Number of NPS promoters (score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS score 0-6)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
description: `Number of NPS detractors (score 0-6)`,
},
passiveCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
description: `Number of passives (NPS score 7-8)`,
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
description: `Number of NPS passives (score 7-8)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
(COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6 THEN 1 END)::numeric)
/ COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
},
averageScore: {
npsAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
filters: [{ sql: `${CUBE}.field_type = 'nps'` }],
description: `Average NPS rating (0-10)`,
},
csatCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL` }],
description: `Number of answered CSAT responses (dismissed responses excluded).`,
},
csatSatisfiedCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4` }],
description: `Number of satisfied CSAT responses (top-2-box on the 1-5 scale)`,
},
csatDissatisfiedCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number BETWEEN 1 AND 2` }],
description: `Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)`,
},
csatNeutralCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number = 3` }],
description: `Number of neutral CSAT responses (middle box on the 1-5 scale)`,
},
csatScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
ELSE ROUND(
(
COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4 THEN 1 END)::numeric
/ COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
) * 100,
2
)
END
`,
description: `CSAT Score: % of answered CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale). NULL when there are no answered CSAT responses.`,
},
csatAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
filters: [{ sql: `${CUBE}.field_type = 'csat'` }],
description: `Average CSAT rating (1-5)`,
},
cesCount: {
type: `count`,
filters: [{ sql: `${CUBE}.field_type = 'ces' AND ${CUBE}.value_number IS NOT NULL` }],
description: `Number of answered CES responses (dismissed responses excluded).`,
},
cesAverage: {
type: `avg`,
sql: `${CUBE}.value_number`,
filters: [{ sql: `${CUBE}.field_type = 'ces'` }],
description: `Average CES rating (scale is 1-5 or 1-7 depending on the question)`,
},
},
@@ -60,12 +133,6 @@ cube(`FeedbackRecords`, {
primaryKey: true,
},
sentiment: {
sql: `${CUBE}.metadata->>'sentiment'`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
@@ -84,16 +151,64 @@ cube(`FeedbackRecords`, {
description: `Type of feedback field (e.g., nps, text, rating)`,
},
fieldLabel: {
sql: `field_label`,
type: `string`,
description: `Human-readable label of the question/field (e.g., "How satisfied are you with support?")`,
},
fieldGroupLabel: {
sql: `field_group_label`,
type: `string`,
description: `Label of the parent composite question for matrix/ranking rows`,
},
language: {
sql: `language`,
type: `string`,
description: `Response language code (e.g., "en", "de"). NULL when language is "default".`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
createdAt: {
sql: `created_at`,
type: `time`,
description: `Timestamp when the feedback record was created in Hub`,
},
updatedAt: {
sql: `updated_at`,
type: `time`,
description: `Timestamp when the feedback record was last updated in Hub`,
},
valueNumber: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
description: `Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, generic number). Pair with a fieldType filter to keep scales consistent.`,
},
valueText: {
sql: `value_text`,
type: `string`,
description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`,
},
valueBoolean: {
sql: `value_boolean`,
type: `boolean`,
description: `Boolean answer value (yes/no questions). Pair with a fieldType filter.`,
},
valueDate: {
sql: `value_date`,
type: `time`,
description: `Date answer value (e.g., "preferred meeting date"). Pair with a fieldType filter.`,
},
responseId: {
@@ -108,65 +223,10 @@ cube(`FeedbackRecords`, {
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `${CUBE}.metadata->>'emotion'`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID linking to FeedbackDirectory`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_id,
fr.tenant_id,
topic_elem.topic
FROM feedback_records fr
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
`,
measures: {
count: {
type: `count`,
},
},
dimensions: {
id: {
sql: `md5(feedback_record_id || '::' || topic)`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
tenantId: {
sql: `tenant_id`,
type: `string`,
description: `Tenant ID for row-level security scoping`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});