723 Commits

Author SHA1 Message Date
MartinBraquet
d121c92708 Release 2026-04-02 13:44:04 +02:00
MartinBraquet
2db74bf256 Update build scripts, constants, and Android configurations for improved web view handling and debugging
- Rename `proxy.ts` to `_proxy.ts` temporarily during build scripts and clean up backup files.
- Adjust Compass URLs to consistently include "www."
- Introduce WebView debugging toggle via `BuildConfig.ENABLE_WEBVIEW_DEBUG`.
- Bump Android version code to 79 and enable `buildConfig` features.
2026-04-02 13:38:37 +02:00
MartinBraquet
0d758eb5b1 Update Compass URLs to include "www" and improve deep link handling 2026-04-02 13:10:28 +02:00
MartinBraquet
223387129e Rename middleware function to proxy in middleware.ts for clarity 2026-04-02 12:39:21 +02:00
MartinBraquet
01944f9a73 Rename middleware function to proxy in middleware.ts for clarity 2026-04-02 12:38:33 +02:00
MartinBraquet
f9a1dce7b5 Migrate apex redirect logic from next.config.ts to middleware for improved flexibility and maintainability 2026-04-02 12:31:11 +02:00
MartinBraquet
98271784b0 Uncomment redirect rule for compassmeet.com to www.compassmeet.com 2026-04-02 12:18:15 +02:00
MartinBraquet
2311fcbf90 Remove redundant "www" from Compass URLs across codebase 2026-04-02 10:35:07 +02:00
MartinBraquet
f6fef171fa Enhance deep link handling for Android with pending link consumption and JavaScript interface 2026-04-02 10:31:43 +02:00
MartinBraquet
7565d373e9 Add deep link handling for Android app 2026-04-02 09:23:52 +02:00
MartinBraquet
c4fb694c71 Handle deep links in Android MainActivity 2026-04-02 09:23:29 +02:00
MartinBraquet
bdf8793d4a Fix 2026-04-01 22:04:57 +02:00
MartinBraquet
05bd25c9ab Fix 2026-04-01 21:53:19 +02:00
MartinBraquet
5dc98fcbcf Fix 2026-04-01 21:49:03 +02:00
MartinBraquet
91f11161a2 Fix 2026-04-01 21:43:27 +02:00
MartinBraquet
0c60e8a865 Add rewrites and redirects for .well-known/assetlinks.json and compassmeet.com domain 2026-04-01 21:35:36 +02:00
MartinBraquet
3c8566a9e8 Update assetlinks.json to add new SHA-256 certificate fingerprint 2026-04-01 21:21:16 +02:00
MartinBraquet
15f0dd5aaf Add assetlinks.json to enable Android app link verification 2026-04-01 21:17:36 +02:00
MartinBraquet
fc199a918a Fix logs not showing in google cloud console 2026-04-01 18:56:41 +02:00
MartinBraquet
b936466a9d Update stats page SEO metadata for improved clarity and engagement 2026-04-01 18:17:27 +02:00
MartinBraquet
781b5ec674 Comment out og:image:width and og:image:height meta tags to align with updated image metadata usage. 2026-04-01 18:10:15 +02:00
MartinBraquet
a3dfcb4080 Fix 2026-04-01 17:56:29 +02:00
MartinBraquet
0fe1ffe78e Update metadata to use static favicon paths and add image type definition 2026-04-01 17:41:55 +02:00
MartinBraquet
e280b1f5e0 Standardize logging by replacing console.debug with dynamic log levels in log.ts module 2026-04-01 16:53:50 +02:00
MartinBraquet
cc43a8f8af Replace console.debug with log for standardized logging and clean up unused imports in get-profiles API module 2026-04-01 16:53:27 +02:00
MartinBraquet
3caf56247f Refactor profile-about component structure to improve flexibility and simplify render logic, add children prop for custom content support. 2026-04-01 15:25:56 +02:00
MartinBraquet
032c70e086 Remove tooltip from switch-setting component to simplify UI 2026-04-01 15:25:48 +02:00
MartinBraquet
903679eaa7 Refine button styling by adding w-fit for better width handling 2026-04-01 15:25:33 +02:00
MartinBraquet
2908a1f16d Improve hover state styling for measurement system toggle 2026-04-01 15:25:23 +02:00
MartinBraquet
b5deefbec1 Enforce text length limits in editor paste handling, adjust API request size limit, and crop excessive content in LLM profile extraction 2026-04-01 13:52:34 +02:00
MartinBraquet
0cb6226643 Refine theme handling with dark mode support and update UI styles accordingly 2026-03-31 15:10:37 +02:00
MartinBraquet
b19f6b81de Fix French translation for "Psychédéliques" label in profile settings 2026-03-31 15:05:25 +02:00
MartinBraquet
401fd816dd Bump version numbers in package.json and Android build.gradle 2026-03-31 13:41:51 +02:00
MartinBraquet
a31765b7ae Refactor profile and form submission logic to support partial updates and improve type handling 2026-03-31 13:41:24 +02:00
MartinBraquet
a7ae62a14b Clarify usage frequency descriptions for psychedelics and cannabis in profile data 2026-03-31 13:40:59 +02:00
MartinBraquet
007bc1f7b0 Refine animated ring behavior to cancel on drag and improve pointer handling 2026-03-31 13:40:47 +02:00
MartinBraquet
bded8cc1fe Add additional Sentry error capture and refine profile data update logic 2026-03-30 23:14:24 +02:00
MartinBraquet
cfaac3e3fa Add UI dynamics 2026-03-30 22:45:11 +02:00
MartinBraquet
9637c80dd7 Add Sentry error handling 2026-03-30 20:15:46 +02:00
MartinBraquet
6a9739ab31 Update Sentry context key to 'Error Info' in profile page 2026-03-30 17:05:33 +02:00
MartinBraquet
a51bd344a2 Fix 2026-03-30 17:05:22 +02:00
MartinBraquet
50f9a00689 Add info more 2026-03-30 16:58:45 +02:00
MartinBraquet
54fb8c4b61 Add info 2026-03-30 16:52:12 +02:00
MartinBraquet
809a996870 Add message 2026-03-30 16:38:43 +02:00
MartinBraquet
85c96ce430 Add more sentry info 2026-03-30 16:33:29 +02:00
MartinBraquet
b670de9c73 Add sentry error capture 2026-03-30 16:25:16 +02:00
MartinBraquet
626b28f4eb Release 2026-03-30 15:47:59 +02:00
MartinBraquet
9c3dd65fc9 Add missing file 2026-03-30 15:03:18 +02:00
MartinBraquet
1e5c7b07c2 Add substance fields: cannabis and psychedelics 2026-03-30 14:55:31 +02:00
MartinBraquet
686c5777fd Seed more profiles 2026-03-30 13:27:43 +02:00
MartinBraquet
611e07a02b Fix div element parsing 2026-03-29 23:05:58 +02:00
MartinBraquet
3c514de79d Fix locale not passed 2026-03-29 21:03:26 +02:00
MartinBraquet
8319d21dea Add translation 2026-03-29 20:36:12 +02:00
MartinBraquet
e4a231b0c5 Release 2026-03-29 20:29:48 +02:00
MartinBraquet
b1efd042cf Fix wrong text editor picked 2026-03-29 20:29:17 +02:00
MartinBraquet
8a7d2120c4 Fix null values 2026-03-29 20:21:31 +02:00
MartinBraquet
e0b3f2d81a Handle null site 2026-03-29 20:05:10 +02:00
MartinBraquet
9f1eaef30c Fix null linknder 2026-03-29 19:55:54 +02:00
MartinBraquet
0218f4e705 Fix null link 2026-03-29 19:55:35 +02:00
MartinBraquet
3171f32cec Fix links and add gemini models 2026-03-29 19:30:51 +02:00
MartinBraquet
156d2870cb Fix height 2026-03-29 18:52:38 +02:00
MartinBraquet
2229d01aa1 Fix test 2026-03-29 18:31:41 +02:00
MartinBraquet
6e4c6f29b5 Add profile auti-fill from content or URL 2026-03-29 18:26:45 +02:00
MartinBraquet
ad51aea069 Fix faq bold 2026-03-27 14:52:02 +01:00
MartinBraquet
b8963c99a7 Add big 5 info 2026-03-23 10:26:42 +01:00
MartinBraquet
b270ce706b Fix button flash 2026-03-21 21:18:35 +01:00
MartinBraquet
2c04b2a6aa Add user dependency to refreshPinnedQuestionIds in PinnedQuestionIdsProvider 2026-03-21 21:08:07 +01:00
MartinBraquet
a00b966e8b Simplify null checks for user and update sign-up text (fix #39) 2026-03-21 21:07:43 +01:00
MartinBraquet
8ac3259f41 Add quotation marks to profile headline and capitalize suffix in profile details 2026-03-20 21:05:06 +01:00
MartinBraquet
56f0423d5a Enable Sentry only for non-local environments 2026-03-19 17:29:09 +01:00
MartinBraquet
e83ec6506c Fix API error handling to correctly parse error details 2026-03-19 17:16:02 +01:00
MartinBraquet
be22658883 Release 2026-03-19 17:04:18 +01:00
MartinBraquet
bdafa43472 Add pinned compatibility questions feature with backend support and UI integration 2026-03-19 17:00:34 +01:00
MartinBraquet
891b91d0ba Clean comment 2026-03-18 10:55:08 +01:00
MartinBraquet
b85b9380c3 Fix icon alignment 2026-03-17 13:33:41 +01:00
Okechi Jones-Williams
cfeab0278c [Housekeeping] Organizing the test structure and files, adding fix for issue #36 (None descriptive error) (#38)
* Added Database checks to the onboarding flow

* Added compatibility page setup
Added more compatibility questions

* Finished up the onboarding flow suite
Added compatibility question tests and verifications
Updated tests to cover Keywords and Headline changes recently made
Updated tests to cover all of the big5 personality traits

* Fix: Added 'tsconfig-paths/register' to playwright config so it applies tsconfig/ts-node to test files and imported modules....there was a syntax error:

SyntaxError: tests/e2e/web/pages/homePage.ts: Unexpected token (2:0)

* .

* Updated ProfilePage: In some cases removed the use of "one shot grabs" using .textContent() to prevent flaky tests
Added test for entering profile information post signup flow

* Linting and Prettier

* Fix: Merge conflict

* Fix: Modified sortedAndFilteredAnswers to use "UseMemo" so that it doesnt run every time something changes

* .

* .

* Merged "verifyInterestedInConnectingWith" and "verifyRelationShipTypeAndInterest" into "verifySeeking" to match ui changes

* Added error message outlining the minimum character requirement for display names and usernames

* Updated displayName error message to show when editing the user profile post signup

* Fix: Added fix for None discriptive error issue #36
Updated signUp.spec.ts to use new fixture
Updated Account information variable names
Deleted "deleteUserFixture.ts" as it was incorporated into the "base.ts" file

* Linting and Prettier

* Minor cleaning

* Organizing helper func

* Linting and Prettier

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2026-03-16 16:02:48 +01:00
MartinBraquet
c8735b8b01 Comment 2026-03-15 17:52:03 +01:00
MartinBraquet
5ecbd3ba91 Fix cached user key 2026-03-15 17:41:48 +01:00
MartinBraquet
f83dbf349e Fix API error details and add toast info 2026-03-15 14:31:42 +01:00
MartinBraquet
3d1e91d100 Fix search input value 2026-03-15 14:04:09 +01:00
MartinBraquet
0353a530d4 Release 2026-03-15 13:42:39 +01:00
MartinBraquet
0592c7e766 Add support for mp4 media in profiles 2026-03-15 13:42:20 +01:00
MartinBraquet
5a8c698ed5 Remove redundant FilterGuide component 2026-03-14 13:55:13 +01:00
MartinBraquet
7ff38bd693 Release 2026-03-14 13:48:08 +01:00
MartinBraquet
6c84926033 Release 2026-03-14 13:11:00 +01:00
MartinBraquet
f35af89f07 Release 2026-03-13 16:04:05 +01:00
MartinBraquet
b1f01fd873 Fix wording 2026-03-13 16:00:42 +01:00
MartinBraquet
96e22136a4 Fix global choices not refreshing after profile creation / edition 2026-03-13 15:56:43 +01:00
MartinBraquet
f81932e14e Remove one skip 2026-03-13 15:55:55 +01:00
MartinBraquet
6b1813e129 Close modal before validating questions 2026-03-13 15:32:18 +01:00
MartinBraquet
307076d88e Update 2026-03-13 15:18:38 +01:00
MartinBraquet
a84ad62ea2 Fix debug 2026-03-13 15:02:54 +01:00
MartinBraquet
c3f21058e5 Fix stable questions 2026-03-13 14:54:59 +01:00
MartinBraquet
d3634d8b1c Move interestChoices to app context to fix db blast 2026-03-13 14:35:20 +01:00
MartinBraquet
cdbba244d0 Comment 2026-03-13 14:34:09 +01:00
MartinBraquet
6daeea908e Allow to set debug level in env var 2026-03-13 14:33:41 +01:00
MartinBraquet
b835a5f137 Add debug 2026-03-13 14:33:11 +01:00
MartinBraquet
25358a9463 Update Sentry project name 2026-03-13 13:04:06 +01:00
MartinBraquet
11063611bf Do not ship source maps even in web 2026-03-13 12:49:12 +01:00
MartinBraquet
6a03c5cc83 Clean 2026-03-13 12:43:56 +01:00
MartinBraquet
37ddf5bab1 Clean up unused instance ID env var 2026-03-13 12:40:08 +01:00
MartinBraquet
f741648522 Stabilize questions order 2026-03-13 00:47:31 +01:00
MartinBraquet
57e6395641 Release 2026-03-12 22:15:20 +01:00
MartinBraquet
bc31df7d0a Use colored fav icon 2026-03-12 12:33:44 +01:00
MartinBraquet
1e4b836985 Release 2026-03-12 10:59:29 +01:00
MartinBraquet
5c1b18b4d9 Fix gap 2026-03-12 01:17:57 +01:00
MartinBraquet
f90b2179b5 Add gender translations 2026-03-12 01:15:02 +01:00
MartinBraquet
0da158ce54 Update e2e test 2026-03-12 00:58:56 +01:00
MartinBraquet
a360b51e12 Update translations 2026-03-12 00:49:14 +01:00
MartinBraquet
3de6adae2e Refine cards and allow users to toggle each card field ON or OFF 2026-03-12 00:36:36 +01:00
MartinBraquet
01a6a6e298 Fix emoji list showing on scrolling 2026-03-11 18:08:36 +01:00
MartinBraquet
54ce5891f6 Add card size selector 2026-03-11 17:40:28 +01:00
MartinBraquet
8a2bcad190 Fix tooltip not open on mobile click 2026-03-11 12:24:28 +01:00
MartinBraquet
a88ba2dd3c Add comments 2026-03-11 12:12:36 +01:00
MartinBraquet
3503110c64 Do not show cancelled events 2026-03-11 12:12:21 +01:00
MartinBraquet
33436c84a4 Imporve wording 2026-03-10 23:52:43 +01:00
MartinBraquet
52f0f04194 Fix user not logging in when they accidentally sign in through the sign up page 2026-03-10 23:48:55 +01:00
MartinBraquet
3d56bb4fe0 Clear logs 2026-03-10 23:48:14 +01:00
MartinBraquet
b0c84687d2 Min 18 yo 2026-03-10 23:48:04 +01:00
MartinBraquet
0ef5ecea30 Send discord notif upon event creation 2026-03-10 23:32:34 +01:00
MartinBraquet
670e863bae Add SEO 2026-03-10 23:32:12 +01:00
MartinBraquet
3309ed1988 Add 2026-03-10-delete-users-without-profile.ts 2026-03-10 23:13:26 +01:00
MartinBraquet
3365445538 Delete orphan user to prevent onboarding issues 2026-03-10 23:02:47 +01:00
MartinBraquet
9808b4a2e7 Clean display 2026-03-10 20:19:05 +01:00
MartinBraquet
f3bd28e29f Fix 2026-03-10 16:29:48 +01:00
MartinBraquet
da9e950e5f Fix margin 2026-03-10 16:25:33 +01:00
MartinBraquet
dbf12a2ab2 Fix zod types and unseen icon not showing 2026-03-10 16:22:44 +01:00
MartinBraquet
1a2aa16645 Release 2026-03-10 00:50:09 +01:00
MartinBraquet
a1df61edaa Update supabase schema 2026-03-10 00:41:08 +01:00
MartinBraquet
9e58e12013 Encrypt early messages that were sent before AES encryption got implement 2026-03-10 00:39:04 +01:00
MartinBraquet
a71c0beb11 Fix unit test 2026-03-09 22:01:50 +01:00
MartinBraquet
93e6b18b49 Disable next button while uploading photos 2026-03-09 21:57:07 +01:00
MartinBraquet
6aae66f0d2 Translate back 2026-03-09 21:33:09 +01:00
MartinBraquet
46f751b712 Translate 2026-03-09 21:31:36 +01:00
MartinBraquet
ccce2cc8b0 Release 2026-03-09 19:34:37 +01:00
MartinBraquet
c38d752dc8 Fix types 2026-03-09 19:33:49 +01:00
MartinBraquet
6f45c03a29 Show number of answers and community importance on prompts 2026-03-09 19:24:32 +01:00
MartinBraquet
5819f08aec Release 2026-03-09 15:19:03 +01:00
MartinBraquet
a322ea77fc Fix .webp not rending in OG: convert to jpg on upload 2026-03-09 14:58:16 +01:00
MartinBraquet
9a5f47f905 Fix early banner not showing 2026-03-09 14:32:00 +01:00
MartinBraquet
a02ba9767b Fix auto scrolling when receiving a message 2026-03-09 14:21:23 +01:00
MartinBraquet
57edf80bfd Translate creating profile 2026-03-09 14:13:51 +01:00
MartinBraquet
3a2db534ab Round back 2026-03-09 14:06:26 +01:00
MartinBraquet
8eac568446 Do not error log if nav share not present 2026-03-09 14:06:13 +01:00
MartinBraquet
5e5015018f Round xl 2026-03-09 14:05:48 +01:00
MartinBraquet
1fce55aebc Clean 2026-03-09 13:38:25 +01:00
MartinBraquet
cae5b96b1e Fix sorting by answer count 2026-03-09 13:36:52 +01:00
MartinBraquet
3c72bca496 Add typing for /stats 2026-03-09 13:00:24 +01:00
MartinBraquet
ba7e158af8 Release 2026-03-09 12:38:08 +01:00
MartinBraquet
34a13458db Fix unit tests 2026-03-09 12:37:46 +01:00
MartinBraquet
3200e3cf79 Fix index and total not refreshing 2026-03-09 12:12:40 +01:00
MartinBraquet
de9c28965f Fix 2026-03-09 03:18:04 +01:00
MartinBraquet
4e61669361 Add option to sort prompts in modal 2026-03-09 03:16:08 +01:00
MartinBraquet
09607ba7c7 Weigh community importance score by answer count to avoid low sample bias 2026-03-09 03:15:21 +01:00
MartinBraquet
24d2fe9c32 Sort compat prompts by community importance and answer count 2026-03-09 02:27:10 +01:00
MartinBraquet
94585b1f1d Fix constitution wording 2026-03-08 21:24:11 +01:00
MartinBraquet
155406935d Add option to search by keyword in the prompts of a profile page: questions, answers, and free answers. 2026-03-08 18:36:57 +01:00
MartinBraquet
b445db6116 Add profile filter: has photos 2026-03-08 18:11:28 +01:00
MartinBraquet
d4de56873f Add currently online choice 2026-03-08 18:10:27 +01:00
MartinBraquet
6c54a9adf0 Add option to view pics on profile cards 2026-03-08 17:38:43 +01:00
MartinBraquet
74f948e6ca Add gender retio to stats 2026-03-08 16:55:58 +01:00
MartinBraquet
0ea9ee969e Add supporting members 2026-03-08 16:32:36 +01:00
MartinBraquet
6ae1af3c1f Add filters doc 2026-03-08 16:21:26 +01:00
MartinBraquet
4ac4ab0ba2 Update FAQ 2026-03-08 14:21:59 +01:00
MartinBraquet
d29edae5fe Fix no messages yet showing even when loading 2026-03-08 14:11:57 +01:00
MartinBraquet
066a620bd4 Fix tooltips not showing 2026-03-08 14:10:46 +01:00
MartinBraquet
7ad464150b Fix avatar not showing in hidden profiles 2026-03-08 13:39:31 +01:00
MartinBraquet
596e70e031 Fix avatar not showing in saved people 2026-03-08 13:27:41 +01:00
MartinBraquet
13f103a3ca Hide logs in prod 2026-03-08 01:17:00 +01:00
MartinBraquet
a699447e9e Fix color prompts 2026-03-08 00:02:45 +01:00
MartinBraquet
159e634a1a Fix 2026-03-07 16:18:57 +01:00
MartinBraquet
0533fdd2ed Fix 2026-03-07 16:02:44 +01:00
MartinBraquet
5119c458d8 Release 2026-03-07 15:59:58 +01:00
MartinBraquet
d8a39f7101 Clean 2026-03-07 15:59:42 +01:00
MartinBraquet
3a0712c193 Fix messaging pagination and scrolling 2026-03-07 15:40:51 +01:00
MartinBraquet
cb9dd51afc Test 2026-03-07 13:46:35 +01:00
MartinBraquet
89ce1a248e Test 2026-03-07 13:43:53 +01:00
MartinBraquet
ffc717c86b Fix 2026-03-07 13:43:07 +01:00
MartinBraquet
30248fd0be Fix 2026-03-07 13:39:43 +01:00
MartinBraquet
c270e6c3d7 Test no deploy 2026-03-07 13:31:55 +01:00
MartinBraquet
e8bc9cda1d Add comment 2026-03-07 13:31:05 +01:00
MartinBraquet
0bc82a3bcf Fix vercel not deploying even if commit before last has changes 2026-03-07 13:29:35 +01:00
MartinBraquet
a5f7898c37 Release 2026-03-07 13:25:20 +01:00
MartinBraquet
1165927337 Fix get message being called for each convo 2026-03-07 13:21:36 +01:00
MartinBraquet
2d5690cea2 Add message count to stats 2026-03-07 12:42:57 +01:00
MartinBraquet
ace1b2823a Improve API docs 2026-03-07 11:42:58 +01:00
MartinBraquet
a50323cd94 Cache stats 2026-03-07 11:42:31 +01:00
MartinBraquet
008bc11ebf Add /stats 2026-03-07 11:28:24 +01:00
MartinBraquet
3ddf81d935 Invalidate user cache 2026-03-07 11:17:29 +01:00
MartinBraquet
67e95be2d4 Upgrade how to auth in API docs 2026-03-07 11:10:48 +01:00
MartinBraquet
0bb0a394ae Reserve /monitoring 2026-03-07 11:10:13 +01:00
MartinBraquet
cc74945371 Fix custom 404 translations 2026-03-07 09:55:13 +01:00
MartinBraquet
2ea34189a8 Add 2026-03-08-migrate-avatar-url.ts 2026-03-07 00:37:39 +01:00
MartinBraquet
66800d949b Hot fix undefined links 2026-03-07 00:24:22 +01:00
MartinBraquet
2eb80b97d5 Clean error message color 2026-03-07 00:23:45 +01:00
MartinBraquet
f4d8822dbe Fix CI 2026-03-07 00:17:38 +01:00
MartinBraquet
0655266366 Move avatar URL and is-banned to separate columns and social links to profiles table 2026-03-06 23:51:49 +01:00
MartinBraquet
4b58e72607 Use API error handler depending on error code 2026-03-06 15:27:49 +01:00
MartinBraquet
29445a8aa7 Update endpoint docs and API error formatting 2026-03-06 13:11:52 +01:00
MartinBraquet
c4a498227f Add docs 2026-03-06 12:35:00 +01:00
MartinBraquet
295fa1dee4 Release 2026-03-06 03:16:22 +01:00
MartinBraquet
ca582f0134 Add DATABASE docs 2026-03-06 02:13:09 +01:00
MartinBraquet
43abe21e45 Remove api docs dark mode 2026-03-06 02:11:42 +01:00
MartinBraquet
c1df4c1307 Fix sentry source maps (2) 2026-03-06 01:06:50 +01:00
MartinBraquet
73802c9c1d Fix sentry source maps 2026-03-06 01:04:10 +01:00
MartinBraquet
2825ded7c0 Clean 2026-03-06 00:55:36 +01:00
MartinBraquet
69161612f6 Get firebase User even if user/privateUser not set 2026-03-06 00:38:40 +01:00
MartinBraquet
2cc6af1f37 Update readmes 2026-03-06 00:20:34 +01:00
MartinBraquet
7a52f55b05 Improve api() 2026-03-06 00:20:20 +01:00
MartinBraquet
f854476614 Get api call 2026-03-06 00:20:04 +01:00
MartinBraquet
7165553080 Use api 2026-03-06 00:18:57 +01:00
MartinBraquet
fbda1caaf7 Fix e2e tests 2026-03-05 23:49:25 +01:00
MartinBraquet
1c3ed84791 Comment 2026-03-05 23:42:14 +01:00
MartinBraquet
6008a5d3a5 Ignore yarn warnings 2026-03-05 23:41:44 +01:00
MartinBraquet
205354c6c4 Fix CI 2026-03-05 20:23:47 +01:00
MartinBraquet
cb8ef458c2 Fix CI 2026-03-05 19:53:36 +01:00
MartinBraquet
d54f0052df Fix flash 2026-03-05 19:49:32 +01:00
MartinBraquet
d979a81b95 Fix jest tests 2026-03-05 19:32:45 +01:00
MartinBraquet
bf8ce092af Fix 2026-03-05 18:25:31 +01:00
MartinBraquet
c53039d97a Release 2026-03-05 18:18:31 +01:00
MartinBraquet
5f32e5d025 Fix profile not found on some signup 2026-03-05 17:51:25 +01:00
MartinBraquet
b3d203afa2 Fix 2026-03-05 15:57:50 +01:00
MartinBraquet
0379c95f9b Add translations 2026-03-05 15:48:25 +01:00
MartinBraquet
512406837d Update options in same API call along with user creation 2026-03-05 15:40:20 +01:00
MartinBraquet
32e8c8570b Add back buttons 2026-03-05 13:09:44 +01:00
MartinBraquet
6c86de75ec Fix 2026-03-04 17:20:49 +01:00
MartinBraquet
4bc91a5311 Trigger deploy 2026-03-04 16:02:15 +01:00
MartinBraquet
822f9150b8 Release 2026-03-04 15:48:47 +01:00
MartinBraquet
6117e59226 Fix e2e tests 2026-03-04 15:47:56 +01:00
MartinBraquet
bf9d25731c Merge branch 'collapse_registration' 2026-03-04 15:23:31 +01:00
MartinBraquet
8686ac4090 Fix tests libs 2026-03-04 15:23:12 +01:00
MartinBraquet
dcacf98ea3 Collapse the 3 onboarding API calls into 1 2026-03-04 15:22:36 +01:00
MartinBraquet
cd9fcb8176 Prettier 2026-03-04 11:08:35 +01:00
MartinBraquet
d158eadf0d Fix lint and typecheck not failing due to ; instead of && 2026-03-04 11:08:08 +01:00
Okechi Jones-Williams
e115df8e11 [Onboarding Flow] Answer compatibility questions in the test suite for onboarding (#35)
* Fixed Type errors

* Added Database checks to the onboarding flow

* Updated Onboarding flow
Changed type ChildrenExpectation so that it can be used for database verification

* Added compatibility page setup
Added more compatibility questions

* Finished up the onboarding flow suite
Added compatibility question tests and verifications
Updated tests to cover Keywords and Headline changes recently made
Updated tests to cover all of the big5 personality traits

* Apply suggestions from code review

---------

Co-authored-by: Martin Braquet <martin.braquet@gmail.com>
2026-03-04 11:05:56 +01:00
MartinBraquet
140ace55bf Fix 2026-03-02 19:57:31 +01:00
MartinBraquet
7a44f3d23c Fix api deploy not installing deps from shared, emails and common 2026-03-02 19:47:30 +01:00
MartinBraquet
2f38d54ea5 Add auth token 2026-03-02 19:00:39 +01:00
MartinBraquet
01deda29e7 Release 2026-03-02 18:06:27 +01:00
MartinBraquet
b59b0edd4a Add sentry 2026-03-02 18:06:04 +01:00
MartinBraquet
be358d8517 Clean 2026-03-02 17:31:45 +01:00
MartinBraquet
4e3f31dd1c Add debug 2026-03-02 16:40:00 +01:00
MartinBraquet
ab439bd85d Clean 2026-03-02 15:58:39 +01:00
MartinBraquet
aa7e32cb77 Fix wording 2026-03-02 15:58:10 +01:00
MartinBraquet
863fd2c0ae Android release 2026-03-01 23:39:54 +01:00
MartinBraquet
a18d308248 Test 2026-03-01 23:36:42 +01:00
MartinBraquet
bfed23769e Fix 2026-03-01 23:35:41 +01:00
MartinBraquet
54a8f0e59b Log 2026-03-01 23:33:11 +01:00
MartinBraquet
90d25c7152 Test 2026-03-01 23:32:10 +01:00
MartinBraquet
9ccdeb6997 Fix 2026-03-01 23:31:08 +01:00
MartinBraquet
ad1b3e813e Fix 2026-03-01 23:30:10 +01:00
MartinBraquet
50949199f4 Update web 2026-03-01 23:26:55 +01:00
MartinBraquet
2d477e498f Fix resolutions 2026-03-01 23:17:48 +01:00
MartinBraquet
f9f9da63a0 Upgrade zod 2026-03-01 23:08:55 +01:00
MartinBraquet
000daa3021 Add ignore command 2026-03-01 23:08:33 +01:00
MartinBraquet
40c30ede11 Fix symbol rotation 2026-03-01 22:47:02 +01:00
MartinBraquet
54fdf67bcf Fix sidebar not closing 2026-03-01 22:46:49 +01:00
MartinBraquet
1bf9b83693 Fix aria label 2026-03-01 22:27:17 +01:00
MartinBraquet
32e97f9da5 Bump 2026-03-01 22:12:55 +01:00
MartinBraquet
677f8bf207 Reduce apk size by excluding .map assets 2026-03-01 22:09:58 +01:00
MartinBraquet
ab92cf2aa9 Upgrade libs 2026-03-01 21:46:13 +01:00
MartinBraquet
0dff23991a Fix android plugins 2026-03-01 21:13:34 +01:00
MartinBraquet
04af8966b5 Release 2026-03-01 19:48:00 +01:00
MartinBraquet
dd239f7b30 Fix infinite loop in setup 2026-03-01 19:47:36 +01:00
MartinBraquet
d2195d7c16 Ignore .email in lint 2026-03-01 19:39:30 +01:00
MartinBraquet
60269b66a7 Release 2026-03-01 19:39:15 +01:00
MartinBraquet
2cad2fca17 Switch to js 2026-03-01 19:35:58 +01:00
MartinBraquet
a10ae2d253 Clean 2026-03-01 19:14:36 +01:00
MartinBraquet
4a4bee658d Add next config for react email dev server 2026-03-01 19:12:30 +01:00
MartinBraquet
75a689707d Ignore react email 2026-03-01 18:31:32 +01:00
MartinBraquet
6f638a22a3 Install react-email 2026-03-01 18:12:06 +01:00
MartinBraquet
18b63f1eb3 Fix OG card not rendering in dev 2026-03-01 18:00:26 +01:00
MartinBraquet
39689b1bfa Update docs next 2026-03-01 18:00:06 +01:00
MartinBraquet
44bc25f061 Update docs 2026-03-01 17:52:00 +01:00
MartinBraquet
e2d9c06362 Clean error boundary 2026-03-01 17:48:57 +01:00
MartinBraquet
165a7e5663 Update next tsconfig 2026-03-01 17:43:20 +01:00
MartinBraquet
8f83011011 Fix import 2026-03-01 17:42:28 +01:00
MartinBraquet
9924c3debf Fix rm 2026-03-01 17:42:00 +01:00
MartinBraquet
836f8f1bfb Git ignore css compiled 2026-03-01 17:32:03 +01:00
MartinBraquet
fae76195ec Fix css 2026-03-01 17:31:51 +01:00
MartinBraquet
0d8d81e09c Fix typecheck 2026-03-01 17:15:28 +01:00
MartinBraquet
699890a0be Massive Next.js (14->16) and React (18->19) upgrade 2026-03-01 16:55:19 +01:00
MartinBraquet
8c68312597 Clean docs 2026-03-01 06:43:10 +01:00
MartinBraquet
55bb9919f7 Fix coverage ts warnings 2026-03-01 06:00:28 +01:00
MartinBraquet
f8ca4bcbfc Android release 2026-03-01 05:36:27 +01:00
MartinBraquet
7037362b40 Update docs 2026-03-01 05:35:46 +01:00
MartinBraquet
e29bc0ab82 Fix test 2026-03-01 05:10:24 +01:00
MartinBraquet
b3cf542fd5 Rename jest config to ts 2026-03-01 04:57:59 +01:00
MartinBraquet
59ddb4360e Fix es lint 2026-03-01 04:56:21 +01:00
MartinBraquet
4411ef25b0 Re-use node setup in CD workflows 2026-03-01 04:46:12 +01:00
MartinBraquet
0d57760d25 Silence debug in tests 2026-03-01 04:38:02 +01:00
MartinBraquet
77f3b550d0 Fix CI 2026-03-01 04:22:40 +01:00
MartinBraquet
79e0421281 Do not wait upon sign up in dev 2026-03-01 04:20:40 +01:00
MartinBraquet
f54e18feb1 Do not show early banner if signup banner is there 2026-03-01 04:20:24 +01:00
MartinBraquet
18d2c59479 API release 2026-03-01 04:16:39 +01:00
MartinBraquet
33d7308cfa Fix tests 2026-03-01 04:15:26 +01:00
MartinBraquet
579ed6de7c Use logger debug 2026-03-01 04:05:14 +01:00
MartinBraquet
8a1ee5cdca Add skip links for accessibility 2026-03-01 03:33:07 +01:00
MartinBraquet
edaf119d9e Add live region 2026-03-01 03:12:39 +01:00
MartinBraquet
1aad769d93 Add logger 2026-03-01 02:58:56 +01:00
MartinBraquet
b5b2bafc78 Add error boundary 2026-03-01 02:56:48 +01:00
MartinBraquet
8ba8604d83 Add /health to vercel API 2026-03-01 02:55:10 +01:00
MartinBraquet
9fdd21e03a DRY checkout 2026-03-01 02:24:08 +01:00
MartinBraquet
418b2c7e52 Fix 2026-03-01 02:22:22 +01:00
MartinBraquet
49237bbe18 Add monorepo info 2026-03-01 02:16:56 +01:00
MartinBraquet
049fffe27f Cache all node modules 2026-03-01 02:16:46 +01:00
MartinBraquet
8d80245adf Test 2026-03-01 02:11:32 +01:00
MartinBraquet
8d235e89f0 DRY the actions 2026-03-01 02:05:24 +01:00
MartinBraquet
b030dd1a52 Improve naming 2026-03-01 01:48:10 +01:00
MartinBraquet
17faf2fe26 Improve CI cache 2026-03-01 01:48:00 +01:00
Okechi Jones-Williams
b18a6d7ff3 [Onboarding Flow] Added database checks to the onboarding flow (#34)
* Fixed Type errors

* Organizing testing utilities

* Added Database checks to the onboarding flow

* Updated Onboarding flow
Changed type ChildrenExpectation so that it can be used for database verification

* Added compatibility page setup
Added more compatibility questions

* Fix

* .

* Fix: Typo

* Fix: Faker usernames can no longer generate symbols

* Fix: Changed how work area is verified

* .

* .

* Fix: Trying to work in headed mode

* Fix: Change back to headless

* Fix: Added timeout after workArea selection

* .

* Clean e2e

* Improve E2E setup

* Prettier

* Log

* Fix: should pull test account from unique identifier like email, username or id; not the display name

* Source env vars in playwright directly

* Clean e2e data

* Clean test account id to be the same for email and username

* Fix import warning

* Add error handling

* Add log

* Temp remove env load

* Update

* Add logs and safeguards against using remote supabase during e2e tests

* Fix playwright report path in C@

* Remove locale log

* Check if userInformationFromDb loading with name instead of username was the issue

* Remove login log

* Check if initial work area names were the issue

* Ignore if no files found

* Cache Firebase emulators in CI

* Reload env vars in playwright

* It did not break tests...

* Clean verifyWorkArea

* Add caching for node modules in CI

* Add caching for node modules in CI (2)

* Do not raise if emulator not running during db seed

* Do not raise if using firebase emulator

* Fix supabase cache in CI

* Add Cache Playwright browsers in CI

* Fix

* Test cache

* Turn off unused supabase services to speed things up

* Back to good one

* Set CI=true

* api is required for client connection

* Add safeguards for missing supabase env vars

* Remove echo

* Remove supabase cache

---------

Co-authored-by: Martin Braquet <martin.braquet@gmail.com>
2026-03-01 01:25:56 +01:00
MartinBraquet
c69a438d08 Add doc to prevent race conditions in e2e tests 2026-02-28 15:21:19 +01:00
MartinBraquet
309cbe7f2b Use unique user ID for each e2e test to fix race condition
Before, each test would delete all existing users—including the ones still used in concurrent tests
2026-02-28 14:37:43 +01:00
MartinBraquet
c0df0028d3 Skip email send locally 2026-02-28 14:35:40 +01:00
MartinBraquet
4722088fd0 Speed up e2e-dev.sh 2026-02-28 14:35:20 +01:00
MartinBraquet
27c03330c8 Format last active filter 2026-02-28 13:23:47 +01:00
MartinBraquet
740a7cc6f9 Fix 2026-02-28 02:11:24 +01:00
MartinBraquet
53ae605e9d Show mobile filter as soon as desktop one disappears 2026-02-28 02:00:35 +01:00
MartinBraquet
84da8b7ad3 Fix mobile padding 2026-02-28 02:00:04 +01:00
MartinBraquet
8b283cc5ce Fix interest filter width 2026-02-28 01:48:01 +01:00
MartinBraquet
8548b85d03 Fix local storage read (now with ttl attached) 2026-02-28 01:45:12 +01:00
MartinBraquet
fbb10344e1 Clean UI 2026-02-28 01:30:51 +01:00
MartinBraquet
615033547c Hide gender they seek 2026-02-28 01:08:51 +01:00
MartinBraquet
8f854995c5 Fix missing key 2026-02-28 01:07:15 +01:00
MartinBraquet
f8bb15e376 Fix typecheck 2 2026-02-28 01:00:10 +01:00
MartinBraquet
f6a65e875b Show selected filters at the top and allow for removed them one by one 2026-02-28 00:34:11 +01:00
MartinBraquet
74fc6a744e Improve wants kids and has kids filters 2026-02-27 22:42:34 +01:00
MartinBraquet
6920b8293d Add filter sections 2026-02-27 21:34:44 +01:00
MartinBraquet
6c71022ed6 Move desktop filters to right side 2026-02-27 17:19:32 +01:00
MartinBraquet
d0176c2b65 Seed more profiles 2026-02-27 17:17:41 +01:00
MartinBraquet
5ce38fea65 Add missing sql 2026-02-27 17:17:33 +01:00
MartinBraquet
19ee048536 Set to the best guess (first city) if no option selected 2026-02-27 15:09:28 +01:00
MartinBraquet
2531ee6fe4 Add banner to let users know it's early stage 2026-02-27 14:59:59 +01:00
MartinBraquet
1722cb531f Add TTL support to persistent cache hook 2026-02-27 14:59:39 +01:00
MartinBraquet
f59325cbed Clean 2026-02-27 13:39:19 +01:00
MartinBraquet
1c595d3e33 Move DropdownOptions 2026-02-27 13:26:33 +01:00
MartinBraquet
4f2df43232 Fix wording 2026-02-27 13:23:55 +01:00
MartinBraquet
b7fe357fb2 Translate filter for last online 2026-02-27 13:22:15 +01:00
MartinBraquet
59d52d4c11 Add filter for last online 2026-02-27 13:01:42 +01:00
MartinBraquet
8c1a75e26b Reduce profile card modal height 2026-02-27 11:33:52 +01:00
MartinBraquet
ce8e7d141a Translate view profile card 2026-02-27 00:17:35 +01:00
MartinBraquet
0a2e4a7df1 Add profile card to profile 2026-02-26 23:56:15 +01:00
MartinBraquet
26bc68e4db Fix 2026-02-26 19:25:49 +01:00
MartinBraquet
945f4a0d82 Fix [Signup Flow] Unable to edit User/Display name during flow after clicking next #32 2026-02-26 19:21:21 +01:00
MartinBraquet
41da848714 Show share button on mobile as well 2026-02-26 17:21:12 +01:00
MartinBraquet
5a92c47c99 Add docs 2026-02-26 17:15:35 +01:00
MartinBraquet
69f181e8ee Fix package name 2026-02-26 16:57:16 +01:00
MartinBraquet
f374fef4f9 Double check API deploy workflow 2026-02-26 16:54:17 +01:00
MartinBraquet
263e38f23e Separate version check from action 2026-02-26 16:52:24 +01:00
MartinBraquet
ddd5cd6823 Fix missing keystore 2026-02-26 16:47:03 +01:00
MartinBraquet
7f8f394d58 Rename releases CD 2026-02-26 16:38:11 +01:00
MartinBraquet
57d9d2df38 Fix java version 2026-02-26 16:33:17 +01:00
MartinBraquet
b7500ba634 Ignore *my-release-key.keystore 2026-02-26 16:22:30 +01:00
MartinBraquet
615d56131f Fix missing google services 2026-02-26 16:22:12 +01:00
MartinBraquet
c6f4b05e2a Push asset for android release automation 2026-02-26 16:06:34 +01:00
MartinBraquet
366581bcb1 Test 2026-02-26 15:42:38 +01:00
MartinBraquet
35d96fff5d Fix path 2026-02-26 15:42:06 +01:00
MartinBraquet
24e088b599 Add workflow for android release to testing track 2026-02-26 15:28:23 +01:00
MartinBraquet
432d2df449 Set npx capacitor-assets generate --android 2026-02-26 14:47:22 +01:00
MartinBraquet
68a79c4b90 Clean 2026-02-26 14:46:56 +01:00
MartinBraquet
fa922bdcbe Fix 2026-02-26 11:23:01 +01:00
MartinBraquet
1086f6b4e2 Fix share social 2026-02-26 11:15:50 +01:00
MartinBraquet
44d3e7577b Improve OG layout for scare profile 2026-02-26 11:10:07 +01:00
MartinBraquet
4015db7fda Fix share button 2026-02-26 10:03:59 +01:00
MartinBraquet
04f41c42c4 Clean button 2026-02-26 01:55:54 +01:00
MartinBraquet
67fb98c672 Show OG card at the end of onboarding 2026-02-26 01:41:31 +01:00
MartinBraquet
8c21d2990f Set imageUrl for FCM 2026-02-25 23:21:01 +01:00
MartinBraquet
32201b6dfa Improve OG 2026-02-25 23:20:45 +01:00
MartinBraquet
4326c870a8 Fix icon bubble message 2026-02-25 23:20:28 +01:00
MartinBraquet
e03c714555 Release 2026-02-25 21:35:21 +01:00
MartinBraquet
59cb649540 Fix keyword search 2026-02-25 21:34:22 +01:00
MartinBraquet
77e40c088c Fix 2026-02-25 21:05:13 +01:00
MartinBraquet
e5aeda92c8 Clean capitalize 2026-02-25 20:48:37 +01:00
MartinBraquet
0e99f75b73 Release 2026-02-25 20:00:07 +01:00
MartinBraquet
9ec5fe549b Add support for OG card 2026-02-25 19:55:28 +01:00
MartinBraquet
47cf7bd3b2 Force white background in OG 2026-02-25 15:48:46 +01:00
MartinBraquet
c848007874 Add headline to SEO description 2026-02-25 15:35:18 +01:00
MartinBraquet
abba1260be Add headline 2026-02-25 15:34:02 +01:00
MartinBraquet
cfc6b45a5b Improve SEO image 2026-02-25 15:23:56 +01:00
MartinBraquet
5e8f8167d1 Clean color 2026-02-25 14:40:05 +01:00
MartinBraquet
dce0821b1a Fixes want kids lavel in filters format 2026-02-25 14:31:20 +01:00
MartinBraquet
129dde8713 Release 2026-02-24 23:29:04 +01:00
MartinBraquet
5d368a61eb Fixes interest indicator section 2026-02-24 23:25:57 +01:00
MartinBraquet
2d0a869b00 Improve french wording 2026-02-24 23:25:28 +01:00
MartinBraquet
88efbe4666 Increase subtitle font size 2026-02-24 23:25:04 +01:00
MartinBraquet
46aba5dc8d Fix test for create channel 2026-02-24 19:59:32 +01:00
MartinBraquet
5321dd5690 Entre vous 2026-02-24 19:59:18 +01:00
MartinBraquet
2b0cd7ad3a aAdd notif for interest indicator 2026-02-24 19:59:02 +01:00
MartinBraquet
3c08ba3cae Clean 2026-02-24 19:21:20 +01:00
MartinBraquet
f850b4ada5 API release 2026-02-24 19:09:34 +01:00
MartinBraquet
1dbe4ecdef Improve colors buttons connect-actions.tsx 2026-02-24 19:09:17 +01:00
MartinBraquet
2b31ed3164 Add notifications for interest indicator 2026-02-24 18:00:12 +01:00
MartinBraquet
df2473929a Add connection interest notification and update related API logic 2026-02-24 17:32:36 +01:00
MartinBraquet
80a877301a Add connection preferences feature with API endpoints and UI updates 2026-02-24 17:07:59 +01:00
MartinBraquet
1aae688f3f Update CI configuration for E2E tests and add Supabase start step 2026-02-24 00:58:12 +01:00
MartinBraquet
337ce4523f Replace triangle icons with chevron icons in profile comments 2026-02-24 00:47:35 +01:00
MartinBraquet
e0b26af2bc Update padding in show-more component for better spacing 2026-02-24 00:38:05 +01:00
MartinBraquet
1e2c2bbb8f Update padding in show-more component for better spacing 2026-02-24 00:36:21 +01:00
MartinBraquet
ab0fd0aea4 Add localization support for relative time formatting 2026-02-24 00:36:09 +01:00
MartinBraquet
1310c423bd Update editable bio styles for improved readability 2026-02-24 00:35:42 +01:00
MartinBraquet
32fadcc194 Disable immediate rendering in editor configuration 2026-02-24 00:35:12 +01:00
MartinBraquet
a2959a773e Refactor profile forms to simplify bio handling and remove conditional rendering 2026-02-24 00:09:27 +01:00
MartinBraquet
cadb4a4fd5 Bump version to 1.15.0 in package.json 2026-02-24 00:02:59 +01:00
MartinBraquet
8decdab0c3 Refactor notification tests to improve assertions and add locale support 2026-02-24 00:01:39 +01:00
MartinBraquet
b710fa9f60 Finish notif translations 2026-02-23 23:49:51 +01:00
MartinBraquet
3cb5d08801 Update remotePatterns to include martinbraquet.com 2026-02-23 23:46:54 +01:00
MartinBraquet
aa785c1539 Lint fix email package 2026-02-23 22:20:31 +01:00
MartinBraquet
f0c645b16d Add linting and typechecking scripts for backend/email 2026-02-23 22:19:01 +01:00
MartinBraquet
9870ac5029 Add translations for notifications 2026-02-23 18:39:25 +01:00
MartinBraquet
cd067cd1a9 Replace TriangleDownFillIcon with ChevronDown in ReplyToggle component 2026-02-23 15:09:52 +01:00
MartinBraquet
e1805d9d9e Add ignore-engines setting to .yarnrc and update package.json to include pg-query-stream dependency 2026-02-23 15:05:48 +01:00
MartinBraquet
23a8aa6712 Refactor avatar URL assignment and simplify user retrieval in create-user logic 2026-02-23 14:58:45 +01:00
MartinBraquet
f70a74d20e Upgrade eslint to v9 2026-02-23 14:48:03 +01:00
MartinBraquet
02ea9131e4 Bump version to 1.13.2 in package.json 2026-02-23 14:14:44 +01:00
MartinBraquet
cd8096f524 Update dist:copy script to include messages directory in copy process 2026-02-23 14:09:15 +01:00
MartinBraquet
52819f3259 Upgrade 2026-02-23 13:48:42 +01:00
MartinBraquet
55c1b3983d Refactor message loading and fix paths for translation files 2026-02-23 13:48:23 +01:00
MartinBraquet
27ac1539cb Add locale retrieval from cookies in auth context and improve logging 2026-02-23 13:22:34 +01:00
MartinBraquet
119bd9699d Refactor createUser function to simplify device token handling and improve logging 2026-02-23 13:18:26 +01:00
MartinBraquet
607285f25d Update @tiptap dependencies to version 2.10.4 in package.json 2026-02-23 13:13:41 +01:00
MartinBraquet
192a944f4b Add multi language support for emails 2026-02-23 12:54:43 +01:00
MartinBraquet
c085e8f6dd Add guidelines for adding translations to existing files 2026-02-22 21:51:08 +01:00
MartinBraquet
79f855d39a Add EnglishOnlyWarning component and display warning in relevant pages 2026-02-22 21:50:59 +01:00
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
883 changed files with 55568 additions and 26703 deletions

View File

@@ -0,0 +1,300 @@
---
apply: by model decision
---
---
trigger: always_on
description:
globs:
---
## Project Structure
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
- **Next.js React frontend** `/web`
- Pages, components, hooks, lib
- **Express Node API server** `/backend/api`
- **Shared backend utilities** `/backend/shared`
- **Email functions** `/backend/email`
- **Database schema** `/backend/supabase`
- Supabase-generated types in `/backend/supabase/schema.ts`
- **Files shared between frontend and backend** `/common`
- Types (User, Profile, etc.) and utilities
- Try not to add package dependencies to common
- **Android app** `/android`
## Deployment
- Both dev and prod environments
- Backend on GCP (Google Cloud Platform)
- Frontend on Vercel
- Database on Supabase (PostgreSQL)
- Firebase for authentication and storage
## Code Guidelines
### Component Example
```tsx
import clsx from 'clsx'
import Link from 'next/link'
import {User} from 'common/user'
import {ProfileRow} from 'common/profiles/profile'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
interface ProfileCardProps {
user: User
profile: ProfileRow
}
export function ProfileCard({user, profile}: ProfileCardProps) {
const t = useT()
return (
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
<img src={user.avatarUrl} alt={user.name} />
<h3>{user.name}</h3>
<p>{profile.bio}</p>
</div>
)
}
```
We prefer many smaller components that each represent one logical unit, rather than one large component.
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx`
`ProfileCard`).
### API Calls
**Server-side (getStaticProps):**
```typescript
import {api} from 'web/lib/api'
export async function getStaticProps() {
const profiles = await api('get-profiles', {})
return {
props: {profiles},
revalidate: 30 * 60, // 30 minutes
}
}
```
**Client-side - use hooks:**
```typescript
import {useAPIGetter} from 'web/hooks/use-api-getter'
function ProfileList() {
const {data, refresh} = useAPIGetter('get-profiles', {})
if (!data) return <Loading / >
return (
<div>
{
data.profiles.map((profile) => (
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
/>
))
}
<button onClick = {refresh} > Refresh < /button>
< /div>
)
}
```
### Database Access
**Backend (pg-promise):**
```typescript
import {createSupabaseDirectClient} from 'shared/supabase/init'
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
```
**Frontend (Supabase client):**
```typescript
import {db} from 'web/lib/supabase/db'
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
```
### Translation
```typescript
import {useT} from 'web/lib/locale'
function MyComponent() {
const t = useT()
return <h1>{t('welcome', 'Welcome to Compass'
)
}
</h1>
}
```
Translation files are in `common/messages/` (en.json, fr.json, de.json).
### Backend Endpoints
1. Define schema in `common/src/api/schema.ts`:
```typescript
'get-user-and-profile'
:
{
method: 'GET',
authed
:
false,
rateLimited
:
true,
props
:
z.object({
username: z.string().min(1),
}),
returns
:
{
}
as
{
user: User;
profile: ProfileRow | null
}
,
summary: 'Get user and profile data by username',
tag
:
'Users',
}
,
```
2. Create handler in `backend/api/src/`:
```typescript
import {APIErrors, APIHandler} from './helpers/endpoint'
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
const user = await getUserByUsername(username)
if (!user) {
throw APIErrors.notFound('User not found')
}
return {user, profile}
}
```
3. Register in `backend/api/src/app.ts`:
```typescript
import {getUserAndProfile} from './get-user-and-profile'
const handlers = {
'get-user-and-profile': getUserAndProfile,
// ...
}
```
### Profile Options (Interests, Causes, Work)
Options are stored in separate tables with many-to-many relationships:
- `interests`, `causes`, `work` - option values
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
Fetch in parallel:
```typescript
const [interestsRes, causesRes, workRes] = await Promise.all([
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
])
```
## Testing
### Running Tests
```bash
# Jest (unit + integration)
yarn test
# Playwright (E2E)
yarn test:e2e
```
### Test Structure
- Unit tests: `*.unit.test.ts` in `tests/unit/`
- Integration tests: `*.integration.test.ts` in `tests/integration/`
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
### Mocking Example
```typescript
jest.mock('shared/supabase/init')
import {createSupabaseDirectClient} from 'shared/supabase/init'
const mockPg = {
oneOrNone: jest.fn(),
tx: jest.fn(async (cb) => cb(mockTx)),
}
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
```
## Important Patterns
### User Registration
- Create user + profile + options in single database transaction
- Return full profile data from creation API
- Don't use sleep() hacks - rely on transactional integrity
### API Errors
```typescript
import {APIErrors} from './helpers/endpoint'
throw APIErrors.notFound('User not found')
throw APIErrors.badRequest('Invalid input', {field: 'email'})
```
### Logging
- Use `debug()` from `common/logger` for development
- Use `log` from `shared/utils` for production
## Things to Avoid
- Don't use string concatenation for SQL queries
- Don't add sleep() delays for "eventual consistency"
- Don't create separate API calls when data can be batched in one transaction
- Don't use console.log - use `debug()` or `log()`
## Key Dependencies
- Node.js 20+
- React 19
- Next.js 16
- Supabase (PostgreSQL)
- Firebase (Auth, Storage)
- Tailwind CSS
- Jest (testing)
- Playwright (E2E testing)

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 -->

33
.github/actions/setup/action.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Setup
description: Checkout, cache and install dependencies
runs:
using: composite
steps:
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
web/node_modules
backend/api/node_modules
backend/shared/node_modules
backend/email/node_modules
common/node_modules
key: node-modules-${{ hashFiles('**/yarn.lock') }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
shell: bash
- name: Post-install
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn postinstall
shell: bash

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

@@ -0,0 +1,271 @@
---
trigger: always_on
description:
globs:
---
## Project Structure
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
- **Next.js React frontend** `/web`
- Pages, components, hooks, lib
- **Express Node API server** `/backend/api`
- **Shared backend utilities** `/backend/shared`
- **Email functions** `/backend/email`
- **Database schema** `/backend/supabase`
- Supabase-generated types in `/backend/supabase/schema.ts`
- **Files shared between frontend and backend** `/common`
- Types (User, Profile, etc.) and utilities
- Try not to add package dependencies to common
- **Android app** `/android`
## Deployment
- Both dev and prod environments
- Backend on GCP (Google Cloud Platform)
- Frontend on Vercel
- Database on Supabase (PostgreSQL)
- Firebase for authentication and storage
## Code Guidelines
### Component Example
```tsx
import clsx from 'clsx'
import Link from 'next/link'
import {User} from 'common/user'
import {ProfileRow} from 'common/profiles/profile'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
interface ProfileCardProps {
user: User
profile: ProfileRow
}
export function ProfileCard({user, profile}: ProfileCardProps) {
const t = useT()
return (
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
<img src={user.avatarUrl} alt={user.name} />
<h3>{user.name}</h3>
<p>{profile.bio}</p>
</div>
)
}
```
We prefer many smaller components that each represent one logical unit, rather than one large component.
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx`
`ProfileCard`).
### API Calls
**Server-side (getStaticProps):**
```typescript
import {api} from 'web/lib/api'
export async function getStaticProps() {
const profiles = await api('get-profiles', {})
return {
props: {profiles},
revalidate: 30 * 60, // 30 minutes
}
}
```
**Client-side - use hooks:**
```typescript
import {useAPIGetter} from 'web/hooks/use-api-getter'
function ProfileList() {
const {data, refresh} = useAPIGetter('get-profiles', {})
if (!data) return <CompassLoadingIndicator / >
return (
<div>
{
data.profiles.map((profile) => (
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
/>
))
}
<button onClick = {refresh} > Refresh < /button>
< /div>
)
}
```
### Database Access
**Backend (pg-promise):**
```typescript
import {createSupabaseDirectClient} from 'shared/supabase/init'
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
```
**Frontend (Supabase client):**
```typescript
import {db} from 'web/lib/supabase/db'
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
```
### Translation
```typescript
import {useT} from 'web/lib/locale'
function MyComponent() {
const t = useT()
return <h1>{t('welcome', 'Welcome to Compass')}</h1>
}
```
Translation files are in `common/messages/` (en.json, fr.json, de.json).
### Backend Endpoints
1. Define schema in `common/src/api/schema.ts`:
```typescript
'get-user-and-profile': {
method: 'GET',
authed: false,
rateLimited: true,
props: z.object({
username: z.string().min(1),
}),
returns: {} as {user: User; profile: ProfileRow | null},
summary: 'Get user and profile data by username',
tag: 'Users',
},
```
2. Create handler in `backend/api/src/`:
```typescript
import {APIError, APIHandler} from './helpers/endpoint'
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
const user = await getUserByUsername(username)
if (!user) {
throw APIErrors.notFound('User not found')
}
return {user, profile}
}
```
3. Register in `backend/api/src/app.ts`:
```typescript
import {getUserAndProfile} from './get-user-and-profile'
const handlers = {
'get-user-and-profile': getUserAndProfile,
// ...
}
```
### Profile Options (Interests, Causes, Work)
Options are stored in separate tables with many-to-many relationships:
- `interests`, `causes`, `work` - option values
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
Fetch in parallel:
```typescript
const [interestsRes, causesRes, workRes] = await Promise.all([
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
])
```
## Testing
### Running Tests
```bash
# Jest (unit + integration)
yarn test
# Playwright (E2E)
yarn test:e2e
```
### Test Structure
- Unit tests: `*.unit.test.ts` in `tests/unit/`
- Integration tests: `*.integration.test.ts` in `tests/integration/`
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
### Mocking Example
```typescript
jest.mock('shared/supabase/init')
import {createSupabaseDirectClient} from 'shared/supabase/init'
const mockPg = {
oneOrNone: jest.fn(),
tx: jest.fn(async (cb) => cb(mockTx)),
}
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
```
## Important Patterns
### User Registration
- Create user + profile + options in single database transaction
- Return full profile data from creation API
- Don't use sleep() hacks - rely on transactional integrity
### API Errors
```typescript
import {APIError} from './helpers/endpoint'
throw APIErrors.notFound('User not found')
throw APIErrors.badRequest('Invalid input', {field: 'email'})
```
### Logging
- Use `debug()` from `common/logger` for development
- Use `log` from `shared/utils` for production
## Things to Avoid
- Don't use string concatenation for SQL queries
- Don't add sleep() delays for "eventual consistency"
- Don't create separate API calls when data can be batched in one transaction
- Don't use console.log - use `debug()` or `log()`
## Key Dependencies
- Node.js 20+
- React 19
- Next.js 16
- Supabase (PostgreSQL)
- Firebase (Auth, Storage)
- Tailwind CSS
- Jest (testing)
- Playwright (E2E testing)

View File

@@ -1,24 +1,23 @@
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:
name: Deploy
check-version:
name: Check Version
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # we need full history for git log
- name: Install jq
run: sudo apt-get install -y jq
fetch-depth: 2
- name: Read current version
id: current
@@ -29,7 +28,6 @@ jobs:
- name: Read previous version
id: previous
run: |
# Get previous commits package.json (if it existed)
if git show HEAD^:android/capawesome.json >/dev/null 2>&1; then
previous=$(git show HEAD^:android/capawesome.json | jq -r '.version')
else
@@ -37,32 +35,31 @@ jobs:
fi
echo "version=$previous" >> $GITHUB_OUTPUT
- name: Check version change
- name: Compare versions
id: check
run: |
echo "current=${{ steps.current.outputs.version }}"
echo "previous=${{ steps.previous.outputs.version }}"
if [ "${{ steps.current.outputs.version }}" = "${{ steps.previous.outputs.version }}" ]; then
echo "Version unchanged. Skipping deploy."
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
if: steps.check.outputs.changed == 'true'
uses: actions/setup-node@v4
with:
node-version: '22'
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: check-version
if: needs.check-version.outputs.changed == 'true'
- name: Install dependencies
if: steps.check.outputs.changed == 'true'
run: yarn install
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: Deploy Live Update
if: steps.check.outputs.changed == 'true'
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_SUPABASE_INSTANCE_ID: ${{ secrets.NEXT_PUBLIC_SUPABASE_INSTANCE_ID }}
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
CAPAWESOME_TOKEN: ${{ secrets.CAPAWESOME_TOKEN }}
commitRef: ${{ github.head_ref || github.ref_name }}

88
.github/workflows/cd-android.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Android Release
on:
push:
branches:
- main
paths:
- 'android/app/build.gradle'
- '.github/workflows/cd-android.yml'
jobs:
check-version:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.version_check.outputs.should_build }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Get previous versionCode
id: prev_version
run: |
git checkout HEAD^
PREV=$(grep versionCode android/app/build.gradle | awk '{print $2}')
echo "prev_version=$PREV" >> $GITHUB_OUTPUT
git checkout -
- name: Get current versionCode
id: curr_version
run: |
CURR=$(grep versionCode android/app/build.gradle | awk '{print $2}')
echo "curr_version=$CURR" >> $GITHUB_OUTPUT
- name: Compare versionCodes
id: version_check
run: |
if [ "${{ steps.curr_version.outputs.curr_version }}" -gt "${{ steps.prev_version.outputs.prev_version }}" ]; then
echo "should_build=true" >> $GITHUB_OUTPUT
else
echo "versionCode not increased. Skipping build."
echo "should_build=false" >> $GITHUB_OUTPUT
fi
build-and-release:
runs-on: ubuntu-latest
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle
- name: Compile Web App into Android assets
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
run: yarn build-sync-android
- name: Build AAB
run: |
cd android
echo "${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore
cp release.keystore app/release.keystore
chmod +x gradlew
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=release.keystore \
-Pandroid.injected.signing.store.password=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
-Pandroid.injected.signing.key.alias=compass \
-Pandroid.injected.signing.key.password=${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Upload to Play Store (Internal Track)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.compassconnections.app
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
track: internal

View File

@@ -1,24 +1,23 @@
name: CD API
name: API Release
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:
name: Deploy
check-version:
name: Check Version
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # we need full history for git log
- name: Install jq
run: sudo apt-get install -y jq
fetch-depth: 2
- name: Read current version
id: current
@@ -29,7 +28,6 @@ jobs:
- name: Read previous version
id: previous
run: |
# Get previous commits package.json (if it existed)
if git show HEAD^:backend/api/package.json >/dev/null 2>&1; then
previous=$(git show HEAD^:backend/api/package.json | jq -r '.version')
else
@@ -37,60 +35,53 @@ jobs:
fi
echo "version=$previous" >> $GITHUB_OUTPUT
- name: Check version change
- name: Compare versions
id: check
run: |
echo "current=${{ steps.current.outputs.version }}"
echo "previous=${{ steps.previous.outputs.version }}"
if [ "${{ steps.current.outputs.version }}" = "${{ steps.previous.outputs.version }}" ]; then
echo "Version unchanged. Skipping deploy."
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
if: steps.check.outputs.changed == 'true'
uses: actions/setup-node@v4
with:
node-version: '22'
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: check-version
if: needs.check-version.outputs.changed == 'true'
- name: Install dependencies
if: steps.check.outputs.changed == 'true'
run: yarn install
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: Authenticate to GCP
if: steps.check.outputs.changed == 'true'
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Install gcloud CLI
if: steps.check.outputs.changed == 'true'
uses: google-github-actions/setup-gcloud@v2
with:
project_id: compass-130ba
- name: Configure Docker for Artifact Registry
if: steps.check.outputs.changed == 'true'
run: |
gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
run: gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
- name: Install Tofu (Terraform)
if: steps.check.outputs.changed == 'true'
run: |
sudo apt-get update
sudo apt-get install -y wget unzip
LATEST=https://github.com/opentofu/opentofu/releases/download/v1.10.5/tofu_1.10.5_linux_amd64.zip
curl -LO "$LATEST"
unzip -o tofu_*_linux_amd64.zip
sudo mv tofu /usr/local/bin/
rm tofu_*_linux_amd64.zip
echo "OpenTofu version: $(tofu version)"
cd backend/api || exit 1
cd backend/api
tofu init
- name: Run deploy script
if: steps.check.outputs.changed == 'true'
run: |
chmod +x backend/api/deploy-api.sh
backend/api/deploy-api.sh

View File

@@ -1,14 +1,13 @@
name: CD
name: GitHub Release
# 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

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

@@ -0,0 +1,68 @@
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: Cache Firebase emulators
uses: actions/cache@v4
with:
path: ~/.cache/firebase/emulators
key: firebase-emulators-${{ hashFiles('firebase.json') }}
restore-keys: firebase-emulators-
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package.json') }}
- name: Install Java (for Firebase emulators)
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21' # Required for firebase-tools@15+
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
# Docker load from cache is actually slower than pulling the images every time with supabase start
- name: Start Supabase
run: ./scripts/supabase_start.sh
- 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: tests/reports/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
if-no-files-found: ignore

View File

@@ -2,64 +2,44 @@ name: CI
on:
push:
branches:
- main
branches: [main]
pull_request:
branches:
- main
branches: [main]
jobs:
ci:
name: Tests
lint:
name: Lint & Typecheck
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: yarn install
- name: Type check
run: echo skipping #npx tsc --noEmit
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- name: Lint
run: npm run lint
run: yarn lint
- name: Typecheck
run: yarn typecheck
test:
name: Jest Tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
- 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
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
if: success()
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: |
@@ -71,5 +51,3 @@ jobs:
flags: unit
fail_ci_if_error: true
slug: CompassConnections/Compass
env:
CI: true

3
.gitignore vendored
View File

@@ -39,7 +39,6 @@ yarn-error.log*
.env.local
.env.*
.envrc
supabase/*
# vercel
.vercel
@@ -101,3 +100,5 @@ test-results
/.nyc_output/
**/coverage
*my-release-key.keystore

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

1294
.junie/guidelines.md Normal file
View File

File diff suppressed because it is too large Load Diff

28
.lintstagedrc.mjs Normal file
View File

@@ -0,0 +1,28 @@
export default {
'web/**/*.{ts,tsx,js,jsx}': (files) => [
`prettier --write ${files.join(' ')}`,
`eslint --config web/eslint.config.mjs --fix ${files.join(' ')}`,
`eslint --config web/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
],
'common/**/*.{ts,tsx,js,jsx}': (files) => [
`prettier --write ${files.join(' ')}`,
`eslint --config common/eslint.config.mjs --fix ${files.join(' ')}`,
`eslint --config common/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
],
'backend/api/**/*.{ts,tsx,js,jsx}': (files) => [
`prettier --write ${files.join(' ')}`,
`eslint --config backend/api/eslint.config.mjs --fix ${files.join(' ')}`,
`eslint --config backend/api/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
],
'backend/shared/**/*.{ts,tsx,js,jsx}': (files) => [
`prettier --write ${files.join(' ')}`,
`eslint --config backend/shared/eslint.config.mjs --fix ${files.join(' ')}`,
`eslint --config backend/shared/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
],
'backend/email/**/*.{ts,tsx,js,jsx}': (files) => [
`prettier --write ${files.join(' ')}`,
`eslint --config backend/email/eslint.config.mjs --fix ${files.join(' ')}`,
`eslint --config backend/email/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
],
'**/*.{json,css,scss,md}': (files) => [`prettier --write ${files.join(' ')}`],
}

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
loglevel=error

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,9 +2,12 @@
"tabWidth": 2,
"useTabs": false,
"semi": false,
"trailingComma": "es5",
"singleQuote": true,
"plugins": ["prettier-plugin-sql"],
"singleAttributePerLine": false,
"bracketSpacing": false,
"printWidth": 100,
"trailingComma": "all",
"plugins": ["prettier-plugin-sql", "prettier-plugin-packagejson"],
"overrides": [
{
"files": "*.sql",

View File

@@ -1,422 +1,295 @@
---
trigger: always_on
description:
globs:
description:
globs:
---
## Project Structure
- next.js react tailwind frontend `/web`
- broken down into pages, components, hooks, lib
- express node api server `/backend/api`
- one off scripts, like migrations `/backend/scripts`
- supabase postgres. schema in `/backend/supabase`
- supabase-generated types in `/backend/supabase/schema.ts`
- files shared between backend directories `/backend/shared`
- anything in `/backend` can import from `shared`, but not vice versa
- files shared between the frontend and backend in `/common`
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from `/common`, but not vice versa.
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
- **Next.js React frontend** `/web`
- Pages, components, hooks, lib
- **Express Node API server** `/backend/api`
- **Shared backend utilities** `/backend/shared`
- **Email functions** `/backend/email`
- **Database schema** `/backend/supabase`
- Supabase-generated types in `/backend/supabase/schema.ts`
- **Files shared between frontend and backend** `/common`
- Types (User, Profile, etc.) and utilities
- Try not to add package dependencies to common
- **Android app** `/android`
## Deployment
- The project has both dev and prod environments.
- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform.
- Project ID is `compass-130ba`.
- Both dev and prod environments
- Backend on GCP (Google Cloud Platform)
- Frontend on Vercel
- Database on Supabase (PostgreSQL)
- Firebase for authentication and storage
## Code Guidelines
---
Here's an example component from web in our style:
### Component Example
```tsx
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 {User} from 'common/user'
import {ProfileRow} from 'common/profiles/profile'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
export function HeadlineTabs(props: {
headlines: Headline[]
currentSlug: string
endpoint: DashboardEndpoints
hideEmoji?: boolean
notSticky?: boolean
className?: string
}) {
const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } =
props
const user = useUser()
interface ProfileCardProps {
user: User
profile: ProfileRow
}
export function ProfileCard({user, profile}: ProfileCardProps) {
const t = useT()
return (
<div
className={clsx(
className,
'bg-canvas-50 w-full',
!notSticky && 'sticky top-0 z-50'
)}
>
<Carousel labelsParentClassName="gap-px">
{headlines.map(({ id, slug, title }) => (
<Tab
key={id}
label={hideEmoji ? removeEmojis(title) : title}
href={`/${endpoint}/${slug}`}
active={slug === currentSlug}
/>
))}
{user && <Tab label="More" href="/dashboard" />}
{user && (isAdminId(user.id) || isModId(user.id)) && (
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
)}
</Carousel>
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
<img src={user.avatarUrl} alt={user.name} />
<h3>{user.name}</h3>
<p>{profile.bio}</p>
</div>
)
}
```
---
We prefer many smaller components that each represent one logical unit, rather than one large component.
We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components.
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx`
`ProfileCard`).
It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find.
### API Calls
Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS:
```ts
import { api } from 'web/lib/api/api'
// More imports...
export async function getStaticProps() {
try {
const headlines = await api('headlines', {})
return {
props: {
headlines,
revalidate: 30 * 60, // 30 minutes
},
}
} catch (err) {
return { props: { headlines: [] }, revalidate: 60 }
}
}
export default function Home(props: { headlines: Headline[] }) { ... }
```
---
If we are calling the API on the client, prefer using the `useAPIGetter` hook:
```ts
export const YourTopicsSection = (props: {
user: User
className?: string
}) => {
const { user, className } = props
const { data, refresh } = useAPIGetter('get-followed-groups', {
userId: user.id,
})
const followedGroups = data?.groups ?? []
...
```
This stores the result in memory, and allows you to call refresh() to get an updated version.
---
We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and appear to load instantly.
Here's the definition of usePersistentInMemoryState:
```ts
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
const [state, setState] = useStateCheckEquality<T>(
safeJsonParse(store[key]) ?? initialValue
)
useEffect(() => {
const storedValue = safeJsonParse(store[key]) ?? initialValue
setState(storedValue as T)
}, [key])
const saveState = useEvent((newState: T | ((prevState: T) => T)) => {
setState((prevState) => {
const updatedState = isFunction(newState) ? newState(prevState) : newState
store[key] = JSON.stringify(updatedState)
return updatedState
})
})
return [state, saveState] as const
}
```
---
For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook:
```ts
export function useApiSubscription(opts: SubscriptionOptions) {
useEffect(() => {
const ws = client
if (ws != null) {
if (opts.enabled ?? true) {
ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
return () => {
ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
}
}
}
}, [opts.enabled, JSON.stringify(opts.topics)])
}
```
In `use-bets`, we have this hook to get live updates with useApiSubscription:
```ts
export const useContractBets = (
contractId: string,
opts?: APIParams<'bets'> & { enabled?: boolean }
) => {
const { enabled = true, ...apiOptions } = {
contractId,
...opts,
}
const optionsKey = JSON.stringify(apiOptions)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
[],
`${optionsKey}-bets`
)
const addBets = (bets: Bet[]) => {
setNewBets((currentBets) => {
const uniqueBets = sortBy(
uniqBy([...currentBets, ...bets], 'id'),
'createdTime'
)
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
})
}
const isPageVisible = useIsPageVisible()
useEffect(() => {
if (isPageVisible && enabled) {
api('bets', apiOptions).then(addBets)
}
}, [optionsKey, enabled, isPageVisible])
useApiSubscription({
topics: [`contract/${contractId}/new-bet`],
onBroadcast: (msg) => {
addBets(msg.data.bets as Bet[])
},
enabled,
})
return newBets
}
```
---
Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts`
```ts
export function broadcastUpdatedPrivateUser(userId: string) {
// don't send private user info because it's private and anyone can listen
broadcast(`private-user/${userId}`, {})
}
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
broadcast(`user/${user.id}`, { user })
}
export function broadcastUpdatedComment(comment: Comment) {
broadcast(`user/${comment.onUserId}/comment`, { comment })
}
```
---
We have our scripts in the directory `/backend/scripts`.
To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env.
Example from `/backend/scripts/manicode.ts`
```ts
import { runScript } from 'run-script'
runScript(async ({ pg }) => {
const userPrompt = process.argv[2]
await pg.none(...)
})
```
Generally scripts should be run by me, especially if they modify backend state or schema.
But if you need to run a script, you can use `bun`. For example:
```sh
bun run manicode.ts "Generate a page called cowp, which has cows that make noises!"
```
if that doesn't work, try
```sh
bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!"
```
---
Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `/common/src/api/schema.ts`.
E.g. Here is a hypothetical bet schema:
```ts
bet: {
method: 'POST',
authed: true,
returns: {} as CandidateBet & { betId: string },
props: z
.object({
contractId: z.string(),
amount: z.number().gte(1),
replyToCommentId: z.string().optional(),
limitProb: z.number().gte(0.01).lte(0.99).optional(),
expiresAt: z.number().optional(),
// Used for binary and new multiple choice contracts (cpmm-multi-1).
outcome: z.enum(['YES', 'NO']).default('YES'),
//Multi
answerId: z.string().optional(),
dryRun: z.boolean().optional(),
})
.strict(),
}
```
Then, we define the bet endpoint in `backend/api/src/place-bet.ts`
```ts
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]
)
}
```
And finally, you need to register the handler in `backend/api/src/routes.ts`
```ts
import { placeBet } from './place-bet'
...
const handlers = {
bet: placeBet,
...
}
```
---
We have two ways to access our postgres database.
```ts
import { db } from 'web/lib/supabase/db'
db.from('profiles').select('*').eq('user_id', userId)
```
and
```ts
import { createSupabaseDirectClient } from 'shared/supabase/init'
const pg = createSupabaseDirectClient()
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [userId])
```
The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend.
`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this.
Another example using the direct client:
```ts
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]
)
return res.map((r) => r.user_id as string)
}
```
(you may notice we write sql in lowercase)
We have a few helper functions for updating and inserting data into the database.
```ts
import {
buikInsert,
bulkUpdate,
bulkUpdateData,
bulkUpsert,
insert,
update,
updateData,
} from 'shared/supabase/utils'
...
const pg = createSupabaseDirectClient()
// you are encouraged to use tryCatch for these
const { data, error } = await tryCatch(
insert(pg, 'profiles', { user_id: auth.uid, ...body })
)
if (error) throw APIError(500, 'Error creating profile: ' + error.message)
await update(pg, 'profiles', 'user_id', { user_id: auth.uid, age: 99 })
await updateData(pg, 'private_users', { id: userId, notifications: { ... } })
```
The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including:
- `select`: Specifies the columns to select
- `from`: Specifies the table to query
- `where`: Adds WHERE clauses
- `orderBy`: Specifies the order of results
- `limit`: Limits the number of results
- `renderSql`: Combines all parts into a final SQL string
Example usage:
**Server-side (getStaticProps):**
```typescript
const query = renderSql(
select('distinct user_id'),
from('contract_bets'),
where('contract_id = ${id}', { id }),
orderBy('created_time desc'),
limitValue != null && limit(limitValue)
)
import {api} from 'web/lib/api'
const res = await pg.manyOrNone(query)
export async function getStaticProps() {
const profiles = await api('get-profiles', {})
return {
props: {profiles},
revalidate: 30 * 60, // 30 minutes
}
}
```
Use these functions instead of string concatenation.
**Client-side - use hooks:**
```typescript
import {useAPIGetter} from 'web/hooks/use-api-getter'
function ProfileList() {
const {data, refresh} = useAPIGetter('get-profiles', {})
if (!data) return <Loading / >
return (
<div>
{
data.profiles.map((profile) => (
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
/>
))
}
<button onClick = {refresh} > Refresh < /button>
< /div>
)
}
```
### Database Access
**Backend (pg-promise):**
```typescript
import {createSupabaseDirectClient} from 'shared/supabase/init'
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
```
**Frontend (Supabase client):**
```typescript
import {db} from 'web/lib/supabase/db'
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
```
### Translation
```typescript
import {useT} from 'web/lib/locale'
function MyComponent() {
const t = useT()
return <h1>{t('welcome', 'Welcome to Compass'
)
}
</h1>
}
```
Translation files are in `common/messages/` (en.json, fr.json, de.json).
### Backend Endpoints
1. Define schema in `common/src/api/schema.ts`:
```typescript
'get-user-and-profile'
:
{
method: 'GET',
authed
:
false,
rateLimited
:
true,
props
:
z.object({
username: z.string().min(1),
}),
returns
:
{
}
as
{
user: User;
profile: ProfileRow | null
}
,
summary: 'Get user and profile data by username',
tag
:
'Users',
}
,
```
2. Create handler in `backend/api/src/`:
```typescript
import {APIError, APIHandler} from './helpers/endpoint'
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
const user = await getUserByUsername(username)
if (!user) {
throw APIErrors.notFound('User not found')
}
return {user, profile}
}
```
3. Register in `backend/api/src/app.ts`:
```typescript
import {getUserAndProfile} from './get-user-and-profile'
const handlers = {
'get-user-and-profile': getUserAndProfile,
// ...
}
```
### Profile Options (Interests, Causes, Work)
Options are stored in separate tables with many-to-many relationships:
- `interests`, `causes`, `work` - option values
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
Fetch in parallel:
```typescript
const [interestsRes, causesRes, workRes] = await Promise.all([
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
])
```
### API Errors
```typescript
import {APIError} from './helpers/endpoint'
throw APIErrors.notFound('User not found')
throw APIErrors.badRequest('Invalid input', {field: 'email'})
```
### Logging
- Use `debug()` from `common/logger` for development
- Use `log` from `shared/utils` for production
## Testing
### Running Tests
```bash
# Jest (unit + integration)
yarn test
# Playwright (E2E)
yarn test:e2e
```
### Test Structure
- Unit tests: `*.unit.test.ts` in `tests/unit/`
- Integration tests: `*.integration.test.ts` in `tests/integration/`
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
### Mocking Example
```typescript
jest.mock('shared/supabase/init')
import {createSupabaseDirectClient} from 'shared/supabase/init'
const mockPg = {
oneOrNone: jest.fn(),
tx: jest.fn(async (cb) => cb(mockTx)),
}
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
```
## Important Patterns
### User Registration
- Create user + profile + options in single database transaction
- Return full profile data from creation API
- Don't use sleep() hacks - rely on transactional integrity
## Things to Avoid
- Don't use string concatenation for SQL queries
- Don't add sleep() delays for "eventual consistency"
- Don't create separate API calls when data can be batched in one transaction
- Don't use console.log - use `debug()` or `log()`
- Don't remove commented code
## Key Dependencies
- Node.js 20+
- React 19
- Next.js 16
- Supabase (PostgreSQL)
- Firebase (Auth, Storage)
- Tailwind CSS
- Jest (testing)
- Playwright (E2E testing)

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

