diff --git a/.github/workflows/_deploy-environment.yml b/.github/workflows/_deploy-environment.yml index de3ca8810c..542caf91dd 100644 --- a/.github/workflows/_deploy-environment.yml +++ b/.github/workflows/_deploy-environment.yml @@ -85,7 +85,6 @@ jobs: COMPASS_SYNC_TOKEN: ${{ secrets.COMPASS_SYNC_TOKEN }} GCAL_NOTIFICATION_TOKEN: ${{ secrets.GCAL_NOTIFICATION_TOKEN }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - KIT_API_SECRET: ${{ secrets.KIT_API_SECRET }} MONGO_PASSWORD: ${{ secrets.MONGO_PASSWORD }} MONGO_REPLICA_SET_KEY: ${{ secrets.MONGO_REPLICA_SET_KEY }} MONGO_URI: ${{ secrets.MONGO_URI }} @@ -105,10 +104,6 @@ jobs: # (2026-08-01). if [ "${{ inputs.environment }}" = "production" ]; then NODE_ENV="production" - if [ -z "$KIT_API_SECRET" ]; then - echo "Production deploy requires KIT_API_SECRET." >&2 - exit 1 - fi else NODE_ENV="staging" fi @@ -186,11 +181,6 @@ jobs: " key: \"${POSTHOG_KEY}\"" \ " host: \"${POSTHOG_HOST}\"" fi - if [ -n "$KIT_API_SECRET" ]; then - printf '%s\n' \ - 'email:' \ - " kitApiSecret: \"${KIT_API_SECRET}\"" - fi printf '%s\n' \ 'sync:' \ " mongoUri: \"${SYNC_MONGO_URI}\"" \ diff --git a/compass.example.yaml b/compass.example.yaml index 5c51019815..75534399ca 100644 --- a/compass.example.yaml +++ b/compass.example.yaml @@ -37,9 +37,6 @@ supertokens: # clientId: REPLACE_WITH_GOOGLE_CLIENT_ID # e.g. your-id.apps.googleusercontent.com # clientSecret: REPLACE_WITH_GOOGLE_CLIENT_SECRET -# email: -# kitApiSecret: REPLACE_WITH_KIT_API_SECRET - # posthog: # key: REPLACE_WITH_POSTHOG_KEY # host: REPLACE_WITH_POSTHOG_HOST diff --git a/docs/Config/README.md b/docs/Config/README.md index 68c926d0fb..7c317c0dd7 100644 --- a/docs/Config/README.md +++ b/docs/Config/README.md @@ -96,6 +96,5 @@ database and must not share the backend's database user/data. | key | Required | Description | |---|---|---| -| `email.kitApiSecret` | No | Kit.com API secret key. | | `posthog.key` | No | PostHog project key injected into the web bundle. | | `posthog.host` | No | PostHog host injected into the web bundle. | diff --git a/packages/backend/src/__tests__/drivers/user.controller.driver.ts b/packages/backend/src/__tests__/drivers/user.controller.driver.ts index b6ba6d6e63..a46946365c 100644 --- a/packages/backend/src/__tests__/drivers/user.controller.driver.ts +++ b/packages/backend/src/__tests__/drivers/user.controller.driver.ts @@ -1,6 +1,5 @@ import type request from "supertest"; import { Status } from "@core/errors/status.codes"; -import { type EmailUpdatesResponse } from "@core/types/email/email.types"; import { type UserProfile } from "@core/types/user.types"; import { type BaseDriver } from "@backend/__tests__/drivers/base.driver"; import { type Summary_Delete } from "@backend/user/types/user.types"; @@ -29,26 +28,4 @@ export class UserControllerDriver { .use(this.baseDriver.setSessionPlugin(session)) .expect(status); } - - async getEmailUpdates( - session?: { userId: string }, - status: Status = Status.OK, - ): Promise & { body: EmailUpdatesResponse }> { - return this.baseDriver - .getServer() - .get("/api/user/email-updates") - .use(this.baseDriver.setSessionPlugin(session)) - .expect(status); - } - - async subscribeToEmailUpdates( - session?: { userId: string }, - status: Status = Status.OK, - ): Promise & { body: EmailUpdatesResponse }> { - return this.baseDriver - .getServer() - .put("/api/user/email-updates") - .use(this.baseDriver.setSessionPlugin(session)) - .expect(status); - } } diff --git a/packages/backend/src/common/constants/config.constants.ts b/packages/backend/src/common/constants/config.constants.ts index 3432bb0cee..8c49f5cbfe 100644 --- a/packages/backend/src/common/constants/config.constants.ts +++ b/packages/backend/src/common/constants/config.constants.ts @@ -21,7 +21,6 @@ const ConfigSchema = z GOOGLE_CLIENT_ID: z.string().nonempty().optional(), GOOGLE_CLIENT_SECRET: z.string().nonempty().optional(), DB: z.string().nonempty(), - EMAILER_SECRET: z.string().nonempty().optional(), FRONTEND_URL: z.string().url(), MONGO_URI: z.string().nonempty(), NODE_ENV: z.nativeEnum(NodeEnv), @@ -84,7 +83,6 @@ export function parseRawConfig(config: CompassConfig): Config { GOOGLE_CLIENT_ID: nonEmpty(config.google?.clientId), GOOGLE_CLIENT_SECRET: nonEmpty(config.google?.clientSecret), DB: isDev(nodeEnv) ? "dev_calendar" : "prod_calendar", - EMAILER_SECRET: nonEmpty(config.email?.kitApiSecret), FRONTEND_URL: config.web.url, MONGO_URI: config.mongo.uri, NODE_ENV: nodeEnv, @@ -113,7 +111,6 @@ export function parseConfigFromEnv( GOOGLE_CLIENT_ID: rawEnv["GOOGLE_CLIENT_ID"], GOOGLE_CLIENT_SECRET: rawEnv["GOOGLE_CLIENT_SECRET"], DB: isDev(nodeEnv) ? "dev_calendar" : "prod_calendar", - EMAILER_SECRET: rawEnv["EMAILER_API_SECRET"], FRONTEND_URL: rawEnv["FRONTEND_URL"], MONGO_URI: rawEnv["MONGO_URI"], NODE_ENV: nodeEnv, diff --git a/packages/backend/src/common/errors/emailer/emailer.errors.ts b/packages/backend/src/common/errors/emailer/emailer.errors.ts deleted file mode 100644 index 2b60ffd357..0000000000 --- a/packages/backend/src/common/errors/emailer/emailer.errors.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Status } from "@core/errors/status.codes"; -import { type ErrorMetadata } from "@backend/common/types/error.types"; - -interface EmailerErrors { - InvalidTagId: ErrorMetadata; - InvalidSecret: ErrorMetadata; - InvalidSubscriberData: ErrorMetadata; -} - -export const EmailerError: EmailerErrors = { - InvalidSubscriberData: { - description: "Subscriber data is missing or incorrect", - status: Status.BAD_REQUEST, - isOperational: true, - }, - InvalidSecret: { - description: - "Invalid emailer API secret. Please make sure environment variables beginning with EMAILER_ are correct", - status: Status.INTERNAL_SERVER, - isOperational: true, - }, - InvalidTagId: { - description: - "Invalid emailer tag id. Please make sure environment variables beginning with EMAILER_ are correct", - status: Status.INTERNAL_SERVER, - isOperational: true, - }, -}; diff --git a/packages/backend/src/common/errors/handlers/error.handler.ts b/packages/backend/src/common/errors/handlers/error.handler.ts index 85d28d50fd..77f76d9683 100644 --- a/packages/backend/src/common/errors/handlers/error.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.handler.ts @@ -1,5 +1,4 @@ import { BaseError } from "@core/errors/errors.base"; -import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; import { type ErrorMetadata } from "@backend/common/types/error.types"; @@ -21,19 +20,6 @@ export const error = (cause: ErrorMetadata, result: string) => { ); }; -export const genericError = ( - e: unknown, - result: string, - status = Status.INTERNAL_SERVER, - isOperational = true, -) => { - const _e = e as Error; - const name = _e.name || "GenericName"; - const description = `${name}: ${_e.message || "GenericMsg"}`; - const cause = { description, isOperational, status }; - return error(cause, result); -}; - /** * Returns a safe payload for BaseError to send to clients. * Avoids exposing stack, isOperational, or other internal details. diff --git a/packages/backend/src/email/email.service.test.ts b/packages/backend/src/email/email.service.test.ts deleted file mode 100644 index 3c699b6b63..0000000000 --- a/packages/backend/src/email/email.service.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { type Schema_User } from "@core/types/user.types"; -import EmailService from "./email.service"; -import { afterEach, describe, expect, it, mock } from "bun:test"; - -const originalFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("EmailService", () => { - it("checks an exact active Kit subscriber with one request", async () => { - const email = faker.internet.email().toLowerCase(); - const fetchMock = mock().mockResolvedValue( - new Response( - JSON.stringify({ - subscribers: [{ id: 1, email_address: email, state: "active" }], - }), - ), - ); - globalThis.fetch = fetchMock; - - const status = await EmailService.getEmailUpdatesStatus(email); - - expect(status).toBe("subscribed"); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `https://api.kit.com/v4/subscribers?email_address=${encodeURIComponent(email)}&per_page=1&status=all`, - expect.objectContaining({ method: "GET" }), - ); - }); - - it("does not offer a new subscription to a cancelled Kit subscriber", async () => { - const email = faker.internet.email().toLowerCase(); - globalThis.fetch = mock().mockResolvedValue( - new Response( - JSON.stringify({ - subscribers: [{ id: 1, email_address: email, state: "cancelled" }], - }), - ), - ); - - await expect(EmailService.getEmailUpdatesStatus(email)).resolves.toBe( - "unsubscribed", - ); - }); - - it("subscribes with one subscriber upsert and no tag request", async () => { - const user: Schema_User = { - email: faker.internet.email(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - locale: "en-US", - name: faker.person.fullName(), - }; - const fetchMock = mock().mockResolvedValue( - new Response( - JSON.stringify({ - subscriber: { - created_at: new Date().toISOString(), - email_address: user.email, - fields: {}, - first_name: user.firstName, - id: 1, - state: "active", - }, - }), - ), - ); - globalThis.fetch = fetchMock; - - const status = await EmailService.subscribeToEmailUpdates(user); - - expect(status).toBe("subscribed"); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.kit.com/v4/subscribers", - expect.objectContaining({ method: "POST" }), - ); - }); -}); diff --git a/packages/backend/src/email/email.service.ts b/packages/backend/src/email/email.service.ts deleted file mode 100644 index 8eb9aefcf2..0000000000 --- a/packages/backend/src/email/email.service.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { BaseError } from "@core/errors/errors.base"; -import { Logger } from "@core/logger/winston.logger"; -import { mapCompassUserToEmailSubscriber } from "@core/mappers/subscriber/map.subscriber"; -import { - type EmailUpdatesStatus, - type Subscriber, - SubscriberSchema, -} from "@core/types/email/email.types"; -import { type Schema_User } from "@core/types/user.types"; -import { CONFIG } from "@backend/common/constants/config.constants"; -import { EmailerError } from "@backend/common/errors/emailer/emailer.errors"; -import { - error, - genericError, -} from "@backend/common/errors/handlers/error.handler"; -import { - type Response_ListSubscribers, - type Response_UpsertSubscriber, -} from "./email.types"; - -const logger = Logger("app:email.service"); - -/** - * Internal error class for Kit API failures. - * Used to pass response details from post() to callers, who then - * transform it into appropriate domain errors using error(). - */ -class KitApiError extends Error { - constructor( - message: string, - public readonly method: string, - public readonly url: string, - public readonly status?: number, - public readonly data?: unknown, - ) { - super(message); - this.name = "KitApiError"; - Object.setPrototypeOf(this, new.target.prototype); - Error.captureStackTrace(this); - } -} - -const getResponseData = async (response: Response): Promise => { - const text = await response.text(); - if (!text) { - return undefined; - } - - try { - return JSON.parse(text); - } catch { - return text; - } -}; - -class EmailService { - private static headers: { headers: Record }; - private static readonly baseUrl = "https://api.kit.com/v4"; - - private static initialize() { - if (!EmailService.headers) { - if (!CONFIG.EMAILER_SECRET) { - throw error(EmailerError.InvalidSecret, "Did not instantiate Emailer"); - } - EmailService.headers = { - headers: { - "X-Kit-Api-Key": CONFIG.EMAILER_SECRET, - "Content-Type": "application/json", - Accept: "application/json", - }, - }; - } - } - - static async getEmailUpdatesStatus( - email: string, - ): Promise { - if (!CONFIG.EMAILER_SECRET) return "unavailable"; - - try { - EmailService.initialize(); - const query = new URLSearchParams({ - email_address: email, - per_page: "1", - status: "all", - }); - const response = await EmailService.get( - `${EmailService.baseUrl}/subscribers?${query.toString()}`, - ); - - const subscriber = response.subscribers.find( - (subscriber) => - subscriber.email_address.toLowerCase() === email.toLowerCase(), - ); - - if (!subscriber) return "not_subscribed"; - - return subscriber.state === "active" ? "subscribed" : "unsubscribed"; - } catch (err) { - EmailService.throwKitError(err, "Failed to retrieve email updates"); - } - } - - static async subscribeToEmailUpdates( - user: Schema_User, - ): Promise { - if (!CONFIG.EMAILER_SECRET) return "unavailable"; - - try { - const subscriber = mapCompassUserToEmailSubscriber(user); - const response = await EmailService.upsertSubscriber(subscriber); - return response.subscriber.state === "active" - ? "subscribed" - : "unsubscribed"; - } catch (err) { - EmailService.throwKitError(err, "Failed to subscribe to email updates"); - } - } - - static async upsertSubscriber( - subscriber: Subscriber, - ): Promise { - const { data, success } = SubscriberSchema.safeParse(subscriber); - if (!success) { - throw error( - EmailerError.InvalidSubscriberData, - "Subscriber not upserted", - ); - } - - EmailService.initialize(); - logger.debug(`Adding subscriber: ${subscriber.email_address}`); - const url = `${EmailService.baseUrl}/subscribers`; - return await EmailService.post(url, data); - } - - private static throwKitError(err: unknown, message: string): never { - if (err instanceof BaseError) throw err; - - if (err instanceof KitApiError) { - logger.error({ - message: err.message, - status: err.status, - data: err.data, - method: err.method, - url: err.url, - }); - - if (err.status === 401) { - throw error(EmailerError.InvalidSecret, message); - } - } - - throw genericError(err, message); - } - - private static async get(url: string): Promise { - const response = await fetch(url, { - headers: EmailService.headers.headers, - method: "GET", - }); - const data = await getResponseData(response); - - if (!response.ok) { - throw new KitApiError( - `Kit request failed with status ${response.status}`, - "GET", - url, - response.status, - data, - ); - } - - return data as T; - } - - private static async post(url: string, body: object): Promise { - const response = await fetch(url, { - body: JSON.stringify(body), - headers: EmailService.headers.headers, - method: "POST", - }); - const data = await getResponseData(response); - - if (!response.ok) { - throw new KitApiError( - `Kit request failed with status ${response.status}`, - "POST", - url, - response.status, - data, - ); - } - - return data as T; - } -} - -export default EmailService; diff --git a/packages/backend/src/email/email.types.ts b/packages/backend/src/email/email.types.ts deleted file mode 100644 index 1e29b46550..0000000000 --- a/packages/backend/src/email/email.types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod"; -import { SubscriberStateSchema } from "@core/types/email/email.types"; - -export const Response_UpsertSubscriberSchema = z.object({ - subscriber: z.object({ - id: z.number().int(), - first_name: z.string(), - email_address: z.string().email(), - state: SubscriberStateSchema, - created_at: z.string().datetime(), - fields: z.object({}).optional(), - }), -}); - -export type Response_UpsertSubscriber = z.infer< - typeof Response_UpsertSubscriberSchema ->; - -export const Response_ListSubscribersSchema = z.object({ - subscribers: z.array( - z.object({ - id: z.number().int(), - email_address: z.string().email(), - state: SubscriberStateSchema, - }), - ), -}); - -export type Response_ListSubscribers = z.infer< - typeof Response_ListSubscribersSchema ->; diff --git a/packages/backend/src/user/controllers/user.controller.db.test.ts b/packages/backend/src/user/controllers/user.controller.db.test.ts index 9bc6239a8e..63d567e569 100644 --- a/packages/backend/src/user/controllers/user.controller.db.test.ts +++ b/packages/backend/src/user/controllers/user.controller.db.test.ts @@ -13,7 +13,6 @@ import compassAuthService from "@backend/auth/services/compass/compass.auth.serv import supertokensUserCleanupService from "@backend/auth/services/supertokens/supertokens.user-cleanup.service"; import { UserError } from "@backend/common/errors/user/user.errors"; import mongoService from "@backend/common/services/mongo.service"; -import EmailService from "@backend/email/email.service"; import { afterAll, beforeAll, @@ -141,38 +140,4 @@ describe("UserController", () => { expect(response.error).toEqual(UserError.UserNotFound); }); }); - - describe("email updates", () => { - it("returns the active Kit subscription state for the session user", async () => { - const { user } = await UtilDriver.setupTestUser(); - const getStatus = spyOn( - EmailService, - "getEmailUpdatesStatus", - ).mockResolvedValue("subscribed"); - - const response = await userDriver.getEmailUpdates({ - userId: user._id.toString(), - }); - - expect(response.body).toEqual({ status: "subscribed" }); - expect(getStatus).toHaveBeenCalledWith(user.email); - }); - - it("subscribes the session user without writing user metadata", async () => { - const { user } = await UtilDriver.setupTestUser(); - const subscribe = spyOn( - EmailService, - "subscribeToEmailUpdates", - ).mockResolvedValue("subscribed"); - - const response = await userDriver.subscribeToEmailUpdates({ - userId: user._id.toString(), - }); - - expect(response.body).toEqual({ status: "subscribed" }); - expect(subscribe).toHaveBeenCalledWith( - expect.objectContaining({ _id: user._id }), - ); - }); - }); }); diff --git a/packages/backend/src/user/controllers/user.controller.ts b/packages/backend/src/user/controllers/user.controller.ts index 47ab11b69a..8c5e63b3d0 100644 --- a/packages/backend/src/user/controllers/user.controller.ts +++ b/packages/backend/src/user/controllers/user.controller.ts @@ -2,17 +2,9 @@ import { type Request, type Response } from "express"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; -import { - type EmailUpdatesResponse, - EmailUpdatesResponseSchema, -} from "@core/types/email/email.types"; import { zObjectId } from "@core/types/type.utils"; import { type UserMetadata, type UserProfile } from "@core/types/user.types"; -import { error } from "@backend/common/errors/handlers/error.handler"; -import { UserError } from "@backend/common/errors/user/user.errors"; import { type SReqBody } from "@backend/common/types/express.types"; -import EmailService from "@backend/email/email.service"; -import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import { type Summary_Delete } from "@backend/user/types/user.types"; @@ -20,12 +12,6 @@ import { type Summary_Delete } from "@backend/user/types/user.types"; const logger = Logger("app:user.controller"); class UserController { - private getCurrentUser = async (userId: string) => { - const user = await findCompassUserBy("_id", userId); - if (!user) throw error(UserError.UserNotFound, "User not found"); - return user; - }; - getProfile = async ( req: Request, res: Response, @@ -122,44 +108,6 @@ class UserController { res.status(Status.INTERNAL_SERVER).send(); } }; - - getEmailUpdates = async ( - req: Request, - res: Response, - ) => { - try { - const userId = zObjectId.parse(req.session?.getUserId()).toString(); - const user = await this.getCurrentUser(userId); - const status = await EmailService.getEmailUpdatesStatus(user.email); - - res.status(Status.OK).json(EmailUpdatesResponseSchema.parse({ status })); - } catch (e) { - if (e instanceof BaseError) { - res.status(e.statusCode).send(); - return; - } - res.status(Status.INTERNAL_SERVER).send(); - } - }; - - subscribeToEmailUpdates = async ( - req: Request, - res: Response, - ) => { - try { - const userId = zObjectId.parse(req.session?.getUserId()).toString(); - const user = await this.getCurrentUser(userId); - const status = await EmailService.subscribeToEmailUpdates(user); - - res.status(Status.OK).json(EmailUpdatesResponseSchema.parse({ status })); - } catch (e) { - if (e instanceof BaseError) { - res.status(e.statusCode).send(); - return; - } - res.status(Status.INTERNAL_SERVER).send(); - } - }; } export default new UserController(); diff --git a/packages/backend/src/user/user.routes.config.ts b/packages/backend/src/user/user.routes.config.ts index 43d4d4fb95..61df82e5c0 100644 --- a/packages/backend/src/user/user.routes.config.ts +++ b/packages/backend/src/user/user.routes.config.ts @@ -66,12 +66,6 @@ export class UserRoutes extends CommonRoutesConfig { .get(userController.getMetadata) .post(userController.updateMetadata); - this.app - .route(`/api/user/email-updates`) - .all(verifySession()) - .get(userController.getEmailUpdates) - .put(userController.subscribeToEmailUpdates); - return this.app; } } diff --git a/packages/core/src/config/compass.config.test.ts b/packages/core/src/config/compass.config.test.ts index 7bdab86519..d9c699fbfd 100644 --- a/packages/core/src/config/compass.config.test.ts +++ b/packages/core/src/config/compass.config.test.ts @@ -95,7 +95,7 @@ posthog: "compass.yaml", ); - expect(config.email?.kitApiSecret).toBeUndefined(); + expect(config.email).toBeNull(); expect(config.posthog?.key).toBeUndefined(); }); diff --git a/packages/core/src/config/compass.config.ts b/packages/core/src/config/compass.config.ts index 85c6250275..8fb485133d 100644 --- a/packages/core/src/config/compass.config.ts +++ b/packages/core/src/config/compass.config.ts @@ -46,14 +46,10 @@ const CompassConfigSchema = z clientSecret: optionalString, }) .nullish(), - email: z - .object({ - kitApiSecret: optionalString, - // Retained solely so existing config files remain valid after tags - // stopped being part of Compass's email subscription flow. - kitUserTagId: z.union([z.string(), z.number()]).optional(), - }) - .nullish(), + // Accepted but ignored. Compass no longer sends email; the list is + // managed outside the app. Retained so existing config files stay valid + // on upgrade. + email: z.unknown().optional(), posthog: z .object({ key: optionalString, diff --git a/packages/core/src/mappers/subscriber/map.subscriber.test.ts b/packages/core/src/mappers/subscriber/map.subscriber.test.ts deleted file mode 100644 index 8516c491f2..0000000000 --- a/packages/core/src/mappers/subscriber/map.subscriber.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - type Subscriber, - SubscriberSchema, -} from "@core/types/email/email.types"; - -describe("Subscriber", () => { - it("parses a valid subscriber", () => { - const validSubscriber: Subscriber = { - email_address: "test@example.com", - first_name: "Test", - state: "active", - fields: { - "Last name": "User", - Birthday: "1970-01-01", - Source: "unknown", - Role: "unknown", - Company: "unknown", - "Postal code": "unknown", - Website: "unknown", - "Social media": "unknown", - "How did you hear about us?": "unknown", - Interests: "unknown", - Coupon: "unknown", - }, - }; - - const result = SubscriberSchema.safeParse(validSubscriber); - expect(result.success).toBe(true); - }); - - it("fails to parse an invalid subscriber", () => { - const invalidSubscriber: Subscriber = { - email_address: "invalid", - first_name: "Test", - state: "active", - fields: { - "Last name": "User", - Birthday: "1970-01-01", - Source: "unknown", - Role: "unknown", - Company: "unknown", - "Postal code": "unknown", - Website: "unknown", - "Social media": "unknown", - "How did you hear about us?": "unknown", - Interests: "unknown", - Coupon: "unknown", - }, - }; - - const result = SubscriberSchema.safeParse(invalidSubscriber); - expect(result.success).toBe(false); - }); -}); diff --git a/packages/core/src/mappers/subscriber/map.subscriber.ts b/packages/core/src/mappers/subscriber/map.subscriber.ts deleted file mode 100644 index 8b3e4a0a87..0000000000 --- a/packages/core/src/mappers/subscriber/map.subscriber.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type Subscriber } from "@core/types/email/email.types"; -import { type Schema_User } from "@core/types/user.types"; - -export const mapCompassUserToEmailSubscriber = ( - user: Schema_User, -): Subscriber => { - const UNKNOWN = "unknown"; - return { - email_address: user.email, - first_name: user.firstName, - state: "active", - fields: { - "Last name": user.lastName, - Birthday: "1970-01-01", - Source: UNKNOWN, - Role: UNKNOWN, - Company: UNKNOWN, - "Postal code": UNKNOWN, - Website: UNKNOWN, - "Social media": UNKNOWN, - "How did you hear about us?": UNKNOWN, - Interests: UNKNOWN, - Coupon: UNKNOWN, - }, - }; -}; diff --git a/packages/core/src/types/email/email.types.test.ts b/packages/core/src/types/email/email.types.test.ts deleted file mode 100644 index 7f53cce20a..0000000000 --- a/packages/core/src/types/email/email.types.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EmailUpdatesResponseSchema } from "./email.types"; -import { describe, expect, it } from "bun:test"; - -describe("EmailUpdatesResponseSchema", () => { - it.each([ - "unavailable", - "not_subscribed", - "subscribed", - ])("accepts %s", (status) => { - expect(EmailUpdatesResponseSchema.safeParse({ status }).success).toBe(true); - }); - - it("rejects an unknown status", () => { - expect( - EmailUpdatesResponseSchema.safeParse({ status: "pending" }).success, - ).toBe(false); - }); -}); diff --git a/packages/core/src/types/email/email.types.ts b/packages/core/src/types/email/email.types.ts deleted file mode 100644 index 79b43b2179..0000000000 --- a/packages/core/src/types/email/email.types.ts +++ /dev/null @@ -1,46 +0,0 @@ -import z from "zod"; - -export const SubscriberStateSchema = z - .enum(["active", "bounced", "cancelled", "complained", "inactive"]) - .nullable() - .optional() - .default("active"); -export type SubscriberState = z.infer; - -/** The current user's email-update state, as reported by Kit. */ -export const EmailUpdatesStatusSchema = z.enum([ - "unavailable", - "not_subscribed", - "subscribed", - "unsubscribed", -]); -export type EmailUpdatesStatus = z.infer; - -export const EmailUpdatesResponseSchema = z.strictObject({ - status: EmailUpdatesStatusSchema, -}); -export type EmailUpdatesResponse = z.infer; - -// Keep this up-to-date with: https://developers.kit.com/api-reference/subscribers/create-a-subscriber -export const SubscriberSchema = z.object({ - email_address: z.string().email(), - first_name: z.string().nullable().optional(), - state: SubscriberStateSchema, - fields: z - .object({ - "Last name": z.string(), - Birthday: z.string(), - Source: z.string(), - Role: z.string().optional(), - Company: z.string().optional(), - "Postal code": z.string().optional(), - Website: z.string().optional(), - "Social media": z.string().optional(), - "How did you hear about us?": z.string().optional(), - Interests: z.string().optional(), - Coupon: z.string().optional(), - }) - .nullable(), -}); - -export type Subscriber = z.infer; diff --git a/packages/scripts/src/testing/backend-test-env.ts b/packages/scripts/src/testing/backend-test-env.ts index db32de01d4..ee3492d0f4 100644 --- a/packages/scripts/src/testing/backend-test-env.ts +++ b/packages/scripts/src/testing/backend-test-env.ts @@ -10,7 +10,6 @@ export function applyBackendTestEnv(mongoUri: string): void { process.env["GOOGLE_CLIENT_SECRET"] = "googleSecret"; process.env["SUPERTOKENS_URI"] = "http://localhost:3000"; process.env["SUPERTOKENS_KEY"] = "sTKey"; - process.env["EMAILER_API_SECRET"] = "emailerApiSecret"; process.env["TOKEN_COMPASS_SYNC"] = "secretToken2"; process.env["SYNC_SERVICE_URL"] = "http://localhost:3010"; process.env["SYNC_INTERNAL_AUTH_TOKEN"] = "syncInternalAuthToken"; diff --git a/packages/web/declaration.d.ts b/packages/web/declaration.d.ts index 08181fb66b..95d92d7bad 100644 --- a/packages/web/declaration.d.ts +++ b/packages/web/declaration.d.ts @@ -38,8 +38,8 @@ interface Window { /** Set by Playwright prepareOAuthTestPage; disables SuperTokens session checks in e2e mode. */ __COMPASS_E2E_TEST__?: boolean; /** Semantic store bridges for e2e tests. Each store sets its own key (see - * user-metadata.store.ts, release-notes-prompt.store.ts); keys are optional - * because they populate independently as their modules evaluate. */ + * user-metadata.store.ts); keys are optional because they populate + * independently as their modules evaluate. */ __COMPASS_E2E_STORE__?: { userMetadata?: { getState: () => import("@web/auth/state/user-metadata.store").UserMetadataState; @@ -47,11 +47,6 @@ interface Window { setLoading: () => void; clear: () => void; }; - releaseNotesPrompt?: { - getState: () => import("@web/components/ReleaseNotesPrompt/release-notes-prompt.store").ReleaseNotesPromptState; - open: () => void; - close: () => void; - }; }; /** Session test hooks exposed by SessionProvider for e2e auth control. */ __COMPASS_E2E_HOOKS__?: { diff --git a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts index a620b4272e..8045d1334c 100644 --- a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts +++ b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts @@ -71,12 +71,6 @@ export const globalHandlers = [ rest.post(`${ENV_WEB.API_BASEURL}/user/metadata`, (req, res, ctx) => { return res(ctx.status(Status.OK), ctx.json(req.json())); }), - rest.get(`${ENV_WEB.API_BASEURL}/user/email-updates`, (_req, res, ctx) => { - return res(ctx.status(Status.OK), ctx.json({ status: "unavailable" })); - }), - rest.put(`${ENV_WEB.API_BASEURL}/user/email-updates`, (_req, res, ctx) => { - return res(ctx.status(Status.OK), ctx.json({ status: "subscribed" })); - }), rest.post(`${ENV_WEB.API_BASEURL}/signinup`, (_req, res, ctx) => { return res(ctx.json({ isNewUser: true })); }), diff --git a/packages/web/src/__tests__/utils/state/reset-stores.ts b/packages/web/src/__tests__/utils/state/reset-stores.ts index 55e7b71cf4..a3cc434b0e 100644 --- a/packages/web/src/__tests__/utils/state/reset-stores.ts +++ b/packages/web/src/__tests__/utils/state/reset-stores.ts @@ -14,7 +14,6 @@ import { resetCollapsedAccountsStoreForTests } from "@web/calendars/collapsed-ac import { resetDefaultCalendarStoreForTests } from "@web/calendars/default-calendar.store"; import { resetRecentCommandsStoreForTests } from "@web/components/CommandPalette/recent-commands.store"; import { useFeedbackStore } from "@web/components/Feedback/feedback.store"; -import { useReleaseNotesPromptStore } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store"; import { useWelcomeGuideStore } from "@web/components/WelcomeModal/welcome.guide.store"; import { recurrenceScopeOpportunityActions } from "@web/events/recurrence/recurrence-scope-opportunity.store"; import { resetEventRepositorySourceForTests } from "@web/events/repositories/event.repository.source.store"; @@ -63,11 +62,6 @@ const storeResets: StoreReset[] = [ // is active) - order-dependent, so it only surfaces on some runners. () => setWeekInteractionMotionActive(false), () => useFeedbackStore.setState(useFeedbackStore.getInitialState(), true), - () => - useReleaseNotesPromptStore.setState( - useReleaseNotesPromptStore.getInitialState(), - true, - ), () => useWelcomeGuideStore.setState(useWelcomeGuideStore.getInitialState(), true), () => useThemeStore.setState(useThemeStore.getInitialState(), true), diff --git a/packages/web/src/api/user.api.ts b/packages/web/src/api/user.api.ts index f3601a46cc..77a1bf0582 100644 --- a/packages/web/src/api/user.api.ts +++ b/packages/web/src/api/user.api.ts @@ -1,4 +1,3 @@ -import { type EmailUpdatesResponse } from "@core/types/email/email.types"; import { type UserMetadata, type UserProfile } from "@core/types/user.types"; import { BaseApi } from "@web/api/base/base.api"; @@ -22,18 +21,6 @@ const UserApi = { return response.data; }, - - async getEmailUpdates(): Promise { - const response = - await BaseApi.get(`/user/email-updates`); - return response.data; - }, - - async subscribeToEmailUpdates(): Promise { - const response = - await BaseApi.put(`/user/email-updates`); - return response.data; - }, }; export { UserApi }; diff --git a/packages/web/src/auth/compass/user/hooks/useLoadProfile.test.ts b/packages/web/src/auth/compass/user/hooks/useLoadProfile.test.ts index e79dd077c2..87ea7f6b99 100644 --- a/packages/web/src/auth/compass/user/hooks/useLoadProfile.test.ts +++ b/packages/web/src/auth/compass/user/hooks/useLoadProfile.test.ts @@ -37,8 +37,7 @@ mock.module("@web/auth/compass/state/auth.state.util", () => ({ // UserApi is captured up front and a flag (flipped off in afterAll) decides // which implementation runs on each call. Without this, this file's partial // UserApi shape (getProfile only) would permanently shadow the real module -// for other files' UserApi methods (e.g. useSubscribeCmdItems.test.ts's -// updateMetadata). +// for other files' UserApi methods (e.g. another suite's updateMetadata). const actualUserApi = (await import("@web/api/user.api")).UserApi; let isUserApiMocked = true; diff --git a/packages/web/src/common/constants/toast.constants.ts b/packages/web/src/common/constants/toast.constants.ts index af5502b467..419cd53ea7 100644 --- a/packages/web/src/common/constants/toast.constants.ts +++ b/packages/web/src/common/constants/toast.constants.ts @@ -14,7 +14,6 @@ export const GOOGLE_REFRESH_ALREADY_IN_FLIGHT_TOAST_ID: Id = "google-refresh-already-in-flight"; export const GOOGLE_DELAYED_TOAST_ID: Id = "google-delayed"; export const ACCOUNT_DISCONNECTED_TOAST_ID: Id = "account-disconnected"; -export const SUBSCRIBE_TO_UPDATES_TOAST_ID: Id = "subscribe-to-updates"; export const EXPORT_MY_DATA_TOAST_ID: Id = "export-my-data"; export const LOGGED_OUT_TOAST_ID: Id = "logged-out"; export const EVENT_SAVE_UNAVAILABLE_TOAST_ID: Id = "event-save-unavailable"; diff --git a/packages/web/src/components/AuthModal/AuthModal.tsx b/packages/web/src/components/AuthModal/AuthModal.tsx index 7fc526e56b..4bf827620d 100644 --- a/packages/web/src/components/AuthModal/AuthModal.tsx +++ b/packages/web/src/components/AuthModal/AuthModal.tsx @@ -204,6 +204,12 @@ export const AuthModal: FC = () => { /> ) : null} + {currentView === "signUp" ? ( +

+ Signing up also gets you occasional product emails. Unsubscribe + anytime. +

+ ) : null} {/* Privacy & Terms links */}
{}, -}); -mock.module("@web/auth/compass/session/useSession", () => ({ - useSession: mockUseSession, -})); - -const actualUserApi = (await import("@web/api/user.api")).UserApi; -const mockGetEmailUpdates = mock(); -const mockSubscribeToEmailUpdates = mock(); -let isUserApiMocked = true; - -mockGetEmailUpdates.mockResolvedValue({ status: "unavailable" }); - -mock.module("@web/api/user.api", () => ({ - UserApi: { - ...actualUserApi, - getEmailUpdates: ( - ...args: Parameters - ) => - isUserApiMocked - ? mockGetEmailUpdates(...args) - : actualUserApi.getEmailUpdates(...args), - subscribeToEmailUpdates: ( - ...args: Parameters - ) => - isUserApiMocked - ? mockSubscribeToEmailUpdates(...args) - : actualUserApi.subscribeToEmailUpdates(...args), - }, -})); - -afterAll(() => { - isUserApiMocked = false; - mockUseSession.mockReturnValue({ - authenticated: false, - setAuthenticated: () => {}, - }); -}); - -async function importHook() { - const moduleUrl = new URL( - `./useSubscribeCmdItems.ts?test=${Math.random().toString(36).slice(2)}`, - import.meta.url, - ); - - return import(moduleUrl.href) as Promise< - typeof import("./useSubscribeCmdItems") - >; -} - -describe("useSubscribeCmdItems", () => { - beforeEach(() => { - mockUseSession.mockClear(); - mockGetEmailUpdates.mockClear(); - mockSubscribeToEmailUpdates.mockClear(); - mockGetEmailUpdates.mockResolvedValue({ status: "unavailable" }); - mockUseSession.mockReturnValue({ authenticated: true }); - }); - - it("returns no items when unauthenticated", async () => { - mockUseSession.mockReturnValue({ authenticated: false }); - const { useSubscribeCmdItems } = await importHook(); - - const { result } = renderHook(() => useSubscribeCmdItems(true)); - - expect(result.current).toEqual([]); - expect(mockGetEmailUpdates).not.toHaveBeenCalled(); - }); - - it("waits to fetch until the palette opens and shows nothing while checking", async () => { - let resolveStatus!: (value: { status: "not_subscribed" }) => void; - mockGetEmailUpdates.mockReturnValue( - new Promise((resolve) => { - resolveStatus = resolve; - }), - ); - const { useSubscribeCmdItems } = await importHook(); - - const { result, rerender } = renderHook( - ({ open }) => useSubscribeCmdItems(open), - { initialProps: { open: false } }, - ); - - expect(result.current).toEqual([]); - rerender({ open: true }); - - expect(result.current).toEqual([]); - expect(mockGetEmailUpdates).toHaveBeenCalledTimes(1); - - await act(async () => resolveStatus({ status: "not_subscribed" })); - - await waitFor(() => { - expect(result.current[0]?.label).toBe("Opt in to email updates"); - }); - }); - - it("hides the command when email updates are unavailable", async () => { - let resolveStatus!: (value: { status: "unavailable" }) => void; - mockGetEmailUpdates.mockReturnValue( - new Promise((resolve) => { - resolveStatus = resolve; - }), - ); - const { useSubscribeCmdItems } = await importHook(); - - const { result } = renderHook(() => useSubscribeCmdItems(true)); - - expect(result.current).toEqual([]); - expect(mockGetEmailUpdates).toHaveBeenCalledTimes(1); - - await act(async () => resolveStatus({ status: "unavailable" })); - - expect(result.current).toEqual([]); - expect(mockSubscribeToEmailUpdates).not.toHaveBeenCalled(); - }); - - it("hides the command for an existing subscriber", async () => { - let resolveStatus!: (value: { status: "subscribed" }) => void; - mockGetEmailUpdates.mockReturnValue( - new Promise((resolve) => { - resolveStatus = resolve; - }), - ); - const { useSubscribeCmdItems } = await importHook(); - - const { result } = renderHook(() => useSubscribeCmdItems(true)); - - expect(result.current).toEqual([]); - expect(mockGetEmailUpdates).toHaveBeenCalledTimes(1); - - await act(async () => resolveStatus({ status: "subscribed" })); - - expect(result.current).toEqual([]); - expect(mockSubscribeToEmailUpdates).not.toHaveBeenCalled(); - }); - - it("does not offer an opt-in to a previously unsubscribed user", async () => { - let resolveStatus!: (value: { status: "unsubscribed" }) => void; - mockGetEmailUpdates.mockReturnValue( - new Promise((resolve) => { - resolveStatus = resolve; - }), - ); - const { useSubscribeCmdItems } = await importHook(); - - const { result } = renderHook(() => useSubscribeCmdItems(true)); - - expect(result.current).toEqual([]); - expect(mockGetEmailUpdates).toHaveBeenCalledTimes(1); - - await act(async () => resolveStatus({ status: "unsubscribed" })); - - expect(result.current).toEqual([]); - expect(mockSubscribeToEmailUpdates).not.toHaveBeenCalled(); - }); - - it("subscribes a user who is not on the email list", async () => { - mockGetEmailUpdates.mockResolvedValue({ status: "not_subscribed" }); - mockSubscribeToEmailUpdates.mockResolvedValue({ status: "subscribed" }); - const { useSubscribeCmdItems } = await importHook(); - - const { result } = renderHook(() => useSubscribeCmdItems(true)); - - await waitFor(() => { - expect(result.current[0]?.label).toBe("Opt in to email updates"); - }); - await act(async () => { - result.current[0]?.onClick?.(); - }); - - await waitFor(() => { - expect(result.current).toEqual([]); - }); - expect(mockSubscribeToEmailUpdates).toHaveBeenCalledTimes(1); - }); - - it("hides the check failure and retries after reopening the palette", async () => { - let rejectStatus!: (error: Error) => void; - mockGetEmailUpdates - .mockReturnValueOnce( - new Promise((_, reject) => { - rejectStatus = reject; - }), - ) - .mockResolvedValueOnce({ status: "not_subscribed" }); - const { useSubscribeCmdItems } = await importHook(); - - const { result, rerender } = renderHook( - ({ open }) => useSubscribeCmdItems(open), - { initialProps: { open: true } }, - ); - - expect(mockGetEmailUpdates).toHaveBeenCalledTimes(1); - expect(result.current).toEqual([]); - - await act(async () => rejectStatus(new Error("network error"))); - - expect(result.current).toEqual([]); - rerender({ open: false }); - rerender({ open: true }); - - await waitFor(() => { - expect(result.current[0]?.label).toBe("Opt in to email updates"); - }); - expect(mockGetEmailUpdates).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/web/src/components/CommandPalette/hooks/useSubscribeCmdItems.ts b/packages/web/src/components/CommandPalette/hooks/useSubscribeCmdItems.ts deleted file mode 100644 index 4e284c8cac..0000000000 --- a/packages/web/src/components/CommandPalette/hooks/useSubscribeCmdItems.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { BellIcon } from "@phosphor-icons/react"; -import { useEffect, useRef, useState } from "react"; -import { type EmailUpdatesStatus } from "@core/types/email/email.types"; -import { UserApi } from "@web/api/user.api"; -import { useSession } from "@web/auth/compass/session/useSession"; -import { SUBSCRIBE_TO_UPDATES_TOAST_ID } from "@web/common/constants/toast.constants"; -import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; -import { showStatusToast } from "@web/common/utils/toast/status-toast.util"; -import { type CommandItem } from "@web/components/CommandPalette/command-palette.types"; - -/** - * Returns the email-updates opt-in command when Kit reports the user as - * eligible. Caches one lookup per mount; retries a failed lookup the next - * time the palette opens. - */ -export const useSubscribeCmdItems = (open: boolean): CommandItem[] => { - const { authenticated } = useSession(); - const [status, setStatus] = useState<"idle" | EmailUpdatesStatus>("idle"); - const hasChecked = useRef(false); - - useEffect(() => { - if (!authenticated) { - hasChecked.current = false; - setStatus("idle"); - return; - } - - if (!open || hasChecked.current) return; - - hasChecked.current = true; - void UserApi.getEmailUpdates() - .then((response) => setStatus(response.status)) - .catch(() => { - hasChecked.current = false; - }); - }, [authenticated, open]); - - if (!authenticated || status !== "not_subscribed") return []; - - return [ - { - id: "subscribe-to-updates", - label: "Opt in to email updates", - icon: BellIcon, - keywords: ["newsletter", "email", "updates"], - onClick: () => { - UserApi.subscribeToEmailUpdates() - .then((response) => { - if (response.status !== "subscribed") { - throw new Error("Subscriber is not active"); - } - setStatus("subscribed"); - showStatusToast( - SUBSCRIBE_TO_UPDATES_TOAST_ID, - "Subscribed to updates", - ); - }) - .catch(() => { - showErrorToast("Couldn't subscribe to updates. Please try again.", { - toastId: SUBSCRIBE_TO_UPDATES_TOAST_ID, - }); - }); - }, - }, - ]; -}; diff --git a/packages/web/src/components/ReleaseNotesPrompt/ReleaseNotesPrompt.tsx b/packages/web/src/components/ReleaseNotesPrompt/ReleaseNotesPrompt.tsx deleted file mode 100644 index 5efbea885e..0000000000 --- a/packages/web/src/components/ReleaseNotesPrompt/ReleaseNotesPrompt.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from "react"; -import { UserApi } from "@web/api/user.api"; -import { MODAL_DISMISS_MS } from "@web/common/constants/motion.constants"; -import { useDismissTransition } from "@web/common/hooks/useDismissTransition"; -import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; -import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; -import { releaseNotesPromptActions } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store"; -import { PixelPirate } from "@web/components/WelcomeModal/PixelPirate"; - -type PromptState = - | "asking" - | "confirmed" - | "declined" - | "unavailable" - | "unsubscribed"; - -export function ReleaseNotesPrompt() { - const [state, setState] = useState("asking"); - const { closing, beginDismiss } = useDismissTransition(MODAL_DISMISS_MS); - - const dismiss = () => { - beginDismiss(() => releaseNotesPromptActions.close()); - }; - - const decline = () => { - if (state === "unavailable" || state === "unsubscribed") { - dismiss(); - return; - } - if (state !== "asking") return; - setState("declined"); - window.setTimeout(dismiss, 1300); - }; - - const subscribe = async () => { - if (state !== "asking") return; - try { - const response = await UserApi.subscribeToEmailUpdates(); - if (response.status === "unavailable") { - setState("unavailable"); - return; - } - if (response.status === "unsubscribed") { - setState("unsubscribed"); - return; - } - if (response.status !== "subscribed") { - throw new Error("Subscriber is not active"); - } - setState("confirmed"); - window.setTimeout(dismiss, 1300); - } catch { - showErrorToast("Couldn't subscribe to updates. Please try again."); - dismiss(); - } - }; - - return ( - -
- - {state === "asking" ? ( - <> -
-

- Want the latest Compass news? -

-

- Get monthly product email. Unsubscribe anytime. -

-
-
- - -
- - ) : ( -
-

- {state === "confirmed" - ? "You're in!" - : state === "unavailable" - ? "Email updates aren't available here." - : state === "unsubscribed" - ? "You're unsubscribed from updates." - : "No problem."} -

-

- {state === "confirmed" - ? "You'll get the next release notes in your inbox" - : state === "unavailable" - ? "Ask this Compass instance's administrator to enable email updates." - : state === "unsubscribed" - ? "Kit requires a separate re-subscription before it can send updates again." - : "No problem, you can signup using the cmd palette if you change your mind."} -

- {(state === "unavailable" || state === "unsubscribed") && ( -
- -
- )} -
- )} -
-
- ); -} diff --git a/packages/web/src/components/ReleaseNotesPrompt/release-notes-prompt.store.ts b/packages/web/src/components/ReleaseNotesPrompt/release-notes-prompt.store.ts deleted file mode 100644 index 5b6d9d5323..0000000000 --- a/packages/web/src/components/ReleaseNotesPrompt/release-notes-prompt.store.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { create } from "zustand"; -import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; - -export interface ReleaseNotesPromptState { - isOpen: boolean; -} - -export const useReleaseNotesPromptStore = create()( - () => ({ isOpen: false }), -); - -const SCHEDULED_OPEN_AT_KEY = - "compass.onboarding.release-notes-prompt-scheduled-at"; - -let timeoutId: number | undefined; - -const clearTimer = () => { - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - timeoutId = undefined; - } -}; - -const clearSchedule = () => { - clearTimer(); - persistentBrowserStore.remove(SCHEDULED_OPEN_AT_KEY); -}; - -const setPromptOpen = (isOpen: boolean) => { - clearSchedule(); - useReleaseNotesPromptStore.setState({ isOpen }); -}; - -const startTimer = (delayMs: number) => { - clearTimer(); - timeoutId = window.setTimeout(() => { - timeoutId = undefined; - setPromptOpen(true); - }, delayMs); -}; - -const resumeScheduledOpen = () => { - if (!persistentBrowserStore.isAvailable()) return; - - const storedOpenAt = persistentBrowserStore.get(SCHEDULED_OPEN_AT_KEY); - if (storedOpenAt === null) return; - - const openAtMs = Number(storedOpenAt); - if (!Number.isFinite(openAtMs)) { - persistentBrowserStore.remove(SCHEDULED_OPEN_AT_KEY); - return; - } - - const remainingMs = openAtMs - Date.now(); - if (remainingMs <= 0) { - setPromptOpen(true); - return; - } - - startTimer(remainingMs); -}; - -export const releaseNotesPromptActions = { - open: () => setPromptOpen(true), - close: () => setPromptOpen(false), - scheduleOpen: (delayMs = 45_000) => { - if (persistentBrowserStore.isAvailable()) { - persistentBrowserStore.set( - SCHEDULED_OPEN_AT_KEY, - String(Date.now() + delayMs), - ); - } - startTimer(delayMs); - }, -}; - -export const selectReleaseNotesPromptOpen = (state: ReleaseNotesPromptState) => - state.isOpen; - -// E2e bridge: raise the post-signup prompt without a real signup. Merge so -// sibling store bridges survive. -if (typeof window !== "undefined") { - resumeScheduledOpen(); - - window.__COMPASS_E2E_STORE__ = { - ...window.__COMPASS_E2E_STORE__, - releaseNotesPrompt: { - getState: useReleaseNotesPromptStore.getState, - open: releaseNotesPromptActions.open, - close: releaseNotesPromptActions.close, - }, - }; -} diff --git a/packages/web/src/components/RootShell/RootShell.tsx b/packages/web/src/components/RootShell/RootShell.tsx index 12c6c8bffe..6efa8f4963 100644 --- a/packages/web/src/components/RootShell/RootShell.tsx +++ b/packages/web/src/components/RootShell/RootShell.tsx @@ -5,11 +5,6 @@ import { AuthModal } from "@web/components/AuthModal/AuthModal"; import { AuthModalProvider } from "@web/components/AuthModal/AuthModalProvider"; import { OnboardingTour } from "@web/components/OnboardingTour/OnboardingTour"; import { PostOnboardingFlow } from "@web/components/PostOnboardingFlow/PostOnboardingFlow"; -import { ReleaseNotesPrompt } from "@web/components/ReleaseNotesPrompt/ReleaseNotesPrompt"; -import { - selectReleaseNotesPromptOpen, - useReleaseNotesPromptStore, -} from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store"; import { WelcomeGuideModal } from "@web/components/WelcomeModal/WelcomeGuideModal"; import { WelcomeModal } from "@web/components/WelcomeModal/WelcomeModal"; import { @@ -29,9 +24,6 @@ import { * available on every matched route, including 404s. */ export function RootShell() { - const isReleaseNotesPromptOpen = useReleaseNotesPromptStore( - selectReleaseNotesPromptOpen, - ); const isWelcomeGuideOpen = useWelcomeGuideStore(selectWelcomeGuideOpen); const { isExpired: isTrialExpired } = useTrialStatus(); useNavigationShortcuts(); @@ -60,7 +52,6 @@ export function RootShell() { {isWelcomeGuideOpen && } - {isReleaseNotesPromptOpen && } ); } diff --git a/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx b/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx index 5764b460c9..66e7bb0292 100644 --- a/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx +++ b/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx @@ -32,7 +32,7 @@ export const UpNextBanner: FC = () => { window.open(conferenceUrl, "_blank", "noopener,noreferrer"); // Keeps the banner mounted for one fade-out beat instead of yanking it - // instantly, matching ReleaseNotesPrompt's dismiss pattern. + // instantly. const dismiss = () => { beginDismiss(() => setDismissedId(upNext?._id)); }; diff --git a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx index 18279d6e67..75e76e4abc 100644 --- a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx +++ b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx @@ -7,7 +7,6 @@ import { track } from "@web/auth/posthog/track"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; import { onboardingTourActions } from "@web/components/OnboardingTour/onboarding.tour.store"; import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; -import { releaseNotesPromptActions } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store"; type CompleteAuthentication = ReturnType; @@ -32,7 +31,6 @@ export async function completeGoogleAuthCallback({ showErrorToast(result.message); } else if (result.isNewUser) { track("signup_completed", { method: "google" }); - releaseNotesPromptActions.scheduleOpen(); onboardingTourActions.offerAfterSignupIfPending(); } else { track("login_completed", { method: "google" }); diff --git a/self-host/docker-compose.test.ts b/self-host/docker-compose.test.ts index cc844ee0b7..111ba377b3 100644 --- a/self-host/docker-compose.test.ts +++ b/self-host/docker-compose.test.ts @@ -567,24 +567,6 @@ describe("staging deploy workflow", () => { ); }); - it("writes Kit email config whenever the deployment has a secret", () => { - const workflow = readRepoFile(".github/workflows/_deploy-environment.yml"); - - expect(workflow).toContain( - "KIT_API_SECRET: $".concat("{{ secrets.KIT_API_SECRET }}"), - ); - expect(workflow).toContain( - 'if [ "$'.concat('{{ inputs.environment }}" = "production" ]; then'), - ); - expect(workflow).toContain("Production deploy requires KIT_API_SECRET"); - expect(workflow).toContain('if [ -n "$KIT_API_SECRET" ]; then'); - expect(workflow).toContain("'email:'"); - expect(workflow).toContain( - 'kitApiSecret: \\"$'.concat('{KIT_API_SECRET}\\"'), - ); - expect(workflow).not.toContain("kitUserTagId"); - }); - it("runs deploy health checks after each staging deploy", () => { const workflow = readRepoFile(".github/workflows/deploy-staging.yml");