PWA

ChatGPT와 FCM 개발 - Source

그랜파 개발자 2024. 10. 1. 13:19

92. FCM 알림 서비스 Source code

1. src/firebase.js

// src/firebase.js

import { initializeApp } from 'firebase/app';
import { getMessaging } from 'firebase/messaging';
import { getAuth } from 'firebase/auth';
import { getFirestore, collection } from "firebase/firestore";

// Your Firebase configuration (from Firebase Console)
const firebaseConfig = {
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
  projectId: "YOUR_PROJECT_ID",
  storageBucket: "YOUR_PROJECT_ID.appspot.com",
  messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
  appId: "YOUR_APP_ID"
};

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);
const auth = getAuth(app);
const db = getFirestore(app);

const users_collection = collection(db, "users");

export { db, users_collection, auth, messaging };

2. src/main.js

// src/main.js

import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
import vuetify from './plugins/vuetify'

import { auth } from '@/firebase';
import { getMessaging, onMessage } from "firebase/messaging";
import { onAuthStateChanged } from 'firebase/auth';

Vue.config.productionTip = false

new Vue({
  router,
  store,
  vuetify,
  render: h => h(App),
  created() {
    // Set up Firebase auth state change listener
    const { dispatch } = this.$store;

    dispatch('auth/fetchUsers');

    onAuthStateChanged(auth, user => {
      // 우리는 우리의 웹앱에 저장된 계정 정보를 사용한다.
      // user.uid로 웹앱의 firestore DB에서 계정 정보를 가져온다. 
      if(user != null) {
        dispatch('auth/fetchUserWithUid', {uid: user.uid});
      }
    });

    // Request Notification Permission
    Notification.requestPermission().then((permission) => {
      if (permission === 'granted') {
        console.log('Notification permission granted.');        

        const messaging = getMessaging();
        onMessage(messaging, (payload) => {
          console.log('Message received. ', payload);
          // Show a notification in the foreground
          new Notification(payload.notification.title, {
            body: payload.notification.body,
            icon: "/icon.png"
          })
        });

        // Handle incoming messages in the foreground
        onMessage(messaging, (payload) => {
          console.log('Message received: ', payload);

          // Extract notification data
          const title = payload.notification.title;
          const options = {
            body: payload.notification.body,
            icon: payload.notification.icon || "/img/push-noti-icon.png",
            badge: "/img/push-badge-icon.png",
            image: "/img/push-image.png",
            data: { url: payload.notification.click_action || 'https://velog.io/@inetsos/posts' } // Custom data for the click event
          };

          // Show notification
          if (Notification.permission === 'granted') {
            const notification = new Notification(title, options);

            // Handle click event on the notification
            notification.onclick = (event) => {
              event.preventDefault(); // Prevent the browser from focusing the Notification's tab
              window.open(notification.data.url, '_blank'); // Open the URL in a new tab
            };
          }
        });

      } else {
        console.log('Unable to get permission to notify.');
      }
    });

    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/firebase-messaging-sw.js')
      .then((registration) => {
        console.log('Service Worker registered with scope:', registration.scope);
      }).catch((err) => {
        console.error('Service Worker registration failed:', err);
      });
    } 

  }
}).$mount('#app')

3. src/router/index.js

// src/router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '@/views/LoginView.vue';
import RegisterView from '@/views/RegisterView.vue';
import ProfileView from '@/views/ProfileView.vue';

import SubscribeView from '@/views/SubscribeView.vue'
import SendNotificationView from '@/views/SendNotificationView.vue'