@@ -0,0 +1,288 @@
---
description: Adding Translations to an Existing File (using useT)
---
## AI Assistant Workflow — Adding Translations to an Existing File (using `useT`)
This is **not** about adding a new language.
This is about correctly adding new translation keys to a feature or component that already exists.
Follow this strictly.
---
### 1⃣ Identify All User-Facing Strings
Scan the file and list every:
- Button label
- Title
- Placeholder
- Tooltip
- Toast message
- Modal text
- Validation error
- SEO metadata
- Empty state message
If a string is visible to users, it must be translated.
Do **not** leave inline English in JSX.
Bad:
```tsx
<button>Delete account</button>
```
Correct:
```tsx
<button>{t('settings.delete_account', 'Delete account')}</button>
```
---
### 2⃣ Import and Initialize `useT`
At the top of the file:
```tsx
import {useT} from 'web/lib/locale'
```
Inside the component:
```tsx
const t = useT()
```
No exceptions.
Do not manually access locale files.
For the backend, use
```tsx
import {createT} from 'shared/locale'
const t = createT(locale)
```
---
### 3⃣ Replace Hardcoded Strings
Wrap every string in:
```tsx
t('namespace.key', 'Default English text')
```
Example:
```tsx
t('news.seo.description_general', 'All news and code updates for Compass')
```
Rules:
- First argument = stable translation key
- Second argument = default English fallback
- The English text must exactly match what you want displayed
---
### 4⃣ Naming Convention for Keys
Use structured namespaces.
Good:
```
profile.delete.confirm_title
profile.delete.confirm_body
settings.notifications.email_label
events.create.submit_button
```
Bad:
```
delete1
buttonText
labelNew
```
Keys must be:
- Hierarchical
- Feature-scoped
- Predictable
- Stable (never rename casually)
---
### 5⃣ Add the Keys to All Existing Locale Files
After updating the component:
1. Open:
```
common/src/messages/fr.json
common/src/messages/de.json
...
```
2. Add the new keys to **every language file**
Keys must be identical across all files.
Example:
```json
{
"confirm_title": "Delete account?",
"confirm_body": "This action cannot be undone."
}
```
Then translate values for non-English files.
---
### 6⃣ Use an LLM for Draft Translation (Correctly)
When translating large additions:
- Copy only the new JSON section
- Ask:
```
Translate the values of the JSON below to French.
Keep all keys unchanged.
Return valid JSON only.
```
Never let the model modify keys.
Then manually review.
LLMs make mistakes:
- Wrong tone
- Cultural mismatch
- Broken JSON
- Overly long mobile labels
You must verify.
---
### 7⃣ Respect Mobile Constraints
Certain keys must stay short (< 10 characters):
```
nav.home
nav.messages
nav.more
nav.notifs
nav.people
```
If you add navigation items, enforce brevity.
---
### 8⃣ Handle Variables Properly
For dynamic values:
```tsx
t('events.count', '{count} events', {count})
```
Make sure placeholders match across all languages.
Do not concatenate strings manually.
Bad:
```tsx
'Events: ' + count
```
Correct:
```tsx
t('events.count', '{count} events', {count})
```
---
### 9⃣ SEO & Metadata
Even SEO descriptions must use translations:
```tsx
<meta
name="description"
content={t('profile.seo.description', 'View user profiles and connect with like-minded people.')}
/>
```
Do not hardcode metadata.
---
### 10⃣ Final Verification Checklist
Before committing:
- No visible English strings left
- All new keys added to all locale files
- No missing translations warnings
- JSON is valid
- Mobile nav labels are short
- Variables work in all languages
- No duplicate keys
- Namespaces are consistent
---
### Example — Full Pattern
```tsx
import {useT} from 'web/lib/locale'
export default function DeleteModal() {
const t = useT()
return (
<>
<h2>{t('profile.delete.confirm_title', 'Delete account?')}</h2>
<p>{t('profile.delete.confirm_body', 'This action cannot be undone.')}</p>
<button>{t('profile.delete.confirm_button', 'Delete')}</button>
</>
)
}
```
---
### Summary
When adding translations to a file:
1. Replace every user-visible string
2. Use `useT()`
3. Create structured keys
4. Add keys to every locale file
5. Translate values carefully
6. Validate JSON
7. Test UI in multiple languages
If you skip any of these steps, you create future maintenance debt.
There is no shortcut.

