merge/hanzo (#35)

This commit is contained in:
Zhigang Fang
2020-04-27 09:40:14 +08:00
committed by GitHub
parent 6f51ea4b3e
commit 04eae89f72
31 changed files with 962 additions and 510 deletions

View File

@@ -1,67 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.rndiffapp;
import android.content.Context;
import com.facebook.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new ReactFlipperPlugin());
client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
NetworkingModule.setCustomClientBuilder(
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
// Hence we run if after all native modules have been initialized
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
if (reactContext == null) {
reactInstanceManager.addReactInstanceEventListener(
new ReactInstanceManager.ReactInstanceEventListener() {
@Override
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
new Runnable() {
@Override
public void run() {
client.addPlugin(new FrescoFlipperPlugin());
}
});
}
});
} else {
client.addPlugin(new FrescoFlipperPlugin());
}
}
}
}

View File

@@ -7,6 +7,7 @@ import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { StatusBar } from "react-native";
import ErrorBoundary from "react-native-error-boundary";
import crashlytics from "@react-native-firebase/crashlytics";
import { SafeAreaProvider } from "react-native-safe-area-context";
const App = () => (
<ErrorBoundary
@@ -18,7 +19,9 @@ const App = () => (
<StatusBar barStyle="dark-content" backgroundColor="white" />
<PaperProvider theme={theme}>
<ActionSheetProvider>
<Routes />
<SafeAreaProvider>
<Routes />
</SafeAreaProvider>
</ActionSheetProvider>
</PaperProvider>
</ErrorBoundary>

View File

@@ -0,0 +1,73 @@
import { Alert, TextInputProps } from "react-native";
import React, { useCallback, useState } from "react";
import { Button, Dialog, Portal, TextInput } from "react-native-paper";
const useModalInput = ({
title,
...props
}: { title: string } & TextInputProps) => {
const [onResult, setOnResult] = useState<
(result: string) => void | Promise<void>
>();
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
const element = (
<Portal>
<Dialog
css={`
margin-top: -200px;
`}
visible={onResult != null}
onDismiss={() => setOnResult(undefined)}
>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Content>
<TextInput
autoFocus={true}
dense={true}
value={text}
onChangeText={setText}
{...props}
/>
</Dialog.Content>
<Dialog.Actions>
<Button
loading={loading}
disabled={!text}
onPress={async () => {
const result = onResult?.(text);
if (result == null) {
setOnResult(undefined);
return;
}
try {
setLoading(true);
await result;
setOnResult(undefined);
} catch (e) {
Alert.alert(e.message);
} finally {
setLoading(false);
}
}}
>
Done
</Button>
</Dialog.Actions>
</Dialog>
</Portal>
);
const getInput = useCallback(
(action: typeof onResult) => {
setText("");
setOnResult(() => action);
},
[setOnResult, setText, onResult]
);
return {
getInput,
element,
};
};
export default useModalInput;

View File

@@ -12,6 +12,7 @@ export interface ActionSheetOptions {
message?: string;
tintColor?: string;
anchor?: number;
defaultCancel?: boolean;
}
export const useActionSheet = () => {
@@ -21,14 +22,18 @@ export const useActionSheet = () => {
showActionSheetWithOptions(
{
...options,
options: items.map((i) => i.title),
cancelButtonIndex: items.findIndex((i) => i.type === "cancel"),
options: items
.map((i) => i.title)
.concat(options.defaultCancel ? ["Cancel"] : []),
cancelButtonIndex: options.defaultCancel
? items.length
: items.findIndex((i) => i.type === "cancel"),
destructiveButtonIndex: items.findIndex(
(i) => i.type === "destructive"
),
},
(i) => {
items[i].onPress?.();
items[i]?.onPress?.();
}
);
},

View File

@@ -1,7 +1,12 @@
import firestore, {
FirebaseFirestoreTypes,
} from "@react-native-firebase/firestore";
import { CollectionReference, DocumentReference } from "./firestoreHooks";
import {
CollectionReference,
DocumentReference,
LoadingErrorState,
useListenDocument,
} from "./firestoreHooks";
type GetOptions = FirebaseFirestoreTypes.GetOptions;
@@ -10,6 +15,7 @@ export const collection = (collectionId: string): CollectionReference => {
};
type DocTypedWrapper<T> = {
useListen: () => LoadingErrorState<T>;
read: (options?: GetOptions) => Promise<T>;
update: (value: Partial<T>) => Promise<void>;
listen: (callback: (value: T) => void) => () => void;
@@ -17,26 +23,35 @@ type DocTypedWrapper<T> = {
};
export function makeDocAsType<T>(
doc: () => DocumentReference
docGen: () => DocumentReference
): DocTypedWrapper<T> {
let doc: DocumentReference;
const lazyDoc = () => {
doc = doc ?? docGen();
return doc;
};
async function read(options?: GetOptions) {
const snapshot = await doc().get(options);
const snapshot = await lazyDoc().get(options);
const value: T = snapshot.data() as any;
if (!snapshot.exists || value == null) {
throw new Error(`Doc ${doc().path} does not exist`);
throw new Error(`Doc ${lazyDoc().path} does not exist`);
}
return value;
}
async function update(newValue: Partial<T>) {
return await doc().set(newValue, { merge: true });
return await lazyDoc().set(newValue, { merge: true });
}
function listen(callback: (value: T) => void) {
return doc().onSnapshot((snapshot) => {
return lazyDoc().onSnapshot((snapshot) => {
callback(snapshot.data() as any);
});
}
function useListen() {
return useListenDocument<T>(lazyDoc());
}
return {
ref: doc,
ref: lazyDoc,
useListen,
read,
update,
listen,

View File

@@ -15,6 +15,7 @@ export interface Profile {
name: string;
avatar: string;
email?: string;
onboardingCompleted?: number;
}
// Private profile only you can see or update
@@ -28,12 +29,14 @@ export interface PrivateProfile {
pushTokens: {
[deviceId: string]: string;
};
defaultPayment?: PaymentMethod;
}
// System record only you can see, but not mutate
export interface ReadonlyProfile {
accountBalance: number;
behaviorScore: number;
stripeCustomerId?: string;
}
// Chat Related
@@ -63,3 +66,15 @@ export interface UserStatus {
conversationId?: string;
isTyping?: boolean;
}
export interface AddPaymentMethodInput {
stripeToken: string;
}
export interface PaymentMethod {
id: string;
last4: string;
brand: string;
exp_year: number;
exp_month: number;
}

View File

@@ -1,7 +1,13 @@
import auth from "@react-native-firebase/auth";
import { FirebaseFirestoreTypes } from "@react-native-firebase/firestore";
import firestore, {
FirebaseFirestoreTypes,
} from "@react-native-firebase/firestore";
import { PrivateProfile, Profile, ReadonlyProfile } from "./types";
import { collection, makeDocAsType } from "./firebase/firestore";
import functions from "@react-native-firebase/functions";
import { useEffect, useState } from "react";
import { keyOf } from "./firebase/firestoreHooks";
import DeviceInfo from "react-native-device-info";
type DocumentSnapshot = FirebaseFirestoreTypes.DocumentSnapshot;
@@ -31,7 +37,7 @@ export const typedReadonlyProfile = makeDocAsType<ReadonlyProfile>(() =>
export const userFinishedSignUp = async () => {
const valid = (snapshot: Profile) => {
return snapshot.name && snapshot.avatar;
return snapshot.onboardingCompleted! > 0;
};
const cached = await typedProfile.read({ source: "cache" }).catch(() => null);
if (cached != null && valid(cached)) {
@@ -42,3 +48,31 @@ export const userFinishedSignUp = async () => {
.catch(() => null);
return server != null && valid(server);
};
export const promoteToAdmin = async (password: string) => {
await functions().httpsCallable("user-promoteToAdmin")({ password });
await auth().currentUser?.getIdToken(true);
};
export const useIsAdmin = () => {
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
auth()
.currentUser!.getIdTokenResult()
.then((value) => setIsAdmin(value.claims.isAdmin));
}, []);
return isAdmin;
};
export const logout = async () => {
try {
await typedPrivateProfile
.ref()
.update(
keyOf<PrivateProfile>("pushTokens") + "." + DeviceInfo.getUniqueId(),
firestore.FieldValue.delete()
);
} finally {
await auth().signOut();
}
};

View File

@@ -34,3 +34,7 @@ export const useKeyboardManagerOnFocus = (enable: boolean) => {
export function compose<A, B, C>(l: (a: A) => B, r: (b: B) => C): (a: A) => C {
return (a) => r(l(a));
}
export function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}

View File

@@ -5,6 +5,7 @@ import Home from "./home";
import { trackScreenNavigation } from "../functions/analytics";
import auth from "@react-native-firebase/auth";
import { userFinishedSignUp } from "../functions/user";
import { navigationTheme } from "../styles/theme";
export const AppRouteContext = createContext<{
resetRoute?: () => void;
@@ -45,6 +46,7 @@ const Routes = () => {
<NavigationContainer
key={isLoggedIn}
onStateChange={trackScreenNavigation}
theme={navigationTheme}
>
{isLoggedIn === "home" ? <Home /> : <OnBoarding />}
</NavigationContainer>

View File

@@ -14,6 +14,7 @@ import { useAppLaunchAfterLogin } from "../../functions/app";
import ChatDetailPage, { ChatDetailPageParams } from "../chat/ChatDetailPage";
import ChatListPage from "../chat/ChatListPage";
import ChatContactListPage from "../chat/ChatContactListPage";
import OnboardingProfile from "../onboarding/OnboardingProfile";
type BottomTabParams = {
homePage: undefined;
@@ -61,6 +62,9 @@ export type HomeNavStackParams = {
homeTab: undefined;
chatDetail: ChatDetailPageParams;
chatContactList: undefined;
editProfile: {
edit: true;
};
};
const HomeNavStack = createStackNavigator<HomeNavStackParams>();
@@ -95,6 +99,7 @@ const HomeNav = () => {
component={ChatContactListPage}
options={{ title: "Contacts" }}
/>
<HomeNavStack.Screen name="editProfile" component={OnboardingProfile} />
</HomeNavStack.Navigator>
);
};

View File

@@ -10,12 +10,16 @@ import { useListenDocument } from "../../functions/firebase/firestoreHooks";
import { currentUser, typedProfile } from "../../functions/user";
import { Profile } from "../../functions/types";
import { ActivityIndicator, TextInput } from "react-native-paper";
import { StackNavigationProp } from "@react-navigation/stack";
import { OnboardingStackParams } from "./index";
import { RouteProp, useRoute } from "@react-navigation/core";
import { HomeNavStackParams } from "../home";
import { AppRouteContext } from "../Routes";
const AvatarButton = styled.TouchableOpacity`
margin-top: 10px;
width: 120px;
height: 120px;
width: 100px;
height: 100px;
border-radius: 60px;
align-self: center;
background-color: #cccccc;
@@ -34,9 +38,15 @@ const Avatar = styled(FastImage)`
border-radius: 60px;
`;
const OnboardingProfile = () => {
const { resetRoute } = useContext(AppRouteContext);
const [nameInput, setNameInput] = useState("");
const OnboardingProfile = ({
navigation,
}: {
navigation: StackNavigationProp<OnboardingStackParams>;
}) => {
const route = useRoute<RouteProp<HomeNavStackParams, "editProfile">>();
const isEditing = route.params?.edit;
const [nameInput, setNameInput] = useState<string>();
const [emailInput, setEmailInput] = useState<string>();
const [saving, setSaving] = useState(false);
const { value, update } = useListenDocument<Profile>(typedProfile.ref());
const {
@@ -46,35 +56,60 @@ const OnboardingProfile = () => {
serverImage,
} = usePickAndUploadImage();
const avatar = serverImage || value?.avatar || currentUser().photoURL;
const name = nameInput || value?.name || currentUser().displayName;
const name = nameInput ?? value?.name ?? currentUser().displayName ?? "";
const email = emailInput ?? value?.email ?? currentUser().email ?? "";
const { resetRoute } = useContext(AppRouteContext);
return (
<PageContainer>
<BigTitle>Choose your{"\n"}name and avatar</BigTitle>
{!isEditing && <BigTitle>Choose your{"\n"}name and avatar</BigTitle>}
<AvatarButton onPress={pick}>
{avatar && (
<Avatar
source={{
uri: localImage || avatar,
}}
/>
)}
<Avatar
source={{
uri: localImage || avatar || "",
}}
/>
{isUploading && <ActivityIndicator />}
</AvatarButton>
<TextInput
autoCapitalize="words"
mode="outlined"
label="Name"
value={name || ""}
value={name}
onChangeText={setNameInput}
/>
<TextInput
css={`
margin: 10px 0;
`}
autoCapitalize="none"
keyboardType="email-address"
mode="outlined"
label="Email"
value={email}
onChangeText={setEmailInput}
/>
<BigButton
loading={saving}
disabled={isUploading || avatar == null || !name}
disabled={isUploading || !avatar || !name || !email}
onPress={async () => {
try {
if (!validateEmail(email)) {
Alert.alert("Please input a valid email address");
return;
}
setSaving(true);
await update({ avatar: avatar!, name: name! });
await update({
avatar: avatar!,
name,
email,
onboardingCompleted: 1,
});
setSaving(false);
resetRoute?.();
if (!isEditing) {
resetRoute?.();
} else {
navigation.goBack();
}
} catch (e) {
Alert.alert(e.message);
setSaving(false);
@@ -87,4 +122,10 @@ const OnboardingProfile = () => {
);
};
function validateEmail(email: string) {
// eslint-disable-next-line no-useless-escape
const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
export default OnboardingProfile;

View File

@@ -1,7 +1,12 @@
import styled from "styled-components/native";
import React from "react";
import React, { useContext } from "react";
import LogoutButton from "./components/LogoutButton";
import { code, hash } from "../../../version.json";
import { Text } from "react-native";
import { useActionSheet } from "../../functions/actionsheet";
import { logout, promoteToAdmin, typedProfile } from "../../functions/user";
import useModalInput from "../../components/useModalInput";
import { AppRouteContext } from "../Routes";
const Container = styled.View`
flex: 1;
@@ -9,18 +14,57 @@ const Container = styled.View`
padding: 20px;
`;
const Version = styled.Text`
text-align: center;
color: lightgray;
`;
export const Version = () => {
const showActionSheet = useActionSheet();
const { resetRoute } = useContext(AppRouteContext);
const { getInput, element } = useModalInput({
title: "Password",
autoCapitalize: "none",
placeholder: "Aa123456",
});
return (
<>
{element}
<Text
css={`
text-align: center;
color: lightgray;
`}
onLongPress={() => {
showActionSheet(
[
{
title: "Clear onboarding flat",
onPress: async () => {
await typedProfile.update({ onboardingCompleted: 0 });
await logout();
resetRoute?.();
},
},
{
title: "Promote to admin",
onPress: () => {
getInput(async (result) => {
await promoteToAdmin(result);
});
},
},
],
{ defaultCancel: true }
);
}}
>
{hash.slice(0, 7)}-{code}
</Text>
</>
);
};
const UserPage = () => {
return (
<Container>
<LogoutButton />
<Version>
{hash.slice(0, 7)}-{code}
</Version>
<Version />
</Container>
);
};

View File

@@ -1,46 +1,39 @@
import React, { useContext } from "react";
import { AppRouteContext } from "../../Routes";
import { BigButton } from "../../../components/Button";
import auth from "@react-native-firebase/auth";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { typedPrivateProfile } from "../../../functions/user";
import { keyOf } from "../../../functions/firebase/firestoreHooks";
import { PrivateProfile } from "../../../functions/types";
import DeviceInfo from "react-native-device-info";
import firestore from "@react-native-firebase/firestore";
import { logout } from "../../../functions/user";
import { useActionSheet } from "../../../functions/actionsheet";
import { StyleProp, ViewStyle } from "react-native";
const LogoutButton = () => {
const LogoutButton = (props: { style?: StyleProp<ViewStyle> }) => {
const { resetRoute } = useContext(AppRouteContext);
const { showActionSheetWithOptions } = useActionSheet();
const showActionSheetWithOptions = useActionSheet();
return (
<BigButton
style={props.style}
onPress={() => {
showActionSheetWithOptions(
[
{
title: "Logout",
type: "destructive",
onPress: async () => {
await logout();
resetRoute?.();
},
},
{
type: "cancel",
title: "Cancel",
},
],
{
title: "Are you sure?",
options: ["Logout", "Cancel"],
destructiveButtonIndex: 0,
cancelButtonIndex: 1,
},
async (i) => {
if (i === 1) {
return;
}
await typedPrivateProfile
.ref()
.update(
keyOf<PrivateProfile>("pushTokens") +
"." +
DeviceInfo.getUniqueId(),
firestore.FieldValue.delete()
);
await auth().signOut();
resetRoute?.();
}
);
}}
>
Logout
Sign Out
</BigButton>
);
};

View File

@@ -1,4 +1,5 @@
import { DefaultTheme, Theme } from "react-native-paper";
import { DefaultTheme as NavigationTheme } from "@react-navigation/native";
const theme: Theme = {
...DefaultTheme,
@@ -8,3 +9,12 @@ const theme: Theme = {
};
export default theme;
export const navigationTheme = {
...NavigationTheme,
colors: {
...NavigationTheme.colors,
background: "white",
tintColor: "#752aff",
},
};

View File

@@ -0,0 +1,22 @@
import stripe from "tipsi-stripe";
import * as config from "../app.json";
import functions from "@react-native-firebase/functions";
import { AddPaymentMethodInput } from "../booster/functions/types";
import { Alert } from "react-native";
export const addPaymentFromStripe = async () => {
stripe.setOptions({
publishableKey: config.stripePublishKeys,
});
try {
const { tokenId } = await stripe.paymentRequestWithCardForm({} as any);
await functions().httpsCallable("payments-addPayment")({
stripeToken: tokenId,
} as AddPaymentMethodInput);
} catch (e) {
if (e.code === "cancelled") {
return;
}
Alert.alert(e.message);
}
};

View File

@@ -0,0 +1,19 @@
import { Alert } from "react-native";
import { useState } from "react";
export const useOnPromise = (promise: () => Promise<any>) => {
const [loading, setLoading] = useState(false);
return {
loading,
onPress: async () => {
try {
setLoading(true);
await promise();
setLoading(false);
} catch (e) {
Alert.alert(e.message);
setLoading(false);
}
},
};
};

View File

@@ -11,7 +11,7 @@ service cloud.firestore {
return userLoggedIn() && request.auth.uid in userIds
}
function isAdmin() {
return false
return userLoggedIn() && request.auth.token.isAdmin == true;
}
// User profiles

View File

@@ -1,4 +1,5 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node"
testEnvironment: "node",
setupFilesAfterEnv: ["./jest.setup.js"],
};

8
functions/jest.setup.js Normal file
View File

@@ -0,0 +1,8 @@
const admin = require("firebase-admin");
const adminSDK = require("./src/adminSDK.json");
admin.initializeApp({
credential: admin.credential.cert(adminSDK),
});
jest.setTimeout(30 * 1000);

View File

@@ -17,11 +17,13 @@
"main": "lib/index.js",
"dependencies": {
"@slack/webhook": "^5.0.3",
"date-fns": "^2.12.0",
"firebase-admin": "^8.9.0",
"firebase-functions": "^3.3.0",
"md5": "^2.2.1",
"node-fetch": "^2.6.0",
"sharp": "^0.25.2"
"sharp": "^0.25.2",
"stripe": "^8.44.0"
},
"devDependencies": {
"@types/jest": "^25.1.3",
@@ -33,9 +35,9 @@
"@typescript-eslint/parser": "^2.14.0",
"eslint": "^6.5.1",
"eslint-config-typescript": "^3.0.0",
"jest": "^25.1.0",
"ts-jest": "^25.2.1",
"typescript": "^3.7.2"
"jest": "^25.3.0",
"ts-jest": "^25.4.0",
"typescript": "^3.8.3"
},
"engines": {
"node": "10"

View File

@@ -3,12 +3,12 @@ import { firestore } from "firebase-functions";
import * as admin from "firebase-admin";
import { Conversation, Message, UserStatus } from "../types";
import { sendNotificationsTo } from "./utils/notifications";
import { assertAuth, assertString, now } from "./utils/utils";
import { assertNotNull, assertString, now } from "./utils/utils";
import { useUserProfile } from "./utils/profiles";
export const startConversation = functions.https.onCall(
async (data, context) => {
assertAuth(context.auth);
assertNotNull(context.auth);
assertString(data.target);
const conversation: Conversation = {
@@ -18,16 +18,13 @@ export const startConversation = functions.https.onCall(
userIds: [context.auth.uid, data.target],
users: {
[context.auth.uid]: await useUserProfile(context.auth.uid).read(),
[data.target]: await useUserProfile(data.target).read()
[data.target]: await useUserProfile(data.target).read(),
},
available: true
available: true,
};
const added = await admin
.firestore()
.collection("chats")
.add(conversation);
const added = await admin.firestore().collection("chats").add(conversation);
return {
id: added.id
id: added.id,
};
}
);
@@ -40,26 +37,20 @@ export const onMessageCreate = firestore
const update: Partial<Conversation> = {
updatedAt: message.createdAt,
lastMessage: message
lastMessage: message,
};
const chatRef = admin
.firestore()
.collection("chats")
.doc(chatId);
const chatRef = admin.firestore().collection("chats").doc(chatId);
await chatRef.update(update);
const chat: Conversation = (await chatRef.get()).data() as any;
for (const targetUserId of chat.userIds.filter(
id => id !== message.createdBy
(id) => id !== message.createdBy
)) {
const userStatus: UserStatus = (
await admin
.database()
.ref(`userStatus/${targetUserId}`)
.once("value")
await admin.database().ref(`userStatus/${targetUserId}`).once("value")
).val();
if (
userStatus &&
@@ -81,12 +72,12 @@ export const onMessageCreate = firestore
0
);
await conversationCountsRef.update({
[chatId]: (existingCounts[chatId] ?? 0) + 1
[chatId]: (existingCounts[chatId] ?? 0) + 1,
});
await sendNotificationsTo(targetUserId, {
title: message.user.name,
body: message.content,
badge: currentUnreadCount + 1
badge: currentUnreadCount + 1,
});
}
});

View File

@@ -0,0 +1,35 @@
import { _addPayments, _deletePayment, _pay, _requestRefund } from "./payment";
import { collection } from "../utils/firestore";
const testUser = "testUser";
it("should payments", async function () {
await _addPayments("tok_visa", testUser);
const payments = await collection("userReadonlyProfiles")
.doc(testUser)
.collection("payments")
.get();
console.log(payments.docs.map((p) => p.data()));
await _deletePayment(payments.docs[0].id, testUser);
});
it("should be able to charge", async function () {
const payments = await collection("userReadonlyProfiles")
.doc(testUser)
.collection("payments")
.get();
const paymentId = payments.docs[0].get("id");
console.log(
await _pay({
uid: testUser,
description: "Unit Test",
paymentId,
amountInCents: 50,
})
);
});
it("should be able to refund", async function () {
await _requestRefund("ch_1GaMS3E5qqQ2Pr0jBOBWq6NH");
});

View File

@@ -0,0 +1,97 @@
import Stripe from "stripe";
import { firestore, https } from "firebase-functions";
import { HttpsError } from "firebase-functions/lib/providers/https";
import {
usePayments,
usePrivateProfile,
useReadonlyProfile,
} from "../utils/profiles";
import { AddPaymentMethodInput } from "../../types";
import * as admin from "firebase-admin";
import { assertNotNull } from "../utils/utils";
import * as configs from "../../server.json";
const stripe = new Stripe(configs.stripeKey, {
apiVersion: "2020-03-02",
});
async function createCustomer(asUser: string): Promise<string> {
const customer = await stripe.customers.create({
description: asUser,
});
await useReadonlyProfile(asUser).update({
stripeCustomerId: customer.id,
});
return customer.id;
}
export const _addPayments = async (token: string, asUser: string) => {
const profile = await useReadonlyProfile(asUser).read();
const stripeCustomerId =
profile.stripeCustomerId ?? (await createCustomer(asUser));
const source = await stripe.customers.createSource(stripeCustomerId, {
source: token,
});
await usePayments(asUser, source.id).update({
...source,
addedAt: admin.firestore.FieldValue.serverTimestamp(),
});
await usePrivateProfile(asUser).update({ defaultPayment: source as any });
};
export const addPayment = https.onCall(
async (data: AddPaymentMethodInput, context) => {
assertNotNull(context.auth);
console.log(
`User ${
context.auth.uid
} add payment method with token starts with ${data.stripeToken.slice(
0,
5
)}`
);
await _addPayments(data.stripeToken, context.auth.uid);
}
);
export const _deletePayment = async (paymentId: string, asUser: string) => {
const profile = await useReadonlyProfile(asUser).read();
const privateProfile = await usePrivateProfile(asUser).read();
if (profile.stripeCustomerId == null) {
throw new HttpsError(
"failed-precondition",
"User does not have customer id associated"
);
}
await stripe.customers.deleteSource(profile.stripeCustomerId, paymentId);
if (privateProfile.defaultPayment?.id === paymentId) {
await usePrivateProfile(asUser).update({ defaultPayment: undefined });
}
};
export const onDeletePayment = firestore
.document("/userReadonlyProfiles/{userId}/payments/{paymentId}")
.onDelete(async (snapshot, context) => {
const { paymentId, userId } = context.params;
console.log(`User ${userId} deleted source ${paymentId}`);
await _deletePayment(paymentId, userId);
});
export const _pay = async (props: {
uid: string;
paymentId: string;
amountInCents: number;
description: string;
}) => {
return await stripe.charges.create({
amount: props.amountInCents,
currency: "usd",
customer: (await useReadonlyProfile(props.uid).read()).stripeCustomerId,
source: props.paymentId,
description: props.description,
});
};
export const _requestRefund = async (chargeId: string) => {
return await stripe.refunds.create({ charge: chargeId });
};

View File

@@ -2,10 +2,14 @@ import * as functions from "firebase-functions";
import {
usePrivateProfile,
useReadonlyProfile,
useUserProfile
useUserProfile,
} from "./utils/profiles";
import { promoteToAdminPassword } from "../server.json";
import { assertNotNull, assertString } from "./utils/utils";
import { HttpsError } from "firebase-functions/lib/providers/https";
import * as admin from "firebase-admin";
export const onUserCreate = functions.auth.user().onCreate(async user => {
export const onUserCreate = functions.auth.user().onCreate(async (user) => {
console.log(
`User created: ${user.uid} from ${JSON.stringify(user.providerData)}`
);
@@ -13,22 +17,34 @@ export const onUserCreate = functions.auth.user().onCreate(async user => {
await useUserProfile(user.uid).update({
name: user.displayName,
avatar: user.photoURL,
email: user.email
email: user.email,
});
await usePrivateProfile(user.uid).update({
phone: user.phoneNumber
phone: user.phoneNumber,
});
await useReadonlyProfile(user.uid).update({
accountBalance: 0,
behaviorScore: 0
behaviorScore: 0,
});
});
export const onUserDelete = functions.auth.user().onDelete(async user => {
export const onUserDelete = functions.auth.user().onDelete(async (user) => {
// Clean up user
await useUserProfile(user.uid).delete();
await usePrivateProfile(user.uid).delete();
await useReadonlyProfile(user.uid).delete();
});
export const promoteToAdmin = functions.https.onCall(async (data, context) => {
assertNotNull(context.auth);
const { password } = data;
assertString(password);
if (password !== promoteToAdminPassword) {
throw new HttpsError("permission-denied", "wrong password");
}
await admin.auth().setCustomUserClaims(context.auth.uid, {
isAdmin: true,
});
});

View File

@@ -3,35 +3,65 @@ import * as admin from "firebase-admin";
export const collection = (collectionId: string) =>
admin.firestore().collection(collectionId);
export function useDocAsType<T>() {
return function(doc: admin.firestore.DocumentReference) {
async function read() {
const snapshot = await doc.get();
const profile: T = snapshot.data() as any;
if (!snapshot.exists || profile == null) {
const error = new Error(`Doc ${doc.path} does not exist`);
console.error(error);
throw error;
}
return profile;
function replaceUndefinedWithNull<
T extends {
[key: string]: any;
}
>(input: T): T {
Object.keys(input).forEach((key) => {
if (input[key] == null) {
input[key as keyof T] = null as any;
} else if (typeof input[key] === "object") {
replaceUndefinedWithNull(input[key]);
}
async function update(newValue: Partial<T>) {
return await doc.set(newValue, { merge: true });
});
return input;
}
export function useDocAsType<T>(doc: admin.firestore.DocumentReference) {
async function read() {
const snapshot = await doc.get();
const profile: T = snapshot.data() as any;
if (!snapshot.exists || profile == null) {
const error = new Error(`Doc ${doc.path} does not exist`);
console.error(error);
throw error;
}
async function listen(callback: (value: T) => void) {
return doc.onSnapshot(snapshot => {
callback(snapshot.data() as any);
});
}
async function deleteDoc() {
await doc.delete();
}
return {
ref: doc,
read,
update,
listen,
delete: deleteDoc
};
return profile;
}
async function set(newValue: T): Promise<string> {
await doc.set(replaceUndefinedWithNull(newValue));
return doc.id;
}
async function update(newValue: Partial<T>) {
return await doc.set(replaceUndefinedWithNull(newValue), { merge: true });
}
async function listen(callback: (value: T) => void) {
return doc.onSnapshot((snapshot) => {
callback(snapshot.data() as any);
});
}
async function deleteDoc() {
await doc.delete();
}
return {
ref: doc,
read,
update,
set,
listen,
delete: deleteDoc,
};
}
export const useQueryAsType = <T>(query: admin.firestore.Query) => {
async function read() {
const { docs } = await query.get();
return docs.map((d) => ({
ref: d.ref,
id: d.id,
doc: d.data() as T,
}));
}
return { read };
};

View File

@@ -1,27 +1,19 @@
import { collection, useDocAsType } from "./firestore";
import { compose } from "./utils";
import { PrivateProfile, Profile, ReadonlyProfile } from "../../types";
export const getUserProfileRef = (uid: string) =>
collection("userProfiles").doc(uid);
export const useUserProfile = (uid: string) =>
useDocAsType<Profile>(collection("userProfiles").doc(uid));
export const getPrivateProfileRef = (uid: string) =>
collection("userPrivateProfiles").doc(uid);
export const usePrivateProfile = (uid: string) =>
useDocAsType<PrivateProfile>(collection("userPrivateProfiles").doc(uid));
export const getReadonlyProfileRef = (uid: string) =>
collection("userReadonlyProfiles").doc(uid);
export const useReadonlyProfile = (uid: string) =>
useDocAsType<ReadonlyProfile>(collection("userReadonlyProfiles").doc(uid));
export const useUserProfile = compose(
getUserProfileRef,
useDocAsType<Profile>()
);
export const usePrivateProfile = compose(
getPrivateProfileRef,
useDocAsType<PrivateProfile>()
);
export const useReadonlyProfile = compose(
getReadonlyProfileRef,
useDocAsType<ReadonlyProfile>()
);
export const usePayments = (uid: string, paymentId: string) =>
useDocAsType<any>(
collection("userReadonlyProfiles")
.doc(uid)
.collection("payments")
.doc(paymentId)
);

View File

@@ -3,7 +3,7 @@ import { HttpsError } from "firebase-functions/lib/providers/https";
export const now = admin.firestore.FieldValue.serverTimestamp() as any;
export function assertAuth<T>(val: T): asserts val is NonNullable<T> {
export function assertNotNull<T>(val: T): asserts val is NonNullable<T> {
if (val === undefined || val === null) {
throw new HttpsError("unauthenticated", "You are not authenticated");
}
@@ -16,5 +16,5 @@ export function assertString(val: any): asserts val is string {
}
export function compose<A, B, C>(l: (a: A) => B, r: (b: B) => C): (a: A) => C {
return a => r(l(a));
return (a) => r(l(a));
}

View File

@@ -8,7 +8,8 @@ const lazyFunctions = {
status: () => require("./booster/status").statusCheck,
user: () => require("./booster/user"),
image: () => require("./booster/image"),
chat: () => require("./booster/chat")
chat: () => require("./booster/chat"),
payments: () => require("./booster/payment/payment"),
// profile: () => require("./profile"),
// user: () => require("./user"),
// exp: () => require("./exports"),
@@ -17,7 +18,7 @@ const lazyFunctions = {
const functionName = process.env.FUNCTION_NAME;
(Object.keys(lazyFunctions) as Array<keyof typeof lazyFunctions>).forEach(
name => {
(name) => {
if (!functionName || functionName.startsWith(name)) {
exports[name] = lazyFunctions[name]();
}

View File

@@ -3,7 +3,6 @@
"skipLibCheck": true,
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,

View File

@@ -7,5 +7,6 @@
"channel": "mercy-f16ad"
},
"functionsRoot": "https://us-central1-mercy-f16ad.cloudfunctions.net",
"projectId": "mercy-f16ad"
"projectId": "mercy-f16ad",
"promoteToAdminPassword": "Aa123456"
}

637
yarn.lock

File diff suppressed because it is too large Load Diff