import store from '../store';

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/login',
    name: 'LoginView',
    component: LoginView
  },
  {
    path: '/register',
    name: 'RegisterView',
    component: RegisterView
  },
  {
    path: '/profile',
    name: 'ProfileView',
    component: ProfileView,
    meta: { requiresAuth: true }
  },
  {
    path: '/subscribe',
    name: 'subscribe',
    component: SubscribeView
  },
  {
    path: '/notification',
    name: 'notification',
    component: SendNotificationView
  },
  {
    path: '/about',
    name: 'about',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

router.beforeEach((to, from, next) => {
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
  const user = store.state.auth.user;

  if (requiresAuth && !user) {
    next('/login');
  } else {
    next();
  }
});

export default router

4. src/store/modules/auth.js

// src/store/modules/auth.js

import { db, users_collection, auth } from '@/firebase';
import router from '@/router';  // Vue Router import
import { createUserWithEmailAndPassword, signInWithEmailAndPassword, signInWithPopup, GoogleAuthProvider  } from 'firebase/auth';
import { doc, addDoc, getDocs, query, where, updateDoc, arrayUnion } from "firebase/firestore";

const state = {
    user: null,
    users: [],
    isLoading: false,
    error: null
};

const mutations = {
  setUser(state, user) {
    state.user = user;
  },
  setUsers(state, users) {
    state.users = users;
    state.isLoading = false;
  },
  setError(state, error) {
    state.error = error;
    state.isLoading = false;
  },
  setLoading(state, isLoading) {
    state.isLoading = isLoading;
  }
}

const actions = {
  async fetchUsers({ commit }) {
    commit('setLoading', true);
    try {
      const users = [];
      const querySnapshot = await getDocs(users_collection);
      querySnapshot.forEach((doc) => {
        // doc.data() is never undefined for query doc snapshots
        users.push({ id: doc.id, ...doc.data() });
      });
      commit('setUsers', users);
    } catch (error) {
      commit('setError', error.message);
    }
  },
  async addUser({ dispatch, commit }, user) {
    try {
      // Add a new document with a generated id.
      const newUser = await addDoc(users_collection, {
        email: user.email,
        name: user.name,
        uids: user.uids
      });

      // 로그인 설정
      user.id = newUser.id;
      console.log('addUser :', user);
      commit('setUser', user);

      dispatch('fetchUsers');
    } catch (error) {
      console.error('Error adding user:', error);
    }
  },

  async login({ commit, dispatch }, { email, password }) {    
    try {
      const { user } = await signInWithEmailAndPassword(auth, email, password);;
      // 웹앱의 계정 정보를 가져와 로그인 설정을 한다.
      dispatch('fetchUserWithUid', {uid: user.uid});       
      router.push("/");   // home으로
    } catch (error) {
      commit('setError', error.message);
    }
  },
  async fetchUserWithUid({ commit }, {uid}) {
    try {
      const users = [];
      const userRef = query(users_collection, where('uids', 'array-contains', uid));
      const querySnapshot = await getDocs(userRef);
      querySnapshot.forEach((doc) => {
        // doc.data() is never undefined for query doc snapshots
         users.push({ id: doc.id, ...doc.data() });
      });
      // 로그인 설정을 한다.
      commit('setUser', users[0]);
    } catch (error) {
      console.error('Error fetching user:', error);
    }            
  },
  async register({ commit, dispatch }, { email, password, name }) {
    try {
      const { user } = await createUserWithEmailAndPassword(auth, email, password, name);
      // 웹앱에 계정을 만든다.
      const newUser = {
        email: email,
        name: name,
        uids: [user.uid]
      };
      // newUser 객체가 추가됨
      // dispatch('addUser', { user: newUser });
      // newUser 계정 정보가 추가됨
      dispatch('addUser', newUser);

      router.push("/");   // home으로        
    } catch (error) {
      console.log(error);
      commit('setError', error.message);
    }
  },
  async addUidToUser({ state }, newUid) {      
    //console.log('user.id:', state.user.id);
    if (state.user) {
      try {
        const userDoc = doc(db, "users", state.user.id);
        updateDoc(userDoc, {
          uids: arrayUnion(newUid)
        });
        // Update the local state if neededa
        state.user.uids = [...(state.user.uids || []), newUid];
      } catch (error) {
        console.error('Error adding UID to user:', error);
      }
    }
  },
  async addGoogleUid({ commit, dispatch, getters }) {
    try {
      const provider = new GoogleAuthProvider();
      const { user } = await signInWithPopup(auth, provider);
      try {
        // 이미 연동된 회원의 경우 알림 메시지 출력한다.
        const myUser = getters.getUserByUid(user.uid);
        if (myUser) {
          // 이미 연동되어 있다.
          commit('setError','이미 연동되어 있습니다.');
        } else {
          // 구글 연동을 진행한다.
          dispatch('addUidToUser', user.uid);
        }          
      } catch (error) {
        commit('setError','Error adding google uid');
      }
    } catch (error) {
      commit('setError', error.message);
    }
  },
  async googleLogin({ commit, getters }) {
    try {
      // 구글 계정에 로그인
      const provider = new GoogleAuthProvider();
      const { user } = await signInWithPopup(auth, provider)
      //const { user } = await signInWithPopup(auth); //, googleProvider);
      try {
        // 구글계정의 uid로 웹앱 계정의 정보를 가져옴.
        // 웹앱 계정은 사이트에 접속할 때 전체 회원 정보를 로드하였으므로 
        // 이미 로드된 회원 리스트에서 구글 계정 uid를 가진 myUser를 가져온다.
        const myUser = getters.getUserByUid(user.uid);
        if (myUser) {
          commit('setUser', myUser);
          router.push("/");   // home으로
        } else {
          console.log('등록된 회원이 아닙니다.');
          // 이경우 회원 가입 페이지로 이동 필요
        }
      } catch (error) {
        console.error('Error adding user:', error);
      }
    } catch (error) {
      commit('setError', error.message);
    }
  },
  async logout({ commit }) {
    await auth.signOut();
    commit('setUser', null);
  },
  setError({ commit }, err_message) {
    commit('setError', err_message);
  },
  resetError({ commit }) {
    commit('setError', null);
  },
  setUser({ commit }, user) {
    commit('setUser', user);
  }
}

const getters = {
  user: state => state.user,
  isAuthenticated: state => !!state.user,
  users: state => state.users,
  isLoading: state => state.isLoading,
  error: state => state.error,
  getUserByUid: (state) => (uid) => {
    return state.users.find(user => user.uids && user.uids.includes(uid));
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};

5. src/store/index.js

// src/store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import auth from './modules/auth';
import posts from './modules/posts';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    auth
  }
});