View File

@@ -1 +1,2 @@
save-exact true
save-exact true
ignore-engines true

114
AGENTS.md Normal file
View File

@@ -0,0 +1,114 @@
# AGENTS.md - AI Assistant Guidelines for Compass
This file provides guidance for AI assistants working on the Compass codebase.
## Project Overview
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections. Built with Next.js, React, Supabase, Firebase, and Google Cloud.
## Project Structure
```
/web # Next.js frontend (React, Tailwind CSS)
/backend/api # Express.js REST API
/backend/shared # Shared backend utilities
/backend/email # Email functions
/common # Shared types and utilities between frontend/backend
/supabase # Database schema and migrations
/android # Android mobile app
```
## Key Conventions
### Database Access
- Use `createSupabaseDirectClient()` for backend SQL queries (pg-promise)
- Use Supabase JS client (`db.from('table')`) for frontend queries
- Never use string concatenation for SQL - use parameterized queries
### API Development
1. Add endpoint schema to `common/src/api/schema.ts`
2. Create handler in `backend/api/src/`
3. Register in `backend/api/src/app.ts`
### Component Patterns
- Export main component at top of file
- Name component same as file (e.g., `profile-card.tsx``ProfileCard`)
- Use smaller, composable components over large ones
### Internationalization
- Translation files in `common/messages/` (en.json, fr.json, de.json)
- Use `useT()` hook: `t('key', 'fallback')`
### Testing
- Unit tests: `*.unit.test.ts` in package `tests/unit/`
- Mock external dependencies (DB, APIs, time)
- Use `jest.mock()` at top of test files
### Profile System
- Profile fields stored in `profiles` table
- Options (interests, causes, work) stored in separate tables with many-to-many relationship
- Always fetch profile options in parallel using Promise.all
### User Registration Flow
1. Create user + profile + options in single transaction
2. Never use sleep() hacks - rely on transactional integrity
3. Return full profile data from creation API
### Important Patterns
#### Frontend API calls (server-side):
```typescript
const result = await api('endpoint-name', {props})
```
#### Frontend API calls (client-side):
```typescript
const {data} = useAPIGetter('endpoint-name', {props})
```
#### Translation:
```typescript
const t = useT()
return <div>{t('key', 'Default text')}</div>
```
## Common Tasks
### Adding a profile field
1. Add column to `profiles` table via migration
2. Add to schema in `common/src/api/schema.ts`
3. Update frontend forms/components
### Adding translations
1. Add key to `common/messages/en.json`
2. Add translations to `fr.json`, `de.json`, etc.
## Things to Avoid
- Don't use string concatenation for SQL queries
- Don't add sleep() delays for "eventual consistency" - fix at DB level
- Don't create separate API calls when data can be batched in one transaction
- Don't use console.log - use `debug()` from `common/logger`
## Key Dependencies
- Node.js 20+
- React 19
- Next.js 16
- Supabase (PostgreSQL)
- Firebase (Auth, Storage)
- Tailwind CSS
- Jest (testing)
- Playwright (E2E testing)

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

