mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-05 07:14:02 -04:00
Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
204a35d026 | ||
|
|
fb2841f198 | ||
|
|
5de055c977 | ||
|
|
084659ea3d | ||
|
|
c1a414afab | ||
|
|
a5747034d6 | ||
|
|
fda52fec97 | ||
|
|
e38ec79618 | ||
|
|
1ef125db12 | ||
|
|
b580b640bd | ||
|
|
214bddaca4 | ||
|
|
065d489869 | ||
|
|
46ffefbbb9 | ||
|
|
a19db3bca9 | ||
|
|
2c8d8d9989 | ||
|
|
d52943e31e | ||
|
|
3eababb742 | ||
|
|
8a954d3c20 | ||
|
|
8516901032 | ||
|
|
3f2d246fec | ||
|
|
58fdaa26ca | ||
|
|
7dc1a8790d | ||
|
|
70c9ec1d73 | ||
|
|
2bcbbc96ad | ||
|
|
527d36a159 | ||
|
|
2ce21247ee | ||
|
|
8ea6c406e0 | ||
|
|
e22f50ecd3 | ||
|
|
20dcd98fdf | ||
|
|
bc5708857a | ||
|
|
b9c045ebfb | ||
|
|
c69bd7018e | ||
|
|
078d149175 | ||
|
|
be9f0bd061 | ||
|
|
a4723563f5 | ||
|
|
1fdcd24f28 | ||
|
|
a43480db92 | ||
|
|
e85a072f1c | ||
|
|
bbfa2a4eab | ||
|
|
2f2db4ded8 | ||
|
|
7296a0d2cd | ||
|
|
08e02b6ac0 | ||
|
|
715811d7fd | ||
|
|
c7d6ae6995 | ||
|
|
b1d1396944 | ||
|
|
25a319710e | ||
|
|
796b13dd62 | ||
|
|
8197863ac5 | ||
|
|
89bd164d43 | ||
|
|
80d7061e5f | ||
|
|
c49bac3a09 | ||
|
|
06d53fe801 | ||
|
|
15ba529938 | ||
|
|
83054d0cd1 | ||
|
|
8da486adf2 | ||
|
|
32bc3847fa | ||
|
|
5d763c18c8 | ||
|
|
bd3920cfff | ||
|
|
06d94332b6 | ||
|
|
50614484d8 | ||
|
|
c29d3d8c92 | ||
|
|
26f46af375 | ||
|
|
32b1491dd0 | ||
|
|
51b8a6c80a | ||
|
|
0f63d6d3a0 | ||
|
|
4771b08773 | ||
|
|
9b880101fd | ||
|
|
594806d6e8 | ||
|
|
e9afd4db2f | ||
|
|
b23efe4089 | ||
|
|
e33be41a93 | ||
|
|
33b09df872 | ||
|
|
e9050d0aa0 | ||
|
|
baeb2a33fe | ||
|
|
4ad89acdc7 | ||
|
|
7d87af8f5c | ||
|
|
65c0e84e2a | ||
|
|
7b15d85871 | ||
|
|
ad8ec0f4fd | ||
|
|
2d05d83dd0 | ||
|
|
bd45066b13 | ||
|
|
8ee4274054 | ||
|
|
83a7ed4d6b | ||
|
|
07dbd86ac6 | ||
|
|
0e671d2cc0 | ||
|
|
2d6d3c04ce | ||
|
|
b0148963c7 | ||
|
|
13356950f3 | ||
|
|
629bcb30a7 | ||
|
|
03721fff1c | ||
|
|
2a6911ae3d | ||
|
|
164eddecab | ||
|
|
9eacb38eb9 | ||
|
|
20f5cfb9a7 | ||
|
|
6c6c1cc90a | ||
|
|
a32c099cc1 | ||
|
|
fe2f832e83 | ||
|
|
868746cc23 | ||
|
|
3be7a54284 | ||
|
|
635e1ec8e2 | ||
|
|
a638a35a76 | ||
|
|
8cc33d3418 | ||
|
|
9947f7b967 | ||
|
|
daf5350f41 | ||
|
|
020b9ddb8d | ||
|
|
23aff9497a | ||
|
|
3c119396f3 | ||
|
|
f7c7c47ac0 | ||
|
|
dbe2369bbe | ||
|
|
4e8033d221 | ||
|
|
97a0f87cbd | ||
|
|
bfa2713d43 | ||
|
|
fe5e109751 | ||
|
|
8cc96030b1 | ||
|
|
a2b172ad58 | ||
|
|
e756225d8b | ||
|
|
dd803b604f | ||
|
|
b5c961c8ee | ||
|
|
47cd9d227e | ||
|
|
e2be3aafcd | ||
|
|
015fe76c44 | ||
|
|
44666aec03 | ||
|
|
6a265e4f35 | ||
|
|
12c7316524 | ||
|
|
dcf9741d69 | ||
|
|
63dd1fdd50 | ||
|
|
5aa166bbfd | ||
|
|
34cbf7093e | ||
|
|
159d58949e | ||
|
|
fcf802b7e3 | ||
|
|
92ff6dadb0 | ||
|
|
05fa2f9883 | ||
|
|
71bb8fd784 | ||
|
|
16ffd6dfab | ||
|
|
2661d15910 | ||
|
|
394102bb93 | ||
|
|
3585b12dfd | ||
|
|
423d87d5f1 | ||
|
|
13b13b1104 | ||
|
|
a77e7b96b7 | ||
|
|
d7213c255c | ||
|
|
ddeb1dcdb7 | ||
|
|
221cfa3528 | ||
|
|
d6f6348ff1 | ||
|
|
0c6afdc98e | ||
|
|
02a2148b3f | ||
|
|
36a02268d8 | ||
|
|
450f07f505 | ||
|
|
777eba9fed | ||
|
|
eaa8fa57d1 | ||
|
|
200bf479e1 | ||
|
|
331f409af9 | ||
|
|
ce875a5e63 | ||
|
|
638013f835 | ||
|
|
1de87cbfec | ||
|
|
7f3428b36a | ||
|
|
35595ded47 | ||
|
|
35e9264017 | ||
|
|
02d33c8f83 | ||
|
|
f229ebc3a8 | ||
|
|
0062351f6d | ||
|
|
e86f6798ec | ||
|
|
4f53f7136b | ||
|
|
d80b982dde | ||
|
|
24788aa9af | ||
|
|
9ffae658df | ||
|
|
82ad573cac | ||
|
|
36bf7ad65b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,4 +84,5 @@ email-preview
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.terraform
|
||||
/backups/firebase/auth/data/
|
||||
/backups/firebase/storage/data/
|
||||
|
||||
33
README.md
33
README.md
@@ -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 it’s 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 don’t 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, just use your preferred option:
|
||||
- Ask or DM an admin on Discord
|
||||
- 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 don’t 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
|
||||
@@ -58,7 +75,7 @@ Everything is open to anyone for collaboration, but the following ones are parti
|
||||
- [ ] 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.)
|
||||
@@ -105,7 +122,7 @@ Almost all the features will work out of the box, so you can skip this step and
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -135,6 +152,16 @@ Note: it's normal if page loading locally is much slower than the deployed versi
|
||||
|
||||
Now you can start contributing by making changes and submitting pull requests!
|
||||
|
||||
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
||||
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||
- Console tab for errors and logs
|
||||
- Network tab to see the requests and responses
|
||||
- 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 features.
|
||||
|
||||
### Submission
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"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"
|
||||
|
||||
@@ -50,9 +50,15 @@ 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";
|
||||
|
||||
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||
|
||||
@@ -108,7 +114,7 @@ swaggerDocument.info = {
|
||||
version: "1.0.0",
|
||||
contact: {
|
||||
name: "Compass",
|
||||
email: "compass.meet.info@gmail.com",
|
||||
email: "hello@compassmeet.com",
|
||||
url: "https://compassmeet.com"
|
||||
}
|
||||
};
|
||||
@@ -153,6 +159,9 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'create-vote': createVote,
|
||||
'vote': vote,
|
||||
'contact': contact,
|
||||
'compatible-profiles': getCompatibleProfilesHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
@@ -164,6 +173,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'get-channel-messages': getChannelMessages,
|
||||
'get-channel-seen-time': getLastSeenChannelTime,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
'get-messages-count': getMessagesCount,
|
||||
'set-last-online-time': setLastOnlineTime,
|
||||
}
|
||||
|
||||
Object.entries(handlers).forEach(([path, handler]) => {
|
||||
@@ -191,7 +202,7 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
||||
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
||||
|
||||
// Internal Endpoints
|
||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
@@ -206,6 +217,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"});
|
||||
}
|
||||
}
|
||||
|
||||
41
backend/api/src/contact.ts
Normal file
41
backend/api/src/contact.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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()
|
||||
@@ -29,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 })
|
||||
@@ -46,15 +47,17 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.log('Failed to track create profile', e)
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
await sendDiscordMessage(
|
||||
`**${user.name}** just created a profile at https://www.compassmeet.com/${user.username}`,
|
||||
'members',
|
||||
)
|
||||
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.log('Failed to send discord new profile', e)
|
||||
console.error('Failed to send discord new profile', e)
|
||||
}
|
||||
try {
|
||||
const nProfiles = await pg.one<number>(
|
||||
@@ -69,7 +72,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
n % 50 === 0
|
||||
)
|
||||
}
|
||||
console.log(nProfiles, isMilestone(nProfiles))
|
||||
console.debug(nProfiles, isMilestone(nProfiles))
|
||||
if (isMilestone(nProfiles)) {
|
||||
await sendDiscordMessage(
|
||||
`We just reached **${nProfiles}** total profiles! 🎉`,
|
||||
@@ -78,7 +81,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log('Failed to send discord user milestone', e)
|
||||
console.error('Failed to send discord user milestone', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ 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 {RESERVED_PATHS} from 'common/envs/constants'
|
||||
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";
|
||||
|
||||
export const createUser: APIHandler<'create-user'> = async (
|
||||
props,
|
||||
@@ -126,7 +127,12 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.log('Failed to track create profile', 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
backend/api/src/create-vote.ts
Normal file
27
backend/api/src/create-vote.ts
Normal 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 }
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||
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 love_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
|
||||
@@ -35,7 +37,7 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
try {
|
||||
const auth = admin.auth()
|
||||
await auth.deleteUser(userId)
|
||||
console.log(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||
} catch (e) {
|
||||
console.error('Error deleting user from Firebase Auth:', e)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getCompatibilityQuestions: APIHandler<
|
||||
> = async (_props, _auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const dbQuestions = await pg.manyOrNone<
|
||||
const questions = await pg.manyOrNone<
|
||||
Row<'love_questions'> & { answer_count: number; score: number }
|
||||
>(
|
||||
`SELECT
|
||||
@@ -31,15 +31,15 @@ export const getCompatibilityQuestions: APIHandler<
|
||||
love_questions.answer_type = 'compatibility_multiple_choice'
|
||||
GROUP BY
|
||||
love_questions.id
|
||||
ORDER BY
|
||||
score DESC
|
||||
ORDER BY
|
||||
love_questions.importance_score
|
||||
`,
|
||||
[]
|
||||
)
|
||||
|
||||
const questions = shuffle(dbQuestions)
|
||||
// const questions = shuffle(dbQuestions)
|
||||
|
||||
// console.log(
|
||||
// console.debug(
|
||||
// 'got questions',
|
||||
// questions.map((q) => q.question + ' ' + q.score)
|
||||
// )
|
||||
|
||||
18
backend/api/src/get-messages-count.ts
Normal file
18
backend/api/src/get-messages-count.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {convertRow} from 'shared/love/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,
|
||||
@@ -16,20 +16,27 @@ export type profileQueryType = {
|
||||
pref_age_min?: number | undefined,
|
||||
pref_age_max?: number | undefined,
|
||||
pref_relation_styles?: String[] | undefined,
|
||||
pref_romantic_styles?: 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,
|
||||
@@ -39,16 +46,23 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
pref_age_min,
|
||||
pref_age_max,
|
||||
pref_relation_styles,
|
||||
pref_romantic_styles,
|
||||
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)
|
||||
|
||||
@@ -69,6 +83,8 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_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) &&
|
||||
(!wants_kids_strength ||
|
||||
wants_kids_strength == -1 ||
|
||||
(wants_kids_strength >= 2
|
||||
@@ -81,23 +97,34 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(!is_smoker || 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,7 +133,7 @@ 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}
|
||||
)),
|
||||
|
||||
@@ -124,7 +151,13 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
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}
|
||||
),
|
||||
|
||||
!!wants_kids_strength &&
|
||||
@@ -144,21 +177,40 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -174,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
|
||||
@@ -188,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,
|
||||
|
||||
@@ -163,7 +163,7 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
// TODO: notification only for active user
|
||||
|
||||
const otherUser = await getUser(otherUserId.user_id)
|
||||
console.log('otherUser:', otherUser)
|
||||
console.debug('otherUser:', otherUser)
|
||||
if (!otherUser) return
|
||||
|
||||
await createNewMessageNotification(creator, otherUser, channelId)
|
||||
@@ -175,7 +175,7 @@ const createNewMessageNotification = async (
|
||||
channelId: number
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
console.log('privateUser:', privateUser)
|
||||
console.debug('privateUser:', privateUser)
|
||||
if (!privateUser) return
|
||||
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {Row} from "common/supabase/utils";
|
||||
import {DOMAIN} from "common/envs/constants";
|
||||
|
||||
// abusable: people can report the wrong person, that didn't write the comment
|
||||
// but in practice we check it manually and nothing bad happens to them automatically
|
||||
@@ -33,5 +36,38 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
const continuation = async () => {
|
||||
try {
|
||||
const {data: reporter, error} = await tryCatch(
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
|
||||
)
|
||||
if (error) {
|
||||
console.error('Failed to get user for report', error)
|
||||
return
|
||||
}
|
||||
const {data: reported, error: userError} = await tryCatch(
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
|
||||
)
|
||||
if (userError) {
|
||||
console.error('Failed to get reported user for report', userError)
|
||||
return
|
||||
}
|
||||
let message: string = `
|
||||
🚨 **New Report** 🚨
|
||||
**Type:** ${contentType}
|
||||
**Content ID:** ${contentId}
|
||||
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
|
||||
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
|
||||
`
|
||||
await sendDiscordMessage(message, 'reports')
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord reports', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ import {geodbFetch} from "common/geodb";
|
||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||
const {term, limit} = body
|
||||
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
||||
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`
|
||||
return await geodbFetch(endpoint)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import {APIHandler} from './helpers/endpoint'
|
||||
import {geodbFetch} from "common/geodb";
|
||||
|
||||
const searchNearCityMain = async (cityId: string, radius: number) => {
|
||||
// Limit to 10 cities for now for free plan, was 100 before (may need to buy plan)
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=10`
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||
return await geodbFetch(endpoint)
|
||||
}
|
||||
|
||||
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||
const { cityId, radius } = body
|
||||
const {cityId, radius} = body
|
||||
return await searchNearCityMain(cityId, radius)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const sendSearchNotifications = async () => {
|
||||
from('bookmarked_searches'),
|
||||
)
|
||||
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
||||
console.log(`Running ${searches.length} bookmarked searches`)
|
||||
console.debug(`Running ${searches.length} bookmarked searches`)
|
||||
|
||||
const _users = await pg.map(
|
||||
renderSql(
|
||||
@@ -36,7 +36,7 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'users'>[]
|
||||
const users = keyBy(_users, 'id')
|
||||
console.log('users', users)
|
||||
console.debug('users', users)
|
||||
|
||||
const _privateUsers = await pg.map(
|
||||
renderSql(
|
||||
@@ -47,15 +47,21 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'private_users'>[]
|
||||
const privateUsers = keyBy(_privateUsers, 'id')
|
||||
console.log('privateUsers', privateUsers)
|
||||
console.debug('privateUsers', privateUsers)
|
||||
|
||||
const matches: MatchesByUserType = {}
|
||||
|
||||
for (const row of searches) {
|
||||
if (typeof row.search_filters !== 'object') continue;
|
||||
const props = {...row.search_filters, skipId: row.creator_id, lastModificationWithin: '24 hours'}
|
||||
const { orderBy, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||
const props = {
|
||||
...filters,
|
||||
skipId: row.creator_id,
|
||||
lastModificationWithin: '24 hours',
|
||||
shortBio: true,
|
||||
}
|
||||
const profiles = await loadProfiles(props as profileQueryType)
|
||||
console.log(profiles.map((item: any) => item.name))
|
||||
console.debug(profiles.map((item: any) => item.name))
|
||||
if (!profiles.length) continue
|
||||
if (!(row.creator_id in matches)) {
|
||||
if (!privateUsers[row.creator_id]) continue
|
||||
@@ -74,7 +80,7 @@ export const sendSearchNotifications = async () => {
|
||||
})),
|
||||
})
|
||||
}
|
||||
console.log('matches:', JSON.stringify(matches, null, 2))
|
||||
console.debug('matches:', JSON.stringify(matches, null, 2))
|
||||
await notifyBookmarkedSearch(matches)
|
||||
|
||||
return {status: 'success'}
|
||||
|
||||
22
backend/api/src/set-last-online-time.ts
Normal file
22
backend/api/src/set-last-online-time.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
if (!auth || !auth.uid) return
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none(`
|
||||
INSERT INTO user_activity (user_id, last_online_time)
|
||||
VALUES ($1, now())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE
|
||||
SET last_online_time = EXCLUDED.last_online_time
|
||||
WHERE user_activity.last_online_time < now() - interval '1 minute';
|
||||
`,
|
||||
[auth.uid]
|
||||
)
|
||||
// console.log('setLastOnline')
|
||||
}
|
||||
@@ -24,8 +24,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
|
||||
throw new APIError(404, 'Profile not found')
|
||||
}
|
||||
|
||||
!parsedBody.last_online_time &&
|
||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(parsedBody)
|
||||
if (parsedBody.avatar_url) {
|
||||
|
||||
39
backend/api/src/vote.ts
Normal file
39
backend/api/src/vote.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
|
||||
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Map string choice to smallint (-1, 0, 1)
|
||||
const choiceMap: Record<string, number> = {
|
||||
'for': 1,
|
||||
'abstain': 0,
|
||||
'against': -1,
|
||||
}
|
||||
const choiceVal = choiceMap[choice]
|
||||
if (choiceVal === undefined) {
|
||||
throw new APIError(400, 'Invalid choice')
|
||||
}
|
||||
|
||||
// Upsert the vote result to ensure one vote per user per vote
|
||||
// Assuming table vote_results with unique (user_id, vote_id)
|
||||
const query = `
|
||||
insert into vote_results (user_id, vote_id, choice, priority)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (user_id, vote_id)
|
||||
do update set choice = excluded.choice,
|
||||
priority = excluded.priority
|
||||
returning *;
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
|
||||
return { data: result }
|
||||
} catch (e) {
|
||||
throw new APIError(500, 'Error recording vote', e as any)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {PrivateUser, User} from 'common/user'
|
||||
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
||||
import {getNotificationDestinationsForUser, UNSUBSCRIBE_URL} from 'common/user-notification-preferences'
|
||||
import {sendEmail} from './send-email'
|
||||
import {NewMessageEmail} from '../new-message'
|
||||
import {NewEndorsementEmail} from '../new-endorsement'
|
||||
@@ -8,8 +8,9 @@ import {getProfile} from 'shared/love/supabase'
|
||||
import { render } from "@react-email/render"
|
||||
import {MatchesType} from "common/love/bookmarked_searches";
|
||||
import NewSearchAlertsEmail from "email/new-search_alerts";
|
||||
import WelcomeEmail from "email/welcome";
|
||||
|
||||
const from = 'Compass <no-reply@compassmeet.com>'
|
||||
const from = 'Compass <compass@compassmeet.com>'
|
||||
|
||||
// export const sendNewMatchEmail = async (
|
||||
// privateUser: PrivateUser,
|
||||
@@ -75,6 +76,25 @@ export const sendNewMessageEmail = async (
|
||||
})
|
||||
}
|
||||
|
||||
export const sendWelcomeEmail = async (
|
||||
toUser: User,
|
||||
privateUser: PrivateUser,
|
||||
) => {
|
||||
if (!privateUser.email) return
|
||||
return await sendEmail({
|
||||
from,
|
||||
subject: `Welcome to Compass!`,
|
||||
to: privateUser.email,
|
||||
html: await render(
|
||||
<WelcomeEmail
|
||||
toUser={toUser}
|
||||
unsubscribeUrl={UNSUBSCRIBE_URL}
|
||||
email={privateUser.email}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const sendSearchAlertsEmail = async (
|
||||
toUser: User,
|
||||
privateUser: PrivateUser,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ProfileRow } from 'common/love/profile'
|
||||
import type { User } from 'common/user'
|
||||
import {ProfileRow} from 'common/love/profile'
|
||||
import type {User} from 'common/user'
|
||||
|
||||
// for email template testing
|
||||
|
||||
export const sinclairUser: User = {
|
||||
export const mockUser: User = {
|
||||
createdTime: 0,
|
||||
bio: 'the futa in futarchy',
|
||||
website: 'sincl.ai',
|
||||
@@ -31,14 +31,14 @@ export const sinclairProfile: ProfileRow = {
|
||||
id: 55,
|
||||
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||
created_time: '2023-10-27T00:41:59.851776+00:00',
|
||||
last_online_time: '2024-05-17T02:11:48.83+00:00',
|
||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||
city: 'San Francisco',
|
||||
gender: 'trans-female',
|
||||
pref_gender: ['female', 'trans-female'],
|
||||
pref_age_min: 18,
|
||||
pref_age_max: 21,
|
||||
pref_relation_styles: ['poly', 'open', 'mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 3,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -78,6 +78,7 @@ export const sinclairProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -101,6 +102,8 @@ export const sinclairProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 25,
|
||||
}
|
||||
|
||||
@@ -129,14 +132,14 @@ export const jamesProfile: ProfileRow = {
|
||||
id: 2,
|
||||
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
||||
created_time: '2023-10-21T21:18:26.691211+00:00',
|
||||
last_online_time: '2024-07-06T17:29:16.833+00:00',
|
||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||
city: 'San Francisco',
|
||||
gender: 'male',
|
||||
pref_gender: ['female'],
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 32,
|
||||
pref_relation_styles: ['mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 4,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -173,6 +176,7 @@ export const jamesProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -202,5 +206,7 @@ export const jamesProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 32,
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
type CreateEmailOptions,
|
||||
} from 'resend'
|
||||
import { log } from 'shared/utils'
|
||||
import {sleep} from "common/util/time";
|
||||
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
const limit = pLimit(1) // 1 concurrent per second
|
||||
|
||||
/*
|
||||
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
||||
@@ -17,18 +15,15 @@ export const sendEmail = async (
|
||||
options?: CreateEmailRequestOptions
|
||||
) => {
|
||||
const resend = getResend()
|
||||
console.log(resend, payload, options)
|
||||
console.debug(resend, payload, options)
|
||||
|
||||
async function sendEmailThrottle(data: any, options: any) {
|
||||
if (!resend) return { data: null, error: 'No Resend client' }
|
||||
return limit(() => resend.emails.send(data, options))
|
||||
}
|
||||
if (!resend) return null
|
||||
|
||||
const { data, error } = await sendEmailThrottle(
|
||||
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
|
||||
const { data, error } = await resend.emails.send(
|
||||
{ replyTo: 'Compass <hello@compassmeet.com>', ...payload },
|
||||
options
|
||||
)
|
||||
console.log('resend.emails.send', data, error)
|
||||
console.debug('resend.emails.send', data, error)
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
@@ -39,6 +34,9 @@ export const sendEmail = async (
|
||||
}
|
||||
|
||||
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
||||
|
||||
await sleep(1000) // to avoid rate limits (2 / second in resend free plan)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -47,12 +45,12 @@ const getResend = () => {
|
||||
if (resend) return resend
|
||||
|
||||
if (!process.env.RESEND_KEY) {
|
||||
console.log('No RESEND_KEY, skipping email send')
|
||||
console.debug('No RESEND_KEY, skipping email send')
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = process.env.RESEND_KEY as string
|
||||
// console.log(`RESEND_KEY: ${apiKey}`)
|
||||
// console.debug(`RESEND_KEY: ${apiKey}`)
|
||||
resend = new Resend(apiKey)
|
||||
return resend
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ if (require.main === module) {
|
||||
const email = process.argv[2]
|
||||
if (!email) {
|
||||
console.error('Please provide an email address')
|
||||
console.log('Usage: ts-node send-test-email.ts your@email.com')
|
||||
console.debug('Usage: ts-node send-test-email.ts your@email.com')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
sendTestEmail(email)
|
||||
.then(() => console.log('Email sent successfully!'))
|
||||
.then(() => console.debug('Email sent successfully!'))
|
||||
.catch((error) => console.error('Failed to send email:', error))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {jamesUser, sinclairUser} from './functions/mock'
|
||||
import {jamesUser, mockUser} from './functions/mock'
|
||||
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||
|
||||
interface NewEndorsementEmailProps {
|
||||
@@ -74,7 +74,7 @@ export const NewEndorsementEmail = ({
|
||||
|
||||
NewEndorsementEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
endorsementText:
|
||||
"Sinclair is someone you want to have around because she injects creativity and humor into every conversation, and her laugh is infectious! Not to mention that she's a great employee, treats everyone with respect, and is even-tempered.",
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@rea
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {type ProfileRow} from 'common/love/profile'
|
||||
import {type User} from 'common/user'
|
||||
import {jamesProfile, jamesUser, sinclairUser} from './functions/mock'
|
||||
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
|
||||
import {Footer} from "email/utils";
|
||||
|
||||
interface NewMatchEmailProps {
|
||||
@@ -70,7 +70,7 @@ export const NewMatchEmail = ({
|
||||
}
|
||||
|
||||
NewMatchEmail.PreviewProps = {
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
matchedWithUser: jamesUser,
|
||||
matchedProfile: jamesProfile,
|
||||
email: 'someone@gmail.com',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {type ProfileRow} from 'common/love/profile'
|
||||
import {jamesProfile, jamesUser, sinclairUser,} from './functions/mock'
|
||||
import {jamesProfile, jamesUser, mockUser,} from './functions/mock'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
|
||||
|
||||
@@ -74,7 +74,7 @@ export const NewMessageEmail = ({
|
||||
NewMessageEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
fromUserProfile: jamesProfile,
|
||||
toUser: sinclairUser,
|
||||
toUser: mockUser,
|
||||
channelId: 1,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Body, Container, Head, Html, Link, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {sinclairUser,} from './functions/mock'
|
||||
import {mockUser,} from './functions/mock'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {container, content, Footer, main, paragraph} from "email/utils";
|
||||
import {MatchesType} from "common/love/bookmarked_searches";
|
||||
@@ -140,7 +140,7 @@ const matchSamples = [
|
||||
]
|
||||
|
||||
NewSearchAlertsEmail.PreviewProps = {
|
||||
toUser: sinclairUser,
|
||||
toUser: mockUser,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
matches: matchSamples,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
|
||||
import {discordLink, githubRepo, patreonLink, paypalLink} from "common/constants";
|
||||
import {DOMAIN} from "common/envs/constants";
|
||||
|
||||
interface Props {
|
||||
@@ -15,40 +14,40 @@ export const Footer = ({
|
||||
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
|
||||
<Row>
|
||||
<Column align="center">
|
||||
<Link href={githubRepo} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/github`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/github-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="GitHub"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={discordLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/discord`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/discord-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Discord"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={patreonLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/patreon`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/patreon-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Patreon"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={paypalLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/paypal`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/paypal-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="PayPal"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
|
||||
82
backend/email/emails/welcome.tsx
Normal file
82
backend/email/emails/welcome.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {mockUser,} from './functions/mock'
|
||||
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||
|
||||
function randomHex(length: number) {
|
||||
const bytes = new Uint8Array(Math.ceil(length / 2));
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.slice(0, length);
|
||||
}
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
toUser: User
|
||||
unsubscribeUrl: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export const WelcomeEmail = ({
|
||||
toUser,
|
||||
unsubscribeUrl,
|
||||
email,
|
||||
}: WelcomeEmailProps) => {
|
||||
const name = toUser.name.split(' ')[0]
|
||||
const confirmUrl = `https://compassmeet.com/confirm-email/${randomHex(16)}`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head/>
|
||||
<Preview>Welcome to Compass — Please confirm your email</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Welcome to Compass, {name}!</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
Compass is a free, community-owned platform built to help people form
|
||||
deep, meaningful connections — platonic, romantic, or collaborative.
|
||||
There are no ads, no hidden algorithms, and no subscriptions — just a
|
||||
transparent, open-source space shaped by people like you.
|
||||
</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
To finish creating your account and start exploring Compass, please
|
||||
confirm your email below:
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
style={button}
|
||||
href={confirmUrl}
|
||||
>
|
||||
Confirm My Email
|
||||
</Button>
|
||||
|
||||
<Text style={{marginTop: "40px", fontSize: "10px", color: "#555"}}>
|
||||
Or copy and paste this link into your browser: <br/>
|
||||
<a href={confirmUrl}>{confirmUrl}</a>
|
||||
</Text>
|
||||
|
||||
<Text style={{marginTop: "40px", fontSize: "12px", color: "#555"}}>
|
||||
Your presence and participation are what make Compass possible. Thank you
|
||||
for helping us build an internet space that prioritizes depth, trust, and
|
||||
community over monetization.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
WelcomeEmail.PreviewProps = {
|
||||
toUser: mockUser,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
} as WelcomeEmailProps
|
||||
|
||||
|
||||
export default WelcomeEmail
|
||||
@@ -5,7 +5,7 @@
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
{
|
||||
"bucket": "compass-130ba-private.firebasestorage.app",
|
||||
"bucket": "compass-130ba-private",
|
||||
"rules": "private-storage.rules"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,4 +7,4 @@ service firebase.storage {
|
||||
allow write: if request.auth.uid == userId && request.resource.size <= 20 * 1024 * 1024; // 20MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{allPaths=**} {
|
||||
allow read;
|
||||
// Don't require auth, as dream uploads can be done by anyone
|
||||
allow write: if request.resource.size <= 10 * 1024 * 1024; // 10MB
|
||||
allow write: if request.auth != null && request.resource.size <= 10 * 1024 * 1024;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ runScript(async ({ pg }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('updates', updates.slice(0, 10))
|
||||
// console.debug('updates', updates.slice(0, 10))
|
||||
// return
|
||||
|
||||
let count = 0
|
||||
|
||||
@@ -24,7 +24,7 @@ runScript(async ({ pg }) => {
|
||||
})
|
||||
|
||||
const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
console.log(`\nSearching comments for ${nodeName}...`)
|
||||
console.debug(`\nSearching comments for ${nodeName}...`)
|
||||
const commentQuery = renderSql(
|
||||
select('id, user_id, on_user_id, content'),
|
||||
from('profile_comments'),
|
||||
@@ -32,15 +32,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
const comments = await pg.manyOrNone(commentQuery)
|
||||
|
||||
console.log(`Found ${comments.length} comments:`)
|
||||
console.debug(`Found ${comments.length} comments:`)
|
||||
comments.forEach((comment) => {
|
||||
console.log('\nComment ID:', comment.id)
|
||||
console.log('From user:', comment.user_id)
|
||||
console.log('On user:', comment.on_user_id)
|
||||
console.log('Content:', JSON.stringify(comment.content))
|
||||
console.debug('\nComment ID:', comment.id)
|
||||
console.debug('From user:', comment.user_id)
|
||||
console.debug('On user:', comment.on_user_id)
|
||||
console.debug('Content:', JSON.stringify(comment.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching private messages for ${nodeName}...`)
|
||||
console.debug(`\nSearching private messages for ${nodeName}...`)
|
||||
const messageQuery = renderSql(
|
||||
select('id, user_id, channel_id, content'),
|
||||
from('private_user_messages'),
|
||||
@@ -48,15 +48,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
const messages = await pg.manyOrNone(messageQuery)
|
||||
|
||||
console.log(`Found ${messages.length} private messages:`)
|
||||
console.debug(`Found ${messages.length} private messages:`)
|
||||
messages.forEach((msg) => {
|
||||
console.log('\nMessage ID:', msg.id)
|
||||
console.log('From user:', msg.user_id)
|
||||
console.log('Channel:', msg.channel_id)
|
||||
console.log('Content:', JSON.stringify(msg.content))
|
||||
console.debug('\nMessage ID:', msg.id)
|
||||
console.debug('From user:', msg.user_id)
|
||||
console.debug('Channel:', msg.channel_id)
|
||||
console.debug('Content:', JSON.stringify(msg.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching profiles for ${nodeName}...`)
|
||||
console.debug(`\nSearching profiles for ${nodeName}...`)
|
||||
const users = renderSql(
|
||||
select('user_id, bio'),
|
||||
from('profiles'),
|
||||
@@ -64,9 +64,9 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
|
||||
const usersWithMentions = await pg.manyOrNone(users)
|
||||
console.log(`Found ${usersWithMentions.length} users:`)
|
||||
console.debug(`Found ${usersWithMentions.length} users:`)
|
||||
usersWithMentions.forEach((user) => {
|
||||
console.log('\nUser ID:', user.user_id)
|
||||
console.log('Bio:', JSON.stringify(user.bio))
|
||||
console.debug('\nUser ID:', user.user_id)
|
||||
console.debug('Bio:', JSON.stringify(user.bio))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ async function getTableInfo(pg: SupabaseDirectClient, tableName: string) {
|
||||
}
|
||||
|
||||
async function getFunctions(pg: SupabaseDirectClient) {
|
||||
console.log('Getting functions')
|
||||
console.debug('Getting functions')
|
||||
const rows = await pg.manyOrNone<{
|
||||
function_name: string
|
||||
definition: string
|
||||
@@ -196,7 +196,7 @@ async function getFunctions(pg: SupabaseDirectClient) {
|
||||
}
|
||||
|
||||
async function getViews(pg: SupabaseDirectClient) {
|
||||
console.log('Getting views')
|
||||
console.debug('Getting views')
|
||||
return pg.manyOrNone<{ view_name: string; definition: string }>(
|
||||
`SELECT
|
||||
table_name AS view_name,
|
||||
@@ -214,7 +214,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
(row) => row.tablename as string
|
||||
)
|
||||
|
||||
console.log(`Getting info for ${tables.length} tables`)
|
||||
console.debug(`Getting info for ${tables.length} tables`)
|
||||
const tableInfos = await Promise.all(
|
||||
tables.map((table) => getTableInfo(pg, table))
|
||||
)
|
||||
@@ -331,7 +331,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
await fs.writeFile(`${outputDir}/${tableInfo.tableName}.sql`, content)
|
||||
}
|
||||
|
||||
console.log('Writing remaining functions to functions.sql')
|
||||
console.debug('Writing remaining functions to functions.sql')
|
||||
let functionsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||
|
||||
for (const func of functions) {
|
||||
@@ -340,7 +340,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
|
||||
await fs.writeFile(`${outputDir}/functions.sql`, functionsContent)
|
||||
|
||||
console.log('Writing views to views.sql')
|
||||
console.debug('Writing views to views.sql')
|
||||
let viewsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||
|
||||
for (const view of views) {
|
||||
@@ -350,7 +350,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
|
||||
await fs.writeFile(`${outputDir}/views.sql`, viewsContent)
|
||||
|
||||
console.log('Prettifying SQL files...')
|
||||
console.debug('Prettifying SQL files...')
|
||||
execSync(
|
||||
`prettier --write ${outputDir}/*.sql --ignore-path ../supabase/.gitignore`
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ const removeNodesOfType = (
|
||||
runScript(async ({ pg }) => {
|
||||
const nodeType = 'linkPreview'
|
||||
|
||||
console.log('\nSearching comments for linkPreviews...')
|
||||
console.debug('\nSearching comments for linkPreviews...')
|
||||
const commentQuery = renderSql(
|
||||
select('id, content'),
|
||||
from('profile_comments'),
|
||||
@@ -38,21 +38,21 @@ runScript(async ({ pg }) => {
|
||||
)
|
||||
const comments = await pg.manyOrNone(commentQuery)
|
||||
|
||||
console.log(`Found ${comments.length} comments with linkPreviews`)
|
||||
console.debug(`Found ${comments.length} comments with linkPreviews`)
|
||||
|
||||
for (const comment of comments) {
|
||||
const newContent = removeNodesOfType(comment.content, nodeType)
|
||||
console.log('before', comment.content)
|
||||
console.log('after', newContent)
|
||||
console.debug('before', comment.content)
|
||||
console.debug('after', newContent)
|
||||
|
||||
await pg.none('update profile_comments set content = $1 where id = $2', [
|
||||
newContent,
|
||||
comment.id,
|
||||
])
|
||||
console.log('Updated comment:', comment.id)
|
||||
console.debug('Updated comment:', comment.id)
|
||||
}
|
||||
|
||||
console.log('\nSearching private messages for linkPreviews...')
|
||||
console.debug('\nSearching private messages for linkPreviews...')
|
||||
const messageQuery = renderSql(
|
||||
select('id, content'),
|
||||
from('private_user_messages'),
|
||||
@@ -60,17 +60,17 @@ runScript(async ({ pg }) => {
|
||||
)
|
||||
const messages = await pg.manyOrNone(messageQuery)
|
||||
|
||||
console.log(`Found ${messages.length} messages with linkPreviews`)
|
||||
console.debug(`Found ${messages.length} messages with linkPreviews`)
|
||||
|
||||
for (const msg of messages) {
|
||||
const newContent = removeNodesOfType(msg.content, nodeType)
|
||||
console.log('before', JSON.stringify(msg.content, null, 2))
|
||||
console.log('after', JSON.stringify(newContent, null, 2))
|
||||
console.debug('before', JSON.stringify(msg.content, null, 2))
|
||||
console.debug('after', JSON.stringify(newContent, null, 2))
|
||||
|
||||
await pg.none(
|
||||
'update private_user_messages set content = $1 where id = $2',
|
||||
[newContent, msg.id]
|
||||
)
|
||||
console.log('Updated message:', msg.id)
|
||||
console.debug('Updated message:', msg.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ENV_CONFIG, getStorageBucketId} from "common/envs/constants";
|
||||
|
||||
export const getServiceAccountCredentials = () => {
|
||||
let keyPath = ENV_CONFIG.googleApplicationCredentials
|
||||
// console.log('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
||||
// console.debug('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
||||
if (!keyPath) {
|
||||
// throw new Error(
|
||||
// `Please set the GOOGLE_APPLICATION_CREDENTIALS environment variable to contain the path to your key file.`
|
||||
@@ -16,7 +16,7 @@ export const getServiceAccountCredentials = () => {
|
||||
if (!keyPath.startsWith('/')) {
|
||||
// Make relative paths relative to the current file
|
||||
keyPath = __dirname + '/' + keyPath
|
||||
// console.log(keyPath)
|
||||
// console.debug(keyPath)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -41,11 +41,11 @@ export async function deleteUserFiles(username: string) {
|
||||
const [files] = await bucket.getFiles({prefix: path});
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`No files found in bucket for user ${username}`);
|
||||
console.debug(`No files found in bucket for user ${username}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(files.map(file => file.delete()));
|
||||
console.log(`Deleted ${files.length} files for user ${username}`);
|
||||
console.debug(`Deleted ${files.length} files for user ${username}`);
|
||||
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const generateAvatarUrl = async (
|
||||
const buffer = await res.arrayBuffer()
|
||||
return await upload(userId, Buffer.from(buffer), bucket)
|
||||
} catch (e) {
|
||||
console.log('error generating avatar', e)
|
||||
console.debug('error generating avatar', e)
|
||||
return `https://${DOMAIN}/images/default-avatar.png`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const constructPrefixTsQuery = (term: string) => {
|
||||
.replace(/'/g, "''")
|
||||
.replace(/[!&|():*<>]/g, '')
|
||||
.trim()
|
||||
console.log(`Term: "${sanitized}"`)
|
||||
console.debug(`Term: "${sanitized}"`)
|
||||
if (sanitized === '') return ''
|
||||
const tokens = sanitized.split(/\s+/)
|
||||
return tokens.join(' & ') + ':*'
|
||||
|
||||
@@ -9,12 +9,12 @@ export const initAdmin = () => {
|
||||
if (IS_LOCAL) {
|
||||
try {
|
||||
const serviceAccount = getServiceAccountCredentials()
|
||||
// console.log(serviceAccount)
|
||||
// console.debug(serviceAccount)
|
||||
if (!serviceAccount.project_id) {
|
||||
console.log(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
|
||||
console.debug(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
|
||||
return
|
||||
}
|
||||
console.log(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
||||
console.debug(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
||||
return admin.initializeApp({
|
||||
projectId: serviceAccount.project_id,
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
@@ -25,6 +25,6 @@ export const initAdmin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Initializing connection to default Firebase...`)
|
||||
console.debug(`Initializing connection to default Firebase...`)
|
||||
return admin.initializeApp()
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ export function convertRow(row: ProfileAndUserRow): Profile
|
||||
export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
// Remove internal/search-only fields from the returned profile row
|
||||
const profile: any = {
|
||||
...row,
|
||||
user: { ...row.user, name: row.name, username: row.username } as User,
|
||||
} as Profile
|
||||
}
|
||||
delete profile.bio_text
|
||||
delete profile.bio_tsv
|
||||
return profile as Profile
|
||||
}
|
||||
|
||||
const LOVER_COLS = 'profiles.*, name, username, users.data as user'
|
||||
|
||||
@@ -79,7 +79,7 @@ function writeLog(
|
||||
// record error properties in GCP if you just do log(err)
|
||||
output['error'] = msg
|
||||
}
|
||||
console.log(JSON.stringify(output, replacer))
|
||||
console.debug(JSON.stringify(output, replacer))
|
||||
} else {
|
||||
const category = Object.values(pick(data, DISPLAY_CATEGORY_KEYS)).join()
|
||||
const categoryLabel = category ? dim(category) + ' ' : ''
|
||||
|
||||
@@ -104,7 +104,7 @@ export class MetricWriter {
|
||||
for (const entry of freshEntries) {
|
||||
entry.fresh = false
|
||||
}
|
||||
if (!IS_GOOGLE_CLOUD) {
|
||||
if (IS_GOOGLE_CLOUD) {
|
||||
log.debug('Writing GCP metrics.', {entries: freshEntries})
|
||||
if (this.instance == null) {
|
||||
this.instance = await getInstanceInfo()
|
||||
|
||||
@@ -64,7 +64,7 @@ const newClient = (
|
||||
...settings,
|
||||
}
|
||||
|
||||
// console.log(config)
|
||||
// console.debug(config)
|
||||
|
||||
return pgp(config)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,15 @@ import {
|
||||
import {IS_LOCAL} from "common/envs/constants";
|
||||
import {getWebsocketUrl} from "common/api/utils";
|
||||
|
||||
// Extend the type definition locally
|
||||
interface HeartbeatWebSocket extends WebSocket {
|
||||
isAlive?: boolean
|
||||
}
|
||||
|
||||
const SWITCHBOARD = new Switchboard()
|
||||
|
||||
// if a connection doesn't ping for this long, we assume the other side is toast
|
||||
const CONNECTION_TIMEOUT_MS = 60 * 1000
|
||||
// const CONNECTION_TIMEOUT_MS = 60 * 1000
|
||||
|
||||
export class MessageParseError extends Error {
|
||||
details?: unknown
|
||||
@@ -52,7 +57,7 @@ function parseMessage(data: RawData): ClientMessage {
|
||||
}
|
||||
}
|
||||
|
||||
function processMessage(ws: WebSocket, data: RawData): ServerMessage<'ack'> {
|
||||
function processMessage(ws: HeartbeatWebSocket, data: RawData): ServerMessage<'ack'> {
|
||||
try {
|
||||
const msg = parseMessage(data)
|
||||
const { type, txid } = msg
|
||||
@@ -129,21 +134,26 @@ export function listen(server: HttpServer, path: string) {
|
||||
let deadConnectionCleaner: NodeJS.Timeout | undefined
|
||||
wss.on('listening', () => {
|
||||
log.info(`Web socket server listening on ${path}. ${getWebsocketUrl()}`)
|
||||
deadConnectionCleaner = setInterval(function ping() {
|
||||
const now = Date.now()
|
||||
for (const ws of wss.clients) {
|
||||
const lastSeen = SWITCHBOARD.getClient(ws).lastSeen
|
||||
if (lastSeen < now - CONNECTION_TIMEOUT_MS) {
|
||||
ws.terminate()
|
||||
deadConnectionCleaner = setInterval(() => {
|
||||
for (const ws of wss.clients as Set<HeartbeatWebSocket>) {
|
||||
if (ws.isAlive === false) {
|
||||
log.debug('Terminating dead connection');
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.isAlive = false;
|
||||
// log.debug('Sending ping to client');
|
||||
ws.ping();
|
||||
}
|
||||
}, CONNECTION_TIMEOUT_MS)
|
||||
}, 25000);
|
||||
})
|
||||
wss.on('error', (err) => {
|
||||
log.error('Error on websocket server.', { error: err })
|
||||
})
|
||||
wss.on('connection', (ws) => {
|
||||
// todo: should likely kill connections that haven't sent any ping for a long time
|
||||
wss.on('connection', (ws: HeartbeatWebSocket) => {
|
||||
ws.isAlive = true;
|
||||
// log.debug('Received pong from client');
|
||||
ws.on('pong', () => (ws.isAlive = true));
|
||||
metrics.inc('ws/connections_established')
|
||||
metrics.set('ws/open_connections', wss.clients.size)
|
||||
log.debug('WS client connected.')
|
||||
|
||||
14
backend/supabase/contact.sql
Normal file
14
backend/supabase/contact.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
create table if not exists
|
||||
contact (
|
||||
id text default uuid_generate_v4 () not null,
|
||||
created_time timestamp with time zone default now(),
|
||||
user_id text,
|
||||
content jsonb
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table contact
|
||||
add constraint contact_user_id_fkey foreign key (user_id) references users (id);
|
||||
|
||||
-- Row Level Security
|
||||
alter table contact enable row level security;
|
||||
@@ -9,6 +9,7 @@ END$$;
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
age INTEGER NULL,
|
||||
bio JSONB,
|
||||
bio_length integer null,
|
||||
born_in_location TEXT,
|
||||
city TEXT NOT NULL,
|
||||
city_latitude NUMERIC(9, 6),
|
||||
@@ -27,7 +28,6 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
is_smoker BOOLEAN,
|
||||
is_vegetarian_or_vegan BOOLEAN,
|
||||
last_online_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
|
||||
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
pref_age_min INTEGER NULL,
|
||||
pref_gender TEXT[] NOT NULL,
|
||||
pref_relation_styles TEXT[] NOT NULL,
|
||||
pref_romantic_styles TEXT[],
|
||||
referred_by_username TEXT,
|
||||
region_code TEXT,
|
||||
religious_belief_strength INTEGER,
|
||||
@@ -58,10 +59,8 @@ ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON profiles;
|
||||
|
||||
CREATE POLICY "public read" ON profiles
|
||||
FOR SELECT
|
||||
USING (true);
|
||||
FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "self update" ON profiles;
|
||||
|
||||
@@ -79,22 +78,29 @@ CREATE UNIQUE INDEX unique_user_id ON public.profiles USING btree (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
|
||||
ON public.profiles USING btree (last_modification_time);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
|
||||
ON profiles (bio_length);
|
||||
|
||||
-- Fastest general-purpose index
|
||||
DROP INDEX IF EXISTS profiles_lat_lon_idx;
|
||||
CREATE INDEX profiles_lat_lon_idx ON profiles (city_latitude, city_longitude);
|
||||
|
||||
-- Optional additional index for large tables / clustered inserts
|
||||
DROP INDEX IF EXISTS profiles_lat_lon_brin_idx;
|
||||
CREATE INDEX profiles_lat_lon_brin_idx ON profiles USING BRIN (city_latitude, city_longitude) WITH (pages_per_range = 32);
|
||||
|
||||
|
||||
|
||||
-- Functions and Triggers
|
||||
CREATE
|
||||
OR REPLACE FUNCTION update_last_modification_time()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.last_online_time IS DISTINCT FROM OLD.last_online_time AND row(NEW.*) = row(OLD.*) THEN
|
||||
-- Only last_online_time changed, do nothing
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Some other column changed
|
||||
NEW.last_modification_time = now();
|
||||
RETURN NEW;
|
||||
NEW.last_modification_time = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
CREATE TRIGGER trigger_update_last_mod_time
|
||||
BEFORE UPDATE
|
||||
@@ -110,25 +116,29 @@ CREATE INDEX profiles_bio_trgm_idx
|
||||
|
||||
|
||||
--- bio_text
|
||||
-- ALTER TABLE profiles ADD COLUMN bio_text tsvector;
|
||||
--
|
||||
-- CREATE OR REPLACE FUNCTION profiles_bio_tsvector_update()
|
||||
-- RETURNS trigger AS $$
|
||||
-- BEGIN
|
||||
-- new.bio_text := to_tsvector(
|
||||
-- 'english',
|
||||
-- (
|
||||
-- SELECT string_agg(trim(both '"' from x::text), ' ')
|
||||
-- FROM jsonb_path_query(new.bio, '$.**.text'::jsonpath) AS x
|
||||
-- )
|
||||
-- );
|
||||
-- RETURN new;
|
||||
-- END;
|
||||
-- $$ LANGUAGE plpgsql;
|
||||
--
|
||||
-- CREATE TRIGGER profiles_bio_tsvector_trigger
|
||||
-- BEFORE INSERT OR UPDATE OF bio ON profiles
|
||||
-- FOR EACH ROW EXECUTE FUNCTION profiles_bio_tsvector_update();
|
||||
--
|
||||
-- create index on profiles using gin(bio_text);
|
||||
ALTER TABLE profiles ADD COLUMN bio_text TEXT;
|
||||
UPDATE profiles
|
||||
SET bio_text = (
|
||||
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
|
||||
FROM jsonb_path_query(bio, '$.**.text') AS t(value)
|
||||
);
|
||||
|
||||
ALTER TABLE profiles ADD COLUMN bio_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
|
||||
|
||||
CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_bio_text()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.bio_text := (
|
||||
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
|
||||
FROM jsonb_path_query(NEW.bio, '$.**.text') AS t(value)
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_update_bio_text
|
||||
BEFORE INSERT OR UPDATE OF bio ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_bio_text();
|
||||
|
||||
16
backend/supabase/user_activity.sql
Normal file
16
backend/supabase/user_activity.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE user_activity
|
||||
(
|
||||
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||
last_online_time TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE user_activity ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "public read" ON user_activity;
|
||||
CREATE POLICY "public read" ON user_activity
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_last_online_time
|
||||
ON user_activity (last_online_time DESC);
|
||||
94
backend/supabase/vote_results.sql
Normal file
94
backend/supabase/vote_results.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
CREATE TABLE IF NOT EXISTS vote_results (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
vote_id BIGINT NOT NULL,
|
||||
choice smallint NOT NULL CHECK (choice IN (-1, 0, 1)),
|
||||
priority smallint NOT NULL CHECK (priority IN (0, 1, 2, 3)),
|
||||
UNIQUE (user_id, vote_id) -- ensures one vote per user
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table vote_results
|
||||
add constraint vote_results_user_id_fkey foreign key (user_id) references users (id);
|
||||
|
||||
alter table vote_results
|
||||
add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes (id);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON vote_results;
|
||||
CREATE POLICY "public read" ON vote_results
|
||||
FOR ALL USING (true);
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF EXISTS user_id_idx;
|
||||
CREATE INDEX user_id_idx ON vote_results (user_id);
|
||||
|
||||
DROP INDEX IF EXISTS vote_id_idx;
|
||||
CREATE INDEX vote_id_idx ON vote_results (vote_id);
|
||||
|
||||
DROP INDEX IF EXISTS idx_vote_results_vote_choice;
|
||||
CREATE INDEX idx_vote_results_vote_choice ON vote_results (vote_id, choice);
|
||||
|
||||
DROP INDEX IF EXISTS idx_vote_results_vote_choice_priority;
|
||||
CREATE INDEX idx_vote_results_vote_choice_priority ON vote_results (vote_id, choice, priority);
|
||||
|
||||
DROP INDEX IF EXISTS idx_votes_created_time;
|
||||
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
|
||||
|
||||
|
||||
drop function if exists get_votes_with_results;
|
||||
create or replace function get_votes_with_results(order_by text default 'recent')
|
||||
returns table (
|
||||
id BIGINT,
|
||||
title text,
|
||||
description jsonb,
|
||||
created_time timestamptz,
|
||||
creator_id TEXT,
|
||||
is_anonymous boolean,
|
||||
votes_for int,
|
||||
votes_against int,
|
||||
votes_abstain int,
|
||||
priority int
|
||||
)
|
||||
as $$
|
||||
with results as (
|
||||
SELECT
|
||||
v.id,
|
||||
v.title,
|
||||
v.description,
|
||||
v.created_time,
|
||||
v.creator_id,
|
||||
v.is_anonymous,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for,
|
||||
COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain,
|
||||
COALESCE(SUM(r.priority), 0)::float / GREATEST(COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 1), 1) * 100 / 3 AS priority
|
||||
FROM votes v
|
||||
LEFT JOIN vote_results r ON v.id = r.vote_id
|
||||
GROUP BY v.id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
created_time,
|
||||
creator_id,
|
||||
is_anonymous,
|
||||
votes_for,
|
||||
votes_against,
|
||||
votes_abstain,
|
||||
priority
|
||||
FROM results
|
||||
ORDER BY
|
||||
CASE WHEN order_by = 'recent' THEN created_time END DESC,
|
||||
CASE WHEN order_by = 'mostVoted' THEN (votes_for + votes_against + votes_abstain) END DESC,
|
||||
CASE WHEN order_by = 'mostVoted' THEN created_time END DESC,
|
||||
CASE WHEN order_by = 'priority' THEN priority END DESC,
|
||||
CASE WHEN order_by = 'priority' THEN created_time END DESC;
|
||||
$$ language sql stable;
|
||||
|
||||
|
||||
27
backend/supabase/votes.sql
Normal file
27
backend/supabase/votes.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
creator_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
is_anonymous BOOLEAN NOT NULL,
|
||||
description JSONB
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table votes
|
||||
add constraint votes_creator_id_fkey foreign key (creator_id) references users (id);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON votes;
|
||||
CREATE POLICY "public read" ON votes
|
||||
FOR ALL USING (true);
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF EXISTS creator_id_idx;
|
||||
CREATE INDEX creator_id_idx ON votes (creator_id);
|
||||
|
||||
DROP INDEX IF EXISTS idx_votes_created_time;
|
||||
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PROJECT=compass-130ba
|
||||
|
||||
TIMESTAMP=$(date +"%F_%H-%M-%S")
|
||||
|
||||
DESTINATION=./data/$TIMESTAMP
|
||||
|
||||
mkdir -p $DESTINATION
|
||||
gsutil -m cp -r gs://$PROJECT.firebasestorage.app $DESTINATION
|
||||
|
||||
echo Backup of Firebase Storage done
|
||||
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
locals {
|
||||
project = "compass-130ba"
|
||||
region = "us-west1"
|
||||
zone = "us-west1-b"
|
||||
service_name = "backup"
|
||||
machine_type = "e2-micro"
|
||||
}
|
||||
|
||||
variable "env" {
|
||||
description = "Environment (env or prod)"
|
||||
type = string
|
||||
default = "prod"
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = local.project
|
||||
region = local.region
|
||||
zone = local.zone
|
||||
}
|
||||
|
||||
# Service account for the VM (needs Secret Manager + Storage access)
|
||||
resource "google_service_account" "backup_vm_sa" {
|
||||
account_id = "backup-vm-sa"
|
||||
display_name = "Backup VM Service Account"
|
||||
}
|
||||
|
||||
# IAM roles
|
||||
resource "google_project_iam_member" "backup_sa_secret_manager" {
|
||||
project = "compass-130ba"
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "backup_sa_storage_admin" {
|
||||
project = "compass-130ba"
|
||||
role = "roles/storage.objectAdmin"
|
||||
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
|
||||
}
|
||||
|
||||
# Minimal VM
|
||||
resource "google_compute_instance" "backup_vm" {
|
||||
name = "supabase-backup-vm"
|
||||
machine_type = local.machine_type
|
||||
zone = local.zone
|
||||
|
||||
boot_disk {
|
||||
initialize_params {
|
||||
image = "debian-11-bullseye-v20250915"
|
||||
size = 20
|
||||
}
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
access_config {}
|
||||
}
|
||||
|
||||
service_account {
|
||||
email = google_service_account.backup_vm_sa.email
|
||||
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
|
||||
}
|
||||
|
||||
metadata_startup_script = <<-EOT
|
||||
#!/bin/bash
|
||||
apt-get update
|
||||
apt-get install -y postgresql-client cron wget curl unzip
|
||||
|
||||
# Add PostgreSQL repo
|
||||
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client-17
|
||||
sudo apt-get install -y mailutils
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p /home/martin/supabase_backups
|
||||
chown -R martin:martin /home/martin
|
||||
|
||||
# Example backup script
|
||||
cat <<'EOF' > /home/martin/backup.sh
|
||||
#!/bin/bash
|
||||
|
||||
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname "$0")
|
||||
|
||||
export ENV=prod
|
||||
|
||||
if [ "$ENV" = "prod" ]; then
|
||||
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
|
||||
elif [ "$ENV" = "dev" ]; then
|
||||
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
|
||||
else
|
||||
echo "Error: ENV must be 'prod' or 'dev'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Config
|
||||
PGPORT="5432"
|
||||
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
|
||||
PGDATABASE="postgres"
|
||||
|
||||
# Retrieve password from Secret Manager
|
||||
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
|
||||
|
||||
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
|
||||
BACKUP_DIR="/tmp/supabase_backups"
|
||||
RETENTION_DAYS=30
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP=$(date +"%F_%H-%M-%S")
|
||||
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
|
||||
|
||||
export PGPASSWORD
|
||||
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Backup failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Backup successful: $BACKUP_FILE"
|
||||
|
||||
# UPLOAD TO GCS
|
||||
echo "Uploading backup to GCS..."
|
||||
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
|
||||
|
||||
# LOCAL RETENTION
|
||||
LOCAL_RETENTION_DAYS=7
|
||||
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
|
||||
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
|
||||
|
||||
# GCS RETENTION
|
||||
echo "Cleaning old backups from GCS..."
|
||||
gsutil ls "$BUCKET_NAME/" | while read file; do
|
||||
filename=$(basename "$file")
|
||||
# Extract timestamp from filename
|
||||
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
|
||||
# Convert to seconds since epoch
|
||||
file_date="2025-09-24_13-00-54"
|
||||
date_part=${file_date%_*} # "2025-09-24"
|
||||
time_part=${file_date#*_} # "13-00-54"
|
||||
time_part=${time_part//-/:} # "13:00:54"
|
||||
file_ts=$(date -d "$date_part $time_part" +%s)
|
||||
# echo "$file, $filename, $file_date, $file_ts"
|
||||
if [ -z "$file_ts" ]; then
|
||||
continue
|
||||
fi
|
||||
now=$(date +%s)
|
||||
diff_days=$(( (now - file_ts) / 86400 ))
|
||||
echo "File: $filename is $diff_days days old."
|
||||
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
|
||||
echo "Deleting $file from GCS..."
|
||||
gsutil rm "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Backup and retention process completed at $(date)."
|
||||
EOF
|
||||
|
||||
chmod +x /home/martin/backup.sh
|
||||
|
||||
# Add cron job: daily at 2AM
|
||||
( crontab -l 2>/dev/null; echo '0 2 * * * /home/martin/backup.sh >> /home/martin/backup.log 2>&1 || curl -H "Content-Type: application/json" -X POST -d "{\"content\": \"❌ Backup FAILED on $(hostname) at $(date)\"}" https://discord.com/api/webhooks/1420405275340574873/XgF5pgHABvvWT2fyWASBs3VhAF7Zy11rCH2BkI_RBxH1Xd5duWxGtukrc1cPy1ZucNwx' ) | crontab -
|
||||
# tail -f /home/martin/backup.log
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname "$0")
|
||||
|
||||
#gcloud compute firewall-rules create allow-iap-ssh \
|
||||
# --direction=INGRESS \
|
||||
# --action=ALLOW \
|
||||
# --rules=tcp:22 \
|
||||
# --source-ranges=35.235.240.0/20 \
|
||||
# --target-tags=iap-ssh
|
||||
# gcloud compute instances add-tags "supabase-backup-vm" --tags=iap-ssh --zone="us-west1-b"
|
||||
|
||||
|
||||
gcloud compute ssh --zone "us-west1-b" "supabase-backup-vm" --project "compass-130ba" --tunnel-through-iap
|
||||
|
||||
# sudo crontab -u backup -l
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname "$0")
|
||||
|
||||
export ENV=prod
|
||||
|
||||
if [ "$ENV" = "prod" ]; then
|
||||
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
|
||||
elif [ "$ENV" = "dev" ]; then
|
||||
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
|
||||
else
|
||||
echo "Error: ENV must be 'prod' or 'dev'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Config
|
||||
PGPORT="5432"
|
||||
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
|
||||
PGDATABASE="postgres"
|
||||
|
||||
# Retrieve password from Secret Manager
|
||||
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
|
||||
|
||||
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
|
||||
BACKUP_DIR="/tmp/supabase_backups"
|
||||
RETENTION_DAYS=30
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP=$(date +"%F_%H-%M-%S")
|
||||
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
|
||||
|
||||
export PGPASSWORD
|
||||
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Backup failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Backup successful: $BACKUP_FILE"
|
||||
|
||||
# UPLOAD TO GCS
|
||||
echo "Uploading backup to GCS..."
|
||||
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
|
||||
|
||||
# LOCAL RETENTION
|
||||
LOCAL_RETENTION_DAYS=7
|
||||
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
|
||||
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
|
||||
|
||||
# GCS RETENTION
|
||||
echo "Cleaning old backups from GCS..."
|
||||
gsutil ls "$BUCKET_NAME/" | while read file; do
|
||||
filename=$(basename "$file")
|
||||
# Extract timestamp from filename
|
||||
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
|
||||
# Convert to seconds since epoch
|
||||
file_date="2025-09-24_13-00-54"
|
||||
date_part=${file_date%_*} # "2025-09-24"
|
||||
time_part=${file_date#*_} # "13-00-54"
|
||||
time_part=${time_part//-/:} # "13:00:54"
|
||||
file_ts=$(date -d "$date_part $time_part" +%s)
|
||||
# echo "$file, $filename, $file_date, $file_ts"
|
||||
if [ -z "$file_ts" ]; then
|
||||
continue
|
||||
fi
|
||||
now=$(date +%s)
|
||||
diff_days=$(( (now - file_ts) / 86400 ))
|
||||
echo "File: $filename is $diff_days days old."
|
||||
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
|
||||
echo "Deleting $file from GCS..."
|
||||
gsutil rm "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Backup and retention process completed at $(date)."
|
||||
@@ -4,19 +4,19 @@ import {
|
||||
baseProfilesSchema,
|
||||
arraybeSchema,
|
||||
} from 'common/api/zod-types'
|
||||
import { PrivateChatMessage } from 'common/chat-message'
|
||||
import { CompatibilityScore } from 'common/love/compatibility-score'
|
||||
import { MAX_COMPATIBILITY_QUESTION_LENGTH } from 'common/love/constants'
|
||||
import { Profile, ProfileRow } from 'common/love/profile'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import { z } from 'zod'
|
||||
import { LikeData, ShipData } from './love-types'
|
||||
import { DisplayUser, FullUser } from './user-types'
|
||||
import { PrivateMessageChannel } from 'common/supabase/private-messages'
|
||||
import { Notification } from 'common/notifications'
|
||||
import { arrify } from 'common/util/array'
|
||||
import { notification_preference } from 'common/user-notification-preferences'
|
||||
import {PrivateChatMessage} from 'common/chat-message'
|
||||
import {CompatibilityScore} from 'common/love/compatibility-score'
|
||||
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/love/constants'
|
||||
import {Profile, ProfileRow} from 'common/love/profile'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {PrivateUser, User} from 'common/user'
|
||||
import {z} from 'zod'
|
||||
import {LikeData, ShipData} from './love-types'
|
||||
import {DisplayUser, FullUser} from './user-types'
|
||||
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {arrify} from 'common/util/array'
|
||||
import {notification_preference} from 'common/user-notification-preferences'
|
||||
|
||||
// mqp: very unscientific, just balancing our willingness to accept load
|
||||
// with user willingness to put up with stale data
|
||||
@@ -28,6 +28,8 @@ type APIGenericSchema = {
|
||||
method: 'GET' | 'POST' | 'PUT'
|
||||
// whether the endpoint requires authentication
|
||||
authed: boolean
|
||||
// whether the endpoint requires authentication
|
||||
rateLimited?: boolean
|
||||
// zod schema for the request body (or for params for GET requests)
|
||||
props: z.ZodType
|
||||
// note this has to be JSON serializable
|
||||
@@ -42,33 +44,39 @@ export const API = (_apiTypeCheck = {
|
||||
health: {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as { message: 'Server is working.'; uid?: string },
|
||||
},
|
||||
'get-supabase-token': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as { jwt: string },
|
||||
},
|
||||
'mark-all-notifs-read': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
},
|
||||
'user/by-id/:id/block': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
rateLimited: false,
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'user/by-id/:id/unblock': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
rateLimited: false,
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'ban-user': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
@@ -80,6 +88,7 @@ export const API = (_apiTypeCheck = {
|
||||
// TODO rest
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as { user: User; privateUser: PrivateUser },
|
||||
props: z
|
||||
.object({
|
||||
@@ -91,12 +100,14 @@ export const API = (_apiTypeCheck = {
|
||||
'create-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as Row<'profiles'>,
|
||||
props: baseProfilesSchema,
|
||||
},
|
||||
report: {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
contentOwnerId: z.string(),
|
||||
@@ -112,6 +123,7 @@ export const API = (_apiTypeCheck = {
|
||||
me: {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
props: z.object({}),
|
||||
returns: {} as FullUser,
|
||||
@@ -119,6 +131,7 @@ export const API = (_apiTypeCheck = {
|
||||
'me/update': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
name: z.string().trim().min(1).optional(),
|
||||
username: z.string().trim().min(1).optional(),
|
||||
@@ -146,12 +159,14 @@ export const API = (_apiTypeCheck = {
|
||||
'update-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: combinedLoveUsersSchema.partial(),
|
||||
returns: {} as ProfileRow,
|
||||
},
|
||||
'update-notif-settings': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
type: z.string() as z.ZodType<notification_preference>,
|
||||
medium: z.enum(['email', 'browser', 'mobile']),
|
||||
@@ -161,6 +176,7 @@ export const API = (_apiTypeCheck = {
|
||||
'me/delete': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
username: z.string(), // just so you're sure
|
||||
}),
|
||||
@@ -168,54 +184,61 @@ export const API = (_apiTypeCheck = {
|
||||
'me/private': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as PrivateUser,
|
||||
},
|
||||
'user/:username': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as FullUser,
|
||||
props: z.object({ username: z.string() }).strict(),
|
||||
props: z.object({username: z.string()}).strict(),
|
||||
},
|
||||
'user/:username/lite': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as DisplayUser,
|
||||
props: z.object({ username: z.string() }).strict(),
|
||||
props: z.object({username: z.string()}).strict(),
|
||||
},
|
||||
'user/by-id/:id': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as FullUser,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'user/by-id/:id/lite': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: {} as DisplayUser,
|
||||
props: z.object({ id: z.string() }).strict(),
|
||||
props: z.object({id: z.string()}).strict(),
|
||||
},
|
||||
'search-users': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: [] as FullUser[],
|
||||
props: z
|
||||
.object({
|
||||
term: z.string(),
|
||||
limit: z.coerce.number().gte(0).lte(1000).default(500),
|
||||
limit: z.coerce.number().gte(0).lte(20).default(500),
|
||||
page: z.coerce.number().gte(0).default(0),
|
||||
})
|
||||
.strict(),
|
||||
},
|
||||
'compatible-profiles': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
props: z.object({ userId: z.string() }),
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({userId: z.string()}),
|
||||
returns: {} as {
|
||||
profile: Profile
|
||||
compatibleProfiles: Profile[]
|
||||
@@ -227,7 +250,8 @@ export const API = (_apiTypeCheck = {
|
||||
'remove-pinned-photo': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
returns: { success: true },
|
||||
rateLimited: true,
|
||||
returns: {success: true},
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
@@ -236,7 +260,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-compatibility-questions': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
@@ -249,6 +274,7 @@ export const API = (_apiTypeCheck = {
|
||||
'like-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId: z.string(),
|
||||
remove: z.boolean().optional(),
|
||||
@@ -260,6 +286,7 @@ export const API = (_apiTypeCheck = {
|
||||
'ship-profiles': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId1: z.string(),
|
||||
targetUserId2: z.string(),
|
||||
@@ -271,7 +298,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-likes-and-ships': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
@@ -287,6 +315,7 @@ export const API = (_apiTypeCheck = {
|
||||
'has-free-like': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({}).strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
@@ -296,6 +325,7 @@ export const API = (_apiTypeCheck = {
|
||||
'star-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId: z.string(),
|
||||
remove: z.boolean().optional(),
|
||||
@@ -306,10 +336,11 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-profiles': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
limit: z.coerce.number().gt(0).lte(20).optional().default(20),
|
||||
after: z.string().optional(),
|
||||
// Search and filter parameters
|
||||
name: z.string().optional(),
|
||||
@@ -318,10 +349,15 @@ export const API = (_apiTypeCheck = {
|
||||
pref_age_min: z.coerce.number().optional(),
|
||||
pref_age_max: z.coerce.number().optional(),
|
||||
pref_relation_styles: arraybeSchema.optional(),
|
||||
pref_romantic_styles: arraybeSchema.optional(),
|
||||
wants_kids_strength: z.coerce.number().optional(),
|
||||
has_kids: z.coerce.number().optional(),
|
||||
is_smoker: z.coerce.boolean().optional(),
|
||||
shortBio: z.coerce.boolean().optional(),
|
||||
geodbCityIds: arraybeSchema.optional(),
|
||||
lat: z.coerce.number().optional(),
|
||||
lon: z.coerce.number().optional(),
|
||||
radius: z.coerce.number().optional(),
|
||||
compatibleWithUserId: z.string().optional(),
|
||||
orderBy: z
|
||||
.enum(['last_online_time', 'created_time', 'compatibility_score'])
|
||||
@@ -336,8 +372,9 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-profile-answers': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
props: z.object({ userId: z.string() }).strict(),
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({userId: z.string()}).strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
answers: Row<'love_compatibility_answers'>[]
|
||||
@@ -346,6 +383,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-comment': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
userId: z.string(),
|
||||
content: contentSchema,
|
||||
@@ -356,6 +394,7 @@ export const API = (_apiTypeCheck = {
|
||||
'hide-comment': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
commentId: z.string(),
|
||||
hide: z.boolean(),
|
||||
@@ -365,6 +404,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-memberships': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number().optional(),
|
||||
createdTime: z.string().optional(),
|
||||
@@ -379,6 +419,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-messages': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number(),
|
||||
limit: z.coerce.number(),
|
||||
@@ -389,6 +430,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-seen-time': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelIds: z
|
||||
.array(z.coerce.number())
|
||||
@@ -400,13 +442,21 @@ export const API = (_apiTypeCheck = {
|
||||
'set-channel-seen-time': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number(),
|
||||
}),
|
||||
},
|
||||
'set-last-online-time': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
},
|
||||
'get-notifications': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
returns: [] as Notification[],
|
||||
props: z
|
||||
.object({
|
||||
@@ -418,6 +468,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-private-user-message': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
content: contentSchema,
|
||||
@@ -427,6 +478,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
userIds: z.array(z.string()),
|
||||
@@ -435,6 +487,7 @@ export const API = (_apiTypeCheck = {
|
||||
'update-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
channelId: z.number(),
|
||||
@@ -444,6 +497,7 @@ export const API = (_apiTypeCheck = {
|
||||
'leave-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
channelId: z.number(),
|
||||
@@ -452,15 +506,39 @@ export const API = (_apiTypeCheck = {
|
||||
'create-compatibility-question': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
|
||||
options: z.record(z.string(), z.number()),
|
||||
}),
|
||||
},
|
||||
'create-vote': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
title: z.string().min(1),
|
||||
isAnonymous: z.boolean(),
|
||||
description: contentSchema,
|
||||
}),
|
||||
},
|
||||
'vote': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
voteId: z.number(),
|
||||
priority: z.number(),
|
||||
choice: z.enum(['for', 'abstain', 'against']),
|
||||
}),
|
||||
},
|
||||
'search-location': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
term: z.string(),
|
||||
@@ -469,13 +547,31 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'search-near-city': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
cityId: z.string(),
|
||||
radius: z.number().min(1).max(500),
|
||||
}),
|
||||
},
|
||||
'contact': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
content: contentSchema,
|
||||
userId: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'get-messages-count': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: false,
|
||||
props: z.object({}),
|
||||
returns: {} as { count: number },
|
||||
},
|
||||
} as const)
|
||||
|
||||
export type APIPath = keyof typeof API
|
||||
@@ -487,8 +583,8 @@ export type ValidatedAPIParams<N extends APIPath> = z.output<
|
||||
>
|
||||
|
||||
export type APIResponse<N extends APIPath> = APISchema<N> extends {
|
||||
returns: Record<string, any>
|
||||
}
|
||||
returns: Record<string, any>
|
||||
}
|
||||
? APISchema<N>['returns']
|
||||
: void
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ export class APIError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = 'v0'
|
||||
const prefix = ''
|
||||
|
||||
export function pathWithPrefix(path: string) {
|
||||
return `/${prefix}${path}`
|
||||
return `${prefix}${path}`
|
||||
}
|
||||
|
||||
export function getWebsocketUrl() {
|
||||
@@ -33,5 +33,5 @@ export function getWebsocketUrl() {
|
||||
|
||||
export function getApiUrl(path: string) {
|
||||
const protocol = IS_LOCAL ? 'http' : 'https'
|
||||
return `${protocol}://${BACKEND_DOMAIN}/${prefix}/${path}`
|
||||
return `${protocol}://${BACKEND_DOMAIN}${prefix}/${path}`
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export class APIRealtimeClient {
|
||||
// subscribers by the topic they are subscribed to
|
||||
subscriptions: Map<string, BroadcastHandler[]>
|
||||
connectTimeout?: NodeJS.Timeout
|
||||
heartbeat?: NodeJS.Timeout
|
||||
heartbeat?: number | undefined;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
@@ -90,10 +90,12 @@ export class APIRealtimeClient {
|
||||
if (VERBOSE_LOGGING) {
|
||||
console.info('API websocket opened.')
|
||||
}
|
||||
this.heartbeat = setInterval(
|
||||
async () => this.sendMessage('ping', {}).catch(console.error),
|
||||
30000
|
||||
)
|
||||
// Send a heartbeat ping every 25s
|
||||
this.heartbeat = window.setInterval(() => {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage('ping', {}).catch(console.error);
|
||||
}
|
||||
}, 25000);
|
||||
if (this.subscriptions.size > 0) {
|
||||
this.sendMessage('subscribe', {
|
||||
topics: Array.from(this.subscriptions.keys()),
|
||||
@@ -105,7 +107,7 @@ export class APIRealtimeClient {
|
||||
if (VERBOSE_LOGGING) {
|
||||
console.info(`API websocket closed with code=${ev.code}: ${ev.reason}`)
|
||||
}
|
||||
clearInterval(this.heartbeat)
|
||||
if (this.heartbeat) clearInterval(this.heartbeat)
|
||||
|
||||
// mqp: we might need to change how the txn stuff works if we ever want to
|
||||
// implement "wait until i am subscribed, and then do something" in a component.
|
||||
|
||||
@@ -49,19 +49,14 @@ export const baseProfilesSchema = z.object({
|
||||
pref_gender: genderTypes,
|
||||
pref_age_min: z.number().min(18).max(100).optional(),
|
||||
pref_age_max: z.number().min(18).max(100).optional(),
|
||||
pref_relation_styles: z.array(
|
||||
z.union([
|
||||
z.literal('collaboration'),
|
||||
z.literal('friendship'),
|
||||
z.literal('relationship'),
|
||||
])
|
||||
),
|
||||
pref_relation_styles: z.array(z.string()),
|
||||
wants_kids_strength: z.number(),
|
||||
looking_for_matches: z.boolean(),
|
||||
photo_urls: z.array(z.string()),
|
||||
visibility: z.union([z.literal('public'), z.literal('member')]),
|
||||
|
||||
bio: contentSchema.optional().nullable(),
|
||||
bio_length: z.number().optional().nullable(),
|
||||
|
||||
geodb_city_id: z.string().optional(),
|
||||
city: z.string(),
|
||||
@@ -83,7 +78,6 @@ const optionalProfilesSchema = z.object({
|
||||
height_in_inches: z.number().optional(),
|
||||
has_pets: z.boolean().optional(),
|
||||
education_level: z.string().optional(),
|
||||
last_online_time: z.string().optional(),
|
||||
is_smoker: z.boolean().optional(),
|
||||
drinks_per_month: z.number().min(0).optional(),
|
||||
is_vegetarian_or_vegan: z.boolean().optional(),
|
||||
@@ -97,6 +91,7 @@ const optionalProfilesSchema = z.object({
|
||||
bio: contentSchema.optional().nullable(),
|
||||
twitter: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
pref_romantic_styles: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const combinedLoveUsersSchema =
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const MAX_INT = 99999
|
||||
export const MIN_INT = -MAX_INT
|
||||
|
||||
export const supportEmail = 'compass.meet.info@gmail.com';
|
||||
export const marketingEmail = 'compass.meet.marketing@gmail.com';
|
||||
export const supportEmail = 'hello@compassmeet.com';
|
||||
// export const marketingEmail = 'hello@compassmeet.com';
|
||||
|
||||
export const githubRepo = "https://github.com/CompassConnections/Compass";
|
||||
export const githubIssues = `${githubRepo}/issues`
|
||||
@@ -10,6 +10,14 @@ export const githubIssues = `${githubRepo}/issues`
|
||||
export const paypalLink = "https://www.paypal.com/paypalme/CompassConnections"
|
||||
export const patreonLink = "https://patreon.com/CompassMeet"
|
||||
export const discordLink = "https://discord.gg/8Vd7jzqjun"
|
||||
export const stoatLink = "https://stt.gg/YKQp81yA"
|
||||
export const redditLink = "https://www.reddit.com/r/CompassConnect"
|
||||
export const xLink = "https://x.com/compassmeet"
|
||||
export const formLink = "https://forms.gle/tKnXUMAbEreMK6FC6"
|
||||
|
||||
export const pStyle = "mt-1 text-gray-800 dark:text-white whitespace-pre-line";
|
||||
|
||||
export const IS_MAINTENANCE = false; // set to true to enable maintenance mode banner
|
||||
|
||||
export const MIN_BIO_LENGTH = 250;
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ export const sendDiscordMessage = async (content: string, channel: string) => {
|
||||
let webhookUrl = {
|
||||
members: process.env.DISCORD_WEBHOOK_MEMBERS,
|
||||
general: process.env.DISCORD_WEBHOOK_GENERAL,
|
||||
health: process.env.DISCORD_WEBHOOK_HEALTH,
|
||||
reports: process.env.DISCORD_WEBHOOK_REPORTS,
|
||||
contact: process.env.DISCORD_WEBHOOK_CONTACT,
|
||||
}[channel]
|
||||
|
||||
if (IS_DEV) webhookUrl = process.env.DISCORD_WEBHOOK_DEV
|
||||
|
||||
// console.log(`Discord webhook URL: ${webhookUrl}`, channel, content)
|
||||
|
||||
if (!webhookUrl) return
|
||||
|
||||
const response = await fetch(webhookUrl!, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {DEV_CONFIG} from './dev'
|
||||
import {PROD_CONFIG} from './prod'
|
||||
import {isProd} from "common/envs/is-prod";
|
||||
|
||||
export const MAX_DESCRIPTION_LENGTH = 16000
|
||||
export const MAX_DESCRIPTION_LENGTH = 100000
|
||||
export const MAX_ANSWER_LENGTH = 240
|
||||
|
||||
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG
|
||||
@@ -26,7 +26,7 @@ export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
|
||||
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
|
||||
export const IS_LOCAL = !IS_GOOGLE_CLOUD && !IS_VERCEL
|
||||
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
|
||||
console.log(`Running in ${HOSTING_ENV} (${ENV})`,);
|
||||
console.debug(`Running in ${HOSTING_ENV} (${ENV})`,);
|
||||
|
||||
// class MissingKeyError implements Error {
|
||||
// constructor(key: string) {
|
||||
@@ -68,20 +68,25 @@ export const VERIFIED_USERNAMES = [
|
||||
export const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||
|
||||
export const RESERVED_PATHS = [
|
||||
'404',
|
||||
'_app',
|
||||
'_document',
|
||||
'_next',
|
||||
'about',
|
||||
'ad',
|
||||
'add-funds',
|
||||
'ads',
|
||||
'admin',
|
||||
'ads',
|
||||
'analytics',
|
||||
'api',
|
||||
'browse',
|
||||
'career',
|
||||
'careers',
|
||||
'charts',
|
||||
'chat',
|
||||
'chats',
|
||||
'common',
|
||||
'confirm-email',
|
||||
'contact',
|
||||
'contacts',
|
||||
'create',
|
||||
@@ -89,6 +94,8 @@ export const RESERVED_PATHS = [
|
||||
'discord',
|
||||
'embed',
|
||||
'facebook',
|
||||
'faq',
|
||||
'financials',
|
||||
'find',
|
||||
'github',
|
||||
'google',
|
||||
@@ -96,18 +103,23 @@ export const RESERVED_PATHS = [
|
||||
'groups',
|
||||
'help',
|
||||
'home',
|
||||
'index',
|
||||
'link',
|
||||
'linkAccount',
|
||||
'links',
|
||||
'live',
|
||||
'login',
|
||||
'love-questions',
|
||||
'manifest',
|
||||
'market',
|
||||
'markets',
|
||||
'md',
|
||||
'members',
|
||||
'message',
|
||||
'messages',
|
||||
'notifications',
|
||||
'og-test',
|
||||
'organization',
|
||||
'payments',
|
||||
'privacy',
|
||||
'profile',
|
||||
@@ -115,16 +127,22 @@ export const RESERVED_PATHS = [
|
||||
'questions',
|
||||
'referral',
|
||||
'referrals',
|
||||
'register',
|
||||
'send',
|
||||
'server-sitemap',
|
||||
'sign-in',
|
||||
'sign-in-waiting',
|
||||
'signin',
|
||||
'signup',
|
||||
'sitemap',
|
||||
'slack',
|
||||
'social',
|
||||
'stats',
|
||||
'styles',
|
||||
'support',
|
||||
'team',
|
||||
'terms',
|
||||
'tips-bio',
|
||||
'twitch',
|
||||
'twitter',
|
||||
'user',
|
||||
|
||||
@@ -5,7 +5,7 @@ export const DEV_CONFIG: EnvConfig = {
|
||||
domain: 'dev.compassmeet.com',
|
||||
backendDomain: 'api.dev.compassmeet.com',
|
||||
supabaseInstanceId: 'zbspxezubpzxmuxciurg',
|
||||
supabasePwd: 'FO3y0G7chzdq6aE7', // For database write access (dev). A 16-character password with digits and letters.
|
||||
supabasePwd: 'ZTNlifGKofSKhu8c', // For database write access (dev). A 16-character password with digits and letters.
|
||||
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpic3B4ZXp1YnB6eG11eGNpdXJnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc2ODM0MTMsImV4cCI6MjA3MzI1OTQxM30.ZkM7zlawP8Nke0T3KJrqpOQ4DzqPaXTaJXLC2WU8Y7c',
|
||||
googleApplicationCredentials: 'googleApplicationCredentials-dev.json',
|
||||
firebaseConfig: {
|
||||
@@ -13,10 +13,13 @@ export const DEV_CONFIG: EnvConfig = {
|
||||
authDomain: "compass-57c3c.firebaseapp.com",
|
||||
projectId: "compass-57c3c",
|
||||
storageBucket: "compass-57c3c.firebasestorage.app",
|
||||
privateBucket: 'compass-private.firebasestorage.app',
|
||||
privateBucket: 'compass-130ba-private',
|
||||
messagingSenderId: "297460199314",
|
||||
appId: "1:297460199314:web:c45678c54285910e255b4b",
|
||||
measurementId: "G-N6LZ64EMJ2",
|
||||
region: 'us-west1',
|
||||
},
|
||||
adminIds: [
|
||||
'ULxLz04VW1V4vbnj5XLwvzCSkYd2', // Martin
|
||||
],
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const PROD_CONFIG: EnvConfig = {
|
||||
authDomain: "compass-130ba.firebaseapp.com",
|
||||
projectId: "compass-130ba",
|
||||
storageBucket: "compass-130ba.firebasestorage.app",
|
||||
privateBucket: 'compass-private.firebasestorage.app',
|
||||
privateBucket: 'compass-130ba-private',
|
||||
messagingSenderId: "253367029065",
|
||||
appId: "1:253367029065:web:b338785af99d4145095e98",
|
||||
measurementId: "G-2LSQYJQE6P",
|
||||
|
||||
@@ -2,11 +2,21 @@ import {Profile, ProfileRow} from "common/love/profile";
|
||||
import {cloneDeep} from "lodash";
|
||||
import {filterDefined} from "common/util/array";
|
||||
|
||||
// export type TargetArea = {
|
||||
// lat: number
|
||||
// lon: number
|
||||
// radius: number
|
||||
// }
|
||||
|
||||
export type FilterFields = {
|
||||
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
|
||||
geodbCityIds: string[] | null
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
radius: number | null
|
||||
genders: string[]
|
||||
name: string | undefined
|
||||
shortBio: boolean | undefined
|
||||
} & Pick<
|
||||
ProfileRow,
|
||||
| 'wants_kids_strength'
|
||||
@@ -17,6 +27,7 @@ export type FilterFields = {
|
||||
| 'pref_age_min'
|
||||
| 'pref_age_max'
|
||||
>
|
||||
|
||||
export const orderProfiles = (
|
||||
profiles: Profile[],
|
||||
starredUserIds: string[] | undefined
|
||||
@@ -38,6 +49,9 @@ export const orderProfiles = (
|
||||
}
|
||||
export const initialFilters: Partial<FilterFields> = {
|
||||
geodbCityIds: undefined,
|
||||
lat: undefined,
|
||||
lon: undefined,
|
||||
radius: undefined,
|
||||
name: undefined,
|
||||
genders: undefined,
|
||||
pref_age_max: undefined,
|
||||
@@ -47,6 +61,11 @@ export const initialFilters: Partial<FilterFields> = {
|
||||
is_smoker: undefined,
|
||||
pref_relation_styles: undefined,
|
||||
pref_gender: undefined,
|
||||
shortBio: undefined,
|
||||
orderBy: 'created_time',
|
||||
}
|
||||
export type OriginLocation = { id: string; name: string }
|
||||
|
||||
|
||||
export const FilterKeys = Object.keys(initialFilters) as (keyof FilterFields)[]
|
||||
|
||||
export type OriginLocation = { id: string; name: string, lat: number, lon: number }
|
||||
|
||||
@@ -23,10 +23,10 @@ export const geodbFetch = async (endpoint: string) => {
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
console.log('geodbFetch', endpoint, data)
|
||||
console.debug('geodbFetch', endpoint, data)
|
||||
return {status: 'success', data}
|
||||
} catch (error) {
|
||||
console.log('geodbFetch', endpoint, error)
|
||||
console.debug('geodbFetch', endpoint, error)
|
||||
return {status: 'failure', data: error}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const isPreferredGender = (
|
||||
preferredGenders: string[] | undefined,
|
||||
gender: string | undefined
|
||||
) => {
|
||||
// console.log('isPreferredGender', preferredGenders, gender)
|
||||
// console.debug('isPreferredGender', preferredGenders, gender)
|
||||
if (preferredGenders === undefined || preferredGenders.length === 0 || gender === undefined) return true
|
||||
|
||||
// If simple gender preference, don't include non-binary.
|
||||
@@ -19,7 +19,7 @@ const isPreferredGender = (
|
||||
}
|
||||
|
||||
export const areGenderCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
|
||||
// console.log('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
|
||||
// console.debug('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
|
||||
return (
|
||||
isPreferredGender(profile1.pref_gender, profile2.gender) &&
|
||||
isPreferredGender(profile2.pref_gender, profile1.gender)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { User } from 'common/user'
|
||||
export type ProfileRow = Row<'profiles'>
|
||||
export type Profile = ProfileRow & { user: User }
|
||||
export const getProfileRow = async (userId: string, db: SupabaseClient) => {
|
||||
console.log('getProfileRow', userId)
|
||||
console.debug('getProfileRow', userId)
|
||||
const res = await run(db.from('profiles').select('*').eq('user_id', userId))
|
||||
return res.data[0]
|
||||
}
|
||||
|
||||
89
common/src/md.ts
Normal file
89
common/src/md.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
export function jsonToMarkdown(node: JSONContent): string {
|
||||
if (!node) return ''
|
||||
|
||||
// Text node
|
||||
if (node.type === 'text') {
|
||||
let text = node.text || ''
|
||||
|
||||
if (node.marks) {
|
||||
for (const mark of node.marks) {
|
||||
switch (mark.type) {
|
||||
case 'bold':
|
||||
text = `**${text}**`
|
||||
break
|
||||
case 'italic':
|
||||
text = `*${text}*`
|
||||
break
|
||||
case 'strike':
|
||||
text = `~~${text}~~`
|
||||
break
|
||||
case 'code':
|
||||
text = `\`${text}\``
|
||||
break
|
||||
case 'link':
|
||||
text = `[${text}](${mark.attrs?.href ?? ''})`
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// Non-text nodes: recursively process children
|
||||
const content = (node.content || []).map(jsonToMarkdown).join('')
|
||||
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
return `${content}\n`
|
||||
case 'heading': {
|
||||
const level = node.attrs?.level || 1
|
||||
return `${'#'.repeat(level)} ${content}\n`
|
||||
}
|
||||
case 'bulletList':
|
||||
return `${content}`
|
||||
case 'orderedList':
|
||||
return `${content}`
|
||||
case 'listItem':
|
||||
return `- ${content}`
|
||||
case 'blockquote':
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => (line ? `> ${line}` : ''))
|
||||
.join('\n') + '\n\n'
|
||||
case 'codeBlock':
|
||||
return `\`\`\`\n${content}\n\`\`\`\n\n`
|
||||
case 'horizontalRule':
|
||||
return `---\n\n`
|
||||
case 'hardBreak':
|
||||
return ` \n`
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// function extractTextFromJsonb(bio: JSONContent): string {
|
||||
// try {
|
||||
// const texts: string[] = []
|
||||
// const visit = (node: any) => {
|
||||
// if (!node) return
|
||||
// if (Array.isArray(node)) {
|
||||
// for (const item of node) visit(item)
|
||||
// return
|
||||
// }
|
||||
// if (typeof node === 'object') {
|
||||
// for (const [k, v] of Object.entries(node)) {
|
||||
// if (k === 'text' && typeof v === 'string') texts.push(v)
|
||||
// else visit(v as any)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// visit(bio)
|
||||
// // Remove extra whitespace and join
|
||||
// return texts.map((t) => t.trim()).filter(Boolean).join(' ')
|
||||
// } catch {
|
||||
// return ''
|
||||
// }
|
||||
// }
|
||||
@@ -25,6 +25,18 @@ export type locationType = {
|
||||
radius: number
|
||||
}
|
||||
|
||||
const skippedKeys = [
|
||||
'pref_age_min',
|
||||
'pref_age_max',
|
||||
'geodbCityIds',
|
||||
'orderBy',
|
||||
'shortBio',
|
||||
'targetArea',
|
||||
'lat',
|
||||
'lon',
|
||||
'radius',
|
||||
]
|
||||
|
||||
|
||||
export function formatFilters(filters: Partial<FilterFields>, location: locationType | null): String[] | null {
|
||||
const entries: String[] = []
|
||||
@@ -53,7 +65,7 @@ export function formatFilters(filters: Partial<FilterFields>, location: location
|
||||
const typedKey = key as keyof FilterFields
|
||||
|
||||
if (value === undefined || value === null) return
|
||||
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy') return
|
||||
if (skippedKeys.includes(typedKey)) return
|
||||
if (Array.isArray(value) && value.length === 0) return
|
||||
if (initialFilters[typedKey] === value) return
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export const secrets = (
|
||||
'NEXT_PUBLIC_FIREBASE_API_KEY',
|
||||
'DISCORD_WEBHOOK_MEMBERS',
|
||||
'DISCORD_WEBHOOK_GENERAL',
|
||||
'DISCORD_WEBHOOK_HEALTH',
|
||||
'DISCORD_WEBHOOK_REPORTS',
|
||||
'DISCORD_WEBHOOK_CONTACT',
|
||||
// Some typescript voodoo to keep the string literal types while being not readonly.
|
||||
] as const
|
||||
).concat()
|
||||
@@ -32,7 +35,7 @@ type SecretId = (typeof secrets)[number]
|
||||
export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
|
||||
if (!ids.length && IS_LOCAL) return {}
|
||||
|
||||
// console.log('Fetching secrets...')
|
||||
// console.debug('Fetching secrets...')
|
||||
let client: SecretManagerServiceClient
|
||||
if (credentials) {
|
||||
const projectId = credentials['project_id']
|
||||
@@ -47,7 +50,7 @@ export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
|
||||
|
||||
const secretIds = ids.length > 0 ? ids : secrets
|
||||
|
||||
console.log('secretIds', secretIds)
|
||||
console.debug('secretIds', secretIds)
|
||||
|
||||
const fullSecretNames = secretIds.map(
|
||||
(secret: string) =>
|
||||
@@ -75,7 +78,7 @@ export const loadSecretsToEnv = async (credentials?: any) => {
|
||||
for (const [key, value] of Object.entries(allSecrets)) {
|
||||
if (key && value) {
|
||||
process.env[key] = value
|
||||
// console.log(key, value)
|
||||
// console.debug(key, value)
|
||||
}
|
||||
}
|
||||
refreshConfig()
|
||||
|
||||
@@ -90,7 +90,7 @@ export const getSocialUrl = (site: Site, handle: string) =>
|
||||
const urler: { [key in Site]: (handle: string) => string } = {
|
||||
site: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
okcupid: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
x: (s) => `https://x.com/${s}`,
|
||||
x: (s) => s.startsWith('http') ? s : `https://x.com/${s}`,
|
||||
discord: (s) =>
|
||||
(s.length === 17 || s.length === 18) && !isNaN(parseInt(s, 10))
|
||||
? `https://discord.com/users/${s}` // discord user id
|
||||
@@ -98,14 +98,14 @@ const urler: { [key in Site]: (handle: string) => string } = {
|
||||
bluesky: (s) => `https://bsky.app/profile/${s}`,
|
||||
mastodon: (s) =>
|
||||
s.includes('@') ? `https://${s.split('@')[1]}/@${s.split('@')[0]}` : s,
|
||||
substack: (s) => `https://${s}.substack.com`,
|
||||
instagram: (s) => `https://instagram.com/${s}`,
|
||||
github: (s) => `https://github.com/${s}`,
|
||||
linkedin: (s) => `https://linkedin.com/in/${s}`,
|
||||
facebook: (s) => `https://facebook.com/${s}`,
|
||||
spotify: (s) => `https://open.spotify.com/user/${s}`,
|
||||
paypal: (s) => `https://paypal.com/paypalme/${s}`,
|
||||
patreon: (s) => `https://patreon.com/${s}`,
|
||||
substack: (s) => s.startsWith('http') ? s : `https://${s}.substack.com`,
|
||||
instagram: (s) => s.startsWith('http') ? s : `https://instagram.com/${s}`,
|
||||
github: (s) => s.startsWith('http') ? s : `https://github.com/${s}`,
|
||||
linkedin: (s) => s.startsWith('http') ? s : `https://linkedin.com/in/${s}`,
|
||||
facebook: (s) => s.startsWith('http') ? s : `https://facebook.com/${s}`,
|
||||
spotify: (s) => s.startsWith('http') ? s : `https://open.spotify.com/user/${s}`,
|
||||
paypal: (s) => s.startsWith('http') ? s : `https://paypal.com/paypalme/${s}`,
|
||||
patreon: (s) => s.startsWith('http') ? s : `https://patreon.com/${s}`,
|
||||
calendly: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
datingdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
friendshipdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),
|
||||
|
||||
@@ -44,6 +44,35 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
contact: {
|
||||
Row: {
|
||||
content: Json | null
|
||||
created_time: string | null
|
||||
id: string
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
content?: Json | null
|
||||
created_time?: string | null
|
||||
id?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
content?: Json | null
|
||||
created_time?: string | null
|
||||
id?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'contact_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
love_answers: {
|
||||
Row: {
|
||||
created_time: string
|
||||
@@ -221,45 +250,6 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profile_comments: {
|
||||
Row: {
|
||||
content: Json
|
||||
created_time: string
|
||||
hidden: boolean
|
||||
id: number
|
||||
on_user_id: string
|
||||
reply_to_comment_id: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Insert: {
|
||||
content: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: never
|
||||
on_user_id: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Update: {
|
||||
content?: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: never
|
||||
on_user_id?: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
user_username?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
private_user_message_channel_members: {
|
||||
Row: {
|
||||
channel_id: number
|
||||
@@ -401,10 +391,52 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profile_comments: {
|
||||
Row: {
|
||||
content: Json
|
||||
created_time: string
|
||||
hidden: boolean
|
||||
id: number
|
||||
on_user_id: string
|
||||
reply_to_comment_id: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Insert: {
|
||||
content: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: number
|
||||
on_user_id: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_username: string
|
||||
}
|
||||
Update: {
|
||||
content?: Json
|
||||
created_time?: string
|
||||
hidden?: boolean
|
||||
id?: number
|
||||
on_user_id?: string
|
||||
reply_to_comment_id?: number | null
|
||||
user_avatar_url?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
user_username?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
age: number | null
|
||||
bio: Json | null
|
||||
bio_length: number | null
|
||||
bio_text: string | null
|
||||
bio_tsv: unknown | null
|
||||
born_in_location: string | null
|
||||
city: string
|
||||
city_latitude: number | null
|
||||
@@ -424,7 +456,6 @@ export type Database = {
|
||||
is_smoker: boolean | null
|
||||
is_vegetarian_or_vegan: boolean | null
|
||||
last_modification_time: string
|
||||
last_online_time: string
|
||||
looking_for_matches: boolean
|
||||
messaging_status: string
|
||||
occupation: string | null
|
||||
@@ -436,6 +467,7 @@ export type Database = {
|
||||
pref_age_min: number | null
|
||||
pref_gender: string[]
|
||||
pref_relation_styles: string[]
|
||||
pref_romantic_styles: string[] | null
|
||||
referred_by_username: string | null
|
||||
region_code: string | null
|
||||
religious_belief_strength: number | null
|
||||
@@ -443,13 +475,16 @@ export type Database = {
|
||||
twitter: string | null
|
||||
university: string | null
|
||||
user_id: string
|
||||
visibility: Database['public']['Enums']['profile_visibility']
|
||||
visibility: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength: number
|
||||
website: string | null
|
||||
}
|
||||
Insert: {
|
||||
age?: number | null
|
||||
bio?: Json | null
|
||||
bio_length?: number | null
|
||||
bio_text?: string | null
|
||||
bio_tsv?: unknown | null
|
||||
born_in_location?: string | null
|
||||
city: string
|
||||
city_latitude?: number | null
|
||||
@@ -465,11 +500,10 @@ export type Database = {
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
id?: never
|
||||
id?: number
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
last_online_time?: string
|
||||
looking_for_matches?: boolean
|
||||
messaging_status?: string
|
||||
occupation?: string | null
|
||||
@@ -481,6 +515,7 @@ export type Database = {
|
||||
pref_age_min?: number | null
|
||||
pref_gender: string[]
|
||||
pref_relation_styles: string[]
|
||||
pref_romantic_styles?: string[] | null
|
||||
referred_by_username?: string | null
|
||||
region_code?: string | null
|
||||
religious_belief_strength?: number | null
|
||||
@@ -488,13 +523,16 @@ export type Database = {
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id: string
|
||||
visibility?: Database['public']['Enums']['profile_visibility']
|
||||
visibility?: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength?: number
|
||||
website?: string | null
|
||||
}
|
||||
Update: {
|
||||
age?: number | null
|
||||
bio?: Json | null
|
||||
bio_length?: number | null
|
||||
bio_text?: string | null
|
||||
bio_tsv?: unknown | null
|
||||
born_in_location?: string | null
|
||||
city?: string
|
||||
city_latitude?: number | null
|
||||
@@ -510,11 +548,10 @@ export type Database = {
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
id?: never
|
||||
id?: number
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
last_online_time?: string
|
||||
looking_for_matches?: boolean
|
||||
messaging_status?: string
|
||||
occupation?: string | null
|
||||
@@ -526,6 +563,7 @@ export type Database = {
|
||||
pref_age_min?: number | null
|
||||
pref_gender?: string[]
|
||||
pref_relation_styles?: string[]
|
||||
pref_romantic_styles?: string[] | null
|
||||
referred_by_username?: string | null
|
||||
region_code?: string | null
|
||||
religious_belief_strength?: number | null
|
||||
@@ -533,7 +571,7 @@ export type Database = {
|
||||
twitter?: string | null
|
||||
university?: string | null
|
||||
user_id?: string
|
||||
visibility?: Database['public']['Enums']['profile_visibility']
|
||||
visibility?: Database['public']['Enums']['lover_visibility']
|
||||
wants_kids_strength?: number
|
||||
website?: string | null
|
||||
}
|
||||
@@ -590,6 +628,29 @@ export type Database = {
|
||||
}
|
||||
]
|
||||
}
|
||||
user_activity: {
|
||||
Row: {
|
||||
last_online_time: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
last_online_time: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
last_online_time?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'user_activity_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: true
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
user_events: {
|
||||
Row: {
|
||||
ad_id: string | null
|
||||
@@ -668,6 +729,83 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
vote_results: {
|
||||
Row: {
|
||||
choice: number
|
||||
created_time: string
|
||||
id: number
|
||||
priority: number
|
||||
user_id: string
|
||||
vote_id: number
|
||||
}
|
||||
Insert: {
|
||||
choice: number
|
||||
created_time?: string
|
||||
id?: never
|
||||
priority: number
|
||||
user_id: string
|
||||
vote_id: number
|
||||
}
|
||||
Update: {
|
||||
choice?: number
|
||||
created_time?: string
|
||||
id?: never
|
||||
priority?: number
|
||||
user_id?: string
|
||||
vote_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'vote_results_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'vote_results_vote_id_fkey'
|
||||
columns: ['vote_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'votes'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
votes: {
|
||||
Row: {
|
||||
created_time: string
|
||||
creator_id: string
|
||||
description: Json | null
|
||||
id: number
|
||||
is_anonymous: boolean | null
|
||||
title: string
|
||||
}
|
||||
Insert: {
|
||||
created_time?: string
|
||||
creator_id: string
|
||||
description?: Json | null
|
||||
id?: never
|
||||
is_anonymous?: boolean | null
|
||||
title: string
|
||||
}
|
||||
Update: {
|
||||
created_time?: string
|
||||
creator_id?: string
|
||||
description?: Json | null
|
||||
id?: never
|
||||
is_anonymous?: boolean | null
|
||||
title?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'votes_creator_id_fkey'
|
||||
columns: ['creator_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
@@ -693,10 +831,29 @@ export type Database = {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
get_love_question_answers_and_lovers: {
|
||||
Args: { p_question_id: number }
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
get_love_question_answers_and_profiles: {
|
||||
Args: { p_question_id: number }
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
get_votes_with_results: {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: {
|
||||
created_time: string
|
||||
creator_id: string
|
||||
description: Json
|
||||
id: number
|
||||
is_anonymous: boolean
|
||||
priority: number
|
||||
title: string
|
||||
votes_abstain: number
|
||||
votes_against: number
|
||||
votes_for: number
|
||||
}[]
|
||||
}
|
||||
gtrgm_compress: {
|
||||
Args: { '': unknown }
|
||||
Returns: unknown
|
||||
@@ -750,12 +907,12 @@ export type Database = {
|
||||
Returns: Json
|
||||
}
|
||||
ts_to_millis: {
|
||||
Args: { ts: string }
|
||||
Args: { ts: string } | { ts: string }
|
||||
Returns: number
|
||||
}
|
||||
}
|
||||
Enums: {
|
||||
profile_visibility: 'public' | 'member'
|
||||
lover_visibility: 'public' | 'member'
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
@@ -883,7 +1040,7 @@ export type CompositeTypes<
|
||||
export const Constants = {
|
||||
public: {
|
||||
Enums: {
|
||||
profile_visibility: ['public', 'member'],
|
||||
lover_visibility: ['public', 'member'],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createClient(
|
||||
opts?: SupabaseClientOptionsGeneric<'public'>
|
||||
) {
|
||||
const url = `https://${instanceId}.supabase.co`
|
||||
// console.log('createClient', instanceId, key, opts)
|
||||
// console.debug('createClient', instanceId, key, opts)
|
||||
return createClientGeneric(
|
||||
url,
|
||||
key,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const getDefaultNotificationPreferences = (isDev?: boolean) => {
|
||||
return defaults
|
||||
}
|
||||
|
||||
export const UNSUBSCRIBE_URL = 'https://compassmeet.com/notifications';
|
||||
export const getNotificationDestinationsForUser = (
|
||||
privateUser: PrivateUser,
|
||||
type: notification_preference
|
||||
@@ -72,7 +73,7 @@ export const getNotificationDestinationsForUser = (
|
||||
destinations.includes('browser') && !opt_out.includes('browser'),
|
||||
sendToMobile:
|
||||
destinations.includes('mobile') && !opt_out.includes('mobile'),
|
||||
unsubscribeUrl: 'https://compassmeet.com/notifications',
|
||||
unsubscribeUrl: UNSUBSCRIBE_URL,
|
||||
urlToManageThisNotification: '/notifications',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export type PrivateUser = {
|
||||
blockedByUserIds: string[]
|
||||
}
|
||||
|
||||
export type UserActivity = {
|
||||
user_id: string // same as User.id
|
||||
last_online_time: string
|
||||
}
|
||||
|
||||
export type UserAndPrivateUser = { user: User; privateUser: PrivateUser }
|
||||
|
||||
export function getCurrentUtcTime(): Date {
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function baseApiCall(props: {
|
||||
body:
|
||||
params == null || method === 'GET' ? undefined : JSON.stringify(params),
|
||||
})
|
||||
// console.log(req)
|
||||
// console.log('Request', req)
|
||||
return fetch(req).then(async (resp) => {
|
||||
const json = (await resp.json()) as { [k: string]: any }
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -32,7 +32,7 @@ export function factorizeMatrix(
|
||||
const mFeatures = fillMatrix(m, FEATURES, initCell)
|
||||
const nFeatures = fillMatrix(n, FEATURES, initCell)
|
||||
|
||||
console.log('rows', m, 'columns', n, 'numPoints', points)
|
||||
console.debug('rows', m, 'columns', n, 'numPoints', points)
|
||||
|
||||
const updateFeature = (a: number, b: number, error: number) =>
|
||||
a + LEARNING_RATE * (2 * error * b - REGULARIZATION_RATE * a)
|
||||
@@ -75,7 +75,7 @@ export function factorizeMatrix(
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(iter, 'error', totalError / points)
|
||||
console.debug(iter, 'error', totalError / points)
|
||||
|
||||
// Complete factorization process if total error falls below a certain threshold
|
||||
if (totalError / points < THRESHOLD) break
|
||||
|
||||
7
common/src/votes/constants.ts
Normal file
7
common/src/votes/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const ORDER_BY = ['recent', 'mostVoted', 'priority'] as const
|
||||
export type OrderBy = typeof ORDER_BY[number]
|
||||
export const Constants: Record<OrderBy, string> = {
|
||||
recent: 'Most recent',
|
||||
mostVoted: 'Most voted',
|
||||
priority: 'Highest Priority',
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
|
||||
export function wantsKidsDatabaseToWantsKidsFilter(
|
||||
wantsKidsStrength: wantsKidsDatabase
|
||||
) {
|
||||
// console.log(wantsKidsStrength)
|
||||
// console.debug(wantsKidsStrength)
|
||||
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
|
||||
return wantsKidsLabels.no_preference.strength
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
{
|
||||
"bucket": "compass-130ba-private.firebasestorage.app",
|
||||
"bucket": "compass-130ba-private",
|
||||
"rules": "private-storage.rules"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "compass",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"common",
|
||||
|
||||
@@ -5,7 +5,7 @@ cd "$(dirname "$0")"/..
|
||||
|
||||
source .env
|
||||
|
||||
export url=http://localhost:8088/v0
|
||||
export url=http://localhost:8088
|
||||
#export url=https://api.compassmeet.com
|
||||
|
||||
export endpoint=/internal/send-search-notifications
|
||||
|
||||
@@ -9,6 +9,9 @@ if [ ! -f .env ]; then
|
||||
echo ".env file created from .env.example"
|
||||
fi
|
||||
|
||||
source .env.example
|
||||
source .env
|
||||
|
||||
echo $GOOGLE_CREDENTIALS_ENC_PWD
|
||||
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -in secrets/googleApplicationCredentials-dev.json.enc -out backend/shared/src/googleApplicationCredentials-dev.json -pass pass:$GOOGLE_CREDENTIALS_ENC_PWD
|
||||
@@ -58,7 +58,7 @@ function AddCompatibilityQuestionModal(props: {
|
||||
)
|
||||
const afterAddQuestion = (newQuestion: rowFor<'love_questions'>) => {
|
||||
setDbQuestion(newQuestion)
|
||||
console.log('setDbQuestion', newQuestion)
|
||||
console.debug('setDbQuestion', newQuestion)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -137,7 +137,7 @@ function CreateCompatibilityModalContent(props: {
|
||||
options: generateJson(),
|
||||
};
|
||||
const newQuestion = await api('create-compatibility-question', data)
|
||||
console.log('create-compatibility-question', newQuestion, data)
|
||||
console.debug('create-compatibility-question', newQuestion, data)
|
||||
const q = newQuestion?.question
|
||||
if (q) {
|
||||
afterAddQuestion(q as rowFor<'love_questions'>)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from 'web/components/buttons/button'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { MODAL_CLASS, Modal } from 'web/components/layout/modal'
|
||||
import { AnswerCompatibilityQuestionContent } from './answer-compatibility-question-content'
|
||||
import router from "next/router";
|
||||
|
||||
export function AnswerCompatibilityQuestionButton(props: {
|
||||
user: User | null | undefined
|
||||
@@ -22,13 +23,16 @@ export function AnswerCompatibilityQuestionButton(props: {
|
||||
} = props
|
||||
const [open, setOpen] = useState(fromSignup ?? false)
|
||||
if (!user) return null
|
||||
if (otherQuestions.length === 0) return null
|
||||
const isCore = otherQuestions.some((q) => q.importance_score === 0)
|
||||
const questionsToAnswer = isCore ? otherQuestions.filter((q) => q.importance_score === 0) : otherQuestions
|
||||
return (
|
||||
<>
|
||||
{size === 'md' ? (
|
||||
<Button onClick={() => setOpen(true)} color="gray-outline">
|
||||
Answer Questions{' '}
|
||||
<span className="text-primary-600 ml-2">
|
||||
+{otherQuestions.length}
|
||||
+{questionsToAnswer.length}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
@@ -43,8 +47,11 @@ export function AnswerCompatibilityQuestionButton(props: {
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
otherQuestions={otherQuestions}
|
||||
otherQuestions={questionsToAnswer}
|
||||
refreshCompatibilityAll={refreshCompatibilityAll}
|
||||
onClose={() => {
|
||||
if (fromSignup) router.push('/')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -83,8 +90,9 @@ function AnswerCompatibilityQuestionModal(props: {
|
||||
user: User
|
||||
otherQuestions: QuestionWithCountType[]
|
||||
refreshCompatibilityAll: () => void
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll } = props
|
||||
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose } = props
|
||||
const [questionIndex, setQuestionIndex] = useState(0)
|
||||
return (
|
||||
<Modal
|
||||
@@ -93,6 +101,7 @@ function AnswerCompatibilityQuestionModal(props: {
|
||||
onClose={() => {
|
||||
refreshCompatibilityAll()
|
||||
setQuestionIndex(0)
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
<Col className={MODAL_CLASS}>
|
||||
|
||||
@@ -36,10 +36,10 @@ type ImportanceColorsType = {
|
||||
}
|
||||
|
||||
export const IMPORTANCE_RADIO_COLORS: ImportanceColorsType = {
|
||||
0: `bg-stone-400 ring-stone-400 dark:bg-stone-500 dark:ring-stone-500`,
|
||||
1: `bg-teal-200 ring-teal-200 dark:bg-teal-100 dark:ring-teal-100 `,
|
||||
2: `bg-teal-300 ring-teal-300 dark:bg-teal-200 dark:ring-teal-200 `,
|
||||
3: `bg-teal-400 ring-teal-400`,
|
||||
0: `bg-teal-300 ring-teal-200`,
|
||||
1: `bg-teal-500 ring-teal-200`,
|
||||
2: `bg-teal-700 ring-teal-300`,
|
||||
3: `bg-teal-900 ring-teal-400`,
|
||||
}
|
||||
|
||||
export const IMPORTANCE_DISPLAY_COLORS: ImportanceColorsType = {
|
||||
@@ -157,6 +157,11 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
return (
|
||||
<Col className="h-full w-full gap-4">
|
||||
<Col className="gap-1">
|
||||
{compatibilityQuestion.importance_score > 0 && <Row className="text-blue-400 -mt-4 w-full justify-start text-sm">
|
||||
<span>
|
||||
Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.
|
||||
</span>
|
||||
</Row>}
|
||||
{index !== null &&
|
||||
index !== undefined &&
|
||||
total !== null &&
|
||||
@@ -184,7 +189,7 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
<Col
|
||||
className={clsx(
|
||||
SCROLLABLE_MODAL_CLASS,
|
||||
'h-[30rem] w-full gap-4 sm:h-[30rem]'
|
||||
'h-[20rem] w-full gap-4 sm:h-[30rem]'
|
||||
)}
|
||||
>
|
||||
<Col className="gap-2">
|
||||
|
||||
@@ -402,7 +402,7 @@ function CompatibilityAnswerBlock(props: {
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
<Row className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm">
|
||||
<Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
|
||||
{answerText}
|
||||
</Row>
|
||||
<Row className="px-2 -mt-4">
|
||||
@@ -417,11 +417,11 @@ function CompatibilityAnswerBlock(props: {
|
||||
? 'Acceptable'
|
||||
: 'Also acceptable'}
|
||||
</div>
|
||||
<Row className="flex-wrap gap-2 -mt-2">
|
||||
<Row className="flex-wrap gap-2 mt-0">
|
||||
{distinctPreferredAnswersText.map((text) => (
|
||||
<Row
|
||||
key={text}
|
||||
className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm"
|
||||
className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
{text}
|
||||
</Row>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { User } from 'common/user'
|
||||
import { useOtherAnswers } from 'web/hooks/use-other-answers'
|
||||
import { QuestionWithCountType } from 'web/hooks/use-questions'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar } from 'web/components/widgets/avatar'
|
||||
import { Linkify } from 'web/components/widgets/linkify'
|
||||
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
|
||||
import { UserLink } from 'web/components/widgets/user-link'
|
||||
import { Gender, convertGender } from 'common/gender'
|
||||
import { capitalize } from 'lodash'
|
||||
import {User} from 'common/user'
|
||||
import {useOtherAnswers} from 'web/hooks/use-other-answers'
|
||||
import {QuestionWithCountType} from 'web/hooks/use-questions'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Avatar} from 'web/components/widgets/avatar'
|
||||
import {Linkify} from 'web/components/widgets/linkify'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {UserLink} from 'web/components/widgets/user-link'
|
||||
import {convertGender, Gender} from 'common/gender'
|
||||
import {capitalize} from 'lodash'
|
||||
import clsx from 'clsx'
|
||||
import { shortenedFromNow } from 'web/lib/util/shortenedFromNow'
|
||||
import {shortenedFromNow} from 'web/lib/util/shortenedFromNow'
|
||||
|
||||
export function OtherProfileAnswers(props: {
|
||||
question: QuestionWithCountType
|
||||
user?: User
|
||||
className?: string
|
||||
}) {
|
||||
const { question, className } = props
|
||||
const {question, className} = props
|
||||
const otherAnswers = useOtherAnswers(question.id)
|
||||
const shownAnswers = otherAnswers?.filter(
|
||||
(a) => a.multiple_choice != null || a.free_response || a.integer
|
||||
)
|
||||
|
||||
if (otherAnswers === undefined) return <LoadingIndicator />
|
||||
if (otherAnswers === undefined) return <CompassLoadingIndicator/>
|
||||
if (
|
||||
(otherAnswers === null ||
|
||||
otherAnswers.length ||
|
||||
@@ -50,7 +50,7 @@ export function OtherProfileAnswers(props: {
|
||||
/>
|
||||
<Col>
|
||||
<span className="text-sm">
|
||||
<UserLink user={answerUser} />, {otherAnswer.age}
|
||||
<UserLink user={answerUser}/>, {otherAnswer.age}
|
||||
</span>
|
||||
<Row className="gap-1 text-xs">
|
||||
{otherAnswer.city} •{' '}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { MAX_DESCRIPTION_LENGTH } from 'common/envs/constants'
|
||||
import { Profile } from 'common/love/profile'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { Button } from 'web/components/buttons/button'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { TextEditor, useTextEditor } from 'web/components/widgets/editor'
|
||||
import { updateProfile } from 'web/lib/api'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import React, {useState} from "react";
|
||||
import {Editor} from '@tiptap/core'
|
||||
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
|
||||
import {updateProfile} from 'web/lib/api'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import {useEffect, useState} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import Link from "next/link"
|
||||
import {MIN_BIO_LENGTH} from "common/constants";
|
||||
import {ShowMore} from 'web/components/widgets/show-more'
|
||||
|
||||
const placeHolder = "Tell us about yourself — and what you're looking for!";
|
||||
|
||||
@@ -20,40 +22,16 @@ Write a clear and engaging bio to help others understand who you are and the con
|
||||
- Connection goals (friendship, romantic, collaborative) and availability
|
||||
- What makes you unique and what you care about
|
||||
- Expectations, boundaries, and personality traits
|
||||
- How to contact you or start a conversation (email, social media, etc.)
|
||||
- Optional: romantic preferences, lifestyle habits, and conversation starters
|
||||
`
|
||||
|
||||
export function CharLimitText() {
|
||||
return <p>Profiles with fewer than 250 characters will be hidden by default from the profile grid — write a meaningful bio so others can find you through keyword search and connect intentionally.</p>
|
||||
}
|
||||
|
||||
export function BioTips() {
|
||||
const [showMoreInfo, setShowMoreInfo] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMoreInfo(!showMoreInfo)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
|
||||
>
|
||||
{showMoreInfo ? 'Hide info' : 'Tips'}
|
||||
<svg
|
||||
className={`w-4 h-4 ml-1 transition-transform ${showMoreInfo ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{showMoreInfo && (
|
||||
<div className="mt-2 p-3 rounded-md text-sm customlink">
|
||||
<ReactMarkdown>{tips}</ReactMarkdown>
|
||||
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ShowMore labelClosed="Tips" labelOpen="Hide info" className={'customlink text-sm'}>
|
||||
<ReactMarkdown>{tips}</ReactMarkdown>
|
||||
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
|
||||
</ShowMore>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,18 +40,16 @@ export function EditableBio(props: {
|
||||
onSave: () => void
|
||||
onCancel?: () => void
|
||||
}) {
|
||||
const { profile, onCancel, onSave } = props
|
||||
const editor = useTextEditor({
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: (profile.bio as JSONContent) ?? '',
|
||||
placeholder: placeHolder,
|
||||
})
|
||||
const {profile, onCancel, onSave} = props
|
||||
const [editor, setEditor] = useState<any>(null)
|
||||
const [textLength, setTextLength] = useState(0);
|
||||
|
||||
const hideButtons = editor?.getText().length === 0 && !profile.bio
|
||||
const hideButtons = (textLength === 0) && !profile.bio
|
||||
|
||||
const saveBio = async () => {
|
||||
if (!editor) return
|
||||
const { error } = await tryCatch(updateProfile({ bio: editor.getJSON() }))
|
||||
console.log(editor.getText().length)
|
||||
const {error} = await tryCatch(updateProfile({bio: editor.getJSON(), bio_length: editor.getText().length}))
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
@@ -85,10 +61,15 @@ export function EditableBio(props: {
|
||||
|
||||
return (
|
||||
<Col className="relative w-full">
|
||||
<CharLimitText/>
|
||||
<BioTips/>
|
||||
<TextEditor editor={editor} />
|
||||
|
||||
<BaseBio
|
||||
defaultValue={profile.bio}
|
||||
onEditor={(e) => {
|
||||
setEditor(e);
|
||||
e?.on('update', () => {
|
||||
setTextLength(e.getText().length);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<Row className="absolute bottom-1 right-1 justify-between gap-2">
|
||||
{onCancel && (
|
||||
@@ -112,40 +93,53 @@ export function EditableBio(props: {
|
||||
}
|
||||
|
||||
export function SignupBio(props: {
|
||||
onChange: (e: JSONContent) => void
|
||||
onChange: (e: Editor) => void
|
||||
}) {
|
||||
const { onChange } = props
|
||||
const editor = useTextEditor({
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: '',
|
||||
placeholder: placeHolder,
|
||||
})
|
||||
|
||||
// const [charLength, setCharLength] = useState(0)
|
||||
|
||||
const {onChange} = props
|
||||
return (
|
||||
<Col className="relative w-full">
|
||||
<CharLimitText/>
|
||||
<BioTips/>
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
onBlur={() => {
|
||||
// console.log('onchange', editor?.getText())
|
||||
<BaseBio
|
||||
onBlur={(editor) => {
|
||||
if (!editor) return
|
||||
const e = editor.getJSON()
|
||||
// console.log(e)
|
||||
// const text = e.content.map((block: any) => block.content?.map((c: any) => c.text).join('') ?? '').join('');
|
||||
// setCharLength(text.length)
|
||||
// console.log(text, text.length)
|
||||
// if (text.length < 250) {
|
||||
// return; // do not save
|
||||
// }
|
||||
|
||||
// console.log('bio changed', e, profile.bio);
|
||||
onChange(e)
|
||||
onChange(editor)
|
||||
}}
|
||||
/>
|
||||
{/*<p>{charLength} / 250</p>*/}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
interface BaseBioProps {
|
||||
defaultValue?: any
|
||||
onBlur?: (editor: any) => void
|
||||
onEditor?: (editor: any) => void
|
||||
}
|
||||
|
||||
export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) {
|
||||
const editor = useTextEditor({
|
||||
// extensions: [StarterKit],
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: defaultValue,
|
||||
placeholder: placeHolder,
|
||||
})
|
||||
const textLength = editor?.getText().length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
onEditor?.(editor)
|
||||
}, [editor, onEditor])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{textLength < MIN_BIO_LENGTH &&
|
||||
<p>
|
||||
Add {MIN_BIO_LENGTH - textLength} more {MIN_BIO_LENGTH - textLength === 1 ? 'character' : 'characters'} so
|
||||
your profile can appear in search results—or leave it for now and explore others’ profiles first.
|
||||
</p>
|
||||
}
|
||||
<BioTips/>
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
onBlur={() => onBlur?.(editor)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
// // })
|
||||
// setDialogOpen(false)
|
||||
//
|
||||
// // console.log('result', result)
|
||||
// // console.debug('result', result)
|
||||
//
|
||||
// // if (result.success) {
|
||||
// // window.location.reload()
|
||||
|
||||
18
web/components/buttons/general-button.tsx
Normal file
18
web/components/buttons/general-button.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const GeneralButton = (props: {
|
||||
url: string
|
||||
content: string
|
||||
}) => {
|
||||
const {url, content} = props
|
||||
return <div className="rounded-xl shadow p-6 flex flex-col items-center">
|
||||
<Link
|
||||
href={url}
|
||||
className="px-6 py-2 rounded-full bg-gray-200 text-gray-800 font-semibold text-lg shadow hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 transition"
|
||||
target={url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
</div>;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { SimpleCopyTextButton } from 'web/components/buttons/copy-link-button'
|
||||
import { api } from 'web/lib/api'
|
||||
import { buildArray } from 'common/util/array'
|
||||
import { DeleteYourselfButton } from '../profile/delete-yourself'
|
||||
import {toast} from "react-hot-toast";
|
||||
|
||||
export function MoreOptionsUserButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
@@ -55,11 +56,22 @@ export function MoreOptionsUserButton(props: { user: User }) {
|
||||
<Button
|
||||
color={'red'}
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
api('ban-user', {
|
||||
userId,
|
||||
unban: user.isBannedFromPosting ?? false,
|
||||
})
|
||||
onClick={async () => {
|
||||
await toast.promise(
|
||||
api('ban-user', {
|
||||
userId,
|
||||
unban: user.isBannedFromPosting ?? false,
|
||||
}),
|
||||
{
|
||||
loading: 'Banning...',
|
||||
success: () => {
|
||||
return 'User banned!'
|
||||
},
|
||||
error: () => {
|
||||
return 'Error banning user'
|
||||
},
|
||||
}
|
||||
)
|
||||
}}
|
||||
>
|
||||
{user.isBannedFromPosting ? 'Banned' : 'Ban User'}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const ChatMessageItem = memo(function ChatMessageItem(props: {
|
||||
userId={id}
|
||||
/>
|
||||
)}
|
||||
<Col className="@sm:max-w-[calc(100vw-6rem)] @md:max-w-[80%] max-w-[calc(100vw-2rem)]">
|
||||
<Col className="sm:max-w-[calc(100vw-6rem)] md:max-w-[70%]">
|
||||
{firstOfUser && !isMe && chat.visibility !== 'system_status' && (
|
||||
<Row className={'items-center gap-3'}>
|
||||
<Link
|
||||
|
||||
57
web/components/contact.tsx
Normal file
57
web/components/contact.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
|
||||
import {JSONContent} from "@tiptap/core";
|
||||
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
|
||||
import {Button} from "web/components/buttons/button";
|
||||
import {api} from "web/lib/api";
|
||||
import {Title} from "web/components/widgets/title";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export function ContactComponent() {
|
||||
const user = useUser()
|
||||
|
||||
const editor = useTextEditor({
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: '',
|
||||
placeholder: 'Contact us here...',
|
||||
})
|
||||
|
||||
const hideButton = editor?.getText().length == 0
|
||||
|
||||
return (
|
||||
<Col className="mx-2">
|
||||
<Title className="!mb-2 text-3xl">Contact</Title>
|
||||
<Col>
|
||||
<div className={'mb-2'}>
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
/>
|
||||
</div>
|
||||
{!hideButton && (
|
||||
<Row className="right-1 justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
if (!editor) return
|
||||
const data = {
|
||||
content: editor.getJSON() as JSONContent,
|
||||
userId: user?.id,
|
||||
};
|
||||
const result = await api('contact', data).catch(() => {
|
||||
toast.error('Failed to contact — try again or contact us...')
|
||||
})
|
||||
if (!result) return
|
||||
editor.commands.clearContent()
|
||||
toast.success('Thank you for your message!')
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,40 @@
|
||||
export const RELATIONSHIP_CHOICES = {
|
||||
// Monogamous: 'mono',
|
||||
// Polyamorous: 'poly',
|
||||
// 'Open Relationship': 'open',
|
||||
// Other: 'other',
|
||||
Collaboration: 'collaboration',
|
||||
Friendship: 'friendship',
|
||||
Relationship: 'relationship',
|
||||
};
|
||||
|
||||
export const ROMANTIC_CHOICES = {
|
||||
Monogamous: 'mono',
|
||||
Polyamorous: 'poly',
|
||||
'Open Relationship': 'open',
|
||||
};
|
||||
|
||||
export const POLITICAL_CHOICES = {
|
||||
Progressive: 'progressive',
|
||||
Liberal: 'liberal',
|
||||
'Moderate / Centrist': 'moderate',
|
||||
Conservative: 'conservative',
|
||||
Socialist: 'socialist',
|
||||
Nationalist: 'nationalist',
|
||||
Populist: 'populist',
|
||||
'Green / Eco-Socialist': 'green',
|
||||
Technocratic: 'technocratic',
|
||||
Libertarian: 'libertarian',
|
||||
'Effective Accelerationism': 'e/acc',
|
||||
'Pause AI / Tech Skeptic': 'pause ai',
|
||||
'Independent / Other': 'other',
|
||||
}
|
||||
|
||||
export const REVERTED_RELATIONSHIP_CHOICES = Object.fromEntries(
|
||||
Object.entries(RELATIONSHIP_CHOICES).map(([key, value]) => [value, key])
|
||||
);
|
||||
|
||||
export const REVERTED_ROMANTIC_CHOICES = Object.fromEntries(
|
||||
Object.entries(ROMANTIC_CHOICES).map(([key, value]) => [value, key])
|
||||
);
|
||||
|
||||
export const REVERTED_POLITICAL_CHOICES = Object.fromEntries(
|
||||
Object.entries(POLITICAL_CHOICES).map(([key, value]) => [value, key])
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import {RelationshipFilter, RelationshipFilterText,} from './relationship-filter
|
||||
import {MyMatchesToggle} from './my-matches-toggle'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {FilterFields} from "common/filters";
|
||||
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
|
||||
|
||||
export function DesktopFilters(props: {
|
||||
filters: Partial<FilterFields>
|
||||
@@ -133,6 +134,12 @@ export function DesktopFilters(props: {
|
||||
}
|
||||
popoverClassName="bg-canvas-50"
|
||||
/>
|
||||
{/* Short Bios */}
|
||||
<ShortBioToggle
|
||||
updateFilter={updateFilter}
|
||||
filters={filters}
|
||||
hidden={false}
|
||||
/>
|
||||
{/* PREFERRED GENDER */}
|
||||
{/*<CustomizeableDropdown*/}
|
||||
{/* buttonContent={(open: boolean) => (*/}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user