6. src/views/AboutView.vue

<!-- src/views/AboutView.vue -->
<template>
  <div class="about">
    <br>
    <h3>my-fcm-3는 firebase 생태계의 푸시 알림 서비스를 개발하고 있습니다.</h3>
  </div>
</template>

7. src/views/HomeView.vue

<!-- src/views/HomeView.vue -->
<template>
  <v-container>
    <v-card>
      <v-card-title>my-fcm-3 Home</v-card-title>
    </v-card>
  </v-container>
</template>

<script>
  export default {
    name: 'Home'
  }
</script>

8. src/views/Login.vue

<!-- src/views/Login.vue -->
<template>
  <v-container>
    <v-row>
      <v-col cols="12" class="text-center my-5">
        <h3>로그인</h3>
      </v-col>      
    </v-row>
    <v-row>
      <v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">   
        <v-form @submit.prevent="loginUser">
          <v-text-field v-model="email" label="이메일" type="email" required></v-text-field>
          <v-text-field v-model="password" label="비밀번호" type="password" required></v-text-field>
          <v-btn type="submit" color="primary">Login</v-btn>
          <v-alert v-if="error" type="error" dismissible>{{ error }}</v-alert>
        </v-form>
      </v-col>
    </v-row>
    <v-row>
      <v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">  
        <v-btn color="red" @click="googleLogin" dark>
          <v-icon left>mdi-google</v-icon>
          Sign in with Google
        </v-btn>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
import { mapActions, mapGetters } from 'vuex';

export default {
  name: 'Login',
  data() {
    return {
      email: '',
      password: ''
    };
  },
  computed: {
    ...mapGetters('auth',['error'])
  },
  methods: {
    ...mapActions('auth', ['login', 'googleLogin']),
    async loginUser() {
      await this.login({ email: this.email, password: this.password });
    }
  }
};
</script>

9. src/views/ProfileView.vue

<!-- src/views/ProfileView.vue -->
<template>
  <v-container>
    <v-card v-if="user">
      <v-card-title>User Profile</v-card-title>
      <v-card-text>
        <p><strong>이메일:</strong> {{ user.email }}</p>
        <p><strong>이름:</strong> {{ user.name }}</p>
        <p><strong>id:</strong> {{ user.id }}</p>
      </v-card-text>
      <v-card-text>
        <v-btn color="red" @click="addGoogleAccount" dark>  
          <v-icon left>mdi-google</v-icon>
          Google 계정 연동
        </v-btn>
        <v-alert v-if="error" type="error" dismissible @input="resetErrorMsg" class="my-alert">{{ error }}</v-alert>
      </v-card-text>
    </v-card>
    <v-alert v-else type="info">No user is logged in.</v-alert>
  </v-container>