@@ -1,125 +1,493 @@
# Contributing to This Repository
# Contributing to Compass
We welcome pull requests, but only if they meet the project's quality and design standards. Follow the process below precisely to avoid wasting time—yours or ours.
Thank you for your interest in contributing to Compass! This document provides comprehensive guidelines for contributing
to this open-source project.
## Prerequisites
## Table of Contents
- 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`)
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Environment](#development-environment)
- [Project Structure](#project-structure)
- [Coding Standards](#coding-standards)
- [Making Changes](#making-changes)
- [Testing](#testing)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Documentation](#documentation)
- [Questions and Support](#questions-and-support)
## Fork & Clone
## Code of Conduct
1. **Fork the repository** using the GitHub UI.
2. **Clone your fork** locally:
Please read and follow our [Code of Conduct](./CODE_OF_CONDUCT.md). We are committed to providing a welcoming and
inclusive environment for all contributors.
## Getting Started
### Prerequisites
Before contributing, ensure you have the following installed:
- **Node.js** 20.x or later
- **Yarn** 1.x (classic)
- **Git**
- **Docker** (optional, for isolated development)
### Fork and Clone
1. Fork the [repository](https://github.com/CompassConnections/Compass) on GitHub
2. Clone your fork:
```bash
git clone https://github.com/your-username/Compass.git
cd your-fork
3. **Add the upstream remote**:
git clone https://github.com/<your-username>/Compass.git
cd Compass
```
3. Add the upstream remote:
```bash
git remote add upstream https://github.com/CompassConnections/Compass.git
```
## Create a New Branch
Never work on `main` or `master`.
### Install Dependencies
```bash
git checkout -b fix/brief-but-specific-description
yarn install --frozen-lockfile
```
Use a clear, descriptive branch name. Avoid vague names like `patch-1`.
## Development Environment
## Stay Updated
Before you start, make sure your fork is up to date:
### Running the Development Server
```bash
git fetch upstream
git checkout main
git merge upstream/main
yarn dev
```
Then rebase your feature branch if needed:
Visit http://localhost:3000 to see the application.
### Isolated Development (Recommended)
For full isolation with local Supabase and Firebase emulators:
```bash
git checkout fix/your-feature
git rebase main
yarn dev:isolated
```
## Make Atomic Commits
Benefits:
Each commit should represent a single logical change. Follow this format:
- No conflicts with other contributors
- Works offline
- Faster database queries
- Free to reset and reseed data
```text
type(scope): concise description
Requirements:
body explaining what and why, if necessary
- Docker (~500MB)
- Supabase CLI
- Java 21+ (for Firebase emulators)
- Firebase CLI
See the [README](./README.md) for detailed setup instructions.
### Running Tests
```bash
# Run all tests
yarn test
# Run tests with coverage
yarn test:coverage
# Run tests in watch mode
yarn test:watch
# Run E2E tests
yarn test:e2e
```
### Linting and Type Checking
```bash
# Lint all packages
yarn lint
# Fix linting issues
yarn lint-fix
# Type check all packages
yarn typecheck
```
## Project Structure
This is a Yarn workspaces monorepo with the following packages:
```
Compass/
├── web/ # Next.js web application
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities and services
│ ├── pages/ # Next.js pages
│ └── messages/ # Internationalization files
├── backend/
│ ├── api/ # Express API server
│ ├── shared/ # Shared backend utilities
│ ├── email/ # React email templates
│ └── scripts/ # Database migration scripts
├── common/ # Shared TypeScript types and utilities
├── supabase/ # Database migrations and config
├── android/ # Capacitor Android app
└── docs/ # Project documentation
```
### Key Technologies
| Layer | Technology |
| -------- | -------------------------------- |
| Frontend | Next.js 16, React 19, TypeScript |
| Styling | Tailwind CSS |
| Backend | Express.js, Node.js |
| Database | PostgreSQL (Supabase) |
| Auth | Firebase Auth |
| Storage | Firebase Storage |
| Mobile | Capacitor (Android) |
| Testing | Jest, Playwright |
## Coding Standards
### TypeScript
- Use strict TypeScript typing
- Avoid `any` type; use `unknown` when necessary
- Prefer interfaces over types for object shapes
- Use `const` assertions where appropriate
### React Components
- Use functional components with hooks
- Name components after their file name
- Export primary component at the top of the file
- Use composition over inheritance
- Keep components small and focused
Example component structure:
```tsx
import clsx from 'clsx'
import {useState} from 'react'
interface ProfileCardProps {
name: string
age: number
onSelect?: (id: string) => void
className?: string
}
export function ProfileCard({name, age, onSelect, className}: ProfileCardProps) {
const [selected, setSelected] = useState(false)
const handleClick = () => {
setSelected(!selected)
onSelect?.(name)
}
return (
<div className={clsx('card', selected && 'selected', className)}>
<h3>
{name}, {age}
</h3>
<button onClick={handleClick}>Select</button>
</div>
)
}
```
### Naming Conventions
- **Files**: kebab-case (`profile-card.tsx`)
- **Components**: PascalCase (`ProfileCard`)
- **Hooks**: camelCase with `use` prefix (`useUserProfile`)
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Interfaces**: PascalCase
### Import Order
Run `yarn lint-fix` to automatically sort imports:
1. External libraries (React, Next.js, etc.)
2. Internal packages (`common/`, `shared/`)
3. Relative imports (`../`, `./`)
4. Type imports
### Error Handling
- Use try-catch for async operations
- Create custom error types for API errors
- Implement error boundaries for React components
- Log errors with appropriate context
Example:
```typescript
import {APIError} from './errors'
try {
const result = await api('endpoint', params)
return result
} catch (err) {
if (err instanceof APIError) {
logger.error('API error', {status: err.status, message: err.message})
} else {
logger.error('Unexpected error', err)
}
throw err
}
```
### Accessibility
- Use semantic HTML elements
- Include ARIA labels where appropriate
- Ensure keyboard navigation works
- Use the `SkipLink` component for main content
- Announce dynamic content changes with `useLiveRegion`
```tsx
import {useLiveRegion} from 'web/components/live-region'
function MyComponent() {
const {announce} = useLiveRegion()
const handleAction = () => {
// Action completed
announce('Action successful', 'polite')
}
}
```
## Making Changes
### Creating a Branch
Never work directly on `main`. Create a new branch:
```bash
git checkout -b type/short-description
```
Branch types:
- `feat/` - New features
- `fix/` - Bug fixes
- `docs/` - Documentation
- `refactor/` - Code refactoring
- `test/` - Adding/updating tests
- `chore/` - Maintenance tasks
### Making Commits
Keep commits atomic and descriptive:
```bash
git add .
git commit -m "feat(profiles): add compatibility score display
- Added compatibility score calculation
- Display score on profile cards
- Added tests for scoring algorithm"
```
See [Commit Message Guidelines](#commit-message-guidelines) for details.
### Keeping Your Fork Updated
```bash
# Fetch latest from upstream
git fetch upstream
# Update main branch
git checkout main
git merge upstream/main
# Rebase your feature branch
git checkout feat/your-feature
git rebase main
```
## Testing
### Writing Tests
#### Unit Tests
Place tests in `tests/unit/` within each package:
```typescript
// web/tests/unit/my-function.test.ts
import {myFunction} from '../my-function'
describe('myFunction', () => {
it('should return correct output', () => {
expect(myFunction('input')).toBe('expected')
})
})
```
#### Integration Tests
Place in `tests/integration/`:
```typescript
// web/tests/integration/api.test.ts
import {render, screen} from '@testing-library/react'
import {MyComponent} from '../MyComponent'
describe('MyComponent', () => {
it('renders correctly', () => {
render(<MyComponent / >)
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
```
#### E2E Tests
Place in `tests/e2e/` at the root:
```typescript
// tests/e2e/web/specs/onboarding.spec.ts
import {test, expect} from '@playwright/test'
test('onboarding flow', async ({page}) => {
await page.goto('/signup')
await page.fill('[name="email"]', 'test@example.com')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/onboarding')
})
```
### Running Specific Tests
```bash
# Run unit tests for web
yarn workspace web test
# Run tests matching pattern
yarn test --testPathPattern="profile"
# Run E2E tests
yarn test:e2e
```
### Test Coverage
Aim for meaningful test coverage. Focus on:
- Business logic
- User interactions
- Error handling
- Edge cases
## Pull Request Guidelines
### Before Submitting
1. **Run all tests**: `yarn test`
2. **Run linter**: `yarn lint`
3. **Run type check**: `yarn typecheck`
4. **Update documentation** if needed
5. **Rebase on main** if necessary
### Pull Request Format
**Title**: Clear, descriptive title
**Description**:
```markdown
## Summary
Brief description of changes
## Changes
- Added compatibility score to profile cards
- Updated search algorithm for better results
## Testing
- Added unit tests for scoring algorithm
- Tested manually with synthetic data
## Screenshots (if UI changes)
```
### PR Checklist
- [ ] Code follows style guidelines
- [ ] Tests added/updated and passing
- [ ] Documentation updated
- [ ] No console.log statements (except debugging)
- [ ] No debug code left behind
### Review Process
1. Maintainers review within 48 hours
2. Address feedback promptly
3. Do not open new PRs for changes - update existing one
4. Squash commits before merging
## Commit Message Guidelines
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation
- `style`: Formatting
- `refactor`: Code restructuring
- `test`: Tests
- `chore`: Maintenance
### Examples
```text
fix(api): handle 500 error on invalid payload
feat(profiles): add compatibility scoring algorithm
fix(api): handle rate limiting gracefully
docs(readme): update installation instructions
refactor(auth): simplify token refresh logic
test(profiles): add unit tests for scoring
```
Types include: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`.
## Documentation
## Test Everything
### Updating Documentation
If the project has tests, run them. If it doesnt, write some. Do **not** submit code that hasn't been tested.
- Update relevant README files
- Add JSDoc comments to complex functions
- Update the `/docs` folder for architectural changes
```bash
# Example for Node.js
npm test
```
### API Documentation
No exceptions. If you don't validate your changes, your PR will be closed.
API docs are auto-generated and available at:
## Lint & Format
- Production: https://api.compassmeet.com
- Local: http://localhost:8088 (when running locally)
Ensure code matches the project style. If the repo uses a linter or formatter, run them:
## Questions and Support
```bash
npm run lint
npm run format
```
- **Discord**: https://discord.gg/8Vd7jzqjun
- **Email**: hello@compassmeet.com
- **GitHub Issues**: For bug reports and feature requests
Or whatever command is defined in the repo.
## Write a Good Pull Request
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)
## 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.
## 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
## Security Issues
Do **not** open public issues for security vulnerabilities. Email the development team instead.
## License
By contributing, you agree that your code will be licensed under the same license as the rest of the project.
---
Thank you for contributing to Compass! Together we're building a platform for meaningful connections.

196
README.md
View File

@@ -1,14 +1,16 @@
![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)
[![CD Android](https://github.com/CompassConnections/Compass/actions/workflows/cd-android.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd-android.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
This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming
deep, authentic 1-on-1 connections with clarity and efficiency.
## Features
@@ -16,34 +18,50 @@ This repository contains the source code for [Compass](https://compassmeet.com)
- Radically transparent: user base fully searchable
- Free, ad-free, not for profit (supported by donations)
- Created, hosted, maintained, and moderated by volunteers
- Open source
- Open-source
- Democratically governed
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and
the [FAQ](https://www.compassmeet.com/faq) as well.
A detailed description of the early vision is also available in
this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass
shifted to a more general audience).
**We cant do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
**We cant do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small
donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help
our community thrive!
![Demo](https://raw.githubusercontent.com/CompassConnections/assets/refs/heads/main/assets/demo-2x.gif)
## To Do
No contribution is too small—whether its changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
No contribution is too small—whether its changing a color, resizing a button, tweaking a font, or improving wording.
Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The
goal is to make the platform better step by step, and every improvement counts. If you see something that could be
clearer, smoother, or more engaging, **please jump in**!
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.
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:
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).
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).
- [x] Authentication (user/password and Google Sign In)
- [x] Set up PostgreSQL in Production with supabase
@@ -57,15 +75,16 @@ 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
- [ ] Add events (group calls, in-person meetups, etc.)
- [x] Add events (group calls, in-person meetups, etc.)
#### Secondary To Do
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time contributors.
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time
contributors.
- [x] Clean up learn more page
- [x] Add dark theme
@@ -99,65 +118,167 @@ The web app is coded in Typescript using React as front-end. It includes:
## Development
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or
contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
### Installation
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
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
```
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles;
you should see a few synthetic profiles.
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!
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)
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
- Storage tab to see cookies and local storage
You can also add `console.log()` statements in the code.
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web
components or improving wording in some pages. You can find those files in `web/public/md/`.
##### Resources
There is a lof of documentation in the [docs](docs) folder and across the repo, namely:
There is a lot 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.
- [PERFORMANCE_OPTIMIZATION.md](docs/PERFORMANCE_OPTIMIZATION.md) for frontend, backend, and database performance best practices.
- [DATABASE_CONNECTION_POOLING.md](docs/DATABASE_CONNECTION_POOLING.md) for database connection management and troubleshooting.
- [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for resolving common development issues.
- [web](web) for the web.
- [backend/api](backend/api) for the backend API.
- [android](android) for the Android app.
@@ -167,22 +288,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>
```
@@ -191,17 +316,28 @@ Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `Compa
### Environment Variables
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.
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.
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the
bill.
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
That's why we separate all those services between production and development environments, so that you can code freely
without impacting the functioning of the deployed platform.
Contributors should use the default keys for local development. Production uses a separate environment with stricter
rules and private keys that are not shared.
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.
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.
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

@@ -4,9 +4,121 @@
| Version | Supported |
| ------- | ------------------ |
| 1.0.0 | :white_check_mark: |
| 1.10.x | :white_check_mark: |
| 1.9.x | :white_check_mark: |
| < 1.9.0 | :x: |
## Reporting a Vulnerability
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.
If you discover a security vulnerability within Compass, please send an email to hello@compassmeet.com. All security vulnerabilities will be promptly addressed.
Please do not publicly disclose the vulnerability until it has been resolved.
## Security Practices
Compass takes security seriously and implements several best practices to protect user data and ensure application integrity.
### Authentication & Authorization
- **Firebase Authentication**: User authentication is handled by Firebase Auth, which provides industry-standard security for user credentials
- **JWT Tokens**: Secure token-based authentication for API access
- **Role-Based Access Control**: Different permission levels for users, moderators, and administrators
- **Session Management**: Secure session handling with automatic timeout
### Data Protection
- **Encryption at Rest**: Sensitive data is encrypted in the database
- **Encryption in Transit**: All communications use HTTPS/TLS encryption
- **Environment Variables**: Secrets are managed through secure environment variable configuration
- **Data Minimization**: Only necessary data is collected and stored
### Input Validation
- **Zod Validation**: Strong type checking and validation for all API inputs
- **Sanitization**: Input sanitization to prevent injection attacks
- **Rate Limiting**: Protection against brute force and denial of service attacks
### API Security
- **CORS Configuration**: Restricted cross-origin resource sharing policies
- **Rate Limiting**: Per-endpoint rate limiting to prevent abuse
- **Authentication Middleware**: All protected endpoints require valid authentication
- **Input Validation**: Comprehensive validation of all API inputs
### Database Security
- **Row Level Security**: Fine-grained access control at the database level
- **Parameterized Queries**: Prevention of SQL injection attacks
- **Audit Logging**: Tracking of database access and modifications
- **Regular Backups**: Automated database backups for disaster recovery
### Third-Party Services
- **Firebase Security Rules**: Strict security rules for Firestore and Storage
- **Supabase RLS**: Row-level security policies for PostgreSQL
- **Secrets Management**: Secure storage of API keys and credentials
### Development Practices
- **Code Reviews**: All changes reviewed by multiple developers
- **Automated Testing**: Security-focused tests integrated into CI/CD pipeline
- **Dependency Management**: Regular updates and security scanning of dependencies
- **Security Audits**: Periodic security assessments and penetration testing
## Common Security Issues and Resolutions
### XSS Prevention
- **Content Security Policy**: Strict CSP headers to prevent cross-site scripting
- **Input Sanitization**: All user-generated content is sanitized before display
- **Output Encoding**: Proper encoding of user data in HTML contexts
### CSRF Protection
- **SameSite Cookies**: CSRF protection through SameSite cookie attributes
- **Anti-Forgery Tokens**: Token-based protection for state-changing operations
### Injection Attacks
- **SQL Injection**: Parameterized queries and prepared statements
- **Command Injection**: Input validation and sanitization
- **Script Injection**: Content Security Policy and input filtering
## Incident Response
In the event of a security incident:
1. **Immediate Containment**: Isolate affected systems
2. **Investigation**: Determine scope and impact of breach
3. **Remediation**: Apply fixes and security patches
4. **Notification**: Inform affected users and stakeholders
5. **Review**: Post-incident analysis and process improvement
## Compliance
Compass aims to comply with relevant data protection regulations:
- **GDPR**: General Data Protection Regulation compliance
- **CCPA**: California Consumer Privacy Act compliance
- **Data Retention**: Clear policies for data retention and deletion
## Third-Party Security
We regularly audit third-party services for:
- Security certifications and compliance
- Regular security updates and patches
- Data handling and privacy practices
- Incident response procedures
## Security Contact
For security-related inquiries, contact:
- Email: hello@compassmeet.com
- Response Time: Within 24 hours for critical issues
- Disclosure Policy: Coordinated disclosure with 90-day timeline
---
_Last Updated: March 2026_

View File

@@ -1,12 +1,14 @@
# Compass Android WebView App
This folder contains the source code for the Android application of Compass.
A hybrid mobile app built with **Next.js (TypeScript)** frontend, **Firebase backend**, and wrapped as a **Capacitor WebView** for Android. In the future it may contain native code as well.
A hybrid mobile app built with **Next.js (TypeScript)** frontend, **Firebase backend**, and wrapped as a **Capacitor
WebView** for Android. In the future it may contain native code as well.
This document describes how to:
1. Build and run the web frontend and backend locally
2. Sync and build the Android WebView wrapper
3. Debug, sign, and publish the APK
1. Build and run the web frontend and backend locally
2. Sync and build the Android WebView wrapper
3. Debug, sign, and publish the APK
4. Enable Google Sign-In and push notifications
---
@@ -15,7 +17,8 @@ This document describes how to:
The app is a Capacitor Android project that loads the local Next.js assets inside a WebView.
During development, it can instead load the local frontend (`http://10.0.2.2:3000`) and backend (`http://10.0.2.2:8088`).
During development, it can instead load the local frontend (`http://10.0.2.2:3000`) and backend (
`http://10.0.2.2:8088`).
Firebase handles authentication and push notifications.
Google Sign-In is supported natively in the WebView via the Capacitor Social Login plugin.
@@ -29,29 +32,36 @@ Project Structure
- `AndroidManifest.xml`: The manifest file that describes essential information about the application.
### **Why Local Is the Default**
- **Performance:** Local assets load instantly, without network latency.
- **Reliability:** Works offline or in poor connectivity environments.
- **App Store policy compliance:** Apple and Google generally prefer that the main experience doesnt depend on a remote site (for security, review, and performance reasons).
- **App Store policy compliance:** Apple and Google generally prefer that the main experience doesnt depend on a remote
site (for security, review, and performance reasons).
- **Version consistency:** The web bundle is versioned with the app, ensuring no breaking updates outside your control.
When Remote (No Local Assets) Is sometimes Used
Loading from a **remote URL** (e.g. `https://compassmeet.com`) is **less common**, but seen in a few cases:
- **Internal enterprise apps** where the WebView just wraps an existing web portal.
- **Dynamic content** or **frequent updates** where pushing a new web build every time through app stores would be too slow.
- **Dynamic content** or **frequent updates** where pushing a new web build every time through app stores would be too
slow.
- To leverage the low latency of ISR and SSR.
However, this approach requires:
However, this approach requires:
- Careful handling of **CORS**, **SSL**, and **login/session** persistence.
- Compliance with **Google Play policies** (they may reject apps that are “just a webview of a website” unless theres meaningful native integration).
- Compliance with **Google Play policies** (they may reject apps that are “just a webview of a website” unless theres
meaningful native integration).
**A middle ground we use:**
- The app ships with **local assets** for core functionality.
- The app **fetches remote content or updates** (e.g., via Capacitor Live Updates, Ionic Appflow).
## 2. Prerequisites
### Required Software
| Tool | Version | Purpose |
| -------------- | ------- | ---------------------------------- |
|----------------|---------|------------------------------------|
| Node.js | 22+ | For building frontend/backend |
| yarn | latest | Package manager |
| Java | 21 | Required for Android Gradle plugin |
@@ -60,6 +70,7 @@ However, this approach requires:
| OpenJDK | 21 | JDK for Gradle |
### Environment Setup
```bash
sudo apt install openjdk-21-jdk
sudo update-alternatives --config java
@@ -84,6 +95,7 @@ yarn build-web-view
If you want the webview to load from your local web version of Compass, run the web app.
In root directory:
```bash
export NEXT_PUBLIC_LOCAL_ANDROID=1
yarn dev # or prod
@@ -94,7 +106,8 @@ yarn dev # or prod
### Deployed mode
If you want the webview to load from the deployed web version of Compass (like at www.compassmeet.com), no web app to run.
If you want the webview to load from the deployed web version of Compass (like at compassmeet.com), no web app to
run.
---
@@ -108,6 +121,7 @@ cd android
```
Sync web files and native plugins with Android, for offline fallback. In root:
```
export NEXT_PUBLIC_LOCAL_ANDROID=1 # if running your local web Compass
yarn build-web-view # if you made changes to web app
@@ -116,20 +130,28 @@ npx cap sync android
### Load from site
During local development, open Android Studio project and run the app on an emulator or your physical device.
During local development, open Android Studio project and run the app on an emulator or your physical device.
To use an emulator:
```
npx cap open android
```
To use a physical device for the local web version, you need your mobile and computer to be on the same network / Wi-Fi and point the URL (`LOCAL_BACKEND_DOMAIN` in the code) to your computer IP address (for example, `192.168.1.3:3000`). You also need to set
To use a physical device for the local web version, you need your mobile and computer to be on the same network / Wi-Fi
and point the URL (`LOCAL_BACKEND_DOMAIN` in the code) to your computer IP address (for example, `192.168.1.3:3000`).
You also need to set
```
export NEXT_PUBLIC_WEBVIEW_DEV_PHONE=1
```
Then adb install the app your phone (or simply run it from Android Studio on your phone) and the app should be loading content directly from the local code on your computer. You can make changes in the code and it will refresh instantly on the phone.
Then adb install the app your phone (or simply run it from Android Studio on your phone) and the app should be loading
content directly from the local code on your computer. You can make changes in the code and it will refresh instantly on
the phone.
Building the Application:
1. Open Android Studio.
2. Click on "Open an existing Android Studio project".
3. Navigate to the `android` folder in this repository and select it.
@@ -145,7 +167,8 @@ Building the Application:
### From Android Studio
- If you want to generate a signed APK for release, go to "Build" > "Generate Signed Bundle / APK..." and follow the prompts.
- If you want to generate a signed APK for release, go to "Build" > "Generate Signed Bundle / APK..." and follow the
prompts.
- Make sure to test the application thoroughly on different devices and Android versions to ensure compatibility.
### Debug build
@@ -184,19 +207,26 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
### Release on App Stores
To release on the app stores, you need to submit the .aab files, which are not signed, instead of APK. Google or Apple will then sign it with their own key.
To release on the app stores, you need to submit the .aab files, which are not signed, instead of APK. Google or Apple
will then sign it with their own key.
However, it's recommended to use the GitHub Action for better version control and automation. See section below:
`Deploy to Play Store`.
---
## 9. Debugging
Client logs from the emulator on Chrome can be accessed at:
Client logs from the emulator on Chrome can be accessed at:
```
chrome://inspect/#devices
```
Backend logs can be accessed from the output of `yarn prod / dev` like in the web application.
Java/Kotlin logs can be accessed via Android Studio's Logcat.
Backend logs can be accessed from the output of `yarn prod / dev` like in the web application.
Java/Kotlin logs can be accessed via Android Studio's Logcat.
```
adb logcat | grep CompassApp
adb logcat | grep com.compassconnections.app
@@ -219,22 +249,29 @@ webView.setWebChromeClient(new WebChromeClient() {
## 10. Deploy to Play Store
The best way to deploy to the Play Store is to use the GitHub Action defined
in [cd-android.yml](../.github/workflows/cd-android.yml). You
increase the version in `android/app/build.gradle`, commit to the main branch and it will automatically build the
release APK and upload it to the Play Store.
To deploy manually, follow these steps:
1. Sign the release APK or AAB.
2. Verify package name matches Firebase settings (`com.compassconnections.app`).
3. Upload to Google Play Console.
4. Add Privacy Policy and content rating.
5. Submit for review.
4. Add Privacy Policy and content rating (one time).
5. Submit for review. It takes around an hour for it to be approved and appear in the store.
---
## 11. Common Issues
| Problem | Cause | Fix |
| -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Old APK signed with different key | Uninstall old app first |
| `Account reauth failed [16]` | Missing or incorrect SHA-1 in Firebase | Re-add SHA-1 of keystore |
| App opens in Firefox | Missing `WebViewClient` override | Fix `shouldOverrideUrlLoading` |
| APK > 1 GB | Cached webpack artifacts included | Add `.next/` and `/public/cache` to `.gitignore` and build excludes |
| Problem | Cause | Fix |
|--------------------------------------|----------------------------------------|---------------------------------------------------------------------|
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Old APK signed with different key | Uninstall old app first |
| `Account reauth failed [16]` | Missing or incorrect SHA-1 in Firebase | Re-add SHA-1 of keystore |
| App opens in Firefox | Missing `WebViewClient` override | Fix `shouldOverrideUrlLoading` |
| APK > 1 GB | Cached webpack artifacts included | Add `.next/` and `/public/cache` to `.gitignore` and build excludes |
---
@@ -256,6 +293,8 @@ npx cap sync android
## 14. Deployment Workflow
To deploy manually:
```bash
# Build web app for production and Sync assets to Android
yarn build-sync-android
@@ -263,25 +302,38 @@ yarn build-sync-android
# Build signed release APK in Android Studio
```
But prefer using the GitHub Action, see `Deploy to Play Store`.
---
## Live Updates
To avoid releasing to the app stores after every code update in the web pages, we build the new bundle and store it in Capawesome Cloud (an alternative to Ionic). To add a new update, increment the version number in [capawesome.json](capawesome.json) and push to main (or make a PR to main). A GitHub Action will automatically build the new bundle and push it to Capawesome.
Note: As of early 2026, we don't use the live update anymore because the free plan is too limited for our use case. To
update the android app, we need to stick to the normal release process on the app stores.
To avoid releasing to the app stores after every code update in the web pages, we build the new bundle and store it in
Capawesome Cloud (an alternative to Ionic). To add a new update, increment the version number
in [capawesome.json](capawesome.json) and push to main (or make a PR to main). A GitHub Action will automatically build
the new bundle and push it to Capawesome.
You can also do so locally if you have admin access. First, you need to do this one-time setup:
```
npm install -g @capawesome/cli@latest
npx @capawesome/cli login
```
Then, run this to build your local assets and push them to Capawesome. Once done, each mobile app user will receive a notice that there is a new update available, which they can approve to download.
Then, run this to build your local assets and push them to Capawesome. Once done, each mobile app user will receive a
notice that there is a new update available, which they can approve to download.
```
yarn android:live-update
```
That's all. So you should run the lines above every time you want your web updates pushed to main (which essentially updates the web app) to update the mobile app as well.
There is a limit of 100 monthly active user per month, though. So we may need to pay or create our custom limit as we scale. Next plan is $9 / month and allows 1000 MAUs.
That's all. So you should run the lines above every time you want your web updates pushed to main (which essentially
updates the web app) to update the mobile app as well.
There is a limit of 100 monthly active user per month, though. So we may need to pay or create our custom limit as we
scale. Next plan is $9 / month and allows 1000 MAUs.
- ∞ Live Updates
- 100 Monthly Active Users
@@ -297,7 +349,6 @@ There is a limit of 100 monthly active user per month, though. So we may need to
* [FCM HTTP API](https://firebase.google.com/docs/cloud-messaging/send-message)
* [Next.js Deployment](https://nextjs.org/docs/deployment)
# Useful Commands
- To build the project: `./gradlew assembleDebug`
@@ -318,6 +369,7 @@ There is a limit of 100 monthly active user per month, though. So we may need to
# One time setups
Was already done for Compass, so you only need to do the steps below if you create a project separated from Compass.
## Configure Firebase
### In Firebase Console
@@ -345,7 +397,6 @@ keytool -list -v \
Add both SHA-1 and SHA-256 to Firebase.
## 7. Google Sign-In (Web + Native)
In Firebase Console:
@@ -357,10 +408,9 @@ In Firebase Console:
In your code:
```ts
import { googleNativeLogin } from 'web/lib/service/android-push'
import {googleNativeLogin} from 'web/lib/service/android-push'
```
## 8. Push Notifications (FCM)
### Setup FCM
@@ -381,17 +431,17 @@ import { googleNativeLogin } from 'web/lib/service/android-push'
### Test notification
```ts
const message = {
notification: {
title: "Test Notification",
body: "Hello from Firebase Admin SDK"
},
token: "..."
};
initAdmin()
await admin.messaging().send(message)
.then(response => console.log("Successfully sent message:", response))
.catch(error => console.error("Error sending message:", error));
const message = {
notification: {
title: "Test Notification",
body: "Hello from Firebase Admin SDK"
},
token: "..."
};
initAdmin()
await admin.messaging().send(message)
.then(response => console.log("Successfully sent message:", response))
.catch(error => console.error("Error sending message:", error));
```
---
@@ -405,7 +455,77 @@ Example:
com.compassconnections.app://auth
```
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data. It's useful to open links in the app instead of the browser. For example, if there's a link to Compass on Discord and we click on it on a mobile device that has the app, we want the link to open in the app instead of the browser.
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data. It's
useful to open links in the app instead of the browser. For example, if there's a link to Compass on Discord and we
click on it on a mobile device that has the app, we want the link to open in the app instead of the browser.
You register this scheme in your `AndroidManifest.xml` so Android knows which app handles it.
## Automatic Workflow for App Release
Below is a **minimal, production-ready GitHub Actions setup** that:
* Builds on push to `main`
* Checks if `versionCode` increased
* Only builds if it did
* Signs the AAB
* Uploads to Google Play (internal track)
#### A. Create Play Console API access
1. Go to google cloud console. Create service account without selecting any specific permission or roles. Just copy
paste the email address and generate a JSON key.
2. Go to **Google Play Console**
3. Invite user, enter the service account email address.
5. Give it:
* Release Manager role
You will store the JSON key in GitHub Secrets.
---
#### B. Prepare Keystore
If you already sign locally, you have a `.jks` or `.keystore` file.
Base64 encode it:
```bash
base64 my-release-key.keystore
```
Copy the output.
---
#### C. GitHub Secrets
In your GitHub repo:
Settings → Secrets and variables → Actions → New repository secret
Add:
```
ANDROID_KEYSTORE_BASE64
ANDROID_KEYSTORE_PASSWORD
ANDROID_KEY_PASSWORD
PLAY_SERVICE_ACCOUNT_JSON
```
For the JSON:
* Paste full raw JSON (not base64)
#### GitHub Actions YAML
We compare:
* `versionCode` in current commit
* `versionCode` in previous commit
If not increased → skip build.
We extract from `app/build.gradle` using grep.
See `.github/workflows/android-release.yml` for all details.

View File

@@ -1,15 +1,18 @@
apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services'
android {
namespace "com.compassconnections.app"
compileSdk 36
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
defaultConfig {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 18
versionName "1.1.6"
versionCode 79
versionName "1.16.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -17,10 +20,17 @@ android {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "boolean", "ENABLE_WEBVIEW_DEBUG", "false"
}
debug {
buildConfigField "boolean", "ENABLE_WEBVIEW_DEBUG", "true"
}
}
}
@@ -50,8 +60,11 @@ dependencies {
// Add the dependencies for any other desired Firebase products
// https://firebase.google.com/docs/android/setup#available-libraries
implementation 'com.google.android.gms:play-services-auth:21.4.0'
implementation 'com.google.android.gms:play-services-auth:21.5.1'
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

@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}

