381 Commits
1.1.2 ... 1.5.0

Author SHA1 Message Date
MartinBraquet
17f9e72a9f Release 2025-10-25 04:19:29 +02:00
MartinBraquet
120aeed56f Add API to FAQ 2025-10-25 04:19:19 +02:00
MartinBraquet
8128c3b2d7 Update docs 2025-10-25 04:10:37 +02:00
MartinBraquet
4581a33cae Fix package import 2025-10-25 04:10:32 +02:00
MartinBraquet
d43e2af3ae Remove unused openapi 2025-10-25 04:07:46 +02:00
MartinBraquet
0283eb4d85 Massive upgrade to the API Swagger UI @ api.compassmeet.com 2025-10-25 03:42:23 +02:00
MartinBraquet
f483ae42a8 Add smoker to your filters 2025-10-25 01:31:43 +02:00
MartinBraquet
f974eba465 Move Reset filter to top 2025-10-25 01:25:24 +02:00
MartinBraquet
7d7969fe0f Reset filter 2025-10-25 01:22:01 +02:00
MartinBraquet
2a3d7e8362 Fix you filter 2025-10-25 01:21:46 +02:00
MartinBraquet
a38c03c4e0 Fix 2025-10-25 01:02:21 +02:00
MartinBraquet
342a0c612a Add filter for smoking 2025-10-25 00:58:53 +02:00
MartinBraquet
f1f9970407 Either instead of Any Kids 2025-10-24 23:03:41 +02:00
MartinBraquet
c83a3e6315 Log error 2025-10-24 23:03:17 +02:00
MartinBraquet
fbc65e7e2a Update helpers for local run 2025-10-24 23:03:04 +02:00
MartinBraquet
d9e9407cab Update doc 2025-10-24 23:02:36 +02:00
MartinBraquet
d0881b76e0 Update migrate 2025-10-24 23:02:28 +02:00
MartinBraquet
61c867b49c Add info for add new profile field 2025-10-24 23:02:17 +02:00
MartinBraquet
87de30d257 Create share notif 2025-10-24 23:02:03 +02:00
MartinBraquet
817605417c Default to DEV in local 2025-10-24 23:01:50 +02:00
MartinBraquet
65b018db2a Add contact links 2025-10-24 20:48:45 +02:00
MartinBraquet
addb52e3fa Add /donate page with OC widget 2025-10-24 19:24:58 +02:00
MartinBraquet
c3124ec7c3 Clean 2025-10-24 18:52:00 +02:00
MartinBraquet
b1caa6dfdc Remove log 2025-10-24 18:34:55 +02:00
MartinBraquet
26f28d55d9 Add filters for drinks 2025-10-24 18:31:43 +02:00
MartinBraquet
cb66688529 Make left nav bar scrollable 2025-10-24 17:20:52 +02:00
MartinBraquet
40c61f11be Speed update local run 2025-10-24 17:10:47 +02:00
MartinBraquet
9b45c75a5b Remove none 2025-10-24 16:25:16 +02:00
MartinBraquet
09425c1910 Rename college 2025-10-24 16:24:01 +02:00
MartinBraquet
591798e98c Add filter for education level 2025-10-24 16:19:37 +02:00
MartinBraquet
acdd82a680 Clean and factor out get-private-messages 2025-10-24 15:18:04 +02:00
MartinBraquet
5719ac3209 Fix age input 2025-10-24 14:20:55 +02:00
MartinBraquet
2ac687b0c2 Fix 2025-10-24 02:41:10 +02:00
MartinBraquet
a86a249f05 Add height in cm 2025-10-24 02:36:30 +02:00
MartinBraquet
e49a7b0bb4 Add backend support for setting compatibility answers 2025-10-24 02:09:46 +02:00
MartinBraquet
e904a7949c Fix RLS 2025-10-24 01:57:01 +02:00
MartinBraquet
080d8110df Delete bookmarked searches in the backend 2025-10-24 01:35:16 +02:00
MartinBraquet
d90826e851 Create bookmarked searches in the backend 2025-10-24 01:30:03 +02:00
MartinBraquet
e495da692b Improve wording in saved searches 2025-10-24 01:29:32 +02:00
MartinBraquet
52970ef93e Add fkeys to schema 2025-10-24 01:26:50 +02:00
MartinBraquet
8f641d117a Cancel deletions automated by cascade delete 2025-10-24 01:08:39 +02:00
MartinBraquet
d164ebc7da Add foreign keys 2025-10-24 01:04:18 +02:00
MartinBraquet
632cc5810d Add icons 2025-10-23 20:15:21 +02:00
MartinBraquet
e565a6c77f Set last online upon user creation 2025-10-23 18:45:37 +02:00
MartinBraquet
c1fe700d7a Add lib clean 2025-10-23 18:18:01 +02:00
MartinBraquet
06ee267804 Remove log 2025-10-23 18:00:11 +02:00
MartinBraquet
aad722c723 Add chat messages AES encryption 2025-10-23 17:57:19 +02:00
MartinBraquet
aefc58b636 Make mobile filter modal scrollable 2025-10-23 15:52:18 +02:00
MartinBraquet
fdd96507b8 Add politics filter 2025-10-23 15:41:46 +02:00
MartinBraquet
2ad87a5ec5 Update SQL schema 2025-10-23 15:25:06 +02:00
MartinBraquet
b94cdba5af Add diet 2025-10-23 14:58:57 +02:00
MartinBraquet
725261335c Reorder about 2025-10-23 14:16:50 +02:00
MartinBraquet
5fb0051fc6 Update README.md for clarity and minor corrections 2025-10-23 02:25:47 +02:00
MartinBraquet
1247847739 Update FAQ 2025-10-23 02:25:34 +02:00
MartinBraquet
18cb4e74d6 Refactor notification process 2025-10-23 02:07:45 +02:00
MartinBraquet
e07cb7fca9 Show all notifs types 2025-10-23 01:46:57 +02:00
MartinBraquet
dc54ed46f8 Add notif for New Vote Page 2025-10-23 01:38:34 +02:00
MartinBraquet
0415d86d71 Replace DROP INDEX with IF NOT EXISTS 2025-10-23 00:34:34 +02:00
MartinBraquet
b8b95be5ce Add on delete cascade 2025-10-23 00:29:14 +02:00
MartinBraquet
46820f0986 Clean 2025-10-23 00:08:54 +02:00
MartinBraquet
dcc022ac7f Add open Collective 2025-10-23 00:05:27 +02:00
MartinBraquet
9142f0d633 Autofocus 2025-10-22 23:49:28 +02:00
MartinBraquet
181c72befe Format 2025-10-22 23:49:18 +02:00
MartinBraquet
99f3459978 Remove user followers in search-users 2025-10-22 23:33:25 +02:00
MartinBraquet
75fbc9679c Fix page props 2025-10-22 23:33:12 +02:00
MartinBraquet
700b7774b1 Margin 404 2025-10-22 23:32:37 +02:00
MartinBraquet
d9f0a9b1ca Fix "younger than 99" 2025-10-22 22:59:25 +02:00
MartinBraquet
70644ff26d Prevent editor focus on mobile devices 2025-10-22 16:22:50 +02:00
MartinBraquet
bbefcc3bc8 Add indexes to push_subscriptions 2025-10-22 15:33:55 +02:00
MartinBraquet
09767dbae3 Add WPA to faq 2025-10-22 15:33:42 +02:00
MartinBraquet
57eafa95ba Fire and forget other user notif 2025-10-22 15:00:41 +02:00
MartinBraquet
f4f28a411e Typo 2025-10-22 15:00:27 +02:00
MartinBraquet
f6059ef5c7 Focus on editor upon page loading 2025-10-22 14:47:07 +02:00
MartinBraquet
e3fa4efa95 Remove expired subscriptions 2025-10-22 14:28:59 +02:00
MartinBraquet
6884a91eb8 Ignore ico 2025-10-22 14:28:23 +02:00
MartinBraquet
71ba018a42 Remove misplace favicon 2025-10-22 13:57:24 +02:00
MartinBraquet
10f5232ac3 Clean 2025-10-22 13:54:56 +02:00
MartinBraquet
78d707484d Update badge 2 2025-10-22 13:54:47 +02:00
MartinBraquet
69db66fbbb Update badge 2025-10-22 13:32:53 +02:00
MartinBraquet
99691cd7ee Factor out vapid key 2025-10-22 13:23:14 +02:00
MartinBraquet
47cef359ca Put back icons 2025-10-22 03:57:57 +02:00
MartinBraquet
046105498f Hide icons 2025-10-22 03:53:07 +02:00
MartinBraquet
4d3ef5dd2a Log payload 2025-10-22 03:43:56 +02:00
MartinBraquet
8bcd5623bf Add service worker info 2025-10-22 03:43:46 +02:00
MartinBraquet
a29b4a3a8e Remove badge 2025-10-22 03:40:15 +02:00
MartinBraquet
dee0fb396b Remove cache 2025-10-22 03:39:35 +02:00
MartinBraquet
b5c707e07f Send notif for every single private message 2025-10-22 03:25:04 +02:00
MartinBraquet
8fe35bd1d7 Add VAPID secrets 2025-10-22 03:02:34 +02:00
MartinBraquet
6c864c35cd Implement subscriptions for mobile notifications 2025-10-22 02:52:41 +02:00
MartinBraquet
f00acf6af1 Improve link accessibility 2025-10-22 01:18:45 +02:00
MartinBraquet
49e1599bc4 Fix react error 2025-10-22 00:44:22 +02:00
MartinBraquet
7311d4b724 Remove cache 2025-10-22 00:38:56 +02:00
MartinBraquet
fa44e348a2 Fix WPA theme color 2025-10-22 00:22:28 +02:00
MartinBraquet
8cba02741c Fix WPA 2025-10-22 00:01:58 +02:00
MartinBraquet
48d04d5e72 Add WPA 2025-10-21 23:37:20 +02:00
MartinBraquet
7cac25c0e2 Remove react import (2) 2025-10-21 20:50:56 +02:00
MartinBraquet
88b0fa0163 Remove react import 2025-10-21 20:32:35 +02:00
MartinBraquet
3fcef24cc9 Add SEO (including better tab titles) 2025-10-21 20:29:10 +02:00
MartinBraquet
d9fba6ce6b Fix link 2025-10-21 12:40:28 +02:00
MartinBraquet
8bc2f0c40e Fix flashing submit button 2025-10-21 12:36:54 +02:00
MartinBraquet
21254695d5 Remove unused import 2025-10-21 12:32:39 +02:00
MartinBraquet
f063f0a6f4 Add donate links 2025-10-21 12:32:00 +02:00
MartinBraquet
2d847cbcdb Add Github sponsor 2025-10-21 12:15:27 +02:00
MartinBraquet
547e99f526 Add vercel config for backup info 2025-10-20 17:36:55 +02:00
MartinBraquet
a9794cd2ee Add packages 2025-10-20 17:24:42 +02:00
MartinBraquet
c651abd8ae Add unmet deps 2025-10-20 16:45:02 +02:00
MartinBraquet
15781475b6 Rename Love to Profile 2025-10-20 16:35:59 +02:00
MartinBraquet
26a28175fd Rename LovePage 2025-10-20 16:24:22 +02:00
MartinBraquet
aa3680934b Rename lover to profile 2025-10-20 16:18:49 +02:00
MartinBraquet
0b36586ddf Rename love folder in backend 2025-10-20 16:14:23 +02:00
MartinBraquet
7b58acac0d Rename love folder 2025-10-20 16:13:43 +02:00
MartinBraquet
27bf4eadf9 Rename compatibility_answers_free 2025-10-20 16:10:41 +02:00
MartinBraquet
c8d4353888 Rename love_waitlist 2025-10-20 16:05:14 +02:00
MartinBraquet
4876ca2643 Rename love_stars 2025-10-20 16:03:28 +02:00
MartinBraquet
e06a382c94 Rename love_ships 2025-10-20 16:01:33 +02:00
MartinBraquet
d1a421ca15 Rename compatibility_prompts 2025-10-20 15:59:44 +02:00
MartinBraquet
cd3c8d89d0 Rename love_likes 2025-10-20 15:48:22 +02:00
MartinBraquet
1f943ccead Rename love_compatibility_answers 2025-10-20 15:46:16 +02:00
MartinBraquet
753776fa9a Fix 2025-10-20 15:37:33 +02:00
MartinBraquet
9787a2446e Fix 2025-10-20 15:31:39 +02:00
MartinBraquet
4cb29d274b Add floating info box 2025-10-20 15:28:59 +02:00
MartinBraquet
df55d63f99 Add security and help pages 2025-10-20 13:31:58 +02:00
MartinBraquet
236e2d48c5 Update security email 2025-10-20 13:20:31 +02:00
MartinBraquet
30d45d834f Add compat prompts number to stats 2025-10-20 13:03:26 +02:00
MartinBraquet
edf30897f2 Fix effect 2025-10-20 12:56:42 +02:00
MartinBraquet
3d31ebb576 Fix hooks 2025-10-20 12:55:16 +02:00
MartinBraquet
d3bac8bcc0 Fix vercel badge 2025-10-20 12:53:37 +02:00
MartinBraquet
a360f80cdf Add warning message about short bio 2025-10-20 12:51:28 +02:00
MartinBraquet
0cc7549546 remove log 2025-10-20 12:51:10 +02:00
MartinBraquet
283d2743e0 Fix mod 2025-10-19 22:50:09 +02:00
MartinBraquet
b431fa11fa Fix pref_gender filtering 2025-10-19 21:42:37 +02:00
MartinBraquet
648e00867f Reduce v space 2025-10-19 21:30:44 +02:00
MartinBraquet
552af7bb6b Add romantic type in filters 2025-10-19 21:25:17 +02:00
MartinBraquet
92980f7c79 Increase contrast 2025-10-19 20:19:52 +02:00
MartinBraquet
09a563bf73 Clean 2025-10-19 20:19:45 +02:00
MartinBraquet
141fa12a20 Add kids strength and other relationship filters 2025-10-19 12:36:48 +02:00
MartinBraquet
6e0035d4f3 Show profiles that don't set kids strength 2025-10-19 12:36:09 +02:00
MartinBraquet
97bac4132c Fix loc filter clearing 2025-10-19 11:11:51 +02:00
MartinBraquet
b23b0280cd Fix mobile nav contrast 2025-10-19 11:00:49 +02:00
MartinBraquet
7ac093a8d0 Fix socials contrast 2025-10-19 10:58:12 +02:00
MartinBraquet
dfc524b957 Fix 2025-10-19 00:32:48 +02:00
MartinBraquet
65ba0d348b Increase contrast for better accessibility 2025-10-19 00:11:59 +02:00
MartinBraquet
ed07031539 Fix primary key 2025-10-18 23:31:32 +02:00
MartinBraquet
93f3690344 Speed up init theme 2025-10-18 22:47:43 +02:00
MartinBraquet
1341d1356a Fix warning 2025-10-18 22:37:46 +02:00
MartinBraquet
38dcf16c03 customlink -> custom-link 2025-10-18 22:36:37 +02:00
MartinBraquet
8696a42959 SSH and view logs in one click 2025-10-18 22:36:12 +02:00
MartinBraquet
c6fc7db1e9 Move up 2025-10-18 13:17:44 +02:00
MartinBraquet
58540aca57 Add proposals and votes number to stats 2025-10-18 13:16:05 +02:00
MartinBraquet
b7b75279c2 Add warning message upon console opening 2025-10-18 12:49:36 +02:00
MartinBraquet
204a35d026 Release 1.4 2025-10-18 12:24:13 +02:00
MartinBraquet
fb2841f198 Update compat answer box 2025-10-18 12:23:47 +02:00
MartinBraquet
5de055c977 Improve importance radio contrast 2025-10-18 12:14:35 +02:00
MartinBraquet
084659ea3d Remove debug 2025-10-18 12:14:20 +02:00
MartinBraquet
c1a414afab Make votes sortable 2025-10-18 11:49:54 +02:00
MartinBraquet
a5747034d6 Fix props name 2025-10-18 10:40:35 +02:00
MartinBraquet
fda52fec97 Move proposal up and hide by default 2025-10-18 10:39:50 +02:00
MartinBraquet
e38ec79618 remove quote 2025-10-18 02:50:05 +02:00
MartinBraquet
1ef125db12 Fix md format 2025-10-18 02:46:07 +02:00
MartinBraquet
b580b640bd Remove unused react 2025-10-18 02:39:24 +02:00
MartinBraquet
214bddaca4 Add contact links 2025-10-18 02:36:54 +02:00
MartinBraquet
065d489869 Add contact form 2025-10-18 02:20:31 +02:00
MartinBraquet
46ffefbbb9 Add anonymous option for votes 2025-10-18 00:53:35 +02:00
MartinBraquet
a19db3bca9 Clean 2025-10-18 00:20:32 +02:00
MartinBraquet
2c8d8d9989 Clean 2025-10-18 00:12:53 +02:00
MartinBraquet
d52943e31e Fix 2025-10-17 23:24:08 +02:00
MartinBraquet
3eababb742 Fix 2025-10-17 23:19:20 +02:00
MartinBraquet
8a954d3c20 Add voting / proposal page 2025-10-17 23:15:15 +02:00
MartinBraquet
8516901032 Allow get notified for anyone 2025-10-17 19:04:57 +02:00
MartinBraquet
3f2d246fec Fix short bios not showing when sorting by compatibility 2025-10-17 16:55:44 +02:00
MartinBraquet
58fdaa26ca Move to distance filtering to improve accuracy and speed 2025-10-17 16:43:27 +02:00
MartinBraquet
7dc1a8790d Fix 2025-10-17 14:53:55 +02:00
MartinBraquet
70c9ec1d73 Show full political names 2025-10-17 14:02:04 +02:00
MartinBraquet
2bcbbc96ad Add political options 2025-10-17 13:55:48 +02:00
MartinBraquet
527d36a159 Move want kids closer to connection type 2025-10-17 13:50:46 +02:00
MartinBraquet
2ce21247ee Add romantic style (poly, mono, other) 2025-10-17 13:42:32 +02:00
MartinBraquet
8ea6c406e0 Add webhook to report to discord 2025-10-16 20:59:46 +02:00
MartinBraquet
e22f50ecd3 Show loading indicator 2025-10-16 15:28:29 +02:00
MartinBraquet
20dcd98fdf Allow unauth requests to get-messages-count (used in public stats) 2025-10-16 15:16:21 +02:00
MartinBraquet
bc5708857a Improve onboarding UI 2025-10-16 14:37:17 +02:00
MartinBraquet
b9c045ebfb Do not render sign up button, redirect to home 2025-10-16 14:22:53 +02:00
MartinBraquet
c69bd7018e Use compass loading sign 2025-10-16 14:08:34 +02:00
MartinBraquet
078d149175 Redirect to profiles grid after sign up 2025-10-16 14:08:24 +02:00
MartinBraquet
be9f0bd061 Add 20 core compatibility prompts 2025-10-16 13:48:00 +02:00
MartinBraquet
a4723563f5 Keep blue loading circle for buttons 2025-10-16 13:41:03 +02:00
MartinBraquet
1fdcd24f28 Improve design of loading indicator 2025-10-16 12:42:38 +02:00
MartinBraquet
a43480db92 Increase API_RATE_LIMIT_PER_MIN_UNAUTHED 2025-10-16 01:27:24 +02:00
MartinBraquet
e85a072f1c Add user loaded log 2025-10-16 01:24:05 +02:00
MartinBraquet
bbfa2a4eab Wait longer for user to appear 2025-10-16 01:23:12 +02:00
MartinBraquet
2f2db4ded8 Rollback toast error as it shows randomly 2025-10-16 00:48:38 +02:00
MartinBraquet
7296a0d2cd Remove rate limit for endpoints not prone to scraping 2025-10-16 00:41:21 +02:00
MartinBraquet
08e02b6ac0 Add too many requests toast 2025-10-16 00:28:25 +02:00
MartinBraquet
715811d7fd Commetn 2025-10-16 00:28:10 +02:00
MartinBraquet
c7d6ae6995 Hide log 2025-10-16 00:27:58 +02:00
MartinBraquet
b1d1396944 Fix import 2025-10-15 23:52:46 +02:00
MartinBraquet
25a319710e Fix import 2025-10-15 23:50:27 +02:00
MartinBraquet
796b13dd62 Add toast error for too many requests 2025-10-15 23:47:01 +02:00
MartinBraquet
8197863ac5 Clean auth and rate limiting 2025-10-15 23:37:24 +02:00
MartinBraquet
89bd164d43 Add authed 2025-10-15 23:20:20 +02:00
MartinBraquet
80d7061e5f Pre commit 2025-10-15 22:50:50 +02:00
MartinBraquet
c49bac3a09 Make API calls authed 2025-10-15 22:42:26 +02:00
MartinBraquet
06d53fe801 Redirect if logged out in /notifications 2025-10-15 22:32:58 +02:00
MartinBraquet
15ba529938 Fix "column reference "user_id" is ambiguous" 2025-10-15 19:26:10 +02:00
MartinBraquet
83054d0cd1 Fix link opening in same tab 2025-10-15 17:04:00 +02:00
MartinBraquet
8da486adf2 Optimistically remove starred profile upon deletion 2025-10-15 16:01:11 +02:00
MartinBraquet
32bc3847fa Add option to save / bookmark profiles 2025-10-15 15:45:47 +02:00
MartinBraquet
5d763c18c8 Comment log 2025-10-15 15:45:27 +02:00
MartinBraquet
bd3920cfff Fix pagination for last active sorting 2025-10-14 22:09:20 +02:00
MartinBraquet
06d94332b6 Update keyword search placeholder names 2025-10-14 21:35:37 +02:00
MartinBraquet
50614484d8 Move last above social links 2025-10-14 21:06:08 +02:00
MartinBraquet
c29d3d8c92 Clean 2025-10-14 20:51:06 +02:00
MartinBraquet
26f46af375 Fix unused botBadge 2025-10-14 20:49:36 +02:00
MartinBraquet
32b1491dd0 Fix unused node 2025-10-14 20:48:50 +02:00
MartinBraquet
51b8a6c80a Fix unused open 2025-10-14 20:48:21 +02:00
MartinBraquet
0f63d6d3a0 Remove unused react imports 2025-10-14 20:42:41 +02:00
MartinBraquet
4771b08773 Show and write when the user was last online in their profile 2025-10-14 20:36:58 +02:00
MartinBraquet
9b880101fd Make plot larger on mobile 2025-10-14 19:56:35 +02:00
MartinBraquet
594806d6e8 Improve charts design 2025-10-14 19:37:38 +02:00
MartinBraquet
e9afd4db2f Regen supabase types 2025-10-14 19:11:26 +02:00
MartinBraquet
b23efe4089 Add active members tile and move /charts to /stats 2025-10-14 19:09:56 +02:00
MartinBraquet
e33be41a93 Store last_online_time in user_activity.sql table instead of profiles 2025-10-14 19:09:08 +02:00
MartinBraquet
33b09df872 Reduce chart height 2025-10-14 19:03:30 +02:00
MartinBraquet
e9050d0aa0 Simplify and make grid for organization.tsx 2025-10-14 18:57:59 +02:00
MartinBraquet
baeb2a33fe Simplify and make grid for social 2025-10-14 18:57:52 +02:00
MartinBraquet
4ad89acdc7 Use last_online_time from user_activity instead of profiles 2025-10-14 17:53:29 +02:00
MartinBraquet
7d87af8f5c Add user_activity.sql 2025-10-14 17:52:09 +02:00
MartinBraquet
65c0e84e2a Do not prepend social url if full url is provided 2025-10-14 11:28:48 +02:00
MartinBraquet
7b15d85871 Do not render protocol and subdomain in socials 2025-10-14 11:28:21 +02:00
MartinBraquet
ad8ec0f4fd Add browser dev info 2025-10-14 11:27:39 +02:00
MartinBraquet
2d05d83dd0 Always show relationship questions when connection type includes relationships 2025-10-14 11:02:50 +02:00
MartinBraquet
bd45066b13 Add IDE note 2025-10-13 19:43:06 +02:00
MartinBraquet
8ee4274054 Rename voting members 2025-10-13 19:13:13 +02:00
MartinBraquet
83a7ed4d6b Add stats to organization page 2025-10-13 19:05:41 +02:00
MartinBraquet
07dbd86ac6 Add How fast is Compass growing? to FAQ 2025-10-13 18:49:05 +02:00
MartinBraquet
0e671d2cc0 Add nice stats 2025-10-13 18:42:39 +02:00
MartinBraquet
2d6d3c04ce Add stat box 2025-10-13 18:42:06 +02:00
MartinBraquet
b0148963c7 Remove bookmarked_searches and love_compatibility_answers upon account deletion 2025-10-13 17:35:12 +02:00
MartinBraquet
13356950f3 Wait instead of thread resend emails 2025-10-13 17:27:59 +02:00
MartinBraquet
629bcb30a7 Add health discord webhook and send error message there 2025-10-13 15:13:05 +02:00
MartinBraquet
03721fff1c Do not pass orderBy when processing saved searches 2025-10-13 15:12:24 +02:00
MartinBraquet
2a6911ae3d Move email links to our domain 2025-10-13 13:34:10 +02:00
MartinBraquet
164eddecab Release v1.3.0 2025-10-13 12:39:38 +02:00
MartinBraquet
9eacb38eb9 Show bio in discord message of profile creation 2025-10-13 12:26:40 +02:00
MartinBraquet
20f5cfb9a7 Fix demo 2025-10-13 10:49:19 +02:00
Martin Braquet
6c6c1cc90a Upgrade geodb plan to increase radius and page limit (#14)
* Upgrade geodb plan to increase radius and page limit

* Speed location debounce
2025-10-12 15:58:52 +02:00
MartinBraquet
a32c099cc1 Rename social 2025-10-12 14:54:58 +02:00
MartinBraquet
fe2f832e83 Improve support page 2025-10-12 14:52:39 +02:00
MartinBraquet
868746cc23 Use Atkinson Hyperlegible font 2025-10-12 14:35:59 +02:00
MartinBraquet
3be7a54284 Fix compat modal (had to scroll to see Next on mobile) 2025-10-12 13:13:01 +02:00
MartinBraquet
635e1ec8e2 Add TODO readme info 2025-10-11 23:23:52 +02:00
MartinBraquet
a638a35a76 Upgrade ban logic 2025-10-11 22:58:50 +02:00
MartinBraquet
8cc33d3418 Add massive upgrade text 2025-10-11 21:42:46 +02:00
MartinBraquet
9947f7b967 Fix 2025-10-11 21:42:33 +02:00
MartinBraquet
daf5350f41 Add stem vector search in bio 2025-10-11 21:15:03 +02:00
MartinBraquet
020b9ddb8d Fix 2025-10-11 19:56:54 +02:00
MartinBraquet
23aff9497a Fix 2025-10-11 19:54:25 +02:00
MartinBraquet
3c119396f3 Add demo 2025-10-11 19:51:48 +02:00
MartinBraquet
f7c7c47ac0 Remove backup info from git 2025-10-11 19:44:38 +02:00
MartinBraquet
dbe2369bbe Fix avatar link 2025-10-11 19:44:12 +02:00
MartinBraquet
4e8033d221 Add info about contact 2025-10-11 19:44:02 +02:00
MartinBraquet
97a0f87cbd Use georgia font 2025-10-11 12:15:26 +02:00
MartinBraquet
bfa2713d43 Fix wording 2025-10-11 11:46:38 +02:00
MartinBraquet
fe5e109751 Improve reading 2025-10-10 22:53:30 +02:00
MartinBraquet
8cc96030b1 Speed up placeholder 2025-10-10 22:52:09 +02:00
MartinBraquet
a2b172ad58 Improve charts 2025-10-10 21:31:30 +02:00
MartinBraquet
e756225d8b Move pics above endorsements 2025-10-10 20:58:06 +02:00
MartinBraquet
dd803b604f Update reserved paths 2025-10-10 20:20:10 +02:00
MartinBraquet
b5c961c8ee Hide complete profile button 2025-10-10 20:05:01 +02:00
MartinBraquet
47cd9d227e Add shortBio filter to mobile filters 2025-10-10 19:13:32 +02:00
MartinBraquet
e2be3aafcd Add shortBio filter 2025-10-10 19:03:57 +02:00
MartinBraquet
015fe76c44 Hide profiles with small bio 2025-10-10 18:33:47 +02:00
MartinBraquet
44666aec03 Update post install 2025-10-10 18:33:11 +02:00
MartinBraquet
6a265e4f35 Do not render home before user loads 2025-10-10 18:32:37 +02:00
MartinBraquet
12c7316524 Refactor buttons 2025-10-10 17:04:26 +02:00
MartinBraquet
dcf9741d69 Format required form as step by step onboarding 2025-10-10 16:46:17 +02:00
MartinBraquet
63dd1fdd50 Replace user with voting member and member with volunteer for clarity and inclusion 2025-10-10 15:22:55 +02:00
MartinBraquet
5aa166bbfd Open links in same tab 2025-10-10 15:12:48 +02:00
MartinBraquet
34cbf7093e Skip welcome email if local 2025-10-10 14:51:22 +02:00
MartinBraquet
159d58949e Reformat 2025-10-09 21:51:21 +02:00
MartinBraquet
fcf802b7e3 Refactor bios and add character counter 2025-10-09 21:51:08 +02:00
MartinBraquet
92ff6dadb0 Add email 2025-10-09 20:00:01 +02:00
MartinBraquet
05fa2f9883 Add socials and organization pages 2025-10-09 19:47:32 +02:00
MartinBraquet
71bb8fd784 Commetn 2025-10-09 19:33:51 +02:00
MartinBraquet
16ffd6dfab Fix message view without sign in 2025-10-09 19:30:24 +02:00
MartinBraquet
2661d15910 Remove waitlist 2025-10-09 19:17:15 +02:00
MartinBraquet
394102bb93 Fix avatar icon 2025-10-09 18:37:11 +02:00
MartinBraquet
3585b12dfd Remove maintenance banner 2025-10-09 18:20:53 +02:00
MartinBraquet
423d87d5f1 Remove logs 2025-10-09 18:19:14 +02:00
MartinBraquet
13b13b1104 Fix 2025-10-09 18:02:15 +02:00
MartinBraquet
a77e7b96b7 Move logs to debug status 2025-10-09 17:59:10 +02:00
MartinBraquet
d7213c255c Add client side heartbeat 2025-10-09 17:50:43 +02:00
MartinBraquet
ddeb1dcdb7 Improve ping pong connection duration 2025-10-09 17:38:05 +02:00
MartinBraquet
221cfa3528 Fix websockets not reaching the container and remove v0/ prefix 2025-10-09 16:58:20 +02:00
MartinBraquet
d6f6348ff1 Add maintenance banner 2025-10-09 16:25:47 +02:00
MartinBraquet
0c6afdc98e Add star 2025-10-09 15:14:38 +02:00
MartinBraquet
02a2148b3f Improve messages width 2025-10-09 13:14:38 +02:00
MartinBraquet
36a02268d8 Fix 2025-10-09 11:28:11 +02:00
MartinBraquet
450f07f505 Add private backup 2025-10-09 11:27:24 +02:00
MartinBraquet
777eba9fed Move backup to private storage 2025-10-09 11:23:30 +02:00
MartinBraquet
eaa8fa57d1 Add private bucket 2025-10-09 11:18:37 +02:00
MartinBraquet
200bf479e1 Clean 2025-10-09 00:43:27 +02:00
MartinBraquet
331f409af9 Increase debounce 2025-10-09 00:28:27 +02:00
MartinBraquet
ce875a5e63 Fix Porto not showing 2025-10-09 00:16:58 +02:00
MartinBraquet
638013f835 Update email address 2025-10-08 23:44:37 +02:00
MartinBraquet
1de87cbfec Add welcome email 2025-10-08 23:40:53 +02:00
MartinBraquet
7f3428b36a Factor out unsubscribe url 2025-10-08 23:40:37 +02:00
MartinBraquet
35595ded47 Fix bullets 2025-10-08 23:40:18 +02:00
MartinBraquet
35e9264017 Show profiles number, not users number 2025-10-08 23:39:42 +02:00
MartinBraquet
02d33c8f83 Rename mock user 2025-10-08 20:38:31 +02:00
MartinBraquet
f229ebc3a8 Add email confirmation 2025-10-08 20:38:20 +02:00
MartinBraquet
0062351f6d Add welcome email 2025-10-08 20:38:09 +02:00
MartinBraquet
e86f6798ec Fix bullet 2025-10-08 20:37:35 +02:00
MartinBraquet
4f53f7136b Add members 2025-10-08 20:35:57 +02:00
MartinBraquet
d80b982dde Simplify tab title 2025-10-08 17:32:19 +02:00
MartinBraquet
24788aa9af Add optional Garamond font 2025-10-08 14:11:51 +02:00
MartinBraquet
9ffae658df Clean 2025-10-08 11:58:58 +02:00
MartinBraquet
82ad573cac Add stoat link 2025-10-08 11:58:52 +02:00
MartinBraquet
36bf7ad65b Fix 2025-10-07 22:53:37 +02:00
MartinBraquet
b30af128c7 Release 2025-10-07 22:11:34 +02:00
MartinBraquet
72c31ae097 Add compat score math video link 2025-10-07 21:25:08 +02:00
Martin Braquet
d2c608021d Improve home (#10) 2025-10-05 10:38:04 +02:00
Martin Braquet
1f36fb2413 Update faq.md 2025-10-05 10:14:09 +02:00
Martin Braquet
16a0cbcecf Update about.tsx 2025-10-05 10:00:30 +02:00
Martin Braquet
e068e246aa Update faq.md 2025-10-03 13:58:31 +02:00
MartinBraquet
ec7c77fcf9 Log 2025-10-02 15:14:13 +02:00
MartinBraquet
46a338b874 Clean 2025-10-02 14:54:47 +02:00
MartinBraquet
bfee7ff09d Fix age rendering 2025-10-02 14:14:24 +02:00
MartinBraquet
ce1305d8ae Host logos 2025-10-02 13:17:22 +02:00
MartinBraquet
aaebf88438 Log profile count 2025-10-01 09:02:50 +02:00
MartinBraquet
dde2c99e36 Fix 2025-10-01 08:58:11 +02:00
MartinBraquet
4dc2f3b9b9 Add Community Growth over Time 2025-10-01 08:56:03 +02:00
MartinBraquet
f30cfffb86 Fix bio parsing grid 2025-09-30 22:17:28 +02:00
MartinBraquet
ca3eb62ba7 Fix 2 2025-09-30 21:48:15 +02:00
MartinBraquet
c8e55ca4ce Fix 2025-09-30 21:46:31 +02:00
MartinBraquet
e4acb25a40 Better render headings and lists in profile grid 2025-09-30 21:45:09 +02:00
MartinBraquet
c741e10139 Stop spamming prod discord channels 2025-09-30 21:30:38 +02:00
MartinBraquet
28d0b35f8e Move discord move to create profile 2025-09-30 20:53:26 +02:00
MartinBraquet
f7f09cd9e5 Send message to Discord when reaching 50, 100, ..., users 2025-09-28 21:09:11 +02:00
MartinBraquet
501c92c350 Send discord message at every profile creation 2025-09-28 20:47:31 +02:00
MartinBraquet
f021101322 Remove unused React 2025-09-26 22:17:24 +02:00
MartinBraquet
369265bc2c Add firebase storage backup script 2025-09-26 22:12:08 +02:00
Martin Braquet
b1f1e5db1f Fully delete profile in database and Firebase auth + storage (#7) 2025-09-26 22:10:38 +02:00
MartinBraquet
51d32e5afb Link donations in FAQ 2025-09-26 13:52:40 +02:00
MartinBraquet
f396e8e482 Your filters 2025-09-26 13:51:08 +02:00
MartinBraquet
077321731e Add bio warning message 2025-09-25 23:02:04 +02:00
MartinBraquet
60eb0c6978 Add 'datingdoc', 'friendshipdoc', 'connectiondoc', 'workdoc' 2025-09-25 22:49:54 +02:00
MartinBraquet
475f0af78a Add supabase backup VM to Firebase storage and discord error hook 2025-09-24 15:59:46 +02:00
MartinBraquet
206fa07035 Ignore tf 2025-09-24 15:13:33 +02:00
MartinBraquet
aff949714c Add charts example 2025-09-24 13:28:46 +02:00
Martin Braquet
7e834b9ff6 Update FUNDING.yml 2025-09-24 12:46:20 +02:00
Martin Braquet
19bad26a98 Create FUNDING.yml 2025-09-24 12:44:37 +02:00
MartinBraquet
7cc7c8d27b Hide age, city and gender if null 2025-09-22 11:06:52 +02:00
MartinBraquet
ae5a8c7cfa Add page loading warning 2025-09-22 11:02:53 +02:00
MartinBraquet
5004b73210 Fix calendly link 2025-09-22 10:52:46 +02:00
MartinBraquet
02f613d269 Add Ko-fi donation link 2025-09-22 10:42:23 +02:00
MartinBraquet
439ac0310b Add option to delete an answered compatibility prompt 2025-09-22 10:09:13 +02:00
MartinBraquet
3e95467819 Add okcupid and calendly links 2025-09-22 00:06:05 +02:00
MartinBraquet
90522cb88b Comment log 2025-09-22 00:05:15 +02:00
MartinBraquet
af39b01d4a Reload env config after setting env vars 2025-09-21 16:56:42 +02:00
MartinBraquet
73a0a5ff0b Fix vercel env 2025-09-21 16:08:29 +02:00
MartinBraquet
e157f500bc Fix dev firebase admin key 2025-09-21 16:00:29 +02:00
MartinBraquet
274ee5ed5f Remove json 2025-09-21 15:44:33 +02:00
MartinBraquet
4cb11ba8c0 Remove log 2025-09-21 15:00:26 +02:00
MartinBraquet
7b8e775139 Fix google cloud env 2025-09-21 14:55:32 +02:00
MartinBraquet
86a7d26bfd Clean readme 2025-09-20 23:54:56 +02:00
MartinBraquet
84a437772d Make local DEV work out of the box 2025-09-20 23:51:28 +02:00
MartinBraquet
d7c95e2ae0 Clean ENV 2025-09-20 18:26:03 +02:00
MartinBraquet
b4f0ef8b43 Move supabase dev pwd inside code 2025-09-20 18:12:46 +02:00
MartinBraquet
6d30cd7ae4 Add open source to FAQ 2025-09-19 14:47:58 +02:00
MartinBraquet
f631236ee7 Add platform to FAQ 2025-09-19 14:42:36 +02:00
MartinBraquet
1a58ff5c4c Shuffle prompts 2025-09-18 15:33:23 +02:00
MartinBraquet
73aca913a1 Fix 2025-09-18 14:14:58 +02:00
MartinBraquet
24dee0cad6 Add bio char template 2025-09-18 13:34:29 +02:00
MartinBraquet
2d2de75372 Add bio tips 2025-09-18 13:12:48 +02:00
MartinBraquet
d98982e6fd Factor out links 2025-09-18 11:30:59 +02:00
MartinBraquet
14c12ffb08 Rename 2025-09-18 11:19:09 +02:00
MartinBraquet
f260afca11 Ignore 2025-09-18 11:18:22 +02:00
MartinBraquet
5bcbe25d97 Rm 2025-09-18 11:18:04 +02:00
MartinBraquet
2eee366fbd Update financials 2025-09-18 11:12:33 +02:00
MartinBraquet
85d57ec5e6 Fix resend email limit 2/sec 2025-09-17 18:35:29 +02:00
MartinBraquet
502c878f82 Fix 2025-09-17 18:15:02 +02:00
421 changed files with 10132 additions and 3736 deletions

View File

@@ -1,20 +1,7 @@
# Rename this file to `.env` and fill in the values.
# You already have access to basic local functionality (UI, authentication, database read access).
# Optional variables for the backend server functionality (modifying user data, etc.)
# For database write access (dev).
# A 16-character password with digits and letters.
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
# For Firebase access.
# Open a GitHub issue with your contribution ideas and an admin will give you the key.
# TODO: find a way to give anyone moderate access to dev firebase.
GOOGLE_APPLICATION_CREDENTIALS_DEV="[...].json"
# The URL where your local backend server is running.
# You can change the port if needed.
NEXT_PUBLIC_API_URL=localhost:8088
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
GOOGLE_CREDENTIALS_ENC_PWD=nP7s3274uzOG4c2t
# Optional variables for full local functionality
@@ -23,10 +10,6 @@ NEXT_PUBLIC_API_URL=localhost:8088
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
GEODB_API_KEY=
# For analytics like page views, user actions, feature usage, etc.
# Create a free account at https://posthog.com and get a project API key. Should start with "phc_".
POSTHOG_KEY=
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
# Create a free account at https://resend.com and get an API key. Should start with "re_".
RESEND_API_KEY=
RESEND_KEY=

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [CompassConnections] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: CompassMeet # Replace with a single Patreon username
open_collective: compass-connection # Replace with a single Open Collective username
ko_fi: compassconnections # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

8
.gitignore vendored
View File

@@ -68,6 +68,7 @@ email-preview
*.jpeg
*.gif
*.svg
*.ico
*.mp4
*.mov
*.avi
@@ -79,3 +80,10 @@ email-preview
*.zip
*.tar.gz
*.rar
/favicon_color.ico
/backend/shared/src/googleApplicationCredentials-dev.json
*.tfstate
*.tfstate.backup
*.terraform
/backups/firebase/auth/data/
/backups/firebase/storage/data/

View File

@@ -1,7 +1,7 @@
[![CI](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
[![CD](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
![Vercel](https://deploy-badge.vercel.app/vercel/bayesbond)
![Vercel](https://deploy-badge.vercel.app/vercel/compass)
# Compass
@@ -21,11 +21,28 @@ This repository contains the source code for [Compass](https://compassmeet.com)
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
<p style="text-align: center;">
<img src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fdemo_compass.gif?alt=media&token=e3ae4334-4e3f-4026-b121-c08b4b724cd1" alt="Compass Demo" width="600">
</p>
## 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**!
Here are some examples 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).
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
- Email hello@compassmeet.com
- Raise an issue on GitHub
If you want to add tasks without creating an account, you can simply email
```
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
```
Put the task title in the email subject and the task description in the email content.
Here is a tailored selection of things that would be very useful. If you want to help but dont know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
- [x] Authentication (user/password and Google Sign In)
- [x] Set up PostgreSQL in Production with supabase
@@ -49,16 +66,16 @@ Everything is open to anyone for collaboration, but the following ones are parti
- [x] Clean up learn more page
- [x] Add dark theme
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
- [ ] Cover with tests (very important, just the test template and framework are ready)
- [ ] Add profile fields (intellectual interests, cause areas, personality type, conflict style, timezone, etc.)
- [ ] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
- [ ] Cover with tests (crucial, just the test template and framework are ready)
- [ ] Make the app more user-friendly and appealing (UI/UX)
- [ ] Clean up terms and conditions (convert to Markdown)
- [ ] Clean up privacy notice (convert to Markdown)
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
- [ ] Add email verification
- [ ] Add password reset
- [ ] Add automated welcome email
- [x] Add automated welcome email
- [ ] Security audit and penetration testing
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
- [ ] Create settings page (change email, password, delete account, etc.)
@@ -100,20 +117,17 @@ yarn install
### 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.
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 our paid plan
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the platform.
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.
Most of the code will work out of the box. All you need to do is creating an `.env` file as a copy of `.env.example`:
```bash
cp .env.example .env
```
If you do need one of the few remaining services, you need to 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.
### Tests
@@ -132,11 +146,23 @@ 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.
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!
### Contributing
Now you can start contributing by making changes and submitting pull requests!
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
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/`.
See [development.md](docs/development.md) for additional instructions, such as adding new profile fields.
### Submission

View File

@@ -8,5 +8,5 @@
## Reporting a Vulnerability
Contact the development team at compass.meet.info@gmail.com to report a vulnerability. You should receive updates within a week.
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.

View File

View File

View File

View File

@@ -1,6 +1,5 @@
'use client';
import {aColor, supportEmail} from "@/lib/client/constants";
import Image from 'next/image';
export default function PrivacyPage() {

View File

View File

@@ -0,0 +1,2 @@
'use client';

View File

View File

View File

View File

View File

View File

View File

View File

@@ -54,6 +54,9 @@ gcloud projects add-iam-policy-binding compass-130ba \
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
gcloud run services list
gcloud compute backend-services update api-backend \
--global \
--timeout=600s
```
Set up the saved search notifications job:
@@ -158,5 +161,4 @@ docker rmi -f $(docker images -aq)
### Documentation
The API docs are available at https://api.compassmeet.com. They are defined in [openapi.json](openapi.json).
Just a few endpoints are mentioned in that JSON doc. Feel free to help by adding the remaining ones!
The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts).

View File

@@ -185,29 +185,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 {
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
# }
}
}

View File

@@ -1,29 +0,0 @@
{
"openapi": "3.0.0",
"info": {
"title": "Compass API",
"version": "1.0.0"
},
"paths": {
"/health": {
"get": {
"summary": "Health",
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/get-profiles": {
"get": {
"summary": "List profiles",
"responses": {
"200": {
"description": "OK"
}
}
}
}
}
}

View File

@@ -4,21 +4,23 @@
"version": "1.0.0",
"private": true,
"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\"",
"watch:serve": "nodemon -r tsconfig-paths/register --watch lib --ignore 'lib/**/*.map' src/serve.ts",
"dev": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
"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)",
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
"dist": "yarn dist:clean && yarn dist:copy",
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp openapi.json dist",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api",
"watch": "tsc -w",
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
"regen-types": "cd ../supabase && make ENV=prod regen-types",
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev"
},
"engines": {
"node": ">=20.0.0"
@@ -44,29 +46,32 @@
"colors": "1.4.0",
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",
"express": "5.0.0",
"firebase-admin": "13.5.0",
"gcp-metadata": "6.1.0",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.21",
"openapi-types": "12.1.3",
"pg-promise": "11.4.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",
"ws": "8.17.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "3.21.4"
"web-push": "3.6.7",
"ws": "8.17.1",
"zod": "3.22.3"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@types/swagger-ui-express": "4.1.8",
"@types/web-push": "3.6.4",
"@types/ws": "8.5.10"
}
}

View File

@@ -12,6 +12,7 @@ import {blockUser, unblockUser} from './block-user'
import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {setCompatibilityAnswer} from './set-compatibility-answer'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
@@ -19,7 +20,6 @@ import {getLikesAndShips} from './get-likes-and-ships'
import {getProfileAnswers} from './get-profile-answers'
import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
import {getDisplayUser, getUser} from './get-user'
import {getMe} from './get-me'
import {hasFreeLike} from './has-free-like'
import {health} from './health'
@@ -40,7 +40,7 @@ import {getCurrentPrivateUser} from './get-current-private-user'
import {createPrivateUserMessage} from './create-private-user-message'
import {
getChannelMemberships,
getChannelMessages,
getChannelMessagesEndpoint,
getLastSeenChannelTime,
setChannelLastSeenTime,
} from 'api/get-private-messages'
@@ -50,10 +50,28 @@ import {leavePrivateUserMessageChannel} from './leave-private-user-message-chann
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {getNotifications} from './get-notifications'
import {updateNotifSettings} from './update-notif-setting'
import {setLastOnlineTime} from './set-last-online-time'
import swaggerUi from "swagger-ui-express"
import * as fs from "fs"
import {sendSearchNotifications} from "api/send-search-notifications";
import {sendDiscordMessage} from "common/discord/core";
import {getMessagesCount} from "api/get-messages-count";
import {createVote} from "api/create-vote";
import {vote} from "api/vote";
import {contact} from "api/contact";
import {saveSubscription} from "api/save-subscription";
import {createBookmarkedSearch} from './create-bookmarked-search'
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
import {OpenAPIV3} from 'openapi-types';
import {version as pkgVersion} from './../package.json'
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from "zod";
import {getUser} from "api/get-user";
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
// methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
// allowedHeaders: ['Content-Type', 'Authorization'],
// credentials: true, // if you use cookies or auth headers
// };
const allowCorsUnrestricted: RequestHandler = cors({})
function cacheController(policy?: string): RequestHandler {
@@ -101,33 +119,200 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
export const app = express()
app.use(requestMonitoring)
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
swaggerDocument.info = {
...swaggerDocument.info,
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Its made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
version: "1.0.0",
contact: {
name: "Compass",
email: "compass.meet.info@gmail.com",
url: "https://compassmeet.com"
const schemaCache = new WeakMap<ZodTypeAny, any>();
export function zodToOpenApiSchema(
zodObj: ZodTypeAny,
nameHint?: string
): any { // Prevent infinite recursion
if (schemaCache.has(zodObj)) {
return schemaCache.get(zodObj);
}
};
const def: any = (zodObj as any)._def;
const typeName = def.typeName as ZodFirstPartyTypeKind;
// Placeholder so recursive references can point here
const placeholder: any = {};
schemaCache.set(zodObj, placeholder);
let schema: any;
switch (typeName) {
case 'ZodString':
schema = { type: 'string' };
break;
case 'ZodNumber':
schema = { type: 'number' };
break;
case 'ZodBoolean':
schema = { type: 'boolean' };
break;
case 'ZodEnum':
schema = { type: 'string', enum: def.values };
break;
case 'ZodArray':
schema = { type: 'array', items: zodToOpenApiSchema(def.type) };
break;
case 'ZodObject': {
const shape = def.shape();
const properties: Record<string, any> = {};
const required: string[] = [];
for (const key in shape) {
const child = shape[key];
properties[key] = zodToOpenApiSchema(child, key);
if (!child.isOptional()) required.push(key);
}
schema = {
type: 'object',
properties,
...(required.length ? { required } : {}),
};
break;
}
case 'ZodRecord':
schema = {
type: 'object',
additionalProperties: zodToOpenApiSchema(def.valueType),
};
break;
case 'ZodIntersection': {
const left = zodToOpenApiSchema(def.left);
const right = zodToOpenApiSchema(def.right);
schema = { allOf: [left, right] };
break;
}
case 'ZodLazy':
// Recursive schema: use a $ref placeholder name
schema = {
$ref: `#/components/schemas/${nameHint ?? 'RecursiveType'}`,
};
break;
case 'ZodUnion':
schema = {
oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)),
};
break;
default:
schema = { type: 'string' }; // fallback for unhandled
}
Object.assign(placeholder, schema);
return schema;
}
function generateSwaggerPaths(api: typeof API) {
const paths: Record<string, any> = {};
for (const [route, config] of Object.entries(api)) {
const pathKey = '/' + route.replace(/_/g, '-'); // optional: convert underscores to dashes
const method = config.method.toLowerCase();
const summary = (config as any).summary ?? route;
// Include props in request body for POST/PUT
const operation: any = {
summary,
tags: [(config as any).tag ?? 'API'],
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {type: 'object'}, // could be improved by introspecting returns
},
},
},
},
};
// Include props in request body for POST/PUT
if (config.props && ['post', 'put', 'patch'].includes(method)) {
operation.requestBody = {
required: true,
content: {
'application/json': {
schema: zodToOpenApiSchema(config.props),
},
},
};
}
// Include props as query parameters for GET/DELETE
if (config.props && ['get', 'delete'].includes(method)) {
const shape = (config.props as z.ZodObject<any>)._def.shape();
operation.parameters = Object.entries(shape).map(([key, zodType]) => {
const typeMap: Record<string, string> = {
ZodString: 'string',
ZodNumber: 'number',
ZodBoolean: 'boolean',
};
const t = zodType as z.ZodTypeAny; // assert type to ZodTypeAny
return {
name: key,
in: 'query',
required: !(t.isOptional ?? false),
schema: {type: typeMap[t._def.typeName] ?? 'string'},
};
});
}
paths[pathKey] = {
[method]: operation,
}
if (config.authed) {
operation.security = [{BearerAuth: []}];
}
}
return paths;
}
const swaggerDocument: OpenAPIV3.Document = {
openapi: "3.0.0",
info: {
title: "Compass API",
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Its made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
version: pkgVersion,
contact: {
name: "Compass",
email: "hello@compassmeet.com",
url: "https://compassmeet.com"
}
},
paths: generateSwaggerPaths(API),
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
}
} as OpenAPIV3.Document;
const rootPath = pathWithPrefix("/")
app.get(rootPath, swaggerUi.setup(swaggerDocument))
app.use(rootPath, swaggerUi.serve)
app.options('*', allowCorsUnrestricted)
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
// May not be necessary
// app.options('*', allowCorsUnrestricted)
const handlers: { [k in APIPath]: APIHandler<k> } = {
health: health,
'get-supabase-token': getSupabaseToken,
'get-notifications': getNotifications,
'mark-all-notifs-read': markAllNotifsRead,
'user/:username': getUser,
'user/:username/lite': getDisplayUser,
// 'user/:username': getUser,
// 'user/:username/lite': getDisplayUser,
'user/by-id/:id': getUser,
'user/by-id/:id/lite': getDisplayUser,
// 'user/by-id/:id/lite': getDisplayUser,
'user/by-id/:id/block': blockUser,
'user/by-id/:id/unblock': unblockUser,
'search-users': searchUsers,
@@ -153,6 +338,10 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'create-comment': createComment,
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'set-compatibility-answer': setCompatibilityAnswer,
'create-vote': createVote,
'vote': vote,
'contact': contact,
'compatible-profiles': getCompatibleProfilesHandler,
'search-location': searchLocation,
'search-near-city': searchNearCity,
@@ -161,9 +350,14 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'update-private-user-message-channel': updatePrivateUserMessageChannel,
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
'get-channel-memberships': getChannelMemberships,
'get-channel-messages': getChannelMessages,
'get-channel-messages': getChannelMessagesEndpoint,
'get-channel-seen-time': getLastSeenChannelTime,
'set-channel-seen-time': setChannelLastSeenTime,
'get-messages-count': getMessagesCount,
'set-last-online-time': setLastOnlineTime,
'save-subscription': saveSubscription,
'create-bookmarked-search': createBookmarkedSearch,
'delete-bookmarked-search': deleteBookmarkedSearch,
}
Object.entries(handlers).forEach(([path, handler]) => {
@@ -191,8 +385,6 @@ Object.entries(handlers).forEach(([path, handler]) => {
}
})
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
// Internal Endpoints
app.post(pathWithPrefix("/internal/send-search-notifications"),
async (req, res) => {
@@ -206,6 +398,10 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
return res.status(200).json(result)
} catch (err) {
console.error("Failed to send notifications:", err);
await sendDiscordMessage(
"Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...",
"health"
)
return res.status(500).json({error: "Internal server error"});
}
}

View File

@@ -1,11 +1,11 @@
import { groupBy, sortBy } from 'lodash'
import { APIError, type APIHandler } from 'api/helpers/endpoint'
import { getCompatibilityScore } from 'common/love/compatibility-score'
import { getCompatibilityScore } from 'common/profiles/compatibility-score'
import {
getProfile,
getCompatibilityAnswers,
getGenderCompatibleProfiles,
} from 'shared/love/supabase'
} from 'shared/profiles/supabase'
import { log } from 'shared/utils'
export const getCompatibleProfilesHandler: APIHandler<

View File

@@ -0,0 +1,41 @@
import {APIError, APIHandler} from './helpers/endpoint'
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";
// 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
) => {
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')
const continuation = async () => {
try {
const md = jsonToMarkdown(content)
const message: string = `**New Contact Message**\n${md}`
await sendDiscordMessage(message, 'contact')
} catch (e) {
console.error('Failed to send discord contact', e)
}
}
return {
success: true,
result: {},
continue: continuation,
}
}

