diff --git a/.env.example b/.env.example index b82c907d70..d6cf1908bc 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,10 @@ CALCOM_TELEMETRY_DISABLED= # ApiKey for cronjobs CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0' +# Whether to automatically keep app metadata in the database in sync with the metadata/config files. When disabled, the +# sync runs in a reporting-only dry-run mode. +CRON_ENABLE_APP_SYNC=false + # Application Key for symmetric encryption and decryption # must be 32 bytes for AES256 encryption algorithm # You can use: `openssl rand -base64 24` to generate one diff --git a/.github/workflows/cron-syncAppMeta.yml b/.github/workflows/cron-syncAppMeta.yml new file mode 100644 index 0000000000..baea2304e3 --- /dev/null +++ b/.github/workflows/cron-syncAppMeta.yml @@ -0,0 +1,24 @@ +name: Cron - syncAppMeta + +on: + workflow_dispatch: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # Runs “Every month at 1st (see https://crontab.guru) + - cron: "0 0 1 * *" +jobs: + cron-syncAppMeta: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_API_KEY: ${{ secrets.CRON_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.CRON_API_KEY }} + run: | + curl ${{ secrets.APP_URL }}/api/cron/syncAppMeta \ + -X POST \ + -H 'content-type: application/json' \ + -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ + --fail diff --git a/.github/workflows/env-create-file.yml b/.github/workflows/env-create-file.yml index 8089d9949f..6cf7f6dcdb 100644 --- a/.github/workflows/env-create-file.yml +++ b/.github/workflows/env-create-file.yml @@ -11,6 +11,7 @@ env: INPUT_ENV_GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} INPUT_ENV_GOOGLE_LOGIN_ENABLED: true # INPUT_ENV_CRON_API_KEY: xxx + # INPUT_ENV_CRON_ENABLE_APP_SYNC: true|false INPUT_ENV_CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} INPUT_ENV_NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} INPUT_ENV_STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} diff --git a/app.json b/app.json index 5180797014..4078d8be61 100644 --- a/app.json +++ b/app.json @@ -37,6 +37,10 @@ "description": "ApiKey for cronjobs", "value": "" }, + "CRON_ENABLE_APP_SYNC": { + "description": "Whether to automatically keep app metadata in the database in sync with the metadata/config files. When disabled, the sync runs in a reporting-only dry-run mode.", + "value": "false" + }, "SEND_FEEDBACK_EMAIL": { "description": "Send feedback email", "value": "" diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index d06adcb2dc..17382ba553 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -117,8 +117,16 @@ export const EventSetupTab = ( const [selectedLocation, setSelectedLocation] = useState(undefined); const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration); - const locationOptions = props.locationOptions.filter((option) => { - return !team ? option.label !== "Conferencing" : true; + const locationOptions = props.locationOptions.map((locationOption) => { + const options = locationOption.options.filter((option) => { + // Skip "Organizer's Default App" for non-team members + return !team ? option.label !== t("organizer_default_conferencing_app") : true; + }); + + return { + ...locationOption, + options, + }; }); const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({ diff --git a/apps/web/next.config.js b/apps/web/next.config.js index d9b5f80a90..73c0d38219 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -493,6 +493,16 @@ const nextConfig = { destination: "/event-types?openIntercom=true", permanent: true, }, + { + source: "/apps/categories/video", + destination: "/apps/categories/conferencing", + permanent: true, + }, + { + source: "/apps/installed/video", + destination: "/apps/installed/conferencing", + permanent: true, + }, ]; if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") { diff --git a/apps/web/pages/api/cron/syncAppMeta.ts b/apps/web/pages/api/cron/syncAppMeta.ts new file mode 100644 index 0000000000..fbb9383bc3 --- /dev/null +++ b/apps/web/pages/api/cron/syncAppMeta.ts @@ -0,0 +1,67 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; +import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import type { AppCategories, Prisma } from "@calcom/prisma/client"; + +const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true"; +const log = logger.getChildLogger({ + prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])], +}); + +/** + * syncAppMeta makes sure any app metadata that has been replicated into the database + * remains synchronized with any changes made to the app config files. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.headers.authorization || req.query.apiKey; + if (process.env.CRON_API_KEY !== apiKey) { + res.status(401).json({ message: "Not authenticated" }); + return; + } + if (req.method !== "POST") { + res.status(405).json({ message: "Invalid method" }); + return; + } + + log.info(`🧐 Checking DB apps are in-sync with app metadata`); + + const dbApps = await prisma.app.findMany(); + + for await (const dbApp of dbApps) { + const app = await getAppWithMetadata(dbApp); + const updates: Prisma.AppUpdateManyMutationInput = {}; + + if (!app) { + log.warn(`💀 App ${dbApp.slug} (${dbApp.dirName}) no longer exists.`); + continue; + } + + // Check for any changes in the app categories (tolerates changes in ordering) + if ( + dbApp.categories.length !== app.categories.length || + !dbApp.categories.every((category) => app.categories.includes(category)) + ) { + updates["categories"] = app.categories as AppCategories[]; + } + + if (dbApp.dirName !== (app.dirName ?? app.slug)) { + updates["dirName"] = app.dirName ?? app.slug; + } + + if (Object.keys(updates).length > 0) { + log.info(`🔨 Updating app ${dbApp.slug} with ${Object.keys(updates).join(", ")}`); + if (!isDryRun) { + await prisma.app.update({ + where: { slug: dbApp.slug }, + data: updates, + }); + } + } else { + log.info(`✅ App ${dbApp.slug} is up-to-date and correct`); + } + } + + res.json({ ok: true }); +} diff --git a/apps/web/pages/apps/installed/[category].tsx b/apps/web/pages/apps/installed/[category].tsx index f0261754db..03caec4c47 100644 --- a/apps/web/pages/apps/installed/[category].tsx +++ b/apps/web/pages/apps/installed/[category].tsx @@ -6,11 +6,11 @@ import { AppSettings } from "@calcom/app-store/_components/AppSettings"; import { InstallAppButton } from "@calcom/app-store/components"; import type { EventLocationType } from "@calcom/app-store/locations"; import { getEventLocationTypeFromApp } from "@calcom/app-store/locations"; -import { InstalledAppVariants } from "@calcom/app-store/utils"; import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog"; import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { AppCategories } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { App } from "@calcom/types/App"; @@ -29,11 +29,14 @@ import { DropdownItem, showToast, } from "@calcom/ui"; +import type { LucideIcon } from "@calcom/ui/components/icon"; import { BarChart, Calendar, + Contact, CreditCard, Grid, + Mail, MoreHorizontal, Plus, Share2, @@ -101,8 +104,8 @@ function ConnectOrDisconnectIntegrationMenuItem(props: { } interface IntegrationsContainerProps { - variant?: (typeof InstalledAppVariants)[number]; - exclude?: (typeof InstalledAppVariants)[number][]; + variant?: AppCategories; + exclude?: AppCategories[]; handleDisconnect: (credentialId: number) => void; } @@ -225,14 +228,19 @@ const IntegrationsContainer = ({ }: IntegrationsContainerProps): JSX.Element => { const { t } = useLocale(); const query = trpc.viewer.integrations.useQuery({ variant, exclude, onlyInstalled: true }); - const emptyIcon = { + + // TODO: Refactor and reuse getAppCategories? + const emptyIcon: Record = { calendar: Calendar, conferencing: Video, automation: Share2, analytics: BarChart, payment: CreditCard, - web3: BarChart, + web3: BarChart, // deprecated other: Grid, + video: Video, // deprecated + messaging: Mail, + crm: Contact, }; return ( @@ -267,9 +275,7 @@ const IntegrationsContainer = ({ className="mb-6" actions={