View File

@@ -8,8 +8,8 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:fitsSystemWindows="true"
android:requestLegacyExternalStorage="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
@@ -17,19 +17,28 @@
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- <intent-filter>-->
<!-- <action android:name="openapp" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="compassmeet.com" />
<data android:scheme="https" />
<data android:host="www.compassmeet.com" />
</intent-filter>
<!-- <data android:scheme="com.compassconnections.app" android:host="details"/>-->
<!-- </intent-filter>-->
<!-- <intent-filter>-->
<!-- <action android:name="openapp" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
<!-- <data android:scheme="com.compassconnections.app" android:host="details"/>-->
<!-- </intent-filter>-->
<!-- <intent-filter android:autoVerify="true">-->
<!-- <action android:name="android.intent.action.VIEW" />-->
@@ -39,16 +48,16 @@
<!-- </intent-filter>-->
</activity>
<!-- <service-->
<!-- android:name=".MyMessagingService"-->
<!-- android:exported="false">-->
<!-- <intent-filter>-->
<!-- <action android:name="com.google.firebase.MESSAGING_EVENT" />-->
<!-- </intent-filter>-->
<!--&lt;!&ndash; <meta-data&ndash;&gt;-->
<!--&lt;!&ndash; android:name="com.google.firebase.messaging.default_notification_channel_id"&ndash;&gt;-->
<!--&lt;!&ndash; android:value="@string/default_notification_channel_id" />&ndash;&gt;-->
<!-- </service>-->
<!-- <service-->
<!-- android:name=".MyMessagingService"-->
<!-- android:exported="false">-->
<!-- <intent-filter>-->
<!-- <action android:name="com.google.firebase.MESSAGING_EVENT" />-->
<!-- </intent-filter>-->
<!--&lt;!&ndash; <meta-data&ndash;&gt;-->
<!--&lt;!&ndash; android:name="com.google.firebase.messaging.default_notification_channel_id"&ndash;&gt;-->
<!--&lt;!&ndash; android:value="@string/default_notification_channel_id" />&ndash;&gt;-->
<!-- </service>-->
<provider
android:name="androidx.core.content.FileProvider"
@@ -65,6 +74,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,56 +28,31 @@ 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.net.URL;
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 {
private String pendingDeepLink = null;
// Declare this at class level
private final ActivityResultLauncher<String> requestPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
@@ -91,31 +75,136 @@ 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 String getPendingDeepLink() {
String link = pendingDeepLink;
pendingDeepLink = null; // consume it
return link;
}
@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);
// String data = intent.getDataString();
String endpoint = intent.getStringExtra("endpoint");
Log.i("CompassApp", "onNewIntent called with endpoint: " + endpoint);
if (endpoint != null) {
Log.i("CompassApp", "redirecting to endpoint: " + endpoint);
try {
String payload = new JSONObject().put("endpoint", endpoint).toString();
Log.i("CompassApp", "Payload: " + payload);
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("bridgeRedirect(" + payload + ");", null));
Log.i("CompassApp", "Handling notif click: " + payload);
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("handleAppLink(" + payload + ");", null));
} catch (JSONException e) {
Log.i("CompassApp", "Failed to encode JSON payload", e);
}
} else {
Log.i("CompassApp", "No relevant data");
Uri data = intent.getData();
if (data != null) {
handleDeepLink(data.toString());
} else {
Log.i("CompassApp", "No relevant data");
}
}
}
@@ -127,14 +216,15 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
WebView webView = this.bridge.getWebView();
webView.setWebViewClient(new BridgeWebViewClient(this.bridge));
WebView.setWebContentsDebuggingEnabled(true);
if (BuildConfig.ENABLE_WEBVIEW_DEBUG) {
WebView.setWebContentsDebuggingEnabled(true);
}
// Set a recognizable User-Agent (always reliable)
WebSettings settings = webView.getSettings();
settings.setUserAgentString(settings.getUserAgentString() + " CompassAppWebView");
settings.setJavaScriptEnabled(true);
webView.addJavascriptInterface(new NativeBridge(), "AndroidBridge");
webView.addJavascriptInterface(new WebAppInterface(this), "AndroidBridge");
registerPlugin(PushNotificationsPlugin.class);
// Initialize the Bridge with Push Notifications plugin
@@ -143,6 +233,23 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
// }});
askNotificationPermission();
appUpdateManager = AppUpdateManagerFactory.create(this);
checkForUpdates();
Uri data = getIntent().getData();
if (data != null) pendingDeepLink = data.toString();
}
private void handleDeepLink(String url) {
try {
String path = new URL(url).getPath();
String payload = new JSONObject().put("url", url).put("endpoint", path).toString();
Log.i("CompassApp", "Handling deep link: " + url);
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("handleAppLink(" + payload + ");", null));
} catch (Exception e) {
Log.e("CompassApp", "Failed to handle deep link for " + url, e);
}
}
@Override
@@ -169,5 +276,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
}

BIN
assets/icon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

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