View File

@@ -0,0 +1,23 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = async (
props,
auth
) => {
const creator_id = auth.uid
const {search_filters, location = null, search_name = null} = props
const pg = createSupabaseDirectClient()
const inserted = await pg.one(
`
INSERT INTO bookmarked_searches (creator_id, search_filters, location, search_name)
VALUES ($1, $2, $3, $4)
RETURNING *
`,
[creator_id, search_filters, location, search_name]
)
return inserted
}

View File

@@ -13,7 +13,7 @@ export const createCompatibilityQuestion: APIHandler<
const pg = createSupabaseDirectClient()
const { data, error } = await tryCatch(
insert(pg, 'love_questions', {
insert(pg, 'compatibility_prompts', {
creator_id: creator.id,
question,
answer_type: 'compatibility_multiple_choice',

View File

@@ -0,0 +1,76 @@
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {Notification} from 'common/notifications'
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
import {tryCatch} from "common/util/try-catch";
import {Row} from "common/supabase/utils";
export const createShareNotifications = async () => {
const createdTime = Date.now();
const id = `share-${createdTime}`
const notification: Notification = {
id,
userId: 'todo',
createdTime: createdTime,
isSeen: false,
sourceType: 'info',
sourceUpdateType: 'created',
sourceSlug: '/contact',
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%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.',
}
return await createNotifications(notification)
}
export const createVoteNotifications = async () => {
const createdTime = Date.now();
const id = `vote-${createdTime}`
const notification: Notification = {
id,
userId: 'todo',
createdTime: createdTime,
isSeen: false,
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',
title: 'New Proposals & Votes Page',
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')
)
if (error) {
console.error('Error fetching users', error)
return
}
if (!users) {
console.error('No users found')
return
}
for (const user of users) {
try {
await createNotification(user, notification, pg)
} catch (e) {
console.error('Failed to create notification', e, user)
}
}
return {
success: true,
}
}
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)
}

View File

@@ -2,7 +2,7 @@ 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/junk-drawer/private-messages'
import { addUsersToPrivateMessageChannel } from 'api/helpers/private-messages'
import { getPrivateUser, getUser } from 'shared/utils'
export const createPrivateUserMessageChannel: APIHandler<

View File

@@ -1,23 +1,25 @@
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 { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
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 {createPrivateUserMessageMain} from 'api/helpers/private-messages'
export const createPrivateUserMessage: APIHandler<
'create-private-user-message'
> = async (body, auth) => {
const { content, channelId } = body
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}`
)
}
const pg = createSupabaseDirectClient()
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')
const pg = createSupabaseDirectClient()
return await createPrivateUserMessageMain(
creator,
channelId,

View File

@@ -2,11 +2,13 @@ import { APIError, APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { log, getUser } from 'shared/utils'
import { HOUR_MS } from 'common/util/time'
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
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";
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -28,7 +30,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
}
console.log('body', body)
console.debug('body', body)
const { data, error } = await tryCatch(
insert(pg, 'profiles', { user_id: auth.uid, ...body })
@@ -40,7 +42,51 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
}
log('Created user', data)
await track(user.id, 'create profile', { username: user.username })
return data
const continuation = async () => {
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.error('Failed to track create profile', e)
}
try {
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
if (body.bio) {
const bioText = jsonToMarkdown(body.bio)
if (bioText) message += `\n${bioText}`
}
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
)
}
console.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: data,
continue: continuation,
}
}

View File

@@ -1,27 +1,27 @@
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 { getStorage } from 'firebase-admin/storage'
import { DEV_CONFIG } from 'common/envs/dev'
import { PROD_CONFIG } from 'common/envs/prod'
import { RESERVED_PATHS } from 'common/envs/constants'
import { log, isProd, getUser, getUserByUsername } from 'shared/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { insert } from 'shared/supabase/utils'
import { convertPrivateUser, convertUser } from 'common/supabase/users'
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 {IS_LOCAL, RESERVED_PATHS} from 'common/envs/constants'
import {getUser, getUserByUsername, log} from 'shared/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
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";
export const createUser: APIHandler<'create-user'> = async (
props,
auth,
req
) => {
const { deviceToken: preDeviceToken } = props
const {deviceToken: preDeviceToken} = props
const firebaseUser = await admin.auth().getUser(auth.uid)
const testUserAKAEmailPasswordUser =
@@ -52,7 +52,7 @@ export const createUser: APIHandler<'create-user'> = async (
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
const name = cleanDisplayName(rawName)
const bucket = getStorage().bucket(getStorageBucketId())
const bucket = getBucket()
const avatarUrl = fbUser.photoURL
? fbUser.photoURL
: await generateAvatarUrl(auth.uid, name, bucket)
@@ -63,7 +63,9 @@ export const createUser: APIHandler<'create-user'> = async (
// Check username case-insensitive
const dupes = await pg.one<number>(
`select count(*) from users where username ilike $1`,
`select count(*)
from users
where username ilike $1`,
[username],
(r) => r.count
)
@@ -71,7 +73,7 @@ export const createUser: APIHandler<'create-user'> = async (
const isReservedName = RESERVED_PATHS.includes(username)
if (usernameExists || isReservedName) username += randomString(4)
const { user, privateUser } = await pg.tx(async (tx) => {
const {user, privateUser} = await pg.tx(async (tx) => {
const preexistingUser = await getUser(auth.uid, tx)
if (preexistingUser)
throw new APIError(403, 'User already exists', {
@@ -81,13 +83,13 @@ export const createUser: APIHandler<'create-user'> = async (
// Check exact username to avoid problems with duplicate requests
const sameNameUser = await getUserByUsername(username, tx)
if (sameNameUser)
throw new APIError(403, 'Username already taken', { username })
throw new APIError(403, 'Username already taken', {username})
const user = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip))
(ip && bannedIpAddresses.includes(ip))
),
link: {},
})
@@ -120,10 +122,24 @@ export const createUser: APIHandler<'create-user'> = async (
}
})
log('created user ', { username: user.username, firebaseId: auth.uid })
log('created user ', {username: user.username, firebaseId: auth.uid})
const continuation = async () => {
await track(auth.uid, 'create profile', { username: user.username })
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.error('Failed to track create profile', e)
}
try {
if (!IS_LOCAL) 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)
}
}
return {
@@ -135,12 +151,6 @@ export const createUser: APIHandler<'create-user'> = async (
}
}
function getStorageBucketId() {
return isProd()
? PROD_CONFIG.firebaseConfig.storageBucket
: DEV_CONFIG.firebaseConfig.storageBucket
}
// Automatically ban users with these device tokens or ip addresses.
const bannedDeviceTokens = [
'fa807d664415',

View File

@@ -0,0 +1,27 @@
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'
export const createVote: APIHandler<
'create-vote'
> = async ({ title, description, isAnonymous }, auth) => {
const creator = await getUser(auth.uid)
if (!creator) throw new APIError(401, 'Your account was not found')
const pg = createSupabaseDirectClient()
const { data, error } = await tryCatch(
insert(pg, 'votes', {
creator_id: creator.id,
title,
description,
is_anonymous: isAnonymous,
})
)
if (error) throw new APIError(401, 'Error creating question')
return { data }
}

View File

@@ -0,0 +1,23 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const deleteBookmarkedSearch: APIHandler<'delete-bookmarked-search'> = async (
props,
auth
) => {
const creator_id = auth.uid
const {id} = props
const pg = createSupabaseDirectClient()
// Only allow deleting your own bookmarked searches
await pg.none(
`
DELETE FROM bookmarked_searches
WHERE id = $1 AND creator_id = $2
`,
[id, creator_id]
)
return {}
}

View File

@@ -1,11 +1,11 @@
import { getUser } from 'shared/utils'
import { APIError, APIHandler } from './helpers/endpoint'
import { updatePrivateUser, updateUser } from 'shared/supabase/users'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { FieldVal } from 'shared/supabase/utils'
import {getUser} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import * as admin from "firebase-admin";
import {deleteUserFiles} from "shared/firebase-utils";
export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
const { username } = body
const {username} = body
const user = await getUser(auth.uid)
if (!user) {
throw new APIError(401, 'Your account was not found')
@@ -16,13 +16,30 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
)
}
const userId = user.id
if (!userId) {
throw new APIError(400, 'Invalid user ID')
}
// Remove user data from Supabase
const pg = createSupabaseDirectClient()
await updateUser(pg, auth.uid, {
userDeleted: true,
isBannedFromPosting: true,
})
await updatePrivateUser(pg, auth.uid, {
email: FieldVal.delete(),
})
await pg.none('DELETE FROM users WHERE id = $1', [userId])
// Should cascade delete in other tables
// await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
// await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
// await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
// await pg.none('DELETE FROM compatibility_answers WHERE creator_id = $1', [userId])
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
// Delete user files from Firebase Storage
await deleteUserFiles(user.username)
// Remove user from Firebase Auth
try {
const auth = admin.auth()
await auth.deleteUser(userId)
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
} catch (e) {
console.error('Error deleting user from Firebase Auth:', e)
}
}

View File

@@ -2,37 +2,47 @@ import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { Row } from 'common/supabase/utils'
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) => {
const pg = createSupabaseDirectClient()
const questions = await pg.manyOrNone<
Row<'love_questions'> & { answer_count: number; score: number }
Row<'compatibility_prompts'> & { answer_count: number; score: number }
>(
`SELECT
love_questions.*,
COUNT(love_compatibility_answers.question_id) as answer_count,
AVG(POWER(love_compatibility_answers.importance + 1 + CASE WHEN love_compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
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
love_questions
compatibility_prompts
LEFT JOIN
love_compatibility_answers ON love_questions.id = love_compatibility_answers.question_id
compatibility_answers ON compatibility_prompts.id = compatibility_answers.question_id
WHERE
love_questions.answer_type = 'compatibility_multiple_choice'
compatibility_prompts.answer_type = 'compatibility_multiple_choice'
GROUP BY
love_questions.id
ORDER BY
score DESC
compatibility_prompts.id
ORDER BY
compatibility_prompts.importance_score
`,
[]
)
if (false)
console.log(
'got questions',
questions.map((q) => q.question + ' ' + q.score)
)
// const questions = shuffle(dbQuestions)
// console.debug(
// 'got questions',
// questions.map((q) => q.question + ' ' + q.score)
// )
return {
status: 'success',

View File

@@ -20,10 +20,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
created_time: number
}>(
`
select target_id, love_likes.created_time
from love_likes
join profiles on profiles.user_id = love_likes.target_id
join users on users.id = love_likes.target_id
select target_id, profile_likes.created_time
from profile_likes
join profiles on profiles.user_id = profile_likes.target_id
join users on users.id = profile_likes.target_id
where creator_id = $1
and looking_for_matches
and profiles.pinned_url is not null
@@ -42,10 +42,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
created_time: number
}>(
`
select creator_id, love_likes.created_time
from love_likes
join profiles on profiles.user_id = love_likes.creator_id
join users on users.id = love_likes.creator_id
select creator_id, profile_likes.created_time
from profile_likes
join profiles on profiles.user_id = profile_likes.creator_id
join users on users.id = profile_likes.creator_id
where target_id = $1
and looking_for_matches
and profiles.pinned_url is not null
@@ -68,11 +68,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
}>(
`
select
target1_id, target2_id, creator_id, love_ships.created_time,
target1_id, target2_id, creator_id, profile_ships.created_time,
target1_id as target_id
from love_ships
join profiles on profiles.user_id = love_ships.target1_id
join users on users.id = love_ships.target1_id
from profile_ships
join profiles on profiles.user_id = profile_ships.target1_id
join users on users.id = profile_ships.target1_id
where target2_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null
@@ -81,11 +81,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
union all
select
target1_id, target2_id, creator_id, love_ships.created_time,
target1_id, target2_id, creator_id, profile_ships.created_time,
target2_id as target_id
from love_ships
join profiles on profiles.user_id = love_ships.target2_id
join users on users.id = love_ships.target2_id
from profile_ships
join profiles on profiles.user_id = profile_ships.target2_id
join users on users.id = profile_ships.target2_id
where target1_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null

View File

@@ -0,0 +1,18 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from "shared/supabase/init";
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, auth) => {
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);
return {
count: count,
}
}

View File

@@ -1,16 +1,15 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIHandler } from './helpers/endpoint'
import {
convertPrivateChatMessage,
PrivateMessageChannel,
} from 'common/supabase/private-messages'
import { groupBy, mapValues } from 'lodash'
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";
export const getChannelMemberships: APIHandler<
'get-channel-memberships'
> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const { channelId, lastUpdatedTime, createdTime, limit } = props
const {channelId, lastUpdatedTime, createdTime, limit} = props
let channels: PrivateMessageChannel[]
const convertRow = (r: any) => ({
@@ -24,55 +23,56 @@ export const getChannelMemberships: APIHandler<
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
join private_user_message_channels pumc on pumc.id = pumcm.channel_id
where user_id = $1
and channel_id = $2
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
`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: {} }
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'
`,
and channel_id in ($2:list)
and not status = 'left'
`,
[auth.uid, channelIds],
(r) => ({
channel_id: r.channel_id as number,
@@ -91,39 +91,56 @@ export const getChannelMemberships: APIHandler<
}
}
export const getChannelMessages: APIHandler<'get-channel-messages'> = async (
export const getChannelMessagesEndpoint: APIHandler<'get-channel-messages'> = async (
props,
auth
) => {
const userId = auth.uid
return await getChannelMessages({...props, userId})
}
export async function getChannelMessages(props: {
channelId: number;
limit: number;
id?: number | undefined;
userId: string;
}) {
// console.log('initial message request', props)
const {channelId, limit, id, userId} = props
const pg = createSupabaseDirectClient()
const { channelId, limit, id } = props
return await pg.map(
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 from private_user_message_channel_members pumcm
where pumcm.user_id = $2
and pumcm.channel_id = $1
)
from private_user_messages
where channel_id = $1
and exists (select 1
from private_user_message_channel_members pumcm
where pumcm.user_id = $2
and pumcm.channel_id = $1)
and ($4 is null or id > $4)
and not visibility = 'system_status'
order by created_time desc
limit $3
`,
[channelId, auth.uid, limit, id],
and not visibility = 'system_status'
order by created_time desc
limit $3
`,
[channelId, userId, limit, id],
convertPrivateChatMessage
)
))
if (error) {
console.error(error)
throw new APIError(401, 'Error getting messages')
}
// 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 {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)
where channel_id = any ($1)
and user_id = $2
order by channel_id, created_time desc
`,
@@ -137,11 +154,11 @@ export const setChannelLastSeenTime: APIHandler<
'set-channel-seen-time'
> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const { channelId } = props
const {channelId} = props
await pg.none(
`insert into private_user_seen_message_channels (user_id, channel_id)
values ($1, $2)
`,
`insert into private_user_seen_message_channels (user_id, channel_id)
values ($1, $2)
`,
[auth.uid, channelId]
)
}

View File

@@ -9,8 +9,8 @@ export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
const { userId } = props
const pg = createSupabaseDirectClient()
const answers = await pg.manyOrNone<Row<'love_compatibility_answers'>>(
`select * from love_compatibility_answers
const answers = await pg.manyOrNone<Row<'compatibility_answers'>>(
`select * from compatibility_answers
where
creator_id = $1
order by created_time desc

View File

@@ -1,10 +1,10 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {convertRow} from 'shared/love/supabase'
import {convertRow} from 'shared/profiles/supabase'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {getCompatibleProfiles} from 'api/compatible-profiles'
import {intersection} from 'lodash'
import {MAX_INT, MIN_INT} from "common/constants";
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
export type profileQueryType = {
limit?: number | undefined,
@@ -12,43 +12,67 @@ export type profileQueryType = {
// 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,
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,
}
const userActivityColumns = ['last_online_time']
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
console.log(props)
console.debug(props)
const {
limit: limitParam,
after,
name,
genders,
education_levels,
pref_gender,
pref_age_min,
pref_age_max,
drinks_min,
drinks_max,
pref_relation_styles,
pref_romantic_styles,
diet,
political_beliefs,
wants_kids_strength,
has_kids,
is_smoker,
shortBio,
geodbCityIds,
lat,
lon,
radius,
compatibleWithUserId,
orderBy: orderByParam = 'created_time',
lastModificationWithin,
skipId,
} = props
const filterLocation = lat && lon && radius
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
// console.debug('keywords:', keywords)
@@ -64,13 +88,24 @@ export const loadProfiles = async (props: profileQueryType) => {
(l) =>
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
(!genders || genders.includes(l.gender)) &&
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
(!drinks_min || (l.drinks_per_month ?? MAX_INT) >= drinks_min) &&
(!drinks_max || (l.drinks_per_month ?? MIN_INT) <= drinks_max) &&
(!pref_relation_styles ||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
(!pref_romantic_styles ||
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
(!diet ||
intersection(diet, l.diet).length) &&
(!political_beliefs ||
intersection(political_beliefs, l.political_beliefs).length) &&
(!wants_kids_strength ||
wants_kids_strength == -1 ||
!l.wants_kids_strength ||
l.wants_kids_strength == -1 ||
(wants_kids_strength >= 2
? l.wants_kids_strength >= wants_kids_strength
: l.wants_kids_strength <= wants_kids_strength)) &&
@@ -78,26 +113,37 @@ export const loadProfiles = async (props: profileQueryType) => {
has_kids == -1 ||
(has_kids == 0 && !l.has_kids) ||
(l.has_kids && l.has_kids > 0)) &&
(!is_smoker || l.is_smoker === is_smoker) &&
(is_smoker === undefined || l.is_smoker === is_smoker) &&
(l.id.toString() != skipId) &&
(!geodbCityIds ||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
(!filterLocation ||(
l.city_latitude && l.city_longitude &&
Math.abs(l.city_latitude - lat) < radius / 69.0 &&
Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) &&
Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2)
)) &&
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
)
const cursor = after
? profiles.findIndex((l) => l.id.toString() === after) + 1
: 0
console.log(cursor)
console.debug(cursor)
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
return profiles
}
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
const query = renderSql(
select('profiles.*, name, username, users.data as user'),
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
from('profiles'),
join('users on users.id = profiles.user_id'),
leftJoin(userActivityJoin),
where('looking_for_matches = true'),
// where(`pinned_url is not null and pinned_url != ''`),
where(
@@ -106,14 +152,16 @@ export const loadProfiles = async (props: profileQueryType) => {
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)) || '%'`,
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
{word}
)),
genders?.length && where(`gender = ANY($(gender))`, {gender: genders}),
genders?.length && where(`gender = ANY($(genders))`, {genders}),
education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}),
pref_gender?.length &&
where(`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}),
@@ -121,44 +169,90 @@ export const loadProfiles = async (props: profileQueryType) => {
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}),
drinks_max &&
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_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 }
{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}
),
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}
),
!!wants_kids_strength &&
wants_kids_strength !== -1 &&
where(
wants_kids_strength >= 2
? `wants_kids_strength >= $(wants_kids_strength)`
: `wants_kids_strength <= $(wants_kids_strength)`,
'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 && where(`is_smoker = $(is_smoker)`, {is_smoker}),
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}
)
),
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
skipId && where(`user_id != $(skipId)`, {skipId}),
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
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)))))
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
AND SQRT(
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}),
orderBy(`${orderByParam} desc`),
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
orderBy(`${tablePrefix}.${orderByParam} DESC`),
after &&
where(
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
`${tablePrefix}.${orderByParam} < (
SELECT ${tablePrefix}.${orderByParam}
FROM profiles
LEFT JOIN ${userActivityJoin}
WHERE profiles.id = $(after)
)`,
{after}
),
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
limitParam && limit(limitParam)
)
// console.log('query:', query)
// console.debug('query:', query)
return await pg.map(query, [], convertRow)
}

