Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docker-build-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
contents: read
env:
DATABASE_URL: postgresql://test:test@127.0.0.1:5432/formbricks
POSTGRES_PASSWORD: build-time-placeholder
ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
REDIS_URL: redis://127.0.0.1:6379
CUBEJS_API_URL: http://127.0.0.1:4000
Expand Down Expand Up @@ -110,6 +111,7 @@ jobs:
env:
AUTHZED_DATABASE_PASSWORD: 0000000000000000000000000000000000000000000000000000000000000002
AUTHZED_TOKEN: 0000000000000000000000000000000000000000000000000000000000000001
POSTGRES_PASSWORD: build-time-placeholder
run: |
set -euo pipefail

Expand Down Expand Up @@ -322,6 +324,7 @@ jobs:
printf '%s\n' \
'WEBAPP_URL=http://localhost:3000' \
'NEXTAUTH_URL=http://localhost:3000' \
'POSTGRES_PASSWORD=compose-postgres-placeholder' \
"ENCRYPTION_KEY=$DUMMY_ENCRYPTION_KEY" \
"BETTER_AUTH_SECRET=$DUMMY_ENCRYPTION_KEY" \
'HUB_API_KEY=build-time-placeholder' \
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15

services:
postgres:
image: pgvector/pgvector@sha256:2ba9ca5f2e7daa0f0e7723cba1ee9167bab54efd3640516a44ac1a928dd67e7a
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: formbricks_migration_shadow
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s
--health-timeout=5s
--health-retries=5

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
Expand Down Expand Up @@ -42,5 +57,51 @@ jobs:
- name: Check formatting
run: pnpm format:check

- name: Lint changed database migrations
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || '' }}
shell: bash
run: |
set -euo pipefail

base_sha="${BASE_SHA}"
if [[ -z "${base_sha}" ]]; then
base_sha="$(git rev-parse HEAD^)"
echo "::notice::No event base SHA was provided; comparing against ${base_sha}."
fi