@@ -14,6 +14,9 @@ RUN yarn install --frozen-lockfile --production && \
yarn cache clean --force && \
rm -rf /usr/local/share/.cache/yarn
# Show installed packages
RUN npm list || true
# Copy over typescript payload
COPY dist ./

View File

@@ -1,11 +1,67 @@
# Backend API
This is the code for the API running at https://api.compassmeet.com.
It runs in a docker inside a Google Cloud virtual machine.
Express.js REST API for Compass, running at https://api.compassmeet.com.
### Requirements
## Overview
You must have the `gcloud` CLI.
The API handles:
- User authentication and management
- Profile CRUD operations
- Search and filtering
- Messaging
- Notifications
- Compatibility scoring
- Events management
- WebSocket connections for real-time features
## Tech Stack
- **Runtime**: Node.js 20+
- **Framework**: Express.js 5.0
- **Language**: TypeScript
- **Database**: PostgreSQL (via Supabase)
- **ORM**: pg-promise
- **Validation**: Zod
- **WebSocket**: ws library
- **API Docs**: Swagger/OpenAPI
## Project Structure
```
backend/api/
├── src/
│ ├── app.ts # Express app setup
│ ├── routes.ts # Route definitions
│ ├── test.ts # Test utilities
│ ├── get-*.ts # GET endpoints
│ ├── create-*.ts # POST endpoints
│ ├── update-*.ts # PUT/PATCH endpoints
│ ├── delete-*.ts # DELETE endpoints
│ └── helpers/ # Shared utilities
├── tests/
│ └── unit/ # Unit tests
├── package.json
├── tsconfig.json
└── README.md
```
## Getting Started
### Prerequisites
- Node.js 20.x or later
- Yarn
- Access to Supabase project (for database)
### Installation
```bash
# From root directory
yarn install
```
You must also have the `gcloud` CLI.
On macOS:
@@ -28,11 +84,362 @@ 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).
### Running Locally
```bash
# Run all services (web + API)
yarn dev
# Run API only (from backend/api)
cd backend/api
yarn serve
```
The API runs on http://localhost:8088 when running locally with the full stack.
### Testing
```bash
# Run unit tests
yarn test
# Run with coverage
yarn test --coverage
```
### Linting
```bash
# Check lint
yarn lint
# Fix issues
yarn lint-fix
```
## API Endpoints
### Authentication
| Method | Endpoint | Description |
| ------ | -------------- | --------------- |
| POST | `/create-user` | Create new user |
### Users
| Method | Endpoint | Description |
| ------ | ------------ | ------------------- |
| GET | `/get-me` | Get current user |
| PUT | `/update-me` | Update current user |
| DELETE | `/delete-me` | Delete account |
### Profiles
| Method | Endpoint | Description |
| ------ | ----------------- | ------------------ |
| GET | `/get-profiles` | List profiles |
| GET | `/get-profile` | Get single profile |
| POST | `/create-profile` | Create profile |
| PUT | `/update-profile` | Update profile |
| DELETE | `/delete-profile` | Delete profile |
### Messaging
| Method | Endpoint | Description |
| ------ | ------------------------------ | -------------- |
| GET | `/get-private-messages` | Get messages |
| POST | `/create-private-user-message` | Send message |
| PUT | `/edit-message` | Edit message |
| DELETE | `/delete-message` | Delete message |
### Notifications
| Method | Endpoint | Description |
| ------ | ----------------------- | ------------------ |
| GET | `/get-notifications` | List notifications |
| PUT | `/update-notif-setting` | Update settings |
### Search
| Method | Endpoint | Description |
| ------ | ------------------ | ------------------ |
| GET | `/search-users` | Search users |
| GET | `/search-location` | Search by location |
### Compatibility
| Method | Endpoint | Description |
| ------ | ------------------------------ | ----------------------- |
| GET | `/get-compatibility-questions` | List questions |
| POST | `/set-compatibility-answers` | Submit answers |
| GET | `/compatible-profiles` | Get compatible profiles |
## Writing Endpoints
### 1. Define Schema
Add endpoint definition in `common/src/api/schema.ts`:
```typescript
const endpoints = {
myEndpoint: {
method: 'POST',
authed: true,
returns: z.object({
success: z.boolean(),
data: z.any(),
}),
props: z
.object({
userId: z.string(),
option: z.string().optional(),
})
.strict(),
},
}
```
### 2. Implement Handler
Create handler file in `backend/api/src/`:
```typescript
import {z} from 'zod'
import {APIHandler} from './helpers/endpoint'
export const myEndpoint: APIHandler<'myEndpoint'> = async (props, auth) => {
const {userId, option} = props
// Implementation
return {
success: true,
data: {userId},
}
}
```
### 3. Register Route
Add to `routes.ts`:
```typescript
import {myEndpoint} from './my-endpoint'
const handlers = {
myEndpoint,
// ...
}
```
## Authentication
### Authenticated Endpoints
Use the `authed: true` schema property. The auth object is passed to the handler:
```typescript
export const getProfile: APIHandler<'get-profile'> = async (props, auth) => {
// auth.uid - user ID
// auth.creds - credentials type
}
```
### Auth Types
- `firebase` - Firebase Auth token
- `session` - Session-based auth
## Database Access
### Using pg-promise
```typescript
import {createSupabaseDirectClient} from 'shared/supabase/init'
const pg = createSupabaseDirectClient()
const result = await pg.oneOrNone<User>('SELECT * FROM users WHERE id = $1', [userId])
```
### Using Supabase Client
But this works only in the front-end.
```typescript
import {db} from 'web/lib/supabase/db'
const {data, error} = await db.from('profiles').select('*').eq('user_id', userId)
```
## Rate Limiting
The API includes built-in rate limiting:
```typescript
export const myEndpoint: APIHandler<'myEndpoint'> = withRateLimit(
async (props, auth) => {
// Handler implementation
},
{
name: 'my-endpoint',
limit: 100,
windowMs: 60 * 1000, // 1 minute
},
)
```
## Error Handling
Use `APIError` for consistent error responses:
```typescript
import {APIError} from './helpers/endpoint'
throw APIError(404, 'User not found')
throw APIError(400, 'Invalid input', {field: 'email'})
```
Error codes:
- `400` - Bad Request
- `401` - Unauthorized
- `403` - Forbidden
- `404` - Not Found
- `429` - Too Many Requests
- `500` - Internal Server Error
## WebSocket
WebSocket connections are handled for real-time features:
```typescript
// Subscribe to updates
ws.subscribe('user/123', (data) => {
console.log('User updated:', data)
})
// Unsubscribe
ws.unsubscribe('user/123', callback)
```
Available topics:
- `user/{userId}` - User updates
- `private-user/{userId}` - Private user updates
- `message/{channelId}` - New messages
## Logging
Use the shared logger:
```typescript
import {log} from 'shared/monitoring/log'
log.info('Processing request', {userId: auth.uid})
log.error('Failed to process', error)
```
## Deployment
### Production Deployment
Deployments are automated via GitHub Actions. Push to main triggers deployment:
```bash
# Increment version
# Update package.json version
git add package.json
git commit -m "chore: bump version"
git push origin main
```
### Manual Deployment
```bash
cd backend/api
./deploy-api.sh prod
```
### Server Access
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs,
files, debug, etc.
```bash
# SSH into production server
cd backend/api
./ssh-api.sh prod
```
Useful commands on server:
```bash
sudo journalctl -u konlet-startup --no-pager -ef # View logs
sudo docker logs -f $(sudo docker ps -alq) # Container logs
docker exec -it $(sudo docker ps -alq) sh # Shell access
docker run -it --rm $(docker images -q | head -n 1) sh
docker rmi -f $(docker images -aq)
```
## Environment Variables
Required secrets (set in Google Cloud Secrets Manager):
| Variable | Description |
| ---------------------- | ---------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `FIREBASE_PROJECT_ID` | Firebase project ID |
| `FIREBASE_PRIVATE_KEY` | Firebase private key |
| `SUPABASE_SERVICE_KEY` | Supabase service role key |
| `JWT_SECRET` | JWT signing secret |
## Testing
### Writing Unit Tests
```typescript
// tests/unit/my-endpoint.unit.test.ts
import {myEndpoint} from '../my-endpoint'
describe('myEndpoint', () => {
it('should return success', async () => {
const result = await myEndpoint({userId: '123'}, mockAuth)
expect(result.success).toBe(true)
})
})
```
### Mocking Database
```typescript
const mockPg = {
oneOrNone: jest.fn().mockResolvedValue({id: '123'}),
}
```
## API Documentation
Full API docs available at:
- Production: https://api.compassmeet.com
- Local: http://localhost:8088 (when running)
Docs are generated from route definitions in `app.ts`.
## See Also
- [Main README](../../README.md)
- [Contributing Guide](../../CONTRIBUTING.md)
- [Shared Backend Utils](../shared/README.md)
- [Database Migrations](../../supabase)
### Setup
This section is only for the people who are creating a server from scratch, for instance for a forked project.
@@ -105,8 +512,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 +527,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 +542,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:
@@ -153,46 +560,3 @@ in [Google Cloud Secrets manager](https://console.cloud.google.com/security/secr
can access them.
For Compass, the name of the secrets are in [secrets.ts](../../common/src/secrets.ts).
### Run Locally
In root directory, run the local api with hot reload, along with all the other backend and web code.
```bash
./run_local.sh prod
```
### Deploy
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
```
### Connect to the server
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs,
files, debug, etc.
```bash
./ssh-api.sh prod
```
Useful commands once inside the server:
```bash
sudo journalctl -u konlet-startup --no-pager -efb
sudo docker logs -f $(sudo docker ps -alq)
docker exec -it $(sudo docker ps -alq) sh
docker run -it --rm $(docker images -q | head -n 1) sh
docker rmi -f $(docker images -aq)
```
### Documentation
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

View File

@@ -49,7 +49,7 @@ echo "🚀 Deploying ${SERVICE_NAME} to ${ENV} ($(date "+%Y-%m-%d %I:%M:%S %p"))
yarn build
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin us-west1-docker.pkg.dev
docker build . --tag ${IMAGE_URL} --platform linux/amd64
docker build . --tag ${IMAGE_URL} --platform linux/amd64 --progress=plain
echo "docker push ${IMAGE_URL}"
docker push ${IMAGE_URL}

31
backend/api/dist_copy.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
rsync -a --delete ../../common/lib/ dist/common/lib
rsync -a --delete ../../common/messages/ dist/common/messages/
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 package.json dist/backend/api
cp metadata.json dist
cp metadata.json dist/backend/api
cp ../../yarn.lock dist
# Installing from backend/api/package.json is not enough
# Need to install the deps from all the workspaces used in the back end
node -e "
const fs = require('fs');
const deps = ['../api', '../shared', '../email', '../../common']
.map(p => require('./' + p + '/package.json').dependencies || {})
.reduce((acc, d) => ({ ...acc, ...d }), {});
const pkg = require('./package.json');
pkg.dependencies = { ...deps, ...pkg.dependencies };
fs.writeFileSync('./dist/package.json', JSON.stringify(pkg, null, 2));
"

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

@@ -0,0 +1,76 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import lodash from 'eslint-plugin-lodash'
import unusedImports from 'eslint-plugin-unused-imports'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
lodash,
'unused-imports': unusedImports,
'simple-import-sort': simpleImportSort,
},
languageOptions: {
parserOptions: {
project: ['./tsconfig.json', './tsconfig.test.json', 'tsconfig.eslint.json'],
tsconfigRootDir: import.meta.dirname,
},
globals: {
process: 'readonly',
console: 'readonly',
module: 'readonly',
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
Buffer: 'readonly',
global: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
clearImmediate: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
fetch: 'readonly',
WebSocket: 'readonly',
},
},
rules: {
'@typescript-eslint/no-empty-object-type': 'error',
'@typescript-eslint/no-unsafe-function-type': 'error',
'@typescript-eslint/no-wrapper-object-types': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-extra-semi': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'unused-imports/no-unused-imports': 'warn',
'no-constant-condition': 'off',
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
'lodash/import-scope': [2, 'member'],
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
},
},
{
ignores: [
'dist',
'lib',
'coverage',
'eslint.config.mjs',
'jest.config.ts',
'ecosystem.config.js',
],
},
eslintConfigPrettier,
)

View File

@@ -1,31 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
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,
globals: {
'ts-jest': {
tsconfig: "<rootDir>/tsconfig.test.json"
}
},
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts"
],
};

View File

@@ -0,0 +1,29 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
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',
},
],
},
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
silent: true,
}

View File

@@ -11,8 +11,8 @@ variable "env" {
}
locals {
project = "compass-130ba"
region = "us-west1"
project = "compass-130ba"
region = "us-west1"
zone = "us-west1-b"
service_name = "api"
machine_type = "e2-small"
@@ -55,7 +55,7 @@ resource "google_storage_bucket" "public_storage" {
# static IPs
resource "google_compute_global_address" "api_lb_ip" {
name = "api-lb-ip-2"
name = "api-lb-ip-2"
address_type = "EXTERNAL"
}
@@ -81,7 +81,7 @@ resource "google_compute_instance_template" "api_template" {
}
network_interface {
network = "default"
network = "default"
subnetwork = "default"
access_config {
network_tier = "PREMIUM"
@@ -105,6 +105,7 @@ spec:
ports:
- containerPort: 80
EOF
google-logging-enabled = "true"
}
lifecycle {
@@ -116,12 +117,12 @@ EOF
resource "google_compute_region_instance_group_manager" "api_group" {
name = "${local.service_name}-group"
base_instance_name = "${local.service_name}-group"
region = local.region
region = local.region
target_size = 1
version {
instance_template = google_compute_instance_template.api_template.id
name = "primary"
name = "primary"
}
update_policy {
@@ -185,29 +186,29 @@ resource "google_compute_url_map" "api_url_map" {
path_matcher {
name = "allpaths"
default_service = google_compute_backend_service.api_backend.self_link
#
# # Priority 0: passthrough /v0/* requests
# route_rules {
# priority = 1
# match_rules {
# prefix_match = "/v0"
# }
# service = google_compute_backend_service.api_backend.self_link
# }
#
# # Priority 1: rewrite everything else to /v0
# route_rules {
# priority = 2
# match_rules {
# prefix_match = "/"
# }
# route_action {
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
# path_prefix_rewrite = "/v0/"
# }
# }
# service = google_compute_backend_service.api_backend.self_link
# }
#
# # Priority 0: passthrough /v0/* requests
# route_rules {
# priority = 1
# match_rules {
# prefix_match = "/v0"
# }
# service = google_compute_backend_service.api_backend.self_link
# }
#
# # Priority 1: rewrite everything else to /v0
# route_rules {
# priority = 2
# match_rules {
# prefix_match = "/"
# }
# route_action {
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
# path_prefix_rewrite = "/v0/"
# }
# }
# service = google_compute_backend_service.api_backend.self_link
# }
}
}
@@ -267,10 +268,10 @@ resource "google_compute_firewall" "allow_health_check" {
}
resource "google_compute_firewall" "default_allow_https" {
name = "default-allow-http"
network = "default"
priority = 1000
direction = "INGRESS"
name = "default-allow-http"
network = "default"
priority = 1000
direction = "INGRESS"
allow {
protocol = "tcp"

View File

@@ -1,8 +1,8 @@
{
"git": {
"revision": "704bcb4",
"commitDate": "2025-12-15 13:38:09 +0200",
"revision": "c085e8f",
"commitDate": "2026-02-22 21:51:08 +0100",
"author": "MartinBraquet",
"message": "Increase API docs font size on mobile"
"message": "Add guidelines for adding translations to existing files"
}
}

View File

@@ -1,79 +1,66 @@
{
"name": "@compass/api",
"description": "Backend API endpoints",
"version": "1.0.14",
"version": "1.30.3",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",
"scripts": {
"watch:serve": "tsx watch src/serve.ts",
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
"dev": "yarn watch:serve",
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
"build:fast": "yarn compile && yarn dist:copy",
"clean": "rm -rf lib && (cd ../../common && rm -rf lib) && (cd ../shared && rm -rf lib) && (cd ../email && rm -rf lib)",
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias) && cp -r src/public/ lib/",
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
"dev": "yarn watch:serve",
"dist": "yarn dist:clean && yarn dist:copy",
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api && cp metadata.json dist && cp metadata.json dist/backend/api",
"watch": "tsc -w",
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
"dist:copy": "./dist_copy.sh",
"lint": "npx eslint . --max-warnings 0",
"lint-fix": "npx eslint . --fix",
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
"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",
"test:coverage": "jest --config jest.config.js --coverage"
"test": "jest --config jest.config.ts",
"test:coverage": "jest --config jest.config.ts --coverage",
"typecheck": "yarn build && npx tsc --noEmit",
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
"watch:serve": "tsx watch src/serve.ts"
},
"engines": {
"node": ">=20.0.0"
},
"main": "src/serve.ts",
"dependencies": {
"@google-cloud/monitoring": "4.0.0",
"@google-cloud/secret-manager": "4.2.1",
"@react-email/components": "0.0.33",
"@supabase/supabase-js": "2.38.5",
"@tiptap/core": "2.3.2",
"@tiptap/extension-blockquote": "2.3.2",
"@tiptap/extension-bold": "2.3.2",
"@tiptap/extension-bubble-menu": "2.3.2",
"@tiptap/extension-floating-menu": "2.3.2",
"@tiptap/extension-image": "2.3.2",
"@tiptap/extension-link": "2.3.2",
"@tiptap/extension-mention": "2.3.2",
"@tiptap/html": "2.3.2",
"@tiptap/pm": "2.3.2",
"@tiptap/starter-kit": "2.3.2",
"@tiptap/suggestion": "2.3.2",
"colors": "1.4.0",
"@mozilla/readability": "0.6.0",
"@sentry/node": "10.41.0",
"@tiptap/core": "2.10.4",
"cors": "2.8.5",
"dayjs": "1.11.4",
"dayjs": "1.11.19",
"express": "5.0.0",
"firebase-admin": "13.5.0",
"gcp-metadata": "6.1.0",
"jsdom": "29.0.1",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.21",
"lodash": "4.17.23",
"marked": "17.0.5",
"openapi-types": "12.1.3",
"pg-promise": "11.4.1",
"pg-promise": "12.6.1",
"posthog-node": "4.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"resend": "4.1.2",
"string-similarity": "4.0.4",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"tsconfig-paths": "4.2.0",
"twitter-api-v2": "1.15.0",
"web-push": "3.6.7",
"ws": "8.17.1",
"zod": "3.22.3"
"zod": "^3.25"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@types/jsdom": "28.0.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/lodash": "^4.17.0",
"@types/swagger-ui-express": "4.1.8",
"@types/web-push": "3.6.4",
"@types/ws": "8.5.10"
},
"engines": {
"node": ">=20.9.0"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@
// const tokens = await tokenRes.json();
// if (tokens.error) {
// console.error('Google token error:', tokens);
// throw new APIError(400, 'Google token error: ' + JSON.stringify(tokens))
// throw APIErrors.badRequest('Google token error: ' + JSON.stringify(tokens))
// }
// console.log('Google Tokens:', tokens);
//

View File

@@ -1,21 +1,17 @@
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 {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {isAdminId} from 'common/envs/constants'
import {trackPublicEvent} from 'shared/analytics'
import {throwErrorIfNotMod} from 'shared/helpers/auth'
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 db = createSupabaseDirectClient()
const {userId, unban} = body
await throwErrorIfNotMod(auth.uid)
if (isAdminId(userId)) throw new APIError(403, 'Cannot ban admin')
if (isAdminId(userId)) throw APIErrors.forbidden('Cannot ban admin')
await trackPublicEvent(auth.uid, 'ban user', {
userId,
})
await updateUser(db, userId, {
isBannedFromPosting: !unban,
})
await updateUser(userId, {isBannedFromPosting: !unban})
log('updated user')
}

View File

@@ -1,13 +1,11 @@
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
) => {
if (auth.uid === id) throw new APIError(400, 'You cannot block yourself')
import {APIErrors, APIHandler} from './helpers/endpoint'
export const blockUser: APIHandler<'user/by-id/:id/block'> = async ({id}, auth) => {
if (auth.uid === id) throw APIErrors.badRequest('You cannot block yourself')
const pg = createSupabaseDirectClient()
await pg.tx(async (tx) => {
@@ -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 {APIErrors, 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 APIErrors.notFound('Event not found')
}
if (event.creator_id !== auth.uid) {
throw APIErrors.forbidden('Only the event creator can cancel this event')
}
if (event.status === 'cancelled') {
throw APIErrors.badRequest('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 APIErrors.internalServerError('Failed to cancel event: ' + error.message)
}
return {success: true}
}

View File

@@ -0,0 +1,38 @@
import {APIErrors, 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 APIErrors.notFound('RSVP not found')
}
// Delete the RSVP
const {error} = await tryCatch(
pg.none(
`DELETE
FROM events_participants
WHERE id = $1`,
[rsvp.id],
),
)
if (error) {
throw APIErrors.internalServerError('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,27 +1,25 @@
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 {APIErrors, 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')
if (error) throw APIErrors.internalServerError('Failed to submit contact message')
const continuation = async () => {
try {

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,35 +1,28 @@
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 {APIErrors, 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} 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')
if (!onUser) throw APIErrors.notFound('User not found')
const pg = createSupabaseDirectClient()
const comment = await pg.one<Row<'profile_comments'>>(
@@ -43,7 +36,7 @@ export const createComment: APIHandler<'create-comment'> = async (
userId,
content,
replyToCommentId,
]
],
)
if (onUser.id !== creator.id)
await createNewCommentOnProfileNotification(
@@ -51,37 +44,31 @@ export const createComment: APIHandler<'create-comment'> = async (
creator,
richTextToString(content),
comment.id,
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')
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
if (!creator) throw APIErrors.unauthorized('Your account was not found')
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
const otherUser = await getPrivateUser(userId)
if (!otherUser) throw new APIError(404, 'Other user not found')
if (!otherUser) throw APIErrors.notFound('Other user not found')
if (otherUser.blockedUserIds.includes(creatorId)) {
throw new APIError(404, 'User has blocked you')
throw APIErrors.notFound('User has blocked you')
}
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.`
throw APIErrors.badRequest(
`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 +76,15 @@ const createNewCommentOnProfileNotification = async (
creator: User,
sourceText: string,
commentId: number,
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,
@@ -113,7 +101,7 @@ const createNewCommentOnProfileNotification = async (
sourceSlug: onUser.username,
}
if (sendToBrowser) {
await insertNotificationToSupabase(notification, pg)
await insertNotificationToSupabase(notification)
}
if (sendToMobile) {
// await createPushNotification(

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 {APIErrors, 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')
if (!creator) throw APIErrors.unauthorized('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')
if (error) throw APIErrors.internalServerError('Error creating question')
return { question: data }
return {question: data}
}

View File

@@ -0,0 +1,67 @@
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {sendDiscordMessage} from 'common/discord/core'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
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 APIErrors.badRequest('In-person events require a location address')
}
if (body.locationType === 'online' && !body.locationUrl) {
throw APIErrors.badRequest('Online events require a location URL')
}
// Validate dates
const startTime = new Date(body.eventStartTime)
if (startTime < new Date()) {
throw APIErrors.badRequest('Event start time must be in the future')
}
if (body.eventEndTime) {
const endTime = new Date(body.eventEndTime)
if (endTime <= startTime) {
throw APIErrors.badRequest('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,
}), // consider using convertObjectToSQLRow() to convert snake case to camel case
)
if (error) {
throw APIErrors.internalServerError('Failed to create event: ' + error.message)
}
const continuation = async () => {
try {
const user = await pg.oneOrNone(`select name from users where id = $1 `, [auth.uid])
const message: string = `${user.name} created a new [event](${DEPLOYED_WEB_URL}/events)!\n**${body.title}**\n${body.description}\nStart: ${body.eventStartTime.replace('T', ' @ ').replace('.000Z', ' UTC')}`
await sendDiscordMessage(message, 'general')
} catch (e) {
console.error('Failed to send discord event', e)
}
}
return {
result: {
success: true,
event: data,
},
continue: continuation,
}
}

View File

@@ -1,12 +1,19 @@
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,
NotificationTemplateTranslation,
} from 'shared/supabase/notifications'
const COMPASS_LOGO_URL =
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185'
export const createAndroidReleaseNotifications = async () => {
const createdTime = Date.now();
const createdTime = Date.now()
const id = `android-release-${createdTime}`
const notification: Notification = {
id,
@@ -16,15 +23,16 @@ 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: COMPASS_LOGO_URL,
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 +42,16 @@ 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: COMPASS_LOGO_URL,
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 +61,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 +70,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 +80,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 +115,95 @@ 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 () => {
// 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: COMPASS_LOGO_URL,
sourceUpdateType: 'created',
})
console.log(`Created events notification template ${templateId} for ${count} users`)
return {
success: true,
templateId,
userCount: count,
}
}
export const createSomeNotifications = async () => {
const translations: Omit<NotificationTemplateTranslation, 'template_id' | 'created_time'>[] = [
// French translation
{
locale: 'fr',
title: 'Bonjour',
source_text: "C'est une notif",
},
// German translation
{
locale: 'de',
title: 'Halo',
source_text: 'Dis das',
},
]
// Create template with translations
const {templateId, count} = await createBulkNotification(
{
sourceType: 'hello',
title: 'Hello world',
sourceText: 'This is a notification',
sourceSlug: '/settings',
sourceUserAvatarUrl: COMPASS_LOGO_URL,
sourceUpdateType: 'created',
},
translations,
)
console.log(`Created some notification template ${templateId} for ${count} users`)
}
export const createInterestIndicatorNotifications = async () => {
const translations: Omit<NotificationTemplateTranslation, 'template_id' | 'created_time'>[] = [
// French translation
{
locale: 'fr',
title: 'Nouveau : Signaux dintérêt privés',
source_text:
'Vous pouvez désormais exprimer votre intérêt en privé à la fin de chaque profil. Lautre personne nest informée que si lintérêt est réciproque.',
},
]
// Create template with translations
const {templateId, count} = await createBulkNotification(
{
sourceType: 'info',
title: 'New: Private interest signals',
sourceText:
'You can now express interest privately at the end of each profile. The other person is only notified if its mutual.',
sourceSlug: '/',
sourceUserAvatarUrl: COMPASS_LOGO_URL,
sourceUpdateType: 'created',
},
translations,
)
console.log(`Created some notification template ${templateId} for ${count} users`)
}

View File

@@ -1,52 +1,71 @@
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 {getConnectionInterests} from 'api/get-connection-interests'
import {APIErrors, 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 {getProfile} from 'shared/profiles/supabase'
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 APIErrors.forbidden('You must verify your email to contact people.')
}
const userIds = uniq(body.userIds.concat(auth.uid))
const pg = createSupabaseDirectClient()
const creatorId = auth.uid
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)))
)
if (!creator) throw APIErrors.unauthorized('Your account was not found')
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
const toPrivateUsers = filterDefined(await Promise.all(userIds.map((id) => getPrivateUser(id))))
if (toPrivateUsers.length !== userIds.length)
throw new APIError(
404,
throw APIErrors.notFound(
`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 APIErrors.forbidden('One of the users has blocked another user in the list')
}
for (const u of toPrivateUsers) {
const p = await getProfile(u.id)
if (p && !p.allow_direct_messaging) {
const {interests, targetInterests} = await getConnectionInterests(
{targetUserId: u.id},
auth.uid,
)
const matches = interests.filter((interest: string[]) => targetInterests.includes(interest))
if (matches.length > 0) continue
const failedUser = await getUser(u.id)
throw APIErrors.forbidden(`${failedUser?.username} has disabled direct messaging`)
}
}
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 +74,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,30 +1,22 @@
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 {APIErrors, 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 APIErrors.badRequest(`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`)
}
const creator = await getUser(auth.uid)
if (!creator) throw new APIError(401, 'Your account was not found')
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
if (!creator) throw APIErrors.unauthorized('Your account was not found')
if (creator.isBannedFromPosting) throw APIErrors.forbidden('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,44 +1,44 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { log, getUser } from 'shared/utils'
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {sendDiscordMessage} from 'common/discord/core'
import {debug} from 'common/logger'
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 {updateUserData} 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: existingProfile} = 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')
if (existingProfile) {
throw APIErrors.badRequest('Profile 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) throw APIErrors.unauthorized('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 })
updateUserData(pg, auth.uid, {avatarUrl: body.pinned_url || undefined})
}
console.debug('body', body)
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)
throw new APIError(500, 'Error creating user')
throw APIErrors.internalServerError('Error creating user')
}
log('Created profile', data)
@@ -54,7 +54,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
// So we can sse their full profile as soon as we get the notif on discord. And that allows OG to pull their pic for the link preview.
// Regardless, you need to wait for at least 5 seconds that the profile is fully in the db—otherwise ISR may cache "profile not created yet"
await sleep(10 * MINUTE_MS)
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
let message: string = `[**${user.name}**](https://compassmeet.com/${user.username}) just created a profile`
if (body.bio) {
const bioText = jsonToMarkdown(body.bio)
if (bioText) message += `\n${bioText}`
@@ -64,10 +64,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) => {
@@ -76,14 +74,10 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
n % 50 === 0
)
}
console.debug(nProfiles, isMilestone(nProfiles))
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