View File

@@ -1,8 +1,6 @@
import { sign } from 'jsonwebtoken'
import { APIError, APIHandler } from './helpers/endpoint'
import { DEV_CONFIG } from 'common/envs/dev'
import { PROD_CONFIG } from 'common/envs/prod'
import { isProd } from 'shared/utils'
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 (
_,
@@ -12,21 +10,17 @@ export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
if (jwtSecret == null) {
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")
}
const instanceId = isProd()
? PROD_CONFIG.supabaseInstanceId
: DEV_CONFIG.supabaseInstanceId
const instanceId = ENV_CONFIG.supabaseInstanceId
if (!instanceId) {
throw new APIError(500, 'No Supabase instance ID in config.')
}
const payload = { role: 'anon' } // postgres role
const payload = {role: 'anon'} // postgres role
return {
jwt: sign(payload, jwtSecret, {
algorithm: 'HS256', // same as what supabase uses for its auth tokens
expiresIn: '1d',
audience: instanceId,
issuer: isProd()
? PROD_CONFIG.firebaseConfig.projectId
: DEV_CONFIG.firebaseConfig.projectId,
issuer: ENV_CONFIG.firebaseConfig.projectId,
subject: auth.uid,
}),
}

View File

@@ -17,17 +17,18 @@ export const getUser = async (props: { id: string } | { username: string }) => {
return toUserAPIResponse(user)
}
export const getDisplayUser = async (
props: { id: string } | { username: string }
) => {
const pg = createSupabaseDirectClient()
const liteUser = await pg.oneOrNone(
`select ${displayUserColumns}
from users
where ${'id' in props ? 'id' : 'username'} = $1`,
['id' in props ? props.id : props.username]
)
if (!liteUser) throw new APIError(404, 'User not found')
return removeNullOrUndefinedProps(liteUser)
}
// export const getDisplayUser = async (
// props: { id: string } | { username: string }
// ) => {
// console.log('getDisplayUser', props)
// const pg = createSupabaseDirectClient()
// const liteUser = await pg.oneOrNone(
// `select ${displayUserColumns}
// from users
// where ${'id' in props ? 'id' : 'username'} = $1`,
// ['id' in props ? props.id : props.username]
// )
// if (!liteUser) throw new APIError(404, 'User not found')
//
// return removeNullOrUndefinedProps(liteUser)
// }

View File

@@ -17,7 +17,7 @@ export const getHasFreeLike = async (userId: string) => {
const likeGivenToday = await pg.oneOrNone<object>(
`
select 1
from love_likes
from profile_likes
where creator_id = $1
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
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')

View File

@@ -1,35 +1,29 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'
import {z} from 'zod'
import {NextFunction, Request, Response} from 'express'
import { PrivateUser } from 'common/user'
import { APIError } from 'common/api/utils'
export { APIError } from 'common/api/utils'
import {
API,
APIPath,
APIResponseOptionalContinue,
APISchema,
ValidatedAPIParams,
} from 'common/api/schema'
import { log } from 'shared/utils'
import { getPrivateUserByKey } from 'shared/utils'
import {PrivateUser} from 'common/user'
import {APIError} from 'common/api/utils'
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
import {getPrivateUserByKey, log} from 'shared/utils'
export type Json = Record<string, unknown> | Json[]
export type JsonHandler<T extends Json> = (
req: Request,
res: Response
) => Promise<T>
export type AuthedHandler<T extends Json> = (
req: Request,
user: AuthedUser,
res: Response
) => Promise<T>
export type MaybeAuthedHandler<T extends Json> = (
req: Request,
user: AuthedUser | undefined,
res: Response
) => Promise<T>
export {APIError} from 'common/api/utils'
// export type Json = Record<string, unknown> | Json[]
// export type JsonHandler<T extends Json> = (
// req: Request,
// res: Response
// ) => Promise<T>
// export type AuthedHandler<T extends Json> = (
// req: Request,
// user: AuthedUser,
// res: Response
// ) => Promise<T>
// export type MaybeAuthedHandler<T extends Json> = (
// req: Request,
// user: AuthedUser | undefined,
// res: Response
// ) => Promise<T>
export type AuthedUser = {
uid: string
@@ -39,6 +33,29 @@ 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> {
// TODO: make local dev work without firebase admin SDK setup.
// if (IS_LOCAL) {
// // Skip real verification locally (to avoid needing to set up admin service account).
// return {
// aud: "",
// auth_time: 0,
// email_verified: false,
// exp: 0,
// firebase: {identities: {}, sign_in_provider: ""},
// iat: 0,
// iss: "",
// phone_number: "",
// picture: "",
// sub: "",
// uid: 'dev-user',
// user_id: 'dev-user',
// email: 'dev-user@example.com'
// };
// }
// return await admin.auth().verifyIdToken(payload);
// }
export const parseCredentials = async (req: Request): Promise<Credentials> => {
const auth = admin.auth()
const authHeader = req.get('Authorization')
@@ -57,14 +74,14 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
throw new APIError(401, 'Firebase JWT payload undefined.')
}
try {
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
return {kind: 'jwt', data: await auth.verifyIdToken(payload)}
} catch (err) {
// 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.')
}
case 'Key':
return { kind: 'key', data: payload }
return {kind: 'key', data: payload}
default:
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
}
@@ -76,7 +93,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
if (typeof creds.data.user_id !== 'string') {
throw new APIError(401, 'JWT must contain user ID.')
}
return { uid: creds.data.user_id, creds }
return {uid: creds.data.user_id, creds}
}
case 'key': {
const key = creds.data
@@ -84,7 +101,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
if (!privateUser) {
throw new APIError(401, `No private user exists with API key ${key}.`)
}
return { uid: privateUser.id, creds: { privateUser, ...creds } }
return {uid: privateUser.id, creds: {privateUser, ...creds}}
}
default:
throw new APIError(401, 'Invalid credential type.')
@@ -109,45 +126,45 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
}
}
export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
res.status(200).json(await fn(req, res))
} catch (e) {
next(e)
}
}
}
export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const authedUser = await lookupUser(await parseCredentials(req))
res.status(200).json(await fn(req, authedUser, res))
} catch (e) {
next(e)
}
}
}
export const MaybeAuthedEndpoint = <T extends Json>(
fn: MaybeAuthedHandler<T>
) => {
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
try {
authUser = await lookupUser(await parseCredentials(req))
} catch {
// it's treated as an anon request
}
try {
res.status(200).json(await fn(req, authUser, res))
} catch (e) {
next(e)
}
}
}
// export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// try {
// res.status(200).json(await fn(req, res))
// } catch (e) {
// next(e)
// }
// }
// }
//
// export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// try {
// const authedUser = await lookupUser(await parseCredentials(req))
// res.status(200).json(await fn(req, authedUser, res))
// } catch (e) {
// next(e)
// }
// }
// }
//
// export const MaybeAuthedEndpoint = <T extends Json>(
// fn: MaybeAuthedHandler<T>
// ) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// let authUser: AuthedUser | undefined = undefined
// try {
// authUser = await lookupUser(await parseCredentials(req))
// } catch {
// // it's treated as an anon request
// }
//
// try {
// res.status(200).json(await fn(req, authUser, res))
// } catch (e) {
// next(e)
// }
// }
// }
export type APIHandler<N extends APIPath> = (
props: ValidatedAPIParams<N>,
@@ -157,11 +174,63 @@ export type APIHandler<N extends APIPath> = (
req: Request
) => Promise<APIResponseOptionalContinue<N>>
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
// Not suitable for multi-instance deployments without a shared store, but provides basic protection.
// Limits are configurable via env:
// 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()
function getRateLimitConfig() {
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
const unAuthed = Number(process.env.API_RATE_LIMIT_PER_MIN_UNAUTHED ?? 120)
return {authedLimit: authed, unAuthLimit: unAuthed}
}
function rateLimitKey(name: string, req: Request, auth?: AuthedUser) {
if (auth) return `uid:${auth.uid}`
// fallback to IP for unauthenticated requests
return `ip:${req.ip}`
}
function checkRateLimit(name: string, req: Request, res: Response, auth?: AuthedUser) {
const {authedLimit, unAuthLimit} = getRateLimitConfig()
const key = rateLimitKey(name, req, auth)
const limit = auth ? authedLimit : unAuthLimit
const now = Date.now()
const windowMs = 60_000
const windowStart = Math.floor(now / windowMs) * windowMs
let state = __rateLimitState.get(key)
if (!state || state.windowStart !== windowStart) {
state = {windowStart, count: 0}
__rateLimitState.set(key, state)
}
state.count += 1
const remaining = Math.max(0, limit - state.count)
const reset = Math.ceil((state.windowStart + windowMs - now) / 1000)
// Set standard-ish rate limit headers
res.setHeader('X-RateLimit-Limit', String(limit))
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, remaining)))
res.setHeader('X-RateLimit-Reset', String(reset))
// console.log(`Rate limit check for ${key} on ${name}: ${state.count}/${limit} (remaining: ${remaining}, resets in ${reset}s)`)
if (state.count > limit) {
res.setHeader('Retry-After', String(reset))
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
}
}
export const typedEndpoint = <N extends APIPath>(
name: N,
handler: APIHandler<N>
) => {
const { props: propSchema, authed: authRequired, method } = API[name]
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
@@ -171,6 +240,15 @@ export const typedEndpoint = <N extends APIPath>(
if (authRequired) return next(e)
}
// Apply rate limiting before invoking the handler
if (rateLimited) {
try {
checkRateLimit(String(name), req, res, authUser)
} catch (e) {
return next(e)
}
}
const props = {
...(method === 'GET' ? req.query : req.body),
...req.params,
@@ -194,8 +272,8 @@ export const typedEndpoint = <N extends APIPath>(
if (!res.headersSent) {
// Convert bigint to number, b/c JSON doesn't support bigint.
const convertedResult = deepConvertBigIntToNumber(result)
res.status(200).json(convertedResult ?? { success: true })
// console.debug('API result', convertedResult)
res.status(200).json(convertedResult ?? {success: true})
}
if (hasContinue) {

View File

@@ -0,0 +1,260 @@
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 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";
dayjs.extend(utc)
dayjs.extend(timezone)
export const leaveChatContent = (userName: string) => ({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{text: `${userName} left the chat`, type: 'text'}],
},
],
})
export const joinChatContent = (userName: string) => {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{text: `${userName} joined the chat!`, type: 'text'}],
},
],
}
}
export const insertPrivateMessage = async (
content: Json,
channelId: number,
userId: string,
visibility: ChatVisibility,
pg: SupabaseDirectClient
) => {
const plaintext = JSON.stringify(content);
const {ciphertext, iv, tag} = encryptMessage(plaintext);
const lastMessage = await pg.one(
`insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility)
values ($1, $2, $3, $4, $5, $6)
returning created_time`,
[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]
)
}
export const addUsersToPrivateMessageChannel = async (
userIds: string[],
channelId: number,
pg: SupabaseDirectClient
) => {
await Promise.all(
userIds.map((id) =>
pg.none(
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
values ($1, $2, 'member', 'proposed')
on conflict do nothing
`,
[channelId, id]
)
)
)
await pg.none(
`update private_user_message_channels
set last_updated_time = now()
where id = $1`,
[channelId]
)
}
export const createPrivateUserMessageMain = async (
creator: User,
channelId: number,
content: JSONContent,
pg: SupabaseDirectClient,
visibility: ChatVisibility
) => {
log('createPrivateUserMessageMain', creator, channelId, content)
// Normally, users can only submit messages to channels that they are members of
const authorized = await pg.oneOrNone(
`select 1
from private_user_message_channel_members
where channel_id = $1
and user_id = $2`,
[channelId, creator.id]
)
if (!authorized)
throw new APIError(403, 'You are not authorized to post to this channel')
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
const privateMessage = {
content: content as Json,
channel_id: channelId,
user_id: creator.id,
}
const otherUserIds = await pg.map<string>(
`select user_id
from private_user_message_channel_members
where channel_id = $1
and user_id != $2
and status != 'left'
`,
[channelId, creator.id],
(r) => r.user_id
)
otherUserIds.concat(creator.id).forEach((otherUserId) => {
broadcast(`private-user-messages/${otherUserId}`, {})
})
// Fire and forget safely
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
.catch((err) => {
console.error('notifyOtherUserInChannelIfInactive failed', err)
});
track(creator.id, 'send private message', {
channelId,
otherUserIds,
})
return privateMessage
}
const notifyOtherUserInChannelIfInactive = async (
channelId: number,
creator: User,
content: JSONContent,
pg: SupabaseDirectClient
) => {
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]
)
// We're only sending notifs for 1:1 channels
if (!otherUserIds || otherUserIds.length > 1) return
const otherUserId = first(otherUserIds)
if (!otherUserId) return
// TODO: notification only for active user
const otherUser = await getUser(otherUserId.user_id)
console.debug('otherUser:', otherUser)
if (!otherUser) return
// Push notif
webPush.setVapidDetails(
'mailto:hello@compassmeet.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
const textContent = parseJsonContentToText(content)
// Retrieve subscription from the database
const subscriptions = await getSubscriptionsFromDB(otherUser.id, pg);
for (const subscription of subscriptions) {
try {
const payload = JSON.stringify({
title: `${creator.name}`,
body: textContent,
url: `/messages/${channelId}`,
})
console.log('Sending notification to:', subscription.endpoint, payload);
await webPush.sendNotification(subscription, payload);
} catch (err: any) {
console.log('Failed to send notification', err);
if (err.statusCode === 410 || err.statusCode === 404) {
console.warn('Removing expired subscription', subscription.endpoint);
await pg.none(
`DELETE
FROM push_subscriptions
WHERE endpoint = $1
AND user_id = $2`,
[subscription.endpoint, otherUser.id]
);
} else {
console.error('Push failed', err);
}
}
}
const startOfDay = dayjs()
.tz('America/Los_Angeles')
.startOf('day')
.toISOString()
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
`select count(*)
from private_user_messages
where channel_id = $1
and user_id = $2
and created_time > $3
`,
[channelId, creator.id, startOfDay]
)
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
if (previousMessagesThisDayBetweenTheseUsers.count > 0) return
await createNewMessageNotification(creator, otherUser, channelId)
}
const createNewMessageNotification = async (
fromUser: User,
toUser: User,
channelId: number,
) => {
const privateUser = await getPrivateUser(toUser.id)
console.debug('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}
export async function getSubscriptionsFromDB(
userId: string,
pg: SupabaseDirectClient
) {
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 [];
}
}

View File

@@ -1,181 +0,0 @@
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 dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
export const leaveChatContent = (userName: string) => ({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ text: `${userName} left the chat`, type: 'text' }],
},
],
})
export const joinChatContent = (userName: string) => {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ text: `${userName} joined the chat!`, type: 'text' }],
},
],
}
}
export const insertPrivateMessage = async (
content: Json,
channelId: number,
userId: string,
visibility: ChatVisibility,
pg: SupabaseDirectClient
) => {
const lastMessage = await pg.one(
`insert into private_user_messages (content, channel_id, user_id, visibility)
values ($1, $2, $3, $4) returning created_time`,
[content, channelId, userId, visibility]
)
await pg.none(
`update private_user_message_channels set last_updated_time = $1 where id = $2`,
[lastMessage.created_time, channelId]
)
}
export const addUsersToPrivateMessageChannel = async (
userIds: string[],
channelId: number,
pg: SupabaseDirectClient
) => {
await Promise.all(
userIds.map((id) =>
pg.none(
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
values
($1, $2, 'member', 'proposed')
on conflict do nothing
`,
[channelId, id]
)
)
)
await pg.none(
`update private_user_message_channels set last_updated_time = now() where id = $1`,
[channelId]
)
}
export const createPrivateUserMessageMain = async (
creator: User,
channelId: number,
content: JSONContent,
pg: SupabaseDirectClient,
visibility: ChatVisibility
) => {
log('createPrivateUserMessageMain', creator, channelId, content)
// Normally, users can only submit messages to channels that they are members of
const authorized = await pg.oneOrNone(
`select 1
from private_user_message_channel_members
where channel_id = $1
and user_id = $2`,
[channelId, creator.id]
)
if (!authorized)
throw new APIError(403, 'You are not authorized to post to this channel')
await notifyOtherUserInChannelIfInactive(channelId, creator, pg)
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
const privateMessage = {
content: content as Json,
channel_id: channelId,
user_id: creator.id,
}
const otherUserIds = await pg.map<string>(
`select user_id from private_user_message_channel_members
where channel_id = $1 and user_id != $2
and status != 'left'
`,
[channelId, creator.id],
(r) => r.user_id
)
otherUserIds.concat(creator.id).forEach((otherUserId) => {
broadcast(`private-user-messages/${otherUserId}`, {})
})
track(creator.id, 'send private message', {
channelId,
otherUserIds,
})
return privateMessage
}
const notifyOtherUserInChannelIfInactive = async (
channelId: number,
creator: User,
pg: SupabaseDirectClient
) => {
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]
)
// We're only sending notifs for 1:1 channels
if (!otherUserIds || otherUserIds.length > 1) return
const otherUserId = first(otherUserIds)
if (!otherUserId) return
const startOfDay = dayjs()
.tz('America/Los_Angeles')
.startOf('day')
.toISOString()
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
`select count(*) from private_user_messages
where channel_id = $1
and user_id = $2
and created_time > $3
`,
[channelId, creator.id, startOfDay]
)
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
if (previousMessagesThisDayBetweenTheseUsers.count > 0) return
// TODO: notification only for active user
const otherUser = await getUser(otherUserId.user_id)
console.log('otherUser:', otherUser)
if (!otherUser) return
await createNewMessageNotification(creator, otherUser, channelId)
}
const createNewMessageNotification = async (
fromUser: User,
toUser: User,
channelId: number
) => {
const privateUser = await getPrivateUser(toUser.id)
console.log('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}

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