</template>

<script>
import { mapActions, mapGetters } from 'vuex';

export default {
  computed: {
    ...mapGetters('auth',['user', 'error'])
  },
  methods: {
    ...mapActions('auth',['addGoogleUid', 'resetError']),
    async addGoogleAccount() {
      await this.addGoogleUid();
    },
    resetErrorMsg() {
      this.resetError();
    }
  }
};
</script>

<style scoped>
.my-alert {
  margin: 20px 0;
}
</style>

10. src/views/RegisterView.vue

<!-- src/views/RegisterView.vue -->
<template>
  <v-container>
    <v-row>
      <v-col cols="12" class="text-center my-5">
        <h3>계정 만들기</h3>
      </v-col>      
    </v-row>
    <v-row>
      <v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">    
        <v-form @submit.prevent="userRegister">
          <v-text-field v-model="email" label="이메일" type="email" required></v-text-field>
          <v-text-field v-model="name" label="이름" type="text" required></v-text-field>
          <v-text-field v-model="password" label="비밀번호" type="password" required></v-text-field>
          <v-text-field v-model="confirmPassword" name="confirmPassword" label="비밀번호 확인" type="password" required :rules="[comparePassword]"></v-text-field>
          <v-progress-circular v-if="isLoading" indeterminate :width="7" :size="70" color="grey lighten-1"></v-progress-circular>    
          <v-btn type="submit" color="primary">Register</v-btn>
          <v-alert v-if="error" type="error" dismissible>{{ error }}</v-alert>
        </v-form>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
import { mapActions, mapGetters } from 'vuex';

export default {
  data() {
    return {
      email: '',
      name: '',
      password: '',
      confirmPassword: ''
    };
  },
  computed: {
    ...mapGetters('auth', ['error', 'isLoading']),
    comparePassword() {
      if(this.password == this.confirmPassword) return true;
      else return "비밀번호가 일치하지 않습니다.";
    },
  },
  methods: {
    ...mapActions('auth', ['register']),
    async userRegister() {
      await this.register({ email: this.email, password: this.password, name: this.name });
    }
  }
};
</script>

11. src/views/SendNotificationView.vue

<!-- src/views/SendNotificationView.vue -->
<template>
  <v-container>
    <v-row wrap>
      <v-col class="mb-4">
        <h2 class="display-1 font-weight-bold mb-3">
          Welcome to my-fcm-3
        </h2>       
      </v-col>
      <v-col cols="12" class="text-center">
        <h4 class="display-1">알림 보내기</h4>
      </v-col>

      <v-col class="my-3" offset="1" cols="10">
        <v-card color="blue-grey lighten-1" dark>
          <v-container class="my-3">
            <v-row>
              <v-col cols="12">
                <v-text-field autofocus name="title" label="제목" type="text" v-model="notiTitle" color="white"></v-text-field>
              </v-col>
              <v-col cols="12">
                <v-textarea rows="3" name="message" label="내용" type="text" v-model="notiMsg" color="white"></v-textarea>
              </v-col>
            </v-row>
          </v-container>
          <v-card-text>
            <v-btn block large color="primary" dark @click="sendNotification">
              <v-icon left>mdi-message</v-icon>Send Notification
            </v-btn>           
          </v-card-text>
        </v-card>
        <v-alert v-if="error" type="error" dismissible @input="resetErrorMsg" class="my-alert">{{ error }}</v-alert>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
import { mapActions, mapGetters } from 'vuex';

export default {
  data() {
    return {
      notiTitle: '',
      notiMsg: ''
    }
  },
  computed: {
    ...mapGetters('auth',['error'])
  },
  methods: {
    ...mapActions('auth',['setError', 'resetError']),
    resetErrorMsg() {
      this.resetError();
    },

    sendNotification() { 
      const user = this.$store.state.auth.user;
      if(user) {
        const userId = user.id;
        this.triggerNotification(userId);
      }      
    },
    async triggerNotification(userId) {
      if(this.notiTitle == '' || this.notiMsg == '') {
        this.setError("제목과 내용을 입력하세요.");
        return;
      }
      try {


        const response = await fetch('https://sendpushnotification-your-region-your-project-id.net', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ 
            userId: userId,
            title: this.notiTitle,  //"알림 테스트",
            body: this.notiMsg,   // "알림을 보냅니다. 받아 주세요....."
          }),
        });

        if (response.ok) {
          console.log('Notification sent successfully');
        } else {
          console.error('Failed to send notification');
        }
      } catch (error) {
        console.error('Error:', error);
      }
    }
  }
}
</script>

