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
43 changes: 43 additions & 0 deletions .github/workflows/dependabot-to-linear.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Dependabot Alerts to Linear

on:
schedule:
- cron: "0 11 * * *" # 11:00 UTC = 12:00 CET (noon); +1h during CEST
workflow_dispatch: {}

permissions:
contents: read

concurrency:
group: dependabot-to-linear-sync
cancel-in-progress: false

jobs:
sync:
name: Sync Dependabot alerts to Linear
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit

- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24

- name: Sync alerts to Linear
env:
DEPENDABOT_ALERTS_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
# Fallback used by the script if LINEAR_API_KEY is not set; must be
# listed here because the job only sees secrets exposed via env.
LINEAR_ACCESS_KEY: ${{ secrets.LINEAR_ACCESS_KEY }}
run: node scripts/dependabot-to-linear.mjs
12 changes: 12 additions & 0 deletions apps/web/app/api/v1/client/[workspaceId]/environment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ export const GET = withV1ApiWrapper({

// Use optimized environment state fetcher with new caching approach
const workspace = await getWorkspaceState(workspaceId);

// Guard against unexpected empty state before destructuring.
if (!workspace?.data) {
logger.error(
{ workspaceId, url: req.url },
"getWorkspaceState returned unexpected null/empty payload"
);
return {
response: responses.notFoundResponse("Workspace", workspaceId),
};
}

const { data } = workspace;

return {
Expand Down
26 changes: 26 additions & 0 deletions apps/web/lib/cache/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockCacheService = {
del: vi.fn(),
exists: vi.fn(),
withCache: vi.fn(),
withCacheNullable: vi.fn(),
getRedisClient: vi.fn(),
};

Expand Down Expand Up @@ -219,6 +220,31 @@ describe("Cache Index", () => {
expect(result).toBe("cached-result");
});

test("should proxy withCacheNullable method correctly when cache is available", async () => {
const mockFn = vi.fn().mockResolvedValue(null);
mockCacheService.withCacheNullable.mockResolvedValue(null);

const result = await cache.withCacheNullable(mockFn, "cache-key" as CacheKey, 3000);

expect(mockCacheService.withCacheNullable).toHaveBeenCalledWith(mockFn, "cache-key" as CacheKey, 3000);
expect(result).toBeNull();
});

test("should execute nullable function directly when cache service fails", async () => {
const mockFn = vi.fn().mockResolvedValue("function-result");

mockGetCacheService.mockResolvedValue({
ok: false,
error: { code: "CACHE_UNAVAILABLE" },
});

const result = await cache.withCacheNullable(mockFn, "cache-key" as CacheKey, 3000);

expect(result).toBe("function-result");
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockCacheService.withCacheNullable).not.toHaveBeenCalled();
});

test("should execute function directly when cache service fails", async () => {
const mockFn = vi.fn().mockResolvedValue("function-result");

Expand Down
30 changes: 28 additions & 2 deletions apps/web/lib/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ type CacheService = {
set(key: string, value: unknown, ttlMs?: number): Promise<CacheResult<void>>;
del(keys: string[]): Promise<CacheResult<void>>;
tryLock(key: string, value: string, ttlMs: number): Promise<CacheResult<boolean>>;
withCache<T>(fn: () => Promise<T>, key: string, ttlMs: number): Promise<T>;
withCache<T extends NonNullable<unknown>>(fn: () => Promise<T>, key: string, ttlMs: number): Promise<T>;
withCacheNullable<T extends NonNullable<unknown>>(
fn: () => Promise<T | null>,
key: string,
ttlMs: number
): Promise<T | null>;
getRedisClient(): RedisClientType | null;
};

Expand All @@ -31,7 +36,7 @@ export const cache = new Proxy({} as AsyncCacheService, {
get(_target, prop: keyof CacheService) {
// Special-case: withCache must never fail; fall back to direct fn on init failure.
if (prop === "withCache") {
return async <T>(fn: () => Promise<T>, ...rest: [string, number]) => {
return async <T extends NonNullable<unknown>>(fn: () => Promise<T>, ...rest: [string, number]) => {
try {
const cacheServiceResult = await getCacheService();

Expand All @@ -48,6 +53,27 @@ export const cache = new Proxy({} as AsyncCacheService, {
};
}

if (prop === "withCacheNullable") {
return async <T extends NonNullable<unknown>>(
fn: () => Promise<T | null>,
...rest: [string, number]
) => {
try {
const cacheServiceResult = await getCacheService();

if (!cacheServiceResult.ok) {
return await fn();
}

const cacheService = cacheServiceResult.data as CacheService;
return cacheService.withCacheNullable(fn, ...rest);
} catch (error) {
logger.warn({ error }, "Cache unavailable; executing function directly");
return await fn();
}
};
}

if (prop === "getRedisClient") {
return async () => {
const cacheServiceResult = await getCacheService();
Expand Down
13 changes: 8 additions & 5 deletions apps/web/modules/ee/billing/lib/organization-billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
prismaOrganizationBillingUpsert: vi.fn(),
prismaOrganizationBillingUpdate: vi.fn(),
cacheWithCache: vi.fn(),
cacheWithCacheNullable: vi.fn(),
cacheDel: vi.fn(),
loggerWarn: vi.fn(),
getCloudPlanFromProduct: vi.fn(),
Expand Down Expand Up @@ -88,6 +89,7 @@ vi.mock("@formbricks/database", () => ({
vi.mock("@/lib/cache", () => ({
cache: {
withCache: mocks.cacheWithCache,
withCacheNullable: mocks.cacheWithCacheNullable,
del: mocks.cacheDel,
},
}));
Expand Down Expand Up @@ -156,6 +158,7 @@ describe("organization-billing", () => {
[namespace, identifier, subresource].filter(Boolean).join(":")
);
mocks.cacheWithCache.mockImplementation(async (fn: () => Promise<unknown>) => await fn());
mocks.cacheWithCacheNullable.mockImplementation(async (fn: () => Promise<unknown>) => await fn());
mocks.getCloudPlanFromProduct.mockReturnValue("pro");
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.customersList.mockResolvedValue({ data: [] });
Expand Down Expand Up @@ -1761,7 +1764,7 @@ describe("organization-billing", () => {
},
usageCycleAnchor: new Date().toISOString(),
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
mocks.cacheWithCacheNullable.mockResolvedValue(cachedBilling);

const result = await getOrganizationBillingWithReadThroughSync("org_1");

Expand All @@ -1774,7 +1777,7 @@ describe("organization-billing", () => {
stripeCustomerId: "cus_1",
stripe: { lastSyncedAt: new Date().toISOString() },
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
mocks.cacheWithCacheNullable.mockResolvedValue(cachedBilling);

const result = await getOrganizationBillingWithReadThroughSync("org_1");

Expand All @@ -1787,7 +1790,7 @@ describe("organization-billing", () => {
stripeCustomerId: "cus_1",
stripe: { lastSyncedAt: new Date(Date.now() - 6 * 60 * 1000).toISOString() },
};
mocks.cacheWithCache.mockResolvedValue(cachedBilling);
mocks.cacheWithCacheNullable.mockResolvedValue(cachedBilling);
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
Expand Down Expand Up @@ -1826,7 +1829,7 @@ describe("organization-billing", () => {

const result = await getOrganizationBillingWithReadThroughSync("org_1");

expect(mocks.cacheWithCache).not.toHaveBeenCalled();
expect(mocks.cacheWithCacheNullable).not.toHaveBeenCalled();
expect(result).toEqual({
stripeCustomerId: null,
limits: {
Expand All @@ -1841,7 +1844,7 @@ describe("organization-billing", () => {

test("getOrganizationBillingWithReadThroughSync returns null when organization billing is missing", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue(null);
mocks.cacheWithCache.mockImplementation(async (fn: () => Promise<unknown>) => await fn());
mocks.cacheWithCacheNullable.mockImplementation(async (fn: () => Promise<unknown>) => await fn());

await expect(getOrganizationBillingWithReadThroughSync("org_1")).resolves.toBeNull();
});
Expand Down
2 changes: 1 addition & 1 deletion apps/web/modules/ee/billing/lib/organization-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,7 +1218,7 @@ export const getOrganizationBillingWithReadThroughSync = async (
return await getOrganizationBillingFromDatabase(organizationId);
}

const cachedBilling = await cache.withCache(
const cachedBilling = await cache.withCacheNullable(
async () => await getOrganizationBillingFromDatabase(organizationId),
getBillingCacheKey(organizationId),
BILLING_SYNC_STALE_MS
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
Expand All @@ -17,6 +16,7 @@ import {
RESERVED_FUTURE_DEFAULT_ATTRIBUTE_SAFE_IDENTIFIER_KEYS_TEXT,
isReservedFutureDefaultAttributeKey,
} from "@/modules/ee/contacts/lib/attribute-key-policy";
import { parseContactsCSV } from "@/modules/ee/contacts/lib/parse-contacts-csv";
import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
Expand Down Expand Up @@ -80,10 +80,7 @@ export const UploadContactsCSVButton = ({
const csv = e.target?.result as string;

try {
const records = parse(csv, {
columns: true, // Parse the header as column names
skip_empty_lines: true, // Skip empty lines
});
const records = parseContactsCSV(csv);

const parsedRecords = ZContactCSVUploadResponse.safeParse(records);
if (!parsedRecords.success) {
Expand Down
50 changes: 50 additions & 0 deletions apps/web/modules/ee/contacts/lib/parse-contacts-csv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest";
import { parseContactsCSV } from "./parse-contacts-csv";

describe("parseContactsCSV", () => {
const expected = [
{ email: "user1@example.com", user_id: "1001", first_name: "John" },
{ email: "user2@example.com", user_id: "1002", first_name: "Jane" },
];

test("parses comma-delimited CSV", () => {
const csv = `email,user_id,first_name
user1@example.com,1001,John
user2@example.com,1002,Jane`;
expect(parseContactsCSV(csv)).toEqual(expected);
});

test("parses semicolon-delimited CSV (EU Excel default)", () => {
const csv = `email;user_id;first_name
user1@example.com;1001;John
user2@example.com;1002;Jane`;
expect(parseContactsCSV(csv)).toEqual(expected);
});

test("parses tab-delimited CSV", () => {
const csv = `email\tuser_id\tfirst_name
user1@example.com\t1001\tJohn
user2@example.com\t1002\tJane`;
expect(parseContactsCSV(csv)).toEqual(expected);
});

test("skips empty lines", () => {
const csv = `email,user_id,first_name

user1@example.com,1001,John

user2@example.com,1002,Jane
`;
expect(parseContactsCSV(csv)).toEqual(expected);
});

test("returns empty array for header-only CSV", () => {
expect(parseContactsCSV("email,user_id,first_name")).toEqual([]);
});

test("throws on malformed CSV with inconsistent column count", () => {
const csv = `email,user_id,first_name
user1@example.com,1001`;
expect(() => parseContactsCSV(csv)).toThrow();
});
});
10 changes: 10 additions & 0 deletions apps/web/modules/ee/contacts/lib/parse-contacts-csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { parse } from "csv-parse/sync";

export const parseContactsCSV = (csv: string): unknown[] => {
return parse(csv, {
bom: true,
columns: true,
skip_empty_lines: true,
delimiter: [",", ";", "\t"],
});
};
Loading
Loading