300 Commits

Author SHA1 Message Date
MartinBraquet
f3cb8d51fc Bump version to 1.10.0 in package.json 2026-02-22 21:07:58 +01:00
MartinBraquet
1bb9ac2006 Update French translation for account deletion confirmation label 2026-02-22 18:34:37 +01:00
MartinBraquet
07c8f74de7 Refactor filter initialization logic in useFilters for improved readability 2026-02-22 18:34:21 +01:00
MartinBraquet
e305fba93d Add reason tracking for user deletion in unit tests 2026-02-22 18:20:15 +01:00
MartinBraquet
ce680d6c8a Prompt for reasons upon profile deletion 2026-02-22 18:06:38 +01:00
MartinBraquet
5362403a04 Add language mapping based on locale for signup and filters 2026-02-22 16:46:30 +01:00
MartinBraquet
abd77c4c4b Add support for language filter based on signup locale in useFilters 2026-02-22 16:33:00 +01:00
MartinBraquet
e4a9337fab Fix 2026-02-22 16:27:32 +01:00
MartinBraquet
a9d9f0a190 Improve debug logging in run-script.ts for clarity 2026-02-22 10:42:09 +01:00
MartinBraquet
a112350ad4 Bump version to 1.11.1 2026-02-21 19:49:46 +01:00
MartinBraquet
243e602fda Refactor pre-commit hooks and update lint-staged configuration 2026-02-21 19:48:17 +01:00
MartinBraquet
b6d7955130 Add profile field: place they grew up 2026-02-21 19:47:26 +01:00
MartinBraquet
0277cc390d Update French translations for political affiliations 2026-02-21 18:08:50 +01:00
MartinBraquet
2d85b5c25a Bump version to 1.8.0 and increment version code to 39 2026-02-21 11:45:20 +01:00
MartinBraquet
837a45dd91 Add optional environment variable logging to runScript 2026-02-21 11:45:03 +01:00
MartinBraquet
b941960013 Enhance event card with timezone display and improve date formatting 2026-02-20 22:57:59 +01:00
MartinBraquet
650b3f2469 Clean 2026-02-20 19:29:54 +01:00
MartinBraquet
3a1273dfac Fix 2026-02-20 19:00:50 +01:00
MartinBraquet
b7ab669adc Test API release 2026-02-20 18:55:09 +01:00
MartinBraquet
affea2ce26 Fix 2026-02-20 18:53:43 +01:00
MartinBraquet
85ea90b9c8 Rollback failing API deployment 2026-02-20 18:49:22 +01:00
MartinBraquet
0bfc714a41 Clean 2026-02-20 17:41:53 +01:00
Martin Braquet
ba9b3cfb06 Add pretty formatting (#29)
* Test

* Add pretty formatting

* Fix Tests

* Fix Tests

* Fix Tests

* Fix

* Add pretty formatting fix

* Fix

* Test

* Fix tests

* Clean typeckech

* Add prettier check

* Fix api tsconfig

* Fix api tsconfig

* Fix tsconfig

* Fix

* Fix

* Prettier
2026-02-20 17:32:27 +01:00
Okechi Jones-Williams
1994697fa1 Adding onboarding E2E foundations and first tests (#30)
* .

* Centralizing config details

* Added data-testId attributes where necessary and started the onboarding flow scaffolding

* Continued onboarding test scaffolding

* Continued work on tests for the Onboarding flow

* .

* Updated "Want kids" options to be less flaky
Updated playwright.config so that expect timeout matching test timeout

* Continued updating front-end scaffolding

* .

* .

* .

* .

* Updated fixture function deleteUser: to also remove the database user information

* Rm

* Fix

* Fixes

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2026-02-20 16:56:26 +01:00
MartinBraquet
1c26b6381e Remove test files 2026-02-19 17:24:29 +01:00
MartinBraquet
f5daa3cdc8 Test pre-commit formatting with updated config 2026-02-19 17:24:05 +01:00
MartinBraquet
8c71f6231a Test pre-commit formatting 2026-02-19 17:23:19 +01:00
MartinBraquet
2f8e6147f6 Configure pre-commit hook to format files with Prettier 2026-02-19 17:22:42 +01:00
MartinBraquet
2ff778a387 Add husky 2026-02-19 17:19:03 +01:00
MartinBraquet
70c3493057 Fix 2026-02-19 17:15:50 +01:00
MartinBraquet
e62dac276a Fix 2026-02-19 17:05:41 +01:00
MartinBraquet
1115b4f1b0 Test 2026-02-19 17:03:47 +01:00
MartinBraquet
c05af9ffcb Fix 2026-02-19 17:02:13 +01:00
MartinBraquet
2bb84d3992 Fix 2026-02-19 17:01:19 +01:00
MartinBraquet
2c90fc6cc8 Fix linting 2026-02-19 16:34:39 +01:00
MartinBraquet
d89bf2d92a Test 2026-02-19 15:51:24 +01:00
MartinBraquet
f591cb13bb Upgrade packages and pre commit lint 2026-02-19 15:43:32 +01:00
MartinBraquet
52720d2520 Upgrade packages, add lint and type check 2026-02-19 15:08:38 +01:00
MartinBraquet
45473e0679 API Release 2026-02-19 14:41:35 +01:00
MartinBraquet
5e3c10d26a Fix /get-channel-memberships and /get-channel-seen-time called all the time 2026-02-19 14:41:11 +01:00
MartinBraquet
d0c58f753b Clean 2026-02-19 14:32:10 +01:00
MartinBraquet
20ba6f7ea9 Adjust padding in compatibility question content layout 2026-02-19 14:32:00 +01:00
MartinBraquet
27e93da06a Make answers to compatibility prompts searchable 2026-02-19 14:11:22 +01:00
MartinBraquet
c1a204e3be Update import path for user information choices 2026-02-19 12:34:12 +01:00
MartinBraquet
6be69b74e8 Fix user notif test 2026-02-19 12:23:15 +01:00
MartinBraquet
4b894363af Translate saved searches 2026-02-19 12:19:35 +01:00
MartinBraquet
cf843f66c4 Refactor measurement system imports and enhance filter formatting 2026-02-19 01:30:59 +01:00
MartinBraquet
cb98314bec Add metric vs imperial switch 2026-02-19 01:00:58 +01:00
MartinBraquet
4046cc19ef Add referral invitation titles in German and French translations 2026-02-18 22:58:20 +01:00
MartinBraquet
6787cdffa3 Remove impossible options for min max age 2026-02-18 22:53:50 +01:00
MartinBraquet
d583dbb945 Implement notification template system and bulk notification creation 2026-02-18 22:42:02 +01:00
MartinBraquet
bbd395b904 Add confirmation prompt for event cancellation and update translations 2026-02-18 21:54:53 +01:00
MartinBraquet
e1077e95a2 Fix 2026-02-18 21:37:59 +01:00
MartinBraquet
28f33da6d0 Enhance event creation with locale-aware date formatting and improved translations 2026-02-18 21:02:26 +01:00
MartinBraquet
dbdaa9d7ec Remove unused useIsPageVisible hook and adjust event fetching to only occur on initial mount 2026-02-18 20:14:04 +01:00
MartinBraquet
f4f009dc4a Refactor UserLinkFromId component to enforce userId type and remove null checks 2026-02-18 20:08:32 +01:00
MartinBraquet
aacdd380c4 Update French translation for event cancellation message 2026-02-18 20:08:27 +01:00
MartinBraquet
01f774ef7e Add UserLinkFromId component for improved user linking 2026-02-18 20:01:20 +01:00
MartinBraquet
57d7df63c8 Fix margin 2026-02-18 19:35:26 +01:00
MartinBraquet
f41c05a338 Fix translation 2026-02-18 19:33:01 +01:00
MartinBraquet
66526abeef Improve migration cleanup by using find command 2026-02-18 19:04:50 +01:00
MartinBraquet
3772d28129 Fix onboarding with no questions 2026-02-18 19:02:50 +01:00
MartinBraquet
3cc7222309 Fix 2026-02-18 18:36:09 +01:00
MartinBraquet
46082c5f64 Add events page 2026-02-18 18:34:18 +01:00
MartinBraquet
cad1fd72e3 Fix firebase emulator for storage 2026-02-18 17:46:06 +01:00
MartinBraquet
3a2e932628 Use md prose 2026-02-18 17:45:38 +01:00
MartinBraquet
1b3b40917b Seed dev_1 profile in db 2026-02-18 13:59:06 +01:00
MartinBraquet
c181c571c4 Update migration script to clear existing files and adjust timestamp 2026-02-18 13:58:36 +01:00
MartinBraquet
8b094769e4 Apply sqlmatch 2026-02-18 13:43:25 +01:00
MartinBraquet
4b2b46d4f7 Add link to development documentation for project setup guidelines 2026-02-17 22:51:01 +01:00
MartinBraquet
fc6628be08 Refactor unit tests to use sqlMatch for SQL query assertions 2026-02-17 22:37:32 +01:00
MartinBraquet
07ce2780c6 Refactor user profile handling by removing deprecated fields and improving link management 2026-02-17 22:02:47 +01:00
MartinBraquet
c8b7b85391 Add color-coded status and error messages to run_local_isolated.sh 2026-02-17 20:08:27 +01:00
MartinBraquet
0af6b9aff5 Allow profile creation step to display when profile is already created 2026-02-17 20:05:57 +01:00
MartinBraquet
494e62720d Add loading indicator for profile page while fetching user data 2026-02-17 19:55:08 +01:00
MartinBraquet
8df2be1969 Fix 2026-02-17 19:46:27 +01:00
MartinBraquet
3c59be763a Add translation guidelines and coding tips to documentation 2026-02-17 19:41:03 +01:00
MartinBraquet
60a44b2ed1 Add age validation and error messages to optional profile form 2026-02-17 19:36:01 +01:00
MartinBraquet
e58a3ecb43 Fix age selection logic to ensure min is not greater than max 2026-02-17 19:17:44 +01:00
MartinBraquet
20025c825f Fix 2026-02-17 17:41:42 +01:00
MartinBraquet
aec30cd5b5 Fix 2026-02-17 17:40:20 +01:00
MartinBraquet
771cf887d5 Fix 2026-02-17 17:33:31 +01:00
MartinBraquet
78325cddfa Fix 2026-02-17 17:27:45 +01:00
MartinBraquet
1428ef1687 Fix 2026-02-17 16:39:32 +01:00
MartinBraquet
145f544ff1 Fix 2026-02-17 16:39:23 +01:00
MartinBraquet
28a582d9c4 Install from lock file 2026-02-17 16:17:42 +01:00
MartinBraquet
19fc2b798b Add env test 2026-02-17 16:17:34 +01:00
MartinBraquet
922decd252 Clean 2026-02-17 16:08:23 +01:00
MartinBraquet
0188a4ab51 Prepend npx 2026-02-17 16:07:07 +01:00
MartinBraquet
c47e693e69 Clean 2026-02-17 15:54:58 +01:00
MartinBraquet
dd049dfb88 Fix 2026-02-17 15:15:21 +01:00
MartinBraquet
e6d64f2668 Add test docs 2026-02-17 15:13:27 +01:00
MartinBraquet
ee1e894e2f Add e2e dev 2026-02-17 15:13:16 +01:00
MartinBraquet
b3365cd773 Clean 2026-02-17 15:12:58 +01:00
MartinBraquet
920e0f37f2 Upgrade java 2026-02-17 15:12:47 +01:00
MartinBraquet
fd7b4edc02 Clean 2026-02-17 14:33:52 +01:00
MartinBraquet
e689d0253c Fix lint 2026-02-17 13:45:19 +01:00
MartinBraquet
9efd3e4510 Fix 2026-02-17 13:42:43 +01:00
MartinBraquet
4fda21c582 Fix 2026-02-17 13:40:53 +01:00
MartinBraquet
76abe4ad28 Fix 2026-02-17 13:23:21 +01:00
MartinBraquet
e92f8afb46 Fix deps 2026-02-17 13:17:36 +01:00
MartinBraquet
e3907a3e64 Fix 2026-02-17 13:16:36 +01:00
MartinBraquet
5c9aa4f9f0 Fix 2026-02-17 13:13:33 +01:00
MartinBraquet
ae3b045772 Fix 2026-02-17 13:08:21 +01:00
MartinBraquet
6638d2b184 Fix 2026-02-17 13:02:47 +01:00
MartinBraquet
6a97045bad Fix lint 2026-02-17 12:47:20 +01:00
MartinBraquet
ba5beea17e Add ai assistant guidelines copilot 2026-02-17 12:41:50 +01:00
MartinBraquet
7df3b301c6 Add ai assistant guidelines 2026-02-17 12:34:35 +01:00
MartinBraquet
ab81949927 Fix java version 2026-02-17 12:34:22 +01:00
MartinBraquet
f7c0d77e9c Add local supabase for DB isolation 2026-02-17 12:10:17 +01:00
MartinBraquet
b7d1fd9903 Clean button UI 2026-02-16 13:38:33 +01:00
Martin Braquet
e3b743f87b Add font preference selector in settings (#28)
* Add font preference selector in settings

* Consolidate font family config into shared global constant

* Revert "Consolidate font family config into shared global constant"

This reverts commit 789ddc98e1.

* Fix
2026-02-16 12:24:45 +01:00
MartinBraquet
54e1106237 Fix 2026-02-15 17:15:15 +01:00
MartinBraquet
6a18d482e2 API release 2026-02-15 17:01:23 +01:00
MartinBraquet
c46fc2a5bd Prevent usernmaes ilike existing ones (case-insensitive) 2026-02-15 16:57:46 +01:00
MartinBraquet
e28263ff1f Fix comments 2026-02-15 13:45:34 +01:00
MartinBraquet
117b2d22e4 Fix 2026-02-15 13:41:42 +01:00
MartinBraquet
d61133ef74 Cache data for required form as well 2026-02-14 01:50:46 +01:00
MartinBraquet
ccf68b80c0 Fix 2026-02-14 01:09:04 +01:00
MartinBraquet
d2929a94ce Cache unsaved profile editions for 24h 2026-02-14 01:00:20 +01:00
MartinBraquet
7fd509b7e4 Add back button to md pages 2026-02-14 00:58:35 +01:00
MartinBraquet
2c3314becc Move FirebaseUser to context to avoid duplicated subscriptions 2026-02-13 22:55:02 +01:00
MartinBraquet
6815513ac3 Fix download file for android 2026-02-13 22:18:25 +01:00
MartinBraquet
e8cfc77902 Fix email verif not refreshing component 2026-02-13 21:03:49 +01:00
MartinBraquet
7928e58d3b Update todo 2026-02-13 18:32:43 +01:00
MartinBraquet
d8743a4b1c API release 2026-02-13 18:17:39 +01:00
MartinBraquet
6626f23ef3 Android release 2026-02-13 18:07:17 +01:00
MartinBraquet
a0c1cf964b Allow download on native mobile 2026-02-13 17:59:13 +01:00
MartinBraquet
7437a2fb45 Fix DM not parsed in data export 2026-02-13 17:36:12 +01:00
MartinBraquet
8ff5b8a577 Allow users to download all their data 2026-02-13 16:50:06 +01:00
MartinBraquet
cd434e2fb5 Move pics above compat prompts 2026-02-13 15:46:26 +01:00
MartinBraquet
7734b689a3 Add Big 5 profile field 2026-02-13 15:23:54 +01:00
MartinBraquet
ca55a93d5f Add indices 2026-02-13 14:15:14 +01:00
MartinBraquet
6b7e3acf93 Fix double success 2026-02-13 13:17:52 +01:00
MartinBraquet
e865f75d95 Android release 2026-02-13 13:11:07 +01:00
MartinBraquet
af95174929 Refresh page after email verified 2026-02-13 13:11:02 +01:00
MartinBraquet
b6598f917c Fix flash 2026-02-13 00:53:52 +01:00
MartinBraquet
702d4ea1a2 Fix button UI, default gray 2026-02-13 00:44:13 +01:00
MartinBraquet
dbe45d5181 Fix flash 2026-02-13 00:36:11 +01:00
MartinBraquet
4e11cc7ed1 API release 2026-02-13 00:31:50 +01:00
MartinBraquet
2d5184a0ee Fix: update avatar pic to be the same as pinned profile pic 2026-02-13 00:28:06 +01:00
MartinBraquet
1d2a2beb7a Fix 2026-02-12 23:46:11 +01:00
MartinBraquet
60dba612ba Fix tests 2026-02-12 23:37:42 +01:00
MartinBraquet
ea4047bc47 Fix 2026-02-12 23:03:23 +01:00
MartinBraquet
fab2316f28 Warn user in toast if need to wait before sending verif email again 2026-02-12 22:57:37 +01:00
MartinBraquet
ae708313aa Log 2026-02-12 17:27:25 +01:00
MartinBraquet
812f6acac7 Release API 2026-02-12 17:14:55 +01:00
MartinBraquet
f56373fd73 People must verify their email to send messages 2026-02-12 17:14:40 +01:00
MartinBraquet
5d9a1c1bf8 Allow bio edition in profile edition 2026-02-12 15:49:04 +01:00
MartinBraquet
a3257cd4c0 Add insta 2026-02-12 13:56:12 +01:00
MartinBraquet
67f8861d12 Fix alignment 2026-02-12 13:43:24 +01:00
MartinBraquet
3fd37b21f3 Clean UI 2026-02-12 13:34:12 +01:00
MartinBraquet
9c1fbbd258 Fix typo 2026-02-12 13:05:37 +01:00
MartinBraquet
32fb79e9a2 Clean 2026-02-12 12:50:39 +01:00
MartinBraquet
ebecf18b6c Improve button UI 2026-02-12 12:23:03 +01:00
MartinBraquet
403af8106f Swap eye symbol: show eye off when hidden profile 2026-02-12 12:14:46 +01:00
MartinBraquet
85ceee0b0d Fix unit test 2026-02-11 22:36:26 +01:00
MartinBraquet
33f75420a2 Android release 2026-02-11 19:22:18 +01:00
MartinBraquet
a20767761b Fix hidden profiles not updating 2026-02-11 19:11:33 +01:00
MartinBraquet
b13a40f892 Use persistent in storage for hidden profiles and allow toggle hidden profile 2026-02-11 18:25:58 +01:00
MartinBraquet
a86f841b7e Remove toasts profile grid 2026-02-11 17:35:58 +01:00
MartinBraquet
41c9da04b1 Remove toasts 2026-02-11 17:32:22 +01:00
MartinBraquet
f42a1ad64f Fix tooltip (3) 2026-02-11 16:52:37 +01:00
MartinBraquet
9c00fffb89 Throttle toasts 2026-02-11 16:52:27 +01:00
MartinBraquet
7b07610613 Fix 2026-02-11 16:42:23 +01:00
MartinBraquet
f1bc2f9dcb Use loading indicator 2026-02-11 16:37:23 +01:00
MartinBraquet
4a317018cb Fix tooltip message still showing after click on mobile on profile card (2) 2026-02-11 16:37:15 +01:00
MartinBraquet
05219b2938 Fix tooltip message still showing after click on mobile on profile card 2026-02-11 16:25:58 +01:00
MartinBraquet
32edb4697c Release API 2026-02-11 16:13:37 +01:00
MartinBraquet
7e790ca353 Add hidden_profiles table 2026-02-11 16:13:26 +01:00
MartinBraquet
939767cb42 Improve layout for saved people 2026-02-11 16:12:46 +01:00
MartinBraquet
31dc39fad7 Show the profiles I hid in the settings 2026-02-11 15:58:18 +01:00
MartinBraquet
243d22822a Add hide feature to profile page 2026-02-11 15:13:42 +01:00
MartinBraquet
17d0fba831 Hide feature to hide per-user profiles 2026-02-11 14:57:27 +01:00
MartinBraquet
05d003535b Sort lines 2026-02-11 14:13:36 +01:00
MartinBraquet
bd39bc290c Fix wrap 2026-02-11 13:59:47 +01:00
MartinBraquet
4b203ae686 Android release 2026-02-11 13:50:43 +01:00
MartinBraquet
4e306af344 Fix 2026-02-11 13:50:38 +01:00
MartinBraquet
46489e25ff Fix translation 2026-02-11 13:36:44 +01:00
MartinBraquet
56d76922dc Fix logo not working when loading /messages/[id] 2026-02-11 13:28:58 +01:00
MartinBraquet
6ad3d3051f Show messages of deleted users 2026-02-11 13:16:28 +01:00
MartinBraquet
7598f32c56 Update financials 2026-02-11 12:01:00 +01:00
MartinBraquet
46fcf042ad Improve /socials page 2026-02-10 13:19:40 +01:00
MartinBraquet
5962512a83 Improve /organisation page 2026-02-10 13:03:14 +01:00
MartinBraquet
eaaae99dff Improve tracking: add user ID, etc. 2026-02-09 17:19:21 +01:00
MartinBraquet
688bbffd5e Add RCF interview to press 2026-02-09 16:38:21 +01:00
MartinBraquet
079ec8fc7e Improve filters UI 2026-02-09 14:41:14 +01:00
MartinBraquet
fba4436a08 Add tooltip to CompatibleBadge and fix z-index in profile-grid 2026-02-09 13:09:54 +01:00
MartinBraquet
d7f7e046ed Update TESTING.md with Jest clarification and Playwright selector guide 2026-02-07 14:16:30 +01:00
MartinBraquet
e001787e5c Add run_local_emulated.sh 2026-02-06 13:05:48 +01:00
MartinBraquet
cc4277a85c Add storage to firebase emulation 2026-02-06 13:05:31 +01:00
MartinBraquet
b49838c49a Fix 2026-02-01 17:23:04 +01:00
MartinBraquet
33ce2c9624 Fix 2026-02-01 17:22:29 +01:00
MartinBraquet
52d09aedd2 Fix 2026-02-01 16:40:26 +01:00
MartinBraquet
cf67ad0d81 Fix 2026-02-01 16:39:31 +01:00
MartinBraquet
20006049d0 Fix 2026-02-01 16:31:27 +01:00
MartinBraquet
a0ce449abe Android release 2026-02-01 16:09:08 +01:00
MartinBraquet
bd8b371c13 Fix faq 2026-01-31 20:12:32 +01:00
MartinBraquet
83433be1c8 Comment line 2026-01-31 15:37:22 +01:00
MartinBraquet
7596906531 Fix scrolling warning 2026-01-31 15:19:56 +01:00
MartinBraquet
cb35813d16 Fix margin 2026-01-31 15:11:33 +01:00
MartinBraquet
55027c790a Fix fiilter coomp 2026-01-31 14:59:43 +01:00
MartinBraquet
09ff56ad61 Fix redirect 2026-01-31 14:59:36 +01:00
MartinBraquet
63e516fb4b Fix text 2026-01-31 14:48:26 +01:00
MartinBraquet
0b2b60bd49 Add onboarding for profiles page 2026-01-31 14:43:22 +01:00
MartinBraquet
3e8470a216 Improve site log margin 2026-01-31 12:48:18 +01:00
MartinBraquet
e04746cde3 Fix sidebar on mobile (was scrolling) 2026-01-31 12:30:02 +01:00
MartinBraquet
8c293c69eb Update with bio to
completed
2026-01-31 11:48:50 +01:00
MartinBraquet
cb96528752 Move next button toward center on desktop 2026-01-30 23:58:37 +01:00
MartinBraquet
8348953510 Hide nav bar while signing up 2026-01-30 23:58:19 +01:00
MartinBraquet
6d25937b56 Apply modal height to all modals 2026-01-30 23:40:45 +01:00
MartinBraquet
d1f1fe945f Improve onboarding margins 2026-01-30 23:03:12 +01:00
MartinBraquet
4155beb7db Fix bottom padding mobile for compat Q 2026-01-30 23:02:55 +01:00
MartinBraquet
374143172d Fix dvh and add hloss 2026-01-30 22:30:42 +01:00
MartinBraquet
6b4b0a9459 Fix dvh 2026-01-30 22:23:52 +01:00
MartinBraquet
21789101fd Fix scroll 2026-01-30 18:50:07 +01:00
MartinBraquet
8bfed9a6cc Fix 2026-01-30 18:42:13 +01:00
MartinBraquet
c36ceb7ed9 Add onboarding 2026-01-30 18:34:02 +01:00
MartinBraquet
e80d8d701a Fix modal layout, make it larger on mobile 2026-01-30 18:33:27 +01:00
MartinBraquet
f336e61304 Active instead of last connected 2026-01-30 14:29:19 +01:00
MartinBraquet
70194541db Show all work in card and fix comma 2026-01-30 14:23:32 +01:00
MartinBraquet
8570f74a24 Trim trailing whitespaces in profile data 2026-01-30 14:23:18 +01:00
MartinBraquet
5b43382fc7 Fix profile list not loading more 2026-01-28 23:26:59 +01:00
MartinBraquet
237ed0b5bf Release 2026-01-28 22:59:09 +01:00
MartinBraquet
b01dcc6bde Include short bios by default and fill those no-bio cards with work and interests 2026-01-28 22:58:44 +01:00
MartinBraquet
a433d1e095 Fix 2026-01-28 14:25:14 +01:00
MartinBraquet
ccc68f00ae Fix bg 2026-01-28 14:20:05 +01:00
MartinBraquet
756f0036eb Fixes 2026-01-28 14:14:14 +01:00
MartinBraquet
54b8ab34bd Fix 2026-01-28 14:02:41 +01:00
MartinBraquet
59ebd539f1 Add QuestionMarkTooltip 2026-01-28 13:50:45 +01:00
MartinBraquet
7fad10d203 Revert "Fix"
This reverts commit acb29c0600.
2026-01-28 13:24:12 +01:00
MartinBraquet
acb29c0600 Fix 2026-01-28 13:19:14 +01:00
MartinBraquet
398c7b92b7 Fix couriel 2026-01-28 13:07:48 +01:00
MartinBraquet
95a9d8a50a translate copy link buttons 2026-01-28 13:00:35 +01:00
MartinBraquet
95c018786d Inform user about filtered profiles 2026-01-28 12:51:17 +01:00
MartinBraquet
d7053dee14 Translate MoreOptionsUserButton 2026-01-28 12:27:14 +01:00
MartinBraquet
f878521a40 Translate star button and editor buttons 2026-01-28 12:20:58 +01:00
MartinBraquet
40bbdb3fd9 Translate message button 2026-01-28 12:13:46 +01:00
MartinBraquet
7ac933160d Fix interest (etc.) shown as IDs instead of labels in saved searches and emails 2026-01-27 23:04:06 +01:00
MartinBraquet
0ac5ce7b41 Android release 2026-01-26 23:41:26 +01:00
MartinBraquet
152ed99727 Fix test 2 2026-01-26 23:32:21 +01:00
MartinBraquet
a48633a074 Fix test 2026-01-26 23:17:52 +01:00
MartinBraquet
b2ebf7e78e Release API 2026-01-26 23:14:21 +01:00
MartinBraquet
a5259f4c61 Fix minor bugs 2026-01-26 23:13:32 +01:00
MartinBraquet
542152eadb Store option IDs instead of EN labels in profiles and make keyword search match selected options 2026-01-26 22:53:31 +01:00
MartinBraquet
ffc966f3b3 Render keyword examples in the user language 2026-01-26 22:48:28 +01:00
MartinBraquet
e45b24880f Clarify bio role 2026-01-26 14:18:22 +01:00
MartinBraquet
beac17cecf Add file format warning 2026-01-26 00:00:37 +01:00
MartinBraquet
7ca682a4f9 Fix storage name 2026-01-25 23:48:17 +01:00
MartinBraquet
07bc32d781 Fix HEIC photos not rendered 2026-01-25 23:40:18 +01:00
MartinBraquet
d59961f6cc Fix test 2026-01-25 22:47:53 +01:00
MartinBraquet
945febffb6 Android release 2026-01-25 22:47:34 +01:00
MartinBraquet
bda34dddd0 Add translation support for compatibility prompts 2026-01-25 22:18:54 +01:00
MartinBraquet
ccb2eaaddf Add press article summaries 2026-01-22 21:27:34 +01:00
MartinBraquet
7880c391f1 Add l'avenir press article 2026-01-22 17:45:54 +01:00
MartinBraquet
ad4ea7328f Fix AI assistant that removed 2 translations... 2026-01-22 17:30:33 +01:00
MartinBraquet
763b74ef31 Show no kids in profile about 2026-01-22 14:04:07 +01:00
MartinBraquet
851d945545 Add multi-lingual support for fromNow() 2026-01-21 13:42:37 +01:00
MartinBraquet
80af2e9aeb Add categories to profile form 2026-01-21 12:55:09 +01:00
MartinBraquet
473734bbd4 Fixes 2026-01-21 11:28:03 +01:00
MartinBraquet
afe36f98be Fix 2026-01-21 11:23:34 +01:00
MartinBraquet
0888eb1a81 Improve test docs 2026-01-21 11:21:30 +01:00
MartinBraquet
e2cbb5969d Use yarn 2026-01-21 11:20:01 +01:00
Okechi Jones-Williams
026a938e6f Added/Updated Documentation (#27)
* Updating documentation

* Added folder structure

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2026-01-21 09:07:10 +01:00
Martin Braquet
2da7e6d5d9 Enhance language addition guidelines in development.md
Updated instructions for adding a new language with LLM translation tips.
2026-01-20 21:24:13 +01:00
MartinBraquet
fd331dfaf9 Do not render relationship_status in match emails 2026-01-20 21:12:42 +01:00
MartinBraquet
e4a3e7a525 Move work area below job title 2026-01-20 21:11:55 +01:00
MartinBraquet
71695aba6c Improve button for reset filters 2026-01-20 13:35:18 +01:00
MartinBraquet
1aff8c1009 Fix missing space in terms 2026-01-19 20:41:22 +01:00
MartinBraquet
61a2e31175 Fix: Remove status bar black background in light theme 2026-01-19 20:14:35 +01:00
MartinBraquet
c3d547f090 Fix FR typo 2026-01-19 20:02:17 +01:00
MartinBraquet
33d6dc1455 Clarify kids name 2026-01-19 14:32:40 +01:00
MartinBraquet
43c3ef591c Android release 2026-01-19 14:14:16 +01:00
MartinBraquet
79d56b0b4b Clean 2026-01-19 14:06:05 +01:00
MartinBraquet
1d505a2ae3 Fix status bar not visible in light theme 2026-01-19 14:06:01 +01:00
MartinBraquet
218de89583 Suggest update on play store instead of live 2026-01-19 13:49:18 +01:00
MartinBraquet
94298e4609 Fix options in multiple languages and filter language mismatch 2026-01-18 23:32:42 +01:00
MartinBraquet
274ea45cb8 Fix stringOrStringArrayToText 2026-01-18 23:01:01 +01:00
MartinBraquet
d5322e1863 Add missing translations for optional form 2026-01-18 22:48:02 +01:00
MartinBraquet
7131a5edaf Add translations for profile 2026-01-18 22:44:11 +01:00
MartinBraquet
ae977fbde7 Clean mobile filters 2026-01-18 22:34:08 +01:00
MartinBraquet
f68321690a Add matele reel 2026-01-18 20:40:45 +01:00
Martin Braquet
016841d6c2 Update press.tsx 2026-01-17 10:42:40 +01:00
MartinBraquet
635538b640 Center button text 2026-01-16 14:53:05 +01:00
MartinBraquet
cf6989fcd7 Clean /about buttons 2026-01-16 14:37:16 +01:00
MartinBraquet
9e324150c4 Disable sidebar open on swipe for now 2026-01-16 14:29:48 +01:00
MartinBraquet
f6bf4b5b23 Improve button look 2026-01-16 14:04:12 +01:00
MartinBraquet
111f8809ca Clean profile grid 2026-01-16 13:40:25 +01:00
MartinBraquet
548c1d3ad9 Move include short bios 2026-01-15 22:27:18 +01:00
MartinBraquet
c39dddf1db Clean /organization 2026-01-15 22:23:57 +01:00
MartinBraquet
39d856f368 Speed ISR for not found 2026-01-15 22:18:16 +01:00
MartinBraquet
b2c4e46180 Use locale to infer user spoken language 2026-01-15 20:29:05 +01:00
MartinBraquet
7622e864cb Reload page after sign up 2026-01-15 20:22:34 +01:00
MartinBraquet
bc66c0334a Revert "Reload page after sign up"
This reverts commit babef5f032.
2026-01-15 20:06:47 +01:00
MartinBraquet
babef5f032 Reload page after sign up 2026-01-15 20:00:24 +01:00
MartinBraquet
f7f67b0ab0 Add press info to FAQ 2026-01-15 19:53:22 +01:00
MartinBraquet
9219671430 Ask to reload 2026-01-15 19:52:19 +01:00
MartinBraquet
a4675246b2 Wait for 5 sec after profile creation 2026-01-15 19:52:01 +01:00
MartinBraquet
e2d78722d8 Ask to reload 2026-01-15 19:51:44 +01:00
MartinBraquet
d5c2f06784 Add Matele reportage to /press 2026-01-15 19:41:59 +01:00
751 changed files with 35134 additions and 20275 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,3 @@
# use firebase emulator for running e2e tests
NEXT_PUBLIC_FIREBASE_EMULATOR=false
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
# You already have access to basic local functionality (UI, authentication, database read access).
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc

19
.env.test Normal file
View File

@@ -0,0 +1,19 @@
# Database
#DATABASE_URL=postgresql://test_user:test_password@localhost:5433/test_db
#DIRECT_URL=postgresql://test_user:test_password@localhost:5433/test_db
# Firebase
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
# Next.js
NEXT_PUBLIC_API_URL=http://localhost:8088
NEXT_PUBLIC_FIREBASE_ENV=TEST
NEXT_PUBLIC_FIREBASE_EMULATOR=true
# API
NODE_ENV=test
PORT=8088
ENVIRONMENT=DEV

View File

@@ -12,9 +12,9 @@ body:
attributes:
label: Info
description: |
- Browser: [e.g. chrome, safari]
- Device (if mobile): [e.g. iPhone6]
- Build info
- Browser: [e.g. chrome, safari]
- Device (if mobile): [e.g. iPhone6]
- Build info
placeholder: |
Build info from `Settings` -> `About`
validations:

View File

@@ -4,5 +4,5 @@
- [ ] Tests added and passed if fixing a bug or adding a new feature.
### Description
<!-- Describe your changes in detail -->
<!-- Describe your changes in detail -->

1294
.github/copilot-instructions.md vendored Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
name: CD Android Live Update
on:
push:
branches: [ main, master ]
branches: [main, master]
paths:
- "android/capawesome.json"
- ".github/workflows/cd-android-live-update.yml"
- 'android/capawesome.json'
- '.github/workflows/cd-android-live-update.yml'
jobs:
deploy:
@@ -15,7 +15,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # we need full history for git log
fetch-depth: 0 # we need full history for git log
- name: Install jq
run: sudo apt-get install -y jq

View File

@@ -1,10 +1,10 @@
name: CD API
on:
push:
branches: [ main, master ]
branches: [main, master]
paths:
- "backend/api/package.json"
- ".github/workflows/cd-api.yml"
- 'backend/api/package.json'
- '.github/workflows/cd-api.yml'
jobs:
deploy:
@@ -15,7 +15,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # we need full history for git log
fetch-depth: 0 # we need full history for git log
- name: Install jq
run: sudo apt-get install -y jq

View File

@@ -2,13 +2,12 @@ name: CD
# Must select "Read and write permissions" in GitHub → Repo → Settings → Actions → General → Workflow permissions
on:
push:
branches: [ main, master ]
branches: [main, master]
paths:
- "package.json"
- ".github/workflows/cd.yml"
- 'package.json'
- '.github/workflows/cd.yml'
jobs:
release:
@@ -18,7 +17,7 @@ jobs:
- name: Checkout repo
uses: actions/checkout@master
with:
fetch-depth: 0 # To fetch all history for tags
fetch-depth: 0 # To fetch all history for tags
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -32,4 +31,4 @@ jobs:
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
./scripts/release.sh
./scripts/release.sh

64
.github/workflows/ci-e2e.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'
- name: Install Java (for Firebase emulators)
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21' # Required for firebase-tools@15+
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
with:
version: latest
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run E2E tests
env:
SKIP_DB_CLEANUP: true # Don't try to stop Docker in CI
FIREBASE_TOKEN: 'dummy' # Suppresses auth warning
# or
run: |
yarn test:e2e
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/
retention-days: 7

View File

@@ -1,17 +1,16 @@
name: CI
name: Jest Tests
on:
push:
branches:
- main
branches: [main]
pull_request:
branches:
- main
branches: [main]
jobs:
ci:
name: Tests
name: Jest Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -21,42 +20,31 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'
- name: Install dependencies
run: yarn install
- name: Type check
run: echo skipping #npx tsc --noEmit
run: yarn install --frozen-lockfile
- name: Lint
run: npm run lint
run: yarn lint
- name: Type check
run: yarn typecheck
- name: Run Jest tests
env:
NEXT_PUBLIC_FIREBASE_ENV: DEV
run: |
yarn test:coverage
# npm install -g lcov-result-merger
# mkdir coverage
# lcov-result-merger \
# "backend/api/coverage/lcov.info" \
# "backend/shared/coverage/lcov.info" \
# "backend/email/coverage/lcov.info" \
# "common/coverage/lcov.info" \
# "web/coverage/lcov.info" \
# > coverage/lcov.info
# Optional: Playwright E2E tests
- name: Install Playwright deps
run: |
npx playwright install chromium
# npx playwright install --with-deps
# npm install @playwright/test
- name: Run E2E tests
run: |
chmod +x scripts/e2e.sh
./scripts/e2e.sh
# npm install -g lcov-result-merger
# mkdir coverage
# lcov-result-merger \
# "backend/api/coverage/lcov.info" \
# "backend/shared/coverage/lcov.info" \
# "backend/email/coverage/lcov.info" \
# "common/coverage/lcov.info" \
# "web/coverage/lcov.info" \
# > coverage/lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5

1
.gitignore vendored
View File

@@ -39,7 +39,6 @@ yarn-error.log*
.env.local
.env.*
.envrc
supabase/*
# vercel
.vercel

3
.husky/pre-commit Normal file
View File

@@ -0,0 +1,3 @@
npx lint-staged
yarn --cwd=web lint-fix
yarn --cwd=web lint

1294
.junie/guidelines.md Normal file
View File

File diff suppressed because it is too large Load Diff

35
.prettierignore Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules
.yarn
# Build outputs
dist
build
.next
out
lib
# Generated files
coverage
*.min.js
*.min.css
# Database / migrations
**/*.sql
# Config / lock files
yarn.lock
package-lock.json
pnpm-lock.yaml
# Android / iOS
android
ios
capacitor.config.ts
# Playwright
tests/reports
playwright-report
coverage
.vscode

View File

@@ -2,8 +2,11 @@
"tabWidth": 2,
"useTabs": false,
"semi": false,
"trailingComma": "es5",
"singleQuote": true,
"singleAttributePerLine": false,
"bracketSpacing": false,
"printWidth": 100,
"trailingComma": "all",
"plugins": ["prettier-plugin-sql"],
"overrides": [
{

View File

@@ -1,7 +1,7 @@
---
trigger: always_on
description:
globs:
description:
globs:
---
## Project Structure
@@ -33,14 +33,14 @@ Here's an example component from web in our style:
import clsx from 'clsx'
import Link from 'next/link'
import { isAdminId, isModId } from 'common/envs/constants'
import { type Headline } from 'common/news'
import { EditNewsButton } from 'web/components/news/edit-news-button'
import { Carousel } from 'web/components/widgets/carousel'
import { useUser } from 'web/hooks/use-user'
import { track } from 'web/lib/service/analytics'
import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page'
import { removeEmojis } from 'common/util/string'
import {isAdminId, isModId} from 'common/envs/constants'
import {type Headline} from 'common/news'
import {EditNewsButton} from 'web/components/news/edit-news-button'
import {Carousel} from 'web/components/widgets/carousel'
import {useUser} from 'web/hooks/use-user'
import {track} from 'web/lib/service/analytics'
import {DashboardEndpoints} from 'web/components/dashboard/dashboard-page'
import {removeEmojis} from 'common/util/string'
export function HeadlineTabs(props: {
headlines: Headline[]
@@ -50,20 +50,13 @@ export function HeadlineTabs(props: {
notSticky?: boolean
className?: string
}) {
const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } =
props
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} = props
const user = useUser()
return (
<div
className={clsx(
className,
'bg-canvas-50 w-full',
!notSticky && 'sticky top-0 z-50'
)}
>
<div className={clsx(className, 'bg-canvas-50 w-full', !notSticky && 'sticky top-0 z-50')}>
<Carousel labelsParentClassName="gap-px">
{headlines.map(({ id, slug, title }) => (
{headlines.map(({id, slug, title}) => (
<Tab
key={id}
label={hideEmoji ? removeEmojis(title) : title}
@@ -137,9 +130,7 @@ Here's the definition of usePersistentInMemoryState:
```ts
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
const [state, setState] = useStateCheckEquality<T>(
safeJsonParse(store[key]) ?? initialValue
)
const [state, setState] = useStateCheckEquality<T>(safeJsonParse(store[key]) ?? initialValue)
useEffect(() => {
const storedValue = safeJsonParse(store[key]) ?? initialValue
@@ -183,25 +174,19 @@ In `use-bets`, we have this hook to get live updates with useApiSubscription:
```ts
export const useContractBets = (
contractId: string,
opts?: APIParams<'bets'> & { enabled?: boolean }
opts?: APIParams<'bets'> & {enabled?: boolean},
) => {
const { enabled = true, ...apiOptions } = {
const {enabled = true, ...apiOptions} = {
contractId,
...opts,
}
const optionsKey = JSON.stringify(apiOptions)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
[],
`${optionsKey}-bets`
)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>([], `${optionsKey}-bets`)
const addBets = (bets: Bet[]) => {
setNewBets((currentBets) => {
const uniqueBets = sortBy(
uniqBy([...currentBets, ...bets], 'id'),
'createdTime'
)
const uniqueBets = sortBy(uniqBy([...currentBets, ...bets], 'id'), 'createdTime')
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
})
}
@@ -236,12 +221,12 @@ export function broadcastUpdatedPrivateUser(userId: string) {
broadcast(`private-user/${userId}`, {})
}
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
broadcast(`user/${user.id}`, { user })
export function broadcastUpdatedUser(user: Partial<User> & {id: string}) {
broadcast(`user/${user.id}`, {user})
}
export function broadcastUpdatedComment(comment: Comment) {
broadcast(`user/${comment.onUserId}/comment`, { comment })
broadcast(`user/${comment.onUserId}/comment`, {comment})
}
```
@@ -310,7 +295,7 @@ export const placeBet: APIHandler<'bet'> = async (props, auth) => {
const isApi = auth.creds.kind === 'key'
return await betsQueue.enqueueFn(
() => placeBetMain(props, auth.uid, isApi),
[props.contractId, auth.uid]
[props.contractId, auth.uid],
)
}
```
@@ -332,7 +317,7 @@ const handlers = {
We have two ways to access our postgres database.
```ts
import { db } from 'web/lib/supabase/db'
import {db} from 'web/lib/supabase/db'
db.from('profiles').select('*').eq('user_id', userId)
```
@@ -340,7 +325,7 @@ db.from('profiles').select('*').eq('user_id', userId)
and
```ts
import { createSupabaseDirectClient } from 'shared/supabase/init'
import {createSupabaseDirectClient} from 'shared/supabase/init'
const pg = createSupabaseDirectClient()
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [userId])
@@ -353,13 +338,10 @@ The supabase client just uses the supabase client library, which is a wrapper ar
Another example using the direct client:
```ts
export const getUniqueBettorIds = async (
contractId: string,
pg: SupabaseDirectClient
) => {
export const getUniqueBettorIds = async (contractId: string, pg: SupabaseDirectClient) => {
const res = await pg.manyOrNone(
'select distinct user_id from contract_bets where contract_id = $1',
[contractId]
[contractId],
)
return res.map((r) => r.user_id as string)
}
@@ -411,12 +393,12 @@ Example usage:
const query = renderSql(
select('distinct user_id'),
from('contract_bets'),
where('contract_id = ${id}', { id }),
where('contract_id = ${id}', {id}),
orderBy('created_time desc'),
limitValue != null && limit(limitValue)
limitValue != null && limit(limitValue),
)
const res = await pg.manyOrNone(query)
```
Use these functions instead of string concatenation.
Use these functions instead of string concatenation.

42
.windsurf/rules/next.md Normal file
View File

@@ -0,0 +1,42 @@
---
trigger: manual
description:
globs:
---
### Translations
```typescript
import {useT} from 'web/lib/locale'
const t = useT()
t('common.key', 'English translations')
```
Translations should go to the JSON files in `web/messages` (`de.json` and `fr.json`, as of now).
### Misc coding tips
We have many useful hooks that should be reused rather than rewriting them again.
---
We prefer using lodash functions instead of reimplementing them with for loops:
```ts
import {keyBy, uniq} from 'lodash'
const betsByUserId = keyBy(bets, 'userId')
const betIds = uniq(bets, (b) => b.id)
```
---
Instead of Sets, consider using lodash's uniq function:
```ts
const betIds = uniq([])
for (const id of betIds) {
...
}
```

View File

@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within

View File

@@ -7,15 +7,20 @@ We welcome pull requests, but only if they meet the project's quality and design
- Familiarity with Git and GitHub (basic commands, branching, forking, etc.)
- A functioning development environment
- Node.js, Python, or other relevant runtime/tools installed (check the `README.md`)
- Read the [Development Documentation](docs/development.md) for project-specific setup and guidelines (adding languages,
profile fields, etc.)
## Fork & Clone
1. **Fork the repository** using the GitHub UI.
2. **Clone your fork** locally:
```bash
git clone https://github.com/your-username/Compass.git
cd your-fork
```
3. **Add the upstream remote**:
```bash
@@ -93,27 +98,26 @@ Or whatever command is defined in the repo.
When opening a pull request:
* **Title**: Describe what the PR does, clearly and specifically.
* **Description**: Explain the context. Link related issues (use `Fixes #123` if applicable).
* **Checklist**:
* [ ] My code is clean and follows the style guide
* [ ] Ive added or updated tests
* [ ] Ive run all tests and they pass
* [ ] Ive documented my changes (if necessary)
- **Title**: Describe what the PR does, clearly and specifically.
- **Description**: Explain the context. Link related issues (use `Fixes #123` if applicable).
- **Checklist**:
- [ ] My code is clean and follows the style guide
- [ ] Ive added or updated tests
- [ ] Ive run all tests and they pass
- [ ] Ive documented my changes (if necessary)
## Code Review Process
* PRs are reviewed by maintainers or core contributors.
* If feedback is given, respond and push updates. Do **not** open new PRs for changes to an existing one.
* PRs that are incomplete, sloppy, or violate the above will be closed.
- PRs are reviewed by maintainers or core contributors.
- If feedback is given, respond and push updates. Do **not** open new PRs for changes to an existing one.
- PRs that are incomplete, sloppy, or violate the above will be closed.
## Don't Do This
* Dont commit directly to `main`
* Dont submit multiple unrelated changes in a single PR
* Dont ignore CI/test failures
* Dont expect hand-holding—read the docs and the source first
- Dont commit directly to `main`
- Dont submit multiple unrelated changes in a single PR
- Dont ignore CI/test failures
- Dont expect hand-holding—read the docs and the source first
## Security Issues
@@ -122,4 +126,3 @@ Do **not** open public issues for security vulnerabilities. Email the developmen
## License
By contributing, you agree that your code will be licensed under the same license as the rest of the project.

111
README.md
View File

@@ -1,10 +1,10 @@
![Vercel](https://deploy-badge.vercel.app/vercel/compass)
[![CD](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
[![CD API](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
[![CI](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
[![CI E2E](https://github.com/CompassConnections/Compass/actions/workflows/ci-e2e.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci-e2e.yml)
[![codecov](https://codecov.io/gh/CompassConnections/Compass/branch/main/graph/badge.svg)](https://codecov.io/gh/CompassConnections/Compass)
[![Users](https://img.shields.io/badge/Users-400%2B-blue?logo=myspace)](https://www.compassmeet.com/stats)
[![Users](https://img.shields.io/badge/Users-500%2B-blue?logo=myspace)](https://www.compassmeet.com/stats)
# Compass
@@ -33,14 +33,17 @@ No contribution is too small—whether its changing a color, resizing a butto
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
- Email hello@compassmeet.com
- Raise an issue on GitHub
If you want to add tasks without creating an account, you can simply email
```
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
```
Put the task title in the email subject and the task description in the email content.
Here is a tailored selection of things that would be very useful. If you want to help but dont know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
@@ -57,7 +60,7 @@ Here is a tailored selection of things that would be very useful. If you want to
- [ ] Cover more than 90% with tests (unit, integration, e2e)
- [x] Add Android mobile app
- [ ] Add iOS mobile app
- [ ] Add better onboarding (tooltips, modals, etc.)
- [x] Add better onboarding (tooltips, modals, etc.)
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
- [ ] Add calendar integration and scheduling
@@ -104,32 +107,38 @@ Below are the steps to contribute. If you have any trouble or questions, please
### Installation
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
```bash
git clone https://github.com/<your-username>/Compass.git
cd Compass
```
Install `yarn` (if not already installed):
```bash
npm install --global yarn
```
Then, install the dependencies for this project:
```bash
yarn install
yarn install --frozen-lockfile
```
### Tests
Make sure the tests pass:
Make sure the Jest tests pass:
```bash
yarn test
```
If they don't and you can't find out why, simply raise an issue! Sometimes it's something on our end that we overlooked.
### Running the Development Server
Start the development server:
```bash
yarn dev
```
@@ -138,11 +147,95 @@ Once the server is running, visit http://localhost:3000 to start using the app.
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it would be great to improve that though!
#### Full isolation
Running `yarn dev:isolated` spins up a local Supabase and Firebase emulator instead of pointing at the shared remote
database. This is strongly recommended for day-to-day development:
- **Freedom** — Reset, wipe, and reseed your local database as many times as you want without affecting other
contributors.
- **No conflicts** — Multiple contributors can work simultaneously without stepping on each other's data or schema.
- **Works offline** — No internet required once services are started locally.
- **Faster** — No network latency on every database query.
However, running in full isolation requires installing several heavy dependencies:
- **Docker** (~500MB) — runs the Supabase Postgres container
- **Supabase CLI** — manages the local Supabase stack (10+ Docker containers, ~1-2GB RAM when running)
- **Java 21+** (~300MB) — required by Firebase emulators
- **Firebase CLI** — manages the local Firebase emulators
First startup is slow (30-60s) and the stack uses significant memory. If your machine has less than 8GB RAM, you may
notice slowdowns.
If this feels like too much, you can skip isolation entirely — `yarn dev` works out of the box against the shared remote
and is perfectly fine for most contributions, especially UI changes, wording fixes, or anything that doesn't touch the
database or authentication.
###### Setup instructions
As always, don't hesitate to raise an issue if you run into any problems!
Docker
```bash
# Ubuntu/Debian (native - recommended over snap)
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# Log out and back in for group changes to take effect
# macOS
brew install --cask docker
# Or download from https://www.docker.com/products/docker-desktop
# Verify
docker --version
```
Supabase CLI
```bash
# Verify it got installed from `yarn install`
npx supabase --version
```
Java 21+
```bash
# Ubuntu/Debian
sudo apt install openjdk-21-jdk
# macOS
brew install openjdk@21
# Verify (must be 21+)
java -version
```
Firebase CLI
```bash
npm install -g firebase-tools
# Verify
firebase --version
```
Run in isolation
```bash
yarn dev:isolated
```
Visit `http://localhost:3000` as usual. Your local database comes preloaded with synthetic test profiles so the app
looks and feels like the real thing.
### Contributing
Now you can start contributing by making changes and submitting pull requests!
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
- Console tab for errors and logs
- Network tab to see the requests and responses
@@ -155,9 +248,11 @@ If you are new to Typescript or the open-source space, you could start with smal
##### Resources
There is a lof of documentation in the [docs](docs) folder and across the repo, namely:
- [Next.js.md](docs/Next.js.md) for core fundamentals about our web / page-rendering framework.
- [knowledge.md](docs/knowledge.md) for general information about the project structure.
- [development.md](docs/development.md) for additional instructions, such as adding new profile fields or languages.
- [TESTING.md](docs/TESTING.md) for how to write tests.
- [web](web) for the web.
- [backend/api](backend/api) for the backend API.
- [android](android) for the Android app.
@@ -167,22 +262,26 @@ There are a lot of useful scripts you can use in the [scripts](scripts) folder.
### Submission
Add the original repo as upstream for syncing:
```bash
git remote add upstream https://github.com/CompassConnections/Compass.git
```
Create a new branch for your changes:
```bash
git checkout -b <branch-name>
```
Make changes, then stage and commit:
```bash
git add .
git commit -m "Describe your changes"
```
Push branch to your fork:
```bash
git push origin <branch-name>
```
@@ -194,6 +293,7 @@ Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `Compa
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the following services: email, geolocation.
We can't make the following information public, for security and privacy reasons:
- Database, otherwise anyone could access all the user data (including private messages)
- Firebase, otherwise anyone could remove users or modify the media files
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
@@ -204,4 +304,5 @@ Contributors should use the default keys for local development. Production uses
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
## Acknowledgements
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.

View File

@@ -9,4 +9,3 @@
## Reporting a Vulnerability
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.

View File

@@ -8,8 +8,8 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 18
versionName "1.1.6"
versionCode 39
versionName "1.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -52,6 +52,9 @@ dependencies {
implementation 'com.google.android.gms:play-services-auth:21.4.0'
implementation 'com.google.firebase:firebase-auth:24.0.1'
implementation 'com.google.android.play:app-update:2.1.0'
implementation 'com.google.android.play:app-update-ktx:2.1.0'
}
apply from: 'capacitor.build.gradle'

View File

@@ -8,6 +8,7 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:fitsSystemWindows="true"
android:requestLegacyExternalStorage="true"
android:theme="@style/AppTheme">
<activity
@@ -65,6 +66,7 @@
<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="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />

View File

@@ -1,17 +1,26 @@
package com.compassconnections.app;
import android.Manifest;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin;
@@ -19,54 +28,26 @@ import com.getcapacitor.BridgeActivity;
import com.getcapacitor.BridgeWebViewClient;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginHandle;
import com.google.android.play.core.appupdate.AppUpdateInfo;
import com.google.android.play.core.appupdate.AppUpdateManager;
import com.google.android.play.core.appupdate.AppUpdateManagerFactory;
import com.google.android.play.core.appupdate.AppUpdateOptions;
import com.google.android.play.core.install.model.AppUpdateType;
import com.google.android.play.core.install.model.UpdateAvailability;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
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
@@ -91,13 +72,107 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
}
}
public static class NativeBridge {
@JavascriptInterface
public boolean isNativeApp() {
return true;
public class WebAppInterface {
private final Context context;
public WebAppInterface(Context context) {
this.context = context;
}
@JavascriptInterface
public void downloadFile(String filename, String content) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+ (API 29+) - Use MediaStore
downloadFileModern(filename, content);
} else {
// Android 9 and below - Use legacy method
downloadFileLegacy(filename, content);
}
// Show success message
runOnUiThread(() ->
Toast.makeText(MainActivity.this, "File downloaded: " + filename, Toast.LENGTH_SHORT).show()
);
} catch (IOException e) {
Log.e("CompassApp", "Failed to download file", e);
runOnUiThread(() ->
Toast.makeText(MainActivity.this, "Download failed: " + e.getMessage(), Toast.LENGTH_SHORT).show()
);
}
}
// For Android 10+ (Scoped Storage)
@RequiresApi(api = Build.VERSION_CODES.Q)
private void downloadFileModern(String filename, String content) throws IOException {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(filename));
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
if (uri != null) {
try (OutputStream outputStream = resolver.openOutputStream(uri)) {
if (outputStream != null) {
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
}
}
}
}
// For Android 9 and below
private void downloadFileLegacy(String filename, String content) throws IOException {
File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
if (!downloadsDir.exists()) {
downloadsDir.mkdirs();
}
File file = getUniqueFile(downloadsDir, filename);
try (FileOutputStream fos = new FileOutputStream(file)) {
fos.write(content.getBytes(StandardCharsets.UTF_8));
}
MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);
}
private File getUniqueFile(File directory, String filename) {
File file = new File(directory, filename);
if (!file.exists()) {
return file;
}
// Add number suffix if file exists
String name = filename.substring(0, filename.lastIndexOf("."));
String extension = filename.substring(filename.lastIndexOf("."));
int counter = 1;
while (file.exists()) {
file = new File(directory, name + "(" + counter + ")" + extension);
counter++;
}
return file;
}
// Helper method to determine MIME type
private String getMimeType(String filename) {
String extension = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
return switch (extension) {
case "txt" -> "text/plain";
case "pdf" -> "application/pdf";
case "json" -> "application/json";
case "csv" -> "text/csv";
case "html" -> "text/html";
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
default -> "application/octet-stream";
};
}
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
@@ -134,7 +209,7 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
settings.setUserAgentString(settings.getUserAgentString() + " CompassAppWebView");
settings.setJavaScriptEnabled(true);
webView.addJavascriptInterface(new NativeBridge(), "AndroidBridge");
webView.addJavascriptInterface(new WebAppInterface(this), "AndroidBridge");
registerPlugin(PushNotificationsPlugin.class);
// Initialize the Bridge with Push Notifications plugin
@@ -143,6 +218,9 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
// }});
askNotificationPermission();
appUpdateManager = AppUpdateManagerFactory.create(this);
checkForUpdates();
}
@Override
@@ -169,5 +247,69 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
@Override
public void IHaveModifiedTheMainActivityForTheUseWithSocialLoginPlugin() {
}
private static final int UPDATE_REQUEST_CODE = 500;
private AppUpdateManager appUpdateManager;
private static final String TAG = "MainActivity";
private void checkForUpdates() {
appUpdateManager.getAppUpdateInfo()
.addOnSuccessListener(appUpdateInfo -> {
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) {
startImmediateUpdate(appUpdateInfo);
} else if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
startFlexibleUpdate(appUpdateInfo);
}
}
})
.addOnFailureListener(exception -> {
// Handle error - log it
Log.e(TAG, "Failed to check For Updates", exception);
});
}
private void startImmediateUpdate(AppUpdateInfo appUpdateInfo) {
AppUpdateOptions options = AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build();
appUpdateManager.startUpdateFlow(appUpdateInfo, this, options)
.addOnSuccessListener(result -> {
Log.i(TAG, "Immediate update started successfully");
})
.addOnFailureListener(exception -> {
Log.e(TAG, "Failed to start immediate update", exception);
});
}
private void startFlexibleUpdate(AppUpdateInfo appUpdateInfo) {
AppUpdateOptions options = AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build();
appUpdateManager.startUpdateFlow(appUpdateInfo, this, options)
.addOnSuccessListener(result -> {
Log.i(TAG, "Flexible update started successfully");
})
.addOnFailureListener(exception -> {
Log.e(TAG, "Failed to start flexible update", exception);
});
}
@Override
public void onResume() {
super.onResume();
// Check if an immediate update was interrupted
appUpdateManager.getAppUpdateInfo().addOnSuccessListener(appUpdateInfo -> {
if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
startImmediateUpdate(appUpdateInfo);
}
});
}
@Override
public void onDestroy() {
super.onDestroy();
appUpdateManager = null;
}
}

View File

@@ -1,3 +1,3 @@
{
"version": 23
"version": 24
}

View File

@@ -1,7 +1,7 @@
module.exports = {
plugins: ['lodash', 'unused-imports'],
plugins: ['lodash', 'unused-imports', 'simple-import-sort'],
extends: ['eslint:recommended'],
ignorePatterns: ['dist', 'lib'],
ignorePatterns: ['dist', 'lib', 'coverage'],
env: {
node: true,
},
@@ -16,15 +16,9 @@ module.exports = {
project: ['./tsconfig.json', './tsconfig.test.json'],
},
rules: {
'@typescript-eslint/ban-types': [
'error',
{
extendDefaults: true,
types: {
'{}': false,
},
},
],
'@typescript-eslint/no-empty-object-type': 'error', // replaces banning {}
'@typescript-eslint/no-unsafe-function-type': 'error', // replaces banning Function
'@typescript-eslint/no-wrapper-object-types': 'error', // replaces banning String, Number, etc.
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-extra-semi': 'off',
'@typescript-eslint/no-unused-vars': [
@@ -41,10 +35,9 @@ module.exports = {
},
],
rules: {
'linebreak-style': [
'error',
process.platform === 'win32' ? 'windows' : 'unix',
],
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
'lodash/import-scope': [2, 'member'],
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
},
}

View File

@@ -0,0 +1,34 @@
# Dependencies
node_modules
.yarn
# Build outputs
dist
build
.next
out
lib
# Generated files
coverage
*.min.js
*.min.css
# Database / migrations
**/*.sql
# Config / lock files
yarn.lock
package-lock.json
pnpm-lock.yaml
# Android / iOS
android
ios
capacitor.config.ts
# Playwright
tests/reports
playwright-report
coverage

View File

@@ -28,9 +28,11 @@ gcloud config set project YOUR_PROJECT_ID
```
You also need `opentofu` and `docker`. Try running this (from root) on Linux or macOS for a faster install:
```bash
./script/setup.sh
```
If it doesn't work, you can install them manually (google how to install `opentofu` and `docker` for your OS).
### Setup
@@ -105,8 +107,8 @@ gcloud iam service-accounts keys create keyfile.json --iam-account=ci-deployer@c
##### DNS
* After deployment, Terraform assigns a static external IP to this resource.
* You can get it manually:
- After deployment, Terraform assigns a static external IP to this resource.
- You can get it manually:
```bash
gcloud compute addresses describe api-lb-ip-2 --global --format="get(address)"
@@ -120,11 +122,11 @@ Since Vercel manages your domain (`compassmeet.com`):
3. Add an **A record** for your API subdomain:
| Type | Name | Value | TTL |
|------|------|--------------|-------|
| ---- | ---- | ------------ | ----- |
| A | api | 34.123.45.67 | 600 s |
* `Name` is just the subdomain: `api``api.compassmeet.com`.
* `Value` is the **external IP of the LB** from step 1.
- `Name` is just the subdomain: `api``api.compassmeet.com`.
- `Value` is the **external IP of the LB** from step 1.
Verify connectivity
From your local machine:
@@ -135,8 +137,8 @@ ping -c 3 api.compassmeet.com
curl -I https://api.compassmeet.com
```
* `nslookup` should return the LB IP (`34.123.45.67`).
* `curl -I` should return `200 OK` from your service.
- `nslookup` should return the LB IP (`34.123.45.67`).
- `curl -I` should return `200 OK` from your service.
If SSL isnt ready (may take 15 mins), check LB logs:
@@ -167,6 +169,7 @@ In root directory, run the local api with hot reload, along with all the other b
To deploy the backend code, simply increment the version number in [package.json](package.json) and push to the `main` branch.
Or if you have access to the project on google cloud, run in this directory:
```bash
./deploy-api.sh prod
```
@@ -195,4 +198,5 @@ docker rmi -f $(docker images -aq)
The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts).
### Todo (Tests)
- [ ] Finish get-supabase-token unit test when endpoint is implemented
- [ ] Finish get-supabase-token unit test when endpoint is implemented

View File

@@ -1,21 +1,21 @@
module.exports = {
apps: [
{
name: "api",
script: "node",
args: "--dns-result-order=ipv4first backend/api/lib/serve.js",
env: {
NODE_ENV: "production",
NODE_PATH: "/usr/src/app/node_modules", // <- ensures Node finds tsconfig-paths
PORT: 80,
},
instances: 1,
exec_mode: "fork",
autorestart: true,
watch: false,
// 4 GB on the box, give 3 GB to the JS heap
node_args: "--max-old-space-size=3072",
max_memory_restart: "3500M"
}
]
};
apps: [
{
name: 'api',
script: 'node',
args: '--dns-result-order=ipv4first backend/api/lib/serve.js',
env: {
NODE_ENV: 'production',
NODE_PATH: '/usr/src/app/node_modules', // <- ensures Node finds tsconfig-paths
PORT: 80,
},
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
// 4 GB on the box, give 3 GB to the JS heap
node_args: '--max-old-space-size=3072',
max_memory_restart: '3500M',
},
],
}

View File

@@ -1,31 +1,28 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: [
"<rootDir>/tests/**/*.test.ts",
"<rootDir>/tests/**/*.spec.ts"
rootDir: '.',
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.spec.ts'],
moduleNameMapper: {
'^api/(.*)$': '<rootDir>/src/$1',
'^shared/(.*)$': '<rootDir>/../shared/src/$1',
'^common/(.*)$': '<rootDir>/../../common/src/$1',
'^email/(.*)$': '<rootDir>/../email/emails/$1',
},
moduleFileExtensions: ['tsx', 'ts', 'js', 'json'],
clearMocks: true,
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.test.json',
},
],
},
moduleNameMapper: {
"^api/(.*)$": "<rootDir>/src/$1",
"^shared/(.*)$": "<rootDir>/../shared/src/$1",
"^common/(.*)$": "<rootDir>/../../common/src/$1",
"^email/(.*)$": "<rootDir>/../email/emails/$1"
},
moduleFileExtensions: ["tsx","ts", "js", "json"],
clearMocks: true,
globals: {
'ts-jest': {
tsconfig: "<rootDir>/tsconfig.test.json"
}
},
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts"
],
};
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
}

View File

@@ -1,7 +1,7 @@
{
"name": "@compass/api",
"description": "Backend API endpoints",
"version": "1.0.14",
"version": "1.12.0",
"private": true,
"scripts": {
"watch:serve": "tsx watch src/serve.ts",
@@ -17,8 +17,9 @@
"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 && cp metadata.json dist && cp metadata.json dist/backend/api",
"watch": "tsc -w",
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
"lint": "npx eslint . --max-warnings 0",
"lint-fix": "npx eslint . --fix",
"typecheck": "yarn build && npx tsc --noEmit",
"regen-types": "cd ../supabase && make ENV=prod regen-types",
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev",
"test": "jest --config jest.config.js",
@@ -52,9 +53,9 @@
"firebase-admin": "13.5.0",
"gcp-metadata": "6.1.0",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.21",
"lodash": "4.17.23",
"openapi-types": "12.1.3",
"pg-promise": "11.4.1",
"pg-promise": "11.5.5",
"posthog-node": "4.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",

View File

@@ -1,81 +1,93 @@
import {API, type APIPath} from 'common/api/schema'
import {APIError, pathWithPrefix} from 'common/api/utils'
import cors from 'cors'
import * as crypto from 'crypto'
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
import path from 'node:path'
import {hrtime} from 'node:process'
import {withMonitoringContext} from 'shared/monitoring/context'
import {log} from 'shared/monitoring/log'
import {metrics} from 'shared/monitoring/metrics'
import {banUser} from './ban-user'
import {blockUser, unblockUser} from './block-user'
import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {setCompatibilityAnswer} from './set-compatibility-answer'
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
import {getLikesAndShips} from './get-likes-and-ships'
import {getProfileAnswers} from './get-profile-answers'
import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
import {getMe} from './get-me'
import {hasFreeLike} from './has-free-like'
import {health} from './health'
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
import {hideComment} from './hide-comment'
import {likeProfile} from './like-profile'
import {markAllNotifsRead} from './mark-all-notifications-read'
import {removePinnedPhoto} from './remove-pinned-photo'
import {report} from './report'
import {searchLocation} from './search-location'
import {searchNearCity} from './search-near-city'
import {shipProfiles} from './ship-profiles'
import {starProfile} from './star-profile'
import {updateProfile} from './update-profile'
import {updateMe} from './update-me'
import {deleteMe} from './delete-me'
import {getCurrentPrivateUser} from './get-current-private-user'
import {createPrivateUserMessage} from './create-private-user-message'
import {contact} from 'api/contact'
import {createVote} from 'api/create-vote'
import {deleteMessage} from 'api/delete-message'
import {editMessage} from 'api/edit-message'
import {getHiddenProfiles} from 'api/get-hidden-profiles'
import {getMessagesCount} from 'api/get-messages-count'
import {getOptions} from 'api/get-options'
import {
getChannelMemberships,
getChannelMessagesEndpoint,
getLastSeenChannelTime,
setChannelLastSeenTime,
} from 'api/get-private-messages'
import {searchUsers} from './search-users'
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {getNotifications} from './get-notifications'
import {updateNotifSettings} from './update-notif-setting'
import {setLastOnlineTime} from './set-last-online-time'
import swaggerUi from "swagger-ui-express"
import {sendSearchNotifications} from "api/send-search-notifications";
import {sendDiscordMessage} from "common/discord/core";
import {getMessagesCount} from "api/get-messages-count";
import {createVote} from "api/create-vote";
import {vote} from "api/vote";
import {contact} from "api/contact";
import {saveSubscription} from "api/save-subscription";
import {createBookmarkedSearch} from './create-bookmarked-search'
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
import {OpenAPIV3} from 'openapi-types';
import {version as pkgVersion} from './../package.json'
import {getUser} from 'api/get-user'
import {hideProfile} from 'api/hide-profile'
import {reactToMessage} from 'api/react-to-message'
import {saveSubscription} from 'api/save-subscription'
import {saveSubscriptionMobile} from 'api/save-subscription-mobile'
import {sendSearchNotifications} from 'api/send-search-notifications'
import {localSendTestEmail} from 'api/test'
import {unhideProfile} from 'api/unhide-profile'
import {updateOptions} from 'api/update-options'
import {vote} from 'api/vote'
import {API, type APIPath} from 'common/api/schema'
import {APIError, pathWithPrefix} from 'common/api/utils'
import {sendDiscordMessage} from 'common/discord/core'
import {IS_LOCAL} from 'common/hosting/constants'
import cors from 'cors'
import * as crypto from 'crypto'
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
import {OpenAPIV3} from 'openapi-types'
import {withMonitoringContext} from 'shared/monitoring/context'
import {log} from 'shared/monitoring/log'
import {metrics} from 'shared/monitoring/metrics'
import swaggerUi from 'swagger-ui-express'
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from 'zod'
import {git} from './../metadata.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";
import {updateOptions} from "api/update-options";
import {getOptions} from "api/get-options";
import {version as pkgVersion} from './../package.json'
import {banUser} from './ban-user'
import {blockUser, unblockUser} from './block-user'
import {cancelEvent} from './cancel-event'
import {cancelRsvp} from './cancel-rsvp'
import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createBookmarkedSearch} from './create-bookmarked-search'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {createEvent} from './create-event'
import {createPrivateUserMessage} from './create-private-user-message'
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
import {deleteMe} from './delete-me'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
import {getCurrentPrivateUser} from './get-current-private-user'
import {getEvents} from './get-events'
import {getLikesAndShips} from './get-likes-and-ships'
import {getMe} from './get-me'
import {getNotifications} from './get-notifications'
import {getProfileAnswers} from './get-profile-answers'
import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
import {getUserDataExport} from './get-user-data-export'
import {hasFreeLike} from './has-free-like'
import {health} from './health'
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
import {hideComment} from './hide-comment'
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
import {likeProfile} from './like-profile'
import {markAllNotifsRead} from './mark-all-notifications-read'
import {removePinnedPhoto} from './remove-pinned-photo'
import {report} from './report'
import {rsvpEvent} from './rsvp-event'
import {searchLocation} from './search-location'
import {searchNearCity} from './search-near-city'
import {searchUsers} from './search-users'
import {setCompatibilityAnswer} from './set-compatibility-answer'
import {setLastOnlineTime} from './set-last-online-time'
import {shipProfiles} from './ship-profiles'
import {starProfile} from './star-profile'
import {updateEvent} from './update-event'
import {updateMe} from './update-me'
import {updateNotifSettings} from './update-notif-setting'
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {updateProfile} from './update-profile'
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
@@ -94,9 +106,7 @@ function cacheController(policy?: string): RequestHandler {
const requestMonitoring: RequestHandler = (req, _res, next) => {
const traceContext = req.get('X-Cloud-Trace-Context')
const traceId = traceContext
? traceContext.split('/')[0]
: crypto.randomUUID()
const traceId = traceContext ? traceContext.split('/')[0] : crypto.randomUUID()
const context = {endpoint: req.path, traceId}
withMonitoringContext(context, () => {
const startTs = hrtime.bigint()
@@ -113,7 +123,7 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
if (error instanceof APIError) {
log.info(error)
if (!res.headersSent) {
const output: { [k: string]: unknown } = {message: error.message}
const output: {[k: string]: unknown} = {message: error.message}
if (error.details != null) {
output.details = error.details
}
@@ -130,92 +140,91 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
export const app = express()
app.use(requestMonitoring)
const schemaCache = new WeakMap<ZodTypeAny, any>()
const schemaCache = new WeakMap<ZodTypeAny, any>();
export function zodToOpenApiSchema(zodObj: ZodTypeAny,): any {
export function zodToOpenApiSchema(zodObj: ZodTypeAny): any {
if (schemaCache.has(zodObj)) {
return schemaCache.get(zodObj);
return schemaCache.get(zodObj)
}
const def: any = (zodObj as any)._def;
const typeName = def.typeName as ZodFirstPartyTypeKind;
const def: any = (zodObj as any)._def
const typeName = def.typeName as ZodFirstPartyTypeKind
// Placeholder so recursive references can point here
const placeholder: any = {};
schemaCache.set(zodObj, placeholder);
const placeholder: any = {}
schemaCache.set(zodObj, placeholder)
let schema: any;
let schema: any
switch (typeName) {
case 'ZodString':
schema = {type: 'string'};
break;
schema = {type: 'string'}
break
case 'ZodNumber':
schema = {type: 'number'};
break;
schema = {type: 'number'}
break
case 'ZodBoolean':
schema = {type: 'boolean'};
break;
schema = {type: 'boolean'}
break
case 'ZodEnum':
schema = {type: 'string', enum: def.values};
break;
schema = {type: 'string', enum: def.values}
break
case 'ZodArray':
schema = {type: 'array', items: zodToOpenApiSchema(def.type)};
break;
schema = {type: 'array', items: zodToOpenApiSchema(def.type)}
break
case 'ZodObject': {
const shape = def.shape();
const properties: Record<string, any> = {};
const required: string[] = [];
const shape = def.shape()
const properties: Record<string, any> = {}
const required: string[] = []
for (const key in shape) {
const child = shape[key];
properties[key] = zodToOpenApiSchema(child);
if (!child.isOptional()) required.push(key);
const child = shape[key]
properties[key] = zodToOpenApiSchema(child)
if (!child.isOptional()) required.push(key)
}
schema = {
type: 'object',
properties,
...(required.length ? {required} : {}),
};
break;
}
break
}
case 'ZodRecord':
schema = {
type: 'object',
additionalProperties: zodToOpenApiSchema(def.valueType),
};
break;
}
break
case 'ZodIntersection': {
const left = zodToOpenApiSchema(def.left);
const right = zodToOpenApiSchema(def.right);
schema = {allOf: [left, right]};
break;
const left = zodToOpenApiSchema(def.left)
const right = zodToOpenApiSchema(def.right)
schema = {allOf: [left, right]}
break
}
case 'ZodLazy':
schema = {type: 'object', description: 'Lazy schema - details omitted'};
break;
schema = {type: 'object', description: 'Lazy schema - details omitted'}
break
case 'ZodUnion':
schema = {
oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)),
};
break;
}
break
default:
schema = {type: 'string'}; // fallback for unhandled
schema = {type: 'string'} // fallback for unhandled
}
Object.assign(placeholder, schema);
return schema;
Object.assign(placeholder, schema)
return schema
}
function generateSwaggerPaths(api: typeof API) {
const paths: Record<string, any> = {};
const paths: Record<string, any> = {}
for (const [route, config] of Object.entries(api)) {
const pathKey = '/' + route.replace(/_/g, '-'); // optional: convert underscores to dashes
const method = config.method.toLowerCase();
const summary = (config as any).summary ?? route;
const pathKey = '/' + route.replace(/_/g, '-') // optional: convert underscores to dashes
const method = config.method.toLowerCase()
const summary = (config as any).summary ?? route
// Include props in request body for POST/PUT
const operation: any = {
@@ -231,7 +240,7 @@ function generateSwaggerPaths(api: typeof API) {
},
},
},
};
}
// Include props in request body for POST/PUT
if (config.props && ['post', 'put', 'patch'].includes(method)) {
@@ -242,26 +251,26 @@ function generateSwaggerPaths(api: typeof API) {
schema: zodToOpenApiSchema(config.props),
},
},
};
}
}
// Include props as query parameters for GET/DELETE
if (config.props && ['get', 'delete'].includes(method)) {
const shape = (config.props as z.ZodObject<any>)._def.shape();
const shape = (config.props as z.ZodObject<any>)._def.shape()
operation.parameters = Object.entries(shape).map(([key, zodType]) => {
const typeMap: Record<string, string> = {
ZodString: 'string',
ZodNumber: 'number',
ZodBoolean: 'boolean',
};
const t = zodType as z.ZodTypeAny; // assert type to ZodTypeAny
}
const t = zodType as z.ZodTypeAny // assert type to ZodTypeAny
return {
name: key,
in: 'query',
required: !(t.isOptional ?? false),
schema: {type: typeMap[t._def.typeName] ?? 'string'},
};
});
}
})
}
paths[pathKey] = {
@@ -269,25 +278,24 @@ function generateSwaggerPaths(api: typeof API) {
}
if (config.authed) {
operation.security = [{BearerAuth: []}];
operation.security = [{BearerAuth: []}]
}
}
return paths;
return paths
}
const swaggerDocument: OpenAPIV3.Document = {
openapi: "3.0.0",
openapi: '3.0.0',
info: {
title: "Compass API",
title: 'Compass API',
description: `Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Its made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.\n Git: ${git.commitDate} (${git.revision}).`,
version: pkgVersion,
contact: {
name: "Compass",
email: "hello@compassmeet.com",
url: "https://compassmeet.com"
}
name: 'Compass',
email: 'hello@compassmeet.com',
url: 'https://compassmeet.com',
},
},
paths: generateSwaggerPaths(API),
components: {
@@ -303,81 +311,90 @@ const swaggerDocument: OpenAPIV3.Document = {
name: 'x-api-key',
},
},
}
} as OpenAPIV3.Document;
},
} as OpenAPIV3.Document
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
// May not be necessary
// app.options('*', allowCorsUnrestricted)
const handlers: { [k in APIPath]: APIHandler<k> } = {
health: health,
'get-supabase-token': getSupabaseToken,
'get-notifications': getNotifications,
'mark-all-notifs-read': markAllNotifsRead,
// 'user/:username': getUser,
// 'user/:username/lite': getDisplayUser,
'user/by-id/:id': getUser,
// 'user/by-id/:id/lite': getDisplayUser,
'user/by-id/:id/block': blockUser,
'user/by-id/:id/unblock': unblockUser,
'search-users': searchUsers,
const handlers: {[k in APIPath]: APIHandler<k>} = {
'ban-user': banUser,
report: report,
'create-user': createUser,
'create-profile': createProfile,
me: getMe,
'me/private': getCurrentPrivateUser,
'me/update': updateMe,
'update-notif-settings': updateNotifSettings,
'me/delete': deleteMe,
'update-profile': updateProfile,
'like-profile': likeProfile,
'ship-profiles': shipProfiles,
'get-likes-and-ships': getLikesAndShips,
'has-free-like': hasFreeLike,
'star-profile': starProfile,
'get-profiles': getProfiles,
'get-profile-answers': getProfileAnswers,
'get-compatibility-questions': getCompatibilityQuestions,
'remove-pinned-photo': removePinnedPhoto,
'create-comment': createComment,
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'set-compatibility-answer': setCompatibilityAnswer,
'delete-compatibility-answer': deleteCompatibilityAnswer,
'create-vote': createVote,
'vote': vote,
'contact': contact,
'compatible-profiles': getCompatibleProfilesHandler,
'search-location': searchLocation,
'search-near-city': searchNearCity,
contact: contact,
'create-bookmarked-search': createBookmarkedSearch,
'create-comment': createComment,
'create-compatibility-question': createCompatibilityQuestion,
'create-private-user-message': createPrivateUserMessage,
'create-private-user-message-channel': createPrivateUserMessageChannel,
'update-private-user-message-channel': updatePrivateUserMessageChannel,
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
'create-profile': createProfile,
'create-user': createUser,
'create-vote': createVote,
'delete-bookmarked-search': deleteBookmarkedSearch,
'delete-compatibility-answer': deleteCompatibilityAnswer,
'delete-message': deleteMessage,
'edit-message': editMessage,
'get-channel-memberships': getChannelMemberships,
'get-channel-messages': getChannelMessagesEndpoint,
'get-channel-seen-time': getLastSeenChannelTime,
'set-channel-seen-time': setChannelLastSeenTime,
'get-compatibility-questions': getCompatibilityQuestions,
'get-likes-and-ships': getLikesAndShips,
'get-messages-count': getMessagesCount,
'set-last-online-time': setLastOnlineTime,
'get-notifications': getNotifications,
'get-options': getOptions,
'get-profile-answers': getProfileAnswers,
'get-profiles': getProfiles,
'get-supabase-token': getSupabaseToken,
'has-free-like': hasFreeLike,
'hide-comment': hideComment,
'hide-profile': hideProfile,
'unhide-profile': unhideProfile,
'get-hidden-profiles': getHiddenProfiles,
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
'like-profile': likeProfile,
'mark-all-notifs-read': markAllNotifsRead,
'me/delete': deleteMe,
'me/data': getUserDataExport,
'me/private': getCurrentPrivateUser,
'me/update': updateMe,
'react-to-message': reactToMessage,
'remove-pinned-photo': removePinnedPhoto,
'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,
'search-location': searchLocation,
'search-near-city': searchNearCity,
'search-users': searchUsers,
'set-channel-seen-time': setChannelLastSeenTime,
'set-compatibility-answer': setCompatibilityAnswer,
'set-last-online-time': setLastOnlineTime,
'ship-profiles': shipProfiles,
'star-profile': starProfile,
'update-notif-settings': updateNotifSettings,
'update-options': updateOptions,
'get-options': getOptions,
// 'auth-google': authGoogle,
'update-private-user-message-channel': updatePrivateUserMessageChannel,
'update-profile': updateProfile,
'user/by-id/:id': getUser,
'user/by-id/:id/block': blockUser,
'user/by-id/:id/unblock': unblockUser,
vote: vote,
// 'user/:username': getUser,
// 'user/:username/lite': getDisplayUser,
// 'user/by-id/:id/lite': getDisplayUser,
'cancel-event': cancelEvent,
'cancel-rsvp': cancelRsvp,
'create-event': createEvent,
'get-events': getEvents,
'rsvp-event': rsvpEvent,
'update-event': updateEvent,
health: health,
me: getMe,
report: report,
}
Object.entries(handlers).forEach(([path, handler]) => {
const api = API[path as APIPath]
const cache = cacheController((api as any).cache)
const url = pathWithPrefix('/' + path as APIPath)
const url = pathWithPrefix(('/' + path) as APIPath)
const apiRoute = [
url,
@@ -400,75 +417,73 @@ Object.entries(handlers).forEach(([path, handler]) => {
})
// Internal Endpoints
app.post(pathWithPrefix("/internal/send-search-notifications"),
async (req, res) => {
const apiKey = req.header("x-api-key");
if (apiKey !== process.env.COMPASS_API_KEY) {
return res.status(401).json({error: "Unauthorized"});
}
try {
const result = await sendSearchNotifications()
return res.status(200).json(result)
} catch (err) {
console.error("Failed to send notifications:", err);
await sendDiscordMessage(
"Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...",
"health"
)
return res.status(500).json({error: "Internal server error"});
}
app.post(pathWithPrefix('/internal/send-search-notifications'), async (req, res) => {
const apiKey = req.header('x-api-key')
if (apiKey !== process.env.COMPASS_API_KEY) {
return res.status(401).json({error: 'Unauthorized'})
}
);
try {
const result = await sendSearchNotifications()
return res.status(200).json(result)
} catch (err) {
console.error('Failed to send notifications:', err)
await sendDiscordMessage(
'Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...',
'health',
)
return res.status(500).json({error: 'Internal server error'})
}
})
const responses = {
200: {
description: "Request successful",
description: 'Request successful',
content: {
"application/json": {
'application/json': {
schema: {
type: "object",
type: 'object',
properties: {
status: {type: "string", example: "success"}
status: {type: 'string', example: 'success'},
},
},
},
},
},
401: {
description: "Unauthorized (e.g., invalid or missing API key)",
description: 'Unauthorized (e.g., invalid or missing API key)',
content: {
"application/json": {
'application/json': {
schema: {
type: "object",
type: 'object',
properties: {
error: {type: "string", example: "Unauthorized"},
error: {type: 'string', example: 'Unauthorized'},
},
},
},
},
},
500: {
description: "Internal server error during request processing",
description: 'Internal server error during request processing',
content: {
"application/json": {
'application/json': {
schema: {
type: "object",
type: 'object',
properties: {
error: {type: "string", example: "Internal server error"},
error: {type: 'string', example: 'Internal server error'},
},
},
},
},
},
};
}
swaggerDocument.paths["/internal/send-search-notifications"] = {
swaggerDocument.paths['/internal/send-search-notifications'] = {
post: {
summary: "Trigger daily search notifications",
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"],
'Internal endpoint used by Compass schedulers to send daily notifications for bookmarked searches. Requires a valid `x-api-key` header.',
tags: ['Internal'],
security: [
{
ApiKeyAuth: [],
@@ -481,28 +496,25 @@ swaggerDocument.paths["/internal/send-search-notifications"] = {
},
} 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});
}
app.post(pathWithPrefix('/local/send-test-email'), async (req, res) => {
if (!IS_LOCAL) {
return res.status(401).json({error: 'Unauthorized'})
}
);
swaggerDocument.paths["/local/send-test-email"] = {
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"],
summary: 'Send a test email',
description: 'Local endpoint to send a test email.',
tags: ['Local'],
requestBody: {
required: false,
},
@@ -511,8 +523,7 @@ if (IS_LOCAL) {
} as any
}
const rootPath = pathWithPrefix("/")
const rootPath = pathWithPrefix('/')
app.get(
rootPath,
swaggerUi.setup(swaggerDocument, {
@@ -528,7 +539,7 @@ app.get(
)
app.use(rootPath, swaggerUi.serve)
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.static(path.join(__dirname, 'public')))
app.use(allowCorsUnrestricted, (req, res) => {
if (req.method === 'OPTIONS') {

View File

@@ -1,13 +1,13 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { trackPublicEvent } from 'shared/analytics'
import { throwErrorIfNotMod } from 'shared/helpers/auth'
import { isAdminId } from 'common/envs/constants'
import { log } from 'shared/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { updateUser } from 'shared/supabase/users'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {trackPublicEvent} from 'shared/analytics'
import {throwErrorIfNotMod} from 'shared/helpers/auth'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {log} from 'shared/utils'
export const banUser: APIHandler<'ban-user'> = async (body, auth) => {
const { userId, unban } = body
const {userId, unban} = body
const db = createSupabaseDirectClient()
await throwErrorIfNotMod(auth.uid)
if (isAdminId(userId)) throw new APIError(403, 'Cannot ban admin')

View File

@@ -1,12 +1,10 @@
import { APIError, APIHandler } from './helpers/endpoint'
import { FieldVal } from 'shared/supabase/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { updatePrivateUser } from 'shared/supabase/users'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updatePrivateUser} from 'shared/supabase/users'
import {FieldVal} from 'shared/supabase/utils'
export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
{ id },
auth
) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const blockUser: APIHandler<'user/by-id/:id/block'> = async ({id}, auth) => {
if (auth.uid === id) throw new APIError(400, 'You cannot block yourself')
const pg = createSupabaseDirectClient()
@@ -20,10 +18,7 @@ export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
})
}
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async (
{ id },
auth
) => {
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async ({id}, auth) => {
const pg = createSupabaseDirectClient()
await pg.tx(async (tx) => {
await updatePrivateUser(tx, auth.uid, {

View File

@@ -0,0 +1,46 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {update} from 'shared/supabase/utils'
export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
// Check if event exists and user is the creator
const event = await pg.oneOrNone<{
id: string
creator_id: string
status: string
}>(
`SELECT id, creator_id, status
FROM events
WHERE id = $1`,
[body.eventId],
)
if (!event) {
throw new APIError(404, 'Event not found')
}
if (event.creator_id !== auth.uid) {
throw new APIError(403, 'Only the event creator can cancel this event')
}
if (event.status === 'cancelled') {
throw new APIError(400, 'Event is already cancelled')
}
// Update event status to cancelled
const {error} = await tryCatch(
update(pg, 'events', 'id', {
status: 'cancelled',
id: body.eventId,
}),
)
if (error) {
throw new APIError(500, 'Failed to cancel event: ' + error.message)
}
return {success: true}
}

View File

@@ -0,0 +1,38 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
// Check if RSVP exists
const rsvp = await pg.oneOrNone<{
id: string
}>(
`SELECT id
FROM events_participants
WHERE event_id = $1
AND user_id = $2`,
[body.eventId, auth.uid],
)
if (!rsvp) {
throw new APIError(404, 'RSVP not found')
}
// Delete the RSVP
const {error} = await tryCatch(
pg.none(
`DELETE
FROM events_participants
WHERE id = $1`,
[rsvp.id],
),
)
if (error) {
throw new APIError(500, 'Failed to cancel RSVP: ' + error.message)
}
return {success: true}
}

View File

@@ -1,13 +1,11 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from "shared/supabase/init";
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getCompatibleProfilesHandler: APIHandler<'compatible-profiles'> = async (props) => {
return getCompatibleProfiles(props.userId)
}
export const getCompatibleProfiles = async (
userId: string,
) => {
export const getCompatibleProfiles = async (userId: string) => {
const pg = createSupabaseDirectClient()
const scores = await pg.map(
`select *
@@ -15,7 +13,7 @@ export const getCompatibleProfiles = async (
where score is not null
and (user_id_1 = $1 or user_id_2 = $1)`,
[userId],
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const,
)
const profileCompatibilityScores = Object.fromEntries(scores)

View File

@@ -1,24 +1,22 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {sendDiscordMessage} from 'common/discord/core'
import {jsonToMarkdown} from 'common/md'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {sendDiscordMessage} from "common/discord/core";
import {jsonToMarkdown} from "common/md";
import {APIError, APIHandler} from './helpers/endpoint'
// Stores a contact message into the `contact` table
// Web sends TipTap JSON in `content`; we store it as string in `description`.
// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults.
export const contact: APIHandler<'contact'> = async (
{content, userId},
_auth
) => {
export const contact: APIHandler<'contact'> = async ({content, userId}, _auth) => {
const pg = createSupabaseDirectClient()
const {error} = await tryCatch(
insert(pg, 'contact', {
user_id: userId,
content: JSON.stringify(content),
})
}),
)
if (error) throw new APIError(500, 'Failed to submit contact message')

View File

@@ -1,9 +1,10 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from './helpers/endpoint'
export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = async (
props,
auth
auth,
) => {
const creator_id = auth.uid
const {search_filters, location = null, search_name = null} = props
@@ -16,7 +17,7 @@ export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = as
VALUES ($1, $2, $3, $4)
RETURNING *
`,
[creator_id, search_filters, location, search_name]
[creator_id, search_filters, location, search_name],
)
return inserted

View File

@@ -1,32 +1,25 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { type JSONContent } from '@tiptap/core'
import { getPrivateUser, getUser } from 'shared/utils'
import {
createSupabaseDirectClient,
SupabaseDirectClient,
} from 'shared/supabase/init'
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
import { Notification } from 'common/notifications'
import { insertNotificationToSupabase } from 'shared/supabase/notifications'
import { User } from 'common/user'
import { richTextToString } from 'common/util/parse'
import {type JSONContent} from '@tiptap/core'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {Notification} from 'common/notifications'
import {convertComment} from 'common/supabase/comment'
import {type Row} from 'common/supabase/utils'
import {User} from 'common/user'
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
import {richTextToString} from 'common/util/parse'
import * as crypto from 'crypto'
import { sendNewEndorsementEmail } from 'email/functions/helpers'
import { type Row } from 'common/supabase/utils'
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
import { convertComment } from 'common/supabase/comment'
import {sendNewEndorsementEmail} from 'email/functions/helpers'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
import {getPrivateUser, getUser} from 'shared/utils'
import {broadcastUpdatedComment} from 'shared/websockets/helpers'
export const MAX_COMMENT_JSON_LENGTH = 20000
export const createComment: APIHandler<'create-comment'> = async (
{ userId, content: submittedContent, replyToCommentId },
auth
{userId, content: submittedContent, replyToCommentId},
auth,
) => {
const { creator, content } = await validateComment(
userId,
auth.uid,
submittedContent
)
const {creator, content} = await validateComment(userId, auth.uid, submittedContent)
const onUser = await getUser(userId)
if (!onUser) throw new APIError(404, 'User not found')
@@ -43,7 +36,7 @@ export const createComment: APIHandler<'create-comment'> = async (
userId,
content,
replyToCommentId,
]
],
)
if (onUser.id !== creator.id)
await createNewCommentOnProfileNotification(
@@ -51,19 +44,15 @@ export const createComment: APIHandler<'create-comment'> = async (
creator,
richTextToString(content),
comment.id,
pg
pg,
)
broadcastUpdatedComment(convertComment(comment))
return { status: 'success' }
return {status: 'success'}
}
const validateComment = async (
userId: string,
creatorId: string,
content: JSONContent
) => {
const validateComment = async (userId: string, creatorId: string, content: JSONContent) => {
const creator = await getUser(creatorId)
if (!creator) throw new APIError(401, 'Your account was not found')
@@ -78,10 +67,10 @@ const validateComment = async (
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
throw new APIError(
400,
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`,
)
}
return { content, creator }
return {content, creator}
}
const createNewCommentOnProfileNotification = async (
@@ -89,14 +78,16 @@ const createNewCommentOnProfileNotification = async (
creator: User,
sourceText: string,
commentId: number,
pg: SupabaseDirectClient
pg: SupabaseDirectClient,
) => {
const privateUser = await getPrivateUser(onUser.id)
if (!privateUser) return
const id = crypto.randomUUID()
const reason = 'new_endorsement'
const { sendToBrowser, sendToMobile, sendToEmail } =
getNotificationDestinationsForUser(privateUser, reason)
const {sendToBrowser, sendToMobile, sendToEmail} = getNotificationDestinationsForUser(
privateUser,
reason,
)
const notification: Notification = {
id,
userId: privateUser.id,

View File

@@ -1,27 +1,29 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { getUser } from 'shared/utils'
import { APIHandler, APIError } from './helpers/endpoint'
import { insert } from 'shared/supabase/utils'
import { tryCatch } from 'common/util/try-catch'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {getUser} from 'shared/utils'
export const createCompatibilityQuestion: APIHandler<
'create-compatibility-question'
> = async ({ question, options }, auth) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const createCompatibilityQuestion: APIHandler<'create-compatibility-question'> = async (
{question, options},
auth,
) => {
const creator = await getUser(auth.uid)
if (!creator) throw new APIError(401, 'Your account was not found')
const pg = createSupabaseDirectClient()
const { data, error } = await tryCatch(
const {data, error} = await tryCatch(
insert(pg, 'compatibility_prompts', {
creator_id: creator.id,
question,
answer_type: 'compatibility_multiple_choice',
multiple_choice_options: options,
})
}),
)
if (error) throw new APIError(401, 'Error creating question')
return { question: data }
return {question: data}
}

View File

@@ -0,0 +1,49 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
export const createEvent: APIHandler<'create-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
// Validate location
if (body.locationType === 'in_person' && !body.locationAddress) {
throw new APIError(400, 'In-person events require a location address')
}
if (body.locationType === 'online' && !body.locationUrl) {
throw new APIError(400, 'Online events require a location URL')
}
// Validate dates
const startTime = new Date(body.eventStartTime)
if (startTime < new Date()) {
throw new APIError(400, 'Event start time must be in the future')
}
if (body.eventEndTime) {
const endTime = new Date(body.eventEndTime)
if (endTime <= startTime) {
throw new APIError(400, 'Event end time must be after start time')
}
}
const {data, error} = await tryCatch(
insert(pg, 'events', {
creator_id: auth.uid,
title: body.title,
description: body.description,
location_type: body.locationType,
location_address: body.locationAddress,
location_url: body.locationUrl,
event_start_time: body.eventStartTime,
event_end_time: body.eventEndTime,
max_participants: body.maxParticipants,
}),
)
if (error) {
throw new APIError(500, 'Failed to create event: ' + error.message)
}
return {success: true, event: data}
}

View File

@@ -1,12 +1,12 @@
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {ANDROID_APP_URL} from 'common/constants'
import {Notification} from 'common/notifications'
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
import {tryCatch} from "common/util/try-catch";
import {Row} from "common/supabase/utils";
import {ANDROID_APP_URL} from "common/constants";
import {Row} from 'common/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {createBulkNotification, insertNotificationToSupabase} from 'shared/supabase/notifications'
export const createAndroidReleaseNotifications = async () => {
const createdTime = Date.now();
const createdTime = Date.now()
const id = `android-release-${createdTime}`
const notification: Notification = {
id,
@@ -16,15 +16,17 @@ export const createAndroidReleaseNotifications = async () => {
sourceType: 'info',
sourceUpdateType: 'created',
sourceSlug: ANDROID_APP_URL,
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
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 Released on Google Play',
sourceText: 'The Compass Android app is now publicly available on Google Play! Download it today to stay connected on the go.',
sourceText:
'The Compass Android app is now publicly available on Google Play! Download it today to stay connected on the go.',
}
return await createNotifications(notification)
}
export const createAndroidTestNotifications = async () => {
const createdTime = Date.now();
const createdTime = Date.now()
const id = `android-test-${createdTime}`
const notification: Notification = {
id,
@@ -34,15 +36,17 @@ export const createAndroidTestNotifications = async () => {
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',
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 Playregistered email address so we can add you as a tester and complete the review process.',
sourceText:
'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Playregistered 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 createdTime = Date.now()
const id = `share-${createdTime}`
const notification: Notification = {
id,
@@ -52,7 +56,8 @@ export const createShareNotifications = async () => {
sourceType: 'info',
sourceUpdateType: 'created',
sourceSlug: '/contact',
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Ficon-outreach-outstrip-outreach-272151502.jpg?alt=media&token=6d6fcecb-818c-4fca-a8e0-d2d0069b9445',
sourceUserAvatarUrl:
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Ficon-outreach-outstrip-outreach-272151502.jpg?alt=media&token=6d6fcecb-818c-4fca-a8e0-d2d0069b9445',
title: 'Give us tips to reach more people',
sourceText: '250 members already! Tell us where and how we can best share Compass.',
}
@@ -60,7 +65,7 @@ export const createShareNotifications = async () => {
}
export const createVoteNotifications = async () => {
const createdTime = Date.now();
const createdTime = Date.now()
const id = `vote-${createdTime}`
const notification: Notification = {
id,
@@ -70,18 +75,17 @@ export const createVoteNotifications = async () => {
sourceType: 'info',
sourceUpdateType: 'created',
sourceSlug: '/vote',
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fvote-icon-design-free-vector.jpg?alt=media&token=f70b6d14-0511-49b2-830d-e7cabf7bb751',
sourceUserAvatarUrl:
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fvote-icon-design-free-vector.jpg?alt=media&token=f70b6d14-0511-49b2-830d-e7cabf7bb751',
title: 'New Proposals & Votes Page',
sourceText: 'Create proposals and vote on other people\'s suggestions!',
sourceText: "Create proposals and vote on other people's suggestions!",
}
return await createNotifications(notification)
}
export const createNotifications = async (notification: Notification) => {
const pg = createSupabaseDirectClient()
const {data: users, error} = await tryCatch(
pg.many<Row<'users'>>('select * from users')
)
const {data: users, error} = await tryCatch(pg.many<Row<'users'>>('select * from users'))
if (error) {
console.error('Error fetching users', error)
@@ -106,8 +110,59 @@ export const createNotifications = async (notification: Notification) => {
}
}
export const createNotification = async (user: Row<'users'>, notification: Notification, pg: SupabaseDirectClient) => {
export const createNotification = async (
user: Row<'users'>,
notification: Notification,
pg: SupabaseDirectClient,
) => {
notification.userId = user.id
console.log('notification', user.username)
return await insertNotificationToSupabase(notification, pg)
}
/**
* Send "Events now available" notification to all users
* Uses the new template-based system for efficient bulk notifications
*/
export const createEventsAvailableNotifications = async () => {
const pg = createSupabaseDirectClient()
// Fetch all users
const {data: users, error} = await tryCatch(pg.many<Row<'users'>>('select id from users'))
if (error) {
console.error('Error fetching users', error)
return {success: false, error}
}
if (!users || users.length === 0) {
console.error('No users found')
return {success: false, error: 'No users found'}
}
const userIds = users.map((u) => u.id)
// Create template and bulk notifications using the new system
const {templateId, count} = await createBulkNotification(
{
sourceType: 'info',
title: 'New Events Page',
sourceText:
'You can now create and join events on Compass! Meet up with other members online or in person for workshops, social events, etc.',
sourceSlug: '/events',
sourceUserAvatarUrl:
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
sourceUpdateType: 'created',
},
userIds,
pg,
)
console.log(`Created events notification template ${templateId} for ${count} users`)
return {
success: true,
templateId,
userCount: count,
}
}

View File

@@ -1,13 +1,21 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { filterDefined } from 'common/util/array'
import { uniq } from 'lodash'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { addUsersToPrivateMessageChannel } from 'api/helpers/private-messages'
import { getPrivateUser, getUser } from 'shared/utils'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {addUsersToPrivateMessageChannel} from 'api/helpers/private-messages'
import {filterDefined} from 'common/util/array'
import * as admin from 'firebase-admin'
import {uniq} from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {getPrivateUser, getUser} from 'shared/utils'
export const createPrivateUserMessageChannel: APIHandler<
'create-private-user-message-channel'
> = async (body, auth) => {
// Do not use auth.creds.data as its info can be staled. It comes from a client token, which refreshes hourly or so
const user = await admin.auth().getUser(auth.uid)
// console.log(JSON.stringify(user, null, 2))
if (!user?.emailVerified) {
throw new APIError(403, 'You must verify your email to contact people.')
}
const userIds = uniq(body.userIds.concat(auth.uid))
const pg = createSupabaseDirectClient()
@@ -16,37 +24,33 @@ export const createPrivateUserMessageChannel: APIHandler<
const creator = await getUser(creatorId)
if (!creator) throw new APIError(401, 'Your account was not found')
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
const toPrivateUsers = filterDefined(
await Promise.all(userIds.map((id) => getPrivateUser(id)))
)
const toPrivateUsers = filterDefined(await Promise.all(userIds.map((id) => getPrivateUser(id))))
if (toPrivateUsers.length !== userIds.length)
throw new APIError(
404,
`Private user ${userIds.find(
(uid) => !toPrivateUsers.map((p) => p.id).includes(uid)
)} not found`
(uid) => !toPrivateUsers.map((p: any) => p.id).includes(uid),
)} not found`,
)
if (
toPrivateUsers.some((user) =>
user.blockedUserIds.some((blockedId) => userIds.includes(blockedId))
toPrivateUsers.some((user: any) =>
user.blockedUserIds.some((blockedId: string) => userIds.includes(blockedId)),
)
) {
throw new APIError(
403,
'One of the users has blocked another user in the list'
)
throw new APIError(403, 'One of the users has blocked another user in the list')
}
const currentChannel = await pg.oneOrNone(
`
select channel_id from private_user_message_channel_members
group by channel_id
having array_agg(user_id::text) @> array[$1]::text[]
and array_agg(user_id::text) <@ array[$1]::text[]
`,
[userIds]
select channel_id
from private_user_message_channel_members
group by channel_id
having array_agg(user_id::text) @> array [$1]::text[]
and array_agg(user_id::text) <@ array [$1]::text[]
`,
[userIds],
)
if (currentChannel)
return {
@@ -55,17 +59,19 @@ export const createPrivateUserMessageChannel: APIHandler<
}
const channel = await pg.one(
`insert into private_user_message_channels default values returning id`
`insert into private_user_message_channels default
values
returning id`,
)
await pg.none(
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
values ($1, $2, 'creator', 'joined')
`,
[channel.id, creatorId]
values ($1, $2, 'creator', 'joined')
`,
[channel.id, creatorId],
)
const memberIds = userIds.filter((id) => id !== creatorId)
await addUsersToPrivateMessageChannel(memberIds, channel.id, pg)
return { status: 'success', channelId: Number(channel.id) }
return {status: 'success', channelId: Number(channel.id)}
}

View File

@@ -1,18 +1,16 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {getUser} from 'shared/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {getUser} from 'shared/utils'
export const createPrivateUserMessage: APIHandler<
'create-private-user-message'
> = async (body, auth) => {
export const createPrivateUserMessage: APIHandler<'create-private-user-message'> = async (
body,
auth,
) => {
const {content, channelId} = body
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
throw new APIError(
400,
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
)
throw new APIError(400, `Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`)
}
const creator = await getUser(auth.uid)
@@ -20,11 +18,5 @@ export const createPrivateUserMessage: APIHandler<
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
const pg = createSupabaseDirectClient()
return await createPrivateUserMessageMain(
creator,
channelId,
content,
pg,
'private'
)
return await createPrivateUserMessageMain(creator, channelId, content, pg, 'private')
}

View File

@@ -1,40 +1,39 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { log, getUser } from 'shared/utils'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {sendDiscordMessage} from 'common/discord/core'
import {jsonToMarkdown} from 'common/md'
import {trimStrings} from 'common/parsing'
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'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
import {sendDiscordMessage} from "common/discord/core";
import {jsonToMarkdown} from "common/md";
import {tryCatch} from 'common/util/try-catch'
import {track} from 'shared/analytics'
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {insert} from 'shared/supabase/utils'
import {getUser, log} from 'shared/utils'
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
const { data: existingUser } = await tryCatch(
pg.oneOrNone<{ id: string }>('select id from profiles where user_id = $1', [
auth.uid,
])
const {data: existingUser} = await tryCatch(
pg.oneOrNone<{id: string}>('select id from profiles where user_id = $1', [auth.uid]),
)
if (existingUser) {
throw new APIError(400, 'User already exists')
}
await removePinnedUrlFromPhotoUrls(body)
trimStrings(body)
const user = await getUser(auth.uid)
if (!user) throw new APIError(401, 'Your account was not found')
if (user.createdTime > Date.now() - HOUR_MS) {
// If they just signed up, set their avatar to be their pinned photo
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
updateUser(pg, auth.uid, {avatarUrl: body.pinned_url})
}
console.debug('body', body)
const { data, error } = await tryCatch(
insert(pg, 'profiles', { user_id: auth.uid, ...body })
)
const {data, error} = await tryCatch(insert(pg, 'profiles', {user_id: auth.uid, ...body}))
if (error) {
log.error('Error creating user: ' + error.message)
@@ -64,10 +63,8 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
console.error('Failed to send discord new profile', e)
}
try {
const nProfiles = await pg.one<number>(
`SELECT count(*) FROM profiles`,
[],
(r) => Number(r.count)
const nProfiles = await pg.one<number>(`SELECT count(*) FROM profiles`, [], (r) =>
Number(r.count),
)
const isMilestone = (n: number) => {
@@ -78,12 +75,8 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
}
console.debug(nProfiles, isMilestone(nProfiles))
if (isMilestone(nProfiles)) {
await sendDiscordMessage(
`We just reached **${nProfiles}** total profiles! 🎉`,
'general',
)
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
}
} catch (e) {
console.error('Failed to send discord user milestone', e)
}

View File

@@ -1,32 +1,28 @@
import * as admin from 'firebase-admin'
import {PrivateUser} from 'common/user'
import {randomString} from 'common/util/random'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {getIp, track} from 'shared/analytics'
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 {setLastOnlineTimeUser} from 'api/set-last-online-time'
import {RESERVED_PATHS} from 'common/envs/constants'
import {getUser, getUserByUsername, log} from 'shared/utils'
import {IS_LOCAL} from 'common/hosting/constants'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
import {PrivateUser} from 'common/user'
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {removeUndefinedProps} from 'common/util/object'
import {randomString} from 'common/util/random'
import {sendWelcomeEmail} from 'email/functions/helpers'
import * as admin from 'firebase-admin'
import {getIp, track} from 'shared/analytics'
import {getBucket} from 'shared/firebase-utils'
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
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";
import {getUser, getUserByUsername, log} from 'shared/utils'
export const createUser: APIHandler<'create-user'> = async (
props,
auth,
req
) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const createUser: APIHandler<'create-user'> = async (props, auth, req) => {
const {deviceToken: preDeviceToken} = props
const firebaseUser = await admin.auth().getUser(auth.uid)
const testUserAKAEmailPasswordUser =
firebaseUser.providerData[0].providerId === 'password'
const testUserAKAEmailPasswordUser = firebaseUser.providerData[0].providerId === 'password'
// if (
// testUserAKAEmailPasswordUser &&
@@ -68,7 +64,7 @@ export const createUser: APIHandler<'create-user'> = async (
from users
where username ilike $1`,
[username],
(r) => r.count
(r) => r.count,
)
const usernameExists = dupes > 0
const isReservedName = RESERVED_PATHS.includes(username)
@@ -83,14 +79,13 @@ export const createUser: APIHandler<'create-user'> = async (
// Check exact username to avoid problems with duplicate requests
const sameNameUser = await getUserByUsername(username, tx)
if (sameNameUser)
throw new APIError(403, 'Username already taken', {username})
if (sameNameUser) throw new APIError(403, 'Username already taken', {username})
const user = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip))
(ip && bannedIpAddresses.includes(ip)),
),
link: {},
})

View File

@@ -1,28 +1,30 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { getUser } from 'shared/utils'
import { APIHandler, APIError } from './helpers/endpoint'
import { insert } from 'shared/supabase/utils'
import { tryCatch } from 'common/util/try-catch'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {getUser} from 'shared/utils'
export const createVote: APIHandler<
'create-vote'
> = async ({ title, description, isAnonymous }, auth) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const createVote: APIHandler<'create-vote'> = async (
{title, description, isAnonymous},
auth,
) => {
const creator = await getUser(auth.uid)
if (!creator) throw new APIError(401, 'Your account was not found')
const pg = createSupabaseDirectClient()
const { data, error } = await tryCatch(
const {data, error} = await tryCatch(
insert(pg, 'votes', {
creator_id: creator.id,
title,
description,
is_anonymous: isAnonymous,
status: 'voting_open',
})
}),
)
if (error) throw new APIError(401, 'Error creating question')
return { data }
return {data}
}

View File

@@ -1,9 +1,10 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from './helpers/endpoint'
export const deleteBookmarkedSearch: APIHandler<'delete-bookmarked-search'> = async (
props,
auth
auth,
) => {
const creator_id = auth.uid
const {id} = props
@@ -16,7 +17,7 @@ export const deleteBookmarkedSearch: APIHandler<'delete-bookmarked-search'> = as
DELETE FROM bookmarked_searches
WHERE id = $1 AND creator_id = $2
`,
[id, creator_id]
[id, creator_id],
)
return {}

View File

@@ -1,10 +1,12 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError} from 'common/api/utils'
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
{id}, auth) => {
{id},
auth,
) => {
const pg = createSupabaseDirectClient()
// Verify user is the answer author
@@ -13,7 +15,7 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
FROM compatibility_answers
WHERE id = $1
AND creator_id = $2`,
[id, auth.uid]
[id, auth.uid],
)
if (!item) {
@@ -26,7 +28,7 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
FROM compatibility_answers
WHERE id = $1
AND creator_id = $2`,
[id, auth.uid]
[id, auth.uid],
)
const continuation = async () => {

View File

@@ -1,10 +1,11 @@
import {getUser} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import * as admin from 'firebase-admin'
import {deleteUserFiles} from 'shared/firebase-utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import * as admin from "firebase-admin";
import {deleteUserFiles} from "shared/firebase-utils";
import {getUser} from 'shared/utils'
export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const deleteMe: APIHandler<'me/delete'> = async ({reasonCategory, reasonDetails}, auth) => {
const user = await getUser(auth.uid)
if (!user) {
throw new APIError(401, 'Your account was not found')
@@ -14,8 +15,23 @@ export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
throw new APIError(400, 'Invalid user ID')
}
// Remove user data from Supabase
const pg = createSupabaseDirectClient()
// Store deletion reason before deleting the account
try {
await pg.none(
`
INSERT INTO deleted_users (username, reason_category, reason_details)
VALUES ($1, $2, $3)
`,
[user.username, reasonCategory, reasonDetails],
)
} catch (e) {
console.error('Error storing deletion reason:', e)
// Don't fail the deletion if we can't store the reason
}
// Remove user data from Supabase
await pg.none('DELETE FROM users WHERE id = $1', [userId])
// Should cascade delete in other tables

View File

@@ -1,6 +1,7 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {broadcastPrivateMessages} from "api/helpers/private-messages";
import {APIError, APIHandler} from './helpers/endpoint'
// const DELETED_MESSAGE_CONTENT: JSONContent = {
// type: 'doc',
@@ -26,7 +27,7 @@ export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, a
FROM private_user_messages
WHERE id = $1
AND user_id = $2`,
[messageId, auth.uid]
[messageId, auth.uid],
)
if (!message) {
@@ -51,14 +52,12 @@ export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, a
FROM private_user_messages
WHERE id = $1
AND user_id = $2`,
[messageId, auth.uid]
[messageId, auth.uid],
)
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
.catch((err) => {
console.error('broadcastPrivateMessages failed', err)
})
void broadcastPrivateMessages(pg, message.channel_id, auth.uid).catch((err) => {
console.error('broadcastPrivateMessages failed', err)
})
return {success: true}
}

View File

@@ -1,8 +1,8 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
import {encryptMessage} from 'shared/encryption'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {encryptMessage} from "shared/encryption";
import {broadcastPrivateMessages} from "api/helpers/private-messages";
import {APIError, APIHandler} from './helpers/endpoint'
export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
const pg = createSupabaseDirectClient()
@@ -15,7 +15,7 @@ export const editMessage: APIHandler<'edit-message'> = async ({messageId, conten
AND user_id = $2
-- AND created_time > NOW() - INTERVAL '1 day'
AND deleted = FALSE`,
[messageId, auth.uid]
[messageId, auth.uid],
)
if (!message) {
@@ -32,13 +32,12 @@ export const editMessage: APIHandler<'edit-message'> = async ({messageId, conten
is_edited = TRUE,
edited_at = NOW()
WHERE id = $4`,
[ciphertext, iv, tag, messageId]
[ciphertext, iv, tag, messageId],
)
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
.catch((err) => {
console.error('broadcastPrivateMessages failed', err)
})
void broadcastPrivateMessages(pg, message.channel_id, auth.uid).catch((err) => {
console.error('broadcastPrivateMessages failed', err)
})
return {success: true}
}

View File

@@ -1,42 +1,93 @@
import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { Row } from 'common/supabase/utils'
import {type APIHandler} from 'api/helpers/endpoint'
import {Row} from 'common/supabase/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export function shuffle<T>(array: T[]): T[] {
const arr = [...array]; // copy to avoid mutating the original
const arr = [...array] // copy to avoid mutating the original
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr;
return arr
}
export const getCompatibilityQuestions: APIHandler<
'get-compatibility-questions'
> = async (_props, _auth) => {
export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'> = async (
props,
_auth,
) => {
const {locale = 'en', keyword} = props
const pg = createSupabaseDirectClient()
// Build query parameters
const params: (string | number)[] = [locale]
const paramIndex = 2
// Build keyword filter condition - search in question text and multiple_choice_options keys
const keywordFilter = keyword
? `AND (
COALESCE(cpt.question, cp.question) ILIKE $${paramIndex}
OR EXISTS (
SELECT 1
FROM jsonb_object_keys(
COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options)
) AS option_key
WHERE option_key ILIKE $${paramIndex}
)
)`
: ''
if (keyword) {
params.push(`%${keyword}%`)
}
const questions = await pg.manyOrNone<
Row<'compatibility_prompts'> & { answer_count: number; score: number }
Row<'compatibility_prompts'> & {answer_count: number; score: number}
>(
`SELECT
compatibility_prompts.*,
COUNT(compatibility_answers.question_id) as answer_count,
AVG(POWER(compatibility_answers.importance + 1 + CASE WHEN compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
FROM
compatibility_prompts
LEFT JOIN
compatibility_answers ON compatibility_prompts.id = compatibility_answers.question_id
WHERE
compatibility_prompts.answer_type = 'compatibility_multiple_choice'
GROUP BY
compatibility_prompts.id
ORDER BY
compatibility_prompts.importance_score
`
SELECT cp.id,
cp.answer_type,
cp.importance_score,
cp.created_time,
cp.creator_id,
cp.category,
-- locale-aware fields
COALESCE(cpt.question, cp.question) AS question,
COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options) AS multiple_choice_options,
COUNT(ca.question_id) AS answer_count,
AVG(
POWER(
ca.importance + 1 +
CASE WHEN ca.explanation IS NULL THEN 1 ELSE 0 END,
2
)
) AS score
FROM compatibility_prompts cp
LEFT JOIN compatibility_answers ca
ON cp.id = ca.question_id
LEFT JOIN compatibility_prompts_translations cpt
ON cp.id = cpt.question_id
AND cpt.locale = $1
AND $1 <> 'en'
WHERE cp.answer_type = 'compatibility_multiple_choice'
${keywordFilter}
GROUP BY cp.id,
cpt.question,
cpt.multiple_choice_options
ORDER BY cp.importance_score
`,
[]
params,
)
// console.debug({questions})
// const questions = shuffle(dbQuestions)
// console.debug(

View File

@@ -1,27 +1,19 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError, APIHandler } from './helpers/endpoint'
import { PrivateUser } from 'common/user'
import { Row } from 'common/supabase/utils'
import { tryCatch } from 'common/util/try-catch'
import {Row} from 'common/supabase/utils'
import {PrivateUser} from 'common/user'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getCurrentPrivateUser: APIHandler<'me/private'> = async (
_,
auth
) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const getCurrentPrivateUser: APIHandler<'me/private'> = async (_, auth) => {
const pg = createSupabaseDirectClient()
const { data, error } = await tryCatch(
pg.oneOrNone<Row<'private_users'>>(
'select * from private_users where id = $1',
[auth.uid]
)
const {data, error} = await tryCatch(
pg.oneOrNone<Row<'private_users'>>('select * from private_users where id = $1', [auth.uid]),
)
if (error) {
throw new APIError(
500,
'Error fetching private user data: ' + error.message
)
throw new APIError(500, 'Error fetching private user data: ' + error.message)
}
if (!data) {

View File

@@ -0,0 +1,89 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getEvents: APIHandler<'get-events'> = async () => {
const pg = createSupabaseDirectClient()
const events = await pg.manyOrNone<{
id: string
created_time: string
creator_id: string
title: string
description: string | null
location_type: 'in_person' | 'online'
location_address: string | null
location_url: string | null
event_start_time: object
event_end_time: object | null
is_public: boolean
max_participants: number | null
status: 'active' | 'cancelled' | 'completed'
}>(
`SELECT *
FROM events
WHERE is_public = true
AND status = 'active'
ORDER BY event_start_time`,
)
// Get participants for each event
const eventIds = events.map((e) => e.id)
const participants =
eventIds.length > 0
? await pg.manyOrNone<{
event_id: string
user_id: string
status: 'going' | 'maybe' | 'not_going'
}>(
`SELECT event_id, user_id, status
FROM events_participants
WHERE event_id = ANY ($1)`,
[eventIds],
)
: []
// Get creator info for each event
const creatorIds = [...new Set(events.map((e) => e.creator_id))]
const creators =
creatorIds.length > 0
? await pg.manyOrNone<{
id: string
name: string
username: string
avatar_url: string | null
}>(
`SELECT id, name, username, data ->> 'avatarUrl' as avatar_url
FROM users
WHERE id = ANY ($1)`,
[creatorIds],
)
: []
const now = new Date()
const eventsWithDetails = events.map((event) => ({
...event,
participants: participants
.filter((p) => p.event_id === event.id && p.status === 'going')
.map((p) => p.user_id),
maybe: participants
.filter((p) => p.event_id === event.id && p.status === 'maybe')
.map((p) => p.user_id),
creator: creators.find((c) => c.id === event.creator_id),
}))
const upcoming: typeof eventsWithDetails = []
const past: typeof eventsWithDetails = []
for (const e of eventsWithDetails) {
if ((e.event_end_time ?? e.event_start_time) > now) {
upcoming.push(e)
} else {
past.push(e)
}
}
// console.debug({events, eventsWithDetails, upcoming, past, now})
return {upcoming, past}
}

View File

@@ -0,0 +1,38 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getHiddenProfiles: APIHandler<'get-hidden-profiles'> = async (
{limit = 100, offset = 0},
auth,
) => {
const pg = createSupabaseDirectClient()
// Count total hidden for pagination info
const countRes = await pg.one<{count: string}>(
`select count(*)::text as count
from hidden_profiles
where hider_user_id = $1`,
[auth.uid],
)
const count = Number(countRes.count) || 0
// Fetch hidden users joined with users table for display
const rows = await pg.map(
`select u.id, u.name, u.username, u.data ->> 'avatarUrl' as "avatarUrl", hp.created_time as "createdTime"
from hidden_profiles hp
join users u on u.id = hp.hidden_user_id
where hp.hider_user_id = $1
order by hp.created_time desc
limit $2 offset $3`,
[auth.uid, limit, offset],
(r: any) => ({
id: r.id as string,
name: r.name as string,
username: r.username as string,
avatarUrl: r.avatarUrl as string | null | undefined,
createdTime: r.createdTime as string | undefined,
}),
)
return {status: 'success', hidden: rows, count}
}

View File

@@ -1,10 +1,8 @@
import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import {type APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getLikesAndShips: APIHandler<'get-likes-and-ships'> = async (
props
) => {
const { userId } = props
export const getLikesAndShips: APIHandler<'get-likes-and-ships'> = async (props) => {
const {userId} = props
return {
status: 'success',
@@ -34,7 +32,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
(r) => ({
user_id: r.target_id,
created_time: new Date(r.created_time).getTime(),
})
}),
)
const likesReceived = await pg.map<{
@@ -56,7 +54,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
(r) => ({
user_id: r.creator_id,
created_time: new Date(r.created_time).getTime(),
})
}),
)
const ships = await pg.map<{
@@ -95,7 +93,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
(r) => ({
...r,
created_time: new Date(r.created_time).getTime(),
})
}),
)
return {

View File

@@ -1,6 +1,7 @@
import { type APIHandler } from './helpers/endpoint'
import { getUser } from 'api/get-user'
import {getUser} from 'api/get-user'
import {type APIHandler} from './helpers/endpoint'
export const getMe: APIHandler<'me'> = async (_, auth) => {
return getUser({ id: auth.uid })
return getUser({id: auth.uid})
}

View File

@@ -1,5 +1,6 @@
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from "shared/supabase/init";
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _auth) => {
const pg = createSupabaseDirectClient()
@@ -8,10 +9,10 @@ export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _aut
SELECT COUNT(*) AS count
FROM private_user_messages;
`,
[]
);
const count = Number(result.count);
console.debug('private_user_messages count:', count);
[],
)
const count = Number(result.count)
console.debug('private_user_messages count:', count)
return {
count: count,
}

View File

@@ -1,23 +1,49 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIHandler } from 'api/helpers/endpoint'
import { Notification } from 'common/notifications'
import {APIHandler} from 'api/helpers/endpoint'
import {Notification} from 'common/notifications'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getNotifications: APIHandler<'get-notifications'> = async (
props,
auth
) => {
const { limit, after } = props
export const getNotifications: APIHandler<'get-notifications'> = async (props, auth) => {
const {limit, after} = props
const pg = createSupabaseDirectClient()
const query = `
select data from user_notifications
where user_id = $1
and ($3 is null or (data->'createdTime')::bigint > $3)
order by (data->'createdTime')::bigint desc
select case
when un.template_id is not null then
jsonb_build_object(
'id', un.notification_id,
'userId', un.user_id,
'templateId', un.template_id,
'title', nt.title,
'sourceType', nt.source_type,
'sourceUpdateType', nt.source_update_type,
'createdTime', nt.created_time,
'isSeen', coalesce((un.data ->> 'isSeen')::boolean, false),
'viewTime', (un.data ->> 'viewTime')::bigint,
'sourceText', nt.source_text,
'sourceSlug', nt.source_slug,
'sourceUserAvatarUrl', nt.source_user_avatar_url,
'data', nt.data
)
else
un.data
end as notification_data
from user_notifications un
left join notification_templates nt on un.template_id = nt.id
where un.user_id = $1
and ($3 is null or
case
when un.template_id is not null then nt.created_time > $3
else (un.data ->> 'createdTime')::bigint > $3
end
)
order by case
when un.template_id is not null then nt.created_time
else (un.data ->> 'createdTime')::bigint
end desc
limit $2
`
return await pg.map(
query,
[auth.uid, limit, after],
(row) => row.data as Notification
(row) => row.notification_data as Notification,
)
}

View File

@@ -1,20 +1,17 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {OPTION_TABLES} from 'common/profiles/constants'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
import {tryCatch} from 'common/util/try-catch'
import {OPTION_TABLES} from "common/profiles/constants";
export const getOptions: APIHandler<'get-options'> = async (
{table},
_auth
) => {
export const getOptions: APIHandler<'get-options'> = async ({table}, _auth) => {
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
const pg = createSupabaseDirectClient()
const result = await tryCatch(
pg.manyOrNone<{ name: string }>(`SELECT interests.name
FROM interests`)
pg.manyOrNone<{name: string}>(`SELECT interests.name
FROM interests`),
)
if (result.error) {
@@ -22,7 +19,6 @@ export const getOptions: APIHandler<'get-options'> = async (
throw new APIError(500, 'Error getting profile options')
}
const names = result.data.map(row => row.name)
const names = result.data.map((row) => row.name)
return {names}
}

View File

@@ -1,13 +1,12 @@
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError, APIHandler} from './helpers/endpoint'
import {PrivateMessageChannel,} from 'common/supabase/private-messages'
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {tryCatch} from 'common/util/try-catch'
import {groupBy, mapValues} from 'lodash'
import {convertPrivateChatMessage} from "shared/supabase/messages";
import {tryCatch} from "common/util/try-catch";
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {convertPrivateChatMessage} from 'shared/supabase/messages'
export const getChannelMemberships: APIHandler<
'get-channel-memberships'
> = async (props, auth) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const getChannelMemberships: APIHandler<'get-channel-memberships'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelId, lastUpdatedTime, createdTime, limit} = props
@@ -29,7 +28,7 @@ export const getChannelMemberships: APIHandler<
limit $3
`,
[auth.uid, channelId, limit],
convertRow
convertRow,
)
} else {
channels = await pg.map(
@@ -59,11 +58,10 @@ export const getChannelMemberships: APIHandler<
limit $3
`,
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
convertRow
convertRow,
)
}
if (!channels || channels.length === 0)
return {channels: [], memberIdsByChannelId: {}}
if (!channels || channels.length === 0) return {channels: [], memberIdsByChannelId: {}}
const channelIds = channels.map((c) => c.channel_id)
const members = await pg.map(
@@ -77,12 +75,11 @@ export const getChannelMemberships: APIHandler<
(r) => ({
channel_id: r.channel_id as number,
user_id: r.user_id as string,
})
}),
)
const memberIdsByChannelId = mapValues(
groupBy(members, 'channel_id'),
(members) => members.map((m) => m.user_id)
const memberIdsByChannelId = mapValues(groupBy(members, 'channel_id'), (members) =>
members.map((m) => m.user_id),
)
return {
@@ -93,23 +90,24 @@ export const getChannelMemberships: APIHandler<
export const getChannelMessagesEndpoint: APIHandler<'get-channel-messages'> = async (
props,
auth
auth,
) => {
const userId = auth.uid
return await getChannelMessages({...props, userId})
}
export async function getChannelMessages(props: {
channelId: number;
limit: number;
id?: number | undefined;
userId: string;
channelId: number
limit?: number
id?: number | undefined
userId: string
}) {
// console.log('initial message request', props)
const {channelId, limit, id, userId} = props
const pg = createSupabaseDirectClient()
const {data, error} = await tryCatch(pg.map(
`select *, created_time as created_time_ts
const {data, error} = await tryCatch(
pg.map(
`select *, created_time as created_time_ts
from private_user_messages
where channel_id = $1
and exists (select 1
@@ -119,11 +117,12 @@ export async function getChannelMessages(props: {
and ($4 is null or id > $4)
and not visibility = 'system_status'
order by created_time desc
limit $3
${limit ? 'limit $3' : ''}
`,
[channelId, userId, limit, id],
convertPrivateChatMessage
))
[channelId, userId, limit, id],
convertPrivateChatMessage,
),
)
if (error) {
console.error(error)
throw new APIError(401, 'Error getting messages')
@@ -132,9 +131,7 @@ export async function getChannelMessages(props: {
return data
}
export const getLastSeenChannelTime: APIHandler<
'get-channel-seen-time'
> = async (props, auth) => {
export const getLastSeenChannelTime: APIHandler<'get-channel-seen-time'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelIds} = props
const unseens = await pg.map(
@@ -145,20 +142,18 @@ export const getLastSeenChannelTime: APIHandler<
order by channel_id, created_time desc
`,
[channelIds, auth.uid],
(r) => [r.channel_id as number, r.created_time as string]
(r) => [r.channel_id as number, r.created_time as string],
)
return unseens as [number, string][]
}
export const setChannelLastSeenTime: APIHandler<
'set-channel-seen-time'
> = async (props, auth) => {
export const setChannelLastSeenTime: APIHandler<'set-channel-seen-time'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelId} = props
await pg.none(
`insert into private_user_seen_message_channels (user_id, channel_id)
values ($1, $2)
`,
[auth.uid, channelId]
[auth.uid, channelId],
)
}

View File

@@ -1,12 +1,9 @@
import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { Row } from 'common/supabase/utils'
import {type APIHandler} from 'api/helpers/endpoint'
import {Row} from 'common/supabase/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
props,
_auth
) => {
const { userId } = props
export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (props, _auth) => {
const {userId} = props
const pg = createSupabaseDirectClient()
const answers = await pg.manyOrNone<Row<'compatibility_answers'>>(
@@ -15,7 +12,7 @@ export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
creator_id = $1
order by created_time desc
`,
[userId]
[userId],
)
return {

View File

@@ -1,50 +1,71 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {OptionTableKey} from 'common/profiles/constants'
import {compact} from 'lodash'
import {convertRow} from 'shared/profiles/supabase'
import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {MIN_BIO_LENGTH} from "common/constants";
import {compact} from "lodash";
import {OptionTableKey} from "common/profiles/constants";
import {
from,
join,
leftJoin,
limit,
orderBy,
renderSql,
select,
where,
} from 'shared/supabase/sql-builder'
export type profileQueryType = {
limit?: number | undefined,
after?: string | undefined,
// Search and filter parameters
name?: string | undefined,
genders?: string[] | undefined,
education_levels?: string[] | undefined,
pref_gender?: string[] | undefined,
pref_age_min?: number | undefined,
pref_age_max?: number | undefined,
drinks_min?: number | undefined,
drinks_max?: number | undefined,
pref_relation_styles?: string[] | undefined,
pref_romantic_styles?: string[] | undefined,
diet?: string[] | undefined,
political_beliefs?: string[] | undefined,
mbti?: string[] | undefined,
relationship_status?: string[] | undefined,
languages?: string[] | undefined,
religion?: string[] | undefined,
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
is_smoker?: boolean | undefined,
shortBio?: boolean | undefined,
geodbCityIds?: string[] | undefined,
lat?: number | undefined,
lon?: number | undefined,
radius?: number | undefined,
compatibleWithUserId?: string | undefined,
skipId?: string | undefined,
orderBy?: string | undefined,
lastModificationWithin?: string | undefined,
limit?: number | undefined
after?: string | undefined
userId?: string | undefined
name?: string | undefined
genders?: string[] | undefined
education_levels?: string[] | undefined
pref_gender?: string[] | undefined
pref_age_min?: number | undefined
pref_age_max?: number | undefined
drinks_min?: number | undefined
drinks_max?: number | undefined
big5_openness_min?: number | undefined
big5_openness_max?: number | undefined
big5_conscientiousness_min?: number | undefined
big5_conscientiousness_max?: number | undefined
big5_extraversion_min?: number | undefined
big5_extraversion_max?: number | undefined
big5_agreeableness_min?: number | undefined
big5_agreeableness_max?: number | undefined
big5_neuroticism_min?: number | undefined
big5_neuroticism_max?: number | undefined
pref_relation_styles?: string[] | undefined
pref_romantic_styles?: string[] | undefined
diet?: string[] | undefined
political_beliefs?: string[] | undefined
mbti?: string[] | undefined
relationship_status?: string[] | undefined
languages?: string[] | undefined
religion?: string[] | undefined
wants_kids_strength?: number | undefined
has_kids?: number | undefined
is_smoker?: boolean | undefined
shortBio?: boolean | undefined
geodbCityIds?: string[] | undefined
lat?: number | undefined
lon?: number | undefined
radius?: number | undefined
raised_in_lat?: number | undefined
raised_in_lon?: number | undefined
raised_in_radius?: number | undefined
compatibleWithUserId?: string | undefined
skipId?: string | undefined
orderBy?: string | undefined
lastModificationWithin?: string | undefined
locale?: string | undefined
} & {
[K in OptionTableKey]?: string[] | undefined
}
// const userActivityColumns = ['last_online_time']
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
console.debug('loadProfiles', props)
@@ -52,6 +73,7 @@ export const loadProfiles = async (props: profileQueryType) => {
limit: limitParam,
after,
name,
userId,
genders,
education_levels,
pref_gender,
@@ -59,6 +81,16 @@ export const loadProfiles = async (props: profileQueryType) => {
pref_age_max,
drinks_min,
drinks_max,
big5_openness_min,
big5_openness_max,
big5_conscientiousness_min,
big5_conscientiousness_max,
big5_extraversion_min,
big5_extraversion_max,
big5_agreeableness_min,
big5_agreeableness_max,
big5_neuroticism_min,
big5_neuroticism_max,
pref_relation_styles,
pref_romantic_styles,
diet,
@@ -78,15 +110,25 @@ export const loadProfiles = async (props: profileQueryType) => {
lat,
lon,
radius,
raised_in_lat,
raised_in_lon,
raised_in_radius,
compatibleWithUserId,
orderBy: orderByParam = 'created_time',
lastModificationWithin,
skipId,
locale = 'en',
} = props
const filterLocation = lat && lon && radius
const filterRaisedInLocation = raised_in_lat && raised_in_lon && raised_in_radius
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
const keywords = name
? name
.split(',')
.map((q) => q.trim())
.filter(Boolean)
: []
// console.debug('keywords:', keywords)
if (orderByParam === 'compatibility_score' && !compatibleWithUserId) {
@@ -94,40 +136,50 @@ export const loadProfiles = async (props: profileQueryType) => {
throw Error('Incompatible with user ID')
}
const tablePrefix = orderByParam === 'compatibility_score'
? 'compatibility_scores'
: orderByParam === 'last_online_time'
? 'user_activity'
: 'profiles'
const tablePrefix =
orderByParam === 'compatibility_score'
? 'compatibility_scores'
: orderByParam === 'last_online_time'
? 'user_activity'
: 'profiles'
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
const joinInterests = true // !!interests?.length
const joinCauses = !!causes?.length
const joinWork = true // !!work?.length
// Pre-aggregated interests per profile
function getManyToManyJoin(label: OptionTableKey) {
return `(
SELECT
profile_${label}.profile_id,
ARRAY_AGG(${label}.name ORDER BY ${label}.name) AS ${label}
ARRAY_AGG(${label}.id ORDER BY ${label}.id) AS ${label}
FROM profile_${label}
JOIN ${label} ON ${label}.id = profile_${label}.option_id
GROUP BY profile_${label}.profile_id
) profile_${label} ON profile_${label}.profile_id = profiles.id`
}
const interestsJoin = getManyToManyJoin('interests')
const causesJoin = getManyToManyJoin('causes')
const workJoin = getManyToManyJoin('work')
const compatibilityScoreJoin = pgp.as.format(`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`, {compatibleWithUserId})
const compatibilityScoreJoin = pgp.as.format(
`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`,
{compatibleWithUserId},
)
const joins = [
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
interests && leftJoin(interestsJoin),
causes && leftJoin(causesJoin),
work && leftJoin(workJoin),
joinInterests && leftJoin(interestsJoin),
joinCauses && leftJoin(causesJoin),
joinWork && leftJoin(workJoin),
]
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
const _orderBy =
orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
const afterFilter = renderSql(
select(_orderBy),
from('profiles'),
@@ -146,115 +198,175 @@ export const loadProfiles = async (props: profileQueryType) => {
SELECT 1 FROM profile_${label}
JOIN ${label} ON ${label}.id = profile_${label}.option_id
WHERE profile_${label}.profile_id = profiles.id
AND ${label}.name = ANY (ARRAY[$(values)])
AND ${label}.id = ANY (ARRAY[$(values)])
)`
}
function getOptionClauseKeyword(label: OptionTableKey) {
return `EXISTS (
SELECT 1 FROM profile_${label}
JOIN ${label} ON ${label}.id = profile_${label}.option_id
LEFT JOIN ${label}_translations
ON ${label}_translations.option_id = profile_${label}.option_id
AND ${label}_translations.locale = $(locale)
WHERE profile_${label}.profile_id = profiles.id
AND lower(COALESCE(${label}_translations.name, ${label}.name)) ILIKE '%' || lower($(word)) || '%'
)`
}
const filters = [
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)`
),
where(`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`),
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
...keywords.map(word => where(
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
{word}
)),
...keywords.map((word) =>
where(
`lower(users.name) ilike '%' || lower($(word)) || '%'
or lower(search_text) ilike '%' || lower($(word)) || '%'
or search_tsv @@ phraseto_tsquery('english', $(word))
OR ${getOptionClauseKeyword('interests')}
OR ${getOptionClauseKeyword('causes')}
OR ${getOptionClauseKeyword('work')}
`,
{word, locale},
),
),
genders?.length && where(`gender = ANY($(genders))`, {genders}),
education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}),
education_levels?.length &&
where(`education_level = ANY($(education_levels))`, {education_levels}),
mbti?.length && where(`mbti = ANY($(mbti))`, {mbti}),
pref_gender?.length &&
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {pref_gender}),
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {
pref_gender,
}),
pref_age_min &&
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
pref_age_min && where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
pref_age_max &&
where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
pref_age_max && where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
drinks_min &&
where(`drinks_per_month >= $(drinks_min) or drinks_per_month is null`, {drinks_min}),
where(`drinks_per_month >= $(drinks_min) or drinks_per_month is null`, {drinks_min}),
drinks_max &&
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}),
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}),
big5_openness_min &&
where(`big5_openness >= $(big5_openness_min) or big5_openness is null`, {big5_openness_min}),
big5_openness_max &&
where(`big5_openness <= $(big5_openness_max) or big5_openness is null`, {big5_openness_max}),
big5_conscientiousness_min &&
where(
`big5_conscientiousness >= $(big5_conscientiousness_min) or big5_conscientiousness is null`,
{big5_conscientiousness_min},
),
big5_conscientiousness_max &&
where(
`big5_conscientiousness <= $(big5_conscientiousness_max) or big5_conscientiousness is null`,
{big5_conscientiousness_max},
),
big5_extraversion_min &&
where(`big5_extraversion >= $(big5_extraversion_min) or big5_extraversion is null`, {
big5_extraversion_min,
}),
big5_extraversion_max &&
where(`big5_extraversion <= $(big5_extraversion_max) or big5_extraversion is null`, {
big5_extraversion_max,
}),
big5_agreeableness_min &&
where(`big5_agreeableness >= $(big5_agreeableness_min) or big5_agreeableness is null`, {
big5_agreeableness_min,
}),
big5_agreeableness_max &&
where(`big5_agreeableness <= $(big5_agreeableness_max) or big5_agreeableness is null`, {
big5_agreeableness_max,
}),
big5_neuroticism_min &&
where(`big5_neuroticism >= $(big5_neuroticism_min) or big5_neuroticism is null`, {
big5_neuroticism_min,
}),
big5_neuroticism_max &&
where(`big5_neuroticism <= $(big5_neuroticism_max) or big5_neuroticism is null`, {
big5_neuroticism_max,
}),
pref_relation_styles?.length &&
where(
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
{pref_relation_styles}
),
where(
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
{pref_relation_styles},
),
pref_romantic_styles?.length &&
where(
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
{pref_romantic_styles}
),
where(
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
{pref_romantic_styles},
),
diet?.length &&
where(
`diet IS NULL OR diet = '{}' OR diet && $(diet)`,
{diet}
),
diet?.length && where(`diet IS NULL OR diet = '{}' OR diet && $(diet)`, {diet}),
political_beliefs?.length &&
where(
`political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`,
{political_beliefs}
),
where(
`political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`,
{political_beliefs},
),
relationship_status?.length &&
where(
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
{relationship_status}
),
where(
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
{relationship_status},
),
languages?.length &&
where(
`languages && $(languages)`,
{languages}
),
languages?.length && where(`languages && $(languages)`, {languages}),
religion?.length &&
where(
`religion IS NULL OR religion = '{}' OR religion && $(religion)`,
{religion}
),
where(`religion IS NULL OR religion = '{}' OR religion && $(religion)`, {religion}),
interests?.length && where(getManyToManyClause('interests'), {values: interests}),
interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}),
causes?.length && where(getManyToManyClause('causes'), {values: causes}),
causes?.length && where(getManyToManyClause('causes'), {values: causes.map(Number)}),
work?.length && where(getManyToManyClause('work'), {values: work}),
work?.length && where(getManyToManyClause('work'), {values: work.map(Number)}),
!!wants_kids_strength &&
wants_kids_strength !== -1 &&
where(
'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' + (wants_kids_strength >= 2 ? `wants_kids_strength >= $(wants_kids_strength)` : `wants_kids_strength <= $(wants_kids_strength)`),
{wants_kids_strength}
),
wants_kids_strength !== -1 &&
where(
'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' +
(wants_kids_strength >= 2
? `wants_kids_strength >= $(wants_kids_strength)`
: `wants_kids_strength <= $(wants_kids_strength)`),
{wants_kids_strength},
),
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
has_kids && has_kids > 0 && where(`has_kids > 0`),
is_smoker !== undefined && (
is_smoker !== undefined &&
where(
(is_smoker ? '' : 'is_smoker IS NULL OR ') + // smokers are rare, so we don't include the people who didn't answer if we're looking for smokers
`is_smoker = $(is_smoker)`, {is_smoker}
)
),
`is_smoker = $(is_smoker)`,
{is_smoker},
),
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
geodbCityIds?.length && where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
filterLocation && where(`
filterLocation &&
where(
`
city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0)
AND $(target_lat) + ($(radius) / 69.0)
AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
@@ -263,13 +375,51 @@ export const loadProfiles = async (props: profileQueryType) => {
POWER(city_latitude - $(target_lat), 2)
+ POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
) <= $(radius) / 69.0
`, {target_lat: lat, target_lon: lon, radius}),
`,
{target_lat: lat, target_lon: lon, radius},
),
filterRaisedInLocation &&
where(
`
raised_in_lat BETWEEN $(target_lat) - ($(radius) / 69.0)
AND $(target_lat) + ($(radius) / 69.0)
AND raised_in_lon BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
AND SQRT(
POWER(raised_in_lat - $(target_lat), 2)
+ POWER((raised_in_lon - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
) <= $(radius) / 69.0
`,
{target_lat: raised_in_lat, target_lon: raised_in_lon, radius: raised_in_radius},
),
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
!shortBio &&
where(
`bio_length >= ${100}
OR array_length(profile_work.work, 1) > 0
OR array_length(profile_interests.interests, 1) > 0
OR occupation_title IS NOT NULL
`,
),
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
lastModificationWithin &&
where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {
lastModificationWithin,
}),
// Exclude profiles that the requester has chosen to hide
userId &&
where(
`NOT EXISTS (
SELECT 1 FROM hidden_profiles hp
WHERE hp.hider_user_id = $(userId)
AND hp.hidden_user_id = profiles.user_id
)`,
{userId},
),
]
let selectCols = 'profiles.*, users.name, users.username, users.data as user'
@@ -278,9 +428,9 @@ export const loadProfiles = async (props: profileQueryType) => {
} else if (orderByParam === 'last_online_time') {
selectCols += ', user_activity.last_online_time'
}
if (interests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
if (causes) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
if (work) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
if (joinInterests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
if (joinCauses) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
if (joinWork) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
const query = renderSql(
select(selectCols),
@@ -297,11 +447,7 @@ export const loadProfiles = async (props: profileQueryType) => {
// console.debug('profiles:', profiles)
const countQuery = renderSql(
select(`count(*) as count`),
...tableSelection,
...filters,
)
const countQuery = renderSql(select(`count(*) as count`), ...tableSelection, ...filters)
const count = await pg.one<number>(countQuery, [], (r) => Number(r.count))
@@ -311,7 +457,7 @@ export const loadProfiles = async (props: profileQueryType) => {
export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => {
try {
if (!props.skipId) props.skipId = auth.uid
const {profiles, count} = await loadProfiles(props)
const {profiles, count} = await loadProfiles({...props, userId: auth.uid})
return {status: 'success', profiles: profiles, count: count}
} catch (error) {
console.log(error)

View File

@@ -1,11 +1,9 @@
import {ENV_CONFIG} from 'common/envs/constants'
import {sign} from 'jsonwebtoken'
import {APIError, APIHandler} from './helpers/endpoint'
import {ENV_CONFIG} from "common/envs/constants";
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
_,
auth
) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (_, auth) => {
const jwtSecret = process.env.SUPABASE_JWT_SECRET
if (jwtSecret == null) {
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")

View File

@@ -0,0 +1,180 @@
import {Row} from 'common/supabase/utils'
import {parseJsonContentToText} from 'common/util/parse'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {parseMessageObject} from 'shared/supabase/messages'
import {getLikesAndShipsMain} from './get-likes-and-ships'
import {APIHandler} from './helpers/endpoint'
export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
const userId = auth.uid
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [userId])
const privateUser = await pg.oneOrNone<Row<'private_users'>>(
'select * from private_users where id = $1',
[userId],
)
const profile = await pg.oneOrNone(
`
select profiles.*,
users.name,
users.username,
users.data as "user",
COALESCE(profile_interests.interests, '{}') as interests,
COALESCE(profile_causes.causes, '{}') as causes,
COALESCE(profile_work.work, '{}') as work
from profiles
join users on users.id = profiles.user_id
left join (select pi.profile_id,
array_agg(i.name order by i.id) as interests
from profile_interests pi
join interests i on i.id = pi.option_id
group by pi.profile_id) as profile_interests on profile_interests.profile_id = profiles.id
left join (select pc.profile_id,
array_agg(c.name order by c.id) as causes
from profile_causes pc
join causes c on c.id = pc.option_id
group by pc.profile_id) as profile_causes on profile_causes.profile_id = profiles.id
left join (select pw.profile_id,
array_agg(w.name order by w.id) as work
from profile_work pw
join work w on w.id = pw.option_id
group by pw.profile_id) as profile_work on profile_work.profile_id = profiles.id
where profiles.user_id = $1
`,
[userId],
)
if (profile.bio) {
profile.bio_clean = parseJsonContentToText(profile.bio).replace(/\n/g, ' ').trim()
}
const compatibilityAnswers = await pg.manyOrNone(
`
select a.*,
p.question,
p.answer_type,
p.multiple_choice_options,
p.category,
p.importance_score
from compatibility_answers a
join compatibility_prompts p
on p.id = a.question_id
where a.creator_id = $1
order by a.created_time desc
`,
[userId],
)
const userActivity = await pg.oneOrNone<Row<'user_activity'>>(
'select * from user_activity where user_id = $1',
[userId],
)
const searchBookmarks = await pg.manyOrNone<Row<'bookmarked_searches'>>(
'select * from bookmarked_searches where creator_id = $1 order by id desc',
[userId],
)
const hiddenProfiles = await pg.manyOrNone(
`select hp.id, hp.hidden_user_id, hp.created_time, u.username
from hidden_profiles hp
join users u on u.id = hp.hidden_user_id
where hp.hider_user_id = $1
order by hp.id desc`,
[userId],
)
const messageChannelMemberships = await pg.manyOrNone<
Row<'private_user_message_channel_members'>
>('select * from private_user_message_channel_members where user_id = $1', [userId])
const channelIds = Array.from(new Set(messageChannelMemberships.map((m) => m.channel_id)))
const messageChannels = channelIds.length
? await pg.manyOrNone<Row<'private_user_message_channels'>>(
'select * from private_user_message_channels where id = any($1)',
[channelIds],
)
: []
const messages = channelIds.length
? await pg.manyOrNone<Row<'private_user_messages'>>(
`select *
from private_user_messages
where channel_id = any ($1)
order by created_time`,
[channelIds],
)
: []
for (const message of messages) parseMessageObject(message)
const membershipsWithUsernames = channelIds.length
? await pg.manyOrNone(
`
select m.*,
u.username
from private_user_message_channel_members m
join users u on u.id = m.user_id
where m.channel_id = any ($1)
and m.user_id != $2
`,
[channelIds, userId],
)
: []
const endorsements = await getLikesAndShipsMain(userId)
const accountMetadata = {
// userData: (user as any)?.data ?? null,
userActivity,
}
const voteAnswers = await pg.manyOrNone(
`
select r.*,
v.title,
v.description,
v.is_anonymous,
v.status,
v.created_time as vote_created_time
from vote_results r
join votes v on v.id = r.vote_id
where r.user_id = $1
order by v.created_time desc
`,
[userId],
)
const reports = await pg.manyOrNone<Row<'reports'>>(
'select * from reports where user_id = $1 order by created_time desc nulls last',
[userId],
)
const contactMessages = await pg.manyOrNone<Row<'contact'>>(
'select * from contact where user_id = $1 order by created_time desc nulls last',
[userId],
)
return {
user,
privateUser,
profile,
compatibilityAnswers,
voteAnswers,
messages: {
channels: messageChannels,
memberships: membershipsWithUsernames,
messages,
},
endorsements,
searchBookmarks,
hiddenProfiles,
reports,
contactMessages,
accountMetadata,
}
}

View File

@@ -1,15 +1,15 @@
import { toUserAPIResponse } from 'common/api/user-types'
import { convertUser } from 'common/supabase/users'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError } from 'common/api/utils'
import {toUserAPIResponse} from 'common/api/user-types'
import {APIError} from 'common/api/utils'
import {convertUser} from 'common/supabase/users'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getUser = async (props: { id: string } | { username: string }) => {
export const getUser = async (props: {id: string} | {username: string}) => {
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone(
`select * from users
where ${'id' in props ? 'id' : 'username'} = $1`,
['id' in props ? props.id : props.username],
(r) => (r ? convertUser(r) : null)
(r) => (r ? convertUser(r) : null),
)
if (!user) throw new APIError(404, 'User not found')

View File

@@ -1,10 +1,7 @@
import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import {type APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const hasFreeLike: APIHandler<'has-free-like'> = async (
_props,
auth
) => {
export const hasFreeLike: APIHandler<'has-free-like'> = async (_props, auth) => {
return {
status: 'success',
hasFreeLike: await getHasFreeLike(auth.uid),
@@ -23,7 +20,7 @@ export const getHasFreeLike = async (userId: string) => {
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' < ((now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date + interval '1 day')
limit 1
`,
[userId]
[userId],
)
return !likeGivenToday
}

View File

@@ -1,6 +1,6 @@
import { APIHandler } from './helpers/endpoint'
import {git} from './../metadata.json'
import {version as pkgVersion} from './../package.json'
import {APIHandler} from './helpers/endpoint'
export const health: APIHandler<'health'> = async (_, auth) => {
return {

View File

@@ -1,11 +1,16 @@
import * as admin from 'firebase-admin'
import {z} from 'zod'
import {NextFunction, Request, Response} from 'express'
import {PrivateUser} from 'common/user'
import {
API,
APIPath,
APIResponseOptionalContinue,
APISchema,
ValidatedAPIParams,
} from 'common/api/schema'
import {APIError} from 'common/api/utils'
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
import {PrivateUser} from 'common/user'
import {NextFunction, Request, Response} from 'express'
import * as admin from 'firebase-admin'
import {getPrivateUserByKey, log} from 'shared/utils'
import {z} from 'zod'
export {APIError} from 'common/api/utils'
@@ -27,10 +32,10 @@ export {APIError} from 'common/api/utils'
export type AuthedUser = {
uid: string
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
creds: JwtCredentials | (KeyCredentials & {privateUser: PrivateUser})
}
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
type JwtCredentials = {kind: 'jwt'; data: admin.auth.DecodedIdToken}
type KeyCredentials = {kind: 'key'; data: string}
type Credentials = JwtCredentials | KeyCredentials
// export async function verifyIdToken(payload: string): Promise<DecodedIdToken> {
@@ -76,8 +81,8 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
try {
return {kind: 'jwt', data: await auth.verifyIdToken(payload)}
} catch (err) {
const raw = payload.split(".")[0];
console.log("JWT header:", JSON.parse(Buffer.from(raw, "base64").toString()));
const raw = payload.split('.')[0]
console.log('JWT header:', JSON.parse(Buffer.from(raw, 'base64').toString()))
// This is somewhat suspicious, so get it into the firebase console
console.error('Error verifying Firebase JWT: ', err, scheme, payload)
throw new APIError(500, 'Error validating token.')
@@ -170,10 +175,8 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
export type APIHandler<N extends APIPath> = (
props: ValidatedAPIParams<N>,
auth: APISchema<N> extends { authed: true }
? AuthedUser
: AuthedUser | undefined,
req: Request
auth: APISchema<N> extends {authed: true} ? AuthedUser : AuthedUser | undefined,
req: Request,
) => Promise<APIResponseOptionalContinue<N>>
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
@@ -182,7 +185,7 @@ export type APIHandler<N extends APIPath> = (
// API_RATE_LIMIT_PER_MIN_AUTHED
// API_RATE_LIMIT_PER_MIN_UNAUTHED
// Endpoints can be exempted by adding their name to RATE_LIMIT_EXEMPT (comma-separated)
const __rateLimitState: Map<string, { windowStart: number; count: number }> = new Map()
const __rateLimitState: Map<string, {windowStart: number; count: number}> = new Map()
function getRateLimitConfig() {
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
@@ -228,11 +231,13 @@ function checkRateLimit(name: string, req: Request, res: Response, auth?: Authed
}
}
export const typedEndpoint = <N extends APIPath>(
name: N,
handler: APIHandler<N>
) => {
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>) => {
const {
props: propSchema,
authed: authRequired,
rateLimited = false,
method,
} = API[name] as APISchema<N>
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
@@ -260,16 +265,14 @@ export const typedEndpoint = <N extends APIPath>(
const resultOptionalContinue = await handler(
validate(propSchema, props),
authUser as AuthedUser,
req
req,
)
const hasContinue =
resultOptionalContinue &&
'continue' in resultOptionalContinue &&
'result' in resultOptionalContinue
const result = hasContinue
? resultOptionalContinue.result
: resultOptionalContinue
const result = hasContinue ? resultOptionalContinue.result : resultOptionalContinue
if (!res.headersSent) {
// Convert bigint to number, b/c JSON doesn't support bigint.

View File

@@ -1,23 +1,23 @@
import {Json} from 'common/supabase/schema'
import {SupabaseDirectClient} from 'shared/supabase/init'
import {ChatVisibility} from 'common/chat-message'
import {User} from 'common/user'
import {first} from 'lodash'
import {log} from 'shared/monitoring/log'
import {getPrivateUser, getUser} from 'shared/utils'
import {type JSONContent} from '@tiptap/core'
import {APIError} from 'common/api/utils'
import {broadcast} from 'shared/websockets/server'
import {track} from 'shared/analytics'
import {sendNewMessageEmail} from 'email/functions/helpers'
import {ChatVisibility} from 'common/chat-message'
import {Json} from 'common/supabase/schema'
import {User} from 'common/user'
import {parseJsonContentToText} from 'common/util/parse'
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 utc from 'dayjs/plugin/utc'
import {sendNewMessageEmail} from 'email/functions/helpers'
import * as admin from 'firebase-admin'
import {TokenMessage} from "firebase-admin/lib/messaging/messaging-api";
import {TokenMessage} from 'firebase-admin/lib/messaging/messaging-api'
import {first} from 'lodash'
import {track} from 'shared/analytics'
import {encryptMessage} from 'shared/encryption'
import {log} from 'shared/monitoring/log'
import {SupabaseDirectClient} from 'shared/supabase/init'
import {getPrivateUser, getUser} from 'shared/utils'
import {broadcast} from 'shared/websockets/server'
import webPush from 'web-push'
dayjs.extend(utc)
dayjs.extend(timezone)
@@ -48,7 +48,7 @@ export const insertPrivateMessage = async (
channelId: number,
userId: string,
visibility: ChatVisibility,
pg: SupabaseDirectClient
pg: SupabaseDirectClient,
) => {
const plaintext = JSON.stringify(content)
const {ciphertext, iv, tag} = encryptMessage(plaintext)
@@ -56,20 +56,20 @@ export const insertPrivateMessage = async (
`insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility)
values ($1, $2, $3, $4, $5, $6)
returning created_time`,
[ciphertext, iv, tag, channelId, userId, visibility]
[ciphertext, iv, tag, channelId, userId, visibility],
)
await pg.none(
`update private_user_message_channels
set last_updated_time = $1
where id = $2`,
[lastMessage.created_time, channelId]
[lastMessage.created_time, channelId],
)
}
export const addUsersToPrivateMessageChannel = async (
userIds: string[],
channelId: number,
pg: SupabaseDirectClient
pg: SupabaseDirectClient,
) => {
await Promise.all(
userIds.map((id) =>
@@ -78,15 +78,15 @@ export const addUsersToPrivateMessageChannel = async (
values ($1, $2, 'member', 'proposed')
on conflict do nothing
`,
[channelId, id]
)
)
[channelId, id],
),
),
)
await pg.none(
`update private_user_message_channels
set last_updated_time = now()
where id = $1`,
[channelId]
[channelId],
)
}
@@ -103,12 +103,12 @@ export async function broadcastPrivateMessages(
and status != 'left'
`,
[channelId, userId],
(r) => r.user_id
(r) => r.user_id,
)
otherUserIds.concat(userId).forEach((otherUserId) => {
broadcast(`private-user-messages/${otherUserId}`, {})
})
return otherUserIds;
return otherUserIds
}
export const createPrivateUserMessageMain = async (
@@ -116,7 +116,7 @@ export const createPrivateUserMessageMain = async (
channelId: number,
content: JSONContent,
pg: SupabaseDirectClient,
visibility: ChatVisibility
visibility: ChatVisibility,
) => {
log('createPrivateUserMessageMain', creator, channelId, content)
@@ -126,10 +126,9 @@ export const createPrivateUserMessageMain = async (
from private_user_message_channel_members
where channel_id = $1
and user_id = $2`,
[channelId, creator.id]
[channelId, creator.id],
)
if (!authorized)
throw new APIError(403, 'You are not authorized to post to this channel')
if (!authorized) throw new APIError(403, 'You are not authorized to post to this channel')
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
@@ -138,13 +137,12 @@ export const createPrivateUserMessageMain = async (
channel_id: channelId,
user_id: creator.id,
}
const otherUserIds = await broadcastPrivateMessages(pg, channelId, creator.id);
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)
})
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg).catch((err) => {
console.error('notifyOtherUserInChannelIfInactive failed', err)
})
track(creator.id, 'send private message', {
channelId,
@@ -158,16 +156,16 @@ const notifyOtherUserInChannelIfInactive = async (
channelId: number,
creator: User,
content: JSONContent,
pg: SupabaseDirectClient
pg: SupabaseDirectClient,
) => {
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
const otherUserIds = await pg.manyOrNone<{user_id: string}>(
`select user_id
from private_user_message_channel_members
where channel_id = $1
and user_id != $2
and status != 'left'
`,
[channelId, creator.id]
[channelId, creator.id],
)
// We're only sending notifs for 1:1 channels
if (!otherUserIds || otherUserIds.length > 1) return
@@ -191,10 +189,7 @@ const notifyOtherUserInChannelIfInactive = async (
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
await sendMobileNotifications(pg, receiverId, payload)
const startOfDay = dayjs()
.tz('America/Los_Angeles')
.startOf('day')
.toISOString()
const startOfDay = dayjs().tz('America/Los_Angeles').startOf('day').toISOString()
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
`select count(*)
from private_user_messages
@@ -202,7 +197,7 @@ const notifyOtherUserInChannelIfInactive = async (
and user_id = $2
and created_time > $3
`,
[channelId, creator.id, startOfDay]
[channelId, creator.id, startOfDay],
)
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
if (previousMessagesThisDayBetweenTheseUsers.count > 1) return
@@ -210,27 +205,18 @@ const notifyOtherUserInChannelIfInactive = async (
await createNewMessageNotification(creator, receiver, channelId)
}
const createNewMessageNotification = async (
fromUser: User,
toUser: User,
channelId: number,
) => {
const createNewMessageNotification = async (fromUser: User, toUser: User, channelId: number) => {
const privateUser = await getPrivateUser(toUser.id)
console.debug('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}
async function sendWebNotifications(
pg: SupabaseDirectClient,
userId: string,
payload: string,
) {
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!
process.env.VAPID_PRIVATE_KEY!,
)
// Retrieve subscription from the database
const subscriptions = await getSubscriptionsFromDB(pg, userId)
@@ -250,20 +236,18 @@ async function sendWebNotifications(
}
}
export async function getSubscriptionsFromDB(
pg: SupabaseDirectClient,
userId: string,
) {
export async function getSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
try {
const subscriptions = await pg.manyOrNone(`
const subscriptions = await pg.manyOrNone(
`
select endpoint, keys
from push_subscriptions
where user_id = $1
`, [userId]
`,
[userId],
)
return subscriptions.map(sub => ({
return subscriptions.map((sub) => ({
endpoint: sub.endpoint,
keys: sub.keys,
}))
@@ -273,35 +257,26 @@ export async function getSubscriptionsFromDB(
}
}
async function removeSubscription(
pg: SupabaseDirectClient,
endpoint: any,
userId: string,
) {
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]
[endpoint, userId],
)
}
async function removeMobileSubscription(
pg: SupabaseDirectClient,
token: any,
userId: string,
) {
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]
[token, userId],
)
}
async function sendMobileNotifications(
pg: SupabaseDirectClient,
userId: string,
@@ -349,13 +324,15 @@ export async function sendPushToToken(
} 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 }
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') {
if (
firebaseError.code === 'messaging/registration-token-not-registered' ||
firebaseError.code === 'messaging/invalid-argument'
) {
console.warn('Removing invalid FCM token')
await removeMobileSubscription(pg, token, userId)
}
@@ -366,17 +343,15 @@ export async function sendPushToToken(
return
}
export async function getMobileSubscriptionsFromDB(
pg: SupabaseDirectClient,
userId: string,
) {
export async function getMobileSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
try {
const subscriptions = await pg.manyOrNone(`
const subscriptions = await pg.manyOrNone(
`
select token
from push_subscriptions_mobile
where user_id = $1
`, [userId]
`,
[userId],
)
return subscriptions

View File

@@ -1,35 +1,25 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { isAdminId } from 'common/envs/constants'
import { convertComment } from 'common/supabase/comment'
import { Row } from 'common/supabase/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {convertComment} from 'common/supabase/comment'
import {Row} from 'common/supabase/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {broadcastUpdatedComment} from 'shared/websockets/helpers'
export const hideComment: APIHandler<'hide-comment'> = async (
{ commentId, hide },
auth
) => {
export const hideComment: APIHandler<'hide-comment'> = async ({commentId, hide}, auth) => {
const pg = createSupabaseDirectClient()
const comment = await pg.oneOrNone<Row<'profile_comments'>>(
`select * from profile_comments where id = $1`,
[commentId]
[commentId],
)
if (!comment) {
throw new APIError(404, 'Comment not found')
}
if (
!isAdminId(auth.uid) &&
comment.user_id !== auth.uid &&
comment.on_user_id !== auth.uid
) {
if (!isAdminId(auth.uid) && comment.user_id !== auth.uid && comment.on_user_id !== auth.uid) {
throw new APIError(403, 'You are not allowed to hide this comment')
}
await pg.none(`update profile_comments set hidden = $2 where id = $1`, [
commentId,
hide,
])
await pg.none(`update profile_comments set hidden = $2 where id = $1`, [commentId, hide])
broadcastUpdatedComment(convertComment(comment))
}

View File

@@ -0,0 +1,21 @@
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError, APIHandler} from './helpers/endpoint'
// Hide a profile for the requesting user by inserting a row into hidden_profiles.
// Idempotent: if the pair already exists, succeed silently.
export const hideProfile: APIHandler<'hide-profile'> = async ({hiddenUserId}, auth) => {
if (auth.uid === hiddenUserId) throw new APIError(400, 'You cannot hide yourself')
const pg = createSupabaseDirectClient()
// Insert idempotently: do nothing on conflict
await pg.none(
`insert into hidden_profiles (hider_user_id, hidden_user_id)
values ($1, $2)
on conflict (hider_user_id, hidden_user_id) do nothing`,
[auth.uid, hiddenUserId],
)
return {status: 'success'}
}

View File

@@ -1,14 +1,11 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { log, getUser } from 'shared/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import {
insertPrivateMessage,
leaveChatContent,
} from 'api/helpers/private-messages'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {insertPrivateMessage, leaveChatContent} from 'api/helpers/private-messages'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {getUser, log} from 'shared/utils'
export const leavePrivateUserMessageChannel: APIHandler<
'leave-private-user-message-channel'
> = async ({ channelId }, auth) => {
> = async ({channelId}, auth) => {
const pg = createSupabaseDirectClient()
const user = await getUser(auth.uid)
if (!user) throw new APIError(401, 'Your account was not found')
@@ -16,10 +13,9 @@ export const leavePrivateUserMessageChannel: APIHandler<
const membershipStatus = await pg.oneOrNone(
`select status from private_user_message_channel_members
where channel_id = $1 and user_id = $2`,
[channelId, auth.uid]
[channelId, auth.uid],
)
if (!membershipStatus)
throw new APIError(403, 'You are not authorized to post to this channel')
if (!membershipStatus) throw new APIError(403, 'You are not authorized to post to this channel')
log('membershipStatus: ' + membershipStatus)
// add message that the user left the channel
@@ -29,15 +25,9 @@ export const leavePrivateUserMessageChannel: APIHandler<
set status = 'left'
where channel_id=$1 and user_id=$2;
`,
[channelId, auth.uid]
[channelId, auth.uid],
)
await insertPrivateMessage(
leaveChatContent(user.name),
channelId,
auth.uid,
'system_status',
pg
)
return { status: 'success', channelId: Number(channelId) }
await insertPrivateMessage(leaveChatContent(user.name), channelId, auth.uid, 'system_status', pg)
return {status: 'success', channelId: Number(channelId)}
}

View File

@@ -1,42 +1,43 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError, APIHandler } from './helpers/endpoint'
import { createProfileLikeNotification } from 'shared/create-profile-notification'
import { getHasFreeLike } from './has-free-like'
import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
import { Row } from 'common/supabase/utils'
import {Row} from 'common/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {createProfileLikeNotification} from 'shared/create-profile-notification'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
import {getHasFreeLike} from './has-free-like'
import {APIError, APIHandler} from './helpers/endpoint'
export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
const { targetUserId, remove } = props
const {targetUserId, remove} = props
const creatorId = auth.uid
const pg = createSupabaseDirectClient()
if (remove) {
const { error } = await tryCatch(
pg.none(
'delete from profile_likes where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
const {error} = await tryCatch(
pg.none('delete from profile_likes where creator_id = $1 and target_id = $2', [
creatorId,
targetUserId,
]),
)
if (error) {
throw new APIError(500, 'Failed to remove like: ' + error.message)
}
return { status: 'success' }
return {status: 'success'}
}
// Check if like already exists
const { data: existing } = await tryCatch(
const {data: existing} = await tryCatch(
pg.oneOrNone<Row<'profile_likes'>>(
'select * from profile_likes where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
[creatorId, targetUserId],
),
)
if (existing) {
log('Like already exists, do nothing')
return { status: 'success' }
return {status: 'success'}
}
const hasFreeLike = await getHasFreeLike(creatorId)
@@ -47,11 +48,11 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
}
// Insert the new like
const { data, error } = await tryCatch(
const {data, error} = await tryCatch(
pg.one<Row<'profile_likes'>>(
'insert into profile_likes (creator_id, target_id) values ($1, $2) returning *',
[creatorId, targetUserId]
)
[creatorId, targetUserId],
),
)
if (error) {
@@ -63,7 +64,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
}
return {
result: { status: 'success' },
result: {status: 'success'},
continue: continuation,
}
}

View File

@@ -1,16 +1,14 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIHandler } from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const markAllNotifsRead: APIHandler<'mark-all-notifs-read'> = async (
_,
auth
) => {
import {APIHandler} from './helpers/endpoint'
export const markAllNotifsRead: APIHandler<'mark-all-notifs-read'> = async (_, auth) => {
const pg = createSupabaseDirectClient()
await pg.none(
`update user_notifications
SET data = jsonb_set(data, '{isSeen}', 'true'::jsonb)
where user_id = $1
and data->>'isSeen' = 'false'`,
[auth.uid]
[auth.uid],
)
}

View File

@@ -1,106 +1,135 @@
@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;
}
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;
}
}
/* Increase font sizes on mobile for better readability */
/* Still not working though */
@media (max-width: 640px) {
html,
body,
.swagger-ui {
font-size: 32px !important;
line-height: 1.5 !important;
}
html,
body,
.swagger-ui {
font-size: 32px !important;
line-height: 1.5 !important;
}
.swagger-ui {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
.swagger-ui {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Common text elements */
.swagger-ui p,
.swagger-ui label,
.swagger-ui .btn,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type,
.swagger-ui .parameter__in,
.swagger-ui .response-control-media-type__title,
.swagger-ui table thead tr td,
.swagger-ui table thead tr th,
.swagger-ui table tbody tr td,
.swagger-ui .tab li,
.swagger-ui .response-col_links,
.swagger-ui .opblock-summary-path,
.swagger-ui .opblock-summary-description {
font-size: 32px !important;
}
/* Common text elements */
.swagger-ui p,
.swagger-ui label,
.swagger-ui .btn,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type,
.swagger-ui .parameter__in,
.swagger-ui .response-control-media-type__title,
.swagger-ui table thead tr td,
.swagger-ui table thead tr th,
.swagger-ui table tbody tr td,
.swagger-ui .tab li,
.swagger-ui .response-col_links,
.swagger-ui .opblock-summary-path,
.swagger-ui .opblock-summary-description {
font-size: 32px !important;
}
/* Headings scale */
.swagger-ui h1 { font-size: 1.75rem !important; }
.swagger-ui h2 { font-size: 1.5rem !important; }
.swagger-ui h3 { font-size: 1.25rem !important; }
.swagger-ui h4 { font-size: 1.125rem !important; }
.swagger-ui h5, .swagger-ui h6 { font-size: 1rem !important; }
/* Headings scale */
.swagger-ui h1 {
font-size: 1.75rem !important;
}
.swagger-ui h2 {
font-size: 1.5rem !important;
}
.swagger-ui h3 {
font-size: 1.25rem !important;
}
.swagger-ui h4 {
font-size: 1.125rem !important;
}
.swagger-ui h5,
.swagger-ui h6 {
font-size: 1rem !important;
}
}

View File

@@ -1,9 +1,12 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {broadcastPrivateMessages} from "api/helpers/private-messages";
import {APIError, APIHandler} from './helpers/endpoint'
export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId, reaction, toDelete}, auth) => {
export const reactToMessage: APIHandler<'react-to-message'> = async (
{messageId, reaction, toDelete},
auth,
) => {
const pg = createSupabaseDirectClient()
// Verify user is a member of the channel
@@ -13,7 +16,7 @@ export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId,
JOIN private_user_messages msg ON msg.channel_id = m.channel_id
WHERE m.user_id = $1
AND msg.id = $2`,
[auth.uid, messageId]
[auth.uid, messageId],
)
if (!message) {
@@ -27,7 +30,7 @@ export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId,
SET reactions = reactions - $1
WHERE id = $2
AND reactions -> $1 ? $3`,
[reaction, messageId, auth.uid]
[reaction, messageId, auth.uid],
)
} else {
// Toggle reaction
@@ -47,14 +50,13 @@ export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId,
)
END
WHERE id = $3`,
[reaction, auth.uid, messageId]
[reaction, auth.uid, messageId],
)
}
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
.catch((err) => {
console.error('broadcastPrivateMessages failed', err)
})
void broadcastPrivateMessages(pg, message.channel_id, auth.uid).catch((err) => {
console.error('broadcastPrivateMessages failed', err)
})
return {success: true}
}

View File

@@ -1,23 +1,21 @@
import { APIError } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { type APIHandler } from 'api/helpers/endpoint'
import { isAdminId } from 'common/envs/constants'
import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
import {APIError, type APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
export const removePinnedPhoto: APIHandler<'remove-pinned-photo'> = async (
body: { userId: string },
auth
body: {userId: string},
auth,
) => {
const { userId } = body
log('remove pinned url', { userId })
const {userId} = body
log('remove pinned url', {userId})
if (!isAdminId(auth.uid))
throw new APIError(403, 'Only admins can remove pinned photo')
if (!isAdminId(auth.uid)) throw new APIError(403, 'Only admins can remove pinned photo')
const pg = createSupabaseDirectClient()
const { error } = await tryCatch(
pg.none('update profiles set pinned_url = null where user_id = $1', [userId])
const {error} = await tryCatch(
pg.none('update profiles set pinned_url = null where user_id = $1', [userId]),
)
if (error) {

View File

@@ -1,22 +1,16 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {sendDiscordMessage} from 'common/discord/core'
import {DOMAIN} from 'common/envs/constants'
import {Row} from 'common/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {sendDiscordMessage} from "common/discord/core";
import {Row} from "common/supabase/utils";
import {DOMAIN} from "common/envs/constants";
import {APIError, APIHandler} from './helpers/endpoint'
// abusable: people can report the wrong person, that didn't write the comment
// but in practice we check it manually and nothing bad happens to them automatically
export const report: APIHandler<'report'> = async (body, auth) => {
const {
contentOwnerId,
contentType,
contentId,
description,
parentId,
parentType,
} = body
const {contentOwnerId, contentType, contentId, description, parentId, parentType} = body
const pg = createSupabaseDirectClient()
@@ -29,7 +23,7 @@ export const report: APIHandler<'report'> = async (body, auth) => {
description,
parent_id: parentId,
parent_type: parentType,
})
}),
)
if (result.error) {
@@ -39,14 +33,14 @@ export const report: APIHandler<'report'> = async (body, auth) => {
const continuation = async () => {
try {
const {data: reporter, error} = await tryCatch(
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid]),
)
if (error) {
console.error('Failed to get user for report', error)
return
}
const {data: reported, error: userError} = await tryCatch(
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId]),
)
if (userError) {
console.error('Failed to get reported user for report', userError)

View File

@@ -0,0 +1,83 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert, update} from 'shared/supabase/utils'
export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
// Check if event exists and is active
const event = await pg.oneOrNone<{
id: string
status: string
max_participants: number | null
}>(
`SELECT id, status, max_participants
FROM events
WHERE id = $1`,
[body.eventId],
)
if (!event) {
throw new APIError(404, 'Event not found')
}
if (event.status !== 'active') {
throw new APIError(400, 'Cannot RSVP to a cancelled or completed event')
}
// Check if already RSVPed
const existingRsvp = await pg.oneOrNone<{
id: string
}>(
`SELECT id
FROM events_participants
WHERE event_id = $1
AND user_id = $2`,
[body.eventId, auth.uid],
)
if (existingRsvp) {
// Update existing RSVP
const {error} = await tryCatch(
update(pg, 'events_participants', 'id', {
status: body.status,
id: existingRsvp.id,
}),
)
if (error) {
throw new APIError(500, 'Failed to update RSVP: ' + error.message)
}
} else {
// Check max participants limit
if (event.max_participants && body.status === 'going') {
const count = await pg.one<{count: number}>(
`SELECT COUNT(*)
FROM events_participants
WHERE event_id = $1
AND status = 'going'`,
[body.eventId],
)
if (Number(count.count) >= event.max_participants) {
throw new APIError(400, 'Event is at maximum capacity')
}
}
// Create new RSVP
const {error} = await tryCatch(
insert(pg, 'events_participants', {
event_id: body.eventId,
user_id: auth.uid,
status: body.status,
}),
)
if (error) {
throw new APIError(500, 'Failed to RSVP: ' + error.message)
}
}
return {success: true}
}

View File

@@ -1,7 +1,11 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (
body,
auth,
) => {
const {token} = body
if (!token) {
@@ -12,17 +16,18 @@ export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = as
try {
const pg = createSupabaseDirectClient()
await pg.none(`
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};
[token, 'android', userId],
)
return {success: true}
} catch (err) {
console.error('Error saving subscription', err);
console.error('Error saving subscription', err)
throw new APIError(500, `Failed to save subscription`)
}
}

View File

@@ -1,6 +1,7 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError, APIHandler} from './helpers/endpoint'
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
const {subscription} = body
@@ -13,29 +14,29 @@ export const saveSubscription: APIHandler<'save-subscription'> = async (body, au
try {
const pg = createSupabaseDirectClient()
// Check if a subscription already exists
const exists = await pg.oneOrNone(
'select id from push_subscriptions where endpoint = $1',
[subscription.endpoint]
);
const exists = await pg.oneOrNone('select id from push_subscriptions where endpoint = $1', [
subscription.endpoint,
])
if (exists) {
// Already exists, optionally update keys and userId
await pg.none(
'update push_subscriptions set keys = $1, user_id = $2 where id = $3',
[subscription.keys, userId, exists.id]
);
await pg.none('update push_subscriptions set keys = $1, user_id = $2 where id = $3', [
subscription.keys,
userId,
exists.id,
])
} else {
await pg.none(
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
on conflict(endpoint) do update set keys = excluded.keys
`,
[subscription.endpoint, subscription.keys, userId]
);
[subscription.endpoint, subscription.keys, userId],
)
}
return {success: true};
return {success: true}
} catch (err) {
console.error('Error saving subscription', err);
console.error('Error saving subscription', err)
throw new APIError(500, `Failed to save subscription`)
}
}

View File

@@ -1,5 +1,6 @@
import {geodbFetch} from 'common/geodb'
import {APIHandler} from './helpers/endpoint'
import {geodbFetch} from "common/geodb";
export const searchLocation: APIHandler<'search-location'> = async (body) => {
const {term, limit} = body

View File

@@ -1,5 +1,6 @@
import {geodbFetch} from 'common/geodb'
import {APIHandler} from './helpers/endpoint'
import {geodbFetch} from "common/geodb";
const searchNearCityMain = async (cityId: string, radius: number) => {
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
@@ -13,8 +14,6 @@ export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
export const getNearbyCities = async (cityId: string, radius: number) => {
const result = await searchNearCityMain(cityId, radius)
const cityIds = (result.data.data as any[]).map(
(city) => city.id.toString() as string
)
const cityIds = (result.data.data as any[]).map((city) => city.id.toString() as string)
return cityIds
}

View File

@@ -1,10 +1,11 @@
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 {convertUser} from 'common/supabase/users'
import {uniqBy} from 'lodash'
import {constructPrefixTsQuery} from 'shared/helpers/search'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, limit, orderBy, renderSql, select, where} from 'shared/supabase/sql-builder'
import {type APIHandler} from './helpers/endpoint'
export const searchUsers: APIHandler<'search-users'> = async (props, _auth) => {
const {term, page, limit} = props
@@ -45,19 +46,19 @@ function getSearchUserSQL(props: {
[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)
limit(props.limit, props.offset),
)
}

View File

@@ -1,16 +1,15 @@
import {createSupabaseDirectClient} from "shared/supabase/init";
import {from, renderSql, select} from "shared/supabase/sql-builder";
import {loadProfiles, profileQueryType} from "api/get-profiles";
import {Row} from "common/supabase/utils";
import {sendSearchAlertsEmail} from "email/functions/helpers";
import {MatchesByUserType} from "common/profiles/bookmarked_searches";
import {keyBy} from "lodash";
import {loadProfiles, profileQueryType} from 'api/get-profiles'
import {MatchesByUserType} from 'common/profiles/bookmarked_searches'
import {Row} from 'common/supabase/utils'
import {sendSearchAlertsEmail} from 'email/functions/helpers'
import {keyBy} from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, renderSql, select} from 'shared/supabase/sql-builder'
export function convertSearchRow(row: any): any {
return row
}
export const notifyBookmarkedSearch = async (matches: MatchesByUserType) => {
for (const [_, value] of Object.entries(matches)) {
await sendSearchAlertsEmail(value.user, value.privateUser, value.matches)
@@ -20,43 +19,39 @@ export const notifyBookmarkedSearch = async (matches: MatchesByUserType) => {
export const sendSearchNotifications = async () => {
const pg = createSupabaseDirectClient()
const search_query = renderSql(
select('bookmarked_searches.*'),
from('bookmarked_searches'),
)
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
const search_query = renderSql(select('bookmarked_searches.*'), from('bookmarked_searches'))
const searches = (await pg.map(
search_query,
[],
convertSearchRow,
)) as Row<'bookmarked_searches'>[]
console.debug(`Running ${searches.length} bookmarked searches`)
const _users = await pg.map(
renderSql(
select('users.*'),
from('users'),
),
const _users = (await pg.map(
renderSql(select('users.*'), from('users')),
[],
convertSearchRow
) as Row<'users'>[]
convertSearchRow,
)) as Row<'users'>[]
const users = keyBy(_users, 'id')
console.debug('users', users)
const _privateUsers = await pg.map(
renderSql(
select('private_users.*'),
from('private_users'),
),
const _privateUsers = (await pg.map(
renderSql(select('private_users.*'), from('private_users')),
[],
convertSearchRow
) as Row<'private_users'>[]
convertSearchRow,
)) as Row<'private_users'>[]
const privateUsers = keyBy(_privateUsers, 'id')
console.debug('privateUsers', privateUsers)
const matches: MatchesByUserType = {}
for (const row of searches) {
if (typeof row.search_filters !== 'object') continue;
const { orderBy: _, ...filters } = (row.search_filters ?? {}) as Record<string, any>
if (typeof row.search_filters !== 'object') continue
const {orderBy: _, ...filters} = (row.search_filters ?? {}) as Record<string, any>
const props = {
...filters,
skipId: row.creator_id,
userId: row.creator_id,
lastModificationWithin: '24 hours',
shortBio: true,
}
@@ -84,4 +79,4 @@ export const sendSearchNotifications = async () => {
await notifyBookmarkedSearch(matches)
return {status: 'success'}
}
}

View File

@@ -1,12 +1,16 @@
import "tsconfig-paths/register";
import * as admin from 'firebase-admin'
import {initAdmin} from 'shared/init-admin'
import 'tsconfig-paths/register'
import {IS_LOCAL} from 'common/hosting/constants'
import {loadSecretsToEnv} from 'common/secrets'
import {log} from 'shared/utils'
import {IS_LOCAL} from "common/hosting/constants";
import * as admin from 'firebase-admin'
import {getServiceAccountCredentials} from 'shared/firebase-utils'
import {initAdmin} from 'shared/init-admin'
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
import {log} from 'shared/utils'
import {listen as webSocketListen} from 'shared/websockets/server'
import {app} from './app'
log('Api server starting up....')
if (IS_LOCAL) {
@@ -21,13 +25,10 @@ if (IS_LOCAL) {
METRIC_WRITER.start()
import {app} from './app'
import {getServiceAccountCredentials} from "shared/firebase-utils";
const credentials = IS_LOCAL
? getServiceAccountCredentials()
: // No explicit credentials needed for deployed service.
undefined
undefined
const startupProcess = async () => {
await loadSecretsToEnv(credentials)
@@ -40,4 +41,4 @@ const startupProcess = async () => {
webSocketListen(httpServer, '/ws')
}
startupProcess().then(_r => log('Server started successfully'))
startupProcess().then((_r) => log('Server started successfully'))

View File

@@ -1,11 +1,12 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {Row} from 'common/supabase/utils'
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from './helpers/endpoint'
export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = async (
{questionId, multipleChoice, prefChoices, importance, explanation},
auth
auth,
) => {
const pg = createSupabaseDirectClient()
@@ -21,14 +22,7 @@ export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = as
explanation = EXCLUDED.explanation
RETURNING *
`,
values: [
auth.uid,
questionId,
multipleChoice,
prefChoices,
importance,
explanation ?? null,
],
values: [auth.uid, questionId, multipleChoice, prefChoices, importance, explanation ?? null],
})
const continuation = async () => {

View File

@@ -1,19 +1,17 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
_,
auth
) => {
import {APIHandler} from './helpers/endpoint'
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (_, auth) => {
if (!auth || !auth.uid) return
await setLastOnlineTimeUser(auth.uid)
// console.log('setLastOnline')
}
export const setLastOnlineTimeUser = async (userId: string) => {
const pg = createSupabaseDirectClient()
await pg.none(`
await pg.none(
`
INSERT INTO user_activity (user_id, last_online_time)
VALUES ($1, now())
ON CONFLICT (user_id)
@@ -21,6 +19,6 @@ export const setLastOnlineTimeUser = async (userId: string) => {
SET last_online_time = EXCLUDED.last_online_time
WHERE user_activity.last_online_time < now() - interval '1 minute';
`,
[userId]
[userId],
)
}

View File

@@ -1,41 +1,36 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError, APIHandler } from './helpers/endpoint'
import { createProfileShipNotification } from 'shared/create-profile-notification'
import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {createProfileShipNotification} from 'shared/create-profile-notification'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {log} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) => {
const { targetUserId1, targetUserId2, remove } = props
const {targetUserId1, targetUserId2, remove} = props
const creatorId = auth.uid
const pg = createSupabaseDirectClient()
// Check if ship already exists or with swapped target IDs
const existing = await tryCatch(
pg.oneOrNone<{ ship_id: string }>(
pg.oneOrNone<{ship_id: string}>(
`select ship_id from profile_ships
where creator_id = $1
and (
target1_id = $2 and target2_id = $3
or target1_id = $3 and target2_id = $2
)`,
[creatorId, targetUserId1, targetUserId2]
)
[creatorId, targetUserId1, targetUserId2],
),
)
if (existing.error)
throw new APIError(
500,
'Error when checking ship: ' + existing.error.message
)
if (existing.error) throw new APIError(500, 'Error when checking ship: ' + existing.error.message)
if (existing.data) {
if (remove) {
const { error } = await tryCatch(
pg.none('delete from profile_ships where ship_id = $1', [
existing.data.ship_id,
])
const {error} = await tryCatch(
pg.none('delete from profile_ships where ship_id = $1', [existing.data.ship_id]),
)
if (error) {
throw new APIError(500, 'Failed to remove ship: ' + error.message)
@@ -43,16 +38,16 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
} else {
log('Ship already exists, do nothing')
}
return { status: 'success' }
return {status: 'success'}
}
// Insert the new ship
const { data, error } = await tryCatch(
const {data, error} = await tryCatch(
insert(pg, 'profile_ships', {
creator_id: creatorId,
target1_id: targetUserId1,
target2_id: targetUserId2,
})
}),
)
if (error) {
@@ -67,7 +62,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
}
return {
result: { status: 'success' },
result: {status: 'success'},
continue: continuation,
}
}

View File

@@ -1,51 +1,52 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError, APIHandler } from './helpers/endpoint'
import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
import { Row } from 'common/supabase/utils'
import { insert } from 'shared/supabase/utils'
import {Row} from 'common/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {log} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
const { targetUserId, remove } = props
const {targetUserId, remove} = props
const creatorId = auth.uid
const pg = createSupabaseDirectClient()
if (remove) {
const { error } = await tryCatch(
pg.none(
'delete from profile_stars where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
const {error} = await tryCatch(
pg.none('delete from profile_stars where creator_id = $1 and target_id = $2', [
creatorId,
targetUserId,
]),
)
if (error) {
throw new APIError(500, 'Failed to remove star: ' + error.message)
}
return { status: 'success' }
return {status: 'success'}
}
// Check if star already exists
const { data: existing } = await tryCatch(
const {data: existing} = await tryCatch(
pg.oneOrNone<Row<'profile_stars'>>(
'select * from profile_stars where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
[creatorId, targetUserId],
),
)
if (existing) {
log('star already exists, do nothing')
return { status: 'success' }
return {status: 'success'}
}
// Insert the new star
const { error } = await tryCatch(
insert(pg, 'profile_stars', { creator_id: creatorId, target_id: targetUserId })
const {error} = await tryCatch(
insert(pg, 'profile_stars', {creator_id: creatorId, target_id: targetUserId}),
)
if (error) {
throw new APIError(500, 'Failed to add star: ' + error.message)
}
return { status: 'success' }
return {status: 'success'}
}

View File

@@ -1,8 +1,8 @@
import {sendTestEmail} from "email/functions/helpers";
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!'}
return {message: 'Email sent successfully!'}
}

View File

@@ -0,0 +1,19 @@
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from './helpers/endpoint'
// Unhide a profile for the requesting user by deleting from hidden_profiles.
// Idempotent: if the pair does not exist, succeed silently.
export const unhideProfile: APIHandler<'unhide-profile'> = async ({hiddenUserId}, auth) => {
const pg = createSupabaseDirectClient()
await pg.none(
`delete
from hidden_profiles
where hider_user_id = $1
and hidden_user_id = $2`,
[auth.uid, hiddenUserId],
)
return {status: 'success'}
}

View File

@@ -0,0 +1,53 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {update} from 'shared/supabase/utils'
export const updateEvent: APIHandler<'update-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
// Check if event exists and user is the creator
const event = await pg.oneOrNone<{
id: string
creator_id: string
status: string
}>(
`SELECT id, creator_id, status
FROM events
WHERE id = $1`,
[body.eventId],
)
if (!event) {
throw new APIError(404, 'Event not found')
}
if (event.creator_id !== auth.uid) {
throw new APIError(403, 'Only the event creator can edit this event')
}
if (event.status !== 'active') {
throw new APIError(400, 'Cannot edit a cancelled or completed event')
}
// Update event
const {error} = await tryCatch(
update(pg, 'events', 'id', {
title: body.title,
description: body.description,
location_type: body.locationType,
location_address: body.locationAddress,
location_url: body.locationUrl,
event_start_time: body.eventStartTime,
event_end_time: body.eventEndTime,
max_participants: body.maxParticipants,
id: body.eventId,
}),
)
if (error) {
throw new APIError(500, 'Failed to update event: ' + error.message)
}
return {success: true}
}

View File

@@ -1,14 +1,15 @@
import { toUserAPIResponse } from 'common/api/user-types'
import { RESERVED_PATHS } from 'common/envs/constants'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { removeUndefinedProps } from 'common/util/object'
import { cloneDeep, mapValues } from 'lodash'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { getUser, getUserByUsername } from 'shared/utils'
import { APIError, APIHandler } from './helpers/endpoint'
import { updateUser } from 'shared/supabase/users'
import { broadcastUpdatedUser } from 'shared/websockets/helpers'
import { strip } from 'common/socials'
import {toUserAPIResponse} from 'common/api/user-types'
import {RESERVED_PATHS} from 'common/envs/constants'
import {strip} from 'common/socials'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {removeUndefinedProps} from 'common/util/object'
import {cloneDeep, mapValues} from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {getUser, getUserByUsername} from 'shared/utils'
import {broadcastUpdatedUser} from 'shared/websockets/helpers'
import {APIError, APIHandler} from './helpers/endpoint'
export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
const update = cloneDeep(props)
@@ -32,19 +33,12 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const { name, username, avatarUrl, link = {}, ...rest } = update
const {name, username, avatarUrl, link = {}, ...rest} = update
await updateUser(pg, auth.uid, removeUndefinedProps(rest))
if (update.website != undefined) link.site = update.website
if (update.twitterHandle != undefined) link.x = update.twitterHandle
if (update.discordHandle != undefined) link.discord = update.discordHandle
const stripped = mapValues(link, (value, site) => value && strip(site as any, value))
const stripped = mapValues(
link,
(value, site) => value && strip(site as any, value)
)
const adds = {} as { [key: string]: string }
const adds = {} as {[key: string]: string}
const removes = []
for (const [key, value] of Object.entries(stripped)) {
if (value === null || value === '') {
@@ -57,35 +51,41 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
let newLinks: any = null
if (Object.keys(adds).length > 0 || removes.length > 0) {
const data = await pg.oneOrNone(
`update users
set data = jsonb_set(
data, '{link}',
(data->'link' || $(adds)) - $(removes)
)
where id = $(id)
returning data->'link' as link`,
{ adds, removes, id: auth.uid }
`update users
set data = jsonb_set(
data, '{link}',
(data -> 'link' || $(adds)) - $(removes)
)
where id = $(id)
returning data -> 'link' as link`,
{adds, removes, id: auth.uid},
)
newLinks = data?.link
}
if (name || username || avatarUrl) {
if (name) {
await pg.none(`update users set name = $1 where id = $2`, [
name,
auth.uid,
])
}
if (username) {
await pg.none(`update users set username = $1 where id = $2`, [
username,
auth.uid,
])
}
if (avatarUrl) {
await updateUser(pg, auth.uid, { avatarUrl })
}
if (name) {
await pg.none(
`update users
set name = $1
where id = $2`,
[name, auth.uid],
)
}
if (username) {
await pg.none(
`update users
set username = $1
where id = $2`,
[username, auth.uid],
)
}
if (avatarUrl) {
await updateUser(pg, auth.uid, {avatarUrl})
}
// Ensure clients listening on `user/{id}` (e.g. AuthContext via useWebsocketUser)
// get notified about link-only changes as well.
if (name || username || avatarUrl || newLinks != null) {
broadcastUpdatedUser(
removeUndefinedProps({
id: auth.uid,
@@ -93,9 +93,9 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
username,
avatarUrl,
link: newLinks ?? undefined,
})
}),
)
}
return toUserAPIResponse({ ...user, ...update, link: newLinks })
return toUserAPIResponse({...user, ...update, link: newLinks})
}

View File

@@ -1,11 +1,12 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { updatePrivateUser } from 'shared/supabase/users'
import { type APIHandler } from './helpers/endpoint'
import { broadcastUpdatedPrivateUser } from 'shared/websockets/helpers'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updatePrivateUser} from 'shared/supabase/users'
import {broadcastUpdatedPrivateUser} from 'shared/websockets/helpers'
import {type APIHandler} from './helpers/endpoint'
export const updateNotifSettings: APIHandler<'update-notif-settings'> = async (
{ type, medium, enabled },
auth
{type, medium, enabled},
auth,
) => {
const pg = createSupabaseDirectClient()
if (type === 'opt_out_all' && medium === 'mobile') {
@@ -21,7 +22,7 @@ export const updateNotifSettings: APIHandler<'update-notif-settings'> = async (
${enabled ? `|| '[$2:name]'::jsonb` : `- $2`}
)
where id = $3`,
[type, medium, auth.uid]
[type, medium, auth.uid],
)
broadcastUpdatedPrivateUser(auth.uid)
}

View File

@@ -1,57 +1,79 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {OPTION_TABLES} from 'common/profiles/constants'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
import {tryCatch} from 'common/util/try-catch'
import {OPTION_TABLES} from "common/profiles/constants";
export const updateOptions: APIHandler<'update-options'> = async (
{table, names},
auth
) => {
export const updateOptions: APIHandler<'update-options'> = async ({table, values}, auth) => {
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
if (!names || !Array.isArray(names) || names.length === 0) {
throw new APIError(400, 'No names provided')
if (!values || !Array.isArray(values)) {
throw new APIError(400, 'No ids provided')
}
log('Updating profile options', {table, names})
const idsWithNumbers = values.map((id) => {
const numberId = Number(id)
return isNaN(numberId) ? {isNumber: false, v: id} : {isNumber: true, v: numberId}
})
const names: string[] = idsWithNumbers
.filter((item) => !item.isNumber)
.map((item) => item.v) as string[]
const ids: number[] = idsWithNumbers
.filter((item) => item.isNumber)
.map((item) => item.v) as number[]
log('Updating profile options', {table, ids, names})
const pg = createSupabaseDirectClient()
const profileIdResult = await pg.oneOrNone<{ id: number }>(
const profileIdResult = await pg.oneOrNone<{id: number}>(
'SELECT id FROM profiles WHERE user_id = $1',
[auth.uid]
[auth.uid],
)
if (!profileIdResult) throw new APIError(404, 'Profile not found')
const profileId = profileIdResult.id
const result = await tryCatch(pg.tx(async (t) => {
const ids: number[] = []
for (const name of names) {
const row = await t.one<{ id: number }>(
`INSERT INTO ${table} (name, creator_id)
const result = await tryCatch(
pg.tx(async (t) => {
const currentOptionsResult = await t.manyOrNone<{id: string}>(
`SELECT option_id as id
FROM profile_${table}
WHERE profile_id = $1`,
[profileId],
)
const currentOptions = currentOptionsResult.map((row) => row.id)
if (currentOptions.sort().join(',') === ids.sort().join(',') && !names?.length) {
log(`Skipping /update-${table} because they are already the same`)
return undefined
}
// Add new options
for (const name of names || []) {
const row = await t.one<{id: number}>(
`INSERT INTO ${table} (name, creator_id)
VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE
SET name = ${table}.name
RETURNING id`,
[name, auth.uid]
)
ids.push(row.id)
}
[name, auth.uid],
)
ids.push(row.id)
}
// Delete old options for this profile
await t.none(`DELETE FROM profile_${table} WHERE profile_id = $1`, [profileId])
// Delete old options for this profile
await t.none(`DELETE FROM profile_${table} WHERE profile_id = $1`, [profileId])
// Insert new option_ids
if (ids.length > 0) {
const values = ids.map((id, i) => `($1, $${i + 2})`).join(', ')
await t.none(
`INSERT INTO profile_${table} (profile_id, option_id) VALUES ${values}`,
[profileId, ...ids]
)
}
// Insert new option_ids
if (ids.length > 0) {
const values = ids.map((id, i) => `($1, $${i + 2})`).join(', ')
await t.none(`INSERT INTO profile_${table} (profile_id, option_id) VALUES ${values}`, [
profileId,
...ids,
])
}
return ids
}))
return ids
}),
)
if (result.error) {
log('Error updating profile options', result.error)
@@ -60,4 +82,3 @@ export const updateOptions: APIHandler<'update-options'> = async (
return {updatedIds: result.data}
}

Some files were not shown because too many files have changed in this diff Show More