├── mybuddy-api ├── firebase.json ├── .firebaserc ├── README.md ├── functions │ ├── mybuddy.exe │ ├── gulpfile.js │ ├── package.json │ ├── .gitignore │ ├── strategies.js │ ├── config.js │ ├── notifications.js │ ├── index.js │ └── firestore.js └── .gitignore ├── mybuddy ├── .watchmanconfig ├── screens │ ├── UsersScreen │ │ ├── index.js │ │ ├── UserTab.js │ │ └── UserScreen.js │ ├── index.js │ ├── AppointmentsScreen │ │ ├── index.js │ │ ├── AppointmentsTabNavigator │ │ │ ├── index.js │ │ │ ├── UpcomingAppointmentsScreen.js │ │ │ └── CalendarScreen.js │ │ └── AppointmentFormScreen.js │ ├── TeamsScreen │ │ ├── index.js │ │ ├── Forms │ │ │ ├── AddUserToTeam.js │ │ │ ├── AddTeam.js │ │ │ └── EditTeam.js │ │ ├── TeamsScreen.js │ │ └── TeamMembersScreen.js │ ├── BuddyAppointmentsScreen │ │ ├── index.js │ │ ├── BuddyRequestsScreen.js │ │ ├── UpcomingAppointmentsScreen.js │ │ └── CalendarScreen.js │ ├── EmergenciesScreen.js │ ├── UnauthorisedUserScreen.js │ ├── SettingsScreen.js │ ├── LoginScreen.js │ └── HomeScreen.js ├── components │ ├── Navigation │ │ ├── index.js │ │ ├── createDrawerNavigatorWithHeader.js │ │ └── withHeader.js │ ├── __tests__ │ │ ├── LoadingPage-test.js │ │ ├── MyTitle-test.js │ │ ├── __snapshots__ │ │ │ ├── LoadingPage-test.js.snap │ │ │ ├── MyTitle-test.js.snap │ │ │ ├── SearchBar-test.js.snap │ │ │ └── FormInput-test.js.snap │ │ ├── FormInput-test.js │ │ └── SearchBar-test.js │ ├── LoadingPage.js │ ├── Card │ │ └── index.js │ ├── MyTitle.js │ ├── CardItem │ │ ├── index.js │ │ ├── BuddyAppointmentCardItem.js │ │ ├── EmergencyCardItem.js │ │ ├── AppointmentInfo.js │ │ └── AppointmentCardItem.js │ ├── Form │ │ ├── FormInput.js │ │ ├── ManagerPicker.js │ │ ├── DateTimePicker.js │ │ └── BuddyPicker.js │ ├── SearchBar.js │ ├── DrawerMenu.js │ ├── CardList │ │ └── index.js │ ├── Dropdown.js │ └── SwipeToConfirm.js ├── assets │ ├── images │ │ ├── icon.png │ │ ├── logo.png │ │ ├── button.png │ │ ├── splash.png │ │ ├── robot-dev.png │ │ └── robot-prod.png │ └── fonts │ │ ├── Roboto-Medium.ttf │ │ └── SpaceMono-Regular.ttf ├── api │ ├── index.js │ ├── emergencies.js │ ├── auth.js │ ├── users.js │ ├── teams.js │ └── appointments.js ├── .gitignore ├── babel.config.js ├── navigation │ ├── index.js │ ├── NavigationService.js │ ├── keys.js │ ├── utils.js │ └── Navigator.js ├── constants │ ├── Urls.js │ ├── Keys.js │ ├── Colors.js │ └── Layout.js ├── __tests__ │ ├── __snapshots__ │ │ └── App-test.js.snap │ └── App-test.js ├── firebase.js ├── app.json ├── App.js ├── package.json └── utils │ ├── AsyncStorage.js │ └── ErrorBoundary.js ├── .gitignore └── README.md /mybuddy-api/firebase.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /mybuddy/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /mybuddy/screens/UsersScreen/index.js: -------------------------------------------------------------------------------- 1 | export * from './UserScreen' -------------------------------------------------------------------------------- /mybuddy/components/Navigation/index.js: -------------------------------------------------------------------------------- 1 | export * from './createDrawerNavigatorWithHeader' 2 | -------------------------------------------------------------------------------- /mybuddy-api/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "mybuddy-47e82" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /mybuddy/assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/icon.png -------------------------------------------------------------------------------- /mybuddy/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/logo.png -------------------------------------------------------------------------------- /mybuddy/api/index.js: -------------------------------------------------------------------------------- 1 | export * from './users' 2 | export * from './appointments' 3 | export * from './teams' 4 | -------------------------------------------------------------------------------- /mybuddy/assets/images/button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/button.png -------------------------------------------------------------------------------- /mybuddy/assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/splash.png -------------------------------------------------------------------------------- /mybuddy/assets/images/robot-dev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/robot-dev.png -------------------------------------------------------------------------------- /mybuddy/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/* 3 | npm-debug.* 4 | *.jks 5 | *.p12 6 | *.key 7 | *.mobileprovision 8 | -------------------------------------------------------------------------------- /mybuddy/assets/images/robot-prod.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/robot-prod.png -------------------------------------------------------------------------------- /mybuddy/assets/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /mybuddy/assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /mybuddy/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /mybuddy-api/README.md: -------------------------------------------------------------------------------- 1 | # myBuddy API 2 | 3 | ## Run 4 | 5 | ``` 6 | $ npm start 7 | ``` 8 | 9 | You will have a server successfully running on `http://localhost:3000` -------------------------------------------------------------------------------- /mybuddy/navigation/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Exports the navigation details and constants. 3 | */ 4 | 5 | export * from './Navigator' 6 | export * from './keys' 7 | export * from './NavigationService' 8 | export * from './utils' 9 | -------------------------------------------------------------------------------- /mybuddy-api/functions/mybuddy.exe: -------------------------------------------------------------------------------- 1 | _ __ 2 | ( / ) / / 3 | _ _ _ , /--< , , __/ __/ __ , 4 | / / / (_/_/___/(_/_(_/_(_/_/ (_/_ 5 | / / 6 | ' ' 7 | -------------------------------------------------------------------------------- /mybuddy/constants/Urls.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants class for any urls required throughout the application. 3 | */ 4 | 5 | // Cloud functions url for firebase API. 6 | export const BASE_URL = 'https://europe-west1-mybuddy-47e82.cloudfunctions.net/api' 7 | -------------------------------------------------------------------------------- /mybuddy/constants/Keys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants class for user access level. 3 | * The three access levels: user, manager & admin. 4 | */ 5 | 6 | export const USER = 'user' 7 | export const TEAM_MANAGER = 'teamManager' 8 | export const ADMIN = 'admin' 9 | -------------------------------------------------------------------------------- /mybuddy/__tests__/__snapshots__/App-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App snapshot renders the loading screen 1`] = `null`; 4 | 5 | exports[`Skips loading screen renders the root without loading screen 1`] = `null`; 6 | -------------------------------------------------------------------------------- /mybuddy/screens/index.js: -------------------------------------------------------------------------------- 1 | export * from './AppointmentsScreen' 2 | export * from './HomeScreen' 3 | export * from './LoginScreen' 4 | export * from './SettingsScreen' 5 | export * from './UnauthorisedUserScreen' 6 | export * from './UsersScreen' 7 | export * from './TeamsScreen' 8 | export * from './BuddyAppointmentsScreen' 9 | export * from './EmergenciesScreen' 10 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/LoadingPage-test.js: -------------------------------------------------------------------------------- 1 | import 'react-native'; 2 | import React from 'react'; 3 | import LoadingPage from '../LoadingPage'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | describe('Loading page rendering', () => { 7 | it('loading page renders correctly', () => { 8 | expect(renderer.create().toJSON()).toMatchSnapshot() 9 | }) 10 | }); -------------------------------------------------------------------------------- /mybuddy/constants/Colors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Enumerated constant colour values. 3 | */ 4 | 5 | const DEFAULT_COLORS = { 6 | primaryColor: '#005EB8', 7 | secondaryColor: '#EA952F', 8 | tabIconDefault: '#ffffff', 9 | errorText: '#FF0000', 10 | warningBackground: '#EAEB5E', 11 | warningText: '#666804', 12 | dangerColor: '#BF3B00', 13 | personalAppointmentsMarkColor: '#42a7f4' 14 | } 15 | 16 | export default DEFAULT_COLORS 17 | -------------------------------------------------------------------------------- /mybuddy/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase' 2 | import 'firebase/firestore' 3 | 4 | const config = { 5 | apiKey: '[ADD IT HERE]', 6 | authDomain: '[ADD IT HERE]', 7 | databaseURL: '[ADD IT HERE]', 8 | projectId: '[ADD IT HERE]', 9 | storageBucket: '[ADD IT HERE]', 10 | messagingSenderId: '[ADD IT HERE]' 11 | } 12 | 13 | firebase.initializeApp(config) 14 | export const db = firebase.firestore() 15 | export default firebase 16 | -------------------------------------------------------------------------------- /mybuddy/navigation/NavigationService.js: -------------------------------------------------------------------------------- 1 | // https://reactnavigation.org/docs/en/navigating-without-navigation-prop.html 2 | import { NavigationActions } from 'react-navigation' 3 | 4 | let _navigator 5 | 6 | export const setTopLevelNavigator = navigatorRef => { 7 | _navigator = navigatorRef 8 | } 9 | 10 | export const navigate = (routeName, params = {}) => { 11 | _navigator.dispatch( 12 | NavigationActions.navigate({ 13 | routeName, 14 | params 15 | }) 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/MyTitle-test.js: -------------------------------------------------------------------------------- 1 | import 'react-native' 2 | import React from 'react' 3 | import MyTitle from '../MyTitle' 4 | import renderer from 'react-test-renderer' 5 | import { Text } from 'native-base' 6 | 7 | describe('My title rendering', () => { 8 | it('My title bar renders correctly', () => { 9 | expect( 10 | renderer 11 | .create( 12 | 13 | test 14 | 15 | ) 16 | .toJSON() 17 | ).toMatchSnapshot() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /mybuddy/screens/AppointmentsScreen/index.js: -------------------------------------------------------------------------------- 1 | import { createStackNavigator } from 'react-navigation' 2 | 3 | import AppointmentsTabNavigator from './AppointmentsTabNavigator' 4 | import AppointmentFormScreen from './AppointmentFormScreen' 5 | 6 | export const AppointmentsScreen = createStackNavigator( 7 | { 8 | Base: AppointmentsTabNavigator, 9 | AppointmentForm: AppointmentFormScreen 10 | }, 11 | { 12 | headerMode: 'none', 13 | mode: 'modal', 14 | navigationOptions: { 15 | gesturesEnabled: false 16 | } 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /mybuddy/constants/Layout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Holds constants for the layout of the window such as dimension and width. 3 | * Exports them as constants to refer to. 4 | */ 5 | 6 | import { Dimensions } from 'react-native' 7 | 8 | // The width of the window. 9 | const width = Dimensions.get('window').width 10 | 11 | // The height of the window. 12 | const height = Dimensions.get('window').height 13 | 14 | export default { 15 | window: { 16 | width, 17 | height, 18 | }, 19 | isSmallDevice: width < 375, // Smaller screen devices such as iPhone 5. 20 | } 21 | -------------------------------------------------------------------------------- /mybuddy/components/Navigation/createDrawerNavigatorWithHeader.js: -------------------------------------------------------------------------------- 1 | import { createDrawerNavigator } from 'react-navigation' 2 | import withHeader from './withHeader' 3 | 4 | const _withHeaders = RouteConfigs => 5 | Object.entries(RouteConfigs).reduce( 6 | (obj, [routeName, screen]) => ({ 7 | ...obj, 8 | [routeName]: withHeader(screen, routeName) 9 | }), 10 | {} 11 | ) 12 | 13 | export const createDrawerNavigatorWithHeader = ( 14 | RouteConfigs, 15 | DrawerNavigatorConfig = {} 16 | ) => createDrawerNavigator(_withHeaders(RouteConfigs), DrawerNavigatorConfig) 17 | -------------------------------------------------------------------------------- /mybuddy/components/LoadingPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import { Container, Spinner } from 'native-base' 4 | 5 | import DEFAULT_COLORS from '../constants/Colors' 6 | 7 | export default class LoadingPage extends Component { 8 | render() { 9 | return ( 10 | 11 | 12 | 13 | ) 14 | } 15 | } 16 | 17 | const styles = StyleSheet.create({ 18 | container: { 19 | justifyContent: 'center' 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /mybuddy/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "mybuddy", 4 | "slug": "mybuddy", 5 | "privacy": "public", 6 | "sdkVersion": "32.0.0", 7 | "platforms": ["ios", "android"], 8 | "version": "1.0.0", 9 | "orientation": "portrait", 10 | "icon": "./assets/images/icon.png", 11 | "splash": { 12 | "image": "./assets/images/splash.png", 13 | "resizeMode": "contain" 14 | }, 15 | "updates": { 16 | "fallbackToCacheTimeout": 0 17 | }, 18 | "assetBundlePatterns": ["**/*"], 19 | "ios": { 20 | "supportsTablet": true 21 | }, 22 | "scheme": "mybuddy" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /mybuddy/__tests__/App-test.js: -------------------------------------------------------------------------------- 1 | import 'react-native'; 2 | import React from 'react'; 3 | import App from '../App'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | describe('App snapshot', () => { 7 | it('renders the loading screen', async () => { 8 | const appSnapshot = renderer.create().toJSON(); 9 | expect(appSnapshot).toMatchSnapshot(); 10 | }); 11 | }); 12 | 13 | describe('Skips loading screen', () => { 14 | it('renders the root without loading screen', async () => { 15 | const appSkipLoadingScreen = renderer.create().toJSON(); 16 | expect(appSkipLoadingScreen).toMatchSnapshot(); 17 | }); 18 | }) 19 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/__snapshots__/LoadingPage-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Loading page rendering loading page renders correctly 1`] = ` 4 | 18 | 29 | 30 | `; 31 | -------------------------------------------------------------------------------- /mybuddy/navigation/keys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Holds constant values for the navigation bar screens and titles. 3 | */ 4 | 5 | export const LOGIN_SCREEN = 'login', 6 | UNAUTHORISED_USER_SCREEN = 'unauthorised', 7 | ADMIN_PORTAL = 'admin', 8 | TEAM_MANAGER_PORTAL = 'teamManager', 9 | CLINICIAN_PORTAL = 'clinician', 10 | ADMIN_OR_USER_PORTAL = 'adminOrUser', 11 | HOME_SCREEN = 'Home', 12 | APPOINTMENTS_SCREEN = 'Appointments', 13 | BUDDY_APPOINTMENTS_SCREEN = 'Buddy Appointments', 14 | SETTINGS_SCREEN = 'Settings', 15 | TEAM_SCREEN = 'Team', 16 | TEAMS_SCREEN = 'Teams', 17 | USERS_SCREEN = 'Users', 18 | TEAM_MEMBERS_SCREEN = 'TeamMembers', 19 | EMERGENCIES_SCREEN = 'Emergencies' 20 | -------------------------------------------------------------------------------- /mybuddy/screens/UsersScreen/UserTab.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { FlatList } from 'react-native' 3 | import { Content } from 'native-base' 4 | import { ListItem } from 'react-native-elements' 5 | import _ from 'lodash' 6 | 7 | export class UserTab extends Component { 8 | renderItem = ({ item }) => ( 9 | 14 | ) 15 | 16 | render() { 17 | return ( 18 | 19 | user.key} 22 | renderItem={this.renderItem} 23 | /> 24 | 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /mybuddy-api/functions/gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'), 2 | nodemon = require('gulp-nodemon'), 3 | config = require('./config') 4 | 5 | const ENV = config.IN_DEV ? 'development' : 'production' 6 | 7 | gulp.task('nodemon', function (done) { 8 | nodemon({ 9 | script: 'index.js', 10 | levels: 10, // trace, show everything 11 | ignore: ['*.log'], 12 | nodeArgs: ['--inspect'], 13 | ext: 'js json', 14 | events: { 15 | start: `cat ./mybuddy.exe && echo 'Started myBuddy API (${ENV})'`, 16 | restart: "osascript -e 'display notification \"App restarted due to:\n'$FILENAME'\" with title \"nodemon\"'" 17 | }, 18 | done: done 19 | }) 20 | }) 21 | 22 | gulp.task('default', gulp.parallel('nodemon')) -------------------------------------------------------------------------------- /mybuddy/components/Card/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import { Card } from 'native-base' 4 | import PropTypes from 'prop-types' 5 | 6 | import CardItem from '../CardItem' 7 | 8 | const styles = StyleSheet.create({ 9 | card: { alignSelf: 'center', width: '95%' } 10 | }) 11 | 12 | export default class CustomCard extends Component { 13 | render() { 14 | if (this.props.children) { 15 | return {this.props.children} 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | } 25 | 26 | CustomCard.propTypes = { 27 | children: PropTypes.node 28 | } 29 | -------------------------------------------------------------------------------- /mybuddy/screens/TeamsScreen/index.js: -------------------------------------------------------------------------------- 1 | import { createStackNavigator } from 'react-navigation' 2 | 3 | import TeamScreen from './TeamsScreen' 4 | import TeamMembersScreen from './TeamMembersScreen' 5 | 6 | import AddTeam from './Forms/AddTeam' 7 | import AddUserToTeam from './Forms/AddUserToTeam' 8 | import EditTeam from './Forms/EditTeam' 9 | 10 | const Screens = createStackNavigator( 11 | { 12 | Base: TeamScreen, 13 | TeamMembers: TeamMembersScreen 14 | }, 15 | { headerMode: 'none' } 16 | ) 17 | 18 | export const TeamsScreen = createStackNavigator( 19 | { 20 | Base: Screens, 21 | AddTeamForm: AddTeam, 22 | AddUserToTeam: AddUserToTeam, 23 | EditTeam: EditTeam 24 | }, 25 | { 26 | headerMode: 'none', 27 | mode: 'modal', 28 | navigationOptions: { 29 | gesturesEnabled: false 30 | } 31 | } 32 | ) 33 | -------------------------------------------------------------------------------- /mybuddy/components/MyTitle.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Title } from 'native-base' 3 | import PropTypes from 'prop-types' 4 | 5 | export default class MyTitle extends Component { 6 | render () { 7 | const { style, ...props } = this.props 8 | return ( 9 | 21 | {this.props.children} 22 | 23 | ) 24 | } 25 | } 26 | 27 | MyTitle.propTypes = { 28 | children: PropTypes.oneOfType([ 29 | PropTypes.string, 30 | PropTypes.node, 31 | PropTypes.arrayOf(PropTypes.string, PropTypes.node) 32 | ]).isRequired, 33 | style: PropTypes.object 34 | } 35 | -------------------------------------------------------------------------------- /mybuddy/screens/BuddyAppointmentsScreen/index.js: -------------------------------------------------------------------------------- 1 | import { createMaterialTopTabNavigator } from 'react-navigation' 2 | 3 | import UpcomingAppointmentsScreen from './UpcomingAppointmentsScreen' 4 | import CalendarScreen from './CalendarScreen' 5 | import BuddyRequestsScreen from './BuddyRequestsScreen' 6 | 7 | import DEFAULT_COLORS from '../../constants/Colors' 8 | 9 | const tabBarOptions = { 10 | scrollEnabled: false, 11 | indicatorStyle: { 12 | backgroundColor: DEFAULT_COLORS.primaryColor 13 | }, 14 | labelStyle: { fontWeight: 'bold' }, 15 | style: { 16 | backgroundColor: DEFAULT_COLORS.secondaryColor 17 | } 18 | } 19 | 20 | export const BuddyAppointmentsScreen = createMaterialTopTabNavigator( 21 | { 22 | Requests: BuddyRequestsScreen, 23 | Upcoming: UpcomingAppointmentsScreen, 24 | Calendar: CalendarScreen 25 | }, 26 | { tabBarOptions } 27 | ) 28 | -------------------------------------------------------------------------------- /mybuddy/api/emergencies.js: -------------------------------------------------------------------------------- 1 | import { db } from '../firebase' 2 | import { getCurrentUserAsync } from './users' 3 | 4 | /** 5 | * Fetch all emergencies in the current users team, 6 | * excluding any issued by the current user themselves. 7 | a* @param onSnapshot function to run on update 8 | * @returns Promise(unsubscribe) 9 | */ 10 | export const allEmergenciesOnSnapshot = async onSnapshot => { 11 | const user = await getCurrentUserAsync() 12 | return db 13 | .collection('emergencies') 14 | .where('team', '==', user.team) 15 | .where('status', '==', 'alert') 16 | .onSnapshot(async querySnapshot => { 17 | const emergencies = querySnapshot.docs 18 | .filter(doc => doc.data().user.id !== user.uid) 19 | .map(doc => { 20 | return { 21 | ...doc.data(), 22 | emergencyRef: doc.ref 23 | } 24 | }) 25 | onSnapshot(emergencies) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/__snapshots__/MyTitle-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`My title rendering My title bar renders correctly 1`] = ` 4 | 28 | 38 | test 39 | 40 | 41 | `; 42 | -------------------------------------------------------------------------------- /mybuddy/navigation/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A utility class used to return the initial display after login for a user. 3 | */ 4 | 5 | import { 6 | LOGIN_SCREEN, 7 | ADMIN_PORTAL, 8 | UNAUTHORISED_USER_SCREEN, 9 | TEAM_MANAGER_PORTAL, 10 | CLINICIAN_PORTAL 11 | } from './keys' 12 | import { ADMIN, TEAM_MANAGER, USER } from '../constants/Keys' 13 | 14 | /** 15 | * Returns the initial portal/screen to display just after login. 16 | * Based on the user access level, different screens are available to different access levels. 17 | * An invalid user will be returned back to the login screen. 18 | * @param user - the user. 19 | */ 20 | export const getInitialRouteName = user => { 21 | if (!user) return LOGIN_SCREEN 22 | switch (user.accessLevel) { 23 | case ADMIN: 24 | return ADMIN_PORTAL 25 | case TEAM_MANAGER: 26 | return TEAM_MANAGER_PORTAL 27 | case USER: 28 | return CLINICIAN_PORTAL 29 | default: 30 | return UNAUTHORISED_USER_SCREEN 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /mybuddy/components/CardItem/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { CardItem, Text } from 'native-base' 3 | import PropTypes from 'prop-types' 4 | 5 | import AppointmentCardItem from './AppointmentCardItem' 6 | import BuddyAppointmentCardItem from './BuddyAppointmentCardItem' 7 | import EmergencyCardItem from './EmergencyCardItem' 8 | 9 | export default class CustomCardItem extends Component { 10 | render() { 11 | const { children, type, ...props } = this.props 12 | 13 | if (children) { 14 | return {children} 15 | } 16 | 17 | switch (type) { 18 | case 'appointment': 19 | return 20 | case 'buddy-appointment': 21 | return 22 | case 'emergency': 23 | return 24 | default: 25 | return error 26 | } 27 | } 28 | } 29 | 30 | CustomCardItem.propTypes = { 31 | children: PropTypes.node 32 | } 33 | -------------------------------------------------------------------------------- /mybuddy-api/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mybuddy-api", 3 | "version": "0.0.1", 4 | "private": true, 5 | "engines": { 6 | "node": "8" 7 | }, 8 | "scripts": { 9 | "start": "./node_modules/.bin/gulp", 10 | "firestart": "npm run shell", 11 | "serve": "firebase serve --only functions", 12 | "shell": "firebase functions:shell", 13 | "deploy": "firebase deploy --only functions", 14 | "logs": "firebase functions:log" 15 | }, 16 | "dependencies": { 17 | "body-parser": "^1.15.2", 18 | "cookie-parser": "^1.4.3", 19 | "expo-server-sdk": "^3.2.0", 20 | "express": "^4.16.4", 21 | "express-async-handler": "^1.1.4", 22 | "express-auth-parser": "^0.1.2", 23 | "firebase-admin": "^7.0.0", 24 | "firebase-functions": "^2.2.0", 25 | "lodash": "^4.17.11", 26 | "method-override": "^2.3.6", 27 | "moment": "^2.24.0", 28 | "morgan": "^1.9.1", 29 | "passport": "^0.4.0", 30 | "passport-azure-ad": "^4.0.0", 31 | "passport-http-bearer": "^1.0.1" 32 | }, 33 | "devDependencies": { 34 | "gulp": "^4.0.0", 35 | "gulp-nodemon": "^2.4.2", 36 | "nodemon": "^1.18.10" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mybuddy/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { AppLoading, Font } from 'expo' 3 | 4 | import { createNavigator } from './navigation/Navigator' 5 | import ErrorBoundary from './utils/ErrorBoundary' 6 | import { getCurrentUserAsync } from './api' 7 | 8 | export default class App extends Component { 9 | state = { 10 | isLoading: true, 11 | Navigator: null 12 | } 13 | 14 | async componentDidMount() { 15 | const [_, user] = await Promise.all([ 16 | this._loadResourcesAsync(), 17 | getCurrentUserAsync() 18 | ]) 19 | const Navigator = createNavigator(user) 20 | this.setState({ isLoading: false, Navigator }) 21 | } 22 | 23 | render() { 24 | if (this.state.isLoading) return 25 | 26 | const { Navigator } = this.state 27 | return ( 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | _loadResourcesAsync = async () => { 35 | Promise.all([ 36 | Font.loadAsync({ 37 | Roboto: require('native-base/Fonts/Roboto.ttf'), 38 | Roboto_medium: require('native-base/Fonts/Roboto_medium.ttf') 39 | }) 40 | ]) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /mybuddy/screens/EmergenciesScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content } from 'native-base' 3 | 4 | import Card from '../components/Card' 5 | import MyTitle from '../components/MyTitle' 6 | import { allEmergenciesOnSnapshot } from '../api/emergencies' 7 | 8 | export class EmergenciesScreen extends Component { 9 | state = { 10 | emergencies: [] 11 | } 12 | 13 | async componentDidMount() { 14 | this.unsubscribe = await allEmergenciesOnSnapshot(emergencies => 15 | this.setState({ emergencies }) 16 | ) 17 | } 18 | 19 | componentWillUnmount() { 20 | this.unsubscribe() 21 | } 22 | 23 | render() { 24 | // If there is at least one emergency 25 | if (Boolean(this.state.emergencies.length)) { 26 | return ( 27 | 28 | 29 | {this.state.emergencies.map((emergency, i) => ( 30 | 31 | ))} 32 | 33 | 34 | ) 35 | } 36 | 37 | return ( 38 | No active emergencies 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/FormInput-test.js: -------------------------------------------------------------------------------- 1 | import 'react-native' 2 | import React from 'react' 3 | import FormInput from '../Form/FormInput' 4 | import renderer from 'react-test-renderer' 5 | 6 | const props = (value, touched, error) => ({ 7 | field: { 8 | name: 'test' 9 | }, 10 | form: { 11 | values: { 12 | test: value 13 | }, 14 | touched: { 15 | test: touched 16 | }, 17 | errors: { 18 | test: error 19 | } 20 | } 21 | }); 22 | 23 | describe('DateTimePicker snapshot', () => { 24 | jest.useFakeTimers() 25 | Date.now = jest.fn(() => 1503187200000) 26 | it('form input renders without errors', async () => { 27 | const tree = renderer 28 | .create() 29 | .toJSON() 30 | expect(tree).toMatchSnapshot() 31 | }) 32 | }) 33 | 34 | describe('Form input renders with errors', () => { 35 | jest.useFakeTimers() 36 | Date.now = jest.fn(() => 1503187200000) 37 | it('form input renders with errors', async () => { 38 | const tree = renderer 39 | .create() 40 | .toJSON() 41 | expect(tree).toMatchSnapshot() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/macos,windows 2 | # Edit at https://www.gitignore.io/?templates=macos,windows 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Node Modules 17 | node_modules 18 | 19 | # Files that might appear in the root of a volume 20 | .DocumentRevisions-V100 21 | .fseventsd 22 | .Spotlight-V100 23 | .TemporaryItems 24 | .Trashes 25 | .VolumeIcon.icns 26 | .com.apple.timemachine.donotpresent 27 | 28 | # Directories potentially created on remote AFP share 29 | .AppleDB 30 | .AppleDesktop 31 | Network Trash Folder 32 | Temporary Items 33 | .apdisk 34 | 35 | # VSCode 36 | .vscode 37 | 38 | ### Windows ### 39 | # Windows thumbnail cache files 40 | Thumbs.db 41 | ehthumbs.db 42 | ehthumbs_vista.db 43 | 44 | # Dump file 45 | *.stackdump 46 | 47 | # Folder config file 48 | [Dd]esktop.ini 49 | 50 | # Recycle Bin used on file shares 51 | $RECYCLE.BIN/ 52 | 53 | # Windows Installer files 54 | *.cab 55 | *.msi 56 | *.msix 57 | *.msm 58 | *.msp 59 | 60 | # Windows shortcuts 61 | *.lnk 62 | 63 | # End of https://www.gitignore.io/api/macos,windows 64 | 65 | /vids 66 | **/node_modules 67 | package-lock.json 68 | .expo 69 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/SearchBar-test.js: -------------------------------------------------------------------------------- 1 | import 'react-native'; 2 | import React from 'react'; 3 | import SearchBar from '../SearchBar'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | /** 7 | * Returns a search bar component. 8 | * If getInstance is true, will return an instance of the component else a JSON of it. 9 | * @param getInstance - If we want an instance of the search bar or not. 10 | */ 11 | let getSearchBar = function(getInstance) { 12 | if (getInstance) 13 | return renderer.create().getInstance() 14 | else 15 | return renderer.create().toJSON() 16 | } 17 | 18 | //The search bar as JSON 19 | var searchBarAsJSON = getSearchBar(false) 20 | 21 | //The search bar instance 22 | var searchBarInstance = getSearchBar(true) 23 | 24 | describe('Search bar rendering', () => { 25 | it('search bar renders correctly', () => { 26 | expect(searchBarAsJSON).toMatchSnapshot() 27 | }) 28 | }); 29 | 30 | describe('Search bar text field', () => { 31 | it('search bar starts with no search text', () => { 32 | expect(searchBarInstance.state.searchText).toEqual('') 33 | }) 34 | it('Set search text to a value', () => { 35 | searchBarInstance.setState({searchText: 'Man utd'}) 36 | expect(searchBarInstance.state.searchText).toEqual('Man utd') 37 | }) 38 | }) -------------------------------------------------------------------------------- /mybuddy/screens/BuddyAppointmentsScreen/BuddyRequestsScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content } from 'native-base' 3 | 4 | import Card from '../../components/Card' 5 | import { buddyRequestsOnSnapshot } from '../../api' 6 | import MyTitle from '../../components/MyTitle' 7 | import LoadingPage from '../../components/LoadingPage' 8 | 9 | export default class BuddyRequestsScreen extends Component { 10 | state = { 11 | appointments: {}, 12 | isLoading: true 13 | } 14 | 15 | async componentDidMount() { 16 | this.unsubscribe = await buddyRequestsOnSnapshot(appointments => 17 | this.setState({ 18 | appointments, 19 | isLoading: false 20 | }) 21 | ) 22 | } 23 | 24 | componentWillUnmount() { 25 | this.unsubscribe() 26 | } 27 | 28 | render() { 29 | if (this.state.isLoading) { 30 | return 31 | } 32 | 33 | const appointments = this.state.appointments.map((appointment, i) => ( 34 | 35 | )) 36 | 37 | if (Boolean(appointments.length)) { 38 | return ( 39 | 40 | {appointments} 41 | 42 | ) 43 | } 44 | 45 | return ( 46 | No outstanding requests 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /mybuddy-api/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | 9 | # Firebase cache 10 | .firebase/ 11 | 12 | # Firebase config 13 | 14 | # Uncomment this if you'd like others to create their own Firebase project. 15 | # For a team working on the same Firebase project(s), it is recommended to leave 16 | # it commented so all members can deploy to the same project(s) in .firebaserc. 17 | # .firebaserc 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (http://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | -------------------------------------------------------------------------------- /mybuddy-api/functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | 9 | # Firebase cache 10 | .firebase/ 11 | 12 | # Firebase config 13 | 14 | # Uncomment this if you'd like others to create their own Firebase project. 15 | # For a team working on the same Firebase project(s), it is recommended to leave 16 | # it commented so all members can deploy to the same project(s) in .firebaserc. 17 | # .firebaserc 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (http://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | -------------------------------------------------------------------------------- /mybuddy/api/auth.js: -------------------------------------------------------------------------------- 1 | import { AuthSession } from 'expo' 2 | 3 | import firebase, { db } from '../firebase' 4 | import { navigate } from '../navigation/NavigationService' 5 | import { LOGIN_SCREEN } from '../navigation/keys' 6 | import { BASE_URL } from '../constants/Urls' 7 | 8 | /** 9 | * Log user in using expo's AuthSession, 10 | * then store the user object in AsyncStorage. 11 | * If successful return the user object, else 12 | * return null. 13 | * @return Promise(user) | Promise(null) 14 | */ 15 | export const login = async () => { 16 | const results = await AuthSession.startAsync({ 17 | authUrl: `${BASE_URL}/auth/login?redirectUrl=${AuthSession.getRedirectUrl()}` 18 | }) 19 | if (results.type === 'success') { 20 | const { result, customToken } = results.params 21 | if (result == 0) { 22 | await firebase.auth().signInWithCustomToken(customToken) 23 | const userRef = db 24 | .collection('users') 25 | .doc(firebase.auth().currentUser.uid) 26 | const user = await userRef.get().then(doc => ({ ...doc.data(), userRef })) 27 | return user 28 | } else { 29 | navigate(LOGIN_SCREEN, { 30 | error: { message: 'Authentication failed please contact an admin.' } 31 | }) 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * Remove user from local storage, sign out 38 | * from firebase, and invalidate id_token. 39 | */ 40 | export const logout = async () => { 41 | await firebase.auth().signOut() 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # myBuddy 2 | The myBuddy app is a fully functional cross-platform (Android & iOS) mobile application specially designed for the **Maudsley NHS Foundation Trust** Helping Families Team which are a specialist mental health service for families in complex circumstances. It's a buddy system for the clinicians that allows them to keep track of their appointments, ensure their attendance and ensure the safety of the clinicians when they see their patients by having them check in/out at scheduled times, and notify members of the team if they haven't already. The entire app is centered around the objective to provide a seamless experience for the Helping Families Team Members at the NHS. 3 | 4 | Structure 5 | - `mybuddy` - the app (React Native / Expo) 6 | - `mybuddy-api` - the server (ExpressJS / Firebase Cloud Functions) 7 | 8 | 9 | Learn more at https://habibrehman.com/work/mybuddy 10 | 11 | ## Development 12 | 13 | [![JavaScript Style Guide](https://cdn.rawgit.com/standard/standard/master/badge.svg)](https://github.com/standard/standard) 14 | 15 | ### Setup 16 | 17 | Clone the repo 18 | 19 | ```bash 20 | git clone https://github.com/HR/mybuddy.git && cd mybuddy 21 | ``` 22 | 23 | ### Run 24 | 25 | ```bash 26 | cd mybuddy && npm install && npm start 27 | ``` 28 | 29 | 30 | ## Contributors 31 | - Habib Rehman 32 | - Chow-Ching (Chris) Jim 33 | - Oluwafemi (Femi) Oladipo 34 | - Spenser Smart 35 | - Akshat Sood 36 | - Salich (Sal) Memet Efenti Chousein 37 | - Eugene Fong 38 | -------------------------------------------------------------------------------- /mybuddy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "./node_modules/.bin/expo start", 5 | "clean-start": "npm start -- -c", 6 | "android": "expo start --android", 7 | "ios": "expo start --ios", 8 | "eject": "expo eject", 9 | "test": "node ./node_modules/jest/bin/jest.js --watchAll" 10 | }, 11 | "jest": { 12 | "preset": "jest-expo" 13 | }, 14 | "dependencies": { 15 | "@expo/samples": "2.1.1", 16 | "expo": "^32.0.0", 17 | "firebase": "^5.8.6", 18 | "formik": "^1.5.1", 19 | "fuzzy-search": "^3.0.1", 20 | "inflector-js": "^1.0.1", 21 | "jwt-decode": "^2.2.0", 22 | "lodash": "^4.17.11", 23 | "moment": "^2.24.0", 24 | "native-base": "^2.12.0", 25 | "react": "16.5.0", 26 | "react-native": "https://github.com/expo/react-native/archive/sdk-32.0.0.tar.gz", 27 | "react-native-action-button": "^2.8.5", 28 | "react-native-calendars": "^1.22.0", 29 | "react-native-elements": "^1.1.0", 30 | "react-native-modal": "^9.0.0", 31 | "react-native-modal-datetime-picker": "^6.0.0", 32 | "react-native-picker-select": "^6.1.0", 33 | "react-native-search-box": "0.0.19", 34 | "react-native-snap-carousel": "^3.7.5", 35 | "react-native-swipe-verify": "^0.1.5", 36 | "react-navigation": "^3.0.9", 37 | "xdate": "^0.8.2", 38 | "yup": "^0.27.0" 39 | }, 40 | "devDependencies": { 41 | "babel-preset-expo": "^5.0.0", 42 | "husky": "^1.3.1", 43 | "jest": "^24.5.0", 44 | "jest-expo": "^32.0.0" 45 | }, 46 | "private": true 47 | } 48 | -------------------------------------------------------------------------------- /mybuddy/api/users.js: -------------------------------------------------------------------------------- 1 | import firebase, { db } from '../firebase' 2 | 3 | /** 4 | * Returns user object corresponding to the 5 | * userUid passed in, else null 6 | * @param userUid 7 | * @returns user | null 8 | */ 9 | export const _getUserAsync = async userUid => { 10 | const userRef = db.collection('users').doc(userUid) 11 | return userRef.get().then(doc => { 12 | return { ...doc.data(), userRef: doc.ref } 13 | }) 14 | } 15 | 16 | /** 17 | * Returns user object corresponding to current 18 | * logged in user. 19 | * @returns user 20 | */ 21 | export const getCurrentUserAsync = async () => { 22 | const [unsubscribe, user] = await new Promise(resolve => { 23 | const unsubscribe = firebase.auth().onAuthStateChanged(async authUser => { 24 | let user = null 25 | if (authUser) user = await _getUserAsync(authUser.uid) 26 | resolve([unsubscribe, user]) 27 | }) 28 | }) 29 | unsubscribe() 30 | return user 31 | } 32 | 33 | /** 34 | * Fetch user details and watch for changes 35 | * @param onSnapshot function to run update 36 | * @returns Promise(unsubscribe) 37 | */ 38 | export const currentUserOnSnapshot = async onSnapshot => { 39 | const user = await getCurrentUserAsync() 40 | return user.userRef.onSnapshot(userDoc => { 41 | onSnapshot(userDoc.data()) 42 | }) 43 | } 44 | 45 | /** 46 | * Fetch all users without a team 47 | * @param onSnapshot function to run update 48 | * @returns Promise(unsubscribe) 49 | */ 50 | export const usersWithoutTeamOnSnapshot = async onSnapshot => { 51 | return db 52 | .collection('users') 53 | .where('team', '==', null) 54 | .onSnapshot(querySnapshot => { 55 | const teamlessUsers = querySnapshot.docs.map(userDoc => { 56 | return userDoc.data() 57 | }) 58 | onSnapshot(teamlessUsers) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /mybuddy/screens/AppointmentsScreen/AppointmentsTabNavigator/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import { Icon } from 'native-base' 4 | import { createMaterialTopTabNavigator } from 'react-navigation' 5 | import ActionButton from 'react-native-action-button' 6 | 7 | import CalendarScreen from './CalendarScreen' 8 | import UpcomingAppointmentsScreen from './UpcomingAppointmentsScreen' 9 | import DEFAULT_COLORS from '../../../constants/Colors' 10 | 11 | const tabBarOptions = { 12 | scrollEnabled: false, 13 | indicatorStyle: { 14 | backgroundColor: DEFAULT_COLORS.primaryColor 15 | }, 16 | labelStyle: { fontWeight: 'bold' }, 17 | style: { 18 | backgroundColor: DEFAULT_COLORS.secondaryColor 19 | } 20 | } 21 | 22 | const Screens = createMaterialTopTabNavigator( 23 | { 24 | Upcoming: UpcomingAppointmentsScreen, 25 | Calendar: CalendarScreen 26 | }, 27 | { tabBarOptions } 28 | ) 29 | 30 | export default class extends Component { 31 | static router = Screens.router 32 | 33 | _newAppointment = () => { 34 | this.props.navigation.navigate('AppointmentForm') 35 | } 36 | 37 | render() { 38 | return ( 39 | <> 40 | 41 | ( 45 | 50 | )} 51 | /> 52 | 53 | ) 54 | } 55 | } 56 | 57 | const styles = StyleSheet.create({ 58 | actionButtonIcon: { 59 | fontSize: 20, 60 | height: 22, 61 | color: 'white' 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /mybuddy/api/teams.js: -------------------------------------------------------------------------------- 1 | import { db } from '../firebase' 2 | 3 | /** 4 | * Fetch teams 5 | * @param onSnapshot function to run on update 6 | * @param clauses where conditions 7 | * @param transformation function to transform the shape of the data returned 8 | * @returns Promise(unsubscribe) 9 | */ 10 | export const allTeamsOnSnapshot = async ( 11 | onSnapshot, 12 | clauses = [], 13 | transformation 14 | ) => { 15 | let teamsRef = db.collection('teams') 16 | clauses.forEach(clause => { 17 | teamsRef = teamsRef.where(...clause) 18 | }) 19 | return ( 20 | teamsRef 21 | // listen real time 22 | .onSnapshot( 23 | async querySnapshot => { 24 | let teams = querySnapshot.docs.map(doc => { 25 | return { 26 | ...doc.data(), 27 | teamRef: db.collection('teams').doc(doc.id), 28 | id: doc.id 29 | } 30 | }) 31 | 32 | if (transformation) { 33 | teams = transformation(teams) 34 | } 35 | 36 | onSnapshot(teams) 37 | }, 38 | error => console.warn(error) 39 | ) 40 | ) 41 | } 42 | 43 | /** 44 | * Fetch all users in the managers team excluding 45 | * @param manager object 46 | * @param onSnapshot function to run on update 47 | */ 48 | export const managersTeamMembersOnSnapshot = manager => async onSnapshot => { 49 | return db 50 | .collection('users') 51 | .where('team', '==', manager.team) 52 | .onSnapshot( 53 | querySnapshot => { 54 | const teamMembers = querySnapshot.docs 55 | .filter(doc => doc.id !== manager.uid) 56 | .map(doc => ({ 57 | ...doc.data(), 58 | userRef: doc.ref 59 | })) 60 | 61 | onSnapshot(teamMembers) 62 | }, 63 | error => console.warn(error) 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /mybuddy/screens/BuddyAppointmentsScreen/UpcomingAppointmentsScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, View } from 'native-base' 3 | 4 | import Card from '../../components/Card' 5 | import MyTitle from '../../components/MyTitle' 6 | import LoadingPage from '../../components/LoadingPage' 7 | import { buddyAppointmentsThisWeekOnSnapshot } from '../../api' 8 | 9 | export default class UpcomingAppointmentsScreen extends Component { 10 | state = { 11 | appointments: {}, 12 | isLoading: true 13 | } 14 | 15 | async componentDidMount() { 16 | this.unsubscribe = await buddyAppointmentsThisWeekOnSnapshot( 17 | appointments => 18 | this.setState({ 19 | appointments, 20 | isLoading: false 21 | }), 22 | true 23 | ) 24 | } 25 | 26 | componentWillUnmount() { 27 | this.unsubscribe() 28 | } 29 | 30 | render() { 31 | if (this.state.isLoading) { 32 | return 33 | } 34 | 35 | const appointments = Object.entries(this.state.appointments) 36 | // remove keys with no appointments 37 | .filter(([_, appointments]) => Boolean(appointments.length)) 38 | // display remaining keys 39 | .map(([title, appointments], i) => ( 40 | 41 | {title} 42 | {appointments.map((appointment, i) => ( 43 | 44 | ))} 45 | 46 | )) 47 | 48 | if (Boolean(appointments.length)) { 49 | return ( 50 | 51 | {appointments} 52 | 53 | ) 54 | } 55 | 56 | return ( 57 | 58 | No more appointments this week 59 | 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /mybuddy/components/Form/FormInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Item, Label, Input, Text } from 'native-base' 3 | import { StyleSheet } from 'react-native' 4 | 5 | const capitalize = s => { 6 | if (typeof s !== 'string') return '' 7 | return s.charAt(0).toUpperCase() + s.slice(1) 8 | } 9 | 10 | export default class FormInput extends Component { 11 | _updateFormik = value => { 12 | const { 13 | field, 14 | form: { setFieldValue } 15 | } = this.props 16 | 17 | try { 18 | if (this.props.keyboardType === 'numeric') { 19 | const number = Number(value) 20 | if (number || !value) { 21 | setFieldValue(field.name, number || '') 22 | } 23 | } else { 24 | setFieldValue(field.name, value) 25 | } 26 | } catch (error) {} 27 | } 28 | 29 | render() { 30 | const { 31 | field, // { name, value, onChange, onBlur } 32 | form: { touched, errors, values }, // touched, errors, setXXXX, handleXXXX, dirty, isValid, status, etc. 33 | required, 34 | ...props 35 | } = this.props 36 | return ( 37 | <> 38 | 43 | 46 | 51 | 52 | {touched[field.name] && errors[field.name] && ( 53 | 54 | {errors[field.name]} 55 | 56 | )} 57 | 58 | ) 59 | } 60 | } 61 | 62 | const styles = StyleSheet.create({ 63 | item: { 64 | alignSelf: 'center', 65 | width: '90%', 66 | marginTop: 10 67 | } 68 | }) 69 | -------------------------------------------------------------------------------- /mybuddy/components/SearchBar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import FuzzySearch from 'fuzzy-search' 4 | import { SearchBar as SearchBox } from 'react-native-elements' 5 | 6 | import DEFAULT_COLORS from '../constants/Colors' 7 | 8 | /** 9 | * Represents the search bar that can be used throughout the application. 10 | * The fuzzy search algorithm is used on the data and the result is returned. 11 | */ 12 | export default class Search extends Component { 13 | state = { 14 | searchText: '' 15 | } 16 | 17 | /** 18 | * Updates the search to search for the text inside the search bar. 19 | * The text is formatted by removing whitespace and ensuring the appropriate split is used. 20 | */ 21 | updateSearch = searchText => { 22 | this.setState({ searchText }) 23 | const results = new FuzzySearch(this.props.data, this.props.properties, { 24 | sort: true 25 | }).search( 26 | searchText 27 | .trim() 28 | .split('/[ .,/-]/') 29 | .join('') 30 | ) 31 | this.props.onSearch(results) 32 | } 33 | 34 | render() { 35 | return ( 36 | 55 | ) 56 | } 57 | } 58 | 59 | Search.proptypes = { 60 | data: PropTypes.array.isRequired, 61 | onSearch: PropTypes.func.isRequired, 62 | placeholder: PropTypes.string, 63 | properties: PropTypes.array.isRequired 64 | } 65 | -------------------------------------------------------------------------------- /mybuddy/screens/AppointmentsScreen/AppointmentsTabNavigator/UpcomingAppointmentsScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, View } from 'native-base' 3 | 4 | import Card from '../../../components/Card' 5 | import MyTitle from '../../../components/MyTitle' 6 | import LoadingPage from '../../../components/LoadingPage' 7 | import { appointmentsThisWeekOnSnapshot } from '../../../api' 8 | 9 | export default class UpcomingAppointmentsScreen extends Component { 10 | state = { 11 | appointments: {}, 12 | isLoading: true 13 | } 14 | 15 | async componentDidMount() { 16 | this.unsubscribe = await appointmentsThisWeekOnSnapshot( 17 | appointments => 18 | this.setState({ 19 | appointments, 20 | isLoading: false 21 | }), 22 | true 23 | ) 24 | } 25 | 26 | componentWillUnmount() { 27 | this.unsubscribe() 28 | } 29 | 30 | render() { 31 | if (this.state.isLoading) { 32 | return 33 | } 34 | 35 | const appointments = Object.entries(this.state.appointments) 36 | // remove keys with no appointments 37 | .filter(([_, appointments]) => Boolean(appointments.length)) 38 | // display remaining keys 39 | .map(([title, appointments], i) => ( 40 | 41 | {title} 42 | {appointments.map((appointment, i) => { 43 | return ( 44 | 51 | ) 52 | })} 53 | 54 | )) 55 | 56 | if (Boolean(appointments.length)) { 57 | return ( 58 | 59 | {appointments} 60 | 61 | ) 62 | } 63 | 64 | return ( 65 | 66 | No more appointments this week 67 | 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /mybuddy/components/DrawerMenu.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { SafeAreaView, StyleSheet } from 'react-native' 3 | import { DrawerItems } from 'react-navigation' 4 | import { Text, Content, View } from 'native-base' 5 | 6 | import Colors from '../constants/Colors' 7 | import firebase from '../firebase' 8 | import SwipeToConfirm from './SwipeToConfirm' 9 | 10 | export default class DrawerMenu extends Component { 11 | state = { 12 | displayName: '' 13 | } 14 | 15 | componentDidMount() { 16 | this.unsubscribe = firebase.auth().onAuthStateChanged(user => { 17 | this.setState({ displayName: user ? user.displayName : '' }) 18 | }) 19 | } 20 | 21 | componentWillUnmount() { 22 | this.unsubscribe() 23 | } 24 | 25 | render() { 26 | return ( 27 | <> 28 | 29 | {this.state.displayName} 30 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | } 45 | 46 | // https://reactnavigation.org/docs/en/drawer-navigator.html#contentoptions-for-draweritems 47 | const contentOptions = { 48 | activeTintColor: 'white', 49 | activeBackgroundColor: Colors.secondaryColor, 50 | inactiveTintColor: 'white', 51 | inactiveBackgroundColor: Colors.primaryColor, 52 | itemsContainerStyle: { 53 | marginVertical: 0 54 | }, 55 | iconContainerStyle: { 56 | opacity: 1 57 | } 58 | } 59 | 60 | const styles = StyleSheet.create({ 61 | container: { 62 | backgroundColor: Colors.primaryColor, 63 | flex: 1 64 | }, 65 | displayName: { 66 | textAlign: 'center', 67 | fontSize: 40, 68 | color: 'white', 69 | flexWrap: 'wrap', 70 | paddingTop: 5, 71 | paddingBottom: 5 72 | }, 73 | drawer: { 74 | flex: 1 75 | } 76 | }) 77 | -------------------------------------------------------------------------------- /mybuddy/components/Navigation/withHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import { Header, Left, Body, Right, Button, Icon, Title } from 'native-base' 4 | import { createStackNavigator } from 'react-navigation' 5 | import PropTypes from 'prop-types' 6 | 7 | import DEFAULT_COLORS from '../../constants/Colors' 8 | 9 | // https://github.com/react-navigation/react-navigation/issues/1886#issuecomment-311665040 10 | const HeaderBar = ({ navigation, scene }) => { 11 | const { rightComponent } = scene.descriptor.options 12 | return ( 13 |
18 | 19 | 22 | 23 | 24 | 33 | {navigation.state.key.toUpperCase()} 34 | 35 | 36 | {rightComponent} 37 |
38 | ) 39 | } 40 | 41 | HeaderBar.propTypes = { 42 | navigation: PropTypes.shape({ 43 | openDrawer: PropTypes.func.isRequired, 44 | closeDrawer: PropTypes.func.isRequired, 45 | toggleDrawer: PropTypes.func.isRequired, 46 | dispatch: PropTypes.func.isRequired, 47 | state: PropTypes.object.isRequired 48 | }).isRequired 49 | } 50 | 51 | const styles = StyleSheet.create({ 52 | header: { 53 | backgroundColor: DEFAULT_COLORS.primaryColor, 54 | paddingBottom: 10 55 | }, 56 | headerItem: { 57 | color: 'white' 58 | } 59 | }) 60 | 61 | export default (screen, routeName) => { 62 | screen.navigationOptions = { ...screen.navigationOptions, header: HeaderBar } 63 | return createStackNavigator( 64 | { [routeName]: screen }, 65 | { 66 | headerMode: 'screen', 67 | navigationOptions: { 68 | gesturesEnabled: false 69 | } 70 | } 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /mybuddy/utils/AsyncStorage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class to manage asynchronised storage on the device. 3 | */ 4 | 5 | import { AsyncStorage } from 'react-native' 6 | 7 | /** 8 | * Stores an object with the key specified. 9 | * Will overwrite the key by default and has option to merge. 10 | * key - The key to store with the object. 11 | * obj - The object to store. 12 | * isMerging - If we want to merge. 13 | */ 14 | AsyncStorage.storeObj = async (key, obj, isMerging = false) => { 15 | // console.log(`-> Storing object with key ${key}:`, obj) 16 | try { 17 | if (isMerging) { 18 | let objInStorage = await this.retrieveObj(key) //Deserialized object as JSON array. 19 | if (!objInStorage) objInStorage = {} //If the object is empty, create an empty object in order to merge. 20 | let obj = Object.assign(objInStorage, obj) 21 | } 22 | const serialisedObj = JSON.stringify(obj) 23 | if (!serialisedObj) { //Check if the object is serializable. 24 | console.log( 25 | `Could not store object for key ${key} as its serialised value is:`, 26 | serialisedObj 27 | ) 28 | return 29 | } 30 | await AsyncStorage.setItem(key, serialisedObj) 31 | // console.log(`Successfully stored object with key ${key}:`, obj) 32 | } catch (error) { 33 | // Error retrieving data 34 | console.log(`Error storing object with key ${key}:`, obj) 35 | console.log(error) 36 | } 37 | } 38 | 39 | /** 40 | * Retrieve an object with the specified key it was serialised with. 41 | * key - The key the object was saved with. 42 | */ 43 | AsyncStorage.retrieveObj = async key => { 44 | // console.log(`-> Retrieving object with key: ${key}`) 45 | try { 46 | let savedObject = await AsyncStorage.getItem(key) 47 | if (savedObject !== null) { 48 | // console.log(`Successfully retrieved object with {${key}: ${val}}`) 49 | savedObject = JSON.parse(savedObject) 50 | // console.log(`Successfully deserialised object with ${key}`, val) 51 | } 52 | return savedObject 53 | } catch (error) { 54 | console.log(`Error retrieving object with key ${key}`) 55 | console.log(error) 56 | return null 57 | } 58 | } 59 | 60 | export default AsyncStorage 61 | -------------------------------------------------------------------------------- /mybuddy/screens/TeamsScreen/Forms/AddUserToTeam.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, Text, Button, CardItem, Body } from 'native-base' 3 | 4 | import MyTitle from '../../../components/MyTitle' 5 | import { db } from '../../../firebase' 6 | import { usersWithoutTeamOnSnapshot } from '../../../api' 7 | 8 | export default class AddUserToTeam extends Component { 9 | state = { 10 | team: {}, 11 | users: [] 12 | } 13 | 14 | async componentDidMount() { 15 | const team = this.props.navigation.getParam('team') 16 | this.setState({ 17 | team 18 | }) 19 | 20 | this.unsubscribe = await usersWithoutTeamOnSnapshot(users => 21 | this.setState({ users }) 22 | ) 23 | } 24 | 25 | componentWillUnmount() { 26 | this.unsubscribe() 27 | } 28 | 29 | async _handleSubmit(userId) { 30 | // add selected user to current team on firebase 31 | db.collection('users') 32 | .doc(userId) 33 | .update({ 34 | team: db.doc('teams/' + this.team.id) 35 | }) 36 | this.props.navigation.goBack() 37 | } 38 | 39 | _renderUsers = () => { 40 | return this.state.users.map(user => ( 41 | this._handleSubmit(user.uid)} 47 | > 48 | 49 | {user.displayName} 50 | {user.email} 51 | 52 | 53 | )) 54 | } 55 | 56 | render() { 57 | return ( 58 | 59 | 62 | 63 | Add User to Team 64 | 65 | Team: {this.state.team.name} 66 | 67 | {this.state.users.length ? ( 68 | this._renderUsers() 69 | ) : ( 70 | 71 | No users without a team found 72 | 73 | )} 74 | 75 | 76 | ) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /mybuddy/screens/TeamsScreen/Forms/AddTeam.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, Text, Button } from 'native-base' 3 | import { StyleSheet } from 'react-native' 4 | import { withFormik, Field } from 'formik' 5 | import * as yup from 'yup' 6 | 7 | import { db } from '../../../firebase' 8 | import MyTitle from '../../../components/MyTitle' 9 | import FormInput from '../../../components/Form/FormInput' 10 | import ManagerPicker from '../../../components/Form/ManagerPicker' 11 | 12 | class AddTeam extends Component { 13 | render() { 14 | return ( 15 | 16 | 24 | 25 | Add New Team 26 | 27 | 28 | 36 | 37 | 38 | ) 39 | } 40 | } 41 | 42 | export default withFormik({ 43 | mapPropsToValues: () => { 44 | return { 45 | name: '', 46 | manager: '' 47 | } 48 | }, 49 | handleSubmit: async (values, { props, setSubmitting }) => { 50 | console.log(values) 51 | 52 | try { 53 | const team = await db.collection('teams').add({ 54 | name: values.name, 55 | manager: db.collection('users').doc(values.manager) 56 | }) 57 | await db 58 | .collection('users') 59 | .doc(values.manager) 60 | .update({ 61 | team, 62 | accessLevel: 'manager' 63 | }) 64 | props.navigation.goBack() 65 | } catch (error) { 66 | console.warn(error) 67 | setFieldError('root', 'Unable to complete your request.') 68 | setSubmitting(false) 69 | } 70 | }, 71 | validationSchema: yup.object({ 72 | name: yup 73 | .string() 74 | .max(15) 75 | .required(), 76 | manager: yup.string().required('you must select a manager') 77 | }) 78 | })(AddTeam) 79 | -------------------------------------------------------------------------------- /mybuddy/screens/UnauthorisedUserScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Text, Button, View } from 'native-base' 3 | import { StyleSheet } from 'react-native' 4 | 5 | import LoadingPage from '../components/LoadingPage' 6 | import { getCurrentUserAsync } from '../api' 7 | import { logout } from '../api/auth' 8 | import { LOGIN_SCREEN, UNAUTHORISED_USER_SCREEN } from '../navigation/keys' 9 | import { getInitialRouteName } from '../navigation/utils' 10 | 11 | export class UnauthorisedUserScreen extends Component { 12 | state = { 13 | isLoading: false 14 | } 15 | 16 | _logout = async () => { 17 | await logout() 18 | this.props.navigation.navigate(LOGIN_SCREEN) 19 | } 20 | 21 | _reload = async () => { 22 | // Show loading screen when initializing 23 | this.setState({ isLoading: true }) 24 | try { 25 | const user = await getCurrentUserAsync() 26 | 27 | // User is unauthorized 28 | if (!user) this._logout() 29 | 30 | // Take the user to the unauthorized screen and display instructions 31 | const initialRouteName = getInitialRouteName(user) 32 | if (initialRouteName !== UNAUTHORISED_USER_SCREEN) { 33 | this.props.navigation.navigate(initialRouteName) 34 | } else { 35 | this.setState({ isLoading: false }) 36 | } 37 | } catch (error) { 38 | error.message = '[Unauthorized User]: ' + error.message 39 | console.warn(error) 40 | } 41 | } 42 | 43 | render() { 44 | if (this.state.isLoading) { 45 | return 46 | } 47 | 48 | return ( 49 | 50 | 51 | You currently do not have access rights to use this application, 52 | please contact an admin or your team manager. Press below to try 53 | again, or to sign out. 54 | 55 | 56 | 59 | 62 | 63 | 64 | ) 65 | } 66 | } 67 | 68 | const styles = StyleSheet.create({ 69 | container: { 70 | justifyContent: 'space-evenly' 71 | }, 72 | buttons: { 73 | flexDirection: 'row', 74 | justifyContent: 'space-around', 75 | paddingTop: 20 76 | } 77 | }) 78 | -------------------------------------------------------------------------------- /mybuddy/components/CardList/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Card, CardItem, Text, Icon, Title } from 'native-base' 3 | import PropTypes from 'prop-types' 4 | import { StyleSheet } from 'react-native' 5 | import Inflector from 'inflector-js' 6 | import { withNavigation } from 'react-navigation' 7 | 8 | import CustomCardItem from '../CardItem' 9 | import { 10 | APPOINTMENTS_SCREEN, 11 | BUDDY_APPOINTMENTS_SCREEN, 12 | EMERGENCIES_SCREEN 13 | } from '../../navigation/keys' 14 | 15 | const styles = StyleSheet.create({ 16 | card: { alignSelf: 'center', width: '95%' } 17 | }) 18 | 19 | const toTitleCase = text => 20 | Inflector.pluralize(text) 21 | .toLowerCase() 22 | .split('-') 23 | .map(word => Inflector.capitalize(word)) 24 | .join(' ') 25 | 26 | class CustomCardList extends Component { 27 | state = { 28 | showList: false 29 | } 30 | 31 | _toggleList = () => { 32 | this.setState(({ showList }) => ({ 33 | showList: !showList 34 | })) 35 | } 36 | 37 | _navigateTo = () => { 38 | switch (this.props.type) { 39 | case 'appointment': 40 | return APPOINTMENTS_SCREEN 41 | case 'buddy-appointment': 42 | return BUDDY_APPOINTMENTS_SCREEN 43 | case 'emergency': 44 | return EMERGENCIES_SCREEN 45 | default: 46 | return '' 47 | } 48 | } 49 | 50 | render() { 51 | const { type, ...props } = this.props 52 | return ( 53 | 54 | 55 | 56 | {toTitleCase(type)} 57 | 62 | 63 | {this.state.showList && ( 64 | this.props.navigation.navigate(this._navigateTo())} 68 | > 69 | 70 | View More 71 | 72 | 73 | )} 74 | {this.state.showList && 75 | this.props.data.map((datum, i) => ( 76 | 77 | ))} 78 | 79 | ) 80 | } 81 | } 82 | 83 | CustomCardList.propTypes = { 84 | data: PropTypes.array.isRequired 85 | } 86 | 87 | export default withNavigation(CustomCardList) 88 | -------------------------------------------------------------------------------- /mybuddy/screens/BuddyAppointmentsScreen/CalendarScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { View } from 'react-native' 3 | import { Agenda } from 'react-native-calendars' 4 | import moment from 'moment' 5 | 6 | import { buddyAppointmentsBetweenDatesOnSnapshot } from '../../api' 7 | import Card from '../../components/Card' 8 | 9 | export default class CalendarScreen extends Component { 10 | state = { 11 | items: {} 12 | } 13 | 14 | subscriptions = [] 15 | 16 | componentWillUnmount() { 17 | this.subscriptions.forEach(unsubscribe => unsubscribe()) 18 | } 19 | 20 | _loadItems = async day => { 21 | if (this.state.items.hasOwnProperty(day.dateString)) return 22 | 23 | // Extract a specific month 24 | const startOfMonth = moment(day.dateString).startOf('month') 25 | const endOfMonth = moment(day.dateString).endOf('month') 26 | 27 | const unsubscribe = await buddyAppointmentsBetweenDatesOnSnapshot( 28 | startOfMonth.toDate(), 29 | endOfMonth.toDate() 30 | )(appointments => { 31 | let items = {} 32 | 33 | for ( 34 | let m = moment(startOfMonth); 35 | m.isSameOrBefore(endOfMonth); 36 | m.add(1, 'days') 37 | ) { 38 | items[m.format('YYYY-MM-DD')] = [] 39 | } 40 | 41 | appointments.forEach(appointment => { 42 | // Normalize the appointments' times fetched from the database 43 | const dateString = moment(appointment.startDateTime).format( 44 | 'YYYY-MM-DD' 45 | ) 46 | // Merge it with existing calendar items 47 | items = { 48 | ...items, 49 | [dateString]: [...items[dateString], appointment] 50 | } 51 | }) 52 | 53 | this.setState(prevState => ({ items: { ...prevState.items, ...items } })) 54 | }) 55 | 56 | this.subscriptions.push(unsubscribe) 57 | } 58 | 59 | _rowHasChanged = (r1, r2) => { 60 | // Detect if a specific row has changed 61 | return r1 !== r2 62 | } 63 | 64 | render() { 65 | return ( 66 | (this.calendar = c)} 68 | items={this.state.items} 69 | loadItemsForMonth={this._loadItems} 70 | renderItem={this._renderItem} 71 | renderEmptyDate={this._renderEmptyDate} 72 | rowHasChanged={this._rowHasChanged} 73 | /> 74 | ) 75 | } 76 | 77 | _renderItem = item => { 78 | return 79 | } 80 | 81 | _renderEmptyDate = () => ( 82 | // Render an empty date to fill up the days with no agenda 83 | 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /mybuddy/screens/AppointmentsScreen/AppointmentsTabNavigator/CalendarScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { View } from 'react-native' 3 | import { Agenda } from 'react-native-calendars' 4 | import moment from 'moment' 5 | 6 | import { appointmentsBetweenDatesOnSnapshot } from '../../../api' 7 | import Card from '../../../components/Card' 8 | 9 | export default class CalendarScreen extends Component { 10 | state = { 11 | items: {} 12 | } 13 | 14 | subscriptions = [] 15 | 16 | componentWillUnmount() { 17 | this.subscriptions.forEach(unsubscribe => unsubscribe()) 18 | } 19 | 20 | _loadItems = async day => { 21 | if (this.state.items.hasOwnProperty(day.dateString)) return 22 | 23 | // Extract a specific month 24 | const startOfMonth = moment(day.dateString).startOf('month') 25 | const endOfMonth = moment(day.dateString).endOf('month') 26 | 27 | const unsubscribe = await appointmentsBetweenDatesOnSnapshot( 28 | startOfMonth.toDate(), 29 | endOfMonth.toDate() 30 | )(appointments => { 31 | let items = {} 32 | 33 | // create object of arrays for every date in month 34 | for ( 35 | let m = moment(startOfMonth); 36 | m.isSameOrBefore(endOfMonth); 37 | m.add(1, 'days') 38 | ) { 39 | items[m.format('YYYY-MM-DD')] = [] 40 | } 41 | 42 | // add appointments array on each date 43 | appointments.forEach(appointment => { 44 | // Normalize the appointments' times fetched from the database 45 | const dateString = moment(appointment.startDateTime).format( 46 | 'YYYY-MM-DD' 47 | ) 48 | // Merge it with existing calendar items 49 | items = { 50 | ...items, 51 | [dateString]: [...items[dateString], appointment] 52 | } 53 | }) 54 | 55 | this.setState(prevState => ({ items: { ...prevState.items, ...items } })) 56 | }) 57 | 58 | this.subscriptions.push(unsubscribe) 59 | } 60 | 61 | _rowHasChanged = (r1, r2) => { 62 | // Detect if a specific row has changed 63 | return r1 !== r2 64 | } 65 | 66 | render() { 67 | return ( 68 | (this.calendar = c)} 70 | items={this.state.items} 71 | loadItemsForMonth={this._loadItems} 72 | renderItem={this._renderItem} 73 | renderEmptyDate={this._renderEmptyDate} 74 | rowHasChanged={this._rowHasChanged} 75 | /> 76 | ) 77 | } 78 | 79 | _renderItem = item => { 80 | return 81 | } 82 | 83 | _renderEmptyDate = () => ( 84 | // Render an empty date to fill up the days with no agenda 85 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /mybuddy/components/CardItem/BuddyAppointmentCardItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Text, CardItem, Button, Icon } from 'native-base' 3 | import PropTypes from 'prop-types' 4 | 5 | import AppointmentInfo from './AppointmentInfo' 6 | 7 | export default class BuddyAppointmentCardItem extends Component { 8 | state = { 9 | clinicianName: '' 10 | } 11 | 12 | async componentDidMount() { 13 | try { 14 | const { user } = this.props 15 | const { displayName } = await user.get().then(doc => doc.data()) 16 | this.setState({ clinicianName: displayName }) 17 | } catch (error) { 18 | this.setState({ clinicianName: 'unavailable' }) 19 | } 20 | } 21 | 22 | _handleSendReminder = () => {} 23 | 24 | _handleAccept = () => { 25 | this.props.appointmentRef.update({ 'buddy.status': 'confirmed' }) 26 | } 27 | 28 | _handleReject = () => { 29 | this.props.appointmentRef.update({ 30 | 'buddy.user': null, 31 | 'buddy.status': null 32 | }) 33 | } 34 | 35 | render() { 36 | return ( 37 | <> 38 | 44 | 45 | ) 46 | } 47 | 48 | _renderRequester = () => { 49 | return ( 50 | <> 51 | 56 | {this.state.clinicianName} 57 | 58 | ) 59 | } 60 | 61 | _renderButtons = () => { 62 | const { 63 | buddy: { status } 64 | } = this.props 65 | return ( 66 | 67 | {status === 'confirmed' ? ( 68 | <> 69 | 77 | 78 | ) : ( 79 | <> 80 | 88 | 96 | 97 | )} 98 | 99 | ) 100 | } 101 | } 102 | 103 | BuddyAppointmentCardItem.propTypes = { 104 | appointmentRef: PropTypes.object.isRequired, 105 | buddy: PropTypes.shape({ status: PropTypes.string }).isRequired 106 | } 107 | -------------------------------------------------------------------------------- /mybuddy/components/Dropdown.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Platform, StyleSheet } from 'react-native' 3 | import { View, Text } from 'native-base' 4 | import PropTypes from 'prop-types' 5 | import RNPickerSelect from 'react-native-picker-select' 6 | 7 | export default class Dropdown extends Component { 8 | constructor(props) { 9 | super(props) 10 | this._normalizeItems = this._normalizeItems.bind(this) 11 | this.items = null 12 | } 13 | /** 14 | * items are the dropdown selections 15 | * format: {label: 'Orange', value: 'orange', key: 'orange', color: 'orange'} 16 | * label and value are required, key and color are optional 17 | * value can be any data type 18 | * must have at least one item, cannot be empty 19 | */ 20 | static propTypes = { 21 | style: PropTypes.object, 22 | placeholderText: PropTypes.string, // placeholder={{label: 'Select a color...',value: null}} 23 | dropdownTitle: PropTypes.string, 24 | onValueChange: PropTypes.func.isRequired, 25 | items: PropTypes.arrayOf(PropTypes.object).isRequired, // [{label: 'Orange', value: 'orange'},...] 26 | value: PropTypes.any 27 | } 28 | 29 | componentWillMount = async () => { 30 | console.log(`Normalizing array items input: ${JSON.stringify(this.props.items)}`) 31 | await this._normalizeItems() 32 | console.log(`Items normalized: ${JSON.stringify(this.items)}`) 33 | } 34 | 35 | _normalizeItems = () => { 36 | this.items = this.props.items.map((item) => { 37 | return { 38 | label: item, 39 | value: item 40 | } 41 | }) 42 | } 43 | 44 | render() { 45 | return ( 46 | 47 | 57 | {this.props.dropdownTitle} 58 | 59 | 70 | 71 | ) 72 | } 73 | } 74 | 75 | const styles = StyleSheet.create({ 76 | defaultStyles: { 77 | marginLeft: 30, 78 | marginRight: 30, 79 | width: '100%' 80 | } 81 | }) 82 | 83 | const pickerSelectStyles = StyleSheet.create({ 84 | inputIOS: { 85 | marginLeft: 30, 86 | marginRight: 30, 87 | fontSize: 12, 88 | paddingTop: 13, 89 | paddingHorizontal: 10, 90 | paddingBottom: 12, 91 | borderWidth: 1, 92 | borderColor: 'gainsboro', 93 | borderRadius: 4, 94 | backgroundColor: 'white', 95 | color: 'black' 96 | } 97 | }) 98 | -------------------------------------------------------------------------------- /mybuddy/screens/TeamsScreen/TeamsScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { db } from '../../firebase' 3 | import { StyleSheet } from 'react-native' 4 | import { 5 | Container, 6 | Text, 7 | Content, 8 | CardItem, 9 | Body, 10 | Icon, 11 | Right 12 | } from 'native-base' 13 | import ActionButton from 'react-native-action-button' 14 | import omit from 'lodash/omit' 15 | 16 | import DEFAULT_COLORS from '../../constants/Colors' 17 | import SearchBar from '../../components/SearchBar' 18 | import { allTeamsOnSnapshot } from '../../api' 19 | 20 | export default class TeamsScreen extends Component { 21 | state = { 22 | teams: [], 23 | visibleTeams: [] 24 | } 25 | 26 | async componentDidMount() { 27 | this.unsubscribe = await allTeamsOnSnapshot(async teams => { 28 | teams = await Promise.all( 29 | teams.map(async team => { 30 | const manager = await team.manager 31 | .get() 32 | .then(doc => ({ ...doc.data(), id: doc.id })) 33 | console.log(manager.displayName) 34 | return { ...team, manager } 35 | }) 36 | ) 37 | this.setState({ teams, visibleTeams: teams }) 38 | }) 39 | } 40 | 41 | componentWillUnmount() { 42 | this.unsubscribe() 43 | } 44 | 45 | _search = visibleTeams => { 46 | // Search for all teams available 47 | this.setState({ visibleTeams }) 48 | } 49 | 50 | _renderTeams = () => { 51 | return this.state.visibleTeams.map(team => ( 52 | 58 | this.props.navigation.navigate('TeamMembers', { 59 | managerId: team.manager.id 60 | }) 61 | } 62 | > 63 | 64 | {team.name} 65 | Manager: {team.manager.displayName || 'n/a'} 66 | 67 | 68 | 69 | 70 | 71 | )) 72 | } 73 | 74 | render() { 75 | return ( 76 | 77 | 83 | {this._renderTeams()} 84 | this.props.navigation.navigate('AddTeamForm')} 87 | renderIcon={() => ( 88 | 93 | )} 94 | /> 95 | 96 | ) 97 | } 98 | } 99 | 100 | const styles = StyleSheet.create({ 101 | actionButtonIcon: { 102 | fontSize: 20, 103 | height: 22, 104 | color: 'white' 105 | } 106 | }) 107 | -------------------------------------------------------------------------------- /mybuddy/components/Form/ManagerPicker.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { View, Text, Button, Row, Item, Label, Input } from 'native-base' 3 | import { StyleSheet } from 'react-native' 4 | import RNPickerSelect from 'react-native-picker-select' 5 | 6 | import { db } from '../../firebase' 7 | 8 | export default class ManagerPicker extends Component { 9 | state = { 10 | selectedManager: {}, 11 | users: [] 12 | } 13 | 14 | async componentDidMount() { 15 | await db 16 | .collection('users') 17 | .where('team', '==', null) 18 | .get() 19 | .then(querySnapshot => 20 | querySnapshot.docs.forEach(doc => 21 | this.setState(prevState => { 22 | const { displayName } = doc.data() 23 | return { 24 | users: [ 25 | ...prevState.users, 26 | { 27 | label: displayName, 28 | value: { displayName, user: doc.id } 29 | } 30 | ] 31 | } 32 | }) 33 | ) 34 | ) 35 | } 36 | 37 | _updateFormik = value => { 38 | const { 39 | field, 40 | form: { setFieldValue } 41 | } = this.props 42 | setFieldValue(field.name, value) 43 | } 44 | 45 | _selectManager = selectedManager => { 46 | this.setState({ selectedManager }) 47 | this._updateFormik(selectedManager.user) 48 | } 49 | 50 | render() { 51 | const { 52 | field, 53 | form: { touched, errors }, 54 | required 55 | } = this.props 56 | return ( 57 | 58 | 59 | 60 | 65 | 66 | 71 | 72 | 73 | 74 | (this.pickerSelectRef = ps)} 80 | > 81 | 84 | 85 | 86 | 87 | {touched[field.name] && errors[field.name] && ( 88 | 89 | {errors[field.name]} 90 | 91 | )} 92 | 93 | ) 94 | } 95 | } 96 | 97 | const styles = StyleSheet.create({ 98 | item: { 99 | alignSelf: 'center', 100 | flex: 1, 101 | marginLeft: 20, 102 | marginRight: 20, 103 | marginBottom: 5 104 | } 105 | }) 106 | -------------------------------------------------------------------------------- /mybuddy/screens/TeamsScreen/Forms/EditTeam.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, Text, Button, View } from 'native-base' 3 | import { StyleSheet } from 'react-native' 4 | import { withFormik, Field } from 'formik' 5 | import * as yup from 'yup' 6 | 7 | import MyTitle from '../../../components/MyTitle' 8 | import FormInput from '../../../components/Form/FormInput' 9 | import { db } from '../../../firebase' 10 | import ManagerPicker from '../../../components/Form/ManagerPicker' 11 | 12 | class EditTeam extends Component { 13 | render () { 14 | return ( 15 | 16 | 19 | 20 | Edit Team 21 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | ) 33 | } 34 | } 35 | 36 | const styles = StyleSheet.create({ 37 | form: { 38 | paddingTop: 10, 39 | paddingBottom: 10 40 | }, 41 | buttons: { 42 | flexDirection: 'row', 43 | justifyContent: 'space-evenly', 44 | paddingTop: 20 45 | } 46 | }) 47 | 48 | export default withFormik({ 49 | mapPropsToValues: props => { 50 | const team = props.navigation.getParam('team') || {} 51 | return { 52 | name: team.name || '', 53 | manager: team.manager || '' 54 | } 55 | }, 56 | handleSubmit: async (values, { setSubmitting }) => { 57 | const team = this.props.navigation.getParam('team') 58 | if (team.manager.id === values.manager.id) { 59 | // If the Manager has not been updated 60 | db.collection('teams') 61 | .doc(team.id) 62 | .set({ 63 | name: values.name 64 | }) 65 | } else { 66 | // If the Manager has been updated 67 | // Update Access of Previous Manager 68 | db.collection('users') 69 | .doc(team.manager.id) 70 | .set({ 71 | access: 'clinician', 72 | team: null 73 | }) 74 | // Add new manager to the team and update that user's access 75 | await db 76 | .collection('teams') 77 | .doc(team.id) 78 | .set({ 79 | name: values.name, 80 | manager: db.doc('users/' + values.manager.id) 81 | }) 82 | .then(docRef => { 83 | db.collection('users') 84 | .doc(values.manager.id) 85 | .update({ 86 | team: db.doc('teams/' + docRef.id), 87 | accessLevel: 'manager' 88 | }) 89 | }) 90 | } 91 | props.navigation.goBack() 92 | }, 93 | validationSchema: yup.object({ 94 | name: yup 95 | .string() 96 | .max(15) 97 | .required(), 98 | manager: yup.string().required('you must select a manager') 99 | }) 100 | })(EditTeam) 101 | -------------------------------------------------------------------------------- /mybuddy/utils/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Responsible for customising the way errors are received and handled. 3 | * A custom global error handler is created. 4 | */ 5 | 6 | import React from 'react' 7 | import { Alert } from 'react-native' 8 | import { Updates } from 'expo' 9 | 10 | if(!__DEV__) { 11 | // Console.log will be disabled in production 12 | console.log = () => {} 13 | } 14 | 15 | // The default error handler. 16 | const defaultErrorHandler = ErrorUtils.getGlobalHandler() 17 | 18 | /** 19 | * Builds the global error handler for errors throughout the application. 20 | * @param error - The error to handle. 21 | * @param isFatal - If the error is fatal (will kill the app). 22 | */ 23 | const globalErrorHandler = (error, isFatal) => { 24 | console.log(`Error: { isFatal:${isFatal}, error:${error}}`) 25 | // Display different message if error is fatal/non-fatal. 26 | isFatal 27 | ? error.message = "[Fatal unhandled exception] " + error.message 28 | : error.message = "[Nonfatal unhandled exception] " + error.message 29 | console.log(error) 30 | if(!__DEV__) { 31 | // Use alert popup if the application is not in developer mode. 32 | Alert.alert( 33 | 'Oops!', 34 | 'An unknown error occurred\nPlease check and try again', 35 | [ 36 | { 37 | text: 'Restart the App', 38 | onPress: () => Updates.reload() 39 | }, 40 | { 41 | text: 'OK', 42 | onPress: () => console.log('OK Pressed, error message dismissed'), 43 | style: 'cancel' 44 | } 45 | ], 46 | { cancelable: false } 47 | ) 48 | } 49 | // Pass the error onto the default react native error handler 50 | defaultErrorHandler(error, isFatal) 51 | } 52 | 53 | // Set the global error handler to the custom global error handler. 54 | ErrorUtils.setGlobalHandler(globalErrorHandler) 55 | 56 | /** 57 | * Represents the error boundary class which is a component. 58 | * An error will be displayed as an alert popup with text. 59 | * Displays an option to restart the application or dismiss the error. 60 | * If no errors were caught, it will render its children. 61 | */ 62 | class ErrorBoundary extends React.Component { 63 | constructor(props) { 64 | super(props) 65 | this.state = { 66 | hasError: false 67 | } 68 | } 69 | 70 | /** 71 | * Catches lifecycle errors. 72 | * The error is a string. 73 | * @param error - The error. 74 | * @param information - The information. 75 | */ 76 | async componentDidCatch(error, information) { 77 | console.log(`React native lifecycle exception: ${JSON.stringify(information)}`) 78 | await this.setState({hasError: true}) 79 | error.message = "[React native lifecycle exception] " + error.message 80 | console.log(error) 81 | } 82 | 83 | render() { 84 | if(this.state.hasError && !__DEV__) { 85 | Alert.alert( 86 | 'Oops!', 87 | 'An unknown error occurred\nPlease check and try again', 88 | [ 89 | { 90 | text: 'Restart the App', 91 | onPress: () => Updates.reload() 92 | }, 93 | { 94 | text: 'OK', 95 | onPress: () => console.log('OK Pressed, error message dismissed'), 96 | style: 'cancel' 97 | } 98 | ], 99 | { cancelable: false } 100 | ) 101 | } 102 | return this.props.children 103 | } 104 | } 105 | 106 | export default ErrorBoundary 107 | -------------------------------------------------------------------------------- /mybuddy/navigation/Navigator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages organising the tabs in the navigation. 3 | * Tabs displayed on navigation depends on user access level. 4 | */ 5 | 6 | import React from 'react' 7 | import { createAppContainer, createSwitchNavigator } from 'react-navigation' 8 | import { AsyncStorage } from 'react-native' 9 | import { createDrawerNavigatorWithHeader } from '../components/Navigation' 10 | 11 | import { setTopLevelNavigator } from './NavigationService' 12 | import { getInitialRouteName } from './utils' 13 | 14 | import DrawerMenu from '../components/DrawerMenu' 15 | import { 16 | HomeScreen, 17 | LoginScreen, 18 | AppointmentsScreen, 19 | SettingsScreen, 20 | TeamsScreen, 21 | UnauthorisedUserScreen, 22 | BuddyAppointmentsScreen, 23 | EmergenciesScreen, 24 | UserScreen 25 | } from '../screens' 26 | 27 | import { 28 | APPOINTMENTS_SCREEN, 29 | SETTINGS_SCREEN, 30 | LOGIN_SCREEN, 31 | HOME_SCREEN, 32 | UNAUTHORISED_USER_SCREEN, 33 | CLINICIAN_PORTAL, 34 | TEAM_MANAGER_PORTAL, 35 | ADMIN_PORTAL, 36 | TEAM_SCREEN, 37 | TEAMS_SCREEN, 38 | BUDDY_APPOINTMENTS_SCREEN, 39 | EMERGENCIES_SCREEN, 40 | USERS_SCREEN 41 | } from './keys' 42 | 43 | const ClinicianDrawerNavigator = createDrawerNavigatorWithHeader( 44 | { 45 | [HOME_SCREEN]: HomeScreen, 46 | [APPOINTMENTS_SCREEN]: AppointmentsScreen, 47 | [BUDDY_APPOINTMENTS_SCREEN]: BuddyAppointmentsScreen, 48 | [EMERGENCIES_SCREEN]: EmergenciesScreen, 49 | [SETTINGS_SCREEN]: SettingsScreen 50 | }, 51 | { 52 | contentComponent: DrawerMenu 53 | } 54 | ) 55 | 56 | const TeamManagerDrawerNavigator = createDrawerNavigatorWithHeader( 57 | { 58 | [HOME_SCREEN]: HomeScreen, 59 | [APPOINTMENTS_SCREEN]: AppointmentsScreen, 60 | [BUDDY_APPOINTMENTS_SCREEN]: BuddyAppointmentsScreen, 61 | [EMERGENCIES_SCREEN]: EmergenciesScreen, 62 | // [TEAM_SCREEN]: TeamScreen, 63 | [USERS_SCREEN]: UserScreen, 64 | [SETTINGS_SCREEN]: SettingsScreen 65 | }, 66 | { 67 | contentComponent: DrawerMenu 68 | } 69 | ) 70 | 71 | const AdminDrawerNavigator = createDrawerNavigatorWithHeader( 72 | { 73 | [HOME_SCREEN]: HomeScreen, 74 | [APPOINTMENTS_SCREEN]: AppointmentsScreen, 75 | [BUDDY_APPOINTMENTS_SCREEN]: BuddyAppointmentsScreen, 76 | [EMERGENCIES_SCREEN]: EmergenciesScreen, 77 | [TEAMS_SCREEN]: TeamsScreen, 78 | [USERS_SCREEN]: UserScreen, 79 | [SETTINGS_SCREEN]: SettingsScreen 80 | }, 81 | { 82 | contentComponent: DrawerMenu 83 | } 84 | ) 85 | 86 | const AuthSwitchNavigator = initialRouteName => { 87 | return createSwitchNavigator( 88 | { 89 | [LOGIN_SCREEN]: LoginScreen, 90 | [ADMIN_PORTAL]: AdminDrawerNavigator, 91 | [TEAM_MANAGER_PORTAL]: TeamManagerDrawerNavigator, 92 | [CLINICIAN_PORTAL]: ClinicianDrawerNavigator, 93 | [UNAUTHORISED_USER_SCREEN]: UnauthorisedUserScreen 94 | }, 95 | { initialRouteName, resetOnBlur: true } 96 | ) 97 | } 98 | 99 | export const createNavigator = user => props => { 100 | persistenceKey = __DEV__ ? 'NavigationStateDEV' : null 101 | AsyncStorage.removeItem('NavigationStateDEV') 102 | const initialRouteName = getInitialRouteName(user) 103 | const App = createAppContainer(AuthSwitchNavigator(initialRouteName)) 104 | 105 | return ( 106 | { 108 | setTopLevelNavigator(navigatorRef) 109 | }} 110 | // persistenceKey={persistenceKey} 111 | /> 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /mybuddy/components/CardItem/EmergencyCardItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Platform, Linking } from 'react-native' 3 | import { Button, Col, CardItem, Text, Title, Icon, Row } from 'native-base' 4 | import PropTypes from 'prop-types' 5 | 6 | export default class EmergencyCardItem extends Component { 7 | constructor(props) { 8 | super(props) 9 | this.state = { 10 | user: {}, 11 | appointment: {}, 12 | location: null 13 | } 14 | this._openGps = this._openGps.bind(this) 15 | this._updateEmergency = this._updateEmergency.bind(this) 16 | } 17 | 18 | async componentDidMount() { 19 | const user = await this.props.user.get().then(doc => doc.data()) 20 | const appointment = this.props.appointment 21 | ? await this.props.appointment.get().then(doc => doc.data()) 22 | : {} 23 | let location = this.props.location ? this.props.location : null 24 | this.setState({ user, appointment, location }) 25 | } 26 | 27 | _openGps = () => { 28 | console.log(`Location object acquired: ${JSON.stringify(this.props.location)}`) 29 | const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' }) 30 | const latLng = `${this.props.location.latitude},${this.props.location.longitude}` 31 | const label = 'Custom Label' 32 | const url = Platform.select({ 33 | ios: `${scheme}${label}@${latLng}`, 34 | android: `${scheme}${latLng}(${label})` 35 | }) 36 | Linking.openURL(url) 37 | } 38 | 39 | _updateEmergency = () => { 40 | this.props.emergencyRef.update({ status: 'resolved' }) 41 | this.props.user.update({ currentEmergency: null }) 42 | } 43 | 44 | render() { 45 | return ( 46 | <> 47 | 48 | 49 | {this.state.user.displayName} 50 | 51 | 52 | Appointment Address: {this.state.appointment.address || 'n/a'} 53 | 54 | 55 | 56 | Last Known: { 57 | this.state.location 58 | ? `${this.state.location.street} ${this.state.location.city} ${this.state.location.country} ${this.state.location.postalCode}` 59 | : 'n/a' 60 | } 61 | 62 | 63 | 64 | {this.state.user.email} 65 | 66 | 67 | 68 | {this.state.user.contact || 'n/a'} 69 | 70 | 71 | 72 | 73 | 81 | 89 | 90 | 91 | ) 92 | } 93 | } 94 | 95 | EmergencyCardItem.propTypes = { 96 | user: PropTypes.object.isRequired, 97 | appointment: PropTypes.object, 98 | emergencyRef: PropTypes.object.isRequired 99 | } 100 | -------------------------------------------------------------------------------- /mybuddy/components/Form/DateTimePicker.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import { Button, Text, View } from 'native-base' 4 | import DateTime from 'react-native-modal-datetime-picker' 5 | import moment from 'moment' 6 | 7 | export default class DateTimePicker extends Component { 8 | state = { 9 | startDateTime: moment(), 10 | isDatePickerVisible: false, 11 | isTimePickerVisible: false 12 | } 13 | 14 | componentDidMount() { 15 | const { 16 | field, 17 | form: { values } 18 | } = this.props 19 | const startDateTime = values[field.name] 20 | if (startDateTime) { 21 | this.setState({ startDateTime: moment(startDateTime) }) 22 | } 23 | } 24 | 25 | _togglePicker = DateOrTime => () => { 26 | this.setState(prevState => ({ 27 | [`is${DateOrTime}PickerVisible`]: !prevState[ 28 | `is${DateOrTime}PickerVisible` 29 | ] 30 | })) 31 | } 32 | 33 | _handleDatePicked = dateJs => { 34 | const date = moment(dateJs) 35 | this.setState( 36 | ({ startDateTime }) => ({ 37 | startDateTime: startDateTime 38 | .year(date.year()) 39 | .month(date.month()) 40 | .date(date.date()) 41 | }), 42 | () => this._updateFormik() 43 | ) 44 | 45 | this._togglePicker('Date')() 46 | } 47 | 48 | _handleTimePicked = timeJs => { 49 | const time = moment(timeJs) 50 | this.setState( 51 | ({ startDateTime }) => ({ 52 | startDateTime: startDateTime.hour(time.hour()).minute(time.minute()) 53 | }), 54 | () => this._updateFormik() 55 | ) 56 | 57 | this._togglePicker('Time')() 58 | } 59 | 60 | _updateFormik = () => { 61 | const { 62 | field, 63 | form: { setFieldValue } 64 | } = this.props 65 | setFieldValue(field.name, this.state.startDateTime.toDate()) 66 | } 67 | 68 | _getDate = () => { 69 | return this.state.startDateTime.format('MMM DD, YYYY') 70 | } 71 | 72 | _getTime = () => { 73 | return this.state.startDateTime.format('HH:mm') 74 | } 75 | 76 | render() { 77 | return ( 78 | 79 | {/* Date Picker */} 80 | 88 | 96 | {/* Time Picker */} 97 | 105 | 115 | 116 | ) 117 | } 118 | } 119 | 120 | const styles = StyleSheet.create({ 121 | container: { 122 | paddingTop: 30, 123 | flexDirection: 'row', 124 | justifyContent: 'center' 125 | }, 126 | item: { 127 | width: '45%', 128 | justifyContent: 'center' 129 | } 130 | }) 131 | -------------------------------------------------------------------------------- /mybuddy/screens/SettingsScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import { 4 | Container, 5 | Content, 6 | Text, 7 | CardItem, 8 | Right, 9 | Left, 10 | Body, 11 | Icon, 12 | Badge, 13 | Row, 14 | Button 15 | } from 'native-base' 16 | 17 | import { TextInput } from 'react-native' 18 | 19 | import { logout } from '../api/auth' 20 | import { getCurrentUserAsync } from '../api/users' 21 | import { LOGIN_SCREEN } from '../navigation/keys' 22 | import { navigate } from '../navigation/NavigationService' 23 | import { db } from '../firebase' 24 | 25 | export class SettingsScreen extends Component { 26 | static navigationOptions = { 27 | rightComponent: ( 28 | { 31 | await logout() 32 | navigate(LOGIN_SCREEN) 33 | }} 34 | > 35 | LOG OUT 36 | 37 | ) 38 | } 39 | 40 | state = { 41 | user: {}, 42 | team: {}, 43 | manager: {}, 44 | contact: '' 45 | } 46 | 47 | async componentDidMount () { 48 | const { team, ...user } = await getCurrentUserAsync() 49 | const { manager, ...teamData } = team 50 | ? await team.get().then(doc => doc.data()) 51 | : {} 52 | const managerData = manager 53 | ? await manager.get().then(doc => doc.data()) 54 | : {} 55 | this.setState({ 56 | user, 57 | team: teamData, 58 | manager: managerData, 59 | contact: user.contact || '' 60 | }) 61 | } 62 | 63 | _handleSubmit () { 64 | if (this.state.contact === this.state.user.contact) { 65 | } else { 66 | db.collection('users') 67 | .doc(this.state.user.uid) 68 | .update({ 69 | contact: this.state.contact 70 | }) 71 | } 72 | } 73 | 74 | render () { 75 | return ( 76 | 77 | 80 | 81 | 82 | 83 | 87 | 88 | {this.state.user.displayName} 89 | {this.state.user.email} 90 | 91 | {this.state.user.accessLevel} 92 | 93 | 94 | 95 | 96 | 97 | 98 | Team 99 | 100 | {this.state.team.name} 101 | 102 | 103 | 104 | Manager 105 | 106 | {this.state.manager.displayName} 107 | 108 | 109 | 110 | {/* */} 111 | Contact Number 112 | {/* */} 113 | 114 | this.setState({ contact: text })} 121 | /> 122 | 123 | 124 | 125 | 126 | ) 127 | } 128 | } 129 | 130 | const styles = StyleSheet.create({ 131 | boldText: { 132 | fontWeight: 'bold' 133 | } 134 | }) 135 | -------------------------------------------------------------------------------- /mybuddy/screens/LoginScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Platform, Linking, StyleSheet, Image, Alert } from 'react-native' 3 | import { Container, Button, Text } from 'native-base' 4 | import { Notifications, Permissions, Constants } from 'expo' 5 | 6 | import Layout from '../constants/Layout' 7 | import { login as authLogin } from '../api/auth' 8 | import { getInitialRouteName } from '../navigation/utils' 9 | 10 | export class LoginScreen extends Component { 11 | _login = async () => { 12 | // Try to login user 13 | const user = await authLogin() 14 | if (!user) return 15 | 16 | // Updates the pushToken so that notifications can come through 17 | const pushToken = (await this._registerForPushNotificationsAsync()) || null 18 | await user.userRef.update({ pushToken }) 19 | 20 | // Take the user to the right screen 21 | const initialRouteName = getInitialRouteName(user) 22 | this.props.navigation.navigate(initialRouteName) 23 | } 24 | 25 | _registerForPushNotificationsAsync = async () => { 26 | if (!Constants.isDevice) return 27 | 28 | const { status: existingStatus } = await Permissions.getAsync( 29 | Permissions.NOTIFICATIONS 30 | ) 31 | 32 | // Check if push notification permission is granted 33 | let finalStatus = existingStatus 34 | if (existingStatus !== 'granted') { 35 | const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS) 36 | finalStatus = status 37 | } 38 | 39 | // If push notification permission is not granted yet 40 | if (finalStatus !== 'granted') { 41 | // Deep link to Settings for iOS 42 | Platform.OS === 'ios' 43 | ? Alert.alert( 44 | 'Enable Notifications', 45 | 'Please enable push notification in settings to get the most out of this app.', 46 | [ 47 | { 48 | text: 'SETTINGS', 49 | onPress: () => { 50 | !__DEV__ 51 | ? Linking.openURL('app-settings://notification/expo') 52 | : Linking.openURL('app-settings:') 53 | }, 54 | style: 'cancel' 55 | }, 56 | { 57 | text: 'OK', 58 | onPress: () => 59 | console.log('OK Pressed, error message dismissed') 60 | } 61 | ], 62 | { cancelable: false } 63 | ) 64 | : Alert.alert( 65 | 'Enable Notifications', 66 | 'Please enable push notification in settings to get the most out of this app.', 67 | [ 68 | { 69 | text: 'OK', 70 | onPress: () => 71 | console.log('OK Pressed, error message dismissed') 72 | } 73 | ], 74 | { cancelable: false } 75 | ) 76 | return 77 | } 78 | 79 | return Notifications.getExpoPushTokenAsync() 80 | } 81 | 82 | render() { 83 | const error = this.props.navigation.getParam('error', null) 84 | return ( 85 | 86 | 92 | {error && {error.message}} 93 | 96 | 97 | ) 98 | } 99 | } 100 | 101 | const styles = StyleSheet.create({ 102 | container: { 103 | justifyContent: 'space-around', 104 | alignItems: 'center' 105 | }, 106 | warning: { 107 | color: 'red' 108 | }, 109 | button: { 110 | alignSelf: 'center' 111 | } 112 | }) 113 | -------------------------------------------------------------------------------- /mybuddy/screens/HomeScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, Button, Text } from 'native-base' 3 | import Carousel from 'react-native-snap-carousel' 4 | 5 | import Card from '../components/Card' 6 | 7 | import { 8 | appointmentsThisWeekOnSnapshot, 9 | buddyAppointmentsThisWeekOnSnapshot, 10 | currentUserOnSnapshot 11 | } from '../api' 12 | import { 13 | APPOINTMENTS_SCREEN, 14 | BUDDY_APPOINTMENTS_SCREEN, 15 | EMERGENCIES_SCREEN 16 | } from '../navigation/keys' 17 | import { allEmergenciesOnSnapshot } from '../api/emergencies' 18 | import Layout from '../constants/Layout' 19 | import DEFAULT_COLORS from '../constants/Colors' 20 | 21 | export class HomeScreen extends Component { 22 | state = { 23 | user: {}, 24 | emergencies: [], 25 | appointments: [], 26 | buddyAppointments: [] 27 | } 28 | 29 | subscriptions = [] 30 | 31 | async componentDidMount() { 32 | const currentUser = await currentUserOnSnapshot(user => 33 | this.setState({ user }) 34 | ) 35 | const appointments = await appointmentsThisWeekOnSnapshot(appointments => 36 | this.setState({ appointments }) 37 | ) 38 | const buddyAppointmentsThisWeek = await buddyAppointmentsThisWeekOnSnapshot(buddyAppointments => 39 | this.setState({ buddyAppointments }) 40 | ) 41 | const allEmergencies = await allEmergenciesOnSnapshot(emergencies => 42 | this.setState({ emergencies }) 43 | ) 44 | // Make promises cancellable 45 | await this.subscriptions.push(currentUser, appointments, buddyAppointmentsThisWeek, allEmergencies) 46 | } 47 | 48 | componentWillUnmount() { 49 | this.subscriptions.forEach(unsubscribe => unsubscribe()) 50 | } 51 | 52 | render() { 53 | return ( 54 | 55 | {this.state.user.currentEmergency && ( 56 | 65 | You have an emergency active! 66 | 67 | )} 68 | 69 | 75 | 81 | 82 | 88 | 94 | 95 | 103 | 109 | 110 | 111 | ) 112 | } 113 | 114 | _renderEmergencies = ({ item, index }) => { 115 | return 116 | } 117 | 118 | _renderAppointments = ({ item, index }) => { 119 | return 120 | } 121 | 122 | _renderBuddyAppointments = ({ item, index }) => { 123 | return 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /mybuddy/screens/TeamsScreen/TeamMembersScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import ActionButton from 'react-native-action-button' 4 | import { 5 | Container, 6 | Text, 7 | Content, 8 | CardItem, 9 | Icon, 10 | Left, 11 | Button, 12 | SwipeRow 13 | } from 'native-base' 14 | 15 | import MyTitle from '../../components/MyTitle' 16 | import Card from '../../components/Card' 17 | import DEFAULT_COLORS from '../../constants/Colors' 18 | 19 | import { managersTeamMembersOnSnapshot } from '../../api' 20 | import { db } from '../../firebase' 21 | 22 | export default class TeamMembersScreen extends Component { 23 | state = { 24 | team: {}, 25 | manager: {}, 26 | users: [] 27 | } 28 | 29 | async componentDidMount() { 30 | const managerId = this.props.navigation.getParam('managerId') 31 | const manager = await db 32 | .collection('users') 33 | .doc(managerId) 34 | .get() 35 | .then(doc => ({ ...doc.data(), userRef: doc.ref, id: doc.id })) 36 | 37 | this.setState({ manager }) 38 | 39 | this.unsubscribe = await managersTeamMembersOnSnapshot(manager)(users => 40 | this.setState({ users }) 41 | ) 42 | } 43 | 44 | componentWillUnmount() { 45 | if (this.unsubscribe) this.unsubscribe() 46 | } 47 | 48 | _renderUsers = () => { 49 | // Check if there are team members 50 | if (!this.state.users.length) { 51 | return No Team Members 52 | } 53 | 54 | return ( 55 | 56 | {this.state.users.map(({ userRef, ...user }) => ( 57 | 62 | 63 | 64 | 69 | {user.displayName} 70 | 71 | 72 | 73 | } 74 | right={ 75 | 78 | } 79 | /> 80 | ))} 81 | 82 | ) 83 | } 84 | 85 | render() { 86 | const team = this.props.navigation.getParam('team') 87 | return ( 88 | 89 | 92 | 93 | 94 | {this.state.team.name} Members 95 | 96 | Manager: {this.state.manager.displayName || 'n/a'} 97 | 98 | {this._renderUsers()} 99 | 100 | 101 | 102 | this.props.navigation.navigate('EditTeam', { team })} 106 | > 107 | 108 | 109 | 110 | 114 | this.props.navigation.navigate('AddUserToTeam', { team }) 115 | } 116 | > 117 | 118 | 119 | 120 | 121 | ) 122 | } 123 | } 124 | 125 | const styles = StyleSheet.create({ 126 | actionButtonIcon: { 127 | fontSize: 20, 128 | height: 22, 129 | color: 'white' 130 | }, 131 | removeIcon: { 132 | fontSize: 40, 133 | color: 'white' 134 | }, 135 | cardItem: { 136 | fontSize: 40, 137 | color: 'gray', 138 | marginLeft: 5, 139 | marginRight: 5 140 | }, 141 | noMembers: { 142 | fontStyle: 'italic', 143 | fontSize: 15 144 | }, 145 | card: { paddingBottom: 0, paddingTop: 0 } 146 | }) 147 | -------------------------------------------------------------------------------- /mybuddy/components/CardItem/AppointmentInfo.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { StyleSheet } from 'react-native' 3 | import { Text, CardItem, Icon, Title, Row, Col } from 'native-base' 4 | import PropTypes from 'prop-types' 5 | import moment from 'moment' 6 | 7 | export default class AppointmentInfo extends Component { 8 | state = { 9 | style: {}, 10 | showDescription: false 11 | } 12 | 13 | componentDidMount() { 14 | this.setState({ 15 | style: { backgroundColor: this.props.backgroundColor || 'white' } 16 | }) 17 | } 18 | 19 | _toggleDescription = () => { 20 | this.setState(({ showDescription }) => ({ 21 | showDescription: !showDescription 22 | })) 23 | } 24 | 25 | render() { 26 | return ( 27 | <> 28 | {this._renderHeader()} 29 | 30 | 31 | {/* Location & Date */} 32 | 33 | 34 | 39 | {this.props.address} 40 | 41 | {this.props.info} 42 | 43 | 44 | {/* Time */} 45 | 46 | 47 | 52 | 53 | {moment(this.props.startDateTime).format('MMM DD, YYYY')} 54 | 55 | 56 | 57 | 62 | 63 | {moment(this.props.startDateTime).format('HH:mm')} -{' '} 64 | {moment(this.props.endDateTime).format('HH:mm')} 65 | 66 | 67 | 68 | 69 | 70 | {this.props.buttonsComponent} 71 | {this._renderDescription()} 72 | 73 | ) 74 | } 75 | 76 | _renderHeader = () => { 77 | if (!this.props.header) return null 78 | 79 | if (this.props.renderHeader) { 80 | // renderProp function 81 | return this.props.renderHeader({ ...this.props }) 82 | } 83 | 84 | if (this.props.isCard) { 85 | return ( 86 | 90 | 91 | {this.props.title} 92 | 93 | 98 | 99 | ) 100 | } 101 | 102 | return ( 103 | 104 | {this.props.title} 105 | 106 | ) 107 | } 108 | 109 | _renderDescription = () => { 110 | return ( 111 | this.state.showDescription && ( 112 | 121 | {this.props.description || 'no details provided'} 122 | 123 | ) 124 | ) 125 | } 126 | } 127 | 128 | AppointmentInfo.propTypes = { 129 | // Data 130 | title: PropTypes.string.isRequired, 131 | address: PropTypes.string.isRequired, 132 | startDateTime: PropTypes.object.isRequired, 133 | endDateTime: PropTypes.object.isRequired, 134 | // Properties 135 | buddy: PropTypes.any.isRequired, 136 | header: PropTypes.bool, 137 | isCard: PropTypes.bool, 138 | renderHeader: PropTypes.func, 139 | buttonsComponent: PropTypes.node, 140 | backgroundColor: PropTypes.string 141 | } 142 | 143 | AppointmentInfo.defaultProps = { 144 | header: true, 145 | isCard: false 146 | } 147 | 148 | const styles = StyleSheet.create({ 149 | center: { 150 | alignItems: 'center' 151 | } 152 | }) 153 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/__snapshots__/SearchBar-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Search bar rendering search bar renders correctly 1`] = ` 4 | 19 | 27 | 48 | 58 | 59 | 106 | 107 | 108 | 109 | 110 | 133 | 144 | 151 | 152 | 153 | 154 | 155 | `; 156 | -------------------------------------------------------------------------------- /mybuddy/screens/UsersScreen/UserScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, Tabs, Tab, Text } from 'native-base' 3 | import _ from 'lodash' 4 | import { UserTab } from './UserTab' 5 | import SearchBar from '../../components/SearchBar' 6 | import firebase from '../../firebase' 7 | import Colors from '../../constants/Colors' 8 | 9 | export class UserScreen extends Component { 10 | constructor() { 11 | super() 12 | this.state = { 13 | loading: false, 14 | data: [], 15 | fullData: [] 16 | } 17 | this.ref = firebase.firestore().collection('users') 18 | this.unsubscribe = null 19 | } 20 | 21 | componentDidMount() { 22 | this.unsubscribe = this.ref.onSnapshot(this._onCollectionUpdate) 23 | } 24 | 25 | componentWillUnmount() { 26 | this.unsubscribe() 27 | } 28 | 29 | _onCollectionUpdate = querySnapshot => { 30 | // If user is part of a team, fetch relevant data from db 31 | const users = [] 32 | querySnapshot.forEach(doc => { 33 | const { displayName, email, team, accessLevel, uid } = doc.data() 34 | if (team) { 35 | this.setState({ loading: true }) 36 | team 37 | .get() 38 | .then(teamDoc => { 39 | const { name } = teamDoc.data() 40 | users.push({ 41 | key: uid, 42 | email, // DocumentSnapshot 43 | displayName, 44 | teamName: name, 45 | accessLevel 46 | }) 47 | this.setState({ loading: false }) 48 | }) 49 | .catch(err => { 50 | console.log(err) 51 | }) 52 | } else { 53 | users.push({ 54 | key: uid, 55 | email, // DocumentSnapshot 56 | displayName, 57 | teamName: 'None', 58 | accessLevel 59 | }) 60 | } 61 | }) 62 | this.setState({ 63 | loading: false, 64 | data: users, 65 | fullData: users 66 | }) 67 | } 68 | 69 | _search = visibleUsers => { 70 | // Search for all users available 71 | this.setState({ data: visibleUsers }) 72 | } 73 | 74 | render() { 75 | if (this.state.loading) { 76 | return ( 77 | 78 | 79 | Loading... 80 | 81 | 82 | ) 83 | } else { 84 | return ( 85 | 86 | 92 | 98 | 108 | 109 | user.accessLevel === 'clinician' 112 | )} 113 | /> 114 | 115 | 116 | 126 | user.accessLevel === 'teamManager' 129 | )} 130 | /> 131 | 132 | 142 | user.accessLevel === 'admin' 145 | )} 146 | /> 147 | 148 | 149 | 150 | ) 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /mybuddy/components/Form/BuddyPicker.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { View, Title, Text, Button, Row, Item, Label, Input } from 'native-base' 3 | import { StyleSheet } from 'react-native' 4 | import RNPickerSelect from 'react-native-picker-select' 5 | 6 | import { db } from '../../firebase' 7 | import { getCurrentUserAsync } from '../../api' 8 | 9 | export default class BuddyPicker extends Component { 10 | state = { 11 | currentBuddy: {}, 12 | selectedBuddy: {}, 13 | team: [] 14 | } 15 | 16 | async componentDidMount() { 17 | const { 18 | field, 19 | form: { values } 20 | } = this.props 21 | 22 | const currentBuddy = values[field.name] 23 | this.setState({ 24 | currentBuddy, 25 | selectedBuddy: currentBuddy 26 | }) 27 | 28 | const user = await getCurrentUserAsync() 29 | 30 | await db 31 | .collection('users') 32 | // all users in the current users team 33 | .where('team', '==', user.team) 34 | .get() 35 | .then(querySnapshot => 36 | querySnapshot.docs 37 | // remove the current user from results 38 | .filter(doc => doc.id !== user.uid) 39 | // all each user the list in state 40 | .forEach(doc => 41 | this.setState(prevState => { 42 | const { displayName } = doc.data() 43 | // user object of shape {label: ..., value: ...} 44 | return { 45 | team: [ 46 | ...prevState.team, 47 | { 48 | label: displayName, 49 | value: 50 | currentBuddy.user === doc.id 51 | ? currentBuddy 52 | : { user: doc.id, status: 'pending' } 53 | } 54 | ] 55 | } 56 | }) 57 | ) 58 | ) 59 | } 60 | 61 | _updateFormik = value => { 62 | const { 63 | field, 64 | form: { setFieldValue } 65 | } = this.props 66 | setFieldValue(field.name, value) 67 | } 68 | 69 | _selectBuddy = selectedBuddy => { 70 | this.setState({ selectedBuddy }) 71 | this._updateFormik(selectedBuddy) 72 | } 73 | 74 | _resetBuddy = () => { 75 | this.setState({ selectedBuddy: this.state.currentBuddy }) 76 | this._updateFormik(this.state.currentBuddy) 77 | } 78 | 79 | render() { 80 | const { 81 | field, 82 | form: { touched, errors } 83 | } = this.props 84 | return ( 85 | 86 | Buddy 87 | 88 | {this._renderField('label', 'Name')} 89 | 90 | (this.pickerSelectRef = ps)} 96 | > 97 | 100 | 101 | 102 | 103 | 104 | {this._renderField('value.status', 'Status')} 105 | 106 | 115 | 116 | 117 | {touched[field.name] && errors[field.name] && ( 118 | 119 | {errors[field.name]} 120 | 121 | )} 122 | {this._renderWarningText()} 123 | 124 | ) 125 | } 126 | 127 | _renderField = (fieldName, label) => { 128 | const buddy = 129 | this.state.team.find( 130 | item => item.value.user === this.state.selectedBuddy.user 131 | ) || {} 132 | let displayValue 133 | if (fieldName === 'label') { 134 | displayValue = buddy.label 135 | } else if (fieldName === 'value.status') { 136 | displayValue = buddy.value ? buddy.value.status : null 137 | } 138 | return ( 139 | <> 140 | 141 | 142 | 143 | 144 | 145 | ) 146 | } 147 | 148 | _renderWarningText = () => { 149 | const { currentBuddy, selectedBuddy } = this.state 150 | if ( 151 | currentBuddy.status === 'confirmed' && 152 | currentBuddy.user !== selectedBuddy.user 153 | ) { 154 | return ( 155 | 163 | Warning: you are about to cancel a confirmed buddy, press reset if you 164 | do not intend to do this. 165 | 166 | ) 167 | } 168 | } 169 | } 170 | 171 | const styles = StyleSheet.create({ 172 | item: { 173 | alignSelf: 'center', 174 | flex: 1, 175 | marginLeft: 20, 176 | marginRight: 20, 177 | marginBottom: 5 178 | } 179 | }) 180 | -------------------------------------------------------------------------------- /mybuddy/components/CardItem/AppointmentCardItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Alert } from 'react-native' 3 | import { Text, CardItem, Button, Row, Icon } from 'native-base' 4 | import { withNavigation } from 'react-navigation' 5 | import PropTypes from 'prop-types' 6 | import moment from 'moment' 7 | import omit from 'lodash/omit' 8 | 9 | import AppointmentInfo from './AppointmentInfo' 10 | import { getCurrentUserAsync } from '../../api' 11 | 12 | class AppointmentCardItem extends Component { 13 | state = { 14 | showDescription: false, 15 | buddyName: null, 16 | statusColor: null 17 | } 18 | 19 | componentDidMount() { 20 | this._setRequester(this.props.buddy) 21 | } 22 | 23 | componentDidUpdate(prevProps) { 24 | if (this.props.buddy !== prevProps.buddy) { 25 | this._setRequester(this.props.buddy) 26 | } 27 | } 28 | 29 | _setRequester = async buddy => { 30 | const buddyName = buddy.user 31 | ? await buddy.user.get().then(doc => doc.data().displayName) 32 | : null 33 | 34 | let statusColor = null 35 | switch (buddy.status) { 36 | case 'confirmed': 37 | statusColor = 'green' 38 | break 39 | case 'pending': 40 | statusColor = 'orange' 41 | break 42 | default: 43 | statusColor = 'red' 44 | } 45 | this.setState({ buddyName, statusColor }) 46 | } 47 | 48 | _handleCheckIn = async () => { 49 | if (moment(this.props.startDateTime) >= moment().add(30, 'minutes')) { 50 | alert('You cannot check in until 30 minutes before your appointment') 51 | return 52 | } 53 | const user = await getCurrentUserAsync() 54 | user.userRef.update({ currentAppointment: this.props.appointmentRef }) 55 | this.props.appointmentRef.update({ status: 'in-progress' }) 56 | } 57 | 58 | _handleCheckOut = async () => { 59 | const user = await getCurrentUserAsync() 60 | user.userRef.update({ currentAppointment: null }) 61 | this.props.appointmentRef.update({ status: 'done' }) 62 | } 63 | 64 | _handleEdit = async () => { 65 | const buddyId = this.props.buddy.user 66 | ? await this.props.buddy.user.get().then(doc => doc.id) 67 | : null 68 | 69 | const updatedAppointment = omit( 70 | { 71 | ...this.props, 72 | buddy: { 73 | ...this.props.buddy, 74 | user: buddyId 75 | } 76 | }, 77 | ['user', 'appointmentRef'] 78 | ) 79 | 80 | this.props.navigation.navigate('AppointmentForm', { 81 | appointment: updatedAppointment 82 | }) 83 | } 84 | 85 | _handleExtend = () => { 86 | // Show message popup and options to extend 87 | Alert.alert('Extend Appointment', 'Add minutes to the end time...', [ 88 | { 89 | text: '15 minutes', 90 | onPress: () => 91 | this.props.appointmentRef.update({ 92 | endDateTime: moment(this.props.endDateTime) 93 | .add(15, 'minutes') 94 | .toDate() 95 | }) 96 | }, 97 | { 98 | text: '30 minutes', 99 | onPress: () => 100 | this.props.appointmentRef.update({ 101 | endDateTime: moment(this.props.endDateTime) 102 | .add(30, 'minutes') 103 | .toDate() 104 | }) 105 | }, 106 | { 107 | text: 'Cancel', 108 | style: 'cancel' 109 | } 110 | ]) 111 | } 112 | 113 | render() { 114 | return ( 115 | <> 116 | 121 | 122 | ) 123 | } 124 | 125 | _renderBuddyInfo = () => { 126 | return ( 127 | <> 128 | 133 | {this.state.buddyName || 'none selected'} 134 | 135 | ) 136 | } 137 | 138 | _renderButtons = () => { 139 | const { status, canEdit, canCheckIn } = this.props 140 | return ( 141 | 142 | {status === 'in-progress' ? ( 143 | <> 144 | 152 | 155 | 156 | ) : ( 157 | <> 158 | {canCheckIn && ( 159 | 167 | )} 168 | {canEdit && ( 169 | 172 | )} 173 | 174 | )} 175 | 176 | ) 177 | } 178 | } 179 | 180 | AppointmentCardItem.propTypes = { 181 | appointmentRef: PropTypes.object.isRequired, 182 | status: PropTypes.string.isRequired, 183 | canEdit: PropTypes.bool.isRequired, 184 | canCheckIn: PropTypes.bool.isRequired 185 | } 186 | 187 | AppointmentCardItem.defaultProps = { 188 | canEdit: false, 189 | canCheckIn: false 190 | } 191 | 192 | export default withNavigation(AppointmentCardItem) 193 | -------------------------------------------------------------------------------- /mybuddy/components/__tests__/__snapshots__/FormInput-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DateTimePicker snapshot form input renders without errors 1`] = ` 4 | 33 | 46 | 56 | 70 | Test 71 | 72 | 73 | 74 | 97 | 98 | `; 99 | 100 | exports[`Form input renders with errors form input renders with errors 1`] = ` 101 | Array [ 102 | 131 | 144 | 154 | 168 | Test 169 | 170 | 171 | 172 | 195 | , 196 | , 216 | ] 217 | `; 218 | -------------------------------------------------------------------------------- /mybuddy/api/appointments.js: -------------------------------------------------------------------------------- 1 | import { db } from '../firebase' 2 | import { getCurrentUserAsync } from './users' 3 | import moment from 'moment' 4 | 5 | const thisWeekClause = [ 6 | [ 7 | 'startDateTime', 8 | '>=', 9 | moment() 10 | .startOf('day') 11 | .toDate() 12 | ], 13 | [ 14 | 'startDateTime', 15 | '<=', 16 | moment() 17 | .startOf('isoWeek') 18 | .add(1, 'week') 19 | .toDate() 20 | ] 21 | ] 22 | 23 | /** 24 | * Turn array of appointments into an object of arrays, 25 | * with keys Today, Tomorrow and This Week. 26 | * @param appointments 27 | */ 28 | const _reshapeWeek = appointments => { 29 | const apps = appointments.reduce( 30 | (obj, appointment) => { 31 | let label 32 | if (moment(appointment.startDateTime).isSame(moment(), 'day')) { 33 | label = 'Today' 34 | } else if ( 35 | moment(appointment.startDateTime).isSame(moment().add(1, 'day'), 'day') 36 | ) { 37 | label = 'Tomorrow' 38 | } else { 39 | label = 'This Week' 40 | } 41 | return { 42 | ...obj, 43 | [label]: [...obj[label], appointment] 44 | } 45 | }, 46 | { Today: [], Tomorrow: [], ['This Week']: [] } 47 | ) 48 | 49 | return apps 50 | } 51 | 52 | /** 53 | * Fetch users appointments this week 54 | * @param onSnapshot function to run on update 55 | * @param reshape boolean if true object grouped today tomorrow this week 56 | * else return array of appoinments 57 | * @returns Promise(unsubscribe) 58 | */ 59 | export const appointmentsThisWeekOnSnapshot = async ( 60 | onSnapshot, 61 | reshape = false 62 | ) => { 63 | const user = await getCurrentUserAsync() 64 | const clauses = [...thisWeekClause, ['user', '==', user.userRef]] 65 | return await _allAppointmentsOnSnapshot( 66 | onSnapshot, 67 | clauses, 68 | reshape ? _reshapeWeek : null, 69 | true 70 | ) 71 | } 72 | 73 | /** 74 | * Fetch all user appointments between dates 75 | * @param startDate Date to start search from (inclusive) 76 | * @param {*} endDate Date to end search on (inclusive) 77 | a* @param onSnapshot function to run on update 78 | * @returns Promise(unsubscribe) 79 | */ 80 | export const appointmentsBetweenDatesOnSnapshot = ( 81 | startDate, 82 | endDate 83 | ) => async onSnapshot => { 84 | const user = await getCurrentUserAsync() 85 | const clauses = [ 86 | ['user', '==', user.userRef], 87 | ['startDateTime', '>=', startDate], 88 | ['startDateTime', '<=', endDate] 89 | ] 90 | return await _allAppointmentsOnSnapshot(onSnapshot, clauses) 91 | } 92 | 93 | /** 94 | * Fetch buddy appointments this week 95 | * @param onSnapshot function to run on update 96 | * @returns Promise(unsubscribe) 97 | */ 98 | export const buddyAppointmentsThisWeekOnSnapshot = async ( 99 | onSnapshot, 100 | reshape = false 101 | ) => { 102 | const user = await getCurrentUserAsync() 103 | const clauses = [ 104 | ...thisWeekClause, 105 | ['buddy.user', '==', user.userRef], 106 | ['buddy.status', '==', 'confirmed'] 107 | ] 108 | return await _allAppointmentsOnSnapshot( 109 | onSnapshot, 110 | clauses, 111 | reshape ? _reshapeWeek : null 112 | ) 113 | } 114 | 115 | /** 116 | * Fetch all buddy appointments between dates 117 | * @param startDate Date to start search from (inclusive) 118 | * @param {*} endDate Date to end search on (inclusive) 119 | a* @param onSnapshot function to run on update 120 | * @returns Promise(unsubscribe) 121 | */ 122 | export const buddyAppointmentsBetweenDatesOnSnapshot = ( 123 | startDate, 124 | endDate 125 | ) => async onSnapshot => { 126 | const user = await getCurrentUserAsync() 127 | const clauses = [ 128 | ['buddy.user', '==', user.userRef], 129 | ['startDateTime', '>=', startDate], 130 | ['startDateTime', '<=', endDate] 131 | ] 132 | return await _allAppointmentsOnSnapshot(onSnapshot, clauses) 133 | } 134 | 135 | /** 136 | * Fetch buddy appointments this week 137 | * @param onSnapshot function to run on update 138 | * @returns Promise(unsubscribe) 139 | */ 140 | export const buddyRequestsOnSnapshot = async onSnapshot => { 141 | const user = await getCurrentUserAsync() 142 | const clauses = [ 143 | ['buddy.user', '==', user.userRef], 144 | ['buddy.status', '==', 'pending'] 145 | ] 146 | return await _allAppointmentsOnSnapshot(onSnapshot, clauses) 147 | } 148 | 149 | /** 150 | * Fetch appointments 151 | * @param onSnapshot function to run update 152 | * @param clauses where conditions 153 | * @param transformation function to transform the shape of the data returned 154 | * @returns Promise(unsubscribe) 155 | */ 156 | const _allAppointmentsOnSnapshot = async ( 157 | onSnapshot, 158 | clauses = [], 159 | transformation = null, 160 | notDone = false 161 | ) => { 162 | let appointmentsRef = db.collection('appointments') 163 | clauses.forEach(clause => { 164 | appointmentsRef = appointmentsRef.where(...clause) 165 | }) 166 | return ( 167 | appointmentsRef 168 | .orderBy('startDateTime') 169 | // listen real time 170 | .onSnapshot( 171 | querySnapshot => { 172 | let appointments = [] 173 | 174 | appointments = querySnapshot.docs 175 | // only keep appointments where status is not done 176 | .filter(doc => (notDone ? doc.data().status !== 'done' : true)) 177 | .map(doc => { 178 | const { startDateTime, endDateTime, ...data } = doc.data() 179 | 180 | return { 181 | ...data, 182 | startDateTime: new Date(startDateTime.seconds * 1000), 183 | endDateTime: new Date(endDateTime.seconds * 1000), 184 | appointmentRef: doc.ref, 185 | id: doc.id 186 | } 187 | }) 188 | 189 | if (transformation) { 190 | appointments = transformation(appointments) 191 | } 192 | 193 | onSnapshot(appointments) 194 | }, 195 | error => console.warn(error) 196 | ) 197 | ) 198 | } 199 | -------------------------------------------------------------------------------- /mybuddy/components/SwipeToConfirm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Location, Permissions } from 'expo' 3 | import { Icon, Text, Button, View } from 'native-base' 4 | import { Platform, Linking, StyleSheet, SafeAreaView, Alert } from 'react-native' 5 | import { withNavigation } from 'react-navigation' 6 | import Modal from 'react-native-modal' 7 | import RNSwipeVerify from 'react-native-swipe-verify' 8 | 9 | import { db } from '../firebase' 10 | import { getCurrentUserAsync } from '../api/users' 11 | import DEFAULT_COLORS from '../constants/Colors' 12 | 13 | class SwipeToConfirm extends Component { 14 | constructor(props) { 15 | super(props) 16 | this.state = { 17 | isModalVisible: false, 18 | location: null 19 | } 20 | this._getLocationAsync = this._getLocationAsync.bind(this) 21 | this._toggleModal = this._toggleModal.bind(this) 22 | this._createEmergency = this._createEmergency.bind(this) 23 | this._renderModal = this._renderModal.bind(this) 24 | } 25 | 26 | componentWillMount() { 27 | // Will not work on Sketch in an Android emulator 28 | // Get user's location whenever the user initiates an emergency 29 | this._getLocationAsync() 30 | } 31 | 32 | componentDidUpdate(_, prevState) { 33 | if (this.state.isModalVisible !== prevState.isModalVisible) { 34 | this.props.navigation.closeDrawer() 35 | } 36 | } 37 | 38 | // Gets all the (gps) location data 39 | async _getLocationAsync() { 40 | // Ask permission and deep link to settings 41 | let {status} = await Permissions.askAsync(Permissions.LOCATION) 42 | if (status !== 'granted') { 43 | // Deep link to Settings for iOS 44 | Platform.OS === 'ios' 45 | ? ( 46 | Alert.alert( 47 | 'Enable Location Permissions', 48 | 'Please enable Location in settings to get the most out of this app.', 49 | [ 50 | { 51 | text: 'SETTINGS', 52 | onPress: () => { 53 | !__DEV__ 54 | ? Linking.openURL('app-settings://notification/expo') 55 | : Linking.openURL('app-settings:') 56 | }, 57 | style: 'cancel' 58 | }, 59 | { 60 | text: 'OK', 61 | onPress: () => console.log('OK Pressed, error message dismissed'), 62 | } 63 | ], 64 | { cancelable: false } 65 | ) 66 | ) 67 | : ( 68 | Alert.alert( 69 | 'Enable Notifications', 70 | 'Please enable push notification in settings to get the most out of this app.', 71 | [ 72 | { 73 | text: 'OK', 74 | onPress: () => console.log('OK Pressed, error message dismissed'), 75 | } 76 | ], 77 | { cancelable: false } 78 | ) 79 | ) 80 | return 81 | } 82 | 83 | // Get location lat/lon 84 | let rawLocation = await Location.getCurrentPositionAsync({}) 85 | let {latitude, longitude} = rawLocation['coords'] 86 | // Get city, street, region, country, postal code,... 87 | let resolvedLocation = await Location.reverseGeocodeAsync({latitude, longitude}) 88 | // Get the first match 89 | let {city, country, street, postalCode, isoCountryCode} = resolvedLocation[0] 90 | // Format it into the required format 91 | let location = { 92 | latitude, 93 | longitude, 94 | city, 95 | country, 96 | street, 97 | postalCode, 98 | countryCode: isoCountryCode 99 | } 100 | console.log(`Got Location: ${JSON.stringify(location)}`) 101 | await this.setState({location}) 102 | } 103 | 104 | _toggleModal = () => { 105 | this.setState({ isModalVisible: !this.state.isModalVisible }) 106 | } 107 | 108 | _createEmergency = async () => { 109 | try { 110 | const user = await getCurrentUserAsync() 111 | const emergency = await db.collection('emergencies').add({ 112 | status: 'alert', 113 | user: user.userRef, 114 | team: user.team, 115 | appointment: user.currentAppointment || null, 116 | location: this.state.location 117 | }) 118 | user.userRef.update({ currentEmergency: emergency }) 119 | } catch (error) { 120 | alert ('Emergency Alert Failure!') 121 | error.message = '[Emergency Alert Failure]: ' + error.message 122 | console.warn(error) 123 | } 124 | this.setState({ isModalVisible: false }) 125 | } 126 | 127 | render() { 128 | return ( 129 | <> 130 | 133 | {this._renderModal()} 134 | 135 | ) 136 | } 137 | 138 | _renderModal = () => ( 139 | 140 | 141 | 142 | Are you sure, you want to create an emergency? 143 | 144 | 145 | } 154 | > 155 | swipe to confirm 156 | 157 | 158 | 165 | 166 | 167 | ) 168 | } 169 | 170 | const styles = StyleSheet.create({ 171 | emergencyButton: { 172 | alignSelf: 'center', 173 | backgroundColor: DEFAULT_COLORS.dangerColor, 174 | marginBottom: 10 175 | }, 176 | cancelButton: { 177 | alignSelf: 'center' 178 | }, 179 | modal: { 180 | flex: 1, 181 | justifyContent: 'space-around' 182 | } 183 | }) 184 | 185 | export default withNavigation(SwipeToConfirm) 186 | -------------------------------------------------------------------------------- /mybuddy-api/functions/strategies.js: -------------------------------------------------------------------------------- 1 | /****************************************************************************** 2 | * Auth Strategies 3 | * For PassportJS 4 | ******************************************************************************/ 5 | 'use strict' 6 | 7 | const AzureAD = require('passport-azure-ad'), 8 | BearerStrategy = require('passport-http-bearer') 9 | .Strategy, 10 | _ = require('lodash'), 11 | config = require('./config') 12 | 13 | /** 14 | * Returns the User record if the user exists and null if he does not 15 | * Throws the error if it fails to 16 | */ 17 | async function getUser(uid, firebase) { 18 | try { 19 | let userRecord = await firebase.auth() 20 | .getUser(uid) 21 | return userRecord 22 | } catch (err) { 23 | if (err.code === 'auth/user-not-found') { 24 | return null 25 | } 26 | throw err 27 | } 28 | } 29 | 30 | 31 | /** 32 | * Azure Active Directory OpenID Connect Strategy used for login/signup 33 | * 34 | * Verifies the credentials and updates the corresponding user from the db if it 35 | * exists (login) or creates it otherwise (signup) 36 | **/ 37 | exports.AzureOIDC = function (firebase, users) { 38 | return new AzureAD.OIDCStrategy( 39 | _.cloneDeep(config.creds), 40 | function (iss, sub, profile, accessToken, refreshToken, params, done) { 41 | if (!profile.oid) { 42 | return done(new Error('No oid found'), null) 43 | } 44 | 45 | let user = {} 46 | // Async verification 47 | getUser(profile.oid, firebase) 48 | .then(userRecord => { 49 | // Check if user exists 50 | if (!userRecord) { 51 | // User doesn't exist yet, so create it 52 | user = { 53 | uid: profile.oid, 54 | email: profile._json.email, 55 | emailVerified: true, 56 | displayName: profile._json.name, 57 | disabled: false 58 | } 59 | console.info('User does not exist so creating user', profile.oid) 60 | // Create user 61 | return firebase 62 | .auth() 63 | .createUser(user) 64 | .then(newUserRecord => { 65 | // See the UserRecord reference doc for the contents of newUserRecord 66 | console.debug('Successfully created new user: ', newUserRecord.uid) 67 | /** 68 | * Create a extended user object in Firestore in order to 69 | * add additional user properties (due to Firebase Auth User 70 | * Record limitations) 71 | **/ 72 | // Store Mircosoft account access and refresh token 73 | user.auth = { accessToken, refreshToken } 74 | // Default user properties 75 | Object.assign(user, config.DEFAULT_USER) 76 | return users.doc(profile.oid) 77 | .set(user) 78 | }) 79 | .then(userDoc => { 80 | console.debug('Successfully created new user in Firestore: ', userDoc) 81 | }) 82 | } else { 83 | // User already exists so get the extended user object and attach it to it 84 | console.info('User already exists so getting', profile.oid) 85 | return users 86 | .doc(profile.oid) 87 | .get() 88 | .then(firebaseUser => { 89 | // Ensure user exists 90 | if (!firebaseUser.exists) { 91 | return done(new Error('No user found'), null) 92 | } 93 | // Add the extended user object data 94 | Object.assign(user, userRecord, firebaseUser.data()) 95 | }) 96 | } 97 | }) 98 | .then(() => { 99 | // Create a new custom token for user 100 | return firebase.auth() 101 | .createCustomToken(profile.oid) 102 | }) 103 | .then(customToken => { 104 | // Save the custom token as the ID token for the user 105 | // Set user auth params for client callback 106 | user.customToken = customToken 107 | console.debug('Sending back user', user) 108 | // Auth successfull so pass back params in the client callback 109 | done(null, user) 110 | }) 111 | .catch(err => { 112 | // Catch all other errors 113 | console.error(err) 114 | done(err) 115 | }) 116 | } 117 | ) 118 | } 119 | 120 | /** 121 | * Azure Active Directory OAuth 2.0 Strategy used for authenticating API calls 122 | * 123 | * Verifies the user ID token and attaches it as a `req.user` property to the 124 | * request to make it available to the middlewares downstream 125 | */ 126 | exports.AzureBearer = function (users) { 127 | return new AzureAD.BearerStrategy( 128 | _.omit(config.creds, ['scope']), 129 | function (token, done) { 130 | // Get user from db 131 | users 132 | .doc(token.oid) 133 | .get() 134 | .then(firebaseUser => { 135 | // Ensure user exists 136 | if (!firebaseUser.exists) { 137 | return done(new Error('No user found'), null) 138 | } 139 | console.info('Successfully retrieved user', firebaseUser.data() 140 | .uid) 141 | done(null, firebaseUser.data(), token) 142 | }) 143 | .catch(err => done(err)) 144 | } 145 | ) 146 | } 147 | 148 | /** 149 | * Firebase OAuth 2.0 Strategy used for authenticating API calls 150 | * 151 | * Verifies the user ID token and attaches it as a `req.user` property to the 152 | * request to make it available to the middlewares downstream 153 | * 154 | * Only accepts ID tokens (does NOT accept custom tokens sent back at signup/ 155 | * login). To obtain an ID token from a custom token on the client-side 156 | * First call `firebase.auth().signInWithCustomToken()` 157 | * https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase 158 | * Then call `firebase.auth().currentUser.getToken()` on the signed in user 159 | * https://firebase.google.com/docs/auth/admin/verify-id-tokens#retrieve_id_tokens_on_clients 160 | */ 161 | exports.FirebaseBearer = function (firebase) { 162 | return new BearerStrategy(function (token, done) { 163 | firebase 164 | .auth() 165 | .verifyIdToken(token) 166 | .then(decodedToken => { 167 | console.debug('ID token is valid:', decodedToken) 168 | // Retrieve user via its uid 169 | return firebase.auth() 170 | .getUser(decodedToken.uid) 171 | }) 172 | .then(user => { 173 | console.debug('Successfully fetched user data:', user.toJSON()) 174 | done(null, user) 175 | }) 176 | .catch(err => { 177 | // Catch all other errors 178 | console.error(err) 179 | done(err) 180 | }) 181 | }) 182 | } -------------------------------------------------------------------------------- /mybuddy-api/functions/config.js: -------------------------------------------------------------------------------- 1 | /****************************************************************************** 2 | * Config 3 | * Defines the configuration and constants for the API 4 | ******************************************************************************/ 5 | 'use strict' 6 | /** 7 | * Login/sign up constraint 8 | * 9 | * For dev purposes, set to allow anyone with a work and school accounts from 10 | * Azure AD and personal Microsoft accounts (MSA), such as hotmail.com, 11 | * outlook.com, and msn.com, to login/sign up To use the common endpoint, either 12 | * turn `validateIssuer` off, or provide the `issuer` value 13 | * 14 | * TODO: change to 'nhs.onmicrosoft.com' to restrict login/sign up to just NHS employees 15 | * (from https://s3-eu-west-1.amazonaws.com/comms-mat/Comms-Archive/NHSmail_O365+Hybrid+Onboarding+Guidance+Document.pdf) 16 | **/ 17 | const TENANT_ID = 'common' 18 | const PRODUCTION_HOST = 'https://europe-west1-mybuddy-47e82.cloudfunctions.net' 19 | 20 | /****************************************************************************** 21 | * General 22 | ******************************************************************************/ 23 | exports.FUNCTION_NAME = 'api' 24 | exports.IN_DEV = process.env.NODE_ENV === 'development' 25 | exports.PORT = 3000 26 | exports.HOST = exports.IN_DEV 27 | ? `http://localhost:${exports.PORT}` 28 | : `${PRODUCTION_HOST}/${exports.FUNCTION_NAME}` 29 | exports.FIREBASE_REGION = 'europe-west1' // https://firebase.google.com/docs/functions/locations 30 | exports.COOKIE_MAX_AGE = 15 * 60 * 1000 // 15 minutes 31 | 32 | // Signup defaults 33 | exports.DEFAULT_ACCESS_LEVEL = 'user' 34 | exports.DEFAULT_USER = { 35 | accessLevel: exports.DEFAULT_ACCESS_LEVEL, 36 | team: null 37 | } 38 | 39 | /****************************************************************************** 40 | * Notifications 41 | ******************************************************************************/ 42 | exports.NOTIFICATIONS_STATES = { 43 | PENDING: 'pending', 44 | IN_PROGRESS: 'in-progress', 45 | DONE: 'done' 46 | } 47 | // In minutes 48 | exports.NOTIFICATIONS_INTERVALS = { 49 | CHECK_IN: 15, 50 | CHECK_OUT: [5, 10, 15, 20], 51 | CHECK_OUT_BUDDY: 30 52 | } 53 | exports.NOTIFICATIONS_BUDDY_REQUEST = { 54 | title: 'New buddy request', 55 | body: 'You have a new buddy request', 56 | data: { 57 | type: 'buddy-request' 58 | } 59 | } 60 | exports.NOTIFICATIONS_CHECK_IN = { 61 | title: 'Time to check in!', 62 | body: 'You have appointment coming right up so check in!', 63 | data: { 64 | type: 'check-in' 65 | } 66 | } 67 | exports.NOTIFICATIONS_CHECK_OUT = { 68 | title: 'Time to check out!', 69 | body: 'Your appointment has finished so check out!', 70 | data: { 71 | type: 'check-out' 72 | } 73 | } 74 | exports.NOTIFICATIONS_BUDDY_CHECK_OUT = { 75 | title: 'Check up on your buddy!', 76 | body: 'Your buddy has not checked out yet!', 77 | data: { type: 'check-out-buddy' } 78 | } 79 | 80 | exports.NOTIFICATIONS_EMERGENCY = name => ({ 81 | title: 'Emergency Alert', 82 | body: `${name} initiated an emergency! 🚨`, 83 | data: { type: 'emergency' }, 84 | priority: 'high' 85 | }) 86 | 87 | /****************************************************************************** 88 | * Azure AD 89 | ******************************************************************************/ 90 | exports.creds = { 91 | // Required 92 | identityMetadata: `https://login.microsoftonline.com/${TENANT_ID}/v2.0/.well-known/openid-configuration`, 93 | // or equivalently: 'https://login.microsoftonline.com//v2.0/.well-known/openid-configuration' 94 | 95 | // Required, the client ID of your app in AAD 96 | clientID: '44af2bda-c9bc-406c-9397-ca62f5e63d56', 97 | 98 | // Required, must be 'code', 'code id_token', 'id_token code' or 'id_token' 99 | // If you want to get access_token, you must use 'code', 'code id_token' or 'id_token code' 100 | responseType: 'code id_token', 101 | 102 | // Required 103 | responseMode: 'form_post', 104 | 105 | // Required, the reply URL registered in AAD for your app 106 | redirectUrl: `${exports.HOST}/auth/openid/return`, 107 | 108 | // Required if we use http for redirectUrl 109 | allowHttpForRedirectUrl: true, 110 | 111 | // Required if `responseType` is 'code', 'id_token code' or 'code id_token'. 112 | // If app key contains '\', replace it with '\\'. 113 | clientSecret: 'anzXE1017](gbbxQHYPQ2;_', 114 | 115 | // Required to set to false if you don't want to validate issuer 116 | validateIssuer: false, 117 | 118 | // Required if you want to provide the issuer(s) you want to validate instead of using the issuer from metadata 119 | // issuer could be a string or an array of strings of the following form: 'https://sts.windows.net//v2.0' 120 | issuer: null, 121 | 122 | // Required to set to true if the `verify` function has 'req' as the first parameter 123 | passReqToCallback: false, 124 | 125 | // Use cookies instead of express session 126 | useCookieInsteadOfSession: true, 127 | 128 | // Required if `useCookieInsteadOfSession` is set to true. You can provide multiple set of key/iv pairs for key 129 | // rollover purpose. We always use the first set of key/iv pair to encrypt cookie, but we will try every set of 130 | // key/iv pair to decrypt cookie. Key can be any string of length 32, and iv can be any string of length 12. 131 | cookieEncryptionKeys: [ 132 | { key: '12345678901234567890123456789012', iv: '123456789012' }, 133 | { key: 'abcdefghijklmnopqrstuvwxyzabcdef', iv: 'abcdefghijkl' } 134 | ], 135 | 136 | // The additional scopes we want besides 'openid'. 137 | // 'profile' scope is required, the rest scopes are optional. 138 | // (1) if you want to receive refresh_token, use 'offline_access' scope 139 | // (2) if you want to get access_token for graph api, use the graph api url like 'https://graph.microsoft.com/mail.read' 140 | scope: [ 141 | 'profile', 142 | 'offline_access', 143 | 'email', 144 | 'openid', 145 | 'Calendars.Read.Shared', 146 | 'Calendars.Read' 147 | ], 148 | 149 | // Optional, 'error', 'warn' or 'info' 150 | loggingLevel: 'info', 151 | 152 | // Optional, If this is set to true, no personal information such as tokens and claims will be logged. The default value is true. 153 | // loggingNoPII: false, 154 | 155 | // Optional. The lifetime of nonce in session or cookie, the default value is 3600 (seconds). 156 | nonceLifetime: null, 157 | 158 | // Optional. The max amount of nonce saved in session or cookie, the default value is 10. 159 | nonceMaxAmount: 10, 160 | 161 | // Optional. The clock skew allowed in token validation, the default value is 300 seconds. 162 | clockSkew: 300, 163 | 164 | // B2B 165 | isB2C: false 166 | 167 | // Access token from AAD may not be a JWT (in which case only AAD knows how to validate it) 168 | // So using this URL ensures we always do 169 | // resourceURL: null, 170 | 171 | // Optional, default value is clientID 172 | // audience: null 173 | } 174 | -------------------------------------------------------------------------------- /mybuddy/screens/AppointmentsScreen/AppointmentFormScreen.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Container, Content, Text, Button, View } from 'native-base' 3 | import { StyleSheet, Alert } from 'react-native' 4 | import { withFormik, Field } from 'formik' 5 | import * as yup from 'yup' 6 | import moment from 'moment' 7 | 8 | import firebase, { db } from '../../firebase' 9 | 10 | import MyTitle from '../../components/MyTitle' 11 | import FormInput from '../../components/Form/FormInput' 12 | import DateTimePicker from '../../components/Form/DateTimePicker' 13 | import BuddyPicker from '../../components/Form/BuddyPicker' 14 | 15 | class AppointmentScreen extends Component { 16 | _handleCancel = () => { 17 | // Go back to the previous screen if user cancels 18 | this.props.navigation.goBack() 19 | } 20 | 21 | _handleDelete = () => { 22 | // Set up a confirmation popup 23 | Alert.alert( 24 | 'Delete Appointment', 25 | 'Are you sure you want to delete this appointment?', 26 | [ 27 | { 28 | text: 'OK', 29 | onPress: async () => { 30 | try { 31 | this.props.setSubmitting(true) 32 | await this.props.values.appointmentRef.delete() 33 | this.props.navigation.goBack() 34 | } catch (error) { 35 | console.warn(error) 36 | this.props.setFieldError( 37 | 'root', 38 | 'Unable to complete your request.' 39 | ) 40 | this.props.setSubmitting(false) 41 | } 42 | } 43 | }, 44 | { 45 | text: 'Cancel', 46 | style: 'cancel' 47 | } 48 | ] 49 | ) 50 | } 51 | 52 | render() { 53 | const { errors } = this.props 54 | return ( 55 | 56 | 64 | 65 | {this._renderTitle()} 66 | {errors['root'] && ( 67 | 68 | {errors['root']} 69 | 70 | )} 71 | {this._renderForm()} 72 | {this._renderButtons()} 73 | 74 | 75 | ) 76 | } 77 | 78 | _renderTitle = () => { 79 | // Update title if appointment is created, otherwise set it to create appointment 80 | const isUpdate = Boolean(this.props.navigation.getParam('appointment')) 81 | return isUpdate ? ( 82 | Update Appointment 83 | ) : ( 84 | Create Appointment 85 | ) 86 | } 87 | 88 | _renderForm = () => ( 89 | <> 90 | 91 | 92 | 93 | 94 | 101 | 102 | 103 | ) 104 | 105 | _renderButtons = () => { 106 | // isUpdate checks if the appointment has been created 107 | const isUpdate = Boolean(this.props.navigation.getParam('appointment')) 108 | return ( 109 | 110 | {isUpdate ? ( 111 | <> 112 | 119 | 126 | 127 | ) : ( 128 | 135 | )} 136 | 137 | ) 138 | } 139 | } 140 | 141 | const styles = StyleSheet.create({ 142 | buttons: { 143 | flexDirection: 'row', 144 | justifyContent: 'space-evenly', 145 | paddingTop: 20 146 | } 147 | }) 148 | 149 | export default withFormik({ 150 | mapPropsToValues: props => { 151 | const appointment = props.navigation.getParam('appointment') || {} 152 | // Populate the appointment items 153 | return { 154 | title: appointment.title || '', 155 | description: appointment.description || '', 156 | address: appointment.address || '', 157 | startDateTime: appointment.startDateTime || new Date(), 158 | minutes: 159 | appointment.startDateTime && appointment.endDateTime 160 | ? moment(appointment.endDateTime).diff( 161 | appointment.startDateTime, 162 | 'minutes' 163 | ) 164 | : 0, 165 | user: db.collection('users').doc(firebase.auth().currentUser.uid), 166 | status: appointment.status || 'pending', 167 | buddy: { status: 'n/a', user: null, ...appointment.buddy }, 168 | appointmentRef: appointment.id 169 | ? db.collection('appointments').doc(appointment.id) 170 | : db.collection('appointments').doc() 171 | } 172 | }, 173 | // Send the form data to db 174 | handleSubmit: async (values, { props, setSubmitting, setFieldError }) => { 175 | const { appointmentRef, minutes, ...appointment } = values 176 | 177 | const endDateTime = moment(appointment.startDateTime) 178 | .add(minutes, 'minutes') 179 | .toDate() 180 | 181 | try { 182 | const buddyUser = appointment.buddy.user 183 | ? await db 184 | .collection('users') 185 | .doc(appointment.buddy.user) 186 | .get() 187 | .then(doc => doc.ref) 188 | : null 189 | 190 | const finalAppointment = { 191 | ...appointment, 192 | endDateTime, 193 | buddy: { ...appointment.buddy, user: buddyUser } 194 | } 195 | 196 | await appointmentRef.set(finalAppointment) 197 | props.navigation.goBack() 198 | } catch (error) { 199 | console.warn(error) 200 | setFieldError('root', 'Unable to complete your request.') 201 | setSubmitting(false) 202 | } 203 | }, 204 | // Enforce appointment input types 205 | validationSchema: yup.object({ 206 | title: yup 207 | .string() 208 | .max(25) 209 | .required(), 210 | description: yup.string().max(50), 211 | address: yup 212 | .string() 213 | .max(50) 214 | .required(), 215 | startDateTime: yup.date().required(), 216 | minutes: yup 217 | .number() 218 | .min(15) 219 | .max(1440) 220 | .required('duration is a required field') 221 | .integer() 222 | }) 223 | })(AppointmentScreen) 224 | -------------------------------------------------------------------------------- /mybuddy-api/functions/notifications.js: -------------------------------------------------------------------------------- 1 | /****************************************************************************** 2 | * Expo notifications 3 | ******************************************************************************/ 4 | 'use strict' 5 | 6 | const Expo = require('expo-server-sdk') 7 | .Expo, 8 | _ = require('lodash'), 9 | moment = require('moment'), 10 | config = require('./config'), 11 | INIT_QUEUE = { queue: [] }, 12 | STATE_COLLECTION = 'state', 13 | NOTIFICATION_QUEUE_DOC = 'notification-queue' 14 | 15 | // Create a new Expo SDK client 16 | let expo = new Expo() 17 | 18 | // For sorting notifications by the soonest 19 | function notifComparator(a, b) { 20 | return a.scheduledAtEpoch - b.scheduledAtEpoch 21 | } 22 | 23 | /** 24 | * Sends a push notifications 25 | * 26 | * @param {Message || [Message]} messages Expo push messages as per https://docs.expo.io/versions/latest/guides/push-notifications/#message-format 27 | * @return {void} 28 | */ 29 | exports.send = async function (messages) { 30 | // Ensure messages is an array 31 | if (!_.isArray(messages)) { 32 | messages = [messages] 33 | } 34 | console.log('Sending notifications', messages) 35 | 36 | // Filter by valid push tokens 37 | messages = messages.filter(message => Expo.isExpoPushToken(message.to)) 38 | 39 | if (_.isEmpty(messages)) { 40 | // Throw an error if there are no valid push tokens 41 | return Promise.reject(new Error('No valid push tokens')) 42 | } 43 | 44 | // The Expo push notification service accepts batches of notifications. So 45 | // batch notifications to reduce the number of requests and to compress them 46 | // (notifications with similar content will get compressed) 47 | let chunks = expo.chunkPushNotifications(messages) 48 | 49 | // Send the chunks to the Expo push notification service one chunk at a 50 | // time to spread the load out over time 51 | for (let chunk of chunks) { 52 | try { 53 | let ticketChunk = await expo.sendPushNotificationsAsync(chunk) 54 | console.log('Notification sent', ticketChunk) 55 | if (ticketChunk.status === 'error') { 56 | console.error('An error occured while sending notification', chunk, ticketChunk) 57 | } 58 | } catch (error) { 59 | console.error(error) 60 | return Promise.reject(error) 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Schedules a push notification to be sent to a user 67 | * 68 | * @param {Object} db A Firestore instance 69 | * @param {Moment || [Moment]} scheduledAts The times to schedule the notification for 70 | * @param {String} pushToken The push token of the destined user 71 | * @param {Object} groupId The group id of the notification to group it 72 | * 73 | * @return {Promise} A Firestore DB transaction 74 | */ 75 | exports.schedule = function (db, scheduledAts, payload, pushToken, groupId) { 76 | console.log('Scheduling notification') 77 | // Get notification queue data ref 78 | const notifQueueRef = db.collection(STATE_COLLECTION).doc(NOTIFICATION_QUEUE_DOC) 79 | // Run all ops as a db transaction to alleviate concurrency issues 80 | return db.runTransaction(async function (transaction) { 81 | const notifQueueDoc = await transaction.get(notifQueueRef) 82 | let notifQueue 83 | // Ensure notification queue exists 84 | if (!notifQueueDoc.exists) { 85 | // Initialise it 86 | notifQueue = _.cloneDeep(INIT_QUEUE) 87 | console.log('notifQueue does not exist so initializing it', INIT_QUEUE) 88 | } else { 89 | // Retrieve it 90 | notifQueue = notifQueueDoc.data() 91 | console.log('notifQueue already exists so using it', notifQueue) 92 | } 93 | 94 | // Ensure scheduledAts is an array 95 | if (!_.isArray(scheduledAts)) { 96 | scheduledAts = [scheduledAts] 97 | } 98 | 99 | // Schedule the notifications 100 | scheduledAts.forEach((scheduledAt) => { 101 | let notification = Object.assign({}, payload, { 102 | to: pushToken, 103 | scheduledAt: scheduledAt.toISOString(), 104 | scheduledAtEpoch: scheduledAt.unix(), 105 | groupId 106 | }) 107 | // Add to the front of the queue 108 | notifQueue.queue.unshift(notification) 109 | console.log('Adding notification to queue', notification) 110 | }) 111 | // Sort queue by the soonest to be processed 112 | notifQueue.queue.sort(notifComparator) 113 | transaction.set(notifQueueRef, notifQueue) 114 | console.log('Set queue', notifQueue) 115 | return transaction 116 | }) 117 | } 118 | 119 | /** 120 | * Processes the scheduled push notification in the notification queue 121 | * Sends the scheduled notifications 122 | * 123 | * @param {Object} db A Firestore instance 124 | * @return {Promise} A promise for the notification send requests results 125 | */ 126 | exports.process = function (db) { 127 | console.log('Processing notification') 128 | // Get notification queue data ref 129 | const notifQueueRef = db.collection(STATE_COLLECTION).doc(NOTIFICATION_QUEUE_DOC) 130 | // Run all ops as a db transaction to alleviate concurrency issues 131 | return db.runTransaction(async function (transaction) { 132 | const notifQueueDoc = await transaction.get(notifQueueRef) 133 | // Ensure notification queue exists 134 | if (!notifQueueDoc.exists) { 135 | // Doesn't exist so nothing to process 136 | console.log('Notification queue does not exist so nothing to proceess') 137 | return Promise.resolve() 138 | } 139 | 140 | // Retrieve the notification queue 141 | const notifQueue = notifQueueDoc.data() 142 | const nowEpoch = moment().unix() 143 | let notifMessages = [] 144 | // Find all the notifications that need to be sent 145 | for (let i = 0; i < notifQueue.queue.length; i++) { 146 | if (notifQueue.queue[i].scheduledAtEpoch < nowEpoch) { 147 | // Add to messages to send 148 | let notifMessage = notifQueue.queue.shift() 149 | notifMessages.push(notifMessage) 150 | console.log('Enqueueing for processing notification', notifMessage) 151 | } else { 152 | // No more notifications to send 153 | break 154 | } 155 | } 156 | 157 | if (_.isEmpty(notifMessages)) { 158 | // Empty so nothing to process 159 | console.log('No new notifications to proceess') 160 | return Promise.resolve() 161 | } 162 | 163 | transaction.update(notifQueueRef, notifQueue) 164 | console.log('Updated notification queue', notifQueue) 165 | // Send notifications 166 | return exports.send(notifMessages) 167 | }) 168 | } 169 | 170 | /** 171 | * Cancels a group of scheduled push notification in the notification queue 172 | * 173 | * @param {Object} db A Firestore instance 174 | * @param {String} groupId The ID of the group of notifications to cancel 175 | * @return {Promise} An empty promise 176 | */ 177 | exports.cancel = function (db, groupId) { 178 | // Get notification queue data ref 179 | const notifQueueRef = db.collection(STATE_COLLECTION).doc(NOTIFICATION_QUEUE_DOC) 180 | // Run all ops as a db transaction to alleviate concurrency issues 181 | return db.runTransaction(async function (transaction) { 182 | const notifQueueDoc = await transaction.get(notifQueueRef) 183 | // Ensure notification queue exists 184 | if (!notifQueueDoc.exists) { 185 | // Doesn't exist so nothing to process 186 | return Promise.resolve() 187 | } 188 | 189 | // Retrieve the notification queue 190 | const notifQueue = notifQueueDoc.data() 191 | 192 | if (_.isEmpty(notifQueue.queue)) { 193 | // Empty so nothing to process 194 | return Promise.resolve() 195 | } 196 | 197 | // Filter the group of notifications out 198 | notifQueue.queue = notifQueue.queue.filter(notif => notif.groupId !== groupId) 199 | // Sort queue by the soonest to be processed 200 | notifQueue.queue.sort(notifComparator) 201 | transaction.update(notifQueueRef, notifQueue) 202 | return Promise.resolve() 203 | }) 204 | } -------------------------------------------------------------------------------- /mybuddy-api/functions/index.js: -------------------------------------------------------------------------------- 1 | /****************************************************************************** 2 | * myBuddy API v1 3 | * Primary entry point to the API 4 | ******************************************************************************/ 5 | 'use strict' 6 | 7 | const express = require('express'), 8 | asyncHandler = require('express-async-handler'), 9 | querystring = require('querystring'), 10 | cookieParser = require('cookie-parser'), 11 | bodyParser = require('body-parser'), 12 | methodOverride = require('method-override'), 13 | passport = require('passport'), 14 | authParser = require('express-auth-parser'), 15 | morgan = require('morgan'), 16 | firebase = require('firebase-admin'), 17 | _ = require('lodash'), 18 | moment = require('moment'), 19 | config = require('./config'), 20 | firestore = require('./firestore'), 21 | notifications = require('./notifications'), 22 | authStrategies = require('./strategies') 23 | 24 | const functions = require('firebase-functions').region(config.FIREBASE_REGION) 25 | 26 | // Firebase Cloud Functions mounts the app at HOST/FUNCTION_NAME 27 | const BASE_PATH = config.IN_DEV ? '' : `/${config.FUNCTION_NAME}` 28 | const AUTH_SUCCESS = 0 29 | const AUTH_FAIL = 1 30 | const AUTH_FAIL_PATH = `${BASE_PATH}/auth/callback?result=${AUTH_FAIL}` 31 | // Init Firebase 32 | firebase.initializeApp() 33 | // Get DB refs 34 | const db = firebase.firestore() 35 | // User data ref 36 | const users = db.collection('users') 37 | 38 | // Setup passport to use the auth strategies 39 | const AzureOIDCStrategy = authStrategies.AzureOIDC(firebase, users) 40 | const FirebaseBearerStrategy = authStrategies.FirebaseBearer(firebase) 41 | passport.use(AzureOIDCStrategy) 42 | passport.use(FirebaseBearerStrategy) 43 | 44 | /** 45 | * App init 46 | */ 47 | const app = express() 48 | 49 | app.use(morgan('dev')) 50 | app.use(methodOverride()) 51 | app.use(cookieParser()) 52 | app.use(authParser) 53 | app.use(bodyParser.json()) 54 | app.use(bodyParser.urlencoded({ extended: true })) 55 | 56 | // Initialize Passport 57 | // Use passport.session() middleware 58 | app.use(passport.initialize()) 59 | app.use(passport.session()) 60 | 61 | /** 62 | * Middleware for authenticated routes to ensure the user is already logged in 63 | * throws a 401 if not 64 | */ 65 | const ensureAuthenticated = passport.authenticate('bearer', { session: false }) 66 | 67 | /****************************************************************************** 68 | * Routes 69 | ******************************************************************************/ 70 | 71 | /** 72 | * Processes the notifications queue 73 | * 74 | * To be polled at an interval 75 | */ 76 | app.get( 77 | '/notifications/process', 78 | asyncHandler(async (req, res, next) => { 79 | await notifications.process(db) 80 | res.send('done ✅') 81 | }) 82 | ) 83 | 84 | /** 85 | * Sends push notifications 86 | * 87 | * Requires a `messages` param of the format defined at 88 | * https://docs.expo.io/versions/latest/guides/push-notifications/#message-format 89 | */ 90 | app.post( 91 | '/notifications/send', 92 | asyncHandler(async (req, res, next) => { 93 | let messages = req.body.messages 94 | console.log('Messgaes', messages) 95 | if (!messages) { 96 | res.status(400).send('Bad Request: messages missing g') 97 | return 98 | } 99 | await notifications.send(messages) 100 | res.send('sent ✅') 101 | }) 102 | ) 103 | 104 | /** 105 | * Client auth url (the `authUrl`) 106 | * 107 | * Initiates the client login/signup (OpenID Connect) flow 108 | * Final auth result is passed back to the client at the specified, as a query 109 | * param, `redirectUrl` along with the user data (on success) being encoded as a 110 | * query string with the param `result` being: 111 | * 0 - success 112 | * 1 - failure 113 | * 114 | * And, on success, the user data being: 115 | * uid - the user's ID (the Microsoft account's `oid`) 116 | * name - the user's full name 117 | * email - the user's email 118 | * customToken - the custom token to be used to sign into Firebase on the client-side 119 | * (https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase) 120 | * accessToken - the access token for accessing the user's Micorsoft account data 121 | * refreshToken - the refresh token for the access token (above) 122 | */ 123 | app.get( 124 | '/auth/login', 125 | function(req, res, next) { 126 | let redirectUrl = req.query.redirectUrl 127 | // Ensure redirectUrl is passed 128 | if (!redirectUrl) { 129 | res.status(400).send('Bad Request: the redirectUrl is missing g') 130 | return 131 | } 132 | // Save the specified redirectUrl as a cookie to be used as the client callback 133 | res.cookie('redirectUrl', redirectUrl, { maxAge: config.COOKIE_MAX_AGE }) 134 | console.info('Starting login/signup') 135 | passport.authenticate('azuread-openidconnect', { 136 | response: res, // required 137 | session: false, // do not persist 138 | failureRedirect: AUTH_FAIL_PATH 139 | })(req, res, next) 140 | }, 141 | function(req, res) { 142 | res.redirect(AUTH_FAIL_PATH) 143 | } 144 | ) 145 | 146 | /** 147 | * Server auth callback (the Redirect URL) 148 | * 149 | * Processes the login/signup result received from Azure AD authenticating the 150 | * content returned in query (such as authorization code) 151 | **/ 152 | app.post( 153 | '/auth/openid/return', 154 | function(req, res, next) { 155 | passport.authenticate('azuread-openidconnect', { 156 | response: res, // required 157 | session: false, // do not persist 158 | failureRedirect: AUTH_FAIL_PATH 159 | })(req, res, next) 160 | }, 161 | function(req, res) { 162 | // Flatten the user data and pass it back to the client side 163 | let resUser = Object.assign( 164 | { result: AUTH_SUCCESS }, 165 | _.pick(req.user, ['customToken']) 166 | ) 167 | let redirectUrl = encodeURI( 168 | req.cookies.redirectUrl + '?' + querystring.stringify(resUser) 169 | ) 170 | console.info( 171 | `Finished processing result from Azure AD. Redirecting to ${redirectUrl}` 172 | ) 173 | res.redirect(redirectUrl) 174 | } 175 | ) 176 | 177 | /** 178 | * Gotta catch em all! 179 | */ 180 | app.get('*', function(req, res) { 181 | res.status(404).send('404 welcome to the abyss!') 182 | }) 183 | 184 | /****************************************************************************** 185 | * Server 186 | ******************************************************************************/ 187 | 188 | /** 189 | * Start dev server when in dev environment 190 | */ 191 | if (config.IN_DEV) { 192 | app.listen(config.PORT, function() { 193 | console.info(`myBuddy running at ${config.HOST}`) 194 | }) 195 | } 196 | 197 | // Expose Express API as a single Firebase Cloud Function 198 | exports[config.FUNCTION_NAME] = functions.https.onRequest(app) 199 | 200 | /****************************************************************************** 201 | * Cloud Firestore functions 202 | * Invoked on Firestore changes 203 | * https://firebase.google.com/docs/firestore/extend-with-functions 204 | ******************************************************************************/ 205 | 206 | /** 207 | * Listens to new appointments to send buddy request notifications 208 | */ 209 | exports.createAppointment = functions.firestore 210 | .document('appointments/{appointmentId}') 211 | .onCreate((snap, context) => firestore.createAppointment(snap, context, db)) 212 | 213 | /** 214 | * Listens to appointments status updates to enqueue/dequeue notifications 215 | */ 216 | exports.updateAppointment = functions.firestore 217 | .document('appointments/{appointmentId}') 218 | .onUpdate((snap, context) => firestore.updateAppointment(snap, context, db)) 219 | 220 | /** 221 | * Listens to appointments deletion to cancel remaining notifications 222 | */ 223 | exports.deleteAppointment = functions.firestore 224 | .document('appointments/{appointmentId}') 225 | .onDelete((snap, context) => firestore.deleteAppointment(snap, context, db)) 226 | 227 | /** 228 | * Listens to new emergencies to send emergency notification to everyone 229 | * in the users team 230 | */ 231 | exports.createEmergency = functions.firestore 232 | .document('emergencies/{emergencyId}') 233 | .onCreate((snap, context) => firestore.createEmergency(snap, context, users)) 234 | -------------------------------------------------------------------------------- /mybuddy-api/functions/firestore.js: -------------------------------------------------------------------------------- 1 | /****************************************************************************** 2 | * Cloud Firestore functions 3 | * Invoked on Firestore changes 4 | * https://firebase.google.com/docs/firestore/extend-with-functions 5 | ******************************************************************************/ 6 | 'use strict' 7 | 8 | const notifications = require('./notifications'), 9 | _ = require('lodash'), 10 | moment = require('moment'), 11 | config = require('./config') 12 | 13 | /** 14 | * Calculates the scheduleAts from the appointment date 15 | * 16 | * @param {Moment} appointmentDate the appointment date 17 | * @param {Number || [Number]} intervals the scheduling intervals 18 | * @param {String} op the moment operation to apply (add, subtract,...) 19 | * @return {[Moment]} the scheduledAts 20 | */ 21 | function calcScheduleAts(appointmentDate, intervals, op) { 22 | // Ensure intervals is an array 23 | if (!_.isArray(intervals)) { 24 | intervals = [intervals] 25 | } 26 | 27 | return intervals.map(interval => { 28 | // Ensure immutability of appointmentDate 29 | return appointmentDate.clone()[op](interval, 'minutes') 30 | }) 31 | } 32 | 33 | /** 34 | * Schedules the checkout notifications for the buddy and clinician 35 | * 36 | * @param {Document} appointment appointment document 37 | * @param {String} appointmentId the appointment id 38 | * @param {Object} db database ref 39 | * @return {Promise} a promise that's rejected if any work fails 40 | */ 41 | function scheduleCheckOuts(appointment, appointmentId, db) { 42 | let promises = [] 43 | // Momentify notification due date 44 | const endDate = moment(appointment.endDateTime.toDate()) 45 | 46 | // Schedule clinician checkout notifications 47 | promises.push( 48 | appointment.user.get().then(userDoc => { 49 | const user = userDoc.data() 50 | // Ensure the user and push token exist 51 | if (!user || (user && !user.pushToken)) { 52 | return Promise.resolve() 53 | } 54 | // Calculate times to schedule the notifications 55 | const scheduledAts = calcScheduleAts( 56 | endDate, 57 | config.NOTIFICATIONS_INTERVALS.CHECK_OUT, 58 | 'add' 59 | ) 60 | console.log('Scheduling clinician checkout notifications', scheduledAts) 61 | return notifications.schedule( 62 | db, 63 | scheduledAts, 64 | config.NOTIFICATIONS_CHECK_OUT, 65 | user.pushToken, 66 | appointmentId 67 | ) 68 | }) 69 | ) 70 | 71 | // Schedule buddy check out notification if specified 72 | if (appointment.buddy.user) { 73 | promises.push( 74 | appointment.buddy.user.get().then(buddyDoc => { 75 | const buddy = buddyDoc.data() 76 | // Ensure the user and push token exist 77 | if (!buddy || (buddy && !buddy.pushToken)) { 78 | return Promise.resolve() 79 | } 80 | // Calculate buddy check out notification time 81 | const scheduledAts = calcScheduleAts( 82 | endDate, 83 | config.NOTIFICATIONS_INTERVALS.CHECK_OUT_BUDDY, 84 | 'add' 85 | ) 86 | console.log('Scheduling buddy checkout notifications', scheduledAts) 87 | return notifications.schedule( 88 | db, 89 | scheduledAts, 90 | config.NOTIFICATIONS_BUDDY_CHECK_OUT, 91 | buddy.pushToken, 92 | appointmentId 93 | ) 94 | }) 95 | ) 96 | } 97 | 98 | return Promise.all(promises) 99 | } 100 | 101 | /** 102 | * Listens to new appointments to send buddy request notifications 103 | */ 104 | exports.createAppointment = (appointmentDoc, context, db) => { 105 | // Get the newly created appointment 106 | const appointment = appointmentDoc.data() 107 | console.log('Created appointment', context.params.appointmentId) 108 | let promises = [] 109 | // Send check in notification 110 | promises.push( 111 | appointment.user.get().then(userDoc => { 112 | const user = userDoc.data() 113 | console.log('Got user', user) 114 | // Ensure the user and push token exist 115 | if (!user || (user && !user.pushToken)) { 116 | console.log('user or push token does not exist') 117 | return Promise.resolve() 118 | } 119 | const notifDueDate = moment(appointment.startDateTime.toDate()) 120 | // Calculate time to schedule the notification for 121 | const scheduledAt = calcScheduleAts( 122 | notifDueDate, 123 | config.NOTIFICATIONS_INTERVALS.CHECK_IN, 124 | 'subtract' 125 | ) 126 | console.log( 127 | 'Scheduling notification', 128 | scheduledAt, 129 | config.NOTIFICATIONS_CHECK_IN 130 | ) 131 | return notifications.schedule( 132 | db, 133 | scheduledAt, 134 | config.NOTIFICATIONS_CHECK_IN, 135 | user.pushToken, 136 | context.params.appointmentId 137 | ) 138 | }) 139 | ) 140 | 141 | // Send buddy request notification if buddy specified 142 | if (appointment.buddy.user) { 143 | promises.push( 144 | appointment.buddy.user.get().then(buddyDoc => { 145 | const buddy = buddyDoc.data() 146 | // Ensure the buddy and push token exist 147 | if (!buddy || (buddy && !buddy.pushToken)) { 148 | return Promise.resolve() 149 | } 150 | let notification = Object.assign( 151 | { 152 | to: buddy.pushToken 153 | }, 154 | config.NOTIFICATIONS_BUDDY_REQUEST 155 | ) 156 | return notifications.send(notification) 157 | }) 158 | ) 159 | } 160 | // Terminate async background function once all promises have been processed 161 | return Promise.all(promises) 162 | } 163 | 164 | /** 165 | * Listens to appointments status updates to enqueue/dequeue notifications 166 | */ 167 | exports.updateAppointment = (appointmentDoc, context, db) => { 168 | // Get new appointment 169 | const newAppointment = appointmentDoc.after.data() 170 | // Get previous appointment before this update 171 | const previousAppointment = appointmentDoc.before.data() 172 | console.log('Changed appointment', context.params.appointmentId) 173 | 174 | // Detect appointment status changes 175 | if (newAppointment.status !== previousAppointment.status) { 176 | console.log(`Appointment status changed to ${newAppointment.status}`) 177 | switch (newAppointment.status) { 178 | case config.NOTIFICATIONS_STATES.IN_PROGRESS: 179 | // Checkin 180 | // Schedule checkout notifications for appointment 181 | return scheduleCheckOuts( 182 | newAppointment, 183 | context.params.appointmentId, 184 | db 185 | ) 186 | case config.NOTIFICATIONS_STATES.DONE: 187 | // Checkout 188 | // Cancel remaining checkout notifications for appointment 189 | return notifications.cancel(db, context.params.appointmentId) 190 | default: 191 | console.log('Unknown appointment state') 192 | return Promise.resolve() 193 | } 194 | } 195 | 196 | // Detect appointment extensions 197 | if (!newAppointment.endDateTime.isEqual(previousAppointment.endDateTime)) { 198 | console.log(`Appointment extended to ${newAppointment.endDateTime}`) 199 | let promises = [] 200 | // Cancel all currently scheduled notifications for appointment 201 | promises.push(notifications.cancel(db, context.params.appointmentId)) 202 | // Re-schedule checkout notifications for appointment 203 | return _.concat( 204 | promises, 205 | scheduleCheckOuts(newAppointment, context.params.appointmentId, db) 206 | ) 207 | } 208 | } 209 | 210 | /** 211 | * Listens to appointments deletion to cancel remaining notifications 212 | */ 213 | exports.deleteAppointment = (appointmentDoc, context, db) => { 214 | // Get the deleted appointment 215 | console.log('Deleted appointment', context.params.appointmentId) 216 | // Cancel any scheduled notifications 217 | return notifications.cancel(db, context.params.appointmentId) 218 | } 219 | 220 | /** 221 | * Listens to new emergencies to send emergency notification to everyone 222 | * in the users team 223 | */ 224 | exports.createEmergency = async (emergencyDoc, context, users) => { 225 | const userRef = emergencyDoc.data().user 226 | const userData = await userRef.get().then(userDoc => userDoc.data()) 227 | console.log(userData.displayName, 'initiated an emergency') 228 | 229 | // Check if user has a team 230 | if (!userData.team) { 231 | console.warn(userData.displayName, 'is not a in team!') 232 | return 233 | } 234 | 235 | console.log('Created emergency', context.params.emergencyId) 236 | return ( 237 | users 238 | // Get users in the same team 239 | .where('team', '==', userData.team) 240 | .get() 241 | .then(querySnapshot => { 242 | return ( 243 | querySnapshot.docs 244 | // Don't send notification to user that initiated request 245 | .filter(userDoc => userDoc.id !== userData.uid) 246 | // Remove users with no pushToken 247 | .filter(userDoc => Boolean(userDoc.data().pushToken)) 248 | // Construct messages array 249 | .map(userDoc => { 250 | const { pushToken, displayName } = userDoc.data() 251 | console.log('Sending emergency notifications to:', displayName) 252 | return Object.assign( 253 | { to: pushToken }, 254 | config.NOTIFICATIONS_EMERGENCY(userData.displayName) 255 | ) 256 | }) 257 | ) 258 | }) 259 | // Send notifications 260 | .then(messages => notifications.send(messages)) 261 | ) 262 | } 263 | --------------------------------------------------------------------------------