Skip to content

Commit

Permalink
add push notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
bensu committed Feb 7, 2024
1 parent 039af3b commit b4b3efb
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 10 deletions.
1 change: 1 addition & 0 deletions bin/set-env-variables TEMPLATE.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export TWILIO_SID=get from twilio.com
export TWILIO_AUTH_TOKEN=get from twilio.com
export TWILIO_PHONE_NUMBER=+16502295016
export TWILIO_VERIFY_SERVICE=VAc2b8caa89134e9b10b31e67d4468a637
export EXPO_PUSH_TOKEN=

# when you add a new environment variable to to this file, make
# sure to also add it to `bin/set-env-variables.sh`
1 change: 1 addition & 0 deletions resources/sql/schema-users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ create table if not exists users (
name varchar(255),
email_address varchar(255),
email_notifications varchar(255),
push_token varchar(255),
created_at timestamp not null default current_timestamp,
updated_at timestamp not null default current_timestamp
);
Expand Down
27 changes: 22 additions & 5 deletions src/ketchup/db.clj
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,29 @@

(def users-table :users) ;; stores screen_name of the user who the admin is impersonating (for debug only)

(defn find-or-insert-user! [data]
(let [user-data (merge {:screen_name (:phone data)} ; if they didn't provide a screen_name, use their phone number
data)]
(find-or-insert! users-table :phone user-data)))
(defn user-by-id [id]
(first
(sql/query @pool ["select * from users where id = ?" id])))

(defn user-by-phone [phone]
(first
(sql/query @pool ["select * from users where phone = ?" phone])))

(defn set-push-token! [id token]
{:pre [(string? token)]}
(sql/execute! @pool ["update users set push_token = ? where id = ?" token id]))

(defn find-or-insert-user! [{:keys [phone]}]
(if-let [user (user-by-phone phone)]
user
(let [user-data {:phone phone :screen_name phone}]
(sql/insert! @pool users-table user-data)
(user-by-phone phone))))

(defn update-user-last-ping! [id status]
(println "updating user last ping for id" id "to" status)
(sql/db-do-commands @pool (str "update users set last_ping = now(), status = '" status "' "
"where id = '" id "';")))
"where id = '" id "';")))

(defn get-all-users []
(select-all users-table))
21 changes: 21 additions & 0 deletions src/ketchup/notify.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
(ns ketchup.notify
(:require [sdk.expo :as expo]
[ketchup.db :as db]
[ketchup.env :as env]))

(defn status-change! [user-id status]
(let [expo-push-token (env/get-env-var "EXPO_PUSH_TOKEN")
user (db/user-by-id user-id)
_ (assert user)
users (db/get-all-users)
notifications (->> users
;; TODO: remove the user who is changing status
(filter :push_token)
(mapv (fn [{:keys [screen_name push_token]}]
{:to push_token
:title "Status Change"
:body (format "Your friend %s is now %s"
screen_name
status)})))]
(expo/push-many! expo-push-token
notifications)))
31 changes: 26 additions & 5 deletions src/ketchup/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
[ketchup.auth :as auth]
[ketchup.db :as db]
[ketchup.env :as env]
[ketchup.notify :as notify]
[ring.middleware.cors :refer [wrap-cors]]
[ring.util.request]
[ring.util.response :as resp]))

