Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Notifications: first draft #13

Merged
merged 14 commits into from
Jan 3, 2024
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module.exports = {
"unicorn/filename-case": "off", // use default export name
"unicorn/prefer-top-level-await": "off", // unsupported in react-native
},
ignorePatterns: ["build/", "dist/", ".expo/types/**/*.ts", "expo-env.d.ts", "contracts/lib/"],
ignorePatterns: ["build/", "dist/", ".expo/types/**/*.ts", "expo-env.d.ts", "contracts/lib/", "public/"],
overrides: [
{
files: [...nodeFiles, "pomelo/**"],
Expand Down
9 changes: 9 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ExpoConfig } from "expo/config";
import type { PluginConfigType as BuildPropertiesConfig } from "expo-build-properties/build/pluginConfig";
import type { OneSignalPluginProps } from "onesignal-expo-plugin/types/types";

import metadata from "./package.json";

Expand Down Expand Up @@ -28,6 +29,14 @@ export default {
],
"expo-router",
"sentry-expo",
[
"onesignal-expo-plugin",
{
mode: "development",
jgalat marked this conversation as resolved.
Show resolved Hide resolved
smallIcons: ["./assets/notifications-default.png"],
largeIcons: ["./assets/notifications-default-large.png"],
} as OneSignalPluginProps,
jgalat marked this conversation as resolved.
Show resolved Hide resolved
cruzdanilo marked this conversation as resolved.
Show resolved Hide resolved
],
],
experiments: { typedRoutes: true },
hooks: {
Expand Down
5 changes: 4 additions & 1 deletion app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import tamaguiConfig from "../tamagui.config";
import alchemyConnector from "../utils/alchemyConnector";
import { alchemyAPIKey, chain } from "../utils/constants";
import handleError from "../utils/handleError";
import useOneSignal from "../utils/onesignal";

export { ErrorBoundary } from "expo-router";

Expand Down Expand Up @@ -64,7 +65,9 @@ export default function RootLayout() {
reconnect(wagmiConfig).catch(handleError);
}, []);

if (!loaded) return;
const initialized = useOneSignal({});

if (!loaded || !initialized) return;

