Skip to content

Commit 4aee402

Browse files
authored
feat: support OneSignal web push migration (#3956)
1 parent a553785 commit 4aee402

11 files changed

Lines changed: 562 additions & 88 deletions

File tree

.infra/Pulumi.prod.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,10 @@ config:
273273
secure: AAABAA85JTyvqc0H0ymWJ4UZ+btCk31WpH78pgaNOIiIRKW9jr6p7W1xBMzmCbO6vBfL90LeadptoNV9KQnaPnsmIVlQhTDEERY17PoQ
274274
emailTrackingOrigin:
275275
secure: AAABAErY3w6A5DQ0qsRNWLnAJZhLaLnMdqbimt6M5rZSYgAw6yNbtrlUJLeiHW/EQCDUhw==
276+
onesignalWebAppId:
277+
secure: AAABACoUzLQ6eGNpdjzl7sShD4BDSeSKJPtzdp9Ble0i3Mo3fh49v5dypv2VkcuXF2UH1dgaZlcCE6DhSBTrA/luWKc=
278+
onesignalWebApiKey:
279+
secure: AAABAPNWKCkjj4VNxfW9pxPksz2srCJuBaOfvxnyIRpnb/e7fBFUBIhTJjiZa8Kf2fAOFEqh1Xpioilpy6UrW//CFVHl5EEDtn+1U2K8Ig398ZbLwCYiJcsqj3m0NVnhs5tH/SUfHyiJBexftNuyAjE9KPW1UmyIeUpB4gTUMJG876JFVYqUY7a2XJFjGJ7a4Q==
276280
api:k8s:
277281
host: subs.daily.dev
278282
namespace: daily

__mocks__/isomorphic-dompurify.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1-
// Mock for isomorphic-dompurify to avoid ESM compatibility issues in Jest
2-
// When using dynamic import(), the module is accessed directly (not via .default)
3-
// because Jest's moduleNameMapper resolves to this file which exports these functions directly
1+
import { parse } from 'node-html-parser';
42

5-
export const sanitize = (html: string): string => {
6-
// Simple mock that returns the input - actual sanitization not needed in tests
7-
return html;
3+
type SanitizeConfig = {
4+
ALLOWED_TAGS?: string[];
5+
};
6+
7+
export const sanitize = (html: string, config?: SanitizeConfig): string => {
8+
if (config?.ALLOWED_TAGS?.length !== 0) {
9+
return html;
10+
}
11+
12+
const root = parse(html);
13+
root.querySelectorAll('script,style').forEach((node) => node.remove());
14+
return root.textContent
15+
.split('<script')
16+
.join('')
17+
.split('<style')
18+
.join('')
19+
.split('<')
20+
.join('')
21+
.split('>')
22+
.join('');
823
};
924

1025
export const addHook = (): void => {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { basicHtmlStrip } from '../../src/common/notificationUtils';
2+
3+
describe('notificationUtils', () => {
4+
describe('basicHtmlStrip', () => {
5+
it('should extract plain text without executable HTML', () => {
6+
expect(
7+
basicHtmlStrip(
8+
'<p>Hello <strong>daily.dev</strong></p><script>alert(1)</script><style>body { color: red; }</style><script',
9+
),
10+
).toEqual('Hello daily.dev');
11+
});
12+
});
13+
});

__tests__/webPush.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import {
2+
disposeGraphQLTesting,
3+
GraphQLTestClient,
4+
GraphQLTestingState,
5+
initializeGraphQLTesting,
6+
MockContext,
7+
saveFixtures,
8+
testMutationErrorCode,
9+
} from './helpers';
10+
import createOrGetConnection from '../src/db';
11+
import { DataSource } from 'typeorm';
12+
import { Context } from '../src/Context';
13+
import { User } from '../src/entity';
14+
import { usersFixture } from './fixture/user';
15+
import * as OneSignal from '@onesignal/node-onesignal';
16+
17+
const currentSubscriptionId = '11111111-1111-4111-8111-111111111111';
18+
const staleChromeSubscriptionId = '22222222-2222-4222-8222-222222222222';
19+
const staleFirefoxSubscriptionId = '33333333-3333-4333-8333-333333333333';
20+
const staleSafariLegacySubscriptionId = '55555555-5555-4555-8555-555555555555';
21+
const iosSubscriptionId = '44444444-4444-4444-8444-444444444444';
22+
23+
const MUTATION = /* GraphQL */ `
24+
mutation SyncWebPushSubscription($input: SyncWebPushSubscriptionInput!) {
25+
syncWebPushSubscription(input: $input) {
26+
cleanedUpSubscriptions
27+
}
28+
}
29+
`;
30+
31+
let con: DataSource;
32+
let state: GraphQLTestingState;
33+
let client: GraphQLTestClient;
34+
let loggedUser: string | undefined;
35+
let originalOneSignalAppId: string | undefined;
36+
let originalOneSignalApiKey: string | undefined;
37+
let originalOneSignalWebAppId: string | undefined;
38+
let originalOneSignalWebApiKey: string | undefined;
39+
40+
beforeAll(async () => {
41+
con = await createOrGetConnection();
42+
state = await initializeGraphQLTesting(
43+
() => new MockContext(con, loggedUser) as unknown as Context,
44+
);
45+
client = state.client;
46+
originalOneSignalAppId = process.env.ONESIGNAL_APP_ID;
47+
originalOneSignalApiKey = process.env.ONESIGNAL_API_KEY;
48+
originalOneSignalWebAppId = process.env.ONESIGNAL_WEB_APP_ID;
49+
originalOneSignalWebApiKey = process.env.ONESIGNAL_WEB_API_KEY;
50+
});
51+
52+
beforeEach(async () => {
53+
loggedUser = undefined;
54+
process.env.ONESIGNAL_APP_ID = '00000000-0000-4000-8000-000000000000';
55+
process.env.ONESIGNAL_API_KEY = 'test-key';
56+
process.env.ONESIGNAL_WEB_APP_ID = '99999999-9999-4999-8999-999999999999';
57+
process.env.ONESIGNAL_WEB_API_KEY = 'test-web-key';
58+
jest.restoreAllMocks();
59+
await saveFixtures(con, User, usersFixture);
60+
});
61+
62+
afterAll(async () => {
63+
if (originalOneSignalAppId) {
64+
process.env.ONESIGNAL_APP_ID = originalOneSignalAppId;
65+
} else {
66+
Reflect.deleteProperty(process.env, 'ONESIGNAL_APP_ID');
67+
}
68+
69+
if (originalOneSignalApiKey) {
70+
process.env.ONESIGNAL_API_KEY = originalOneSignalApiKey;
71+
} else {
72+
Reflect.deleteProperty(process.env, 'ONESIGNAL_API_KEY');
73+
}
74+
75+
if (originalOneSignalWebAppId) {
76+
process.env.ONESIGNAL_WEB_APP_ID = originalOneSignalWebAppId;
77+
} else {
78+
Reflect.deleteProperty(process.env, 'ONESIGNAL_WEB_APP_ID');
79+
}
80+
81+
if (originalOneSignalWebApiKey) {
82+
process.env.ONESIGNAL_WEB_API_KEY = originalOneSignalWebApiKey;
83+
} else {
84+
Reflect.deleteProperty(process.env, 'ONESIGNAL_WEB_API_KEY');
85+
}
86+
87+
await disposeGraphQLTesting(state);
88+
});
89+
90+
describe('mutation syncWebPushSubscription', () => {
91+
it('should not authorize when not logged in', () =>
92+
testMutationErrorCode(
93+
client,
94+
{
95+
mutation: MUTATION,
96+
variables: {
97+
input: {
98+
subscriptionId: currentSubscriptionId,
99+
origin: 'https://daily.dev',
100+
},
101+
},
102+
},
103+
'UNAUTHENTICATED',
104+
));
105+
106+
it('should delete stale web subscriptions only', async () => {
107+
loggedUser = '1';
108+
const fetchUserMock = jest
109+
.spyOn(OneSignal.DefaultApi.prototype, 'fetchUser')
110+
.mockResolvedValue({
111+
subscriptions: [
112+
{ id: staleChromeSubscriptionId, type: 'ChromePush' },
113+
{ id: staleFirefoxSubscriptionId, type: 'FirefoxPush' },
114+
{ id: staleSafariLegacySubscriptionId, type: 'SafariLegacyPush' },
115+
{ id: iosSubscriptionId, type: 'iOSPush' },
116+
],
117+
} as OneSignal.User);
118+
const deleteSubscriptionMock = jest
119+
.spyOn(OneSignal.DefaultApi.prototype, 'deleteSubscription')
120+
.mockResolvedValue(undefined);
121+
122+
const res = await client.mutate(MUTATION, {
123+
variables: {
124+
input: {
125+
subscriptionId: currentSubscriptionId,
126+
origin: 'daily.dev',
127+
},
128+
},
129+
});
130+
131+
expect(res.errors).toBeUndefined();
132+
expect(res.data).toEqual({
133+
syncWebPushSubscription: {
134+
cleanedUpSubscriptions: 3,
135+
},
136+
});
137+
138+
expect(fetchUserMock).toHaveBeenCalledWith(
139+
'00000000-0000-4000-8000-000000000000',
140+
'external_id',
141+
'1',
142+
);
143+
expect(deleteSubscriptionMock.mock.calls.sort()).toEqual([
144+
['00000000-0000-4000-8000-000000000000', staleChromeSubscriptionId],
145+
['00000000-0000-4000-8000-000000000000', staleFirefoxSubscriptionId],
146+
['00000000-0000-4000-8000-000000000000', staleSafariLegacySubscriptionId],
147+
]);
148+
});
149+
150+
it('should not call OneSignal cleanup when the subscription is opted out', async () => {
151+
loggedUser = '1';
152+
const fetchUserMock = jest.spyOn(
153+
OneSignal.DefaultApi.prototype,
154+
'fetchUser',
155+
);
156+
157+
const res = await client.mutate(MUTATION, {
158+
variables: {
159+
input: {
160+
subscriptionId: currentSubscriptionId,
161+
origin: 'https://daily.dev',
162+
optedIn: false,
163+
},
164+
},
165+
});
166+
167+
expect(res.errors).toBeUndefined();
168+
expect(res.data).toEqual({
169+
syncWebPushSubscription: {
170+
cleanedUpSubscriptions: 0,
171+
},
172+
});
173+
174+
expect(fetchUserMock).not.toHaveBeenCalled();
175+
});
176+
});

src/common/mailing.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import { GetUsersActiveState } from './googleCloud';
3030
import { logger } from '../logger';
3131
import { notificationFlagsSchema } from './schema/notificationFlagsSchema';
3232
import { DEFAULT_NOTIFICATION_SETTINGS } from '../notifications/common';
33+
export {
34+
addNotificationEmailUtm,
35+
addNotificationUtm,
36+
basicHtmlStrip,
37+
} from './notificationUtils';
3338

3439
export enum CioTransactionalMessageTemplateId {
3540
VerifyCompany = '51',
@@ -48,25 +53,6 @@ export enum CioTransactionalMessageTemplateId {
4853

4954
export const cioApi = new APIClient(process.env.CIO_APP_KEY);
5055

51-
export const addNotificationUtm = (
52-
url: string,
53-
medium: string,
54-
notificationType: string,
55-
): string => {
56-
const urlObj = new URL(url);
57-
urlObj.searchParams.append('utm_source', 'notification');
58-
urlObj.searchParams.append('utm_medium', medium);
59-
urlObj.searchParams.append('utm_campaign', notificationType);
60-
return urlObj.toString();
61-
};
62-
63-
export const addNotificationEmailUtm = (
64-
url: string,
65-
notificationType: string,
66-
): string => addNotificationUtm(url, 'email', notificationType);
67-
68-
export const basicHtmlStrip = (html: string) => html.replace(/<[^>]*>?/gm, '');
69-
7056
export const getFirstName = (name: string): string =>
7157
name?.split?.(' ')?.[0] ?? '';
7258

src/common/notificationUtils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import DOMPurify from 'isomorphic-dompurify';
2+
3+
export const addNotificationUtm = (
4+
url: string,
5+
medium: string,
6+
notificationType: string,
7+
): string => {
8+
const urlObj = new URL(url);
9+
urlObj.searchParams.append('utm_source', 'notification');
10+
urlObj.searchParams.append('utm_medium', medium);
11+
urlObj.searchParams.append('utm_campaign', notificationType);
12+
return urlObj.toString();
13+
};
14+
15+
export const addNotificationEmailUtm = (
16+
url: string,
17+
notificationType: string,
18+
): string => addNotificationUtm(url, 'email', notificationType);
19+
20+
export const basicHtmlStrip = (html: string): string => {
21+
return DOMPurify.sanitize(html, {
22+
ALLOWED_TAGS: [],
23+
ALLOWED_ATTR: [],
24+
});
25+
};

src/common/schema/webPush.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import z from 'zod';
2+
3+
const normalizeOrigin = (origin: string): string => {
4+
const normalized = origin.includes('://') ? origin : `https://${origin}`;
5+
return new URL(normalized).origin;
6+
};
7+
8+
export const syncWebPushSubscriptionSchema = z.object({
9+
subscriptionId: z.uuid().optional(),
10+
origin: z
11+
.string()
12+
.min(1)
13+
.transform((origin, ctx) => {
14+
try {
15+
return normalizeOrigin(origin);
16+
} catch {
17+
ctx.addIssue({
18+
code: 'custom',
19+
message: 'Invalid origin',
20+
});
21+
return z.NEVER;
22+
}
23+
})
24+
.optional(),
25+
optedIn: z.boolean().default(true),
26+
});
27+
28+
export type SyncWebPushSubscriptionInput = z.infer<
29+
typeof syncWebPushSubscriptionSchema
30+
>;

src/graphql.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import * as userHotTake from './schema/userHotTake';
4040
import * as gear from './schema/gear';
4141
import * as userWorkspacePhoto from './schema/userWorkspacePhoto';
4242
import * as personalAccessTokens from './schema/personalAccessTokens';
43+
import * as webPush from './schema/webPush';
4344
import * as feedback from './schema/feedback';
4445
import * as sentiment from './schema/sentiment';
4546
import * as achievements from './schema/achievements';
@@ -107,6 +108,7 @@ export const schema = urlDirective.transformer(
107108
gear.typeDefs,
108109
userWorkspacePhoto.typeDefs,
109110
personalAccessTokens.typeDefs,
111+
webPush.typeDefs,
110112
feedback.typeDefs,
111113
sentiment.typeDefs,
112114
achievements.typeDefs,
@@ -155,6 +157,7 @@ export const schema = urlDirective.transformer(
155157
gear.resolvers,
156158
userWorkspacePhoto.resolvers,
157159
personalAccessTokens.resolvers,
160+
webPush.resolvers,
158161
feedback.resolvers,
159162
sentiment.resolvers,
160163
achievements.resolvers,

0 commit comments

Comments
 (0)