(defn login-v3 [{:keys [query-params] :as _req}]
(defn login-or-signup! [{:keys [query-params] :as _req}]
(let [phone (get-in query-params ["phone"])
smsCode (get-in query-params ["smsCode"])
user (db/find-or-insert-user! {:phone phone})]
(assert user)
(assert (:id user) (pr-str user))
(if (= smsCode (env/get-env-var "SMS_CODE"))
{:success true
:message "Login success!"
Expand All @@ -21,11 +24,12 @@

(defroutes open-routes
(POST "/api/v2/login" req
(resp/response (generate-string (login-v3 req)))))
(resp/response (generate-string (login-or-signup! req)))))

(defn ping [{:keys [params auth/parsed-jwt] :as _req}]
(let [user-id (:user-id parsed-jwt)
status (:status params)]
status (:status params)
user (db/user-by-id user-id)]
(cond
(nil? status) {:success false :message "status not provided"}
(not (or (= status "online")
Expand All @@ -35,6 +39,10 @@
(let [result (db/update-user-last-ping! user-id status)]
(println "just pinged by" user-id " · " (str (java.time.Instant/now)))
(println "updated" (count result) "users \n")
;; only send notification if status has changed
(when-not (= status (:status user))
(future
(notify/status-change! user-id status)))
{:success true
:status status
:message "Ping received"})
Expand Down Expand Up @@ -62,7 +70,16 @@
:updated_at]))

(defn get-all-users []
(mapv select-user-fields (db/select-all db/users-table)))
(mapv select-user-fields (db/get-all-users)))

(defn set-push-token! [{:keys [params auth/parsed-jwt] :as _req}]
(let [user-id (:user-id parsed-jwt)
push_token (:push_token params)]
(if (empty? push_token)
{:success false :message "push token not provided"}
(do
(db/set-push-token! user-id push_token)
{:success true :message "push token set"}))))

;; Routes under this can only be accessed by authenticated clients
(defroutes authenticated-routes
Expand All @@ -71,7 +88,9 @@
(GET "/api/v2/users" _
(resp/response (generate-string (get-all-users))))
(POST "/api/v2/ping" req
(resp/response (generate-string (ping req)))))
(resp/response (generate-string (ping req))))
(POST "/api/v2/push" req
(resp/response (generate-string (set-push-token! req)))))

(defn wrap-body-string [handler]
(fn [request]
Expand All @@ -90,5 +109,7 @@
(def app
(-> (compo/routes open-routes
(auth/wrap-authenticated authenticated-routes))
(wrap-cors :access-control-allow-origin [#".*"]
:access-control-allow-methods [:get :put :post :delete])
(wrap-json-params)
(wrap-body-string)))
70 changes: 70 additions & 0 deletions src/sdk/expo.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
(ns sdk.expo
(:require [clj-http.client :as http]
[clojure.data.json :as json]))

(def base-url "https://exp.host/--")

;; From https://docs.expo.dev/push-notifications/sending-notifications/

;; curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" -d '{
;; "to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
;; "title":"hello",
;; "body": "world"
;; }'

(comment
{:to "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]"
:title "hello"
:body "world"})

(defn valid-notification? [{:keys [to title body]}]
(and (string? to)
(string? title)
(string? body)))

(comment
;; example response
{:data {:status "ok", :id "e9a83bbe-377a-45d7-a0d6-7b281c809e19"}})

(defn push-one!

[expo-push-token {:keys [device-token title body] :as notification}]

{:pre [(valid-notification? notification)]}

(let [data {:to device-token
:title title
:body body}
r (-> (str base-url "/api/v2/push/send")
(http/post
{:as :json
:body (json/write-str data)
:headers {"Content-Type" "application/json"
"Authorization" (str "Bearer " expo-push-token)}}))]
(if (= 200 (:status r))
(:data (:body r))
(throw (ex-info "Failed to send push notification"
{:status (:status r)
:body (:body r)})))))

(def MAX_EXPO_NOTIFICATIONS 100)

(defn push-many!

[expo-push-token notifications]

{:pre [(string? expo-push-token)
(< (count notifications) MAX_EXPO_NOTIFICATIONS)
(every? valid-notification? notifications)]}

(let [r (-> (str base-url "/api/v2/push/send")
(http/post
{:as :json
:body (json/write-str notifications)
:headers {"Content-Type" "application/json"
"Authorization" (str "Bearer " expo-push-token)}}))]
(if (= 200 (:status r))
(:data (:body r))
(throw (ex-info "Failed to send push notification"
{:status (:status r)
:body (:body r)})))))

0 comments on commit b4b3efb

Please sign in to comment.