@@ -0,0 +1,218 @@
import {setLastOnlineTimeUser} from 'api/set-last-online-time'
import {setProfileOptions} from 'api/update-options'
import {APIErrors} from 'common/api/utils'
import {defaultLocale} from 'common/constants'
import {sendDiscordMessage} from 'common/discord/core'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import {debug} from 'common/logger'
import {trimStrings} from 'common/parsing'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
import {PrivateUser} from 'common/user'
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
import {cleanDisplayName} from 'common/util/clean-username'
import {removeUndefinedProps} from 'common/util/object'
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 {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {getUserByUsername, log} from 'shared/utils'
import {APIHandler} from './helpers/endpoint'
import {validateUsername} from './validate-username'
export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async (
props,
auth,
req,
) => {
trimStrings(props)
const {
deviceToken,
locale = defaultLocale,
username,
name,
profile,
interests,
causes,
work,
} = props
await removePinnedUrlFromPhotoUrls(profile)
// const host = req.get('referer')
const ip = getIp(req)
const pg = createSupabaseDirectClient()
const cleanName = cleanDisplayName(name || 'User')
const fbUser = await admin.auth().getUser(auth.uid)
const email = fbUser.email
const bucket = getBucket()
const avatarUrl = profile.pinned_url ?? (await generateAvatarUrl(auth.uid, cleanName, bucket))
let finalUsername = username
const validation = await validateUsername(username)
if (validation.suggestedUsername) {
finalUsername = validation.suggestedUsername
} else if (!validation.valid) {
throw APIErrors.badRequest(validation.message || 'Invalid username', {
field: 'username',
resolution:
'Usernames must be 325 characters and contain only letters, numbers, or underscores.',
})
}
// The pg.tx() call wraps several database operations in a single atomic transaction,
// ensuring they either all succeed or all fail together.
const {user, privateUser, newProfileRow} = await pg.tx(async (tx) => {
const existingUser = await tx.oneOrNone('select id from users where id = $1', [auth.uid])
if (existingUser) {
const existingProfile = await tx.oneOrNone('select id from profiles where user_id = $1', [
auth.uid,
])
if (existingProfile) {
throw APIErrors.conflict('An account for this user already exists', {
resolution:
'If you already have an account, try logging in. If you believe this is a mistake, contact support.',
})
} else {
await pg.none('DELETE FROM users WHERE id = $1', [auth.uid])
}
}
const sameNameUser = await getUserByUsername(finalUsername, tx)
if (sameNameUser) {
throw APIErrors.conflict('Username is already taken', {
field: 'username',
resolution: 'Please choose a different username.',
})
}
const privateUserData: PrivateUser = {
id: auth.uid,
email,
locale,
initialIpAddress: ip,
initialDeviceToken: deviceToken,
notificationPreferences: getDefaultNotificationPreferences(),
blockedUserIds: [],
blockedByUserIds: [],
}
const newUserRow = await insert(tx, 'users', {
id: auth.uid,
name: cleanName,
username: finalUsername,
avatar_url: avatarUrl,
is_banned_from_posting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip)),
),
data: {},
})
const newPrivateUserRow = await insert(tx, 'private_users', {
id: privateUserData.id,
data: privateUserData,
})
const profileData = removeUndefinedProps(profile)
const newProfileRow = await insert(tx, 'profiles', {
user_id: auth.uid,
...profileData,
})
const profileId = newProfileRow.id
await setProfileOptions(tx, profileId, auth.uid, 'interests', interests)
await setProfileOptions(tx, profileId, auth.uid, 'causes', causes)
await setProfileOptions(tx, profileId, auth.uid, 'work', work)
return {
user: convertUser(newUserRow),
privateUser: convertPrivateUser(newPrivateUserRow),
newProfileRow,
}
})
log('created user and profile', {username: user.username, firebaseId: auth.uid})
const continuation = async () => {
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.error('Failed to track create profile', e)
}
try {
await sendWelcomeEmail(user, privateUser)
} catch (e) {
console.error('Failed to sendWelcomeEmail', e)
}
try {
await setLastOnlineTimeUser(auth.uid)
} catch (e) {
console.error('Failed to set last online time', e)
}
try {
const message: string = `[**${user.name}**](${DEPLOYED_WEB_URL}/${user.username}) just created a profile`
await sendDiscordMessage(message, 'members')
} catch (e) {
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 isMilestone = (n: number) => {
return (
[15, 20, 30, 40].includes(n) || // early milestones
n % 50 === 0
)
}
debug(nProfiles, isMilestone(nProfiles))
if (isMilestone(nProfiles)) {
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
}
} catch (e) {
console.error('Failed to send discord user milestone', e)
}
}
return {
result: {
// include everything the frontend needs
user,
privateUser,
profile: {
...newProfileRow,
interests: interests ?? [],
causes: causes ?? [],
work: work ?? [],
},
},
continue: continuation,
}
}
const bannedDeviceTokens = [
'fa807d664415',
'dcf208a11839',
'bbf18707c15d',
'4c2d15a6cc0c',
'0da6b4ea79d3',
]
const bannedIpAddresses: string[] = [
'24.176.214.250',
'2607:fb90:bd95:dbcd:ac39:6c97:4e35:3fed',
'2607:fb91:389:ddd0:ac39:8397:4e57:f060',
'2607:fb90:ed9a:4c8f:ac39:cf57:4edd:4027',
'2607:fb90:bd36:517a:ac39:6c91:812c:6328',
]

View File

@@ -1,50 +1,51 @@
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 {defaultLocale} from 'common/constants'
import {RESERVED_PATHS} from 'common/envs/constants'
import {getUser, getUserByUsername, log} from 'shared/utils'
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
) => {
const {deviceToken: preDeviceToken} = props
const firebaseUser = await admin.auth().getUser(auth.uid)
import {APIErrors, APIHandler} from './helpers/endpoint'
const testUserAKAEmailPasswordUser =
firebaseUser.providerData[0].providerId === 'password'
// if (
// testUserAKAEmailPasswordUser &&
// adminToken !== process.env.TEST_CREATE_USER_KEY
// ) {
// throw new APIError(
// 401,
// 'Must use correct TEST_CREATE_USER_KEY to create user with email/password'
// )
// }
/**
* Create User API Handler
*
* Creates a new user account with associated profile and private user data.
* This endpoint is called after Firebase authentication to initialize
* the user's presence in the Compass database.
*
* Process:
* 1. Validates Firebase authentication token
* 2. Creates user record in users table
* 3. Creates private user record in private_users table
* 4. Generates default profile data
* 5. Sends welcome email asynchronously
* 6. Tracks user creation event
*
* @param props - Request parameters including device token and locale
* @param auth - Authenticated user information from Firebase
* @param req - Express request object for accessing headers/IP
* @returns User and private user objects with continuation function for async tasks
* @throws {APIError} 403 if user already exists or username is taken
*/
export const createUser: APIHandler<'create-user'> = async (props, auth, req) => {
const {deviceToken, locale = defaultLocale} = props
const host = req.get('referer')
log(`Create user from: ${host}`)
log(`Create user from: ${host}, ${props}`)
const ip = getIp(req)
const deviceToken = testUserAKAEmailPasswordUser
? randomString() + randomString()
: preDeviceToken
const fbUser = await admin.auth().getUser(auth.uid)
const email = fbUser.email
@@ -54,9 +55,7 @@ export const createUser: APIHandler<'create-user'> = async (
const name = cleanDisplayName(rawName)
const bucket = getBucket()
const avatarUrl = fbUser.photoURL
? fbUser.photoURL
: await generateAvatarUrl(auth.uid, name, bucket)
const avatarUrl = fbUser.photoURL ?? (await generateAvatarUrl(auth.uid, name, bucket))
const pg = createSupabaseDirectClient()
@@ -68,29 +67,33 @@ 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)
const isReservedName = RESERVED_PATHS.has(username)
if (usernameExists || isReservedName) username += randomString(4)
const {user, privateUser} = await pg.tx(async (tx) => {
const preexistingUser = await getUser(auth.uid, tx)
if (preexistingUser)
throw new APIError(403, 'User already exists', {
userId: auth.uid,
throw APIErrors.forbidden('An account for this user already exists', {
field: 'userId',
context: `User with ID ${auth.uid} already exists`,
})
// 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})
throw APIErrors.conflict('Username is already taken', {
field: 'username',
context: `Username "${username}" is already taken`,
})
const user = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
is_banned_from_posting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip))
(ip && bannedIpAddresses.includes(ip)),
),
link: {},
})
@@ -98,6 +101,7 @@ export const createUser: APIHandler<'create-user'> = async (
const privateUser: PrivateUser = {
id: auth.uid,
email,
locale,
initialIpAddress: ip,
initialDeviceToken: deviceToken,
notificationPreferences: getDefaultNotificationPreferences(),
@@ -132,7 +136,7 @@ export const createUser: APIHandler<'create-user'> = async (
console.error('Failed to track create profile', e)
}
try {
if (!IS_LOCAL) await sendWelcomeEmail(user, privateUser)
await sendWelcomeEmail(user, privateUser)
} catch (e) {
console.error('Failed to sendWelcomeEmail', e)
}

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 {APIErrors, 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')
if (!creator) throw APIErrors.unauthorized('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')
if (error) throw APIErrors.unauthorized('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,14 @@
import {APIHandler} from 'api/helpers/endpoint'
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {
recomputeCompatibilityScoresForUser,
updateCompatibilityPromptsMetrics,
} from 'shared/compatibility/compute-scores'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError} from 'common/api/utils'
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
{id}, auth) => {
{id},
auth,
) => {
const pg = createSupabaseDirectClient()
// Verify user is the answer author
@@ -13,29 +17,31 @@ 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) {
throw new APIError(404, 'Item not found')
throw APIErrors.notFound('Item not found')
}
const questionId = item.question_id
// Delete the answer
await pg.none(
`DELETE
FROM compatibility_answers
WHERE id = $1
AND creator_id = $2`,
[id, auth.uid]
[id, auth.uid],
)
const continuation = async () => {
// Recompute precomputed compatibility scores for this user
await recomputeCompatibilityScoresForUser(auth.uid, pg)
await updateCompatibilityPromptsMetrics(questionId)
await recomputeCompatibilityScoresForUser(auth.uid)
}
return {
status: 'success',
result: {status: 'success'},
continue: continuation,
}
}

View File

@@ -1,21 +1,38 @@
import {getUser} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import {debug} from 'common/logger'
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 {APIErrors, 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')
throw APIErrors.unauthorized('Your account was not found')
}
const userId = user.id
if (!userId) {
throw new APIError(400, 'Invalid user ID')
throw APIErrors.badRequest('Invalid user ID')
}
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
const pg = createSupabaseDirectClient()
await pg.none('DELETE FROM users WHERE id = $1', [userId])
// Should cascade delete in other tables
@@ -26,7 +43,7 @@ export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
try {
const auth = admin.auth()
await auth.deleteUser(userId)
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
} catch (e) {
console.error('Error deleting user from Firebase Auth:', e)
}

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 {APIErrors, APIHandler} from './helpers/endpoint'
// const DELETED_MESSAGE_CONTENT: JSONContent = {
// type: 'doc',
@@ -26,11 +27,11 @@ 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) {
throw new APIError(404, 'Message not found')
throw APIErrors.notFound('Message not found')
}
// Soft delete the 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 {APIErrors, APIHandler} from './helpers/endpoint'
export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
const pg = createSupabaseDirectClient()
@@ -15,11 +15,11 @@ 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) {
throw new APIError(404, 'Message not found or cannot be edited')
throw APIErrors.notFound('Message not found or cannot be edited')
}
const plaintext = JSON.stringify(content)
@@ -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

@@ -0,0 +1,86 @@
import {APIHandler} from 'api/helpers/endpoint'
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {groupBy, mapValues} from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getChannelMemberships: APIHandler<'get-channel-memberships'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelId, lastUpdatedTime, createdTime, limit} = props
let channels: PrivateMessageChannel[]
const convertRow = (r: any) => ({
channel_id: r.channel_id as number,
notify_after_time: r.notify_after_time as string,
created_time: r.created_time as string,
last_updated_time: r.last_updated_time as string,
})
if (channelId) {
channels = await pg.map(
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
from private_user_message_channel_members pumcm
join private_user_message_channels pumc on pumc.id = pumcm.channel_id
where user_id = $1
and channel_id = $2
limit $3
`,
[auth.uid, channelId, limit],
convertRow,
)
} else {
channels = await pg.map(
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
notify_after_time,
pumc.created_time,
(select created_time
from private_user_messages
where channel_id = pumc.id
and visibility != 'system_status'
and user_id != $1
order by created_time desc
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
from private_user_message_channels pumc
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
inner join private_user_messages pum on pumc.id = pum.channel_id
and (pum.visibility != 'introduction' or pum.user_id != $1)
where pumcm.user_id = $1
and not status = 'left'
and ($2 is null or pumcm.created_time > $2)
and ($4 is null or pumc.last_updated_time > $4)
order by pumc.id, pumc.last_updated_time desc)
select *
from latest_channels
order by last_updated_channel_time desc
limit $3
`,
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
convertRow,
)
}
if (!channels || channels.length === 0) return {channels: [], memberIdsByChannelId: {}}
const channelIds = channels.map((c) => c.channel_id)
const members = await pg.map(
`select channel_id, user_id
from private_user_message_channel_members
where not user_id = $1
and channel_id in ($2:list)
and not status = 'left'
`,
[auth.uid, channelIds],
(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),
)
return {
channels,
memberIdsByChannelId,
}
}

View File

@@ -0,0 +1,31 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getLastSeenChannelTime: APIHandler<'get-channel-seen-time'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelIds} = props
const unseens = await pg.map(
`select distinct on (channel_id) channel_id, created_time
from private_user_seen_message_channels
where channel_id = any ($1)
and user_id = $2
order by channel_id, created_time desc
`,
[channelIds, auth.uid],
(r) => [r.channel_id, r.created_time] as [number, Date],
)
// When this hits the network, JSON.stringify() turns the Date into an ISO string.
// Then the zod schema in the endpoint definition casts it back to front-end Date
return unseens
}
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],
)
}

View File

