mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62821ed803 | ||
|
|
903d62ed57 | ||
|
|
adef626b34 | ||
|
|
11a933cc04 | ||
|
|
4b5ce99bb1 | ||
|
|
f8dff77cee | ||
|
|
0a9b08803e | ||
|
|
39a8568663 | ||
|
|
e1502440eb | ||
|
|
5834d032c3 | ||
|
|
0a28a2af61 | ||
|
|
259f56bd26 | ||
|
|
011ad66a3f | ||
|
|
281c72f88d | ||
|
|
1293523ebf | ||
|
|
67e5d34f39 | ||
|
|
8b3dec6116 | ||
|
|
3359f49f0a | ||
|
|
9715cae587 | ||
|
|
4b122bd907 | ||
|
|
edefa36c8a | ||
|
|
e40a352aed | ||
|
|
70267f0623 | ||
|
|
c19ed7f6d5 | ||
|
|
e2fb0f2ee8 | ||
|
|
f9e208a7e0 | ||
|
|
ee2b8a60a2 | ||
|
|
5651f17e96 | ||
|
|
9679b3722a | ||
|
|
6cb736a10d | ||
|
|
ac5c3b421d | ||
|
|
f01fad5fa6 | ||
|
|
110b727cbc | ||
|
|
1dba2debc5 | ||
|
|
8fec73341f | ||
|
|
33c76de397 | ||
|
|
d409eb1126 | ||
|
|
69d53591c8 | ||
|
|
4cf783f07f | ||
|
|
a971c121cc | ||
|
|
b4abfb6aa4 | ||
|
|
8f760081a5 | ||
|
|
c15934f210 | ||
|
|
c7e1bb4463 | ||
|
|
4ac97fc476 | ||
|
|
a5dda01ffd | ||
|
|
9db4937e54 | ||
|
|
3dc50a384e | ||
|
|
ccc48620b8 | ||
|
|
943c3960e1 | ||
|
|
9a84a67555 | ||
|
|
2241e3c7d3 | ||
|
|
03042dae96 | ||
|
|
3df9d067d6 | ||
|
|
c5f3e8b3c2 | ||
|
|
3a3e8c0e10 | ||
|
|
f010bf7eed | ||
|
|
b22d6a77b0 | ||
|
|
f91d9125e4 | ||
|
|
625eedef1e | ||
|
|
61dd33187a | ||
|
|
817640c7d0 | ||
|
|
aa15d5a6df | ||
|
|
60857007bd | ||
|
|
cfd0c5d846 | ||
|
|
71ec88d235 | ||
|
|
902ea583bd | ||
|
|
fb6b54fba0 | ||
|
|
9dfc56987c | ||
|
|
b982a02717 | ||
|
|
15a0d8ee16 | ||
|
|
9ca03132db | ||
|
|
f908ba3ea3 | ||
|
|
79ea09bd91 | ||
|
|
c743a4f1fe | ||
|
|
ccd1fc5c10 | ||
|
|
2f82a64dbe | ||
|
|
71c6eae9c4 | ||
|
|
28894a08cc | ||
|
|
41e366dcc4 | ||
|
|
6ee11a6b2d | ||
|
|
ab82c66f83 | ||
|
|
f94820f45e | ||
|
|
fe03e1ca68 | ||
|
|
44ee6951c9 | ||
|
|
40194f7204 | ||
|
|
40eefef9a2 | ||
|
|
d8ab44ebb5 | ||
|
|
f875539f2e | ||
|
|
d9c2d142cb | ||
|
|
51d5715f04 | ||
|
|
8ad81b1d50 | ||
|
|
6d4083d8a7 | ||
|
|
bb120afea2 | ||
|
|
cebcc20c26 | ||
|
|
0522e787cd | ||
|
|
81e01f1485 | ||
|
|
9b2b93d56f | ||
|
|
133b402e2b | ||
|
|
9a91eab13f | ||
|
|
5f6722b917 | ||
|
|
fdcd4d46ac | ||
|
|
27a72170b8 | ||
|
|
b3021e60ec | ||
|
|
6d8834bd87 | ||
|
|
ab8a9d95d8 | ||
|
|
563ee3f5df | ||
|
|
c3389a7fcf | ||
|
|
e824bbb533 | ||
|
|
7de0e351f3 | ||
|
|
2df5f55390 | ||
|
|
21038cc5ac | ||
|
|
d6749bcd41 | ||
|
|
005c9ccdef | ||
|
|
d47cb53e59 | ||
|
|
adfb3ca4f0 | ||
|
|
88b7e4edda | ||
|
|
e33b57f0fd | ||
|
|
777825b73f | ||
|
|
04ca9b6f9a | ||
|
|
7f3d3eeb9c | ||
|
|
75ac16d43c | ||
|
|
5f1120c718 | ||
|
|
806a0694c6 | ||
|
|
c506ae3242 | ||
|
|
444fa529fb | ||
|
|
62ced3eb04 | ||
|
|
31718f1c4d | ||
|
|
98ab8971b4 | ||
|
|
870c86f9af | ||
|
|
a4f6aabee9 | ||
|
|
5584ad0a10 | ||
|
|
6b4932b4c5 | ||
|
|
7f1cb0aaf3 | ||
|
|
d42a5a48e9 | ||
|
|
8a1b762c35 | ||
|
|
d2d1de41d2 | ||
|
|
d1366af2a0 | ||
|
|
cae4b15bbb | ||
|
|
e41bc64b0c | ||
|
|
3d03ebe487 | ||
|
|
1fcb431d1b | ||
|
|
4f321490af | ||
|
|
04c7469e68 | ||
|
|
98bc0a9309 | ||
|
|
6e537f4cdf | ||
|
|
b121d61852 | ||
|
|
6ba3c3ffbd | ||
|
|
67b3efad4c | ||
|
|
1282e468e3 | ||
|
|
67b2e78a63 | ||
|
|
213c56f945 | ||
|
|
ccde6e4f4b | ||
|
|
2c1c94d24c | ||
|
|
56edb51f36 | ||
|
|
b6ed2c7dd8 | ||
|
|
74c7c5c423 | ||
|
|
314d774bde | ||
|
|
d94091ae4e | ||
|
|
8bf3c4fcd7 | ||
|
|
4358c15432 | ||
|
|
fdf8d649fe | ||
|
|
a4e22ec4b1 | ||
|
|
783bc43547 | ||
|
|
2e6aec175a | ||
|
|
ee41aaa112 | ||
|
|
32e8a8b1b9 | ||
|
|
6b3def230b | ||
|
|
51c46db106 | ||
|
|
64c18179ac | ||
|
|
5ee0b39e07 | ||
|
|
6470319fd6 | ||
|
|
4ca3f3c8ee | ||
|
|
07e2d2d509 | ||
|
|
07d2a143a2 | ||
|
|
968845492f | ||
|
|
c2106b64f9 | ||
|
|
0d73d1d258 | ||
|
|
423d425950 | ||
|
|
7ab0093fec | ||
|
|
56d2757448 | ||
|
|
f5b6037367 | ||
|
|
2c4ce6c8d1 | ||
|
|
f9ccd3628a | ||
|
|
abef2b394e | ||
|
|
97ff6f1de9 | ||
|
|
7fad4435cb | ||
|
|
92a97209ca | ||
|
|
e7c3f083b4 | ||
|
|
7b5961f941 | ||
|
|
85d4b411b5 | ||
|
|
c9ec32aca7 | ||
|
|
13a3013a8e | ||
|
|
0dd3bac855 | ||
|
|
d7a716a5cb | ||
|
|
0bbc9cbe81 | ||
|
|
df766d8d1f | ||
|
|
20a150a228 | ||
|
|
010292a440 | ||
|
|
394dae18e9 | ||
|
|
f1676c52f0 | ||
|
|
05f6f3c79b | ||
|
|
9942b488ea | ||
|
|
5d83f4bf2d | ||
|
|
d9afd914ff | ||
|
|
990d8160f8 | ||
|
|
80c321b66f | ||
|
|
67b45f3e5c | ||
|
|
ca3cee5673 | ||
|
|
ae0d170244 | ||
|
|
9a31cfa938 | ||
|
|
cdbc9c305e | ||
|
|
cdbce13c49 | ||
|
|
0a41ebbcda | ||
|
|
476fe1602b | ||
|
|
2f482e9afc | ||
|
|
d59e6e0691 | ||
|
|
7ec6866f26 | ||
|
|
3686e7facf | ||
|
|
1aba1894ea | ||
|
|
14503c9b8f | ||
|
|
a315668d31 | ||
|
|
db9ea63210 | ||
|
|
51ecbd5b53 | ||
|
|
45ef0d9809 | ||
|
|
356702b50d | ||
|
|
e72ce5376c | ||
|
|
0d35f3fbd2 | ||
|
|
28c22c1eae | ||
|
|
7cf83f65c3 | ||
|
|
4c4f2e720d | ||
|
|
0fa562e6fd | ||
|
|
bcd0f778cf | ||
|
|
401ab9f706 | ||
|
|
8b09a81d5a | ||
|
|
86718cc406 | ||
|
|
ccb72364e1 | ||
|
|
bfd6a59d87 | ||
|
|
af4caa455a | ||
|
|
d511e4a75c | ||
|
|
8fd906223c | ||
|
|
deadb56aaa | ||
|
|
1ff867879c | ||
|
|
f3630dd868 | ||
|
|
39143525c3 | ||
|
|
e8dd1f8f8b | ||
|
|
28e5d2e3f2 | ||
|
|
21def91427 | ||
|
|
bc5d04c662 | ||
|
|
c736227448 | ||
|
|
168285cb64 | ||
|
|
3411f50d29 | ||
|
|
319c14b0e0 | ||
|
|
64c077396f | ||
|
|
65f0d448a1 | ||
|
|
2fdaa464dd | ||
|
|
f86a6a10ac | ||
|
|
08a2438e79 | ||
|
|
60cc47f7ca | ||
|
|
7e4f606492 | ||
|
|
8ff58534d9 | ||
|
|
a4bb184e95 | ||
|
|
940c1f5692 | ||
|
|
0430733b58 | ||
|
|
33136816af | ||
|
|
469a235799 | ||
|
|
2d71c827b3 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -6,7 +6,7 @@ open_collective: compass-connection # Replace with a single Open Collective user
|
||||
ko_fi: compassconnections # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
liberapay: CompassConnections # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -48,13 +48,13 @@ jobs:
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: localhost:8088
|
||||
NEXT_PUBLIC_FIREBASE_ENV: PROD
|
||||
NEXT_PUBLIC_FIREBASE_ENV: DEV
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
||||
run: |
|
||||
yarn --cwd=web serve &
|
||||
npx wait-on http://localhost:3000
|
||||
npx playwright test tests/playwright
|
||||
npx playwright test tests/e2e
|
||||
SERVER_PID=$(fuser -k 3000/tcp)
|
||||
echo $SERVER_PID
|
||||
kill $SERVER_PID
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -13,6 +13,9 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# Playwright
|
||||
/tests/reports/playwright-report
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
@@ -71,6 +74,7 @@ email-preview
|
||||
*.ico
|
||||
*.mp4
|
||||
*.mov
|
||||
*.webp
|
||||
*.avi
|
||||
*.wmv
|
||||
*.mp3
|
||||
@@ -87,3 +91,7 @@ email-preview
|
||||
*.terraform
|
||||
/backups/firebase/auth/data/
|
||||
/backups/firebase/storage/data/
|
||||
|
||||
android/app/release*
|
||||
icons/
|
||||
*.bak
|
||||
|
||||
16
README.md
16
README.md
@@ -7,8 +7,6 @@
|
||||
|
||||
This repository contains the source code for [Compass](https://compassmeet.com) — an open platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
|
||||
|
||||
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
|
||||
|
||||
## Features
|
||||
|
||||
- Extremely detailed profiles for deep connections
|
||||
@@ -21,9 +19,9 @@ This repository contains the source code for [Compass](https://compassmeet.com)
|
||||
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
|
||||
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
|
||||
|
||||
<p style="text-align: center;">
|
||||
<img src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fdemo_compass.gif?alt=media&token=e3ae4334-4e3f-4026-b121-c08b4b724cd1" alt="Compass Demo" width="600">
|
||||
</p>
|
||||
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
|
||||
|
||||

|
||||
|
||||
## To Do
|
||||
|
||||
@@ -73,14 +71,14 @@ Everything is open to anyone for collaboration, but the following ones are parti
|
||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||
- [ ] Clean up privacy notice (convert to Markdown)
|
||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||
- [ ] Add email verification
|
||||
- [ ] Add password reset
|
||||
- [x] Add email verification
|
||||
- [x] Add password reset
|
||||
- [x] Add automated welcome email
|
||||
- [ ] Security audit and penetration testing
|
||||
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||
- [ ] Create settings page (change email, password, delete account, etc.)
|
||||
- [x] Create settings page (change email, password, delete account, etc.)
|
||||
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
||||
- [ ] Improve loading sign (e.g., animation of a compass moving around)
|
||||
- [x] Improve loading sign (e.g., animation of a compass moving around)
|
||||
- [ ] Show compatibility score in profile page
|
||||
|
||||
## Implementation
|
||||
|
||||
0
android/.aiexclude
Normal file
0
android/.aiexclude
Normal file
102
android/.gitignore
vendored
Normal file
102
android/.gitignore
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
/app/release/
|
||||
364
android/README.md
Normal file
364
android/README.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Compass Android WebView App
|
||||
|
||||
This folder contains the source code for the Android application of Compass.
|
||||
A hybrid mobile app built with **Next.js (TypeScript)** frontend, **Firebase backend**, and wrapped as a **Capacitor WebView** for Android. In the future it may contain native code as well.
|
||||
|
||||
This document describes how to:
|
||||
1. Build and run the web frontend and backend locally
|
||||
2. Sync and build the Android WebView wrapper
|
||||
3. Debug, sign, and publish the APK
|
||||
4. Enable Google Sign-In and push notifications
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
The app is a Capacitor Android project that loads the local Next.js assests inside a WebView.
|
||||
|
||||
During development, it can instead load the local frontend (`http://10.0.2.2:3000`) and backend (`http://10.0.2.2:8088`).
|
||||
|
||||
Firebase handles authentication and push notifications.
|
||||
Google Sign-In is supported natively in the WebView via the Capacitor Social Login plugin.
|
||||
|
||||
Project Structure
|
||||
|
||||
- `app/src/main/java/com/compass/app`: Contains the Java/Kotlin source code for the Android application.
|
||||
- `app/src/main/res`: Contains the resources for the application, such as layouts, strings, and images.
|
||||
- `app/build.gradle`: The Gradle build file for the Android application module.
|
||||
- `build.gradle`: The top-level Gradle build file for the project.
|
||||
- `AndroidManifest.xml`: The manifest file that describes essential information about the application.
|
||||
|
||||
### **Why Local Is the Default**
|
||||
- **Performance:** Local assets load instantly, without network latency.
|
||||
- **Reliability:** Works offline or in poor connectivity environments.
|
||||
- **App Store policy compliance:** Apple and Google generally prefer that the main experience doesn’t depend on a remote site (for security, review, and performance reasons).
|
||||
- **Version consistency:** The web bundle is versioned with the app, ensuring no breaking updates outside your control.
|
||||
|
||||
When Remote (No Local Assets) Is sometimes Used
|
||||
Loading from a **remote URL** (e.g. `https://compassmeet.com`) is **less common**, but seen in a few cases:
|
||||
- **Internal enterprise apps** where the WebView just wraps an existing web portal.
|
||||
- **Dynamic content** or **frequent updates** where pushing a new web build every time through app stores would be too slow.
|
||||
- To leverage the low latency of ISR and SSR.
|
||||
However, this approach requires:
|
||||
- Careful handling of **CORS**, **SSL**, and **login/session** persistence.
|
||||
- Compliance with **Google Play policies** (they may reject apps that are “just a webview of a website” unless there’s meaningful native integration).
|
||||
|
||||
**A middle ground we use:**
|
||||
- The app ships with **local assets** for core functionality.
|
||||
- The app **fetches remote content or updates** (e.g., via Capacitor Live Updates, Ionic Appflow).
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
### Required Software
|
||||
| Tool | Version | Purpose |
|
||||
| -------------- | ------- | ---------------------------------- |
|
||||
| Node.js | 22+ | For building frontend/backend |
|
||||
| yarn | latest | Package manager |
|
||||
| Java | 21 | Required for Android Gradle plugin |
|
||||
| Android Studio | latest | For building and signing APKs |
|
||||
| Capacitor CLI | latest | Android bridge |
|
||||
| OpenJDK | 21 | JDK for Gradle |
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
sudo apt install openjdk-21-jdk
|
||||
sudo update-alternatives --config java
|
||||
# Select Java 21
|
||||
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
|
||||
java -version
|
||||
javac -version
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 3. Build and Run the Web App
|
||||
|
||||
```
|
||||
yarn install
|
||||
yarn build-web
|
||||
```
|
||||
|
||||
### Local mode
|
||||
|
||||
If you want the webview to load from your local web version of Compass, run the web app.
|
||||
|
||||
In root directory:
|
||||
```bash
|
||||
export NEXT_PUBLIC_LOCAL_ANDROID=1
|
||||
yarn dev # or prod
|
||||
```
|
||||
|
||||
* Runs Next.js frontend at `http://localhost:3000`
|
||||
* Runs backend at `http://10.0.2.2:8088`
|
||||
|
||||
### Deployed mode
|
||||
|
||||
If you want the webview to load from the deployed web version of Compass (like at www.compassmeet.com), no web app to run.
|
||||
|
||||
---
|
||||
|
||||
## 5. Android WebView App Setup
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```
|
||||
cd android
|
||||
./gradlew clean
|
||||
```
|
||||
|
||||
Sync web files and native plugins with Android, for offline fallback. In root:
|
||||
```
|
||||
export NEXT_PUBLIC_LOCAL_ANDROID=1 # if running your local web Compass
|
||||
yarn build-web # if you made changes to web app
|
||||
npx cap sync android
|
||||
```
|
||||
|
||||
### Load from site
|
||||
|
||||
During local development, open Android Studio project and run the app on an emulator or your physical device. Note that right now you can't use a physical device for the local web version (`10.0.2.2:3000 time out` )
|
||||
|
||||
```
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
Building the Application:
|
||||
1. Open Android Studio.
|
||||
2. Click on "Open an existing Android Studio project".
|
||||
3. Navigate to the `android` folder in this repository and select it.
|
||||
4. Wait for Android Studio to index the project and download any necessary dependencies.
|
||||
5. Connect your Android device via USB or set up an Android emulator.
|
||||
6. Click on the "Run" button (green play button) in Android Studio to build and run the application.
|
||||
7. Select your device or emulator and click "OK".
|
||||
8. The application should now build and launch on your device or emulator.
|
||||
|
||||
---
|
||||
|
||||
## 6. Building the APK
|
||||
|
||||
### From Android Studio
|
||||
|
||||
- If you want to generate a signed APK for release, go to "Build" > "Generate Signed Bundle / APK..." and follow the prompts.
|
||||
- Make sure to test the application thoroughly on different devices and Android versions to ensure compatibility.
|
||||
|
||||
### Debug build
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
```
|
||||
android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Install on emulator
|
||||
|
||||
```bash
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Release build (signed)
|
||||
|
||||
1. Generate a release keystore:
|
||||
|
||||
```bash
|
||||
keytool -genkey -v -keystore release-key.keystore -alias compass \
|
||||
-keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
2. Add signing config to `android/app/build.gradle`
|
||||
3. Build:
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
---
|
||||
## 9. Debugging
|
||||
|
||||
Client logs from the emulator on Chrome can be accessed at:
|
||||
```
|
||||
chrome://inspect/#devices
|
||||
```
|
||||
|
||||
Backend logs can be accessed from the output of `yarn prod / dev` like in the web application.
|
||||
|
||||
Java/Kotlin logs can be accessed via Android Studio's Logcat.
|
||||
```
|
||||
adb logcat | grep CompassApp
|
||||
adb logcat | grep com.compassconnections.app
|
||||
adb logcat | grep Capacitor
|
||||
```
|
||||
|
||||
You can also add this inside `MainActivity.java`:
|
||||
|
||||
```java
|
||||
webView.setWebChromeClient(new WebChromeClient() {
|
||||
@Override
|
||||
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
|
||||
Log.d("WebView", consoleMessage.message());
|
||||
return true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Deploy to Play Store
|
||||
|
||||
1. Sign the release APK or AAB.
|
||||
2. Verify package name matches Firebase settings (`com.compassconnections.app`).
|
||||
3. Upload to Google Play Console.
|
||||
4. Add Privacy Policy and content rating.
|
||||
5. Submit for review.
|
||||
|
||||
---
|
||||
|
||||
## 11. Common Issues
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
| -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Old APK signed with different key | Uninstall old app first |
|
||||
| `Account reauth failed [16]` | Missing or incorrect SHA-1 in Firebase | Re-add SHA-1 of keystore |
|
||||
| App opens in Firefox | Missing `WebViewClient` override | Fix `shouldOverrideUrlLoading` |
|
||||
| APK > 1 GB | Cached webpack artifacts included | Add `.next/` and `/public/cache` to `.gitignore` and build excludes |
|
||||
|
||||
---
|
||||
|
||||
## 13. Local Development Workflow
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
export NEXT_PUBLIC_LOCAL_ANDROID=1
|
||||
yarn dev # or prod
|
||||
|
||||
# Terminal 2: start frontend
|
||||
export NEXT_PUBLIC_LOCAL_ANDROID=1
|
||||
yarn build-web # if you made changes to web app
|
||||
npx cap sync android
|
||||
# Run on emulator or device
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Deployment Workflow
|
||||
|
||||
```bash
|
||||
# 1. Build web app for production
|
||||
yarn build-web
|
||||
|
||||
# 2. Sync assets to Android
|
||||
npx cap sync android
|
||||
|
||||
# 3. Build signed release APK in Android Studio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Resources
|
||||
|
||||
* [Capacitor Docs](https://capacitorjs.com/docs)
|
||||
* [Firebase Android Setup](https://firebase.google.com/docs/android/setup)
|
||||
* [FCM HTTP API](https://firebase.google.com/docs/cloud-messaging/send-message)
|
||||
* [Next.js Deployment](https://nextjs.org/docs/deployment)
|
||||
|
||||
|
||||
# Useful Commands
|
||||
|
||||
- To build the project: `./gradlew assembleDebug`
|
||||
- To run unit tests: `./gradlew test`
|
||||
- To run instrumentation tests: `./gradlew connectedAndroidTest`
|
||||
- To clean the project: `./gradlew clean`
|
||||
- To install dependencies: Open Android Studio and it will handle dependencies automatically.
|
||||
- To update dependencies: Modify the `build.gradle` files and sync the project in Android Studio.
|
||||
- To generate a signed APK: Use the "Generate Signed Bundle / APK..." option in Android Studio.
|
||||
- To lint the project: `./gradlew lint`
|
||||
- To check for updates to the Android Gradle Plugin: `./gradlew dependencyUpdates`
|
||||
- To run the application on a connected device or emulator: `./gradlew installDebug`
|
||||
- To view the project structure: Use the "Project" view in Android Studio.
|
||||
- To analyze the APK: `./gradlew analyzeRelease`
|
||||
- To run ProGuard/R8: `./gradlew minifyRelease`
|
||||
- To generate documentation: `./gradlew javadoc`
|
||||
|
||||
# One time setups
|
||||
|
||||
Was already done for Compass, so you only need to do the steps below if you create a project separated from Compass.
|
||||
## Configure Firebase
|
||||
|
||||
### In Firebase Console
|
||||
|
||||
1. Add a **Web app** → obtain `firebaseConfig`
|
||||
2. Add an **Android app**
|
||||
|
||||
* Package name: `com.compassconnections.app`
|
||||
* Add your SHA-1 and SHA-256 fingerprints (see below)
|
||||
* Download `google-services.json` and put it in:
|
||||
|
||||
```
|
||||
android/app/google-services.json
|
||||
```
|
||||
|
||||
### To get SHA-1 for debug keystore
|
||||
|
||||
```bash
|
||||
keytool -list -v \
|
||||
-keystore ~/.android/debug.keystore \
|
||||
-alias androiddebugkey \
|
||||
-storepass android \
|
||||
-keypass android
|
||||
```
|
||||
|
||||
Add both SHA-1 and SHA-256 to Firebase.
|
||||
|
||||
|
||||
## 7. Google Sign-In (Web + Native)
|
||||
|
||||
In Firebase Console:
|
||||
|
||||
* Enable **Google** provider under *Authentication → Sign-in method*
|
||||
* Add your **Android SHA-1**
|
||||
* Add your **Web OAuth client ID**
|
||||
|
||||
In your code:
|
||||
|
||||
```ts
|
||||
import { googleNativeLogin } from 'web/lib/service/android-push'
|
||||
```
|
||||
|
||||
|
||||
## 8. Push Notifications (FCM)
|
||||
|
||||
### Setup FCM
|
||||
|
||||
* Add Firebase Cloud Messaging to your project
|
||||
* Include `google-services.json` under `android/app/`
|
||||
* Add in `android/build.gradle`:
|
||||
|
||||
```gradle
|
||||
classpath 'com.google.gms:google-services:4.3.15'
|
||||
```
|
||||
* Add at the bottom of `android/app/build.gradle`:
|
||||
|
||||
```gradle
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
```
|
||||
|
||||
### Test notification
|
||||
|
||||
```ts
|
||||
const message = {
|
||||
notification: {
|
||||
title: "Test Notification",
|
||||
body: "Hello from Firebase Admin SDK"
|
||||
},
|
||||
token: "..."
|
||||
};
|
||||
initAdmin()
|
||||
await admin.messaging().send(message)
|
||||
.then(response => console.log("Successfully sent message:", response))
|
||||
.catch(error => console.error("Error sending message:", error));
|
||||
```
|
||||
|
||||
---
|
||||
3
android/app/.gitignore
vendored
Normal file
3
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
/google-services.json
|
||||
66
android/app/build.gradle
Normal file
66
android/app/build.gradle
Normal file
@@ -0,0 +1,66 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
|
||||
android {
|
||||
namespace "com.compassconnections.app"
|
||||
compileSdk 36
|
||||
defaultConfig {
|
||||
applicationId "com.compassconnections.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 13
|
||||
versionName "1.1.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
// Import the Firebase BoM
|
||||
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||
// TODO: Add the dependencies for Firebase products you want to use
|
||||
// When using the BoM, don't specify versions in Firebase dependencies
|
||||
implementation 'com.google.firebase:firebase-analytics'
|
||||
// Add the dependencies for any other desired Firebase products
|
||||
// https://firebase.google.com/docs/android/setup#available-libraries
|
||||
|
||||
implementation 'com.google.android.gms:play-services-auth:21.4.0'
|
||||
implementation 'com.google.firebase:firebase-auth:24.0.1'
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
23
android/app/capacitor.build.gradle
Normal file
23
android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,23 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-social-login')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
android/app/proguard-rules.pro
vendored
Normal file
21
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
37
android/app/release/output-metadata.json
Normal file
37
android/app/release/output-metadata.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.compassconnections.app",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 13,
|
||||
"versionName": "1.1.2",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 23
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
76
android/app/src/main/AndroidManifest.xml
Normal file
76
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.1" encoding="utf-8" ?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:fitsSystemWindows="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="openapp" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||
|
||||
<!-- <data android:scheme="com.compassconnections.app" android:host="details"/>-->
|
||||
<!-- </intent-filter>-->
|
||||
|
||||
<!-- <intent-filter android:autoVerify="true">-->
|
||||
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||
<!-- <data android:scheme="com.compassconnections.app" android:host="auth" />-->
|
||||
<!-- </intent-filter>-->
|
||||
</activity>
|
||||
|
||||
<!-- <service-->
|
||||
<!-- android:name=".MyMessagingService"-->
|
||||
<!-- android:exported="false">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="com.google.firebase.MESSAGING_EVENT" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!--<!– <meta-data–>-->
|
||||
<!--<!– android:name="com.google.firebase.messaging.default_notification_channel_id"–>-->
|
||||
<!--<!– android:value="@string/default_notification_channel_id" />–>-->
|
||||
<!-- </service>-->
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
|
||||
|
||||
<!-- Firebase Cloud Messaging -->
|
||||
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature" />
|
||||
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE" />
|
||||
<!-- Old, can be removed ?-->
|
||||
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.compassconnections.app;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginHandle;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import ee.forgr.capacitor.social.login.GoogleProvider;
|
||||
import ee.forgr.capacitor.social.login.ModifiedMainActivityForSocialLoginPlugin;
|
||||
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
|
||||
|
||||
|
||||
//import android.app.NotificationChannel;
|
||||
//import android.app.NotificationManager;
|
||||
//import android.os.Build;
|
||||
//import com.google.firebase.messaging.RemoteMessage;
|
||||
//import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
||||
|
||||
//public class MyMessagingService extends MessagingService {
|
||||
//
|
||||
// @Override
|
||||
// public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||
// // TODO(developer): Handle FCM messages here.
|
||||
// // Not getting messages here? See why this may be: https://goo.gl/39bRNJ
|
||||
// Log.d(TAG, "From: " + remoteMessage.getFrom());
|
||||
//
|
||||
// // Check if message contains a data payload.
|
||||
// if (remoteMessage.getData().size() > 0) {
|
||||
// Log.d(TAG, "Message data payload: " + remoteMessage.getData());
|
||||
//
|
||||
// if (/* Check if data needs to be processed by long running job */ true) {
|
||||
// // For long-running tasks (10 seconds or more) use WorkManager.
|
||||
// scheduleJob();
|
||||
// } else {
|
||||
// // Handle message within 10 seconds
|
||||
// handleNow();
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
// // Check if message contains a notification payload.
|
||||
// if (remoteMessage.getNotification() != null) {
|
||||
// Log.d(TAG, "Message Notification Body: " + remoteMessage.getNotification().getBody());
|
||||
// }
|
||||
//
|
||||
// // Also if you intend on generating your own notifications as a result of a received FCM
|
||||
// // message, here is where that should be initiated. See sendNotification method below.
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
|
||||
|
||||
// Declare this at class level
|
||||
private final ActivityResultLauncher<String> requestPermissionLauncher =
|
||||
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
|
||||
if (isGranted) {
|
||||
Log.i("CompassApp", "Permission granted");
|
||||
// Permission granted – you can show notifications
|
||||
} else {
|
||||
Log.i("CompassApp", "Permission denied");
|
||||
// Permission denied – handle gracefully
|
||||
}
|
||||
});
|
||||
|
||||
private void askNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // API 33
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
// Permission not yet granted; request it
|
||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class NativeBridge {
|
||||
@JavascriptInterface
|
||||
public boolean isNativeApp() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
|
||||
// String data = intent.getDataString();
|
||||
String endpoint = intent.getStringExtra("endpoint");
|
||||
Log.i("CompassApp", "onNewIntent called with endpoint: " + endpoint);
|
||||
if (endpoint != null) {
|
||||
Log.i("CompassApp", "redirecting to endpoint: " + endpoint);
|
||||
try {
|
||||
String payload = new JSONObject().put("endpoint", endpoint).toString();
|
||||
Log.i("CompassApp", "Payload: " + payload);
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("bridgeRedirect(" + payload + ");", null));
|
||||
} catch (JSONException e) {
|
||||
Log.i("CompassApp", "Failed to encode JSON payload", e);
|
||||
}
|
||||
} else {
|
||||
Log.i("CompassApp", "No relevant data");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i("CompassApp", "onCreate called");
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
WebView webView = this.bridge.getWebView();
|
||||
webView.setWebViewClient(new BridgeWebViewClient(this.bridge));
|
||||
|
||||
WebView.setWebContentsDebuggingEnabled(true);
|
||||
|
||||
// Set a recognizable User-Agent (always reliable)
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setUserAgentString(settings.getUserAgentString() + " CompassAppWebView");
|
||||
|
||||
settings.setJavaScriptEnabled(true);
|
||||
webView.addJavascriptInterface(new NativeBridge(), "AndroidBridge");
|
||||
|
||||
registerPlugin(PushNotificationsPlugin.class);
|
||||
// Initialize the Bridge with Push Notifications plugin
|
||||
// this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
|
||||
// add(com.getcapacitor.plugin.PushNotifications.class);
|
||||
// }});
|
||||
|
||||
askNotificationPermission();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
if (requestCode >= GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MIN && requestCode < GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MAX) {
|
||||
PluginHandle pluginHandle = getBridge().getPlugin("SocialLogin");
|
||||
if (pluginHandle == null) {
|
||||
Log.i("CompassApp", "SocialLogin login handle is null");
|
||||
return;
|
||||
}
|
||||
Plugin plugin = pluginHandle.getInstance();
|
||||
if (!(plugin instanceof SocialLoginPlugin)) {
|
||||
Log.i("CompassApp", "SocialLogin plugin instance is not SocialLoginPlugin");
|
||||
return;
|
||||
}
|
||||
Log.i("CompassApp", "handleGoogleLoginIntent");
|
||||
((SocialLoginPlugin) plugin).handleGoogleLoginIntent(requestCode, data);
|
||||
}
|
||||
}
|
||||
|
||||
// This function will never be called, leave it empty
|
||||
@Override
|
||||
public void IHaveModifiedTheMainActivityForTheUseWithSocialLoginPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
12
android/app/src/main/res/layout/activity_main.xml
Normal file
12
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/strings.xml
Normal file
7
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Compass</string>
|
||||
<string name="title_activity_main">Compass</string>
|
||||
<string name="package_name">com.compassconnections.app</string>
|
||||
<string name="custom_url_scheme">com.compassconnections.app</string>
|
||||
</resources>
|
||||
25
android/app/src/main/res/values/styles.xml
Normal file
25
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:windowTranslucentStatus">true</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:windowTranslucentStatus">true</item>
|
||||
<item name="android:windowTranslucentNavigation">true</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
android/app/src/main/res/xml/file_paths.xml
Normal file
5
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
29
android/build.gradle
Normal file
29
android/build.gradle
Normal file
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
18
android/capacitor.settings.gradle
Normal file
18
android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,18 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
include ':capgo-capacitor-social-login'
|
||||
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')
|
||||
22
android/gradle.properties
Normal file
22
android/gradle.properties
Normal file
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
android/gradlew
vendored
Executable file
252
android/gradlew
vendored
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
Normal file
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
android/settings.gradle
Normal file
5
android/settings.gradle
Normal file
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
android/variables.gradle
Normal file
16
android/variables.gradle
Normal file
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
@@ -11,11 +11,11 @@
|
||||
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
|
||||
"build:fast": "yarn compile && yarn dist:copy",
|
||||
"clean": "rm -rf lib && (cd ../../common && rm -rf lib) && (cd ../shared && rm -rf lib) && (cd ../email && rm -rf lib)",
|
||||
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias)",
|
||||
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias) && cp -r src/public/ lib/",
|
||||
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
|
||||
"dist": "yarn dist:clean && yarn dist:copy",
|
||||
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
|
||||
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api",
|
||||
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api",
|
||||
"watch": "tsc -w",
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0",
|
||||
|
||||
@@ -65,6 +65,13 @@ import {OpenAPIV3} from 'openapi-types';
|
||||
import {version as pkgVersion} from './../package.json'
|
||||
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from "zod";
|
||||
import {getUser} from "api/get-user";
|
||||
import {localSendTestEmail} from "api/test";
|
||||
import path from "node:path";
|
||||
import {saveSubscriptionMobile} from "api/save-subscription-mobile";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
import {editMessage} from "api/edit-message";
|
||||
import {reactToMessage} from "api/react-to-message";
|
||||
import {deleteMessage} from "api/delete-message";
|
||||
|
||||
// const corsOptions: CorsOptions = {
|
||||
// origin: ['*'], // Only allow requests from this domain
|
||||
@@ -119,12 +126,10 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
export const app = express()
|
||||
app.use(requestMonitoring)
|
||||
|
||||
|
||||
const schemaCache = new WeakMap<ZodTypeAny, any>();
|
||||
|
||||
export function zodToOpenApiSchema(
|
||||
zodObj: ZodTypeAny,
|
||||
nameHint?: string
|
||||
): any { // Prevent infinite recursion
|
||||
export function zodToOpenApiSchema(zodObj: ZodTypeAny,): any {
|
||||
if (schemaCache.has(zodObj)) {
|
||||
return schemaCache.get(zodObj);
|
||||
}
|
||||
@@ -140,19 +145,19 @@ export function zodToOpenApiSchema(
|
||||
|
||||
switch (typeName) {
|
||||
case 'ZodString':
|
||||
schema = { type: 'string' };
|
||||
schema = {type: 'string'};
|
||||
break;
|
||||
case 'ZodNumber':
|
||||
schema = { type: 'number' };
|
||||
schema = {type: 'number'};
|
||||
break;
|
||||
case 'ZodBoolean':
|
||||
schema = { type: 'boolean' };
|
||||
schema = {type: 'boolean'};
|
||||
break;
|
||||
case 'ZodEnum':
|
||||
schema = { type: 'string', enum: def.values };
|
||||
schema = {type: 'string', enum: def.values};
|
||||
break;
|
||||
case 'ZodArray':
|
||||
schema = { type: 'array', items: zodToOpenApiSchema(def.type) };
|
||||
schema = {type: 'array', items: zodToOpenApiSchema(def.type)};
|
||||
break;
|
||||
case 'ZodObject': {
|
||||
const shape = def.shape();
|
||||
@@ -161,14 +166,14 @@ export function zodToOpenApiSchema(
|
||||
|
||||
for (const key in shape) {
|
||||
const child = shape[key];
|
||||
properties[key] = zodToOpenApiSchema(child, key);
|
||||
properties[key] = zodToOpenApiSchema(child);
|
||||
if (!child.isOptional()) required.push(key);
|
||||
}
|
||||
|
||||
schema = {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length ? { required } : {}),
|
||||
...(required.length ? {required} : {}),
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -181,14 +186,11 @@ export function zodToOpenApiSchema(
|
||||
case 'ZodIntersection': {
|
||||
const left = zodToOpenApiSchema(def.left);
|
||||
const right = zodToOpenApiSchema(def.right);
|
||||
schema = { allOf: [left, right] };
|
||||
schema = {allOf: [left, right]};
|
||||
break;
|
||||
}
|
||||
case 'ZodLazy':
|
||||
// Recursive schema: use a $ref placeholder name
|
||||
schema = {
|
||||
$ref: `#/components/schemas/${nameHint ?? 'RecursiveType'}`,
|
||||
};
|
||||
schema = {type: 'object', description: 'Lazy schema - details omitted'};
|
||||
break;
|
||||
case 'ZodUnion':
|
||||
schema = {
|
||||
@@ -196,7 +198,7 @@ export function zodToOpenApiSchema(
|
||||
};
|
||||
break;
|
||||
default:
|
||||
schema = { type: 'string' }; // fallback for unhandled
|
||||
schema = {type: 'string'}; // fallback for unhandled
|
||||
}
|
||||
|
||||
Object.assign(placeholder, schema);
|
||||
@@ -291,15 +293,15 @@ const swaggerDocument: OpenAPIV3.Document = {
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'x-api-key',
|
||||
},
|
||||
},
|
||||
}
|
||||
} as OpenAPIV3.Document;
|
||||
|
||||
|
||||
const rootPath = pathWithPrefix("/")
|
||||
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
||||
app.use(rootPath, swaggerUi.serve)
|
||||
|
||||
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
|
||||
// May not be necessary
|
||||
// app.options('*', allowCorsUnrestricted)
|
||||
@@ -356,8 +358,13 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'get-messages-count': getMessagesCount,
|
||||
'set-last-online-time': setLastOnlineTime,
|
||||
'save-subscription': saveSubscription,
|
||||
'save-subscription-mobile': saveSubscriptionMobile,
|
||||
'create-bookmarked-search': createBookmarkedSearch,
|
||||
'delete-bookmarked-search': deleteBookmarkedSearch,
|
||||
'delete-message': deleteMessage,
|
||||
'edit-message': editMessage,
|
||||
'react-to-message': reactToMessage,
|
||||
// 'auth-google': authGoogle,
|
||||
}
|
||||
|
||||
Object.entries(handlers).forEach(([path, handler]) => {
|
||||
@@ -407,6 +414,108 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
}
|
||||
);
|
||||
|
||||
const responses = {
|
||||
200: {
|
||||
description: "Request successful",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {type: "string", example: "success"}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized (e.g., invalid or missing API key)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: {type: "string", example: "Unauthorized"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Internal server error during request processing",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: {type: "string", example: "Internal server error"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
swaggerDocument.paths["/internal/send-search-notifications"] = {
|
||||
post: {
|
||||
summary: "Trigger daily search notifications",
|
||||
description:
|
||||
"Internal endpoint used by Compass schedulers to send daily notifications for bookmarked searches. Requires a valid `x-api-key` header.",
|
||||
tags: ["Internal"],
|
||||
security: [
|
||||
{
|
||||
ApiKeyAuth: [],
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
},
|
||||
responses: responses,
|
||||
},
|
||||
} as any
|
||||
|
||||
|
||||
// Local Endpoints
|
||||
if (IS_LOCAL) {
|
||||
app.post(pathWithPrefix("/local/send-test-email"),
|
||||
async (req, res) => {
|
||||
if (!IS_LOCAL) {
|
||||
return res.status(401).json({error: "Unauthorized"});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await localSendTestEmail()
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
return res.status(500).json({error: err});
|
||||
}
|
||||
}
|
||||
);
|
||||
swaggerDocument.paths["/local/send-test-email"] = {
|
||||
post: {
|
||||
summary: "Send a test email",
|
||||
description: "Local endpoint to send a test email.",
|
||||
tags: ["Local"],
|
||||
requestBody: {
|
||||
required: false,
|
||||
},
|
||||
responses: responses,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
|
||||
const rootPath = pathWithPrefix("/")
|
||||
app.get(
|
||||
rootPath,
|
||||
swaggerUi.setup(swaggerDocument, {
|
||||
customSiteTitle: 'Compass API Docs',
|
||||
customCssUrl: '/swagger.css',
|
||||
}),
|
||||
)
|
||||
app.use(rootPath, swaggerUi.serve)
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.use(allowCorsUnrestricted, (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
|
||||
37
backend/api/src/auth-google.ts
Normal file
37
backend/api/src/auth-google.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// import {APIError, APIHandler} from './helpers/endpoint'
|
||||
// import {GOOGLE_CLIENT_ID} from "common/constants";
|
||||
// import {REDIRECT_URI} from "common/envs/constants";
|
||||
//
|
||||
// export const authGoogle: APIHandler<'auth-google'> = async (
|
||||
// {code},
|
||||
// _auth
|
||||
// ) => {
|
||||
// console.log('Google Auth Codes:', code)
|
||||
// if (!code) return {success: false, result: {}}
|
||||
//
|
||||
// const body = {
|
||||
// client_id: GOOGLE_CLIENT_ID,
|
||||
// client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
// code: code as string,
|
||||
// grant_type: 'authorization_code',
|
||||
// redirect_uri: REDIRECT_URI,
|
||||
// };
|
||||
// console.log('Body:', body)
|
||||
// const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
// method: 'POST',
|
||||
// headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
// body: new URLSearchParams(body),
|
||||
// });
|
||||
//
|
||||
// const tokens = await tokenRes.json();
|
||||
// if (tokens.error) {
|
||||
// console.error('Google token error:', tokens);
|
||||
// throw new APIError(400, 'Google token error: ' + JSON.stringify(tokens))
|
||||
// }
|
||||
// console.log('Google Tokens:', tokens);
|
||||
//
|
||||
// return {
|
||||
// success: true,
|
||||
// result: {tokens},
|
||||
// }
|
||||
// }
|
||||
@@ -4,6 +4,24 @@ import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||
import {tryCatch} from "common/util/try-catch";
|
||||
import {Row} from "common/supabase/utils";
|
||||
|
||||
export const createAndroidTestNotifications = async () => {
|
||||
const createdTime = Date.now();
|
||||
const id = `android-test-${createdTime}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: 'todo',
|
||||
createdTime: createdTime,
|
||||
isSeen: false,
|
||||
sourceType: 'info',
|
||||
sourceUpdateType: 'created',
|
||||
sourceSlug: '/contact',
|
||||
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
|
||||
title: 'Android App Ready for Review — Help Us Unlock the Google Play Release',
|
||||
sourceText: 'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Play–registered email address so we can add you as a tester and complete the review process.',
|
||||
}
|
||||
return await createNotifications(notification)
|
||||
}
|
||||
|
||||
export const createShareNotifications = async () => {
|
||||
const createdTime = Date.now();
|
||||
const id = `share-${createdTime}`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import { HOUR_MS } from 'common/util/time'
|
||||
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
|
||||
import { removePinnedUrlFromPhotoUrls } from 'shared/profiles/parse-photos'
|
||||
import { track } from 'shared/analytics'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
@@ -41,7 +41,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
throw new APIError(500, 'Error creating user')
|
||||
}
|
||||
|
||||
log('Created user', data)
|
||||
log('Created profile', data)
|
||||
|
||||
const continuation = async () => {
|
||||
try {
|
||||
@@ -50,6 +50,10 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
// Let the user fill in the optional form with all their info and pictures before notifying discord of their arrival.
|
||||
// So we can sse their full profile as soon as we get the notif on discord. And that allows OG to pull their pic for the link preview.
|
||||
// Regardless, you need to wait for at least 5 seconds that the profile is fully in the db—otherwise ISR may cache "profile not created yet"
|
||||
await sleep(10 * MINUTE_MS)
|
||||
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
||||
if (body.bio) {
|
||||
const bioText = jsonToMarkdown(body.bio)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||
import {removeUndefinedProps} from 'common/util/object'
|
||||
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||
import {IS_LOCAL, RESERVED_PATHS} from 'common/envs/constants'
|
||||
import {RESERVED_PATHS} from 'common/envs/constants'
|
||||
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
@@ -15,6 +15,7 @@ import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||
import {getBucket} from "shared/firebase-utils";
|
||||
import {sendWelcomeEmail} from "email/functions/helpers";
|
||||
import {setLastOnlineTimeUser} from "api/set-last-online-time";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
|
||||
export const createUser: APIHandler<'create-user'> = async (
|
||||
props,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const createVote: APIHandler<
|
||||
title,
|
||||
description,
|
||||
is_anonymous: isAnonymous,
|
||||
status: 'voting_open',
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -4,18 +4,11 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import * as admin from "firebase-admin";
|
||||
import {deleteUserFiles} from "shared/firebase-utils";
|
||||
|
||||
export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
const {username} = body
|
||||
export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) {
|
||||
throw new APIError(401, 'Your account was not found')
|
||||
}
|
||||
if (user.username != username) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
|
||||
)
|
||||
}
|
||||
const userId = user.id
|
||||
if (!userId) {
|
||||
throw new APIError(400, 'Invalid user ID')
|
||||
@@ -25,11 +18,6 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||
// Should cascade delete in other tables
|
||||
// await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
|
||||
// await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
|
||||
// await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
|
||||
// await pg.none('DELETE FROM compatibility_answers WHERE creator_id = $1', [userId])
|
||||
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
|
||||
|
||||
// Delete user files from Firebase Storage
|
||||
await deleteUserFiles(user.username)
|
||||
|
||||
64
backend/api/src/delete-message.ts
Normal file
64
backend/api/src/delete-message.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||
|
||||
// const DELETED_MESSAGE_CONTENT: JSONContent = {
|
||||
// type: 'doc',
|
||||
// content: [
|
||||
// {
|
||||
// type: 'paragraph',
|
||||
// content: [
|
||||
// {
|
||||
// type: 'text',
|
||||
// text: '[deleted]',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ],
|
||||
// }
|
||||
|
||||
export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Verify user is the message author and message is not too old
|
||||
const message = await pg.oneOrNone(
|
||||
`SELECT *
|
||||
FROM private_user_messages
|
||||
WHERE id = $1
|
||||
AND user_id = $2`,
|
||||
[messageId, auth.uid]
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
throw new APIError(404, 'Message not found')
|
||||
}
|
||||
|
||||
// Soft delete the message
|
||||
// await pg.none(
|
||||
// `UPDATE private_user_messages
|
||||
// SET deleted = TRUE,
|
||||
// content = $2::jsonb,
|
||||
// ciphertext = NULL,
|
||||
// iv = NULL,
|
||||
// tag = NULL
|
||||
// WHERE id = $1`,
|
||||
// [messageId, DELETED_MESSAGE_CONTENT]
|
||||
// )
|
||||
|
||||
// Hard delete the message
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM private_user_messages
|
||||
WHERE id = $1
|
||||
AND user_id = $2`,
|
||||
[messageId, auth.uid]
|
||||
)
|
||||
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||
.catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
|
||||
44
backend/api/src/edit-message.ts
Normal file
44
backend/api/src/edit-message.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {encryptMessage} from "shared/encryption";
|
||||
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||
|
||||
|
||||
export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Verify user is the message author and message is not too old
|
||||
const message = await pg.oneOrNone(
|
||||
`SELECT *
|
||||
FROM private_user_messages
|
||||
WHERE id = $1
|
||||
AND user_id = $2
|
||||
-- AND created_time > NOW() - INTERVAL '1 day'
|
||||
AND deleted = FALSE`,
|
||||
[messageId, auth.uid]
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
throw new APIError(404, 'Message not found or cannot be edited')
|
||||
}
|
||||
|
||||
const plaintext = JSON.stringify(content)
|
||||
const {ciphertext, iv, tag} = encryptMessage(plaintext)
|
||||
await pg.none(
|
||||
`UPDATE private_user_messages
|
||||
SET ciphertext = $1,
|
||||
iv = $2,
|
||||
tag = $3,
|
||||
is_edited = TRUE,
|
||||
edited_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[ciphertext, iv, tag, messageId]
|
||||
)
|
||||
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||
.catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||
|
||||
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, auth) => {
|
||||
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const result = await pg.one(
|
||||
`
|
||||
|
||||
@@ -22,6 +22,7 @@ export type profileQueryType = {
|
||||
pref_romantic_styles?: String[] | undefined,
|
||||
diet?: String[] | undefined,
|
||||
political_beliefs?: String[] | undefined,
|
||||
religion?: String[] | undefined,
|
||||
wants_kids_strength?: number | undefined,
|
||||
has_kids?: number | undefined,
|
||||
is_smoker?: boolean | undefined,
|
||||
@@ -57,6 +58,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
pref_romantic_styles,
|
||||
diet,
|
||||
political_beliefs,
|
||||
religion,
|
||||
wants_kids_strength,
|
||||
has_kids,
|
||||
is_smoker,
|
||||
@@ -87,7 +89,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
const profiles = compatibleProfiles.filter(
|
||||
(l) =>
|
||||
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
||||
(!genders || genders.includes(l.gender)) &&
|
||||
(!genders || genders.includes(l.gender ?? '')) &&
|
||||
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
|
||||
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
||||
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
||||
@@ -102,6 +104,8 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
intersection(diet, l.diet).length) &&
|
||||
(!political_beliefs ||
|
||||
intersection(political_beliefs, l.political_beliefs).length) &&
|
||||
(!religion ||
|
||||
intersection(religion, l.religion).length) &&
|
||||
(!wants_kids_strength ||
|
||||
wants_kids_strength == -1 ||
|
||||
!l.wants_kids_strength ||
|
||||
@@ -114,6 +118,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(has_kids == 0 && !l.has_kids) ||
|
||||
(l.has_kids && l.has_kids > 0)) &&
|
||||
(is_smoker === undefined || l.is_smoker === is_smoker) &&
|
||||
(!l.disabled) &&
|
||||
(l.id.toString() != skipId) &&
|
||||
(!geodbCityIds ||
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
||||
@@ -145,6 +150,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
join('users on users.id = profiles.user_id'),
|
||||
leftJoin(userActivityJoin),
|
||||
where('looking_for_matches = true'),
|
||||
where(`profiles.disabled != true`),
|
||||
// where(`pinned_url is not null and pinned_url != ''`),
|
||||
where(
|
||||
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
|
||||
@@ -199,6 +205,12 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
{political_beliefs}
|
||||
),
|
||||
|
||||
religion?.length &&
|
||||
where(
|
||||
`religion IS NULL OR religion = '{}' OR religion && $(religion)`,
|
||||
{religion}
|
||||
),
|
||||
|
||||
!!wants_kids_strength &&
|
||||
wants_kids_strength !== -1 &&
|
||||
where(
|
||||
|
||||
@@ -13,9 +13,11 @@ import {sendNewMessageEmail} from 'email/functions/helpers'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import webPush from 'web-push';
|
||||
import {parseJsonContentToText} from "common/util/parse";
|
||||
import {encryptMessage} from "shared/encryption";
|
||||
import webPush from 'web-push'
|
||||
import {parseJsonContentToText} from "common/util/parse"
|
||||
import {encryptMessage} from "shared/encryption"
|
||||
import * as admin from 'firebase-admin'
|
||||
import {TokenMessage} from "firebase-admin/lib/messaging/messaging-api";
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
@@ -29,17 +31,17 @@ export const leaveChatContent = (userName: string) => ({
|
||||
},
|
||||
],
|
||||
})
|
||||
export const joinChatContent = (userName: string) => {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{text: `${userName} joined the chat!`, type: 'text'}],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
// export const joinChatContent = (userName: string) => {
|
||||
// return {
|
||||
// type: 'doc',
|
||||
// content: [
|
||||
// {
|
||||
// type: 'paragraph',
|
||||
// content: [{text: `${userName} joined the chat!`, type: 'text'}],
|
||||
// },
|
||||
// ],
|
||||
// }
|
||||
// }
|
||||
|
||||
export const insertPrivateMessage = async (
|
||||
content: Json,
|
||||
@@ -48,8 +50,8 @@ export const insertPrivateMessage = async (
|
||||
visibility: ChatVisibility,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
const plaintext = JSON.stringify(content);
|
||||
const {ciphertext, iv, tag} = encryptMessage(plaintext);
|
||||
const plaintext = JSON.stringify(content)
|
||||
const {ciphertext, iv, tag} = encryptMessage(plaintext)
|
||||
const lastMessage = await pg.one(
|
||||
`insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
@@ -88,6 +90,27 @@ export const addUsersToPrivateMessageChannel = async (
|
||||
)
|
||||
}
|
||||
|
||||
export async function broadcastPrivateMessages(
|
||||
pg: SupabaseDirectClient,
|
||||
channelId: number,
|
||||
userId: string,
|
||||
) {
|
||||
const otherUserIds = await pg.map<string>(
|
||||
`select user_id
|
||||
from private_user_message_channel_members
|
||||
where channel_id = $1
|
||||
and user_id != $2
|
||||
and status != 'left'
|
||||
`,
|
||||
[channelId, userId],
|
||||
(r) => r.user_id
|
||||
)
|
||||
otherUserIds.concat(userId).forEach((otherUserId) => {
|
||||
broadcast(`private-user-messages/${otherUserId}`, {})
|
||||
})
|
||||
return otherUserIds;
|
||||
}
|
||||
|
||||
export const createPrivateUserMessageMain = async (
|
||||
creator: User,
|
||||
channelId: number,
|
||||
@@ -115,26 +138,13 @@ export const createPrivateUserMessageMain = async (
|
||||
channel_id: channelId,
|
||||
user_id: creator.id,
|
||||
}
|
||||
|
||||
const otherUserIds = await pg.map<string>(
|
||||
`select user_id
|
||||
from private_user_message_channel_members
|
||||
where channel_id = $1
|
||||
and user_id != $2
|
||||
and status != 'left'
|
||||
`,
|
||||
[channelId, creator.id],
|
||||
(r) => r.user_id
|
||||
)
|
||||
otherUserIds.concat(creator.id).forEach((otherUserId) => {
|
||||
broadcast(`private-user-messages/${otherUserId}`, {})
|
||||
})
|
||||
const otherUserIds = await broadcastPrivateMessages(pg, channelId, creator.id);
|
||||
|
||||
// Fire and forget safely
|
||||
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
|
||||
.catch((err) => {
|
||||
console.error('notifyOtherUserInChannelIfInactive failed', err)
|
||||
});
|
||||
})
|
||||
|
||||
track(creator.id, 'send private message', {
|
||||
channelId,
|
||||
@@ -162,49 +172,24 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
// We're only sending notifs for 1:1 channels
|
||||
if (!otherUserIds || otherUserIds.length > 1) return
|
||||
|
||||
const otherUserId = first(otherUserIds)
|
||||
if (!otherUserId) return
|
||||
const receiverId = first(otherUserIds)?.user_id
|
||||
if (!receiverId) return
|
||||
|
||||
// TODO: notification only for active user
|
||||
|
||||
const otherUser = await getUser(otherUserId.user_id)
|
||||
console.debug('otherUser:', otherUser)
|
||||
if (!otherUser) return
|
||||
const receiver = await getUser(receiverId)
|
||||
console.debug('receiver:', receiver)
|
||||
if (!receiver) return
|
||||
|
||||
// Push notif
|
||||
webPush.setVapidDetails(
|
||||
'mailto:hello@compassmeet.com',
|
||||
process.env.VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!
|
||||
);
|
||||
// Push notifs
|
||||
const textContent = parseJsonContentToText(content)
|
||||
// Retrieve subscription from the database
|
||||
const subscriptions = await getSubscriptionsFromDB(otherUser.id, pg);
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
title: `${creator.name}`,
|
||||
body: textContent,
|
||||
url: `/messages/${channelId}`,
|
||||
})
|
||||
console.log('Sending notification to:', subscription.endpoint, payload);
|
||||
await webPush.sendNotification(subscription, payload);
|
||||
} catch (err: any) {
|
||||
console.log('Failed to send notification', err);
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
console.warn('Removing expired subscription', subscription.endpoint);
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions
|
||||
WHERE endpoint = $1
|
||||
AND user_id = $2`,
|
||||
[subscription.endpoint, otherUser.id]
|
||||
);
|
||||
} else {
|
||||
console.error('Push failed', err);
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
title: `${creator.name}`,
|
||||
body: textContent,
|
||||
url: `/messages/${channelId}`,
|
||||
}
|
||||
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
|
||||
await sendMobileNotifications(pg, receiverId, payload)
|
||||
|
||||
const startOfDay = dayjs()
|
||||
.tz('America/Los_Angeles')
|
||||
@@ -220,9 +205,9 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
[channelId, creator.id, startOfDay]
|
||||
)
|
||||
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
|
||||
if (previousMessagesThisDayBetweenTheseUsers.count > 0) return
|
||||
if (previousMessagesThisDayBetweenTheseUsers.count > 1) return
|
||||
|
||||
await createNewMessageNotification(creator, otherUser, channelId)
|
||||
await createNewMessageNotification(creator, receiver, channelId)
|
||||
}
|
||||
|
||||
const createNewMessageNotification = async (
|
||||
@@ -237,9 +222,38 @@ const createNewMessageNotification = async (
|
||||
}
|
||||
|
||||
|
||||
export async function getSubscriptionsFromDB(
|
||||
async function sendWebNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
payload: string,
|
||||
) {
|
||||
webPush.setVapidDetails(
|
||||
'mailto:hello@compassmeet.com',
|
||||
process.env.VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!
|
||||
)
|
||||
// Retrieve subscription from the database
|
||||
const subscriptions = await getSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
console.log('Sending notification to:', subscription.endpoint, payload)
|
||||
await webPush.sendNotification(subscription, payload)
|
||||
} catch (err: any) {
|
||||
console.log('Failed to send notification', err)
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
console.warn('Removing expired subscription', subscription.endpoint)
|
||||
await removeSubscription(pg, subscription.endpoint, userId)
|
||||
} else {
|
||||
console.error('Push failed', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getSubscriptionsFromDB(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
pg: SupabaseDirectClient
|
||||
) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(`
|
||||
@@ -247,14 +261,127 @@ export async function getSubscriptionsFromDB(
|
||||
from push_subscriptions
|
||||
where user_id = $1
|
||||
`, [userId]
|
||||
);
|
||||
)
|
||||
|
||||
return subscriptions.map(sub => ({
|
||||
endpoint: sub.endpoint,
|
||||
keys: sub.keys,
|
||||
}));
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err);
|
||||
return [];
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSubscription(
|
||||
pg: SupabaseDirectClient,
|
||||
endpoint: any,
|
||||
userId: string,
|
||||
) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions
|
||||
WHERE endpoint = $1
|
||||
AND user_id = $2`,
|
||||
[endpoint, userId]
|
||||
)
|
||||
}
|
||||
|
||||
async function removeMobileSubscription(
|
||||
pg: SupabaseDirectClient,
|
||||
token: any,
|
||||
userId: string,
|
||||
) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions_mobile
|
||||
WHERE token = $1
|
||||
AND user_id = $2`,
|
||||
[token, userId]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async function sendMobileNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
await sendPushToToken(pg, userId, subscription.token, payload)
|
||||
}
|
||||
}
|
||||
|
||||
interface PushPayload {
|
||||
title: string
|
||||
body: string
|
||||
url: string
|
||||
data?: Record<string, string>
|
||||
}
|
||||
|
||||
export async function sendPushToToken(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
token: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const message: TokenMessage = {
|
||||
token,
|
||||
android: {
|
||||
notification: {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
endpoint: payload.url,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
// Fine to create at each call, as it's a cached singleton
|
||||
const fcm = admin.messaging()
|
||||
console.log('Sending notification to:', token, message)
|
||||
const response = await fcm.send(message)
|
||||
console.log('Push sent successfully:', response)
|
||||
return response
|
||||
} catch (err: unknown) {
|
||||
// Check if it's a Firebase Messaging error
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const firebaseError = err as { code: string; message: string }
|
||||
console.warn('Firebase error:', firebaseError.code, firebaseError.message)
|
||||
|
||||
// Handle specific error cases here if needed
|
||||
// For example, if token is no longer valid:
|
||||
if (firebaseError.code === 'messaging/registration-token-not-registered' ||
|
||||
firebaseError.code === 'messaging/invalid-argument') {
|
||||
console.warn('Removing invalid FCM token')
|
||||
await removeMobileSubscription(pg, token, userId)
|
||||
}
|
||||
} else {
|
||||
console.error('Unknown error:', err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
export async function getMobileSubscriptionsFromDB(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(`
|
||||
select token
|
||||
from push_subscriptions_mobile
|
||||
where user_id = $1
|
||||
`, [userId]
|
||||
)
|
||||
|
||||
return subscriptions
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
64
backend/api/src/public/swagger.css
Normal file
64
backend/api/src/public/swagger.css
Normal file
@@ -0,0 +1,64 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.swagger-ui p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
label,
|
||||
.btn,
|
||||
.parameter__name,
|
||||
.parameter__type,
|
||||
.parameter__in,
|
||||
.response-control-media-type__title,
|
||||
table thead tr td,
|
||||
table thead tr th,
|
||||
.tab li,
|
||||
.response-col_links,
|
||||
.opblock-summary-description {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.swagger-ui .topbar, .opblock-body select, textarea {
|
||||
background-color: #2b2b2b !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.swagger-ui .opblock {
|
||||
background-color: #2c2c2c !important;
|
||||
border-color: #fff !important;
|
||||
}
|
||||
.swagger-ui .opblock .opblock-summary-method {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui .opblock .opblock-section-header {
|
||||
background: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui .responses-wrapper {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
.swagger-ui .response-col_status {
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui .scheme-container {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
.swagger-ui .modal-ux, input {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
.swagger-ui .close-modal svg {
|
||||
color: #1e90ff !important;
|
||||
}
|
||||
a {
|
||||
color: #1e90ff !important;
|
||||
}
|
||||
}
|
||||
60
backend/api/src/react-to-message.ts
Normal file
60
backend/api/src/react-to-message.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||
|
||||
|
||||
export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId, reaction, toDelete}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Verify user is a member of the channel
|
||||
const message = await pg.oneOrNone(
|
||||
`SELECT *
|
||||
FROM private_user_message_channel_members m
|
||||
JOIN private_user_messages msg ON msg.channel_id = m.channel_id
|
||||
WHERE m.user_id = $1
|
||||
AND msg.id = $2`,
|
||||
[auth.uid, messageId]
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
throw new APIError(403, 'Not authorized to react to this message')
|
||||
}
|
||||
|
||||
if (toDelete) {
|
||||
// Remove the reaction
|
||||
await pg.none(
|
||||
`UPDATE private_user_messages
|
||||
SET reactions = reactions - $1
|
||||
WHERE id = $2
|
||||
AND reactions -> $1 ? $3`,
|
||||
[reaction, messageId, auth.uid]
|
||||
)
|
||||
} else {
|
||||
// Toggle reaction
|
||||
await pg.none(
|
||||
`UPDATE private_user_messages
|
||||
SET reactions =
|
||||
CASE
|
||||
WHEN reactions -> $1 IS NOT NULL
|
||||
THEN reactions - $1
|
||||
ELSE jsonb_set(
|
||||
COALESCE(reactions, '{}'::jsonb),
|
||||
ARRAY [$1],
|
||||
(
|
||||
COALESCE(reactions -> $1, '[]'::jsonb) || to_jsonb($2::text)
|
||||
),
|
||||
TRUE
|
||||
)
|
||||
END
|
||||
WHERE id = $3`,
|
||||
[reaction, auth.uid, messageId]
|
||||
)
|
||||
}
|
||||
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||
.catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
28
backend/api/src/save-subscription-mobile.ts
Normal file
28
backend/api/src/save-subscription-mobile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => {
|
||||
const {token} = body
|
||||
|
||||
if (!token) {
|
||||
throw new APIError(400, `Invalid subscription object`)
|
||||
}
|
||||
|
||||
const userId = auth?.uid
|
||||
|
||||
try {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none(`
|
||||
insert into push_subscriptions_mobile(token, platform, user_id)
|
||||
values ($1, $2, $3)
|
||||
on conflict(token) do update set platform = excluded.platform,
|
||||
user_id = excluded.user_id
|
||||
`,
|
||||
[token, 'android', userId]
|
||||
);
|
||||
return {success: true};
|
||||
} catch (err) {
|
||||
console.error('Error saving subscription', err);
|
||||
throw new APIError(500, `Failed to save subscription`)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,20 @@
|
||||
import { constructPrefixTsQuery } from 'shared/helpers/search'
|
||||
import {
|
||||
from,
|
||||
join,
|
||||
limit,
|
||||
orderBy,
|
||||
renderSql,
|
||||
select,
|
||||
where,
|
||||
} from 'shared/supabase/sql-builder'
|
||||
import { type APIHandler } from './helpers/endpoint'
|
||||
import { convertUser } from 'common/supabase/users'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { toUserAPIResponse } from 'common/api/user-types'
|
||||
import { uniqBy } from 'lodash'
|
||||
import {constructPrefixTsQuery} from 'shared/helpers/search'
|
||||
import {from, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {type APIHandler} from './helpers/endpoint'
|
||||
import {convertUser} from 'common/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {toUserAPIResponse} from 'common/api/user-types'
|
||||
import {uniqBy} from 'lodash'
|
||||
|
||||
export const searchUsers: APIHandler<'search-users'> = async (props, auth) => {
|
||||
const { term, page, limit } = props
|
||||
export const searchUsers: APIHandler<'search-users'> = async (props, _auth) => {
|
||||
const {term, page, limit} = props
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const offset = page * limit
|
||||
// const userId = auth?.uid
|
||||
// const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
||||
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
||||
const searchAllSQL = getSearchUserSQL({term, offset, limit})
|
||||
const [all] = await Promise.all([
|
||||
// pg.map(searchFollowersSQL, null, convertUser),
|
||||
pg.map(searchAllSQL, null, convertUser),
|
||||
@@ -39,7 +31,7 @@ function getSearchUserSQL(props: {
|
||||
limit: number
|
||||
userId?: string // search only this user's followers
|
||||
}) {
|
||||
const { term } = props
|
||||
const {term} = props
|
||||
|
||||
return renderSql(
|
||||
// userId
|
||||
@@ -50,21 +42,21 @@ function getSearchUserSQL(props: {
|
||||
// where('user_follows.user_id = $1', [userId]),
|
||||
// ]
|
||||
// :
|
||||
[select('*'), from('users')],
|
||||
[select('*'), from('users')],
|
||||
term
|
||||
? [
|
||||
where(
|
||||
`name_username_vector @@ websearch_to_tsquery('english', $1)
|
||||
where(
|
||||
`name_username_vector @@ websearch_to_tsquery('english', $1)
|
||||
or name_username_vector @@ to_tsquery('english', $2)`,
|
||||
[term, constructPrefixTsQuery(term)]
|
||||
),
|
||||
[term, constructPrefixTsQuery(term)]
|
||||
),
|
||||
|
||||
orderBy(
|
||||
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
|
||||
orderBy(
|
||||
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
|
||||
data->>'lastBetTime' desc nulls last`,
|
||||
[term]
|
||||
),
|
||||
]
|
||||
[term]
|
||||
),
|
||||
]
|
||||
: orderBy(`data->'creatorTraders'->'allTime' desc nulls last`),
|
||||
limit(props.limit, props.offset)
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ export const sendSearchNotifications = async () => {
|
||||
|
||||
for (const row of searches) {
|
||||
if (typeof row.search_filters !== 'object') continue;
|
||||
const { orderBy, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||
const { orderBy: _, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||
const props = {
|
||||
...filters,
|
||||
skipId: row.creator_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
|
||||
import {initAdmin} from 'shared/init-admin'
|
||||
import {loadSecretsToEnv} from 'common/secrets'
|
||||
import {log} from 'shared/utils'
|
||||
import {IS_LOCAL} from "common/envs/constants";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
|
||||
import {listen as webSocketListen} from 'shared/websockets/server'
|
||||
|
||||
@@ -40,4 +40,4 @@ const startupProcess = async () => {
|
||||
|
||||
webSocketListen(httpServer, '/ws')
|
||||
}
|
||||
startupProcess().then(r => log('Server started successfully'))
|
||||
startupProcess().then(_r => log('Server started successfully'))
|
||||
|
||||
8
backend/api/src/test.ts
Normal file
8
backend/api/src/test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {sendTestEmail} from "email/functions/helpers";
|
||||
|
||||
export const localSendTestEmail = async () => {
|
||||
sendTestEmail('hello@compassmeet.com')
|
||||
.then(() => console.debug('Email sent successfully!'))
|
||||
.catch((error) => console.error('Failed to send email:', error))
|
||||
return { message: 'Email sent successfully!'}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {PrivateUser, User} from 'common/user'
|
||||
import {getNotificationDestinationsForUser, UNSUBSCRIBE_URL} from 'common/user-notification-preferences'
|
||||
import {sendEmail} from './send-email'
|
||||
@@ -5,12 +6,13 @@ import {NewMessageEmail} from '../new-message'
|
||||
import {NewEndorsementEmail} from '../new-endorsement'
|
||||
import {Test} from '../test'
|
||||
import {getProfile} from 'shared/profiles/supabase'
|
||||
import { render } from "@react-email/render"
|
||||
import {render} from "@react-email/render"
|
||||
import {MatchesType} from "common/profiles/bookmarked_searches";
|
||||
import NewSearchAlertsEmail from "email/new-search_alerts";
|
||||
import WelcomeEmail from "email/welcome";
|
||||
import * as admin from "firebase-admin";
|
||||
|
||||
const from = 'Compass <compass@compassmeet.com>'
|
||||
export const fromEmail = 'Compass <compass@compassmeet.com>'
|
||||
|
||||
// export const sendNewMatchEmail = async (
|
||||
// privateUser: PrivateUser,
|
||||
@@ -60,7 +62,7 @@ export const sendNewMessageEmail = async (
|
||||
}
|
||||
|
||||
return await sendEmail({
|
||||
from,
|
||||
from: fromEmail,
|
||||
subject: `${fromUser.name} sent you a message!`,
|
||||
to: privateUser.email,
|
||||
html: await render(
|
||||
@@ -81,8 +83,9 @@ export const sendWelcomeEmail = async (
|
||||
privateUser: PrivateUser,
|
||||
) => {
|
||||
if (!privateUser.email) return
|
||||
const verificationLink = await admin.auth().generateEmailVerificationLink(privateUser.email);
|
||||
return await sendEmail({
|
||||
from,
|
||||
from: fromEmail,
|
||||
subject: `Welcome to Compass!`,
|
||||
to: privateUser.email,
|
||||
html: await render(
|
||||
@@ -90,6 +93,7 @@ export const sendWelcomeEmail = async (
|
||||
toUser={toUser}
|
||||
unsubscribeUrl={UNSUBSCRIBE_URL}
|
||||
email={privateUser.email}
|
||||
verificationLink={verificationLink}
|
||||
/>
|
||||
),
|
||||
})
|
||||
@@ -108,7 +112,7 @@ export const sendSearchAlertsEmail = async (
|
||||
if (!email || !sendToEmail) return
|
||||
|
||||
return await sendEmail({
|
||||
from,
|
||||
from: fromEmail,
|
||||
subject: `People aligned with your values just joined`,
|
||||
to: email,
|
||||
html: await render(
|
||||
@@ -135,7 +139,7 @@ export const sendNewEndorsementEmail = async (
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
|
||||
return await sendEmail({
|
||||
from,
|
||||
from: fromEmail,
|
||||
subject: `${fromUser.name} just endorsed you!`,
|
||||
to: privateUser.email,
|
||||
html: await render(
|
||||
@@ -152,7 +156,7 @@ export const sendNewEndorsementEmail = async (
|
||||
|
||||
export const sendTestEmail = async (toEmail: string) => {
|
||||
return await sendEmail({
|
||||
from,
|
||||
from: fromEmail,
|
||||
subject: 'Test email from Compass',
|
||||
to: toEmail,
|
||||
html: await render(<Test name="Test User"/>),
|
||||
|
||||
@@ -36,8 +36,10 @@ export const sinclairProfile: ProfileRow = {
|
||||
pref_gender: ['female', 'trans-female'],
|
||||
pref_age_min: 18,
|
||||
pref_age_max: 21,
|
||||
religion: [],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
disabled: false,
|
||||
wants_kids_strength: 3,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -50,6 +52,7 @@ export const sinclairProfile: ProfileRow = {
|
||||
political_beliefs: ['e/acc', 'libertarian'],
|
||||
religious_belief_strength: null,
|
||||
religious_beliefs: null,
|
||||
political_details: '',
|
||||
photo_urls: [
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2Flove-images%2FnJz22lr3Bl.jpg?alt=media&token=f1e99ba3-39cc-4637-8702-16a3a8dd49db',
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2Flove-images%2FygM0mGgP_j.HEIC?alt=media&token=573b23d9-693c-4d6e-919b-097309f370e1',
|
||||
@@ -135,8 +138,10 @@ export const jamesProfile: ProfileRow = {
|
||||
city: 'San Francisco',
|
||||
gender: 'male',
|
||||
pref_gender: ['female'],
|
||||
disabled: false,
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 32,
|
||||
religion: [],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 4,
|
||||
@@ -151,6 +156,7 @@ export const jamesProfile: ProfileRow = {
|
||||
political_beliefs: ['libertarian'],
|
||||
religious_belief_strength: null,
|
||||
religious_beliefs: '',
|
||||
political_details: '',
|
||||
photo_urls: [
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2FKl0WtbZsZW.jpg?alt=media&token=c928604f-e5ff-4406-a229-152864a4aa48',
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2Fsii17zOItz.jpg?alt=media&token=474034b9-0d23-4005-97ad-5864abfd85fe',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {type ProfileRow} from 'common/profiles/profile'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {type ProfileRow} from 'common/profiles/profile'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {Body, Container, Head, Html, Link, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {mockUser,} from './functions/mock'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
|
||||
import {DOMAIN} from "common/envs/constants";
|
||||
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
import React from 'react';
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {mockUser,} from './functions/mock'
|
||||
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||
|
||||
function randomHex(length: number) {
|
||||
const bytes = new Uint8Array(Math.ceil(length / 2));
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, length);
|
||||
}
|
||||
// function randomHex(length: number) {
|
||||
// const bytes = new Uint8Array(Math.ceil(length / 2));
|
||||
// crypto.getRandomValues(bytes);
|
||||
// return Array.from(bytes, b => b.toString(16).padStart(2, "0"))
|
||||
// .join("")
|
||||
// .slice(0, length);
|
||||
// }
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
toUser: User
|
||||
unsubscribeUrl: string
|
||||
email?: string
|
||||
verificationLink?: string
|
||||
}
|
||||
|
||||
export const WelcomeEmail = ({
|
||||
toUser,
|
||||
unsubscribeUrl,
|
||||
email,
|
||||
verificationLink,
|
||||
}: WelcomeEmailProps) => {
|
||||
const name = toUser.name.split(' ')[0]
|
||||
const confirmUrl = `https://compassmeet.com/confirm-email/${randomHex(16)}`
|
||||
|
||||
// Some users may already have a verified email (e.g., signed it with Googl), but we send them a link anyway so that
|
||||
// their email provider marks Compass as spam-free once they click the link.
|
||||
// We can remove the verif link for them if we ask the user to click on another link in the email (which would not be related to email verification)
|
||||
// const verificationLink = `https://compassmeet.com/confirm-email/${randomHex(16)}`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@@ -48,14 +55,14 @@ export const WelcomeEmail = ({
|
||||
|
||||
<Button
|
||||
style={button}
|
||||
href={confirmUrl}
|
||||
href={verificationLink}
|
||||
>
|
||||
Confirm My Email
|
||||
</Button>
|
||||
|
||||
<Text style={{marginTop: "40px", fontSize: "10px", color: "#555"}}>
|
||||
Or copy and paste this link into your browser: <br/>
|
||||
<a href={confirmUrl}>{confirmUrl}</a>
|
||||
<a href={verificationLink}>{verificationLink}</a>
|
||||
</Text>
|
||||
|
||||
<Text style={{marginTop: "40px", fontSize: "12px", color: "#555"}}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
|
||||
|
||||
|
||||
import {getServiceAccountCredentials} from "shared/firebase-utils";
|
||||
import {IS_LOCAL} from "common/envs/constants";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
|
||||
// Locally initialize Firebase Admin.
|
||||
export const initAdmin = () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { format } from 'node:util'
|
||||
import { isError, pick, omit } from 'lodash'
|
||||
import { dim, red, yellow } from 'colors/safe'
|
||||
import { getMonitoringContext } from './context'
|
||||
import {IS_GOOGLE_CLOUD} from "common/envs/constants";
|
||||
import {IS_GOOGLE_CLOUD} from "common/hosting/constants";
|
||||
|
||||
// mapping JS log levels (e.g. functions on console object) to GCP log levels
|
||||
const JS_TO_GCP_LEVELS = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {log} from './log'
|
||||
import {getInstanceInfo, InstanceInfo} from './instance-info'
|
||||
import {chunk} from 'lodash'
|
||||
import {CUSTOM_METRICS, metrics, MetricStore, MetricStoreEntry,} from './metrics'
|
||||
import {IS_GOOGLE_CLOUD} from "common/envs/constants";
|
||||
import {IS_GOOGLE_CLOUD} from "common/hosting/constants";
|
||||
|
||||
// how often metrics are written. GCP says don't write for a single time series
|
||||
// more than once per 5 seconds.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const removePinnedUrlFromPhotoUrls = async (parsedBody: {
|
||||
pinned_url?: string
|
||||
photo_urls?: string[]
|
||||
photo_urls?: string[] | null
|
||||
}) => {
|
||||
if (parsedBody.photo_urls && parsedBody.pinned_url) {
|
||||
parsedBody.photo_urls = parsedBody.photo_urls.filter(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ServerMessage,
|
||||
CLIENT_MESSAGE_SCHEMA,
|
||||
} from 'common/api/websockets'
|
||||
import {IS_LOCAL} from "common/envs/constants";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
import {getWebsocketUrl} from "common/api/utils";
|
||||
|
||||
// Extend the type definition locally
|
||||
|
||||
55
backend/supabase/migrations/20251106_add_message_actions.sql
Normal file
55
backend/supabase/migrations/20251106_add_message_actions.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Add columns to support message actions
|
||||
ALTER TABLE private_user_messages
|
||||
ADD COLUMN IF NOT EXISTS is_edited BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS reactions JSONB DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
|
||||
|
||||
-- Create a function to update edited_at timestamp
|
||||
-- CREATE OR REPLACE FUNCTION update_edited_at()
|
||||
-- RETURNS TRIGGER AS $$
|
||||
-- BEGIN
|
||||
-- IF NEW.content <> OLD.content THEN
|
||||
-- NEW.is_edited := TRUE;
|
||||
-- NEW.edited_at := NOW();
|
||||
-- END IF;
|
||||
-- RETURN NEW;
|
||||
-- END;
|
||||
-- $$ LANGUAGE plpgsql;
|
||||
--
|
||||
-- -- Create a trigger to update edited_at when content changes
|
||||
-- DROP TRIGGER IF EXISTS update_private_message_edited_at ON private_user_messages;
|
||||
-- CREATE TRIGGER update_private_message_edited_at
|
||||
-- BEFORE UPDATE ON private_user_messages
|
||||
-- FOR EACH ROW
|
||||
-- WHEN (OLD.content IS DISTINCT FROM NEW.content)
|
||||
-- EXECUTE FUNCTION update_edited_at();
|
||||
|
||||
-- Update RLS policies to allow message owners to update their messages
|
||||
-- DROP POLICY IF EXISTS "private message update" ON private_user_messages;
|
||||
-- CREATE POLICY "private message update" ON private_user_messages
|
||||
-- FOR UPDATE USING (
|
||||
-- user_id = firebase_uid()
|
||||
-- AND created_time > NOW() - INTERVAL '1 day' -- Only allow editing for 24 hours
|
||||
-- AND deleted = FALSE
|
||||
-- );
|
||||
|
||||
-- Add policy for soft delete
|
||||
-- DROP POLICY IF EXISTS "private message delete" ON private_user_messages;
|
||||
-- CREATE POLICY "private message delete" ON private_user_messages
|
||||
-- FOR UPDATE USING (
|
||||
-- user_id = firebase_uid()
|
||||
-- );
|
||||
|
||||
-- Add policy for reactions
|
||||
-- DROP POLICY IF EXISTS "private message react" ON private_user_messages;
|
||||
-- CREATE POLICY "private message react" ON private_user_messages
|
||||
-- FOR UPDATE USING (
|
||||
-- EXISTS (
|
||||
-- SELECT 1
|
||||
-- FROM private_user_message_channels ch
|
||||
-- JOIN private_user_message_channel_members m ON ch.id = m.channel_id
|
||||
-- WHERE m.user_id = firebase_uid()
|
||||
-- AND ch.id = private_user_messages.channel_id
|
||||
-- )
|
||||
-- );
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'profile_visibility') THEN
|
||||
@@ -35,6 +34,7 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
occupation_title TEXT,
|
||||
photo_urls TEXT[],
|
||||
pinned_url TEXT,
|
||||
political_details TEXT,
|
||||
political_beliefs TEXT[],
|
||||
pref_age_max INTEGER NULL,
|
||||
pref_age_min INTEGER NULL,
|
||||
@@ -45,12 +45,14 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
region_code TEXT,
|
||||
religious_belief_strength INTEGER,
|
||||
religious_beliefs TEXT,
|
||||
religion TEXT[],
|
||||
twitter TEXT,
|
||||
university TEXT,
|
||||
user_id TEXT NOT NULL,
|
||||
visibility profile_visibility DEFAULT 'member'::profile_visibility NOT NULL,
|
||||
wants_kids_strength INTEGER DEFAULT 0 NOT NULL,
|
||||
website TEXT,
|
||||
disabled BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
CONSTRAINT profiles_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
|
||||
18
backend/supabase/push_subscriptions_mobile.sql
Normal file
18
backend/supabase/push_subscriptions_mobile.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
create table push_subscriptions_mobile (
|
||||
id serial primary key,
|
||||
user_id text not null,
|
||||
token text not null unique,
|
||||
platform text not null, -- 'android' or 'ios'
|
||||
created_at timestamptz default now(),
|
||||
constraint push_subscriptions_mobile_user_id_fkey foreign KEY (user_id) references users (id) on delete CASCADE
|
||||
);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE push_subscriptions_mobile ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF not exists user_id_idx ON push_subscriptions_mobile (user_id);
|
||||
|
||||
CREATE INDEX IF not exists platform_idx ON push_subscriptions_mobile (platform);
|
||||
|
||||
CREATE INDEX IF not exists platform_user_id_idx ON push_subscriptions_mobile (platform, user_id);
|
||||
@@ -44,6 +44,7 @@ create or replace function get_votes_with_results(order_by text default 'recent'
|
||||
created_time timestamptz,
|
||||
creator_id TEXT,
|
||||
is_anonymous boolean,
|
||||
status text,
|
||||
votes_for int,
|
||||
votes_against int,
|
||||
votes_abstain int,
|
||||
@@ -58,6 +59,7 @@ with results as (
|
||||
v.created_time,
|
||||
v.creator_id,
|
||||
v.is_anonymous,
|
||||
v.status,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for,
|
||||
COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain,
|
||||
@@ -73,6 +75,7 @@ SELECT
|
||||
created_time,
|
||||
creator_id,
|
||||
is_anonymous,
|
||||
status,
|
||||
votes_for,
|
||||
votes_against,
|
||||
votes_abstain,
|
||||
|
||||
@@ -4,7 +4,8 @@ CREATE TABLE IF NOT EXISTS votes (
|
||||
creator_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
is_anonymous BOOLEAN NOT NULL,
|
||||
description JSONB
|
||||
description JSONB,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
|
||||
15
capacitor.config.ts
Normal file
15
capacitor.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const WEBVIEW_DEV_PHONE = process.env.NEXT_PUBLIC_WEBVIEW_DEV_PHONE === '1'
|
||||
const LOCAL_ANDROID = WEBVIEW_DEV_PHONE || process.env.NEXT_PUBLIC_LOCAL_ANDROID === '1'
|
||||
const LOCAL_URL = WEBVIEW_DEV_PHONE ? '192.168.1.3' : '10.0.2.2'
|
||||
console.log("CapacitorConfig", {LOCAL_ANDROID, WEBVIEW_DEV_PHONE})
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.compassconnections.app',
|
||||
appName: 'Compass',
|
||||
webDir: 'web/out',
|
||||
server: LOCAL_ANDROID ? { url: `http://${LOCAL_URL}:3000`, cleartext: true } : {}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -238,9 +238,7 @@ export const API = (_apiTypeCheck = {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
username: z.string(), // just so you're sure
|
||||
}),
|
||||
props: z.object({}),
|
||||
summary: 'Delete the authenticated user account',
|
||||
tag: 'Users',
|
||||
},
|
||||
@@ -402,6 +400,7 @@ export const API = (_apiTypeCheck = {
|
||||
pref_age_max: z.coerce.number().optional(),
|
||||
drinks_min: z.coerce.number().optional(),
|
||||
drinks_max: z.coerce.number().optional(),
|
||||
religion: arraybeSchema.optional(),
|
||||
pref_relation_styles: arraybeSchema.optional(),
|
||||
pref_romantic_styles: arraybeSchema.optional(),
|
||||
diet: arraybeSchema.optional(),
|
||||
@@ -575,6 +574,55 @@ export const API = (_apiTypeCheck = {
|
||||
summary: 'Leave a private message channel',
|
||||
tag: 'Messages',
|
||||
},
|
||||
'edit-message': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
messageId: z.number(),
|
||||
content: contentSchema,
|
||||
}),
|
||||
summary: 'Edit a private message',
|
||||
tag: 'Messages',
|
||||
},
|
||||
'delete-message': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
messageId: z.number(),
|
||||
}),
|
||||
summary: 'Delete a private message',
|
||||
tag: 'Messages',
|
||||
},
|
||||
'react-to-message': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
messageId: z.number(),
|
||||
reaction: z.string(),
|
||||
toDelete: z.boolean().optional(),
|
||||
}),
|
||||
summary: 'Add or remove a reaction to a message',
|
||||
tag: 'Messages',
|
||||
},
|
||||
// 'get-message-reactions': {
|
||||
// method: 'GET',
|
||||
// authed: true,
|
||||
// rateLimited: false,
|
||||
// returns: {} as {
|
||||
// reactions: Record<string, number>
|
||||
// },
|
||||
// props: z.object({
|
||||
// messageId: z.string(),
|
||||
// }),
|
||||
// summary: 'Get reactions for a message',
|
||||
// tag: 'Messages',
|
||||
// },
|
||||
'create-compatibility-question': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
@@ -698,6 +746,17 @@ export const API = (_apiTypeCheck = {
|
||||
summary: 'Save a push/browser subscription for the user',
|
||||
tag: 'Notifications',
|
||||
},
|
||||
'save-subscription-mobile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
token: z.string(),
|
||||
}),
|
||||
summary: 'Save a mobile push subscription for the user',
|
||||
tag: 'Notifications',
|
||||
},
|
||||
'create-bookmarked-search': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
@@ -723,6 +782,17 @@ export const API = (_apiTypeCheck = {
|
||||
summary: 'Delete a bookmarked search by ID',
|
||||
tag: 'Searches',
|
||||
},
|
||||
// 'auth-google': {
|
||||
// method: 'GET',
|
||||
// authed: false,
|
||||
// rateLimited: true,
|
||||
// returns: {} as any,
|
||||
// props: z.object({
|
||||
// code: z.string(),
|
||||
// }),
|
||||
// summary: 'Google Auth',
|
||||
// tag: 'Tokens',
|
||||
// },
|
||||
} as const)
|
||||
|
||||
export type APIPath = keyof typeof API
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {BACKEND_DOMAIN, IS_LOCAL} from 'common/envs/constants'
|
||||
import {BACKEND_DOMAIN} from 'common/envs/constants'
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
|
||||
type ErrorCode =
|
||||
| 400 // your input is bad (like zod is mad)
|
||||
|
||||
@@ -47,57 +47,56 @@ export const zBoolean = z
|
||||
.transform((val) => val === true || val === "true");
|
||||
|
||||
export const baseProfilesSchema = z.object({
|
||||
// Required fields
|
||||
age: z.number().min(18).max(100).optional(),
|
||||
gender: genderType,
|
||||
pref_gender: genderTypes,
|
||||
pref_age_min: z.number().min(18).max(100).optional(),
|
||||
pref_age_max: z.number().min(18).max(100).optional(),
|
||||
pref_relation_styles: z.array(z.string()),
|
||||
wants_kids_strength: z.number(),
|
||||
looking_for_matches: zBoolean,
|
||||
photo_urls: z.array(z.string()),
|
||||
visibility: z.union([z.literal('public'), z.literal('member')]),
|
||||
|
||||
age: z.number().min(18).max(100).optional().nullable(),
|
||||
bio: contentSchema.optional().nullable(),
|
||||
bio_length: z.number().optional().nullable(),
|
||||
|
||||
geodb_city_id: z.string().optional(),
|
||||
city: z.string(),
|
||||
region_code: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
city_latitude: z.number().optional(),
|
||||
city_longitude: z.number().optional(),
|
||||
|
||||
city_latitude: z.number().optional().nullable(),
|
||||
city_longitude: z.number().optional().nullable(),
|
||||
country: z.string().optional().nullable(),
|
||||
gender: genderType,
|
||||
geodb_city_id: z.string().optional().nullable(),
|
||||
looking_for_matches: zBoolean,
|
||||
photo_urls: z.array(z.string()).nullable(),
|
||||
pinned_url: z.string(),
|
||||
referred_by_username: z.string().optional(),
|
||||
pref_age_max: z.number().min(18).max(100).optional().nullable(),
|
||||
pref_age_min: z.number().min(18).max(100).optional().nullable(),
|
||||
pref_gender: genderTypes.nullable(),
|
||||
pref_relation_styles: z.array(z.string()).nullable(),
|
||||
referred_by_username: z.string().optional().nullable(),
|
||||
region_code: z.string().optional().nullable(),
|
||||
visibility: z.union([z.literal('public'), z.literal('member')]),
|
||||
wants_kids_strength: z.number().nullable(),
|
||||
})
|
||||
|
||||
const optionalProfilesSchema = z.object({
|
||||
political_beliefs: z.array(z.string()).optional(),
|
||||
religious_belief_strength: z.number().optional(),
|
||||
religious_beliefs: z.string().optional(),
|
||||
ethnicity: z.array(z.string()).optional(),
|
||||
born_in_location: z.string().optional(),
|
||||
height_in_inches: z.number().optional(),
|
||||
has_pets: zBoolean.optional().optional(),
|
||||
education_level: z.string().optional(),
|
||||
is_smoker: zBoolean.optional().optional(),
|
||||
drinks_per_month: z.number().min(0).optional(),
|
||||
diet: z.array(z.string()).optional(),
|
||||
has_kids: z.number().min(0).optional(),
|
||||
university: z.string().optional(),
|
||||
occupation_title: z.string().optional(),
|
||||
occupation: z.string().optional(),
|
||||
company: z.string().optional(),
|
||||
comments_enabled: zBoolean.optional().optional(),
|
||||
website: z.string().optional(),
|
||||
avatar_url: z.string().optional().nullable(),
|
||||
bio: contentSchema.optional().nullable(),
|
||||
twitter: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
pref_romantic_styles: z.array(z.string()),
|
||||
drinks_min: z.number().min(0).optional(),
|
||||
drinks_max: z.number().min(0).optional(),
|
||||
born_in_location: z.string().optional().nullable(),
|
||||
comments_enabled: zBoolean.optional(),
|
||||
company: z.string().optional().nullable(),
|
||||
diet: z.array(z.string()).optional().nullable(),
|
||||
disabled: zBoolean.optional(),
|
||||
drinks_max: z.number().min(0).optional().nullable(),
|
||||
drinks_min: z.number().min(0).optional().nullable(),
|
||||
drinks_per_month: z.number().min(0).optional().nullable(),
|
||||
education_level: z.string().optional().nullable(),
|
||||
ethnicity: z.array(z.string()).optional().nullable(),
|
||||
has_kids: z.number().min(0).optional().nullable(),
|
||||
has_pets: zBoolean.optional().nullable(),
|
||||
height_in_inches: z.number().optional().nullable(),
|
||||
is_smoker: zBoolean.optional().nullable(),
|
||||
occupation: z.string().optional().nullable(),
|
||||
occupation_title: z.string().optional().nullable(),
|
||||
political_beliefs: z.array(z.string()).optional().nullable(),
|
||||
political_details: z.string().optional().nullable(),
|
||||
pref_romantic_styles: z.array(z.string()).nullable(),
|
||||
religion: z.array(z.string()).optional().nullable(),
|
||||
religious_belief_strength: z.number().optional().nullable(),
|
||||
religious_beliefs: z.string().optional().nullable(),
|
||||
twitter: z.string().optional().nullable(),
|
||||
university: z.string().optional().nullable(),
|
||||
website: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export const combinedProfileSchema =
|
||||
|
||||
@@ -2,12 +2,14 @@ import { type JSONContent } from '@tiptap/core'
|
||||
export type ChatVisibility = 'private' | 'system_status' | 'introduction'
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string
|
||||
id: number
|
||||
userId: string
|
||||
channelId: string
|
||||
content: JSONContent
|
||||
createdTime: number
|
||||
visibility: ChatVisibility
|
||||
isEdited: boolean
|
||||
reactions: any
|
||||
}
|
||||
export type PrivateChatMessage = Omit<ChatMessage, 'id'> & {
|
||||
id: number
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
export const MIN_INT = Number.MIN_SAFE_INTEGER
|
||||
export const MAX_INT = Number.MAX_SAFE_INTEGER
|
||||
|
||||
export const supportEmail = 'hello@compassmeet.com';
|
||||
// export const marketingEmail = 'hello@compassmeet.com';
|
||||
export const supportEmail = 'hello@compassmeet.com'
|
||||
// export const marketingEmail = 'hello@compassmeet.com'
|
||||
|
||||
export const githubRepo = "https://github.com/CompassConnections/Compass";
|
||||
export const githubRepoSlug = "CompassConnections/Compass"
|
||||
export const githubRepo = `https://github.com/${githubRepoSlug}`
|
||||
export const githubIssues = `${githubRepo}/issues`
|
||||
|
||||
export const paypalLink = "https://www.paypal.com/paypalme/CompassConnections"
|
||||
export const openCollectiveLink = "https://opencollective.com/compass-connection"
|
||||
export const liberapayLink = "https://liberapay.com/CompassConnections"
|
||||
export const patreonLink = "https://patreon.com/CompassMeet"
|
||||
export const discordLink = "https://discord.gg/8Vd7jzqjun"
|
||||
export const stoatLink = "https://stt.gg/YKQp81yA"
|
||||
@@ -16,9 +18,11 @@ export const redditLink = "https://www.reddit.com/r/CompassConnect"
|
||||
export const xLink = "https://x.com/compassmeet"
|
||||
export const formLink = "https://forms.gle/tKnXUMAbEreMK6FC6"
|
||||
|
||||
export const pStyle = "mt-1 text-gray-800 dark:text-white whitespace-pre-line";
|
||||
export const IS_MAINTENANCE = false // set to true to enable the maintenance mode banner
|
||||
|
||||
export const IS_MAINTENANCE = false; // set to true to enable maintenance mode banner
|
||||
export const MIN_BIO_LENGTH = 250
|
||||
|
||||
export const MIN_BIO_LENGTH = 250;
|
||||
export const WEB_GOOGLE_CLIENT_ID = '253367029065-khkj31qt22l0vc3v754h09vhpg6t33ad.apps.googleusercontent.com'
|
||||
// export const ANDROID_GOOGLE_CLIENT_ID = '253367029065-s9sr5vqgkhc8f7p5s6ti6a4chqsrqgc4.apps.googleusercontent.com'
|
||||
export const GOOGLE_CLIENT_ID = WEB_GOOGLE_CLIENT_ID
|
||||
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import {DEV_CONFIG} from './dev'
|
||||
import {PROD_CONFIG} from './prod'
|
||||
import {isProd} from "common/envs/is-prod";
|
||||
import {HOSTING_ENV, IS_LOCAL, IS_LOCAL_ANDROID, IS_WEBVIEW_DEV_PHONE} from "common/hosting/constants";
|
||||
|
||||
export const MAX_DESCRIPTION_LENGTH = 100000
|
||||
export const MAX_ANSWER_LENGTH = 240
|
||||
|
||||
export const LOCAL_WEB_DOMAIN = 'localhost:3000';
|
||||
export const LOCAL_BACKEND_DOMAIN = 'localhost:8088';
|
||||
|
||||
export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
|
||||
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
|
||||
export const IS_DEPLOYED = IS_GOOGLE_CLOUD || IS_VERCEL
|
||||
export const IS_LOCAL = !IS_DEPLOYED
|
||||
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
|
||||
|
||||
if (IS_LOCAL && !process.env.ENVIRONMENT && !process.env.NEXT_PUBLIC_FIREBASE_ENV) {
|
||||
console.warn("No ENVIRONMENT set, defaulting to DEV")
|
||||
process.env.ENVIRONMENT = 'DEV'
|
||||
}
|
||||
|
||||
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG
|
||||
|
||||
export function isAdminId(id: string) {
|
||||
@@ -30,7 +17,7 @@ export function isModId(id: string) {
|
||||
}
|
||||
|
||||
export const ENV = isProd() ? 'prod' : 'dev'
|
||||
export const IS_PROD = ENV === 'prod'
|
||||
// export const IS_PROD = ENV === 'prod'
|
||||
export const IS_DEV = ENV === 'dev'
|
||||
|
||||
console.debug(`Running in ${HOSTING_ENV} (${ENV})`,);
|
||||
@@ -54,11 +41,18 @@ console.debug(`Running in ${HOSTING_ENV} (${ENV})`,);
|
||||
// throw new MissingKeyError('firebaseConfig.apiKey')
|
||||
// }
|
||||
|
||||
export const LOCAL_WEB_DOMAIN = `localhost:3000`
|
||||
export const LOCAL_BACKEND_DOMAIN = `${IS_WEBVIEW_DEV_PHONE ? '192.168.1.3' : IS_LOCAL_ANDROID ? '10.0.2.2' : 'localhost'}:8088`
|
||||
|
||||
export const DOMAIN = IS_LOCAL ? LOCAL_WEB_DOMAIN : ENV_CONFIG.domain
|
||||
export const DEPLOYED_WEB_URL = `https://www.${ENV_CONFIG.domain}`
|
||||
export const WEB_URL = IS_LOCAL ? `http://${LOCAL_WEB_DOMAIN}` : `https://${DOMAIN}`
|
||||
export const BACKEND_DOMAIN = IS_LOCAL ? LOCAL_BACKEND_DOMAIN : ENV_CONFIG.backendDomain
|
||||
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
|
||||
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
|
||||
|
||||
export const REDIRECT_URI = `${WEB_URL}/auth/callback`
|
||||
|
||||
export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(
|
||||
/-/g,
|
||||
'_'
|
||||
@@ -75,6 +69,7 @@ export const VERIFIED_USERNAMES = [
|
||||
export const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||
|
||||
export const RESERVED_PATHS = [
|
||||
'',
|
||||
'404',
|
||||
'_app',
|
||||
'_document',
|
||||
|
||||
@@ -32,6 +32,7 @@ export type FilterFields = {
|
||||
| 'pref_gender'
|
||||
| 'pref_age_min'
|
||||
| 'pref_age_max'
|
||||
| 'religion'
|
||||
>
|
||||
|
||||
export const orderProfiles = (
|
||||
@@ -70,6 +71,7 @@ export const initialFilters: Partial<FilterFields> = {
|
||||
pref_romantic_styles: undefined,
|
||||
diet: undefined,
|
||||
political_beliefs: undefined,
|
||||
religion: undefined,
|
||||
pref_gender: undefined,
|
||||
shortBio: undefined,
|
||||
drinks_min: undefined,
|
||||
@@ -80,4 +82,4 @@ export const initialFilters: Partial<FilterFields> = {
|
||||
|
||||
export const FilterKeys = Object.keys(initialFilters) as (keyof FilterFields)[]
|
||||
|
||||
export type OriginLocation = { id: string; name: string, lat: number, lon: number }
|
||||
export type OriginLocation = { id: string; name: string | null, lat: number, lon: number }
|
||||
|
||||
15
common/src/hosting/constants.ts
Normal file
15
common/src/hosting/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const IS_WEBVIEW_DEV_PHONE = process.env.NEXT_PUBLIC_WEBVIEW_DEV_PHONE === '1'
|
||||
export const IS_LOCAL_ANDROID = IS_WEBVIEW_DEV_PHONE || process.env.NEXT_PUBLIC_LOCAL_ANDROID === '1'
|
||||
export const IS_WEBVIEW = !!process.env.NEXT_PUBLIC_WEBVIEW
|
||||
export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
|
||||
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
|
||||
export const IS_DEPLOYED = IS_GOOGLE_CLOUD || IS_VERCEL || IS_WEBVIEW
|
||||
export const IS_LOCAL = !IS_DEPLOYED
|
||||
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_WEBVIEW ? 'WebView' : IS_LOCAL ? 'local' : 'unknown'
|
||||
|
||||
if (IS_LOCAL && !process.env.ENVIRONMENT && !process.env.NEXT_PUBLIC_FIREBASE_ENV) {
|
||||
console.warn("No ENVIRONMENT set, defaulting to DEV")
|
||||
process.env.ENVIRONMENT = 'DEV'
|
||||
}
|
||||
|
||||
// console.log('IS_LOCAL_ANDROID', IS_LOCAL_ANDROID)
|
||||
@@ -2,15 +2,15 @@ import { ProfileRow } from 'common/profiles/profile'
|
||||
import {MAX_INT, MIN_INT} from "common/constants";
|
||||
|
||||
const isPreferredGender = (
|
||||
preferredGenders: string[] | undefined,
|
||||
gender: string | undefined
|
||||
preferredGenders: string[] | undefined | null,
|
||||
gender: string | undefined | null,
|
||||
) => {
|
||||
// console.debug('isPreferredGender', preferredGenders, gender)
|
||||
if (preferredGenders === undefined || preferredGenders.length === 0 || gender === undefined) return true
|
||||
if (!preferredGenders?.length || !gender) return true
|
||||
|
||||
// If simple gender preference, don't include non-binary.
|
||||
if (
|
||||
preferredGenders.length === 1 &&
|
||||
preferredGenders?.length === 1 &&
|
||||
(preferredGenders[0] === 'male' || preferredGenders[0] === 'female')
|
||||
) {
|
||||
return preferredGenders.includes(gender)
|
||||
@@ -43,13 +43,15 @@ export const areLocationCompatible = (profile1: ProfileRow, profile2: ProfileRow
|
||||
!profile2.city_latitude ||
|
||||
!profile1.city_longitude ||
|
||||
!profile2.city_longitude
|
||||
)
|
||||
) {
|
||||
if (!profile1.city || !profile2.city) return true
|
||||
return profile1.city.trim().toLowerCase() === profile2.city.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const latitudeDiff = Math.abs(profile1.city_latitude - profile2.city_latitude)
|
||||
const longigudeDiff = Math.abs(profile1.city_longitude - profile2.city_longitude)
|
||||
const longitudeDiff = Math.abs(profile1.city_longitude - profile2.city_longitude)
|
||||
|
||||
const root = (latitudeDiff ** 2 + longigudeDiff ** 2) ** 0.5
|
||||
const root = (latitudeDiff ** 2 + longitudeDiff ** 2) ** 0.5
|
||||
return root < 2.5
|
||||
}
|
||||
|
||||
@@ -57,8 +59,9 @@ export const areRelationshipStyleCompatible = (
|
||||
profile1: ProfileRow,
|
||||
profile2: ProfileRow
|
||||
) => {
|
||||
if (!profile1.pref_relation_styles?.length || !profile2.pref_relation_styles) return true
|
||||
return profile1.pref_relation_styles.some((style) =>
|
||||
profile2.pref_relation_styles.includes(style)
|
||||
profile2.pref_relation_styles?.includes(style)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@ export const areWantKidsCompatible = (profile1: ProfileRow, profile2: ProfileRow
|
||||
const { wants_kids_strength: kids1 } = profile1
|
||||
const { wants_kids_strength: kids2 } = profile2
|
||||
|
||||
if (kids1 === undefined || kids2 === undefined) return true
|
||||
if (kids1 == null || kids2 == null) return true
|
||||
|
||||
const diff = Math.abs(kids1 - kids2)
|
||||
return diff <= 2
|
||||
|
||||
@@ -4,7 +4,7 @@ import { User } from 'common/user'
|
||||
export type ProfileRow = Row<'profiles'>
|
||||
export type Profile = ProfileRow & { user: User }
|
||||
export const getProfileRow = async (userId: string, db: SupabaseClient) => {
|
||||
console.debug('getProfileRow', userId)
|
||||
// console.debug('getProfileRow', userId)
|
||||
const res = await run(db.from('profiles').select('*').eq('user_id', userId))
|
||||
return res.data[0]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const filterLabels: Record<string, string> = {
|
||||
wants_kids_strength: "Kids",
|
||||
is_smoker: "",
|
||||
pref_relation_styles: "Seeking",
|
||||
religion: "",
|
||||
pref_gender: "",
|
||||
orderBy: "",
|
||||
diet: "Diet",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {SecretManagerServiceClient} from '@google-cloud/secret-manager'
|
||||
import {zip} from 'lodash'
|
||||
import {IS_LOCAL} from "common/envs/constants";
|
||||
import {refreshConfig} from "common/envs/prod";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
|
||||
// List of secrets that are available to backend (api, functions, scripts, etc.)
|
||||
// Edit them at:
|
||||
@@ -26,6 +26,7 @@ export const secrets = (
|
||||
'VAPID_PUBLIC_KEY',
|
||||
'VAPID_PRIVATE_KEY',
|
||||
'DB_ENC_MASTER_KEY_BASE64',
|
||||
'GOOGLE_CLIENT_SECRET',
|
||||
// Some typescript voodoo to keep the string literal types while being not readonly.
|
||||
] as const
|
||||
).concat()
|
||||
|
||||
@@ -270,10 +270,14 @@ export type Database = {
|
||||
ciphertext: string | null
|
||||
content: Json | null
|
||||
created_time: string
|
||||
deleted: boolean | null
|
||||
edited_at: string | null
|
||||
id: number
|
||||
is_edited: boolean | null
|
||||
iv: string | null
|
||||
reactions: Json | null
|
||||
tag: string | null
|
||||
user_id: string
|
||||
user_id: string | null
|
||||
visibility: string
|
||||
}
|
||||
Insert: {
|
||||
@@ -281,10 +285,14 @@ export type Database = {
|
||||
ciphertext?: string | null
|
||||
content?: Json | null
|
||||
created_time?: string
|
||||
deleted?: boolean | null
|
||||
edited_at?: string | null
|
||||
id?: never
|
||||
is_edited?: boolean | null
|
||||
iv?: string | null
|
||||
reactions?: Json | null
|
||||
tag?: string | null
|
||||
user_id: string
|
||||
user_id?: string | null
|
||||
visibility?: string
|
||||
}
|
||||
Update: {
|
||||
@@ -292,10 +300,14 @@ export type Database = {
|
||||
ciphertext?: string | null
|
||||
content?: Json | null
|
||||
created_time?: string
|
||||
deleted?: boolean | null
|
||||
edited_at?: string | null
|
||||
id?: never
|
||||
is_edited?: boolean | null
|
||||
iv?: string | null
|
||||
reactions?: Json | null
|
||||
tag?: string | null
|
||||
user_id?: string
|
||||
user_id?: string | null
|
||||
visibility?: string
|
||||
}
|
||||
Relationships: [
|
||||
@@ -533,7 +545,7 @@ export type Database = {
|
||||
bio_text: string | null
|
||||
bio_tsv: unknown
|
||||
born_in_location: string | null
|
||||
city: string
|
||||
city: string | null
|
||||
city_latitude: number | null
|
||||
city_longitude: number | null
|
||||
comments_enabled: boolean
|
||||
@@ -541,10 +553,11 @@ export type Database = {
|
||||
country: string | null
|
||||
created_time: string
|
||||
diet: string[] | null
|
||||
disabled: boolean
|
||||
drinks_per_month: number | null
|
||||
education_level: string | null
|
||||
ethnicity: string[] | null
|
||||
gender: string
|
||||
gender: string | null
|
||||
geodb_city_id: string | null
|
||||
has_kids: number | null
|
||||
height_in_inches: number | null
|
||||
@@ -558,20 +571,22 @@ export type Database = {
|
||||
photo_urls: string[] | null
|
||||
pinned_url: string | null
|
||||
political_beliefs: string[] | null
|
||||
political_details: string | null
|
||||
pref_age_max: number | null
|
||||
pref_age_min: number | null
|
||||
pref_gender: string[]
|
||||
pref_relation_styles: string[]
|
||||
pref_gender: string[] | null
|
||||
pref_relation_styles: string[] | null
|
||||
pref_romantic_styles: string[] | null
|
||||
referred_by_username: string | null
|
||||
region_code: string | null
|
||||
religion: string[] | null
|
||||
religious_belief_strength: number | null
|
||||
religious_beliefs: string | null
|
||||
twitter: string | null
|
||||
university: string | null
|
||||
user_id: string
|
||||
visibility: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength: number
|
||||
wants_kids_strength: number | null
|
||||
website: string | null
|
||||
}
|
||||
Insert: {
|
||||
@@ -581,7 +596,7 @@ export type Database = {
|
||||
bio_text?: string | null
|
||||
bio_tsv?: unknown
|
||||
born_in_location?: string | null
|
||||
city: string
|
||||
city?: string | null
|
||||
city_latitude?: number | null
|
||||
city_longitude?: number | null
|
||||
comments_enabled?: boolean
|
||||
@@ -589,10 +604,11 @@ export type Database = {
|
||||
country?: string | null
|
||||
created_time?: string
|
||||
diet?: string[] | null
|
||||
disabled?: boolean
|
||||
drinks_per_month?: number | null
|
||||
education_level?: string | null
|
||||
ethnicity?: string[] | null
|
||||
gender: string
|
||||
gender?: string | null
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
@@ -606,20 +622,22 @@ export type Database = {
|
||||
photo_urls?: string[] | null
|
||||
pinned_url?: string | null
|
||||
political_beliefs?: string[] | null
|
||||
political_details?: string | null
|
||||
pref_age_max?: number | null
|
||||
pref_age_min?: number | null
|
||||
pref_gender: string[]
|
||||
pref_relation_styles: string[]
|
||||
pref_gender?: string[] | null
|
||||
pref_relation_styles?: string[] | null
|
||||
pref_romantic_styles?: string[] | null
|
||||
referred_by_username?: string | null
|
||||
region_code?: string | null
|
||||
religion?: string[] | null
|
||||
religious_belief_strength?: number | null
|
||||
religious_beliefs?: string | null
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id: string
|
||||
visibility?: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength?: number
|
||||
wants_kids_strength?: number | null
|
||||
website?: string | null
|
||||
}
|
||||
Update: {
|
||||
@@ -629,7 +647,7 @@ export type Database = {
|
||||
bio_text?: string | null
|
||||
bio_tsv?: unknown
|
||||
born_in_location?: string | null
|
||||
city?: string
|
||||
city?: string | null
|
||||
city_latitude?: number | null
|
||||
city_longitude?: number | null
|
||||
comments_enabled?: boolean
|
||||
@@ -637,10 +655,11 @@ export type Database = {
|
||||
country?: string | null
|
||||
created_time?: string
|
||||
diet?: string[] | null
|
||||
disabled?: boolean
|
||||
drinks_per_month?: number | null
|
||||
education_level?: string | null
|
||||
ethnicity?: string[] | null
|
||||
gender?: string
|
||||
gender?: string | null
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
@@ -654,20 +673,22 @@ export type Database = {
|
||||
photo_urls?: string[] | null
|
||||
pinned_url?: string | null
|
||||
political_beliefs?: string[] | null
|
||||
political_details?: string | null
|
||||
pref_age_max?: number | null
|
||||
pref_age_min?: number | null
|
||||
pref_gender?: string[]
|
||||
pref_relation_styles?: string[]
|
||||
pref_gender?: string[] | null
|
||||
pref_relation_styles?: string[] | null
|
||||
pref_romantic_styles?: string[] | null
|
||||
referred_by_username?: string | null
|
||||
region_code?: string | null
|
||||
religion?: string[] | null
|
||||
religious_belief_strength?: number | null
|
||||
religious_beliefs?: string | null
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id?: string
|
||||
visibility?: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength?: number
|
||||
wants_kids_strength?: number | null
|
||||
website?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
@@ -712,6 +733,38 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
push_subscriptions_mobile: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
id: number
|
||||
platform: string
|
||||
token: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
platform: string
|
||||
token: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: number
|
||||
platform?: string
|
||||
token?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'push_subscriptions_mobile_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
reports: {
|
||||
Row: {
|
||||
content_id: string
|
||||
@@ -939,6 +992,7 @@ export type Database = {
|
||||
description: Json | null
|
||||
id: number
|
||||
is_anonymous: boolean | null
|
||||
status: string | null
|
||||
title: string
|
||||
}
|
||||
Insert: {
|
||||
@@ -947,6 +1001,7 @@ export type Database = {
|
||||
description?: Json | null
|
||||
id?: never
|
||||
is_anonymous?: boolean | null
|
||||
status?: string | null
|
||||
title: string
|
||||
}
|
||||
Update: {
|
||||
@@ -955,6 +1010,7 @@ export type Database = {
|
||||
description?: Json | null
|
||||
id?: never
|
||||
is_anonymous?: boolean | null
|
||||
status?: string | null
|
||||
title?: string
|
||||
}
|
||||
Relationships: [
|
||||
@@ -1003,6 +1059,7 @@ export type Database = {
|
||||
id: number
|
||||
is_anonymous: boolean
|
||||
priority: number
|
||||
status: string
|
||||
title: string
|
||||
votes_abstain: number
|
||||
votes_against: number
|
||||
|
||||
20
common/src/util/sorting.ts
Normal file
20
common/src/util/sorting.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {MAX_INT} from "common/constants";
|
||||
|
||||
export function getSortedOptions(options: string[], order: string[] | Record<string, string>) {
|
||||
let parsedOrder: string[]
|
||||
if (Array.isArray(order)) {
|
||||
parsedOrder = order
|
||||
} else {
|
||||
parsedOrder = Object.keys(order)
|
||||
}
|
||||
return options
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ia = parsedOrder.indexOf(a as any)
|
||||
const ib = parsedOrder.indexOf(b as any)
|
||||
const sa = ia === -1 ? MAX_INT : ia
|
||||
const sb = ib === -1 ? MAX_INT : ib
|
||||
if (sa !== sb) return sa - sb
|
||||
return String(a).localeCompare(String(b))
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
export const ORDER_BY = ['recent', 'mostVoted', 'priority'] as const
|
||||
export type OrderBy = typeof ORDER_BY[number]
|
||||
export const Constants: Record<OrderBy, string> = {
|
||||
export const ORDER_BY_CHOICES: Record<OrderBy, string> = {
|
||||
recent: 'Most recent',
|
||||
mostVoted: 'Most voted',
|
||||
priority: 'Highest Priority',
|
||||
}
|
||||
}
|
||||
|
||||
export const STATUS_CHOICES: Record<string, string> = {
|
||||
draft: "Draft",
|
||||
under_review: "Under Review",
|
||||
voting_open: "Voting Open",
|
||||
voting_closed: "Voting Closed",
|
||||
accepted: "Accepted",
|
||||
pending: "Pending Implementation",
|
||||
implemented: "Implemented ✔️",
|
||||
rejected: "Rejected ❌",
|
||||
cancelled: "Cancelled 🚫",
|
||||
superseded: "Superseded",
|
||||
expired: "Expired ⌛",
|
||||
archived: "Archived",
|
||||
}
|
||||
|
||||
export const REVERSED_STATUS_CHOICES: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(STATUS_CHOICES).map(([key, value]) => [value, key])
|
||||
)
|
||||
@@ -13,9 +13,7 @@ See those other useful documents as well:
|
||||
|
||||
A profile field is any variable associated with a user profile, such as age, politics, diet, etc. You may want to add a new profile field if it helps people find better matches.
|
||||
|
||||
To do so, you can add code in a similar way as in [this commit](https://github.com/CompassConnections/Compass/commit/b94cdba5af377b06c31cebb97c0a772ad6324690) for the `diet` field.
|
||||
|
||||
[//]: # (If you also want people to filter by that profile field, you'll also need to add it to the search filters, as done in [this commit](https://github.com/CompassConnections/Compass/commit/591798e98c51144fe257e28cf463707be748c2aa) for the education level. )
|
||||
To do so, you can add code in a similar way as in [this commit](https://github.com/CompassConnections/Compass/commit/940c1f5692f63bf72ddccd4ec3b00b1443801682) for the `religion` field. If you also want people to filter by that profile field, you'll also need to add it to the search filters, as done in [this commit](https://github.com/CompassConnections/Compass/commit/a4bb184e95553184a4c8773d7896e4b570508fe5) (for the `religion` field as well).
|
||||
|
||||
Note that you will also need to add a column to the `profiles` table in the dev database before running the code; you can do so via this SQL command (change the type if not `TEXT`):
|
||||
```sql
|
||||
|
||||
180
docs/webview_oauth_signin.md
Normal file
180
docs/webview_oauth_signin.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# WebView OAuth Sign-in
|
||||
|
||||
How to let a WebView-based app safely complete OAuth, even though Google blocks sign-in *inside* WebViews.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
Google OAuth refuses to complete inside a WebView.
|
||||
You’ll get errors like:
|
||||
|
||||
```
|
||||
403 disallowed_useragent
|
||||
or
|
||||
This browser or app may not be secure
|
||||
```
|
||||
|
||||
This is because embedded WebViews can intercept credentials, and Google requires that the sign-in happen in a **real browser** (like Chrome, Safari, Firefox).
|
||||
|
||||
So we must:
|
||||
|
||||
1. Start the login **from inside** the WebView app.
|
||||
2. Open the Google login page in the **system browser**.
|
||||
3. After the user finishes signing in, Google redirects to a **custom URL** (deep link or universal link).
|
||||
4. The app intercepts that redirect, extracts the `code` from it, and injects it back into the WebView.
|
||||
|
||||
That’s the “catch the redirect with a custom scheme or deep link” part.
|
||||
|
||||
---
|
||||
|
||||
## 2. What a deep link / custom scheme is
|
||||
|
||||
A **custom scheme** is a URL protocol that your app owns.
|
||||
Example:
|
||||
|
||||
```
|
||||
com.compassmeet://auth
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
compassmeet://auth
|
||||
```
|
||||
|
||||
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data.
|
||||
|
||||
You register this scheme in your `AndroidManifest.xml` so Android knows which app handles it.
|
||||
|
||||
---
|
||||
|
||||
## 3.
|
||||
|
||||
### Step 1 — Start flow inside the WebView
|
||||
|
||||
Your web code (running inside WebView) does:
|
||||
|
||||
```ts
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
redirect_uri: 'com.compassmeet://auth', // your deep link
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
});
|
||||
|
||||
window.open(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, '_system');
|
||||
```
|
||||
|
||||
Here, `_system` (or using Capacitor Browser plugin) opens the **system browser**.
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — User signs in (in the browser)
|
||||
|
||||
After login, Google redirects to your registered `redirect_uri`, e.g.:
|
||||
|
||||
```
|
||||
com.compassmeet://auth?code=4/0AfJohXyZ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — The app intercepts that deep link
|
||||
|
||||
In your **Android app code**, you register an intent filter in `AndroidManifest.xml`:
|
||||
|
||||
```xml
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="com.compassmeet" android:host="auth" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
Then, in your app’s main activity, you listen for deep links.
|
||||
In java:
|
||||
```java
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
|
||||
String data = intent.getDataString();
|
||||
String payload = new JSONObject().put("data", data).toString();
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("oauthRedirect(" + payload + ");", null));
|
||||
}
|
||||
```
|
||||
|
||||
That line emits a custom JavaScript event inside the WebView so your web app can pick it up.
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — WebView catches redirect event and exchanges the code in backend
|
||||
|
||||
In your web app (TypeScript side):
|
||||
|
||||
```ts
|
||||
window.addEventListener('oauthRedirect', async (event: any) => {
|
||||
const url = new URL(event.detail);
|
||||
const code = url.searchParams.get('code');
|
||||
|
||||
// fetch backend API
|
||||
const tokens = await api('...', {code})
|
||||
});
|
||||
```
|
||||
|
||||
Backend endpoint
|
||||
```ts
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: 'com.compassmeet://auth',
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
console.log('Tokens:', tokens);
|
||||
```
|
||||
|
||||
|
||||
At this point:
|
||||
|
||||
* You have your `access_token` and `id_token`.
|
||||
* You can sign into Firebase or use them directly.
|
||||
|
||||
---
|
||||
|
||||
## 4. Why this works and what makes it safe
|
||||
|
||||
* The login itself happens in Google’s **system browser**, not in your WebView.
|
||||
* The deep link ensures the token is delivered **only** to your app.
|
||||
|
||||
---
|
||||
|
||||
## 5. Universal links alternative
|
||||
|
||||
If you want to use a normal HTTPS redirect (e.g. `https://www.compassmeet.com/auth/callback`), you can register it as a **universal link**:
|
||||
|
||||
* User finishes login → redirected to your HTTPS domain.
|
||||
* That URL is also registered to open your app (via Digital Asset Links JSON).
|
||||
* Android recognizes it and launches your app instead of loading the page in the browser.
|
||||
* The rest of the flow is the same.
|
||||
|
||||
However, universal links are more setup-heavy (require hosting a `.well-known/assetlinks.json` file).
|
||||
|
||||
---
|
||||
|
||||
## 6. Summary
|
||||
|
||||
| Step | What happens | Where |
|
||||
| ---- | -------------------------------------------------------------- | ----------------- |
|
||||
| 1 | Open Google OAuth URL | WebView |
|
||||
| 2 | User signs in | System browser |
|
||||
| 3 | Browser redirects to deep link (e.g. `com.compassmeet://auth`) | OS → App |
|
||||
| 4 | App intercepts deep link and injects it into WebView | Native layer |
|
||||
| 5 | WebView exchanges `code` with backend for tokens | Web app + backend |
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "compass",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"common",
|
||||
@@ -16,14 +16,27 @@
|
||||
"dev": "./scripts/run_local.sh dev",
|
||||
"prod": "./scripts/run_local.sh prod",
|
||||
"clean-install": "./scripts/install.sh",
|
||||
"build-web": "./scripts/build_web.sh",
|
||||
"build-sync-android": "./scripts/build_sync_android.sh",
|
||||
"sync-android": "./scripts/sync_android.sh",
|
||||
"migrate": "./scripts/migrate.sh",
|
||||
"test": "jest",
|
||||
"playwright": "playwright test",
|
||||
"playwright:ui": "playwright test --ui",
|
||||
"playwright:debug": "playwright test --debug",
|
||||
"playwright:report": "npx playwright show-report tests/reports/playwright-report",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:update": "jest --updateSnapshot",
|
||||
"postinstall": "./scripts/post_install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "7.1.0",
|
||||
"@capacitor/core": "7.4.4",
|
||||
"@capacitor/keyboard": "7.0.3",
|
||||
"@capacitor/push-notifications": "7.0.3",
|
||||
"@capacitor/status-bar": "7.0.3",
|
||||
"@capgo/capacitor-social-login": "7.14.9",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"colorette": "^2.0.20",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -32,6 +45,9 @@
|
||||
"react-markdown": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "7.4.4",
|
||||
"@capacitor/assets": "3.0.5",
|
||||
"@capacitor/cli": "7.4.4",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
|
||||
28
playwright.config.ts
Normal file
28
playwright.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [['html', {outputFolder: `tests/reports/playwright-report`, open: 'on-falure'}]],
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
],
|
||||
});
|
||||
23
scripts/build_android.sh
Executable file
23
scripts/build_android.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"/..
|
||||
|
||||
# keytool -genkeypair -v -keystore my-release-key.keystore -alias compass -keyalg RSA -keysize 2048 -validity 10000
|
||||
|
||||
# npx cap sync android
|
||||
# npx cap run android
|
||||
# npx cap open android
|
||||
|
||||
cd android
|
||||
|
||||
./gradlew --stop
|
||||
./gradlew clean
|
||||
./gradlew assembleRelease
|
||||
./gradlew bundleRelease
|
||||
|
||||
adb install -r app-release.apk
|
||||
|
||||
adb logcat | grep FirebaseMessaging
|
||||
|
||||
18
scripts/build_sync_android.sh
Executable file
18
scripts/build_sync_android.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"/..
|
||||
|
||||
export NEXT_PUBLIC_WEBVIEW=1
|
||||
|
||||
yarn build-web
|
||||
|
||||
source web/.env
|
||||
|
||||
npx cap sync android
|
||||
|
||||
# To generate icons
|
||||
# npx capacitor-assets generate --android
|
||||
|
||||
# Then go to android studio, build, generate signed APK in android/app/release, adb install -r app-release.apk
|
||||
45
scripts/build_web.sh
Executable file
45
scripts/build_web.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"/..
|
||||
|
||||
# Paths
|
||||
ROOT_ENV=".env" # your root .env
|
||||
WEB_ENV="web/.env" # target for frontend
|
||||
|
||||
# Backup existing web/.env if it exists
|
||||
if [ -f "$WEB_ENV" ]; then
|
||||
cp "$WEB_ENV" "${WEB_ENV}.bak"
|
||||
echo "Backed up existing $WEB_ENV to ${WEB_ENV}.bak"
|
||||
fi
|
||||
|
||||
# Filter NEXT_PUBLIC_* lines
|
||||
grep '^NEXT_PUBLIC_' "$ROOT_ENV" > "$WEB_ENV"
|
||||
|
||||
echo "Copied NEXT_PUBLIC_ variables to $WEB_ENV:"
|
||||
|
||||
echo "NEXT_PUBLIC_FIREBASE_ENV=prod" >> "$WEB_ENV"
|
||||
|
||||
cat "$WEB_ENV"
|
||||
|
||||
cd web
|
||||
|
||||
rm -rf .next
|
||||
|
||||
# Hack to ignore getStaticProps and getStaticPaths for mobile webview build
|
||||
# as Next.js doesn't support SSR / ISR on mobile
|
||||
USERNAME_PAGE=pages/[username]/index.tsx
|
||||
|
||||
# rename getStaticProps to _getStaticProps
|
||||
sed -i.bak 's/\bgetStaticProps\b/_getStaticProps/g' $USERNAME_PAGE
|
||||
|
||||
# rename getStaticPaths to _getStaticPaths
|
||||
sed -i.bak 's/\bgetStaticPaths\b/_getStaticPaths/g' $USERNAME_PAGE
|
||||
|
||||
yarn build
|
||||
|
||||
sed -i.bak 's/\b_getStaticProps\b/getStaticProps/g' $USERNAME_PAGE
|
||||
|
||||
# rename getStaticPaths to _getStaticPaths
|
||||
sed -i.bak 's/\b_getStaticPaths\b/getStaticPaths/g' $USERNAME_PAGE
|
||||
11
scripts/sync_android.sh
Executable file
11
scripts/sync_android.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"/..
|
||||
|
||||
export $(grep -E '^NEXT_PUBLIC_LOCAL_ANDROID=' .env)
|
||||
|
||||
npx cap sync android
|
||||
|
||||
# Then go to android studio, build, generate signed APK in android/app/release, adb install -r app-release.apk
|
||||
0
tests/e2e/backend/.keep
Normal file
0
tests/e2e/backend/.keep
Normal file
0
tests/e2e/mobile/.keep
Normal file
0
tests/e2e/mobile/.keep
Normal file
0
tests/e2e/utils/.keep
Normal file
0
tests/e2e/utils/.keep
Normal file
0
tests/e2e/web/.auth/.keep
Normal file
0
tests/e2e/web/.auth/.keep
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user