<style scoped>
.my-alert {
  margin: 20px 0;
}
</style>

12. src/views/SubscribeView.vue

<!--src/views/SubscribeView.vue-->
<template>
  <v-container>
    <v-row>
      <v-col class="mb-4">
        <h1 class="display-2 font-weight-bold mb-3">
          Welcome to my-fcm-3
        </h1>       
      </v-col>
      <v-col cols="12">
        Firebase Cloud Messaging with Vue and Firebase<br>
        <v-btn color="primary" @click="requestNotificationPermission">Enable Notifications</v-btn>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
import { db, messaging } from '@/firebase'
import { getToken } from 'firebase/messaging';
import { doc, setDoc } from 'firebase/firestore';

export default {
  methods: {
    requestNotificationPermission() {
      if ("Notification" in window) {
        Notification.requestPermission().then((permission) => {
          if (permission === "granted") {
            console.log("Notification permission granted.");

            getToken(messaging, { vapidKey: “your-web-push-certificate-key-pair" })
            .then((currentToken) => {
              if (currentToken) {
                console.log("FCM Token:", currentToken);
                // Send the token to your server and update the UI if necessary
                const user = this.$store.state.auth.user;
                if(user) {
                  const userId = user.id;
                  this.saveTokenToFirestore(userId, currentToken);
                }
              } else {
                console.log("No registration token available. Request permission to generate one.");
              }
            })
            .catch((err) => {
              console.log("An error occurred while retrieving token. ", err);
            });
          } else {
            console.log("Notification permission denied.");
          }
        });
      } else {
        console.log("This browser does not support notifications.");
      }
    },

    saveTokenToFirestore(userId, token) {
      try {
        // Create a reference to the document in the 'fcmTokens' collection with the user's ID as the document ID
        const tokenRef = doc(db, 'fcmTokens', userId);
        // Set the document with the token data
        setDoc(tokenRef, {
          token: token,
          createdAt: new Date()
        });

        console.log(`Token for user ${userId} saved to Firestore.`);
      } catch (error) {
        console.error('Error saving token to Firestore:', error);
      }
    },    
  }
};
</script>

13. src/App.vue

<!--src/App.vue -->
<template>
  <v-app>
    <v-navigation-drawer app>
      <v-list>
        <v-list-item link>
          <router-link to="/">Home</router-link>
        </v-list-item>
        <v-list-item link  v-if="!user">
          <router-link to="/login" >로그인</router-link>
        </v-list-item>
        <v-list-item link v-if="!user">
          <router-link to="/register">계정 만들기</router-link>
        </v-list-item>
        <v-list-item link v-if="user">
          <router-link to="/profile">계정 정보</router-link>
        </v-list-item>
        <v-list-item link>
          <router-link to="/subscribe">구독</router-link>
        </v-list-item>
        <v-list-item link>
          <router-link to="/notification">알림</router-link>
        </v-list-item>
        <v-list-item link>
          <router-link to="/about">About</router-link>
        </v-list-item>        
      </v-list>
    </v-navigation-drawer>

    <v-app-bar app color="primary" dark >
      <v-toolbar-title>my-fcm-3</v-toolbar-title>
      <v-spacer></v-spacer>
      <v-btn icon @click="doLogout" v-if="user">
        <v-icon>mdi-logout</v-icon>
      </v-btn>
    </v-app-bar>

    <v-main>
      <router-view/>
    </v-main>
  </v-app>
</template>

<script>
import { mapActions, mapGetters } from 'vuex';

export default {
  name: 'App',
  computed: {
    ...mapGetters('auth', ['user'])
  },
  methods: {
    ...mapActions('auth', ['logout']),
    doLogout() {
      this.logout()
    }
  }
};
</script>