return (
<>
Expand Down
Binary file added assets/notifications-default-large.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/notifications-default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified bun.lockb
Binary file not shown.
6 changes: 6 additions & 0 deletions metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,11 @@ module.exports = {
resolver: {
...defaultConfig.resolver,
sourceExts: [...(defaultConfig.resolver?.sourceExts ?? []), "mjs"],
blockList: [
...(Array.isArray(defaultConfig.resolver?.blockList)
? defaultConfig.resolver.blockList
: [defaultConfig.resolver?.blockList]),
/\/contracts\/lib\//,
],
},
};
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@alchemy/aa-core": "^1.2.0",
"@exactly/protocol": "^0.2.19",
"@expo/vector-icons": "^13.0.0",
"@onesignal/node-onesignal": "^2.0.1-beta2",
jgalat marked this conversation as resolved.
Show resolved Hide resolved
"@react-native-async-storage/async-storage": "1.18.2",
"@sentry/react-native": "^5.15.0",
"@tamagui/config": "^1.79.7",
Expand All @@ -51,14 +52,17 @@
"expo-system-ui": "~2.4.0",
"expo-web-browser": "~12.3.2",
"js-base64": "^3.7.5",
"onesignal-expo-plugin": "^2.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-native": "0.72.6",
"react-native-gesture-handler": "~2.12.0",
"react-native-get-random-values": "~1.9.0",
"react-native-onesignal": "^5.0.4",
"react-native-safe-area-context": "4.6.3",
"react-native-screens": "~3.22.0",
"react-native-web": "^0.19.9",
"react-onesignal": "^3.0.1",
"sentry-expo": "^7.1.1",
"tamagui": "^1.79.7",
"text-encoding": "^0.7.0",
Expand Down
51 changes: 51 additions & 0 deletions pomelo/api/transactions/v1/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";

import buffer from "../../../utils/buffer.js";
import { sendPushNotification } from "../../../utils/notifications.js";
import { captureException } from "../../../utils/sentry.js";
import { notificationRequest } from "../../../utils/types.js";
import { signResponse, verifySignature } from "../../../utils/verify.js";

export const runtime = "nodejs";

export const config = {
api: {
bodyParser: false,
},
};

export default async function notifications(request: VercelRequest, response: VercelResponse) {
if (request.method !== "POST") {
return response.status(405).end("method not allowed");
}

const buf = await buffer(request);
const raw = buf.toString("utf8");

if (!verifySignature(request, raw)) {
return response.status(403).end("forbidden");
}

const parsed = notificationRequest.safeParse(JSON.parse(raw));

if (parsed.success) {
const event = parsed.data.event_detail;
try {
await sendPushNotification({
userId: event.user.id,
headings: {
en: event.status,
},
contents: {
en: event.status_detail,
},
});
return signResponse(request, response.status(200), JSON.stringify(true));
} catch (error: unknown) {
captureException(error, { request, message: "failed to send notification to user" });
return response.status(500).end("internal server error");
}
} else {
return response.status(400).end("bad request");
}
}
6 changes: 4 additions & 2 deletions pomelo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
"node": ">=20.0.0"
},
"dependencies": {
"@onesignal/node-onesignal": "^2.0.1-beta2",
"@sentry/node": "^7.91.0",
"@vercel/node": "^3.0.11",
"@vercel/postgres": "^0.5.1",
"@wagmi/core": "2.0.0-beta.6",
"debug": "^4.3.4",
"drizzle-orm": "^0.29.1",
"viem": "2.0.0-beta.15",
"zod": "^3.22.4",
"debug": "^4.3.4"
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.4",
Expand Down
33 changes: 33 additions & 0 deletions pomelo/utils/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createConfiguration, DefaultApi, Notification as OSNotification } from "@onesignal/node-onesignal";

const APP_ID = process.env.ONE_SIGNAL_APP_ID;

const config = createConfiguration({
appKey: process.env.ONE_SIGNAL_API_KEY,
});

const client = new DefaultApi(config);

type Notification = {
userId: string;
headings: NonNullable<OSNotification["headings"]>;
contents: NonNullable<OSNotification["contents"]>;
};

export async function sendPushNotification({ userId, headings, contents }: Notification) {
if (!APP_ID) {
return;
}

const notification = new OSNotification();
notification.app_id = APP_ID;
notification.target_channel = "push";
notification.include_external_user_ids = [userId];

notification.headings = headings;
notification.contents = contents;

return client.createNotification(notification);
}

export default client;
25 changes: 25 additions & 0 deletions pomelo/utils/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as Sentry from "@sentry/node";
import type { VercelRequest } from "@vercel/node";

Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.ENV === "development" ? "development" : "production",
tracesSampleRate: 1,
attachStacktrace: true,
autoSessionTracking: true,
});

type ExceptionProperties = {
request: VercelRequest;
message: string;
};

export function captureException(error: unknown, { request: { url }, message }: ExceptionProperties) {
try {
Sentry.captureException(error, { tags: { url, message } });
} catch {
// ignore
}
}