if [[ ! "${base_sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Invalid base SHA: ${base_sha}"
exit 1
fi

if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
git fetch --no-tags --depth=1 origin "${base_sha}"
fi

mapfile -d '' migration_files < <(
git diff --diff-filter=ACMR --name-only -z "${base_sha}" HEAD -- \
'packages/database/migration/*/migration.sql'
)

if (( ${#migration_files[@]} == 0 )); then
echo "::notice::No changed schema migrations to lint."
exit 0
fi

absolute_migration_files=()
for migration_file in "${migration_files[@]}"; do
absolute_migration_files+=("${GITHUB_WORKSPACE}/${migration_file}")
done

printf 'Linting migration: %s\n' "${migration_files[@]}"
pnpm --filter @formbricks/database lint:migrations -- "${absolute_migration_files[@]}"

- name: Check database migration drift
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public
SHADOW_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/formbricks_migration_shadow?schema=public
run: pnpm --filter @formbricks/database check:migration-drift

- name: Lint
run: pnpm lint
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { organizationSettingsPath } from "@/modules/settings/lib/routes";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { SettingsTable, type TSettingsTableColumn } from "@/modules/ui/components/settings-table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { type TAlertRow, getAlertRows } from "../lib/alert-rows";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";

Expand All @@ -19,9 +20,6 @@ interface EditAlertsProps {
autoDisableNotificationElementId: string;
}

/** A survey to alert on, carrying the workspace it belongs to for the row's sub-line. */
type TAlertRow = { surveyId: string; surveyName: string; workspaceName: string };

/**
* Defined at module level rather than inside the component: an inline `cell` that returns JSX reads as a
* nested component definition to Sonar (typescript:S6478). One array serves every organization's table.
Expand All @@ -39,15 +37,22 @@ const getAlertColumns = ({
}>): TSettingsTableColumn<TAlertRow>[] => [
{
id: "survey",
header: t("common.surveys"),
headerClassName: "w-[70%]",
header: t("common.survey"),
headerClassName: "w-[45%]",
cellClassName: "font-medium text-slate-900",
skeletonWidth: "w-48",
cell: (row) => (
<>
<div className="font-medium text-slate-900">{row.surveyName}</div>
<div className="text-xs text-slate-400">{row.workspaceName}</div>
</>
),
cell: (row) => row.surveyName,
},
{
// A column of its own rather than a sub-line under the survey name: the surveys of an organization
// are listed together here, so the same name can appear once per workspace, and an unlabelled second
// line left the reader to guess what it named.
id: "workspace",
header: t("common.workspace"),
headerClassName: "w-[30%]",
cellClassName: "text-slate-500",
skeletonWidth: "w-32",
cell: (row) => row.workspaceName,
},
{
id: "alert",
Expand All @@ -64,7 +69,7 @@ const getAlertColumns = ({
</Tooltip>
</TooltipProvider>
),
headerClassName: "w-[30%]",
headerClassName: "w-[25%]",
align: "center",
skeletonWidth: "w-10",
cell: (row) => (
Expand Down Expand Up @@ -103,15 +108,9 @@ export const EditAlerts = ({
return (
<>
{memberships.map((membership) => {
// One row list per organization: the surveys were nested one level deeper, under workspaces, and
// the workspace only contributes a sub-line to each row.
const rows: TAlertRow[] = membership.organization.workspaces.flatMap((workspace) =>
workspace.surveys.map((survey) => ({
surveyId: survey.id,
surveyName: survey.name,
workspaceName: workspace.name,
}))
);
// One row list per organization: the surveys are nested one level deeper, under workspaces, and
// each row names the workspace it came from.
const rows: TAlertRow[] = getAlertRows(membership.organization.workspaces);

return (
<div key={membership.organization.id}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { getAlertRows } from "./alert-rows";

const workspace = (name: string, surveys: { id: string; name: string }[]) => ({ id: name, name, surveys });

describe("getAlertRows", () => {
test("names the workspace on every survey row", () => {
const rows = getAlertRows([
workspace("Website", [{ id: "s1", name: "NPS Survey" }]),
workspace("Mobile App", [{ id: "s2", name: "Churn Survey" }]),
]);

expect(rows).toEqual([
{ surveyId: "s2", surveyName: "Churn Survey", workspaceName: "Mobile App" },
{ surveyId: "s1", surveyName: "NPS Survey", workspaceName: "Website" },
]);
});

test("keeps a workspace's surveys together when the input interleaves them", () => {
// What the page's own query returns: no `orderBy` on either level, so a second workspace can sit
// between two surveys that belong to the same one.
const rows = getAlertRows([
workspace("Website", [{ id: "s1", name: "NPS Survey" }]),
workspace("Docs Portal", [{ id: "s2", name: "Onboarding Feedback" }]),
workspace("Website", [{ id: "s3", name: "Churn Survey" }]),
]);

expect(rows.map((row) => [row.workspaceName, row.surveyName])).toEqual([
["Docs Portal", "Onboarding Feedback"],
["Website", "Churn Survey"],
["Website", "NPS Survey"],
]);
});

test("orders same-named surveys in one workspace by id, so their rows cannot swap", () => {
const duplicates = [
{ id: "s2", name: "NPS Survey" },
{ id: "s1", name: "NPS Survey" },
];

expect(getAlertRows([workspace("Website", duplicates)]).map((row) => row.surveyId)).toEqual(["s1", "s2"]);
expect(
getAlertRows([workspace("Website", [...duplicates].reverse())]).map((row) => row.surveyId)
).toEqual(["s1", "s2"]);
});

test("orders numbered workspaces the way a reader counts them", () => {
const rows = getAlertRows([
workspace("Workspace 10", [{ id: "s1", name: "NPS Survey" }]),
workspace("Workspace 2", [{ id: "s2", name: "NPS Survey" }]),
]);

expect(rows.map((row) => row.workspaceName)).toEqual(["Workspace 2", "Workspace 10"]);
});

test("returns no rows for an organization whose workspaces hold no surveys", () => {
expect(getAlertRows([workspace("Website", []), workspace("Mobile App", [])])).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Membership } from "../types";

/** A survey to alert on, carrying the workspace it belongs to so the list can name it. */
export type TAlertRow = { surveyId: string; surveyName: string; workspaceName: string };

type TWorkspaces = Membership["organization"]["workspaces"];

/**
* Pinned to one locale rather than left to `localeCompare`'s default, because the list is rendered by a
* client component: the server sorts under Node's locale and the browser re-sorts under the visitor's,
* and a collation that disagrees between the two puts the rows in a different order on each side, which
* is a hydration mismatch. `numeric` is what keeps "Workspace 2" ahead of "Workspace 10".
*/
const collator = new Intl.Collator("en", { numeric: true });

/**
* Flattens one organization's workspaces into a row per survey, ordered by workspace, then by survey
* name, then by id.
*
* The order carries the workspace column: the query returns surveys grouped by workspace but with no
* order inside or between those groups, so a workspace's surveys can arrive interleaved with another's
* and two same-named surveys from different workspaces can land rows apart. Sorting keeps each
* workspace's surveys together, so the column reads as a group label rather than a value repeated at
* random. The id breaks the remaining tie, because nothing stops two surveys in one workspace sharing a
* name — without it their rows could swap places between renders.
*/
export const getAlertRows = (workspaces: TWorkspaces): TAlertRow[] =>
workspaces
.flatMap((workspace) =>
workspace.surveys.map((survey) => ({
surveyId: survey.id,
surveyName: survey.name,
workspaceName: workspace.name,
}))
)
.sort(
(a, b) =>
collator.compare(a.workspaceName, b.workspaceName) ||
collator.compare(a.surveyName, b.surveyName) ||
collator.compare(a.surveyId, b.surveyId)
);
Loading
Loading