diff --git a/functions/packages/feed-form/src/__tests__/feed-form.spec.ts b/functions/packages/feed-form/src/__tests__/feed-form.spec.ts index 953c2bef9..0317d5a2b 100644 --- a/functions/packages/feed-form/src/__tests__/feed-form.spec.ts +++ b/functions/packages/feed-form/src/__tests__/feed-form.spec.ts @@ -2,57 +2,96 @@ import { buildFeedRow, buildFeedRows, SheetCol, + writeToSheet, } from "../impl/feed-form-impl"; -import {type FeedSubmissionFormRequestBody} from "../impl/types"; +import * as logger from "firebase-functions/logger"; +import {sampleRequestBodyGTFS, sampleRequestBodyGTFSRT} from "../impl/__mocks__/feed-submission-form-request-body.mock"; +import {HttpsError} from "firebase-functions/v2/https"; -const sampleRequestBodyGTFS: FeedSubmissionFormRequestBody = { - name: "Sample Feed", - isOfficialProducer: "yes", - isOfficialFeed: "yes", - dataType: "gtfs", - transitProviderName: "Sample Transit Provider", - feedLink: "https://example.com/feed", - isUpdatingFeed: "yes", - oldFeedLink: "https://example.com/old-feed", - licensePath: "/path/to/license", - country: "USA", - region: "California", - municipality: "San Francisco", - tripUpdates: "", - vehiclePositions: "", - serviceAlerts: "", - gtfsRelatedScheduleLink: "https://example.com/gtfs-schedule", - authType: "None - 0", - authSignupLink: "https://example.com/signup", - authParameterName: "auth_token", - dataProducerEmail: "producer@example.com", - isInterestedInQualityAudit: "yes", - userInterviewEmail: "interviewee@example.com", - whatToolsUsedText: "Google Sheets, Node.js", - hasLogoPermission: "yes", - unofficialDesc: "For research purposes", - updateFreq: "every month", - emptyLicenseUsage: "unsure", -}; +jest.mock("google-spreadsheet", () => ({ + GoogleSpreadsheet: jest.fn().mockImplementation(() => ({ + loadInfo: jest.fn(), + sheetsByIndex: [ + { + addRows: jest.fn(), + }, + ], + })), +})); +jest.mock("google-auth-library", () => ({ + GoogleAuth: jest.fn(), +})); + +const mockCreateGithubIssue = jest.fn().mockResolvedValue("https://github.com/issue/1"); +const mockSendSlackWebhook = jest.fn().mockResolvedValue(undefined); + +jest.mock("../impl/utils/github-issue", () => ({ + createGithubIssue: (...args: any[]) => mockCreateGithubIssue(...args), +})); +jest.mock("../impl/utils/slack", () => ({ + sendSlackWebhook: (...args: any[]) => mockSendSlackWebhook(...args), +})); + +jest.spyOn(logger, "error").mockImplementation(() => {}); -const sampleRequestBodyGTFSRT: FeedSubmissionFormRequestBody = { - ...sampleRequestBodyGTFS, - dataType: "gtfs_rt", - feedLink: "", - tripUpdates: "https://example.com/gtfs-realtime-trip-update", - vehiclePositions: "https://example.com/gtfs-realtime-vehicle-position", - serviceAlerts: "https://example.com/gtfs-realtime-service-alerts", - oldTripUpdates: "https://example.com/old-feed-tu", - oldServiceAlerts: "https://example.com/old-feed-sa", - oldVehiclePositions: "https://example.com/old-feed-vp", -}; +const defaultEnv = process.env; describe("Feed Form Implementation", () => { + beforeAll(() => { const mockDate = new Date("2023-08-01T00:00:00Z"); jest.spyOn(global, "Date").mockImplementation(() => mockDate); }); + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...defaultEnv }; + process.env.FEED_SUBMIT_GOOGLE_SHEET_ID = "sheet123"; + process.env.GCLOUD_PROJECT = "mobility-feeds-prod"; + process.env.GITHUB_TOKEN = "token"; + process.env.SLACK_WEBHOOK_URL = "https://slack"; + }); + + afterAll(() => { + process.env = defaultEnv; + }); + + it("should throw HttpsError if sheet ID is not defined", async () => { + process.env.FEED_SUBMIT_GOOGLE_SHEET_ID = ""; + const mockRequest = { + auth: { uid: "user1" }, + data: sampleRequestBodyGTFS, + }; + await expect(writeToSheet(mockRequest as any)).rejects.toThrow(HttpsError); + expect(logger.error).toHaveBeenCalledWith( + "Error writing to sheet:", + expect.any(HttpsError) + ); + }); + + it("writeToSheet writes to sheet, creates github issue, sends slack, returns success", async () => { + const mockRequest = { + auth: { uid: "user1" }, + data: sampleRequestBodyGTFS, + }; + const result = await writeToSheet(mockRequest as any); + const { GoogleSpreadsheet } = require("google-spreadsheet"); + expect(GoogleSpreadsheet).toHaveBeenCalledWith("sheet123", expect.anything()); + const doc = GoogleSpreadsheet.mock.results[0].value; + expect(doc.loadInfo).toHaveBeenCalled(); + expect(doc.sheetsByIndex[0].addRows).toHaveBeenCalledWith( + expect.any(Array), + { insert: true } + ); + expect(mockCreateGithubIssue).toHaveBeenCalled(); + expect(mockSendSlackWebhook).toHaveBeenCalledWith( + "sheet123", + "https://github.com/issue/1", + true + ); + expect(result).toEqual({ message: "Data written to the new sheet successfully!" }); + }); + it("should build the rows if gtfs schedule", () => { buildFeedRows(sampleRequestBodyGTFS, "user123"); const expectedRows = [ diff --git a/functions/packages/feed-form/src/__tests__/github-issue-builder.spec.ts b/functions/packages/feed-form/src/__tests__/utils/github-issue.spec.ts similarity index 68% rename from functions/packages/feed-form/src/__tests__/github-issue-builder.spec.ts rename to functions/packages/feed-form/src/__tests__/utils/github-issue.spec.ts index b9f541637..aa9dc04e3 100644 --- a/functions/packages/feed-form/src/__tests__/github-issue-builder.spec.ts +++ b/functions/packages/feed-form/src/__tests__/utils/github-issue.spec.ts @@ -1,5 +1,58 @@ -import {buildGithubIssueBody} from "../impl/feed-form-impl"; -import {FeedSubmissionFormRequestBody} from "../impl/types"; +import { createGithubIssue, buildGithubIssueBody } from "../../impl/utils/github-issue"; +import axios from "axios"; +import * as logger from "firebase-functions/logger"; +import { isValidZipUrl, isValidZipDownload } from "../../impl/utils/url-parse"; +import {FeedSubmissionFormRequestBody} from "../../impl/types"; +import {sampleRequestBodyGTFS} from "../../impl/__mocks__/feed-submission-form-request-body.mock"; + +jest.mock("axios"); +jest.mock("firebase-functions/logger"); +jest.mock("../../impl/utils/url-parse"); + +const mockedAxios = axios as jest.Mocked; +const mockedIsValidZipUrl = isValidZipUrl as jest.Mock; +const mockedIsValidZipDownload = isValidZipDownload as jest.Mock; + +describe("createGithubIssue", () => { + const formData = { + sampleRequestBodyGTFS + } as any; + const spreadsheetId = "sheet123"; + const githubToken = "token123"; + + beforeEach(() => { + jest.clearAllMocks(); + mockedIsValidZipUrl.mockReturnValue(true); + mockedIsValidZipDownload.mockResolvedValue(true); + }); + + it("creates an issue and returns the URL", async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { html_url: "https://github.com/issue/1" } }); + const url = await createGithubIssue(formData, spreadsheetId, githubToken); + expect(url).toBe("https://github.com/issue/1"); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining("github.com/repos"), + expect.objectContaining({ title: expect.any(String) }), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: expect.any(String) }) }) + ); + }); + + it("returns empty string and logs error on failure", async () => { + mockedAxios.post.mockRejectedValueOnce(new Error("fail")); + const url = await createGithubIssue(formData, spreadsheetId, githubToken); + expect(url).toBe(""); + expect(logger.error).toHaveBeenCalled(); + }); + + it("adds 'invalid' label if zip is invalid", async () => { + mockedIsValidZipUrl.mockReturnValue(false); + mockedIsValidZipDownload.mockResolvedValue(false); + mockedAxios.post.mockResolvedValueOnce({ data: { html_url: "https://github.com/issue/2" } }); + await createGithubIssue(formData, spreadsheetId, githubToken); + const call = mockedAxios.post.mock.calls[0][1] as any; + expect(call.labels).toContain("invalid"); + }); +}); describe("buildGithubIssueBody", () => { const spreadsheetId = "testSpreadsheetId"; @@ -203,3 +256,4 @@ describe("buildGithubIssueBody", () => { expect(buildGithubIssueBody(formData, spreadsheetId)).toBe(expectedContent); }); }); + diff --git a/functions/packages/feed-form/src/__tests__/utils/slack.spec.ts b/functions/packages/feed-form/src/__tests__/utils/slack.spec.ts new file mode 100644 index 000000000..17e890096 --- /dev/null +++ b/functions/packages/feed-form/src/__tests__/utils/slack.spec.ts @@ -0,0 +1,42 @@ +import { sendSlackWebhook } from "../../impl/utils/slack"; +import axios from "axios"; +import * as logger from "firebase-functions/logger"; + +jest.mock("axios"); +jest.mock("firebase-functions/logger"); + +describe("sendSlackWebhook", () => { + const spreadsheetId = "sheet123"; + const githubIssueUrl = "https://github.com/issue/1"; + const oldEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...oldEnv, SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/abc" }; + }); + + afterAll(() => { + process.env = oldEnv; + }); + + it("sends a Slack message with correct payload", async () => { + (axios.post as jest.Mock).mockResolvedValueOnce({}); + await sendSlackWebhook(spreadsheetId, githubIssueUrl, true); + expect(axios.post).toHaveBeenCalledWith( + process.env.SLACK_WEBHOOK_URL, + expect.objectContaining({ blocks: expect.any(Array) }) + ); + }); + + it("logs error if webhook URL is not set", async () => { + process.env.SLACK_WEBHOOK_URL = ""; + await sendSlackWebhook(spreadsheetId, githubIssueUrl, false); + expect(logger.error).toHaveBeenCalledWith("Slack webhook URL is not defined"); + }); + + it("logs error if axios fails", async () => { + (axios.post as jest.Mock).mockRejectedValueOnce(new Error("fail")); + await sendSlackWebhook(spreadsheetId, githubIssueUrl, false); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/functions/packages/feed-form/src/__tests__/utils/url-parse.spec.ts b/functions/packages/feed-form/src/__tests__/utils/url-parse.spec.ts new file mode 100644 index 000000000..f308b797b --- /dev/null +++ b/functions/packages/feed-form/src/__tests__/utils/url-parse.spec.ts @@ -0,0 +1,60 @@ +import { isValidZipUrl, isValidZipDownload } from "../../impl/utils/url-parse"; +import axios from "axios"; + +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; + +describe("isValidZipUrl", () => { + it("returns true for valid .zip URL", () => { + expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).toBe(true); + expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/zip_5MB.zip")).toBe(true); + }); + + it("returns false for non-.zip URL", () => { + expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/file_example_CSV_5000.csv")).toBe(false); + expect(isValidZipUrl("https://file-examples.com/wp-content/storage/2017/02/file_example_JSON_1kb.json")).toBe(false); + }); + + it("returns false for invalid or empty input", () => { + expect(isValidZipUrl("")).toBe(false); + expect(isValidZipUrl(undefined)).toBe(false); + expect(isValidZipUrl(null)).toBe(false); + expect(isValidZipUrl("not a url")).toBe(false); + }); +}); + +describe("isValidZipDownload", () => { + afterEach(() => jest.resetAllMocks()); + + it("returns true if content-type includes zip", async () => { + mockedAxios.head.mockResolvedValueOnce({ + headers: { "content-type": "application/zip" } + } as any); + await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(true); + }); + + it("returns true if content-disposition includes zip", async () => { + mockedAxios.head.mockResolvedValueOnce({ + headers: { "content-disposition": "attachment; filename=foo.zip" } + } as any); + await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(true); + }); + + it("returns false if neither header includes zip", async () => { + mockedAxios.head.mockResolvedValueOnce({ + headers: { "content-type": "text/plain" } + } as any); + await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(false); + }); + + it("returns false for invalid/empty url", async () => { + await expect(isValidZipDownload("")).resolves.toBe(false); + await expect(isValidZipDownload(undefined)).resolves.toBe(false); + await expect(isValidZipDownload(null)).resolves.toBe(false); + }); + + it("returns false if axios throws", async () => { + mockedAxios.head.mockRejectedValueOnce(new Error("Network error")); + await expect(isValidZipDownload("https://file-examples.com/wp-content/storage/2017/02/zip_2MB.zip")).resolves.toBe(false); + }); +}); diff --git a/functions/packages/feed-form/src/impl/__mocks__/feed-submission-form-request-body.mock.ts b/functions/packages/feed-form/src/impl/__mocks__/feed-submission-form-request-body.mock.ts new file mode 100644 index 000000000..20f4ffd1a --- /dev/null +++ b/functions/packages/feed-form/src/impl/__mocks__/feed-submission-form-request-body.mock.ts @@ -0,0 +1,43 @@ +import { FeedSubmissionFormRequestBody } from "../types"; + +export const sampleRequestBodyGTFS: FeedSubmissionFormRequestBody = { + name: "Sample Feed", + isOfficialProducer: "yes", + isOfficialFeed: "yes", + dataType: "gtfs", + transitProviderName: "Sample Transit Provider", + feedLink: "https://example.com/feed", + isUpdatingFeed: "yes", + oldFeedLink: "https://example.com/old-feed", + licensePath: "/path/to/license", + country: "USA", + region: "California", + municipality: "San Francisco", + tripUpdates: "", + vehiclePositions: "", + serviceAlerts: "", + gtfsRelatedScheduleLink: "https://example.com/gtfs-schedule", + authType: "None - 0", + authSignupLink: "https://example.com/signup", + authParameterName: "auth_token", + dataProducerEmail: "producer@example.com", + isInterestedInQualityAudit: "yes", + userInterviewEmail: "interviewee@example.com", + whatToolsUsedText: "Google Sheets, Node.js", + hasLogoPermission: "yes", + unofficialDesc: "For research purposes", + updateFreq: "every month", + emptyLicenseUsage: "unsure", +}; + +export const sampleRequestBodyGTFSRT: FeedSubmissionFormRequestBody = { + ...sampleRequestBodyGTFS, + dataType: "gtfs_rt", + feedLink: "", + tripUpdates: "https://example.com/gtfs-realtime-trip-update", + vehiclePositions: "https://example.com/gtfs-realtime-vehicle-position", + serviceAlerts: "https://example.com/gtfs-realtime-service-alerts", + oldTripUpdates: "https://example.com/old-feed-tu", + oldServiceAlerts: "https://example.com/old-feed-sa", + oldVehiclePositions: "https://example.com/old-feed-vp", +}; diff --git a/functions/packages/feed-form/src/impl/feed-form-impl.ts b/functions/packages/feed-form/src/impl/feed-form-impl.ts index f84b0f768..d57c035c4 100644 --- a/functions/packages/feed-form/src/impl/feed-form-impl.ts +++ b/functions/packages/feed-form/src/impl/feed-form-impl.ts @@ -3,8 +3,8 @@ import {GoogleAuth} from "google-auth-library"; import * as logger from "firebase-functions/logger"; import {type FeedSubmissionFormRequestBody} from "./types"; import {type CallableRequest, HttpsError} from "firebase-functions/v2/https"; -import axios from "axios"; -import {countries, continents, type TCountryCode} from "countries-list"; +import {createGithubIssue} from "./utils/github-issue"; +import {sendSlackWebhook} from "./utils/slack"; const SCOPES = [ "https://www.googleapis.com/auth/spreadsheets", @@ -204,240 +204,4 @@ export function buildFeedRow( [SheetCol.EmptyLicenseUsage]: formData.emptyLicenseUsage ?? "", }; } - -/** - * Sends a Slack webhook message to the configured Slack webhook URL - * @param {string} spreadsheetId The ID of the Google Sheet - * @param {string} githubIssueUrl The URL of the created GitHub issue - * @param {boolean} isOfficialSource Whether the feed is an official source - */ -async function sendSlackWebhook( - spreadsheetId: string, - githubIssueUrl: string, - isOfficialSource: boolean -) { - const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL; - const sheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`; - if (slackWebhookUrl !== undefined && slackWebhookUrl !== "") { - let headerText = "New Feed Added"; - if (isOfficialSource) { - headerText += " 🔹 Official Source"; - } - const linksElement = [ - { - type: "emoji", - name: "google_drive", - }, - { - type: "link", - url: sheetUrl, - text: " View Feed ", - style: { - bold: true, - }, - }, - ]; - if (githubIssueUrl !== "") { - linksElement.push( - { - type: "emoji", - name: "github-logo", - }, - { - type: "link", - url: githubIssueUrl, - text: " View Issue ", - style: { - bold: true, - }, - } - ); - } - const slackMessage = { - blocks: [ - { - type: "header", - text: { - type: "plain_text", - text: headerText, - emoji: true, - }, - }, - { - type: "rich_text", - elements: [ - { - type: "rich_text_section", - elements: [ - { - type: "emoji", - name: "inbox_tray", - }, - { - type: "text", - text: " A new entry was received in the OpenMobilityData source updates Google Sheet", - }, - ], - }, - ], - }, - { - type: "rich_text", - elements: [ - { - type: "rich_text_section", - elements: linksElement, - }, - ], - }, - ], - }; - await axios.post(slackWebhookUrl, slackMessage).catch((error) => { - logger.error("Error sending Slack webhook:", error); - }); - } else { - logger.error("Slack webhook URL is not defined"); - } -} /* eslint-enable max-len */ - -/** - * Creates a GitHub issue in the Mobility Database Catalogs repository - * @param {FeedSubmissionFormRequestBody} formData feed submission form - * @param {string} spreadsheetId googleshhet id - * @param {string} githubToken github token to create the issue - * @return {Promise} The URL of the created GitHub issue - */ -async function createGithubIssue( - formData: FeedSubmissionFormRequestBody, - spreadsheetId: string, - githubToken: string -): Promise { - const githubRepoUrlIssue = - "https://api.github.com/repos/MobilityData/mobility-database-catalogs/issues"; - let issueTitle = - "New Feed Added" + - (formData.transitProviderName ? `: ${formData.transitProviderName}` : ""); - if (formData.isOfficialFeed === "yes") { - issueTitle += " - Official Feed"; - } - const issueBody = buildGithubIssueBody(formData, spreadsheetId); - - const labels = ["feed submission"]; - if (formData.country && formData.country in countries) { - const country = countries[formData.country as TCountryCode]; - const continent = continents[country.continent].toLowerCase(); - if (continent != null) labels.push(continent); - } - - try { - const response = await axios.post( - githubRepoUrlIssue, - { - title: issueTitle, - body: issueBody, - labels, - }, - { - headers: { - Authorization: `token ${githubToken}`, - Accept: "application/vnd.github.v3+json", - }, - } - ); - return response.data.html_url; - } catch (error) { - logger.error("Error creating GitHub issue:", error); - return ""; - } -} - -// Markdown format is strange in strings, so we disable eslint for this function -/* eslint-disable */ -export function buildGithubIssueBody( - formData: FeedSubmissionFormRequestBody, - spreadsheetId: string -) { - let content = ""; - if (formData.transitProviderName) { - content += ` - # Agency name/Transit Provider: ${formData.name}`; - } - - if (formData.country || formData.region || formData.municipality) { - let locationName = formData.country ?? ""; - locationName += formData.region ? `, ${formData.region}` : ""; - locationName += formData.municipality ? `, ${formData.municipality}` : ""; - content += ` - - ### Location - ${locationName}`; - } - - content += ` - - ## Details`; - - content += ` - - #### Data type - ${formData.dataType} - - #### Issue type - ${formData.isUpdatingFeed === "yes" ? "Feed update" : "New feed"}`; - - if (formData.name) { - content += ` - - #### Name - ${formData.name}`; - } - - content += ` - - ## URLs - | Current URL on OpenMobilityData.org | Updated/new feed URL | - |---|---|`; - if (formData.dataType === "gtfs") { - content += ` - | ${formData.oldFeedLink} | ${formData.feedLink} |`; - } else { - if (formData.tripUpdates) { - content += ` - | ${formData.oldTripUpdates} | ${formData.tripUpdates} |`; - } - if (formData.vehiclePositions) { - content += ` - | ${formData.oldVehiclePositions} | ${formData.vehiclePositions} |`; - } - if (formData.serviceAlerts) { - content += ` - | ${formData.oldServiceAlerts} | ${formData.serviceAlerts} |`; - } - } - - content += ` - - ## Authentication - #### Authentication type - ${formData.authType}`; - if (formData.authSignupLink) { - content += ` - - #### Link to how to sign up for authentication credentials (API KEY) - ${formData.authSignupLink}`; - } - if (formData.authParameterName) { - content += ` - - #### HTTP header or API key parameter name - ${formData.authParameterName}`; - } - - content += ` - - ## View more details - https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`; - return content; -} -/* eslint-enable */ diff --git a/functions/packages/feed-form/src/impl/utils/github-issue.ts b/functions/packages/feed-form/src/impl/utils/github-issue.ts new file mode 100644 index 000000000..079392416 --- /dev/null +++ b/functions/packages/feed-form/src/impl/utils/github-issue.ts @@ -0,0 +1,156 @@ +import {countries, continents, type TCountryCode} from "countries-list"; +import {isValidZipUrl, isValidZipDownload} from "./url-parse"; +import {type FeedSubmissionFormRequestBody} from "../types"; +import axios from "axios"; +import * as logger from "firebase-functions/logger"; + +/** + * Creates a GitHub issue in the Mobility Database Catalogs repository + * @param {FeedSubmissionFormRequestBody} formData feed submission form + * @param {string} spreadsheetId googleshhet id + * @param {string} githubToken github token to create the issue + * @return {Promise} The URL of the created GitHub issue + */ +export async function createGithubIssue( + formData: FeedSubmissionFormRequestBody, + spreadsheetId: string, + githubToken: string +): Promise { + const githubRepoUrlIssue = + "https://api.github.com/repos/MobilityData/mobility-database-catalogs/issues"; + let issueTitle = + "New Feed Added" + + (formData.transitProviderName ? `: ${formData.transitProviderName}` : ""); + if (formData.isOfficialFeed === "yes") { + issueTitle += " - Official Feed"; + } + const issueBody = buildGithubIssueBody(formData, spreadsheetId); + + const labels = ["feed submission"]; + if (formData.country && formData.country in countries) { + const country = countries[formData.country as TCountryCode]; + const continent = continents[country.continent].toLowerCase(); + if (continent != null) labels.push(`region/${continent}`); + } + + if (formData.authType !== "None - 0") { + labels.push("auth required"); + } + + if (!isValidZipUrl(formData.feedLink)) { + if (!await isValidZipDownload(formData.feedLink)) { + labels.push("invalid"); + } + } + + try { + const response = await axios.post( + githubRepoUrlIssue, + { + title: issueTitle, + body: issueBody, + labels, + }, + { + headers: { + Authorization: `token ${githubToken}`, + Accept: "application/vnd.github.v3+json", + }, + } + ); + return response.data.html_url; + } catch (error) { + logger.error("Error creating GitHub issue:", error); + return ""; + } +} + +// Markdown format is strange in strings, so we disable eslint for this function +/* eslint-disable */ +export function buildGithubIssueBody( + formData: FeedSubmissionFormRequestBody, + spreadsheetId: string +) { + let content = ""; + if (formData.transitProviderName) { + content += ` + # Agency name/Transit Provider: ${formData.name}`; + } + + if (formData.country || formData.region || formData.municipality) { + let locationName = formData.country ?? ""; + locationName += formData.region ? `, ${formData.region}` : ""; + locationName += formData.municipality ? `, ${formData.municipality}` : ""; + content += ` + + ### Location + ${locationName}`; + } + + content += ` + + ## Details`; + + content += ` + + #### Data type + ${formData.dataType} + + #### Issue type + ${formData.isUpdatingFeed === "yes" ? "Feed update" : "New feed"}`; + + if (formData.name) { + content += ` + + #### Name + ${formData.name}`; + } + + content += ` + + ## URLs + | Current URL on OpenMobilityData.org | Updated/new feed URL | + |---|---|`; + if (formData.dataType === "gtfs") { + content += ` + | ${formData.oldFeedLink} | ${formData.feedLink} |`; + } else { + if (formData.tripUpdates) { + content += ` + | ${formData.oldTripUpdates} | ${formData.tripUpdates} |`; + } + if (formData.vehiclePositions) { + content += ` + | ${formData.oldVehiclePositions} | ${formData.vehiclePositions} |`; + } + if (formData.serviceAlerts) { + content += ` + | ${formData.oldServiceAlerts} | ${formData.serviceAlerts} |`; + } + } + + content += ` + + ## Authentication + #### Authentication type + ${formData.authType}`; + if (formData.authSignupLink) { + content += ` + + #### Link to how to sign up for authentication credentials (API KEY) + ${formData.authSignupLink}`; + } + if (formData.authParameterName) { + content += ` + + #### HTTP header or API key parameter name + ${formData.authParameterName}`; + } + + content += ` + + ## View more details + https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`; + return content; +} +/* eslint-enable */ \ No newline at end of file diff --git a/functions/packages/feed-form/src/impl/utils/slack.ts b/functions/packages/feed-form/src/impl/utils/slack.ts new file mode 100644 index 000000000..b191d672d --- /dev/null +++ b/functions/packages/feed-form/src/impl/utils/slack.ts @@ -0,0 +1,97 @@ +import axios from "axios"; +import * as logger from "firebase-functions/logger"; + +/** + * Sends a Slack webhook message to the configured Slack webhook URL + * @param {string} spreadsheetId The ID of the Google Sheet + * @param {string} githubIssueUrl The URL of the created GitHub issue + * @param {boolean} isOfficialSource Whether the feed is an official source + */ +export async function sendSlackWebhook( + spreadsheetId: string, + githubIssueUrl: string, + isOfficialSource: boolean +) { + const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL; + const sheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`; + if (slackWebhookUrl !== undefined && slackWebhookUrl !== "") { + let headerText = "New Feed Added"; + if (isOfficialSource) { + headerText += " 🔹 Official Source"; + } + const linksElement = [ + { + type: "emoji", + name: "google_drive", + }, + { + type: "link", + url: sheetUrl, + text: " View Feed ", + style: { + bold: true, + }, + }, + ]; + if (githubIssueUrl !== "") { + linksElement.push( + { + type: "emoji", + name: "github-logo", + }, + { + type: "link", + url: githubIssueUrl, + text: " View Issue ", + style: { + bold: true, + }, + } + ); + } + const slackMessage = { + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: headerText, + emoji: true, + }, + }, + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { + type: "emoji", + name: "inbox_tray", + }, + { + type: "text", + text: " A new entry was received in the OpenMobilityData source updates Google Sheet", + }, + ], + }, + ], + }, + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: linksElement, + }, + ], + }, + ], + }; + await axios.post(slackWebhookUrl, slackMessage).catch((error) => { + logger.error("Error sending Slack webhook:", error); + }); + } else { + logger.error("Slack webhook URL is not defined"); + } +} \ No newline at end of file diff --git a/functions/packages/feed-form/src/impl/utils/url-parse.ts b/functions/packages/feed-form/src/impl/utils/url-parse.ts new file mode 100644 index 000000000..de91adb84 --- /dev/null +++ b/functions/packages/feed-form/src/impl/utils/url-parse.ts @@ -0,0 +1,38 @@ +import axios from "axios"; + +/** + * Parses the provided URL to check if it is a valid ZIP file URL + * @param {string | undefined | null } url The direct download URL + * @return {boolean} Whether the URL is a valid ZIP file URL + */ +export function isValidZipUrl(url: string | undefined | null): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.pathname.toLowerCase().endsWith(".zip"); + } catch { + return false; + } +} + +/** + * Checks if URL points to a valid ZIP file by making HEAD request + * @param {string | undefined | null } url The download URL + * @return {boolean} Whether the URL downloads a valid ZIP file + */ +export async function isValidZipDownload( + url: string | undefined | null +): Promise { + try { + if (!url) return false; + const response = await axios.head(url, {maxRedirects: 2}); + const contentType = response.headers["content-type"]; + const contentDisposition = response.headers["content-disposition"]; + + if (contentType && contentType.includes("zip")) return true; + if (contentDisposition && contentDisposition.includes("zip")) return true; + return false; + } catch { + return false; + } +} \ No newline at end of file