@@ -1,48 +1,79 @@
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 {QuestionWithStats} from 'common/api/types'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export function shuffle<T>(array: T[]): T[] {
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]];
}
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()
const questions = await pg.manyOrNone<
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
// 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<QuestionWithStats>(
`
SELECT cp.id,
cp.answer_type,
cp.importance_score,
cp.created_time,
cp.creator_id,
cp.category,
COALESCE(cpt.question, cp.question) AS question,
COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options) AS multiple_choice_options,
cp.answer_count,
CASE
WHEN cp.answer_count IS NULL OR cp.answer_count = 0 THEN 0
--- community_importance_score is a weighted sum: max val is 2 * answer_count if everyone marks at the highest level of importance
--- So we divide by 2 * answer_count to ensure it's between and 0 and 1
--- We damp by 20 to ensure questions with few responders don't get a high score
--- The square root is to spread the percent of all questions, since in the early days they don't get higher than 50%.
--- It does not impact ranking though.
--- TODO: remove the square root when we get more answers
ELSE SQRT(cp.community_importance_score::float / (cp.answer_count + 20) / 2) * 100
END AS community_importance_percent,
0 AS score --- update later if needed
FROM compatibility_prompts cp
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,
)
// const questions = shuffle(dbQuestions)
// console.debug(
// 'got questions',
// questions.map((q) => q.question + ' ' + q.score)
// )
// console.debug(questions.find((q) => q.id === 275))
return {
status: 'success',

View File

@@ -0,0 +1,47 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getConnectionInterestsEndpoint: APIHandler<'get-connection-interests'> = async (
props,
auth,
) => {
return getConnectionInterests(props, auth.uid)
}
export const getConnectionInterests = async (props: any, userId: string) => {
const {targetUserId} = props
if (!targetUserId) {
throw new Error('Missing target user ID')
}
if (targetUserId === userId) {
throw new Error('Cannot get connection interests for yourself')
}
const pg = createSupabaseDirectClient()
// Get what connection interest I have with them
const _interests = await pg.query(
'SELECT connection_type FROM connection_interests WHERE user_id = $1 AND target_user_id = $2',
[userId, targetUserId],
)
const interests = _interests.map((i: {connection_type: string}) => i.connection_type) ?? []
// debug({_interests, interests})
// Get what connection interest they have with me (filtering out the ones I haven't expressed interest in
// so it's risk-free to express interest in them)
const _targetInterests = await pg.query(
'SELECT connection_type FROM connection_interests WHERE user_id = $1 AND target_user_id = $2',
[targetUserId, userId],
)
const targetInterests =
_targetInterests
?.map((i: {connection_type: string}) => i.connection_type)
?.filter((i: string) => interests.includes(i)) ?? []
return {
interests,
targetInterests,
}
}

View File

@@ -1,31 +1,23 @@
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 {APIErrors, 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 APIErrors.internalServerError('Error fetching private user data: ' + error.message)
}
if (!data) {
throw new APIError(401, 'Your account was not found')
throw APIErrors.unauthorized('Your account was not found')
}
return data.data as PrivateUser

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, 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,34 @@
import {APIHandler} from 'api/helpers/endpoint'
import {HiddenProfile} from 'common/api/user-types'
import {convertPartialUser} from 'common/supabase/users'
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.avatar_url, 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],
convertPartialUser,
)
return {status: 'success', hidden: rows as HiddenProfile[], count}
}

View File

@@ -0,0 +1,34 @@
import {ChatMessage} from 'common/chat-message'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {convertPrivateChatMessage} from 'shared/supabase/messages'
import {APIHandler} from './helpers/endpoint'
export const getLastMessages: APIHandler<'get-last-messages'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelIds} = props
const messages = await pg.map(
`select distinct on (channel_id) channel_id, id, user_id, created_time, visibility, ciphertext, iv, tag
from private_user_messages
where visibility != 'system_status'
and channel_id in (
select channel_id from private_user_message_channel_members
where user_id = $1 and not status = 'left'
)
${channelIds ? 'and channel_id = any ($2)' : ''}
order by channel_id, created_time desc
`,
[auth.uid, channelIds],
convertPrivateChatMessage,
)
// Required to parse to number? If so, should prob create a helper to reuse in other places?
return messages.reduce(
(acc, msg) => {
acc[Number(msg.channelId)] = msg
return acc
},
{} as Record<number, ChatMessage>,
)
}

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',
@@ -27,14 +25,14 @@ export const getLikesAndShipsMain = async (userId: string) => {
where creator_id = $1
and looking_for_matches
and profiles.pinned_url is not null
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
order by created_time desc
`,
[userId],
(r) => ({
user_id: r.target_id,
created_time: new Date(r.created_time).getTime(),
})
}),
)
const likesReceived = await pg.map<{
@@ -49,14 +47,14 @@ export const getLikesAndShipsMain = async (userId: string) => {
where target_id = $1
and looking_for_matches
and profiles.pinned_url is not null
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
order by created_time desc
`,
[userId],
(r) => ({
user_id: r.creator_id,
created_time: new Date(r.created_time).getTime(),
})
}),
)
const ships = await pg.map<{
@@ -76,7 +74,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
where target2_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
union all
@@ -89,13 +87,13 @@ export const getLikesAndShipsMain = async (userId: string) => {
where target1_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
and not is_banned_from_posting
`,
[userId],
(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,18 +1,24 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from "shared/supabase/init";
import {debug} from 'common/logger'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _auth) => {
import {APIHandler} from './helpers/endpoint'
export async function getMessagesCount() {
const pg = createSupabaseDirectClient()
const result = await pg.one(
`
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)
debug('private_user_messages count:', count)
return {
count: count,
}
}
export const getMessagesCountEndpoint: APIHandler<'get-messages-count'> = async (_, _auth) => {
return await getMessagesCount()
}

View File

@@ -1,23 +1,90 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIHandler } from 'api/helpers/endpoint'
import { Notification } from 'common/notifications'
import {APIHandler} from 'api/helpers/endpoint'
import {defaultLocale} from 'common/constants'
import {Notification} from 'common/notifications'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getNotifications: APIHandler<'get-notifications'> = async (
props,
auth
) => {
const { limit, after } = props
// Helper function to substitute placeholders in template text
function substitutePlaceholders(templateText: string, templateData: any): string {
let result = templateText
if (templateData) {
for (const [key, value] of Object.entries(templateData)) {
// Replace all occurrences of {key} with the value
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value))
}
}
return result
}
export const getNotifications: APIHandler<'get-notifications'> = async (props, auth, _req) => {
const {limit, after, locale = defaultLocale} = 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', COALESCE(ntt.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', COALESCE(ntt.source_text, nt.source_text),
'sourceSlug', nt.source_slug,
'sourceUserAvatarUrl', nt.source_user_avatar_url,
'data', nt.data,
'templateData', un.data->'templateData'
)
else
un.data
end as notification_data
from user_notifications un
left join notification_templates nt on un.template_id = nt.id
left join notification_template_translations ntt
on nt.id = ntt.template_id
and ntt.locale = $4 -- User's locale
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(
const rawNotifications = await pg.map(
query,
[auth.uid, limit, after],
(row) => row.data as Notification
[auth.uid, limit, after, locale],
(row) => row.notification_data,
)
// Process notifications to apply template data substitution
const processedNotifications: Notification[] = rawNotifications.map((notif: any) => {
if (notif.templateId) {
// Apply template data substitution to title and sourceText
const templateData = notif.templateData || {}
const processedNotif = {...notif}
if (processedNotif.title) {
processedNotif.title = substitutePlaceholders(processedNotif.title, templateData)
}
if (processedNotif.sourceText) {
processedNotif.sourceText = substitutePlaceholders(processedNotif.sourceText, templateData)
}
return processedNotif as Notification
}
return notif as Notification
})
return processedNotifications
}

View File

@@ -1,28 +1,44 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {OptionTableKey} from 'common/profiles/constants'
import {validateTable} from 'common/profiles/options'
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
) => {
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
export async function getOptions(table: OptionTableKey, locale?: string): Promise<string[]> {
validateTable(table)
const pg = createSupabaseDirectClient()
const result = await tryCatch(
pg.manyOrNone<{ name: string }>(`SELECT interests.name
FROM interests`)
)
let query: string
const params: any[] = []
if (locale) {
// Get translated options for the specified locale
const translationTable = `${table}_translations`
query = `
SELECT COALESCE(t.name, o.name) as name
FROM ${table} o
LEFT JOIN ${translationTable} t ON o.id = t.option_id AND t.locale = $1
ORDER BY o.id
`
params.push(locale)
} else {
// Get default options (fallback to English)
query = `SELECT name FROM ${table} ORDER BY id`
}
const result = await tryCatch(pg.manyOrNone<{name: string}>(query, params))
if (result.error) {
log('Error getting profile options', result.error)
throw new APIError(500, 'Error getting profile options')
throw APIErrors.internalServerError('Error getting profile options')
}
const names = result.data.map(row => row.name)
return {names}
return result.data.map((row) => row.name)
}
export const getOptionsEndpoint: APIHandler<'get-options'> = async ({table, locale}, _auth) => {
const names = await getOptions(table, locale)
return {names}
}

View File

@@ -0,0 +1,22 @@
import type {APIHandler} from 'api/helpers/endpoint'
import {Row} from 'common/supabase/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export async function getPinnedQuestionIds(userId: string) {
const pg = createSupabaseDirectClient()
const rows = await pg.manyOrNone<Row<'compatibility_prompts_pinned'>>(
`select * from compatibility_prompts_pinned
where user_id = $1
order by created_time desc`,
[userId],
)
// newest-first in table; return in that order
return rows.map((r) => r.question_id)
}
export const getPinnedCompatibilityQuestions: APIHandler<
'get-pinned-compatibility-questions'
> = async (_props, auth) => {
const pinnedQuestionIds = await getPinnedQuestionIds(auth.uid)
return {status: 'success', pinnedQuestionIds}
}

View File

@@ -1,115 +1,29 @@
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError, APIHandler} from './helpers/endpoint'
import {PrivateMessageChannel,} from 'common/supabase/private-messages'
import {groupBy, mapValues} from 'lodash'
import {convertPrivateChatMessage} from "shared/supabase/messages";
import {tryCatch} from "common/util/try-catch";
import {convertPrivateChatMessage} from 'shared/supabase/messages'
export const getChannelMemberships: APIHandler<
'get-channel-memberships'
> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelId, lastUpdatedTime, createdTime, limit} = props
let channels: PrivateMessageChannel[]
const convertRow = (r: any) => ({
channel_id: r.channel_id as number,
notify_after_time: r.notify_after_time as string,
created_time: r.created_time as string,
last_updated_time: r.last_updated_time as string,
})
if (channelId) {
channels = await pg.map(
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
from private_user_message_channel_members pumcm
join private_user_message_channels pumc on pumc.id = pumcm.channel_id
where user_id = $1
and channel_id = $2
limit $3
`,
[auth.uid, channelId, limit],
convertRow
)
} else {
channels = await pg.map(
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
notify_after_time,
pumc.created_time,
(select created_time
from private_user_messages
where channel_id = pumc.id
and visibility != 'system_status'
and user_id != $1
order by created_time desc
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
from private_user_message_channels pumc
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
inner join private_user_messages pum on pumc.id = pum.channel_id
and (pum.visibility != 'introduction' or pum.user_id != $1)
where pumcm.user_id = $1
and not status = 'left'
and ($2 is null or pumcm.created_time > $2)
and ($4 is null or pumc.last_updated_time > $4)
order by pumc.id, pumc.last_updated_time desc)
select *
from latest_channels
order by last_updated_channel_time desc
limit $3
`,
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
convertRow
)
}
if (!channels || channels.length === 0)
return {channels: [], memberIdsByChannelId: {}}
const channelIds = channels.map((c) => c.channel_id)
const members = await pg.map(
`select channel_id, user_id
from private_user_message_channel_members
where not user_id = $1
and channel_id in ($2:list)
and not status = 'left'
`,
[auth.uid, channelIds],
(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)
)
return {
channels,
memberIdsByChannelId,
}
}
import {APIErrors, APIHandler} from './helpers/endpoint'
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
beforeId?: number | undefined
userId: string
}) {
// console.log('initial message request', props)
const {channelId, limit, id, userId} = props
const {channelId, limit, id, beforeId, 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
@@ -117,48 +31,28 @@ export async function getChannelMessages(props: {
where pumcm.user_id = $2
and pumcm.channel_id = $1)
and ($4 is null or id > $4)
and ($5 is null or id < $5)
and not visibility = 'system_status'
order by created_time desc
limit $3
${limit ? 'limit $3' : ''}
`,
[channelId, userId, limit, id],
convertPrivateChatMessage
))
[channelId, userId, limit, id, beforeId],
convertPrivateChatMessage,
),
)
if (error) {
console.error(error)
throw new APIError(401, 'Error getting messages')
console.error('Error getting messages:', error)
// If it's a connection pool error, provide more specific error message
if (error.message && error.message.includes('MaxClientsInSessionMode')) {
throw APIErrors.serviceUnavailable(
'Service temporarily unavailable due to high demand. Please try again in a moment.',
)
}
throw APIErrors.internalServerError('Error getting messages', {
field: 'database',
context: error.message || 'Unknown database error',
})
}
// console.log('final messages', data)
return data
}
export const getLastSeenChannelTime: APIHandler<
'get-channel-seen-time'
> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const {channelIds} = props
const unseens = await pg.map(
`select distinct on (channel_id) channel_id, created_time
from private_user_seen_message_channels
where channel_id = any ($1)
and user_id = $2
order by channel_id, created_time desc
`,
[channelIds, auth.uid],
(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) => {
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]
)
}

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,64 +1,126 @@
import * as Sentry from '@sentry/node'
import {type APIHandler} from 'api/helpers/endpoint'
import {OptionTableKey} from 'common/profiles/constants'
import {compact} from 'lodash'
import {log} from 'shared/monitoring/log'
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'
/**
* Profile query parameters for filtering and pagination
*
* Defines the available filters and pagination options for retrieving
* user profiles from the database. Supports complex filtering based
* on demographics, preferences, and location.
*/
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,
/** Maximum number of profiles to return */
limit?: number | undefined
/** Pagination cursor for retrieving next page of results */
after?: string | undefined
/** Specific user ID to retrieve (for single profile lookups) */
userId?: string | undefined
/** Name filter for profile search */
name?: string | undefined
/** Filter by gender identity */
genders?: string[] | undefined
/** Filter for profiles with photos */
hasPhoto?: boolean | undefined
/** Filter by education level */
education_levels?: string[] | undefined
/** Filter by preferred gender for matches */
pref_gender?: string[] | undefined
/** Minimum preferred age for matches */
pref_age_min?: number | undefined
/** Maximum preferred age for matches */
pref_age_max?: number | undefined
/** Minimum drinks consumed per month */
drinks_min?: number | undefined
/** Maximum drinks consumed per month */
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
psychedelics?: string[] | undefined
cannabis?: string[] | undefined
psychedelics_intention?: string[] | undefined
cannabis_intention?: string[] | undefined
psychedelics_pref?: string[] | undefined
cannabis_pref?: string[] | 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
last_active?: 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)
log('get-profiles', props)
const {
limit: limitParam,
after,
name,
userId,
genders,
hasPhoto,
education_levels,
pref_gender,
pref_age_min,
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,
@@ -74,19 +136,36 @@ export const loadProfiles = async (props: profileQueryType) => {
work,
is_smoker,
shortBio,
psychedelics,
cannabis,
psychedelics_intention,
cannabis_intention,
psychedelics_pref,
cannabis_pref,
geodbCityIds,
lat,
lon,
radius,
raised_in_lat,
raised_in_lon,
raised_in_radius,
compatibleWithUserId,
orderBy: orderByParam = 'created_time',
lastModificationWithin,
skipId,
locale = 'en',
last_active,
} = 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 +173,51 @@ 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'
// Need them in the output for the profile card even we don't filter by them
const joinInterests = true // !!interests?.length
const joinCauses = true // !!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 === 'last_online_time' || last_active) && 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 +236,213 @@ 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->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
where(`not users.is_banned_from_posting`),
// 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')}
OR lower(headline) ilike '%' || lower($(word)) || '%'
OR EXISTS ( SELECT 1 FROM unnest(keywords) AS kw WHERE kw ILIKE '%' || LOWER($(word)) || '%' )
`,
{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}),
psychedelics?.length &&
where(
(psychedelics.includes('never_not_interested') ? 'psychedelics IS NULL OR ' : '') +
`psychedelics = ANY($(psychedelics))`,
{psychedelics},
),
cannabis?.length &&
where(
(cannabis.includes('never_not_interested') ? 'cannabis IS NULL OR ' : '') +
`cannabis = ANY($(cannabis))`,
{cannabis},
),
psychedelics_intention?.length &&
where(
`(psychedelics IS NOT NULL AND psychedelics != 'never_not_interested') AND (psychedelics_intention IS NULL OR psychedelics_intention = '{}') OR psychedelics_intention && $(psychedelics_intention)`,
{psychedelics_intention},
),
cannabis_intention?.length &&
where(
`(cannabis IS NOT NULL AND cannabis != 'never_not_interested') AND (cannabis_intention IS NULL OR cannabis_intention = '{}') OR cannabis_intention && $(cannabis_intention)`,
{cannabis_intention},
),
psychedelics_pref?.length &&
where(
`psychedelics_pref IS NULL OR psychedelics_pref = '{}' OR psychedelics_pref && $(psychedelics_pref)`,
{psychedelics_pref},
),
cannabis_pref?.length &&
where(`cannabis_pref IS NULL OR cannabis_pref = '{}' OR cannabis_pref && $(cannabis_pref)`, {
cannabis_pref,
}),
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,24 +451,81 @@ 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 headline IS NOT NULL
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}),
hasPhoto && where("pinned_url IS NOT NULL AND pinned_url != ''"),
lastModificationWithin &&
where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {
lastModificationWithin,
}),
last_active &&
where(`user_activity.last_online_time >= NOW() - INTERVAL $(last_active_interval)`, {
last_active_interval:
last_active === 'now'
? '30 minutes'
: last_active === 'today'
? '1 day'
: last_active === '3days'
? '3 days'
: last_active === 'week'
? '7 days'
: last_active === 'month'
? '30 days'
: '90 days',
}),
// 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'
if (orderByParam === 'compatibility_score') {
selectCols += ', cs.score as compatibility_score'
} else if (orderByParam === 'last_online_time') {
} else if (orderByParam === 'last_online_time' || last_active) {
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 +542,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,10 +552,11 @@ 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)
Sentry.captureException(error, {extra: props})
return {status: 'fail', profiles: [], count: 0}
}
}

View File

@@ -1,18 +1,16 @@
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 {APIErrors, 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.")
throw APIErrors.internalServerError("No SUPABASE_JWT_SECRET; couldn't sign token.")
}
const instanceId = ENV_CONFIG.supabaseInstanceId
if (!instanceId) {
throw new APIError(500, 'No Supabase instance ID in config.')
throw APIErrors.internalServerError('No Supabase instance ID in config.')
}
const payload = {role: 'anon'} // postgres role
return {

View File

@@ -0,0 +1,68 @@
import {debug} from 'common/logger'
import {ProfileRow} from 'common/profiles/profile'
import {convertUser} from 'common/supabase/users'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {type APIHandler} from './helpers/endpoint'
export async function getUserAndProfile(username: string) {
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone('SELECT * FROM users WHERE username ILIKE $1', [username], (r) =>
r ? convertUser(r) : null,
)
if (!user) return null
// Fetch profile like getProfileRow does
const profileRes = await pg.oneOrNone<ProfileRow>('SELECT * FROM profiles WHERE user_id = $1', [
user.id,
])
if (!profileRes) return {user, profile: null}
// Parallel instead of sequential (like getProfileRow does in frontend)
const [interestsRes, causesRes, workRes] = await Promise.all([
pg.any(
`SELECT interests.id
FROM profile_interests
JOIN interests ON profile_interests.option_id = interests.id
WHERE profile_interests.profile_id = $1`,
[profileRes.id],
),
pg.any(
`SELECT causes.id
FROM profile_causes
JOIN causes ON profile_causes.option_id = causes.id
WHERE profile_causes.profile_id = $1`,
[profileRes.id],
),
pg.any(
`SELECT work.id
FROM profile_work
JOIN work ON profile_work.option_id = work.id
WHERE profile_work.profile_id = $1`,
[profileRes.id],
),
])
const profileWithItems = {
...profileRes,
interests: interestsRes.map((r: any) => String(r.id)),
causes: causesRes.map((r: any) => String(r.id)),
work: workRes.map((r: any) => String(r.id)),
}
return {user, profile: profileWithItems}
}
export const getUserAndProfileHandler: APIHandler<'get-user-and-profile'> = async (
{username},
_auth,
) => {
const result = await getUserAndProfile(username)
debug(result)
return {
user: result?.user,
profile: result?.profile,
}
}

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,17 +1,17 @@
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 {APIErrors} 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')
if (!user) throw APIErrors.notFound('User not found')
return toUserAPIResponse(user)
}
@@ -27,7 +27,7 @@ export const getUser = async (props: { id: string } | { username: string }) => {
// where ${'id' in props ? 'id' : 'username'} = $1`,
// ['id' in props ? props.id : props.username]
// )
// if (!liteUser) throw new APIError(404, 'User not found')
// if (!liteUser) throw APIErrors.notFound('User not found')
//
// return removeNullOrUndefinedProps(liteUser)
// }

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,13 +1,19 @@
import * as admin from 'firebase-admin'
import {z} from 'zod'
import {NextFunction, Request, Response} from 'express'
import * as Sentry from '@sentry/node'
import {
API,
APIPath,
APIResponseOptionalContinue,
APISchema,
ValidatedAPIParams,
} from 'common/api/schema'
import {APIErrors} from 'common/api/utils'
import {PrivateUser} from 'common/user'
import {APIError} from 'common/api/utils'
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
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'
export {APIErrors} from 'common/api/utils'
// export type Json = Record<string, unknown> | Json[]
// export type JsonHandler<T extends Json> = (
@@ -27,10 +33,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> {
@@ -60,32 +66,37 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
const auth = admin.auth()
const authHeader = req.get('Authorization')
if (!authHeader) {
throw new APIError(401, 'Missing Authorization header.')
throw APIErrors.unauthorized('Missing Authorization header.')
}
const authParts = authHeader.split(' ')
if (authParts.length !== 2) {
throw new APIError(401, 'Invalid Authorization header.')
throw APIErrors.unauthorized('Invalid Authorization header.')
}
const [scheme, payload] = authParts
switch (scheme) {
case 'Bearer':
if (payload === 'undefined') {
throw new APIError(401, 'Firebase JWT payload undefined.')
throw APIErrors.unauthorized('Firebase JWT payload undefined.')
}
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]
const _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.')
console.error('Error verifying Firebase JWT: ', err, scheme, payload, {
jwtHeader: _header,
})
Sentry.captureException(err, {
extra: {jwtHeader: _header},
})
throw APIErrors.internalServerError('Error validating token.')
}
case 'Key':
return {kind: 'key', data: payload}
default:
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
throw APIErrors.unauthorized('Invalid auth scheme; must be "Key" or "Bearer".')
}
}
@@ -93,7 +104,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
switch (creds.kind) {
case 'jwt': {
if (typeof creds.data.user_id !== 'string') {
throw new APIError(401, 'JWT must contain user ID.')
throw APIErrors.unauthorized('JWT must contain user ID.')
}
return {uid: creds.data.user_id, creds}
}
@@ -101,12 +112,12 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
const key = creds.data
const privateUser = await getPrivateUserByKey(key)
if (!privateUser) {
throw new APIError(401, `No private user exists with API key ${key}.`)
throw APIErrors.unauthorized(`No private user exists with API key ${key}.`)
}
return {uid: privateUser.id, creds: {privateUser, ...creds}}
}
default:
throw new APIError(401, 'Invalid credential type.')
throw APIErrors.unauthorized('Invalid credential type.')
}
}
@@ -114,15 +125,20 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const result = schema.safeParse(val)
if (!result.success) {
const issues = result.error.issues.map((i) => {
const field = i.path.join('.')
return {
field: i.path.join('.') || null,
error: i.message,
field: field === '' ? undefined : field,
context: i.message,
}
})
if (issues.length > 0) {
log.error(issues.map((i) => `${i.field}: ${i.error}`).join('\n'))
log.error(issues.map((i) => `${i.field}: ${i.context}`).join('\n'))
}
throw new APIError(400, 'Error validating request.', issues)
console.error('Validation failed', {issues, schema, val})
Sentry.captureException(APIErrors.validationFailed(issues), {
extra: {issues, schema, val},
})
throw APIErrors.validationFailed(issues)
} else {
return result.data as z.infer<T>
}
@@ -170,10 +186,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 +196,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)
@@ -224,15 +238,21 @@ function checkRateLimit(name: string, req: Request, res: Response, auth?: Authed
if (state.count > limit) {
res.setHeader('Retry-After', String(reset))
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
throw APIErrors.rateLimitExceeded('Too Many Requests: rate limit exceeded.')
}
}
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 apiSchema = API[name] as APISchema<N> & {
deprecation?: {deprecated: boolean; migrationPath?: string; sunsetDate?: string}
}
const {
props: propSchema,
authed: authRequired,
rateLimited = false,
method,
deprecation,
} = apiSchema
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
@@ -251,6 +271,10 @@ export const typedEndpoint = <N extends APIPath>(
}
}
if (deprecation?.deprecated) {
log('Deprecated endpoint called:', name, req)
}
const props = {
...(method === 'GET' ? req.query : req.body),
...req.params,
@@ -260,18 +284,27 @@ 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) {
// Add deprecation headers for deprecated endpoints
if (deprecation?.deprecated) {
res.setHeader('Deprecation', 'true')
if (deprecation.sunsetDate) {
res.setHeader('Sunset', deprecation.sunsetDate)
}
if (deprecation.migrationPath) {
res.setHeader('Link', `<${deprecation.migrationPath}>; rel="migration"`)
}
}
// Convert bigint to number, b/c JSON doesn't support bigint.
const convertedResult = deepConvertBigIntToNumber(result)
// console.debug('API result', convertedResult)

View File

@@ -1,23 +1,22 @@
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 {APIErrors} from 'common/api/utils'
import {ChatVisibility} from 'common/chat-message'
import {debug} from 'common/logger'
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 * as admin from 'firebase-admin'
import {TokenMessage} from "firebase-admin/lib/messaging/messaging-api";
import utc from 'dayjs/plugin/utc'
import {sendNewMessageEmail} from 'email/functions/helpers'
import {first} from 'lodash'
import {track} from 'shared/analytics'
import {encryptMessage} from 'shared/encryption'
import {sendMobileNotifications, sendWebNotifications} from 'shared/mobile'
import {log} from 'shared/monitoring/log'
import {SupabaseDirectClient} from 'shared/supabase/init'
import {getPrivateUser, getUser} from 'shared/utils'
import {broadcast} from 'shared/websockets/server'
dayjs.extend(utc)
dayjs.extend(timezone)
@@ -48,7 +47,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 +55,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 +77,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 +102,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 +115,7 @@ export const createPrivateUserMessageMain = async (
channelId: number,
content: JSONContent,
pg: SupabaseDirectClient,
visibility: ChatVisibility
visibility: ChatVisibility,
) => {
log('createPrivateUserMessageMain', creator, channelId, content)
@@ -126,10 +125,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 APIErrors.forbidden('You are not authorized to post to this channel')
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
@@ -138,13 +136,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 +155,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
@@ -178,7 +175,7 @@ const notifyOtherUserInChannelIfInactive = async (
// TODO: notification only for active user
const receiver = await getUser(receiverId)
console.debug('receiver:', receiver)
debug('receiver:', receiver)
if (!receiver) return
// Push notifs
@@ -188,13 +185,17 @@ const notifyOtherUserInChannelIfInactive = async (
body: textContent,
url: `/messages/${channelId}`,
}
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
await sendMobileNotifications(pg, receiverId, payload)
const startOfDay = dayjs()
.tz('America/Los_Angeles')
.startOf('day')
.toISOString()
try {
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
} catch (err) {
console.error('Failed to send web notification:', err)
}
try {
await sendMobileNotifications(pg, receiverId, payload)
} catch (err) {
console.error('Failed to send mobile notification:', err)
}
const startOfDay = dayjs().tz('America/Los_Angeles').startOf('day').toISOString()
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
`select count(*)
from private_user_messages
@@ -202,7 +203,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,178 +211,9 @@ 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)
debug('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}
async function sendWebNotifications(
pg: SupabaseDirectClient,
userId: string,
payload: string,
) {
webPush.setVapidDetails(
'mailto:hello@compassmeet.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
// Retrieve subscription from the database
const subscriptions = await getSubscriptionsFromDB(pg, userId)
for (const subscription of subscriptions) {
try {
console.log('Sending notification to:', subscription.endpoint, payload)
await webPush.sendNotification(subscription, payload)
} catch (err: any) {
console.log('Failed to send notification', err)
if (err.statusCode === 410 || err.statusCode === 404) {
console.warn('Removing expired subscription', subscription.endpoint)
await removeSubscription(pg, subscription.endpoint, userId)
} else {
console.error('Push failed', err)
}
}
}
}
export async function getSubscriptionsFromDB(
pg: SupabaseDirectClient,
userId: string,
) {
try {
const subscriptions = await pg.manyOrNone(`
select endpoint, keys
from push_subscriptions
where user_id = $1
`, [userId]
)
return subscriptions.map(sub => ({
endpoint: sub.endpoint,
keys: sub.keys,
}))
} catch (err) {
console.error('Error fetching subscriptions', err)
return []
}
}
async function removeSubscription(
pg: SupabaseDirectClient,
endpoint: any,
userId: string,
) {
await pg.none(
`DELETE
FROM push_subscriptions
WHERE endpoint = $1
AND user_id = $2`,
[endpoint, userId]
)
}
async function removeMobileSubscription(
pg: SupabaseDirectClient,
token: any,
userId: string,
) {
await pg.none(
`DELETE
FROM push_subscriptions_mobile
WHERE token = $1
AND user_id = $2`,
[token, userId]
)
}
async function sendMobileNotifications(
pg: SupabaseDirectClient,
userId: string,
payload: PushPayload,
) {
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
for (const subscription of subscriptions) {
await sendPushToToken(pg, userId, subscription.token, payload)
}
}
interface PushPayload {
title: string
body: string
url: string
data?: Record<string, string>
}
export async function sendPushToToken(
pg: SupabaseDirectClient,
userId: string,
token: string,
payload: PushPayload,
) {
const message: TokenMessage = {
token,
android: {
notification: {
title: payload.title,
body: payload.body,
},
},
data: {
endpoint: payload.url,
},
}
try {
// Fine to create at each call, as it's a cached singleton
const fcm = admin.messaging()
console.log('Sending notification to:', token, message)
const response = await fcm.send(message)
console.log('Push sent successfully:', response)
return response
} catch (err: unknown) {
// Check if it's a Firebase Messaging error
if (err instanceof Error && 'code' in err) {
const firebaseError = err as { code: string; message: string }
console.warn('Firebase error:', firebaseError.code, firebaseError.message)
// Handle specific error cases here if needed
// For example, if token is no longer valid:
if (firebaseError.code === 'messaging/registration-token-not-registered' ||
firebaseError.code === 'messaging/invalid-argument') {
console.warn('Removing invalid FCM token')
await removeMobileSubscription(pg, token, userId)
}
} else {
console.error('Unknown error:', err)
}
}
return
}
export async function getMobileSubscriptionsFromDB(
pg: SupabaseDirectClient,
userId: string,
) {
try {
const subscriptions = await pg.manyOrNone(`
select token
from push_subscriptions_mobile
where user_id = $1
`, [userId]
)
return subscriptions
} catch (err) {
console.error('Error fetching subscriptions', err)
return []
}
}

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 {APIErrors, 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')
throw APIErrors.notFound('Comment not found')
}
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')
if (!isAdminId(auth.uid) && comment.user_id !== auth.uid && comment.on_user_id !== auth.uid) {
throw APIErrors.forbidden('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 {APIErrors, 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 APIErrors.badRequest('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,25 +1,21 @@
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 {APIErrors, 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')
if (!user) throw APIErrors.unauthorized('Your account was not found')
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 APIErrors.forbidden('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)}
}

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