export default Sentry;
71 changes: 51 additions & 20 deletions pomelo/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,27 +111,43 @@ const createCardRequest = z.intersection(

export type CreateCardRequest = z.infer<typeof createCardRequest>;

const transaction = z.object({
id: z.string().regex(/^ctx-.*/),
country_code: z.string(),
type: z.enum([
"PURCHASE",
"WITHDRAWAL",
"EXTRACASH",
"BALANCE_INQUIRY",
"REFUND",
"PAYMENT",
"REVERSAL_PURCHASE",
"REVERSAL_WITHDRAWAL",
"REVERSAL_EXTRACASH",
"REVERSAL_REFUND",
"REVERSAL_PAYMENT",
]),
point_type: z.enum(["POS", "ECOMMERCE", "ATM", "MOTO"]),
entry_mode: z.enum(["MANUAL", "CHIP", "CONTACTLESS", "CREDENTIAL_ON_FILE", "MAG_STRIPE", "OTHER", "UNKNOWN"]),
origin: z.enum(["DOMESTIC", "INTERNATIONAL"]),
source: z.string().optional(),
local_date_time: date,
original_transaction_id: z
.string()
.regex(/^ctx-.*/)
.nullish(),
});

const merchant = z.object({
id: z.string(),
mcc: z.string(),
address: z.string().nullish(),
name: z.string(),
});

export const authorizationRequest = z.object({
transaction: z.object({
id: z.string().regex(/^ctx-.*/),
country_code: z.string(),
type: z.string(),
point_type: z.string(),
entry_mode: z.string(),
origin: z.string(),
source: z.string().optional(),
local_date_time: date,
original_transaction_id: z
.string()
.regex(/^ctx-.*/)
.nullish(),
}),
merchant: z.object({
id: z.string(),
mcc: z.string(),
address: z.string().nullish(),
name: z.string(),
}),
transaction,
merchant,
card: card.pick({
id: true,
product_type: true,
Expand All @@ -156,6 +172,21 @@ export const authorizationRequest = z.object({

export type AuthorizationRequest = z.infer<typeof authorizationRequest>;

export const notificationRequest = z.object({
event_id: z.string(),
event_detail: z.object({
transaction,
merchant,
card: authorizationRequest.shape.card,
user: authorizationRequest.shape.user,
amount: authorizationRequest.shape.amount,
status: z.string(),
status_detail: z.string(),
extra_detail: z.string(),
}),
idempotency_key: z.string(),
});

const authorizationResponse = z.intersection(
z.object({
message: z.string(),
Expand Down
1 change: 1 addition & 0 deletions public/OneSignalSDKWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
importScripts("https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.sw.js");
1 change: 1 addition & 0 deletions utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const alchemyGasPolicyId = process.env.EXPO_PUBLIC_ALCHEMY_GAS_POLICY_ID;
export const turnkeyAPIPublicKey = process.env.EXPO_PUBLIC_TURNKEY_API_PUBLIC_KEY;
export const turnkeyAPIPrivateKey = process.env.EXPO_PUBLIC_TURNKEY_API_PRIVATE_KEY;
export const turnkeyOrganizationId = process.env.EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID;
export const oneSignalAPPId = process.env.EXPO_PUBLIC_ONE_SIGNAL_APP_ID;

export const chain = {
...goerli,
Expand Down
77 changes: 77 additions & 0 deletions utils/onesignal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useState, useEffect, useRef } from "react";
import { Platform } from "react-native";
import { OneSignal as RNOneSignal } from "react-native-onesignal";
import ROneSignal from "react-onesignal";

import { oneSignalAPPId } from "./constants";

type OneSignalProperties = {
userId?: string;
};

type Instance =
| {
type: "native";
value: typeof RNOneSignal;
}
| {
type: "web";
value: typeof ROneSignal;
};

export default function useOneSignal({ userId }: OneSignalProperties) {
const instance = useRef<Instance | null>(null);
const [initialized, setInitialized] = useState(false);

useEffect(() => {
const load = async function () {
if (!oneSignalAPPId) {
return;
}

if (!initialized) {
switch (Platform.OS) {
case "web": {
await ROneSignal.init({
appId: oneSignalAPPId,
allowLocalhostAsSecureOrigin: true,
});
instance.current = { type: "web", value: ROneSignal };
break;
}
case "ios":
case "android": {
RNOneSignal.initialize(oneSignalAPPId);
instance.current = { type: "native", value: RNOneSignal };
break;
}
}

setInitialized(true);
}

if (instance.current && userId) {
await instance.current.value.login(userId);
}
};

load().catch(() => {
setInitialized(true);
});

return () => {
if (!userId || !instance.current) {
return;
}

const logout = instance.current.value.logout();
if (logout instanceof Promise) {
logout.catch(() => {
// ignore
});
}
};
}, [userId, initialized]);

return initialized;
}