Compare commits

...

23 Commits

Author SHA1 Message Date
TheodorTomas 5b9372fb69 fix: changing dashboard api routes to be server actions 2026-02-19 20:05:01 +07:00
TheodorTomas 6080fb9c63 feat: adding CRUD operations for dashboards 2026-02-19 20:01:09 +07:00
TheodorTomas d01dc80712 fix: changing charts api routes to server actions 2026-02-19 19:33:40 +07:00
TheodorTomas d32437b4a6 feat: adding CRUD operations for charts 2026-02-19 17:51:13 +07:00
Theodór Tómas f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas 9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang 4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang 1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:17:44 +05:30
Dhruwang 4f9088559f feat: add Cube.js dev setup and analytics client
- Add Cube container to docker-compose.dev.yml (pinned v1.3.21)
- Add Cube server config (cube/cube.js) and FeedbackRecords schema
- Add @cubejs-client/core dependency and singleton client in EE module
- Add CUBEJS_API_URL and CUBEJS_API_TOKEN to .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:05:47 +05:30
Dhruwang 18550f1d11 feat: link Chart and Dashboard createdBy to User
- Add creator relation on Chart and Dashboard to User
- Add createdBy foreign key constraints in migration (ON DELETE SET NULL)
- Mirror Survey pattern for createdBy user tracking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:26:42 +05:30
Dhruwang 881cd31f74 feat: add Chart, Dashboard, DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- ChartType: area, bar, line, pie, big_number only
- Remove DashboardStatus and WidgetType (widgets are always charts)
- DashboardWidget requires chartId, remove content/type fields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:24:48 +05:30
Dhruwang e00405dca2 feat: add Chart, Dashboard, and DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- Add ChartType, DashboardStatus, WidgetType enums
- Add migration 20260128111722 for charts and dashboards tables

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
Theodór Tómas 33542d0c54 fix: default preview colors (#7277)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-17 11:28:58 +00:00
Matti Nannt f37d22f13d docs: align rate limiting docs with current code enforcement (#7267)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-17 07:42:53 +00:00
Anshuman Pandey 202ae903ac chore: makes rate limit config const (#7274) 2026-02-17 06:49:56 +00:00
Dhruwang Jariwala 6ab5cc367c fix: reduced default height of input (#7259) 2026-02-17 05:11:29 +00:00
Theodór Tómas 21559045ba fix: input placeholder color (#7265) 2026-02-17 05:11:01 +00:00
Theodór Tómas d7c57a7a48 fix: disabling cache in dev (#7269) 2026-02-17 04:44:22 +00:00
Chowdhury Tafsir Ahmed Siddiki 11b2ef4788 docs: remove stale 'coming soon' placeholders (#7254) 2026-02-16 13:21:12 +00:00
Theodór Tómas 6fefd51cce fix: suggest colors has better succes copy (#7258) 2026-02-16 13:18:46 +00:00
Theodór Tómas 65af826222 fix: matrix table preview (#7257)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-16 13:18:17 +00:00
Anshuman Pandey 12eb54c653 fix: fixes number being passed into string attribute (#7255) 2026-02-16 11:18:59 +00:00
Dhruwang Jariwala 5aa1427e64 fix: input combobx height (#7256) 2026-02-16 10:03:23 +00:00
52 changed files with 1513 additions and 178 deletions
+9
View File
@@ -229,5 +229,14 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
+3 -2
View File
@@ -2036,12 +2036,12 @@ checksums:
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
environments/workspace/look/advanced_styling_field_height: f4da6d7ecd26e3fa75cfea03abb60c00
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
environments/workspace/look/advanced_styling_field_input_height_description: b704fc67e805223992c811d6f86a9c00
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
@@ -2104,6 +2104,7 @@ checksums:
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
+39 -3
View File
@@ -118,10 +118,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
// Inputs
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 40,
inputHeight: 20,
inputFontSize: 14,
inputPaddingX: 16,
inputPaddingY: 16,
inputPaddingX: 8,
inputPaddingY: 8,
inputPlaceholderOpacity: 0.5,
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
@@ -149,6 +149,42 @@ export const STYLE_DEFAULTS: TProjectStyling = {
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
/**
* Builds a complete TProjectStyling object from a single brand color.
*
+8 -1
View File
@@ -12,11 +12,18 @@ export function validateInputs<T extends ValidationPair<any>[]>(
for (const [value, schema] of pairs) {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
const zodDetails = inputValidation.error.issues
.map((issue) => {
const path = issue?.path?.join(".") ?? "";
return `${path}${issue.message}`;
})
.join("; ");
logger.error(
inputValidation.error,
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
);
throw new ValidationError("Validation failed");
throw new ValidationError(`Validation failed: ${zodDetails}`);
}
parsedData.push(inputValidation.data);
}
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
"advanced_styling_field_height": "Höhe",
"advanced_styling_field_height": "Mindesthöhe",
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_input_height_description": "Steuert die Höhe des Eingabefelds.",
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
"suggest_colors": "Farben vorschlagen",
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
"theme": "Theme",
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Scales the headline text.",
"advanced_styling_field_headline_weight": "Headline Font Weight",
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
"advanced_styling_field_height": "Height",
"advanced_styling_field_height": "Minimum Height",
"advanced_styling_field_indicator_bg": "Indicator Background",
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
"advanced_styling_field_input_height_description": "Controls the input field height.",
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Show “Powered by Formbricks” Signature",
"styling_updated_successfully": "Styling updated successfully",
"suggest_colors": "Suggest colors",
"suggested_colors_applied_please_save": "Suggested colors generated successfully. Press \"Save\" to persist the changes.",
"theme": "Theme",
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fondo del indicador",
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
"advanced_styling_field_input_height_description": "Controla la altura del campo de entrada.",
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Mostrar firma 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo actualizado correctamente",
"suggest_colors": "Sugerir colores",
"suggested_colors_applied_please_save": "Colores sugeridos generados correctamente. Pulsa \"Guardar\" para conservar los cambios.",
"theme": "Tema",
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
"advanced_styling_field_headline_weight": "Graisse de police du titre",
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
"advanced_styling_field_height": "Hauteur",
"advanced_styling_field_height": "Hauteur minimale",
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur du champ de saisie.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Afficher la signature « Propulsé par Formbricks »",
"styling_updated_successfully": "Style mis à jour avec succès",
"suggest_colors": "Suggérer des couleurs",
"suggested_colors_applied_please_save": "Couleurs suggérées générées avec succès. Appuyez sur « Enregistrer » pour conserver les modifications.",
"theme": "Thème",
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
"advanced_styling_field_height": "Magasság",
"advanced_styling_field_height": "Minimális magasság",
"advanced_styling_field_indicator_bg": "Jelző háttere",
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
"advanced_styling_field_input_height_description": "A beviteli mező magasságát vezérli.",
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Az „A gépházban: Formbricks” aláírás megjelenítése",
"styling_updated_successfully": "A stílus sikeresen frissítve",
"suggest_colors": "Színek ajánlása",
"suggested_colors_applied_please_save": "A javasolt színek sikeresen generálva. Nyomd meg a \"Mentés\" gombot a változtatások véglegesítéséhez.",
"theme": "Téma",
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
"advanced_styling_field_height": "高さ",
"advanced_styling_field_height": "最小の高さ",
"advanced_styling_field_indicator_bg": "インジケーターの背景",
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
"advanced_styling_field_input_height_description": "入力フィールドの高さを調整します。",
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
"styling_updated_successfully": "スタイルを正常に更新しました",
"suggest_colors": "カラーを提案",
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには「保存」を押してください。",
"theme": "テーマ",
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
"advanced_styling_field_headline_weight": "Letterdikte kop",
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
"advanced_styling_field_height": "Hoogte",
"advanced_styling_field_height": "Minimale hoogte",
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
"advanced_styling_field_input_height_description": "Bepaalt de hoogte van het invoerveld.",
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Toon 'Powered by Formbricks' handtekening",
"styling_updated_successfully": "Styling succesvol bijgewerkt",
"suggest_colors": "Kleuren voorstellen",
"suggested_colors_applied_please_save": "Voorgestelde kleuren succesvol gegenereerd. Druk op \"Opslaan\" om de wijzigingen te behouden.",
"theme": "Thema",
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione \"Salvar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressiona \"Guardar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Scalează textul titlului.",
"advanced_styling_field_headline_weight": "Grosime font titlu",
"advanced_styling_field_headline_weight_description": "Face textul titlului mai subțire sau mai îngroșat.",
"advanced_styling_field_height": "Înălțime",
"advanced_styling_field_height": "Înălțime minimă",
"advanced_styling_field_indicator_bg": "Fundal indicator",
"advanced_styling_field_indicator_bg_description": "Colorează partea umplută a barei.",
"advanced_styling_field_input_border_radius_description": "Rotunjește colțurile câmpurilor de introducere.",
"advanced_styling_field_input_font_size_description": "Scalează textul introdus în câmpuri.",
"advanced_styling_field_input_height_description": "Controlează înălțimea câmpului de introducere.",
"advanced_styling_field_input_height_description": "Controlează înălțimea minimă a câmpului de introducere.",
"advanced_styling_field_input_padding_x_description": "Adaugă spațiu la stânga și la dreapta.",
"advanced_styling_field_input_padding_y_description": "Adaugă spațiu deasupra și dedesubt.",
"advanced_styling_field_input_placeholder_opacity_description": "Estompează textul de sugestie din placeholder.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Afișează semnătura „Powered by Formbricks”",
"styling_updated_successfully": "Stilizarea a fost actualizată cu succes",
"suggest_colors": "Sugerează culori",
"suggested_colors_applied_please_save": "Culorile sugerate au fost generate cu succes. Apasă pe „Salvează” pentru a păstra modificările.",
"theme": "Temă",
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Масштабирует текст заголовка.",
"advanced_styling_field_headline_weight": "Толщина шрифта заголовка",
"advanced_styling_field_headline_weight_description": "Делает текст заголовка тоньше или жирнее.",
"advanced_styling_field_height": "Высота",
"advanced_styling_field_height": "Минимальная высота",
"advanced_styling_field_indicator_bg": "Фон индикатора",
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
"advanced_styling_field_input_height_description": "Определяет высоту поля ввода.",
"advanced_styling_field_input_height_description": "Определяет минимальную высоту поля ввода.",
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Показывать подпись «Работает на Formbricks»",
"styling_updated_successfully": "Стили успешно обновлены",
"suggest_colors": "Предложить цвета",
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажми «Сохранить», чтобы применить изменения.",
"theme": "Тема",
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ändrar storleken på rubriken.",
"advanced_styling_field_headline_weight": "Rubrikens teckentjocklek",
"advanced_styling_field_headline_weight_description": "Gör rubriktexten tunnare eller fetare.",
"advanced_styling_field_height": "Höjd",
"advanced_styling_field_height": "Minsta höjd",
"advanced_styling_field_indicator_bg": "Indikatorns bakgrund",
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
"advanced_styling_field_input_height_description": "Styr höjden på inmatningsfältet.",
"advanced_styling_field_input_height_description": "Styr den minsta höjden på inmatningsfältet.",
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "Visa 'Powered by Formbricks'-signatur",
"styling_updated_successfully": "Stiluppdatering lyckades",
"suggest_colors": "Föreslå färger",
"suggested_colors_applied_please_save": "Föreslagna färger har skapats. Tryck på \"Spara\" för att spara ändringarna.",
"theme": "Tema",
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning."
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "调整主标题文字大小。",
"advanced_styling_field_headline_weight": "标题字体粗细",
"advanced_styling_field_headline_weight_description": "设置主标题文字的粗细。",
"advanced_styling_field_height": "高度",
"advanced_styling_field_height": "最小高度",
"advanced_styling_field_indicator_bg": "指示器背景",
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
"advanced_styling_field_input_height_description": "控制输入框高度。",
"advanced_styling_field_input_height_description": "设置输入框的最小高度。",
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "显示“Powered by Formbricks”标识",
"styling_updated_successfully": "样式更新成功",
"suggest_colors": "推荐颜色",
"suggested_colors_applied_please_save": "已成功生成推荐配色。请点击“保存”以保留更改。",
"theme": "主题",
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
},
+3 -2
View File
@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "調整標題文字的大小。",
"advanced_styling_field_headline_weight": "標題字體粗細",
"advanced_styling_field_headline_weight_description": "讓標題文字變細或變粗。",
"advanced_styling_field_height": "高度",
"advanced_styling_field_height": "最小高度",
"advanced_styling_field_indicator_bg": "指示器背景",
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
"advanced_styling_field_input_height_description": "調整輸入欄位的高度。",
"advanced_styling_field_input_height_description": "設定輸入欄位的最小高度。",
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
@@ -2221,6 +2221,7 @@
"show_powered_by_formbricks": "顯示「Powered by Formbricks」標記",
"styling_updated_successfully": "樣式已成功更新",
"suggest_colors": "建議顏色",
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「儲存」以保存變更。",
"theme": "主題",
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
},
@@ -12,7 +12,9 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs
| Prisma.DashboardFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
@@ -30,4 +30,4 @@ export const rateLimitConfigs = {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
};
} as const;
@@ -0,0 +1,293 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/dashboard";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZChartType } from "../types/analysis";
const checkProjectAccess = async (
userId: string,
environmentId: string,
minPermission: "read" | "readWrite" | "manage"
) => {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission, projectId },
],
});
return { organizationId, projectId };
};
const ZCreateChartAction = z.object({
environmentId: ZId,
name: z.string().min(1),
type: ZChartType,
query: ZChartQuery,
config: ZChartConfig.optional().default({}),
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await prisma.chart.create({
data: {
name: parsedInput.name,
type: parsedInput.type,
projectId,
query: parsedInput.query,
config: parsedInput.config || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
name: z.string().min(1).optional(),
type: ZChartType.optional(),
query: ZChartQuery.optional(),
config: ZChartConfig.optional(),
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
const updatedChart = await prisma.chart.update({
where: { id: parsedInput.chartId },
data: {
...(parsedInput.name !== undefined && { name: parsedInput.name }),
...(parsedInput.type !== undefined && { type: parsedInput.type }),
...(parsedInput.query !== undefined && { query: parsedInput.query }),
...(parsedInput.config !== undefined && { config: parsedInput.config }),
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const sourceChart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!sourceChart) {
throw new Error("Chart not found");
}
const duplicatedChart = await prisma.chart.create({
data: {
name: `${sourceChart.name} (copy)`,
type: sourceChart.type,
projectId,
query: sourceChart.query as object,
config: (sourceChart.config as object) || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
const ZDeleteChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
withAuditLogging(
"deleted",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
await prisma.chart.delete({
where: { id: parsedInput.chartId },
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = chart;
return { success: true };
}
)
);
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
},
});
if (!chart) {
throw new Error("Chart not found");
}
return chart;
}
);
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
type: true,
createdAt: true,
updatedAt: true,
query: true,
config: true,
widgets: {
select: { dashboardId: true },
},
},
});
}
);
@@ -0,0 +1,300 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZWidgetLayout } from "@formbricks/types/dashboard";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const checkProjectAccess = async (
userId: string,
environmentId: string,
minPermission: "read" | "readWrite" | "manage"
) => {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission, projectId },
],
});
return { organizationId, projectId };
};
const ZCreateDashboardAction = z.object({
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
});
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await prisma.dashboard.create({
data: {
name: parsedInput.name,
description: parsedInput.description,
projectId,
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZUpdateDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
});
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
const updatedDashboard = await prisma.dashboard.update({
where: { id: parsedInput.dashboardId },
data: {
...(parsedInput.name !== undefined && { name: parsedInput.name }),
...(parsedInput.description !== undefined && { description: parsedInput.description }),
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZDeleteDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
withAuditLogging(
"deleted",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
await prisma.dashboard.delete({
where: { id: parsedInput.dashboardId },
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
return { success: true };
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return prisma.dashboard.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
_count: { select: { widgets: true } },
},
});
}
);
const ZGetDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const getDashboardAction = authenticatedActionClient
.schema(ZGetDashboardAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
},
},
},
},
},
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
return dashboard;
}
);
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
chartId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient
.schema(ZAddChartToDashboardAction)
.action(
withAuditLogging(
"created",
"dashboardWidget",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZAddChartToDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const [chart, dashboard] = await Promise.all([
prisma.chart.findFirst({ where: { id: parsedInput.chartId, projectId } }),
prisma.dashboard.findFirst({ where: { id: parsedInput.dashboardId, projectId } }),
]);
if (!chart) {
throw new Error("Chart not found");
}
if (!dashboard) {
throw new Error("Dashboard not found");
}
const maxOrder = await prisma.dashboardWidget.aggregate({
where: { dashboardId: parsedInput.dashboardId },
_max: { order: true },
});
const widget = await prisma.dashboardWidget.create({
data: {
dashboardId: parsedInput.dashboardId,
chartId: parsedInput.chartId,
title: parsedInput.title,
layout: parsedInput.layout,
order: (maxOrder._max.order ?? -1) + 1,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = widget;
return widget;
}
)
);
@@ -0,0 +1,4 @@
import { z } from "zod";
export const ZChartType = z.enum(["area", "bar", "line", "pie", "big_number"]);
export type TChartType = z.infer<typeof ZChartType>;
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { executeQuery } from "./cube-client";
const mockLoad = vi.fn();
const mockTablePivot = vi.fn();
vi.mock("@cubejs-client/core", () => ({
default: vi.fn(() => ({
load: mockLoad,
})),
}));
describe("executeQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
const resultSet = { tablePivot: mockTablePivot };
mockLoad.mockResolvedValue(resultSet);
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
});
test("loads query and returns tablePivot result", async () => {
const query = { measures: ["FeedbackRecords.count"] };
const result = await executeQuery(query);
expect(mockLoad).toHaveBeenCalledWith(query);
expect(mockTablePivot).toHaveBeenCalled();
expect(result).toEqual([{ id: "1", count: 42 }]);
});
});
@@ -0,0 +1,25 @@
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
const getApiUrl = (): string => {
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
if (baseUrl.includes("/cubejs-api/v1")) {
return baseUrl;
}
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
};
let cubeClient: CubeApi | null = null;
function getCubeClient(): CubeApi {
if (!cubeClient) {
const token = process.env.CUBEJS_API_TOKEN ?? "";
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
}
return cubeClient;
}
export async function executeQuery(query: Query) {
const client = getCubeClient();
const resultSet = await client.load(query);
return resultSet.tablePivot();
}
@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
"dashboard",
"dashboardWidget",
]);
export const ZAuditAction = z.enum([
"created",
@@ -2,13 +2,26 @@ import { NextRequest, userAgent } from "next/server";
import { logger } from "@formbricks/logger";
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
const handleError = (err: unknown, url: string): { response: Response } => {
if (err instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse(err.resourceType, err.resourceId) };
}
if (err instanceof ValidationError) {
return { response: responses.badRequestResponse(err.message, undefined, true) };
}
logger.error({ error: err, url }, "Error in POST /api/v1/client/[environmentId]/user");
return { response: responses.internalServerErrorResponse("Unable to fetch user state", true) };
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -123,16 +136,7 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(responseJson, true),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/user");
return {
response: responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true),
};
return handleError(err, req.url);
}
},
});
@@ -13,22 +13,14 @@ describe("validateAndParseAttributeValue", () => {
}
});
test("converts numbers to string", () => {
test("rejects number values (SDK must pass actual strings)", () => {
const result = validateAndParseAttributeValue(42, "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("42");
expect(result.parsedValue.valueNumber).toBeNull();
}
});
test("converts Date to ISO string", () => {
const date = new Date("2024-01-15T10:30:00.000Z");
const result = validateAndParseAttributeValue(date, "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
expect(result.parsedValue.valueDate).toBeNull();
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error.code).toBe("string_type_mismatch");
expect(result.error.params.key).toBe("testKey");
expect(result.error.params.type).toBe("number");
expect(formatValidationError(result.error)).toContain("received a number");
}
});
});
@@ -27,15 +27,6 @@ export type TAttributeValidationResult =
error: TAttributeValidationError;
};
/**
* Converts any value to a string representation
*/
const convertToString = (value: TRawValue): string => {
if (value instanceof Date) return value.toISOString();
if (typeof value === "number") return String(value);
return value;
};
/**
* Gets a human-readable type name for error messages
*/
@@ -45,16 +36,28 @@ const getTypeName = (value: TRawValue): string => {
};
/**
* Validates and parses a string type attribute
* Validates and parses a string type attribute.
*/
const validateStringType = (value: TRawValue): TAttributeValidationResult => ({
valid: true,
parsedValue: {
value: convertToString(value),
valueNumber: null,
valueDate: null,
},
});
const validateStringType = (value: TRawValue, attributeKey: string): TAttributeValidationResult => {
if (typeof value === "string") {
return {
valid: true,
parsedValue: {
value,
valueNumber: null,
valueDate: null,
},
};
}
return {
valid: false,
error: {
code: "string_type_mismatch",
params: { key: attributeKey, type: getTypeName(value) },
},
};
};
/**
* Validates and parses a number type attribute.
@@ -170,13 +173,13 @@ export const validateAndParseAttributeValue = (
): TAttributeValidationResult => {
switch (expectedDataType) {
case "string":
return validateStringType(value);
return validateStringType(value, attributeKey);
case "number":
return validateNumberType(value, attributeKey);
case "date":
return validateDateType(value, attributeKey);
default:
return validateStringType(value);
return validateStringType(value, attributeKey);
}
};
@@ -185,6 +188,8 @@ export const validateAndParseAttributeValue = (
* Used for API/SDK responses.
*/
const VALIDATION_ERROR_TEMPLATES: Record<string, string> = {
string_type_mismatch:
"Attribute '{key}' expects a string but received a {type}. Pass an actual string value.",
number_type_mismatch:
"Attribute '{key}' expects a number but received a string. Pass an actual number value (e.g., 123 instead of \"123\").",
date_invalid: "Attribute '{key}' expects a valid date. Received: Invalid Date",
@@ -11,7 +11,12 @@ import { useTranslation } from "react-i18next";
import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { previewSurvey } from "@/app/lib/templates";
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import {
COLOR_DEFAULTS,
STYLE_DEFAULTS,
deriveNewFieldsFromLegacy,
getSuggestedColors,
} from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
@@ -62,11 +67,23 @@ export const ThemeStyling = ({
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
: {};
const legacyFills = deriveNewFieldsFromLegacy(cleanSaved);
const form = useForm<TProjectStyling>({
defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved },
defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...cleanSaved },
resolver: zodResolver(ZProjectStyling),
});
// Brand color shown in the preview. Only updated when the user triggers
// "Suggest colors", "Save", or "Reset to default" — NOT on every keystroke
// in the brand-color picker. This prevents the loading-spinner / progress
// bar from updating while the user is still picking a colour.
const [previewBrandColor, setPreviewBrandColor] = useState<string>(
(cleanSaved as Partial<TProjectStyling>).brandColor?.light ??
STYLE_DEFAULTS.brandColor?.light ??
COLOR_DEFAULTS.brandColor
);
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [confirmSuggestColorsOpen, setConfirmSuggestColorsOpen] = useState(false);
@@ -84,6 +101,7 @@ export const ThemeStyling = ({
if (updatedProjectResponse?.data) {
form.reset({ ...STYLE_DEFAULTS });
setPreviewBrandColor(STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
toast.success(t("environments.workspace.look.styling_updated_successfully"));
router.refresh();
} else {
@@ -100,7 +118,10 @@ export const ThemeStyling = ({
form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true });
}
toast.success(t("environments.workspace.look.styling_updated_successfully"));
// Commit brand color to the preview now that all derived colours are in sync.
setPreviewBrandColor(brandColor ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
setConfirmSuggestColorsOpen(false);
};
@@ -113,7 +134,11 @@ export const ThemeStyling = ({
});
if (updatedProjectResponse?.data) {
form.reset({ ...updatedProjectResponse.data.styling });
const saved = updatedProjectResponse.data.styling;
form.reset({ ...saved });
setPreviewBrandColor(
saved?.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor
);
toast.success(t("environments.workspace.look.styling_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
@@ -249,7 +274,9 @@ export const ThemeStyling = ({
survey={previewSurvey(project.name, t)}
project={{
...project,
styling: form.watch("allowStyleOverwrite") ? form.watch() : STYLE_DEFAULTS,
styling: form.watch("allowStyleOverwrite")
? { ...form.watch(), brandColor: { light: previewBrandColor } }
: STYLE_DEFAULTS,
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
@@ -9,7 +9,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import { STYLE_DEFAULTS, deriveNewFieldsFromLegacy, getSuggestedColors } from "@/lib/styling/constants";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
@@ -68,10 +68,15 @@ export const StylingView = ({
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
: {};
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject);
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
const form = useForm<TSurveyStyling>({
defaultValues: {
...STYLE_DEFAULTS,
...projectLegacyFills,
...cleanProject,
...surveyLegacyFills,
...cleanSurvey,
},
});
@@ -94,7 +99,7 @@ export const StylingView = ({
form.setValue(key as keyof TSurveyStyling, value, { shouldDirty: true });
}
toast.success(t("environments.workspace.look.styling_updated_successfully"));
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
setConfirmSuggestColorsOpen(false);
};
@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
tabIndex={0}
aria-controls="options"
aria-expanded={open}
className={cn("flex h-full w-full cursor-pointer items-center justify-end bg-white pr-2", {
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
"pointer-events-none": isClearing,
})}>
@@ -225,10 +225,10 @@ export const PreviewSurvey = ({
)}>
{previewMode === "mobile" && (
<>
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
Preview
</p>
<div className="absolute top-0 right-0 m-2">
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton onClick={resetProgress} />
</div>
<MediaBackground
@@ -265,7 +265,7 @@ export const PreviewSurvey = ({
</Modal>
) : (
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute top-5 left-5">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
@@ -296,7 +296,7 @@ export const PreviewSurvey = ({
</>
)}
{previewMode === "desktop" && (
<div className="flex h-full flex-1 flex-col">
<div className="flex h-full w-full flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
@@ -373,7 +373,7 @@ export const PreviewSurvey = ({
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="absolute top-5 left-5">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
@@ -40,7 +40,10 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
isLoadingScript = true;
try {
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
const response = await fetch(scriptUrl);
const response = await fetch(
scriptUrl,
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
);
if (!response.ok) {
throw new Error("Failed to load the surveys package");
+1
View File
@@ -23,6 +23,7 @@
"@aws-sdk/s3-presigned-post": "3.971.0",
"@aws-sdk/s3-request-presigner": "3.971.0",
"@boxyhq/saml-jackson": "1.52.2",
"@cubejs-client/core": "1.6.6",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -96,6 +96,7 @@ test.describe("Survey Styling", async () => {
expect(css).toContain("--fb-input-background-color: #eeeeee");
expect(css).toContain("--fb-input-border-color: #cccccc");
expect(css).toContain("--fb-input-text-color: #024eff");
expect(css).toContain("--fb-input-placeholder-color:");
expect(css).toContain("--fb-input-border-radius: 5px");
expect(css).toContain("--fb-input-height: 50px");
expect(css).toContain("--fb-input-font-size: 16px");
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
// queryRewrite runs before every Cube query. Use it to enforce row-level security (RLS)
// by injecting filters based on the caller's identity (e.g. organizationId, projectId).
//
// The securityContext is populated from the decoded JWT passed via the API token.
// Currently a passthrough because access control is handled in the Next.js API layer
// before reaching Cube. When Cube is exposed more broadly or multi-tenancy enforcement
// is needed at the Cube level, add filters here based on securityContext claims.
queryRewrite: (query, { securityContext }) => {
return query;
},
};
+159
View File
@@ -0,0 +1,159 @@
// 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.
cube(`FeedbackRecords`, {
sql: `SELECT * FROM feedback_records`,
measures: {
count: {
type: `count`,
description: `Total number of feedback responses`,
},
promoterCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number >= 9` }],
description: `Number of promoters (NPS score 9-10)`,
},
detractorCount: {
type: `count`,
filters: [{ sql: `${CUBE}.value_number <= 6` }],
description: `Number of detractors (NPS 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)`,
},
npsScore: {
type: `number`,
sql: `
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
(
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
/ COUNT(*)::numeric
) * 100,
2
)
END
`,
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
},
averageScore: {
type: `avg`,
sql: `${CUBE}.value_number`,
description: `Average NPS score`,
},
},
dimensions: {
id: {
sql: `id`,
type: `string`,
primaryKey: true,
},
sentiment: {
sql: `sentiment`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
sourceType: {
sql: `source_type`,
type: `string`,
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
},
sourceName: {
sql: `source_name`,
type: `string`,
description: `Human-readable name of the source`,
},
fieldType: {
sql: `field_type`,
type: `string`,
description: `Type of feedback field (e.g., nps, text, rating)`,
},
collectedAt: {
sql: `collected_at`,
type: `time`,
description: `Timestamp when the feedback was collected`,
},
npsValue: {
sql: `value_number`,
type: `number`,
description: `Raw NPS score value (0-10)`,
},
responseId: {
sql: `response_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
},
userIdentifier: {
sql: `user_identifier`,
type: `string`,
description: `Identifier of the user who provided feedback`,
},
emotion: {
sql: `emotion`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
},
joins: {
TopicsUnnested: {
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
relationship: `hasMany`,
},
},
});
cube(`TopicsUnnested`, {
sql: `
SELECT
fr.id as feedback_record_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: `feedback_record_id || '-' || topic`,
type: `string`,
primaryKey: true,
},
feedbackRecordId: {
sql: `feedback_record_id`,
type: `string`,
},
topic: {
sql: `topic`,
type: `string`,
description: `Individual topic from the topics array`,
},
},
});
+28
View File
@@ -36,6 +36,34 @@ services:
volumes:
- minio-data:/data
# Cube connects to the Formbricks Hub Postgres which owns the feedback_records table.
# The CUBEJS_DB_* defaults below must match the Hub's Postgres credentials.
# Override via env vars if your Hub database uses different credentials or runs on another host.
#
# SECURITY: CUBEJS_API_SECRET has no default and must be set explicitly (e.g. in .env).
# Never use a weak secret in production/staging. Generate with: openssl rand -hex 32
cube:
image: cubejs/cube:v1.6.6
ports:
- 4000:4000
- 4001:4001 # Cube Playground UI (dev only)
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres}
CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-postgres}
CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres}
CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres}
CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432}
CUBEJS_DEV_MODE: "true"
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- ./cube/cube.js:/cube/conf/cube.js
- ./cube/schema:/cube/conf/model
restart: on-failure
depends_on:
- postgres
volumes:
postgres:
driver: local
+71 -18
View File
@@ -1,41 +1,94 @@
---
title: "Rate Limiting"
description: "Rate limiting for Formbricks"
description: "Current request rate limits in Formbricks"
icon: "timer"
---
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
## Default Rate Limits
Rate limits are scoped by identifier, depending on the endpoint:
The following rate limits apply to various endpoints:
- IP hash (for unauthenticated/client-side routes and public actions)
- API key ID (for authenticated API calls)
- User ID (for authenticated session-based calls and server actions)
- Organization ID (for follow-up email dispatch)
| **Endpoint** | **Rate Limit** | **Time Window** |
| ----------------------- | -------------- | --------------- |
| `POST /login` | 30 requests | 15 minutes |
| `POST /signup` | 30 requests | 60 minutes |
| `POST /verify-email` | 10 requests | 60 minutes |
| `POST /forgot-password` | 5 requests | 60 minutes |
| `GET /client-side-api` | 100 requests | 1 minute |
| `POST /share` | 100 requests | 60 minutes |
When a limit is exceeded, the API returns `429 Too Many Requests`.
If a request exceeds the defined rate limit, the server will respond with:
## Management API Rate Limits
These are the current limits for Management APIs:
| **Route Group** | **Limit** | **Window** | **Identifier** |
| --- | --- | --- | --- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
## All Enforced Limits
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| --- | --- | --- | --- | --- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
## Current Endpoint Exceptions
The following routes are currently not rate-limited by the server-side limiter:
- `GET /api/v1/client/og` (explicitly excluded)
- `POST /api/v2/client/[environmentId]/responses`
- `POST /api/v2/client/[environmentId]/displays`
- `GET /api/v2/health`
## 429 Response Shape
v1-style endpoints return:
```json
{
"code": 429,
"error": "Too many requests, Please try after a while!"
"code": "too_many_requests",
"message": "Maximum number of requests reached. Please try again later.",
"details": {}
}
```
v2-style endpoints return:
```json
{
"error": {
"code": 429,
"message": "Too Many Requests"
}
}
```
## Disabling Rate Limiting
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
To disable rate limiting, set the following environment variable:
Set:
```bash
RATE_LIMITING_DISABLED=1
```
After making this change, restart your server to apply the new setting.
After changing this value, restart the server.
## Operational Notes
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
@@ -16,8 +16,6 @@ The Churn Survey is among the most effective ways to identify weaknesses in your
* Follow-up to prevent bad reviews
* Coming soon: Make survey mandatory
## Overview
To run the Churn Survey in your app you want to proceed as follows:
@@ -80,13 +78,6 @@ Whenever a user visits this page, matches the filter conditions above and the re
Here is our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions/) covering [No-Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) and [Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) Actions.
<Note>
Pre-churn flow coming soon Were currently building full-screen survey
pop-ups. Youll be able to prevent users from closing the survey unless they
respond to it. Its certainly debatable if you want that but you could force
them to click through the survey before letting them cancel 🤷
</Note>
### 5. Select Action in the “When to ask” card
![Select feedback button action](/images/xm-and-surveys/xm/best-practices/cancel-subscription/select-action.webp)
@@ -46,13 +46,7 @@ _Want to change the button color? Adjust it in the project settings!_
Save, and move over to the **Audience** tab.
### 3. Pre-segment your audience (coming soon)
<Note>
### Filter by Attribute Coming Soon
We're working on pre-segmenting users by attributes. This manual will be updated in the coming days.
</Note>
### 3. Pre-segment your audience
Pre-segmentation isn't needed for this survey since you likely want to target all users who cancel their trial. You can use a specific user action, like clicking **Cancel Trial**, to show the survey only to users trying your product.
@@ -62,13 +56,13 @@ How you trigger your survey depends on your product. There are two options:
- **Trigger by Page view:** If you have a page like `/trial-cancelled` for users who cancel their trial subscription, create a user action with the type "Page View." Select "Limit to specific pages" and apply URL filters with these settings:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
![Add page URL action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-pageurl.webp)
Whenever a user visits this page, the survey will be displayed ✅
- **Trigger by Button Click:** In a different case, you have a “Cancel Trial" button in your app. You can setup a user Action with the `Inner Text`:
![Change text content](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
![Add inner text action](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/action-innertext.webp)
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.
@@ -54,13 +54,7 @@ In the button settings you have to make sure it is set to “External URL”. In
Save, and move over to the “Audience” tab.
### 3. Pre-segment your audience (coming soon)
<Note>
## Filter by attribute coming soon. We're working on pre-segmenting users by
attributes. We will update this manual in the next few days.
</Note>
### 3. Pre-segment your audience
Once you clicked over to the “Audience” tab you can change the settings. In the **Who To Send** card, select “Filter audience by attribute”. This allows you to only show the prompt to a specific segment of your user base.
+4
View File
@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-namespace -- using namespaces is required for prisma-json-types-generator */
import { type TActionClassNoCodeConfig } from "../types/action-classes";
import type { TOrganizationAccess } from "../types/api-key";
import type { TChartConfig, TChartQuery, TWidgetLayout } from "../types/dashboard";
import { type TIntegrationConfig } from "../types/integration";
import { type TOrganizationBilling } from "../types/organizations";
import { type TProjectConfig, type TProjectStyling } from "../types/project";
@@ -55,5 +56,8 @@ declare global {
export type OrganizationAccess = TOrganizationAccess;
export type SurveyMetadata = TSurveyMetadata;
export type SurveyQuotaLogic = TSurveyQuotaLogic;
export type ChartQuery = TChartQuery;
export type ChartConfig = TChartConfig;
export type WidgetLayout = TWidgetLayout;
}
}
@@ -0,0 +1,77 @@
-- CreateEnum
CREATE TYPE "public"."ChartType" AS ENUM ('area', 'bar', 'line', 'pie', 'big_number');
-- CreateTable
CREATE TABLE "public"."Chart" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" "public"."ChartType" NOT NULL,
"projectId" TEXT NOT NULL,
"query" JSONB NOT NULL DEFAULT '{}',
"config" JSONB NOT NULL DEFAULT '{}',
"createdBy" TEXT,
CONSTRAINT "Chart_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Dashboard" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"projectId" TEXT NOT NULL,
"createdBy" TEXT,
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."DashboardWidget" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"dashboardId" TEXT NOT NULL,
"title" TEXT,
"chartId" TEXT NOT NULL,
"layout" JSONB NOT NULL DEFAULT '{"x":0,"y":0,"w":4,"h":3}',
"order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "DashboardWidget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Chart_projectId_created_at_idx" ON "public"."Chart"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Chart_projectId_name_key" ON "public"."Chart"("projectId", "name");
-- CreateIndex
CREATE INDEX "Dashboard_projectId_created_at_idx" ON "public"."Dashboard"("projectId", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "Dashboard_projectId_name_key" ON "public"."Dashboard"("projectId", "name");
-- CreateIndex
CREATE INDEX "DashboardWidget_dashboardId_order_idx" ON "public"."DashboardWidget"("dashboardId", "order");
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_chartId_fkey" FOREIGN KEY ("chartId") REFERENCES "public"."Chart"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+93
View File
@@ -647,6 +647,8 @@ model Project {
logo Json?
projectTeams ProjectTeam[]
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
charts Chart[]
dashboards Dashboard[]
@@unique([organizationId, name])
}
@@ -867,6 +869,8 @@ model User {
/// [Locale]
locale String @default("en-US")
surveys Survey[]
charts Chart[] @relation("chartCreatedBy")
dashboards Dashboard[] @relation("dashboardCreatedBy")
teamUsers TeamUser[]
lastLoginAt DateTime?
isActive Boolean @default(true)
@@ -1004,3 +1008,92 @@ model ProjectTeam {
@@id([projectId, teamId])
@@index([teamId])
}
enum ChartType {
area
bar
line
pie
big_number
}
/// Represents a chart/visualization that can be used in multiple dashboards.
/// Charts are reusable components that query analytics data.
///
/// @property id - Unique identifier for the chart
/// @property name - Display name of the chart
/// @property type - Type of visualization (bar, line, pie, etc.)
/// @property project - The project this chart belongs to
/// @property query - Cube.js query configuration (JSON)
/// @property config - Chart-specific configuration (colors, labels, etc.)
/// @property createdBy - User who created the chart
/// @property dashboards - Dashboards that use this chart
model Chart {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
type ChartType
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
/// [ChartQuery] - Cube.js query configuration
query Json @default("{}")
/// [ChartConfig] - Visualization configuration (colors, labels, formatting)
config Json @default("{}")
creator User? @relation("chartCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a dashboard containing multiple charts.
/// Dashboards aggregate analytics insights at the project level.
///
/// @property id - Unique identifier for the dashboard
/// @property name - Display name of the dashboard
/// @property description - Optional description
/// @property project - The project this dashboard belongs to
/// @property widgets - Charts on this dashboard
/// @property createdBy - User who created the dashboard
model Dashboard {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
description String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
creator User? @relation("dashboardCreatedBy", fields: [createdBy], references: [id], onDelete: SetNull)
createdBy String?
widgets DashboardWidget[]
@@unique([projectId, name])
@@index([projectId, createdAt])
}
/// Represents a chart widget on a dashboard.
/// Widgets are positioned using a grid layout system.
///
/// @property id - Unique identifier for the widget
/// @property dashboard - The dashboard this widget belongs to
/// @property title - Optional title for the widget
/// @property chart - The chart displayed in this widget
/// @property layout - Grid layout configuration (x, y, width, height)
/// @property order - Display order within the dashboard
model DashboardWidget {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
dashboardId String
title String?
chart Chart @relation(fields: [chartId], references: [id], onDelete: Cascade)
chartId String
/// [WidgetLayout] - Grid layout: { x, y, w, h }
layout Json @default("{\"x\":0,\"y\":0,\"w\":4,\"h\":3}")
order Int @default(0)
@@index([dashboardId, order])
}
+5 -5
View File
@@ -127,12 +127,12 @@
--fb-input-font-size: 14px;
--fb-input-font-weight: 400;
--fb-input-color: #414b5a;
--fb-input-placeholder-color: var(--fb-input-color);
--fb-input-placeholder-color: var(--fb-input-text-color, var(--fb-input-color));
--fb-input-placeholder-opacity: 0.5;
--fb-input-width: 100%;
--fb-input-height: 40px;
--fb-input-padding-x: 16px;
--fb-input-padding-y: 16px;
--fb-input-height: 20px;
--fb-input-padding-x: 8px;
--fb-input-padding-y: 8px;
--fb-input-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* ── Progress Bar ──────────────────────────────────────────────────── */
@@ -244,4 +244,4 @@
#fbjs textarea::-webkit-scrollbar-thumb:hover {
background-color: hsl(215.4 16.3% 46.9% / 0.5);
}
}
+33
View File
@@ -443,6 +443,39 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-button-font-size"]).toBe("1.5rem");
});
test("should derive input-placeholder-color from inputTextColor when set", () => {
const styling: TSurveyStyling = {
...getBaseProjectStyling(),
questionColor: { light: "#AABBCC" },
inputTextColor: { light: "#112233" },
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
// Placeholder should be derived from inputTextColor, not questionColor
expect(variables["--fb-input-placeholder-color"]).toBeDefined();
expect(variables["--fb-placeholder-color"]).toBeDefined();
// Both should be based on inputTextColor (#112233) mixed with white, not questionColor (#AABBCC)
// We can verify by checking the placeholder color doesn't contain the questionColor mix
expect(variables["--fb-input-placeholder-color"]).toBe(variables["--fb-placeholder-color"]);
});
test("should derive input-placeholder-color from questionColor when inputTextColor is not set", () => {
const styling: TSurveyStyling = {
...getBaseProjectStyling(),
questionColor: { light: "#AABBCC" },
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
// Placeholder should fall back to questionColor when inputTextColor is not set
expect(variables["--fb-input-placeholder-color"]).toBeDefined();
expect(variables["--fb-placeholder-color"]).toBeDefined();
expect(variables["--fb-input-placeholder-color"]).toBe(variables["--fb-placeholder-color"]);
});
test("should set signature and branding text colors for dark questionColor", () => {
const styling = getBaseProjectStyling({
questionColor: { light: "#202020" }, // A dark color
+37 -17
View File
@@ -111,8 +111,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Backwards-compat: legacy variables still used by some consumers/tests
appendCssVariable("subheading-color", styling.questionColor?.light);
if (styling.questionColor?.light) {
appendCssVariable("placeholder-color", mixColor(styling.questionColor.light, "#ffffff", 0.3));
const placeholderBaseColor = styling.inputTextColor?.light ?? styling.questionColor?.light;
if (placeholderBaseColor) {
appendCssVariable("placeholder-color", mixColor(placeholderBaseColor, "#ffffff", 0.3));
appendCssVariable("input-placeholder-color", mixColor(placeholderBaseColor, "#ffffff", 0.3));
}
appendCssVariable("border-color", styling.inputBorderColor?.light);
@@ -192,8 +194,13 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
}
// Buttons (Advanced)
appendCssVariable("button-bg-color", styling.buttonBgColor?.light);
appendCssVariable("button-text-color", styling.buttonTextColor?.light);
const buttonBg = styling.buttonBgColor?.light ?? styling.brandColor?.light;
let buttonText = styling.buttonTextColor?.light;
if (buttonText === undefined && buttonBg) {
buttonText = isLight(buttonBg) ? "#0f172a" : "#ffffff";
}
appendCssVariable("button-bg-color", buttonBg);
appendCssVariable("button-text-color", buttonText);
if (styling.buttonBorderRadius !== undefined)
appendCssVariable("button-border-radius", formatDimension(styling.buttonBorderRadius));
if (styling.buttonHeight !== undefined)
@@ -209,7 +216,11 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Inputs (Advanced)
appendCssVariable("input-background-color", styling.inputBgColor?.light ?? styling.inputColor?.light);
appendCssVariable("input-text-color", styling.inputTextColor?.light);
const inputTextColor = styling.inputTextColor?.light ?? styling.questionColor?.light;
appendCssVariable("input-text-color", inputTextColor);
if (inputTextColor) {
appendCssVariable("input-placeholder-color", mixColor(inputTextColor, "#ffffff", 0.3));
}
if (styling.inputBorderRadius !== undefined)
appendCssVariable("input-border-radius", formatDimension(styling.inputBorderRadius));
if (styling.inputHeight !== undefined)
@@ -225,8 +236,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
appendCssVariable("input-shadow", styling.inputShadow);
// Options (Advanced)
appendCssVariable("option-bg-color", styling.optionBgColor?.light);
appendCssVariable("option-label-color", styling.optionLabelColor?.light);
appendCssVariable("option-bg-color", styling.optionBgColor?.light ?? styling.inputColor?.light);
appendCssVariable("option-label-color", styling.optionLabelColor?.light ?? styling.questionColor?.light);
if (styling.optionBorderRadius !== undefined)
appendCssVariable("option-border-radius", formatDimension(styling.optionBorderRadius));
if (styling.optionPaddingX !== undefined)
@@ -277,8 +288,15 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Implicitly set the progress track border radius to the roundness of the card
appendCssVariable("progress-track-border-radius", formatDimension(roundness));
appendCssVariable("progress-track-bg-color", styling.progressTrackBgColor?.light);
appendCssVariable("progress-indicator-bg-color", styling.progressIndicatorBgColor?.light);
appendCssVariable(
"progress-track-bg-color",
styling.progressTrackBgColor?.light ??
(styling.brandColor?.light ? mixColor(styling.brandColor.light, "#ffffff", 0.8) : undefined)
);
appendCssVariable(
"progress-indicator-bg-color",
styling.progressIndicatorBgColor?.light ?? styling.brandColor?.light
);
// Close the #fbjs variable block
cssVariables += "}\n";
@@ -304,7 +322,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
headlineDecls += " font-size: var(--fb-element-headline-font-size) !important;\n";
if (styling.elementHeadlineFontWeight !== undefined && styling.elementHeadlineFontWeight !== null)
headlineDecls += " font-weight: var(--fb-element-headline-font-weight) !important;\n";
if (styling.elementHeadlineColor?.light)
if (styling.elementHeadlineColor?.light || styling.questionColor?.light)
headlineDecls += " color: var(--fb-element-headline-color) !important;\n";
addRule("#fbjs .label-headline,\n#fbjs .label-headline *", headlineDecls);
@@ -314,7 +332,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
descriptionDecls += " font-size: var(--fb-element-description-font-size) !important;\n";
if (styling.elementDescriptionFontWeight !== undefined && styling.elementDescriptionFontWeight !== null)
descriptionDecls += " font-weight: var(--fb-element-description-font-weight) !important;\n";
if (styling.elementDescriptionColor?.light)
if (styling.elementDescriptionColor?.light || styling.questionColor?.light)
descriptionDecls += " color: var(--fb-element-description-color) !important;\n";
addRule("#fbjs .label-description,\n#fbjs .label-description *", descriptionDecls);
@@ -324,7 +342,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
upperDecls += " font-size: var(--fb-element-upper-label-font-size) !important;\n";
if (styling.elementUpperLabelFontWeight !== undefined && styling.elementUpperLabelFontWeight !== null)
upperDecls += " font-weight: var(--fb-element-upper-label-font-weight) !important;\n";
if (styling.elementUpperLabelColor?.light) {
if (styling.elementUpperLabelColor?.light || styling.questionColor?.light) {
upperDecls += " color: var(--fb-element-upper-label-color) !important;\n";
upperDecls += " opacity: var(--fb-element-upper-label-opacity, 1) !important;\n";
}
@@ -332,9 +350,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// --- Buttons ---
let buttonDecls = "";
if (styling.buttonBgColor?.light)
if (styling.buttonBgColor?.light || styling.brandColor?.light)
buttonDecls += " background-color: var(--fb-button-bg-color) !important;\n";
if (styling.buttonTextColor?.light) buttonDecls += " color: var(--fb-button-text-color) !important;\n";
if (styling.buttonTextColor?.light || styling.brandColor?.light)
buttonDecls += " color: var(--fb-button-text-color) !important;\n";
if (styling.buttonBorderRadius !== undefined)
buttonDecls += " border-radius: var(--fb-button-border-radius) !important;\n";
if (styling.buttonHeight !== undefined) buttonDecls += " height: var(--fb-button-height) !important;\n";
@@ -355,11 +374,11 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// --- Options ---
if (styling.optionBorderRadius !== undefined)
addRule("#fbjs .rounded-option", " border-radius: var(--fb-option-border-radius) !important;\n");
if (styling.optionBgColor?.light)
if (styling.optionBgColor?.light || styling.inputColor?.light)
addRule("#fbjs .bg-option-bg", " background-color: var(--fb-option-bg-color) !important;\n");
let optionLabelDecls = "";
if (styling.optionLabelColor?.light)
if (styling.optionLabelColor?.light || styling.questionColor?.light)
optionLabelDecls += " color: var(--fb-option-label-color) !important;\n";
if (styling.optionFontSize !== undefined)
optionLabelDecls += " font-size: var(--fb-option-font-size) !important;\n";
@@ -385,7 +404,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
addRule("#fbjs .border-input-border", " border-color: var(--fb-input-border-color) !important;\n");
let inputTextDecls = "";
if (styling.inputTextColor?.light) inputTextDecls += " color: var(--fb-input-text-color) !important;\n";
if (styling.inputTextColor?.light || styling.questionColor?.light)
inputTextDecls += " color: var(--fb-input-text-color) !important;\n";
if (styling.inputFontSize !== undefined)
inputTextDecls += " font-size: var(--fb-input-font-size) !important;\n";
addRule("#fbjs .text-input-text", inputTextDecls);
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
// ── Cube.js Query shape (stored as JSON in Chart.query) ─────────────────────
// Mirrors https://cube.dev/docs/product/apis-integrations/core-data-apis/rest-api/query-format
const ZCubeTimeDimension = z.object({
dimension: z.string(),
dateRange: z.union([z.string(), z.tuple([z.string(), z.string()])]).optional(),
compareDateRange: z.array(z.union([z.string(), z.tuple([z.string(), z.string()])])).optional(),
granularity: z.enum(["second", "minute", "hour", "day", "week", "month", "quarter", "year"]).optional(),
});
const ZCubeMemberFilter = z.object({
member: z.string(),
operator: z.string(),
values: z.array(z.string()).optional(),
});
type TCubeFilter = z.infer<typeof ZCubeMemberFilter> | { and: TCubeFilter[] } | { or: TCubeFilter[] };
const ZCubeFilter: z.ZodType<TCubeFilter> = z.union([
ZCubeMemberFilter,
z.object({ and: z.lazy(() => z.array(ZCubeFilter)) }),
z.object({ or: z.lazy(() => z.array(ZCubeFilter)) }),
]);
export const ZChartQuery = z.object({
measures: z.array(z.string()).optional(),
dimensions: z.array(z.string()).optional(),
segments: z.array(z.string()).optional(),
timeDimensions: z.array(ZCubeTimeDimension).optional(),
filters: z.array(ZCubeFilter).optional(),
order: z
.union([z.array(z.tuple([z.string(), z.enum(["asc", "desc"])])), z.record(z.enum(["asc", "desc"]))])
.optional(),
limit: z.number().int().positive().optional(),
total: z.boolean().optional(),
offset: z.number().int().nonnegative().optional(),
timezone: z.string().optional(),
renewQuery: z.boolean().optional(),
ungrouped: z.boolean().optional(),
joinHints: z.array(z.tuple([z.string(), z.string()])).optional(),
});
export type TChartQuery = z.infer<typeof ZChartQuery>;
// ── Chart visualization config (stored as JSON in Chart.config) ─────────────
export const ZChartConfig = z.object({
colors: z.array(z.string()).optional(),
xAxisLabel: z.string().optional(),
yAxisLabel: z.string().optional(),
showLegend: z.boolean().optional(),
legendPosition: z.enum(["top", "bottom", "left", "right"]).optional(),
stacked: z.boolean().optional(),
showGrid: z.boolean().optional(),
showValues: z.boolean().optional(),
numberFormat: z.string().optional(),
dateFormat: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
});
export type TChartConfig = z.infer<typeof ZChartConfig>;
// ── Widget grid layout (stored as JSON in DashboardWidget.layout) ───────────
export const ZWidgetLayout = z.object({
x: z.number().int().nonnegative(),
y: z.number().int().nonnegative(),
w: z.number().int().positive(),
h: z.number().int().positive(),
});
export type TWidgetLayout = z.infer<typeof ZWidgetLayout>;
+56 -17
View File
@@ -28,7 +28,7 @@ importers:
dependencies:
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
@@ -139,6 +139,9 @@ importers:
'@boxyhq/saml-jackson':
specifier: 1.52.2
version: 1.52.2(socks@2.8.7)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.8.3))
'@cubejs-client/core':
specifier: 1.6.6
version: 1.6.6(encoding@0.1.13)
'@dnd-kit/core':
specifier: 6.3.1
version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -294,7 +297,7 @@ importers:
version: 1.2.6(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@sentry/nextjs':
specifier: 10.5.0
version: 10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
version: 10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)
@@ -384,13 +387,13 @@ importers:
version: 3.0.1
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-auth:
specifier: 4.24.12
version: 4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-safe-action:
specifier: 7.10.8
version: 7.10.8(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4)
version: 7.10.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -1775,6 +1778,9 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@cubejs-client/core@1.6.6':
resolution: {integrity: sha512-JWEVQaaS7y6Y3Nhe4Lcjl8coP5eXIgYZQTb2WMlLdMluSGM4mWOSFpAis77lAPV+nybFvRf9ri+PjCGqGJXH5g==}
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
@@ -7155,6 +7161,9 @@ packages:
core-js-compat@3.47.0:
resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==}
core-js@3.48.0:
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -7170,6 +7179,9 @@ packages:
engines: {node: '>=20'}
hasBin: true
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -10035,6 +10047,9 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
ramda@0.27.2:
resolution: {integrity: sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -11306,6 +11321,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
url-search-params-polyfill@7.0.1:
resolution: {integrity: sha512-bAw7L2E+jn9XHG5P9zrPnHdO0yJub4U+yXJOdpcpkr7OBd9T8oll4lUos0iSGRcDvfZoLUKfx9a6aNmIhJ4+mQ==}
url-template@2.0.8:
resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
@@ -13515,6 +13533,17 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@cubejs-client/core@1.6.6(encoding@0.1.13)':
dependencies:
core-js: 3.48.0
cross-fetch: 3.2.0(encoding@0.1.13)
dayjs: 1.11.19
ramda: 0.27.2
url-search-params-polyfill: 7.0.1
uuid: 11.1.0
transitivePeerDependencies:
- encoding
'@date-fns/tz@1.2.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
@@ -17049,7 +17078,7 @@ snapshots:
'@sentry/core@10.5.0': {}
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))':
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.38.0
@@ -17062,7 +17091,7 @@ snapshots:
'@sentry/vercel-edge': 10.5.0
'@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.12))
chalk: 3.0.0
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
resolve: 1.22.8
rollup: 4.54.0
stacktrace-parser: 0.1.11
@@ -19786,6 +19815,8 @@ snapshots:
dependencies:
browserslist: 4.28.1
core-js@3.48.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@@ -19800,6 +19831,12 @@ snapshots:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-fetch@3.2.0(encoding@0.1.13):
dependencies:
node-fetch: 2.7.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -22186,13 +22223,13 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next-auth@4.24.12(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.11)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
@@ -22203,9 +22240,9 @@ snapshots:
optionalDependencies:
nodemailer: 7.0.11
next-safe-action@7.10.8(next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4):
next-safe-action@7.10.8(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.24.4):
dependencies:
next: 16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
@@ -22219,7 +22256,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.10
'@next/swc-darwin-x64': 16.0.10
@@ -22236,7 +22273,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@16.1.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
@@ -22245,7 +22282,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
@@ -22933,6 +22970,8 @@ snapshots:
quick-format-unescaped@4.0.4: {}
ramda@0.27.2: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -23991,12 +24030,10 @@ snapshots:
stubborn-utils@1.0.2: {}
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3):
styled-jsx@5.1.6(react@19.2.3):
dependencies:
client-only: 0.0.1
react: 19.2.3
optionalDependencies:
'@babel/core': 7.28.5
stylis@4.3.6: {}
@@ -24504,6 +24541,8 @@ snapshots:
dependencies:
punycode: 2.3.1
url-search-params-polyfill@7.0.1: {}
url-template@2.0.8: {}
use-callback-ref@1.3.3(@types/react@19.2.1)(react@19.2.1):
+2
View File
@@ -156,6 +156,8 @@
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",
"CUBEJS_API_TOKEN",
"CUBEJS_API_URL",
"DATABASE_URL",
"DEBUG",
"E2E_TESTING",