Repository: dailydotdev/daily-api
Pull request #2704: feat: add analytics logging and slack notifications
Author: login: omBratteng
Created: 2025-03-17T13:46:32Z | Merged: 2025-03-18T10:18:22Z

- Adds a check to prevent unauthorised users getting subscription via Sandbox endpoint (TestFlight app)
- Log analytics events
- Send new subscription events to Slack


Diff:
diff --git a/src/common/utils.ts b/src/common/utils.ts
index a242940009..254876ca09 100644
--- a/src/common/utils.ts
+++ b/src/common/utils.ts
@@ -259,3 +259,6 @@ export const isSpecialUser = ({
 }): boolean => {
   return !!userId && [ghostUser.id, systemUser.id].includes(userId);
 };
+
+export const concatText = (a?: string, b?: string) =>
+  [a, b].filter(Boolean).join(`\n`);
diff --git a/src/remoteConfig.ts b/src/remoteConfig.ts
index aba4286ed2..1d5c1a0f9e 100644
--- a/src/remoteConfig.ts
+++ b/src/remoteConfig.ts
@@ -20,6 +20,7 @@ type RemoteConfigValue = {
     transfer: number;
   }>;
   enableBalance: boolean;
+  approvedStoreKitSandboxUsers: string[];
 };
 
 class RemoteConfig {
diff --git a/src/routes/webhooks/apple.ts b/src/routes/webhooks/apple.ts
index 4a2de32796..d5a06660b6 100644
--- a/src/routes/webhooks/apple.ts
+++ b/src/routes/webhooks/apple.ts
@@ -11,7 +11,7 @@ import {
   type JWSRenewalInfoDecodedPayload,
   type ResponseBodyV2DecodedPayload,
 } from '@apple/app-store-server-library';
-import { isTest } from '../../common';
+import { concatText, isTest, webhooks } from '../../common';
 import { isInSubnet } from 'is-in-subnet';
 import { isNullOrUndefined } from '../../common/object';
 import createOrGetConnection from '../../db';
@@ -24,6 +24,12 @@ import {
 import { JsonContains } from 'typeorm';
 import { SubscriptionCycles } from '../../paddle';
 import { updateStoreKitUserSubscription } from '../../plusSubscription';
+import {
+  AnalyticsEventName,
+  sendAnalyticsEvent,
+} from '../../integrations/analytics';
+import type { Block, KnownBlock } from '@slack/web-api';
+import { remoteConfig } from '../../remoteConfig';
 
 const certificatesToLoad = isTest
   ? ['__tests__/fixture/testCA.der']
@@ -139,15 +145,71 @@ const getSubscriptionStatus = (
   }
 };
 
+const getSubscriptionAnalyticsEvent = (
+  notificationType: ResponseBodyV2DecodedPayload['notificationType'],
+  subtype?: ResponseBodyV2DecodedPayload['subtype'],
+): AnalyticsEventName | null => {
+  switch (notificationType) {
+    case NotificationTypeV2.SUBSCRIBED:
+    case NotificationTypeV2.DID_RENEW:
+      return AnalyticsEventName.ReceivePayment;
+    case NotificationTypeV2.DID_CHANGE_RENEWAL_PREF:
+      return AnalyticsEventName.ChangeBillingCycle;
+    case NotificationTypeV2.DID_CHANGE_RENEWAL_STATUS: // Disable/Enable Auto-Renew
+      return subtype === Subtype.AUTO_RENEW_ENABLED
+        ? null
+        : AnalyticsEventName.CancelSubscription;
+    case NotificationTypeV2.DID_FAIL_TO_RENEW:
+      // When user fails to renew and there is no grace period
+      if (isNullOrUndefined(subtype)) {
+        return AnalyticsEventName.CancelSubscription;
+      }
+    default:
+      return null;
+  }
+};
+
+export const logAppleAnalyticsEvent = async (
+  data: JWSRenewalInfoDecodedPayload,
+  eventName: AnalyticsEventName,
+  user: User,
+) => {
+  if (!data || isTest) {
+    return;
+  }
+
+  const cycle =
+    productIdToCycle[data?.autoRenewProductId as keyof typeof productIdToCycle];
+  const cost = data?.renewalPrice;
+
+  const extra = {
+    cycle,
+    localCost: cost ? cost / 100 : undefined,
+    localCurrency: data?.currency,
+  };
+
+  await sendAnalyticsEvent([
+    {
+      event_name: eventName,
+      event_timestamp: new Date(data?.signedDate || ''),
+      event_id: data.appTransactionId,
+      app_platform: 'api',
+      user_id: user.id,
+      extra: JSON.stringify(extra),
+    },
+  ]);
+};
+
 const handleNotifcationRequest = async (
   verifier: SignedDataVerifier,
   request: FastifyRequest<{ Body: AppleNotificationRequest }>,
   response: FastifyReply,
+  environment: Environment,
 ) => {
   const { signedPayload } = request.body || {};
 
   if (isNullOrUndefined(signedPayload)) {
-    logger.info(
+    logger.error(
       { body: request.body, provider: SubscriptionProvider.AppleStoreKit },
       "Missing 'signedPayload' in request body",
     );
@@ -158,6 +220,17 @@ const handleNotifcationRequest = async (
     const notification =
       await verifier.verifyAndDecodeNotification(signedPayload);
 
+    // Don't proceed any further if it's a test notification
+    if (notification.notificationType === NotificationTypeV2.TEST) {
+      logger.info(
+        { notification, provider: SubscriptionProvider.AppleStoreKit },
+        'Received Test Notification',
+      );
+      return response.status(200).send({ received: true });
+    }
+
+    // Check if the event is a subscription event
+    // NOTE: When adding support for purchasing cores, we must remove this check as it's not a subscription event
     if (isNullOrUndefined(notification?.data?.signedRenewalInfo)) {
       logger.info(
         { notification, provider: SubscriptionProvider.AppleStoreKit },
@@ -188,6 +261,19 @@ const handleNotifcationRequest = async (
       return response.status(404).send({ error: 'Invalid Payload' });
     }
 
+    // Only allow sandbox requests from approved users
+    if (
+      environment === Environment.SANDBOX &&
+      !remoteConfig.vars.approvedStoreKitSandboxUsers?.includes(user.id)
+    ) {
+      logger.error(
+        { user, provider: SubscriptionProvider.AppleStoreKit },
+        'User not approved for sandbox',
+      );
+      return response.status(403).send({ error: 'Invalid Payload' });
+    }
+
+    // Prevent double subscription
     if (user.subscriptionFlags?.provider === SubscriptionProvider.Paddle) {
       logger.error(
         {
@@ -200,12 +286,12 @@ const handleNotifcationRequest = async (
       throw new Error('User already has a Paddle subscription');
     }
 
-    logger.info(
-      { renewalInfo, user, provider: SubscriptionProvider.AppleStoreKit },
-      'Received Apple App Store Server Notification',
+    const subscriptionStatus = getSubscriptionStatus(
+      notification.notificationType,
+      notification.subtype,
     );
 
-    const subscriptionStatus = getSubscriptionStatus(
+    const eventName = getSubscriptionAnalyticsEvent(
       notification.notificationType,
       notification.subtype,
     );
@@ -218,9 +304,20 @@ const handleNotifcationRequest = async (
       data: subscriptionFlags,
     });
 
-    return {
-      received: true,
-    };
+    if (eventName) {
+      await logAppleAnalyticsEvent(renewalInfo, eventName, user);
+    }
+
+    if (notification.notificationType === NotificationTypeV2.SUBSCRIBED) {
+      await notifyNewStoreKitSubscription(renewalInfo, user);
+    }
+
+    logger.info(
+      { renewalInfo, user, provider: SubscriptionProvider.AppleStoreKit },
+      'Received Apple App Store Server Notification',
+    );
+
+    return response.status(200).send({ received: true });
   } catch (_err) {
     const err = _err as Error;
     if (err instanceof VerificationException) {
@@ -247,6 +344,91 @@ const handleNotifcationRequest = async (
   }
 };
 
+export const notifyNewStoreKitSubscription = async (
+  data: JWSRenewalInfoDecodedPayload,
+  user: User,
+) => {
+  if (isTest) {
+    return;
+  }
+
+  const blocks: (KnownBlock | Block)[] = [
+    {
+      type: 'header',
+      text: {
+        type: 'plain_text',
+        text: 'New Plus subscriber :moneybag: :apple-ico:',
+        emoji: true,
+      },
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Transaction ID:*', data.appTransactionId),
+        },
+      ],
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Type:*', data.autoRenewProductId),
+        },
+        {
+          type: 'mrkdwn',
+          text: concatText(
+            '*Purchased by:*',
+            `<https://app.daily.dev/${user.id}|${user.id}>`,
+          ),
+        },
+      ],
+    },
+    // {
+    //   type: 'section',
+    //   fields: [
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText(
+    //         '*Cost:*',
+    //         new Intl.NumberFormat('en-US', {
+    //           style: 'currency',
+    //           currency: currencyCode,
+    //         }).format((parseFloat(total) || 0) / 100),
+    //       ),
+    //     },
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText('*Currency:*', currencyCode),
+    //     },
+    //   ],
+    // },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText(
+            '*Cost (local):*',
+            new Intl.NumberFormat('en-US', {
+              style: 'currency',
+              currency: data.currency,
+            }).format((data.renewalPrice || 0) / 1000),
+          ),
+        },
+        {
+          type: 'mrkdwn',
+          text: concatText('*Currency (local):*', data.currency),
+        },
+      ],
+    },
+  ];
+
+  await webhooks.transactions.send({ blocks });
+};
+
 export const apple = async (fastify: FastifyInstance): Promise<void> => {
   let appleRootCAs: Buffer[] = [];
   fastify.addHook('onRequest', async (request, res) => {
@@ -274,7 +456,7 @@ export const apple = async (fastify: FastifyInstance): Promise<void> => {
         appAppleId,
       );
 
-      await handleNotifcationRequest(verifier, request, response);
+      await handleNotifcationRequest(verifier, request, response, environment);
     },
   );
 
@@ -292,7 +474,12 @@ export const apple = async (fastify: FastifyInstance): Promise<void> => {
         appAppleId,
       );
 
-      await handleNotifcationRequest(verifier, request, response);
+      await handleNotifcationRequest(
+        verifier,
+        request,
+        response,
+        Environment.SANDBOX,
+      );
     },
   );
 };
diff --git a/src/routes/webhooks/paddle.ts b/src/routes/webhooks/paddle.ts
index f09354563e..064620c636 100644
--- a/src/routes/webhooks/paddle.ts
+++ b/src/routes/webhooks/paddle.ts
@@ -10,6 +10,7 @@ import {
 } from '@paddle/paddle-node-sdk';
 import createOrGetConnection from '../../db';
 import {
+  concatText,
   updateFlagsStatement,
   updateSubscriptionFlags,
   webhooks,
@@ -239,7 +240,6 @@ const logPaddleAnalyticsEvent = async (
   ]);
 };
 
-const concatText = (a: string, b: string) => [a, b].filter(Boolean).join(`\n`);
 const notifyNewPaddleTransaction = async ({
   event: { data },
 }: {
@@ -284,10 +284,10 @@ const notifyNewPaddleTransaction = async ({
 
   const headerText = (() => {
     if (gifter_id) {
-      return 'Gift subscription :gift:';
+      return 'Gift subscription :gift: :paddle:';
     }
 
-    return 'New Plus subscriber :moneybag:';
+    return 'New Plus subscriber :moneybag: :paddle:';
   })();
 
   const blocks = [


Reviews:
user: login: omBratteng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T13:47:04Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR enhances the Apple webhook handler by introducing analytics logging and slack notifications while adding a safeguard for Sandbox endpoints. Key changes include adding new analytics event functions, updating logging and error responses, and centralizing text concatenation for notification messages.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

| File                             | Description                                                              |
| -------------------------------- | ------------------------------------------------------------------------ |
| src/routes/webhooks/apple.ts     | Adds analytics event mapping and logging, slack notifications, and updated sandbox checks |
| src/common/utils.ts              | Introduces a reusable concatText utility function                        |
| src/remoteConfig.ts              | Adds an approvedStoreKitSandboxUsers property to the remote configuration  |
| src/routes/webhooks/paddle.ts     | Updates notification messages to include additional branding/emojis      |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/routes/webhooks/apple.ts:177**
* The variable 'env' is used but not defined; ensure 'env' is imported or replace it with the appropriate environment variable accessor (e.g., process.env).
```
if (!data || env.NODE_ENV === 'test') {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T14:52:28Z
user: login: omBratteng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T15:19:38Z
user: login: idoshamun
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-17T15:25:09Z
user: login: capJavert
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:29:00Z
user: login: rebelchris
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:55:25Z

Review comments:
diff_hunk: @@ -247,6 +344,87 @@ const handleNotifcationRequest = async (
   }
 };
 
+const notifyNewStoreKitSubscription = async (
+  data: JWSRenewalInfoDecodedPayload,
+  user: User,
+) => {
+  const blocks: (KnownBlock | Block)[] = [
+    {
+      type: 'header',
+      text: {
+        type: 'plain_text',
+        text: 'New Plus subscriber :moneybag: :apple-ico:',
+        emoji: true,
+      },
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Transaction ID:*', data.appTransactionId),
+        },
+      ],
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Type:*', data.autoRenewProductId),
+        },
+        {
+          type: 'mrkdwn',
+          text: concatText(
+            '*Purchased by:*',
+            `<https://app.daily.dev/${user.id}|${user.id}>`,
+          ),
+        },
+      ],
+    },
+    // {
+    //   type: 'section',
+    //   fields: [
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText(
+    //         '*Cost:*',
+    //         new Intl.NumberFormat('en-US', {
+    //           style: 'currency',
+    //           currency: currencyCode,
+    //         }).format((parseFloat(total) || 0) / 100),
+    //       ),
+    //     },
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText('*Currency:*', currencyCode),
+    //     },
+    //   ],
+    // },
path: src/routes/webhooks/apple.ts
user: login: omBratteng
body: This is commented out for now, as I have not found an easy way of getting USD prices
created_at: 2025-03-17T13:47:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -247,6 +344,91 @@ const handleNotifcationRequest = async (
   }
 };
 
+export const notifyNewStoreKitSubscription = async (
+  data: JWSRenewalInfoDecodedPayload,
+  user: User,
+) => {
+  if (env.NODE_ENV === 'test') {
path: src/routes/webhooks/apple.ts
user: login: Copilot
body: The 'env' variable is referenced in notifyNewStoreKitSubscription without being defined; consider importing or defining 'env' to correctly handle test environments.
created_at: 2025-03-17T14:52:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -247,6 +344,87 @@ const handleNotifcationRequest = async (
   }
 };
 
+const notifyNewStoreKitSubscription = async (
+  data: JWSRenewalInfoDecodedPayload,
+  user: User,
+) => {
+  const blocks: (KnownBlock | Block)[] = [
+    {
+      type: 'header',
+      text: {
+        type: 'plain_text',
+        text: 'New Plus subscriber :moneybag: :apple-ico:',
+        emoji: true,
+      },
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Transaction ID:*', data.appTransactionId),
+        },
+      ],
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Type:*', data.autoRenewProductId),
+        },
+        {
+          type: 'mrkdwn',
+          text: concatText(
+            '*Purchased by:*',
+            `<https://app.daily.dev/${user.id}|${user.id}>`,
+          ),
+        },
+      ],
+    },
+    // {
+    //   type: 'section',
+    //   fields: [
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText(
+    //         '*Cost:*',
+    //         new Intl.NumberFormat('en-US', {
+    //           style: 'currency',
+    //           currency: currencyCode,
+    //         }).format((parseFloat(total) || 0) / 100),
+    //       ),
+    //     },
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText('*Currency:*', currencyCode),
+    //     },
+    //   ],
+    // },
path: src/routes/webhooks/apple.ts
user: login: omBratteng
body: Will add a separate PR for this.
created_at: 2025-03-17T15:19:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -259,3 +259,6 @@ export const isSpecialUser = ({
 }): boolean => {
   return !!userId && [ghostUser.id, systemUser.id].includes(userId);
 };
+
+export const concatText = (a?: string, b?: string) =>
path: src/common/utils.ts
user: login: capJavert
body: allow it to accept `...args` if we are making this kind of helper
created_at: 2025-03-17T16:28:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -247,6 +344,87 @@ const handleNotifcationRequest = async (
   }
 };
 
+const notifyNewStoreKitSubscription = async (
+  data: JWSRenewalInfoDecodedPayload,
+  user: User,
+) => {
+  const blocks: (KnownBlock | Block)[] = [
+    {
+      type: 'header',
+      text: {
+        type: 'plain_text',
+        text: 'New Plus subscriber :moneybag: :apple-ico:',
+        emoji: true,
+      },
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Transaction ID:*', data.appTransactionId),
+        },
+      ],
+    },
+    {
+      type: 'section',
+      fields: [
+        {
+          type: 'mrkdwn',
+          text: concatText('*Type:*', data.autoRenewProductId),
+        },
+        {
+          type: 'mrkdwn',
+          text: concatText(
+            '*Purchased by:*',
+            `<https://app.daily.dev/${user.id}|${user.id}>`,
+          ),
+        },
+      ],
+    },
+    // {
+    //   type: 'section',
+    //   fields: [
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText(
+    //         '*Cost:*',
+    //         new Intl.NumberFormat('en-US', {
+    //           style: 'currency',
+    //           currency: currencyCode,
+    //         }).format((parseFloat(total) || 0) / 100),
+    //       ),
+    //     },
+    //     {
+    //       type: 'mrkdwn',
+    //       text: concatText('*Currency:*', currencyCode),
+    //     },
+    //   ],
+    // },
path: src/routes/webhooks/apple.ts
user: login: capJavert
body: just remove it, no need to be here, or replace with dedicated TODO
created_at: 2025-03-17T16:28:48Z
author_association: CONTRIBUTOR

PR comments:
user: login: pulumi[bot]
created_at: 2025-03-17T13:58:14Z
body: :tropical_drink: The **[Update (preview)](https://app.pulumi.com/dailydotdev/api/prod/previews/a2e27ca5-0929-4f67-b26f-bbc5c7fd63f3)** for **[dailydotdev/api/prod](https://app.pulumi.com/dailydotdev/api/prod)** was successful.


## Resource Changes
```diff
    Name                                            Type                           Operation
~   vpc-native-personalized-digest-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-users-cron              kubernetes:batch/v1:CronJob    update
~   vpc-native-sync-subscription-with-cio-cron      kubernetes:batch/v1:CronJob    update
~   vpc-native-calculate-top-readers-cron           kubernetes:batch/v1:CronJob    update
~   vpc-native-update-tag-recommendations-cron      kubernetes:batch/v1:CronJob    update
~   vpc-native-temporal-deployment                  kubernetes:apps/v1:Deployment  update
~   vpc-native-update-source-tag-view-cron          kubernetes:batch/v1:CronJob    update
~   vpc-native-update-highlighted-views-cron        kubernetes:batch/v1:CronJob    update
+   vpc-native-api-migration-086a846e               kubernetes:batch/v1:Job        create
~   vpc-native-validate-active-users-cron           kubernetes:batch/v1:CronJob    update
~   vpc-native-ws-deployment                        kubernetes:apps/v1:Deployment  update
~   vpc-native-hourly-notification-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-update-tags-str-cron                 kubernetes:batch/v1:CronJob    update
-   vpc-native-api-migration-550dd998               kubernetes:batch/v1:Job        delete
~   vpc-native-daily-digest-cron                    kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-user-companies-cron     kubernetes:batch/v1:CronJob    update
~   vpc-native-update-trending-cron                 kubernetes:batch/v1:CronJob    update
~   vpc-native-deployment                           kubernetes:apps/v1:Deployment  update
~   vpc-native-update-current-streak-cron           kubernetes:batch/v1:CronJob    update
~   vpc-native-personalized-digest-deployment       kubernetes:apps/v1:Deployment  update
~   vpc-native-clean-gifted-plus-cron               kubernetes:batch/v1:CronJob    update
~   vpc-native-check-analytics-report-cron          kubernetes:batch/v1:CronJob    update
~   vpc-native-update-views-cron                    kubernetes:batch/v1:CronJob    update
~   vpc-native-generate-search-invites-cron         kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-images-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-bg-deployment                        kubernetes:apps/v1:Deployment  update
~   vpc-native-update-source-public-threshold-cron  kubernetes:batch/v1:CronJob    update
~   vpc-native-generic-referral-reminder-cron       kubernetes:batch/v1:CronJob    update
~   vpc-native-private-deployment                   kubernetes:apps/v1:Deployment  update

```




author_association: NONE
user: login: pulumi[bot]
created_at: 2025-03-17T15:22:05Z
body: :tropical_drink: The **[Update (preview)](https://app.pulumi.com/dailydotdev/api/prod/previews/c2d0f11e-6e29-411a-988c-d8c506658eab)** for **[dailydotdev/api/prod](https://app.pulumi.com/dailydotdev/api/prod)** was successful.


## Resource Changes
```diff
    Name                                            Type                           Operation
~   vpc-native-update-highlighted-views-cron        kubernetes:batch/v1:CronJob    update
~   vpc-native-deployment                           kubernetes:apps/v1:Deployment  update
~   vpc-native-calculate-top-readers-cron           kubernetes:batch/v1:CronJob    update
~   vpc-native-generate-search-invites-cron         kubernetes:batch/v1:CronJob    update
~   vpc-native-temporal-deployment                  kubernetes:apps/v1:Deployment  update
~   vpc-native-private-deployment                   kubernetes:apps/v1:Deployment  update
~   vpc-native-update-source-tag-view-cron          kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-user-companies-cron     kubernetes:batch/v1:CronJob    update
~   vpc-native-update-views-cron                    kubernetes:batch/v1:CronJob    update
~   vpc-native-validate-active-users-cron           kubernetes:batch/v1:CronJob    update
~   vpc-native-update-tags-str-cron                 kubernetes:batch/v1:CronJob    update
~   vpc-native-update-trending-cron                 kubernetes:batch/v1:CronJob    update
~   vpc-native-update-current-streak-cron           kubernetes:batch/v1:CronJob    update
~   vpc-native-update-source-public-threshold-cron  kubernetes:batch/v1:CronJob    update
~   vpc-native-update-tag-recommendations-cron      kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-users-cron              kubernetes:batch/v1:CronJob    update
~   vpc-native-generic-referral-reminder-cron       kubernetes:batch/v1:CronJob    update
-   vpc-native-api-migration-550dd998               kubernetes:batch/v1:Job        delete
~   vpc-native-daily-digest-cron                    kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-images-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-sync-subscription-with-cio-cron      kubernetes:batch/v1:CronJob    update
+   vpc-native-api-migration-db4916f1               kubernetes:batch/v1:Job        create
~   vpc-native-bg-deployment                        kubernetes:apps/v1:Deployment  update
~   vpc-native-hourly-notification-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-check-analytics-report-cron          kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-gifted-plus-cron               kubernetes:batch/v1:CronJob    update
~   vpc-native-personalized-digest-deployment       kubernetes:apps/v1:Deployment  update
~   vpc-native-personalized-digest-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-ws-deployment                        kubernetes:apps/v1:Deployment  update

```




author_association: NONE