Add pretty formatting (#29)

* Test

* Add pretty formatting

* Fix Tests

* Fix Tests

* Fix Tests

* Fix

* Add pretty formatting fix

* Fix

* Test

* Fix tests

* Clean typeckech

* Add prettier check

* Fix api tsconfig

* Fix api tsconfig

* Fix tsconfig

* Fix

* Fix

* Prettier
This commit is contained in:
Martin Braquet
2026-02-20 17:32:27 +01:00
committed by GitHub
parent 1994697fa1
commit ba9b3cfb06
695 changed files with 22382 additions and 23209 deletions

View File

@@ -3,25 +3,27 @@ apply: by model decision
---
---
trigger: always_on
description:
globs:
---
## Project Structure
- next.js react tailwind frontend `/web`
- broken down into pages, components, hooks, lib
- broken down into pages, components, hooks, lib
- express node api server `/backend/api`
- one off scripts, like migrations `/backend/scripts`
- supabase postgres. schema in `/backend/supabase`
- supabase-generated types in `/backend/supabase/schema.ts`
- supabase-generated types in `/backend/supabase/schema.ts`
- files shared between backend directories `/backend/shared`
- anything in `/backend` can import from `shared`, but not vice versa
- anything in `/backend` can import from `shared`, but not vice versa
- files shared between the frontend and backend in `/common`
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility
functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from
`/common`, but not vice versa.
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility
functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from
`/common`, but not vice versa.
## Deployment
@@ -56,18 +58,11 @@ export function HeadlineTabs(props: {
notSticky?: boolean
className?: string
}) {
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} =
props
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} = props
const user = useUser()
return (
<div
className={clsx(
className,
'bg-canvas-50 w-full',
!notSticky && 'sticky top-0 z-50'
)}
>
<div className={clsx(className, 'bg-canvas-50 w-full', !notSticky && 'sticky top-0 z-50')}>
<Carousel labelsParentClassName="gap-px">
{headlines.map(({id, slug, title}) => (
<Tab
@@ -77,9 +72,9 @@ export function HeadlineTabs(props: {
active={slug === currentSlug}
/>
))}
{user && <Tab label="More" href="/dashboard"/>}
{user && <Tab label="More" href="/dashboard" />}
{user && (isAdminId(user.id) || isModId(user.id)) && (
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines}/>
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
)}
</Carousel>
</div>
@@ -150,9 +145,7 @@ Here's the definition of usePersistentInMemoryState:
```ts
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
const [state, setState] = useStateCheckEquality<T>(
safeJsonParse(store[key]) ?? initialValue
)
const [state, setState] = useStateCheckEquality<T>(safeJsonParse(store[key]) ?? initialValue)
useEffect(() => {
const storedValue = safeJsonParse(store[key]) ?? initialValue
@@ -196,7 +189,7 @@ In `use-bets`, we have this hook to get live updates with useApiSubscription:
```ts
export const useContractBets = (
contractId: string,
opts?: APIParams<'bets'> & { enabled?: boolean }
opts?: APIParams<'bets'> & {enabled?: boolean},
) => {
const {enabled = true, ...apiOptions} = {
contractId,
@@ -204,17 +197,11 @@ export const useContractBets = (
}
const optionsKey = JSON.stringify(apiOptions)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
[],
`${optionsKey}-bets`
)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>([], `${optionsKey}-bets`)
const addBets = (bets: Bet[]) => {
setNewBets((currentBets) => {
const uniqueBets = sortBy(
uniqBy([...currentBets, ...bets], 'id'),
'createdTime'
)
const uniqueBets = sortBy(uniqBy([...currentBets, ...bets], 'id'), 'createdTime')
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
})
}
@@ -249,7 +236,7 @@ export function broadcastUpdatedPrivateUser(userId: string) {
broadcast(`private-user/${userId}`, {})
}
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
export function broadcastUpdatedUser(user: Partial<User> & {id: string}) {
broadcast(`user/${user.id}`, {user})
}
@@ -334,7 +321,7 @@ export const placeBet: APIHandler<'bet'> = async (props, auth) => {
const isApi = auth.creds.kind === 'key'
return await betsQueue.enqueueFn(
() => placeBetMain(props, auth.uid, isApi),
[props.contractId, auth.uid]
[props.contractId, auth.uid],
)
}
```
@@ -380,13 +367,10 @@ using the pg-promise library. The client (code in web) does not have permission
Another example using the direct client:
```ts
export const getUniqueBettorIds = async (
contractId: string,
pg: SupabaseDirectClient
) => {
export const getUniqueBettorIds = async (contractId: string, pg: SupabaseDirectClient) => {
const res = await pg.manyOrNone(
'select distinct user_id from contract_bets where contract_id = $1',
[contractId]
[contractId],
)
return res.map((r) => r.user_id as string)
}
@@ -441,7 +425,7 @@ const query = renderSql(
from('contract_bets'),
where('contract_id = ${id}', {id}),
orderBy('created_time desc'),
limitValue != null && limit(limitValue)
limitValue != null && limit(limitValue),
)
const res = await pg.manyOrNone(query)
@@ -480,7 +464,7 @@ can do so via this SQL command (change the type if not `TEXT`):
```sql
ALTER TABLE profiles
ADD COLUMN profile_field TEXT;
ADD COLUMN profile_field TEXT;
```
Store it in `add_profile_field.sql` in the [migrations](../backend/supabase/migrations) folder and
@@ -544,28 +528,28 @@ How we apply it here
This project uses three complementary test types. Use the right level for the job:
- Unit tests
- Purpose: Verify a single function/module in isolation; fast, deterministic.
- Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`,
etc.).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.unit.test.ts` (or `.tsx` for React in `web`).
- When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies.
- Purpose: Verify a single function/module in isolation; fast, deterministic.
- Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`,
etc.).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.unit.test.ts` (or `.tsx` for React in `web`).
- When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies.
- Integration tests
- Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without
spinning up the full app.
- Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.integration.test.ts` (or `.tsx` for React in `web`).
- When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB,
component trees with providers.
- Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without
spinning up the full app.
- Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.integration.test.ts` (or `.tsx` for React in `web`).
- When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB,
component trees with providers.
- End-to-End (E2E) tests
- Purpose: Validate real user flows across the full stack.
- Where: Top-level `tests/e2e` with separate areas for `web` and `backend`.
- Runner: Playwright (see root `playwright.config.ts`, `testDir: ./tests/e2e`).
- Naming: `*.e2e.spec.ts`.
- When to use: Critical journeys (signup, login, checkout), cross-service interactions, smoke tests for deployments.
- Purpose: Validate real user flows across the full stack.
- Where: Top-level `tests/e2e` with separate areas for `web` and `backend`.
- Runner: Playwright (see root `playwright.config.ts`, `testDir: ./tests/e2e`).
- Naming: `*.e2e.spec.ts`.
- When to use: Critical journeys (signup, login, checkout), cross-service interactions, smoke tests for deployments.
Quick commands
@@ -635,23 +619,23 @@ web/
- Unit and integration tests live in each packages `tests` folder and are executed by Jest via the root
`jest.config.js` projects array.
- Naming:
- Unit: `*.unit.test.ts` (or `.tsx` for React in `web`)
- Integration: `*.integration.test.ts`
- E2E (Playwright): `*.e2e.spec.ts`
- Unit: `*.unit.test.ts` (or `.tsx` for React in `web`)
- Integration: `*.integration.test.ts`
- E2E (Playwright): `*.e2e.spec.ts`
### Best Practices
* Test Behavior, Not Implementation. Dont test internal state or function calls unless youre testing utilities or very
- Test Behavior, Not Implementation. Dont test internal state or function calls unless youre testing utilities or very
critical behavior.
* Use msw to Mock APIs. Don't manually mock fetch—use msw to simulate realistic behavior, including network delays and
- Use msw to Mock APIs. Don't manually mock fetch—use msw to simulate realistic behavior, including network delays and
errors.
* Dont Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
- Dont Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
schemas).
* Prefer userEvent Over fireEvent. It simulates real user interactions more accurately.
* Avoid Testing Next.js Internals . You dont need to test getStaticProps, getServerSideProps themselves-test what they
- Prefer userEvent Over fireEvent. It simulates real user interactions more accurately.
- Avoid Testing Next.js Internals . You dont need to test getStaticProps, getServerSideProps themselves-test what they
render.
* Don't test just for coverage. Test to prevent regressions, document intent, and handle edge cases.
* Don't write end-to-end tests for features that change frequently unless absolutely necessary.
- Don't test just for coverage. Test to prevent regressions, document intent, and handle edge cases.
- Don't write end-to-end tests for features that change frequently unless absolutely necessary.
### Jest Unit Testing Guide
@@ -679,14 +663,14 @@ yarn test path/to/test.unit.test.ts
#### Test Standards
- Test file names should convey what to expect
- Follow the pattern: `<exact-filename>.[unit,integration].test.ts`. Examples:
- filename.unit.test.ts
- filename.integration.test.ts
- Follow the pattern: `<exact-filename>.[unit,integration].test.ts`. Examples:
- filename.unit.test.ts
- filename.integration.test.ts
- Group related tests using describe blocks
- Use descriptive test names that explain the expected behavior.
- Follow the pattern: "should `expected behavior` [relevant modifier]". Examples:
- should `ban user` [with matching user id]
- should `ban user` [with matching user name]
- Follow the pattern: "should `expected behavior` [relevant modifier]". Examples:
- should `ban user` [with matching user id]
- should `ban user` [with matching user name]
#### Mocking
@@ -730,15 +714,15 @@ When writing mocks, assert both outcome and interaction:
Why mocking is important?
- *Isolation* - Test your code independently of databases, APIs, and external systems. Tests only fail when your code
- _Isolation_ - Test your code independently of databases, APIs, and external systems. Tests only fail when your code
breaks, not when a server is down.
- *Speed* - Mocked tests run in milliseconds vs. seconds for real network/database calls. Run your suite constantly
- _Speed_ - Mocked tests run in milliseconds vs. seconds for real network/database calls. Run your suite constantly
without waiting.
- *Control* - Easily simulate edge cases like API errors, timeouts, or rare conditions that are difficult to reproduce
- _Control_ - Easily simulate edge cases like API errors, timeouts, or rare conditions that are difficult to reproduce
with real systems.
- *Reliability* - Eliminate unpredictable failures from network issues, rate limits, or changing external data. Same
- _Reliability_ - Eliminate unpredictable failures from network issues, rate limits, or changing external data. Same
inputs = same results, every time.
- *Focus* - Verify your function's logic and how it uses its dependencies, without requiring those dependencies to
- _Focus_ - Verify your function's logic and how it uses its dependencies, without requiring those dependencies to
actually work yet.
###### Use `jest.mock()`
@@ -751,40 +735,40 @@ functions return value isnt used, theres no need to mock it further.
```tsx
//Function and module mocks
jest.mock('path/to/module');
jest.mock('path/to/module')
//Function and module imports
import {functionUnderTest} from "path/to/function"
import {module} from "path/to/module"
import {functionUnderTest} from 'path/to/function'
import {module} from 'path/to/module'
describe('functionUnderTest', () => {
//Setup
beforeEach(() => {
//Run before each test
jest.resetAllMocks(); // Resets any mocks from previous tests
});
jest.resetAllMocks() // Resets any mocks from previous tests
})
afterEach(() => {
//Run after each test
jest.restoreAllMocks(); // Cleans up between tests
});
jest.restoreAllMocks() // Cleans up between tests
})
describe('when given valid input', () => {
it('should describe what is being tested', async () => {
//Arrange: Setup test data
const mockData = 'test';
const mockData = 'test'
//Act: Execute the function under test
const result = myFunction(mockData);
const result = myFunction(mockData)
//Assert: Verify the result
expect(result).toBe('expected');
});
});
expect(result).toBe('expected')
})
})
describe('when an error occurs', () => {
//Test cases for errors
});
});
})
})
```
###### Modules
@@ -794,27 +778,27 @@ called and what it was called with.
```tsx
//functionFile.ts
import {module as mockedDep} from "path/to/module"
import {module as mockedDep} from 'path/to/module'
export const functionUnderTest = async (param) => {
return await mockedDep(param);
};
return await mockedDep(param)
}
```
```tsx
//testFile.unit.test.ts
import {functionUnderTest} from "path/to/function";
import {module as mockedDep} from "path/to/module";
import {functionUnderTest} from 'path/to/function'
import {module as mockedDep} from 'path/to/module'
jest.mock('path/to/module');
jest.mock('path/to/module')
/**
* Inside the test case
* We create a mock for any information passed into the function that is being tested
* and if the function returns a result we create a mock to test the result
*/
const mockParam = "mockParam";
const mockReturnValue = "mockModuleValue";
const mockParam = 'mockParam'
const mockReturnValue = 'mockModuleValue'
/**
* use .mockResolvedValue when handling async/await modules that return values
@@ -822,15 +806,15 @@ const mockReturnValue = "mockModuleValue";
*/
describe('functionUnderTest', () => {
it('returns mocked module value and calls dependency correctly', async () => {
(mockedDep as jest.Mock).mockResolvedValue(mockReturnValue);
;(mockedDep as jest.Mock).mockResolvedValue(mockReturnValue)
const result = await functionUnderTest(mockParam);
const result = await functionUnderTest(mockParam)
expect(result).toBe(mockReturnValue);
expect(mockedDep).toHaveBeenCalledTimes(1);
expect(mockedDep).toHaveBeenCalledWith(mockParam);
});
});
expect(result).toBe(mockReturnValue)
expect(mockedDep).toHaveBeenCalledTimes(1)
expect(mockedDep).toHaveBeenCalledWith(mockParam)
})
})
```
Use namespace imports when you want to import everything a module exports under a single name.
@@ -838,37 +822,36 @@ Use namespace imports when you want to import everything a module exports under
```tsx
//moduleFile.ts
export const module = async (param) => {
const value = "module"
const value = 'module'
return value
};
}
export const moduleTwo = async (param) => {
const value = "moduleTwo"
const value = 'moduleTwo'
return value
};
}
```
```tsx
//functionFile.ts
import {module, moduleTwo} from "path/to/module"
import {module, moduleTwo} from 'path/to/module'
export const functionUnderTest = async (param) => {
const mockValue = await moduleTwo(param)
const returnValue = await module(mockValue)
return returnValue;
};
return returnValue
}
```
```tsx
//testFile.unit.test.ts
jest.mock('path/to/module');
jest.mock('path/to/module')
/**
* This creates an object containing all named exports from ./path/to/module
*/
import * as mockModule from "path/to/module"
(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue);
import * as mockModule from 'path/to/module'
;(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue)
```
When mocking modules, you can use `jest.spyOn()` instead of `jest.mock()`.
@@ -876,21 +859,21 @@ When mocking modules, you can use `jest.spyOn()` instead of `jest.mock()`.
- `jest.mock()` mocks the entire module, which is ideal for external dependencies like Axios or database clients.
- `jest.spyOn()` mocks specific methods while keeping the real implementation for others. It can also be used to observe
how a real method is called without changing its behavior.
- also replaces the need to have `jest.mock()` at the top of the file.
- also replaces the need to have `jest.mock()` at the top of the file.
```tsx
//testFile.unit.test.ts
import * as mockModule from "path/to/module"
import * as mockModule from 'path/to/module'
//Mocking the return value of the module
jest.spyOn(mockModule, 'module').mockResolvedValue(mockReturnValue);
jest.spyOn(mockModule, 'module').mockResolvedValue(mockReturnValue)
//Spying on the module to check functionality
jest.spyOn(mockModule, 'module');
jest.spyOn(mockModule, 'module')
//You can assert the module functionality with both of the above exactly like you would if you used jest.mock()
expect(mockModule.module).toBeCalledTimes(1);
expect(mockModule.module).toBeCalledWith(mockParam);
expect(mockModule.module).toBeCalledTimes(1)
expect(mockModule.module).toBeCalledWith(mockParam)
```
###### Dependencies
@@ -900,119 +883,114 @@ external functionality.
```tsx
//functionFile.ts
import {dependency} from "path/to/dependency"
import {dependency} from 'path/to/dependency'
export const functionUnderTest = async (param) => {
const depen = await dependency();
const value = depen.module();
const depen = await dependency()
const value = depen.module()
return value;
};
return value
}
```
```tsx
//testFile.unit.test.ts
jest.mock('path/to/dependency');
jest.mock('path/to/dependency')
import {dependency} from "path/to/dependency"
import {dependency} from 'path/to/dependency'
describe('functionUnderTest', () => {
/**
* Because the dependency has modules that are used we need to
* create a variable outside of scope that can be asserted on
*/
let mockDependency = {} as any;
let mockDependency = {} as any
beforeEach(() => {
mockDependency = {
module: jest.fn(),
};
jest.resetAllMocks(); // Resets any mocks from previous tests
});
}
jest.resetAllMocks() // Resets any mocks from previous tests
})
afterEach(() => {
//Run after each test
jest.restoreAllMocks(); // Cleans up between tests
});
jest.restoreAllMocks() // Cleans up between tests
})
//Inside the test case
(mockDependency.module as jest.Mock).mockResolvedValue(mockReturnValue);
;(mockDependency.module as jest.Mock).mockResolvedValue(mockReturnValue)
expect(mockDependency.module).toBeCalledTimes(1);
expect(mockDependency.module).toBeCalledWith(mockParam);
});
expect(mockDependency.module).toBeCalledTimes(1)
expect(mockDependency.module).toBeCalledWith(mockParam)
})
```
###### Error checking
```tsx
//function.ts
const result = await functionName(param);
const result = await functionName(param)
if (!result) {
throw new Error(403, 'Error text', error);
throw new Error(403, 'Error text', error)
}
;
```
```tsx
//testFile.unit.test.ts
const mockParam = {} as any;
const mockParam = {} as any
//This will check only the error message
expect(functionName(mockParam))
.rejects
.toThrowError('Error text');
expect(functionName(mockParam)).rejects.toThrowError('Error text')
//This will check the complete error
try {
await functionName(mockParam);
fail('Should have thrown');
await functionName(mockParam)
fail('Should have thrown')
} catch (error) {
const functionError = error as Error;
expect(functionError.code).toBe(403);
expect(functionError.message).toBe('Error text');
expect(functionError.details).toBe(mockParam);
expect(functionError.name).toBe('Error');
const functionError = error as Error
expect(functionError.code).toBe(403)
expect(functionError.message).toBe('Error text')
expect(functionError.details).toBe(mockParam)
expect(functionError.name).toBe('Error')
}
```
```tsx
//For console.error types
console.error('Error message', error);
console.error('Error message', error)
//Use spyOn to mock
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {
});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
expect(errorSpy).toHaveBeenCalledWith(
'Error message',
expect.objectContaining({name: 'Error'}) //The error 'name' refers to the error type
);
expect.objectContaining({name: 'Error'}), //The error 'name' refers to the error type
)
```
###### Mocking array return value
```tsx
//arrayFile.ts
const exampleArray = [1, 2, 3, 4, 5];
const exampleArray = [1, 2, 3, 4, 5]
const arrayResult = exampleArray.includes(2);
const arrayResult = exampleArray.includes(2)
```
```tsx
//testFile.unit.test.ts
//This will mock 'includes' for all arrays and force the return value to be true
jest.spyOn(Array.prototype, 'includes').mockReturnValue(true);
jest.spyOn(Array.prototype, 'includes').mockReturnValue(true)
// ---
//This will specify which 'includes' array to mock based on the args passed into the .includes()
jest.spyOn(Array.prototype, 'includes').mockImplementation(function (value) {
if (value === 2) {
return true;
return true
}
return false;
});
return false
})
```
### Playwright (E2E) Testing Guide
@@ -1038,23 +1016,26 @@ yarn test:db:reset
Use this priority order for selecting elements in Playwright tests:
1. Prefer `getByRole()` — use semantic roles that reflect how users interact
```typescript
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', {name: 'Submit'}).click()
```
If a meaningful ARIA role is not available, fall back to accessible text selectors (next point).
2. Use accessible text selectors — when roles don't apply, target user-facing text
```typescript
await page.getByLabel('Email').fill('user@example.com');
await page.getByPlaceholder('Enter your name').fill('John');
await page.getByText('Welcome back').isVisible();
await page.getByLabel('Email').fill('user@example.com')
await page.getByPlaceholder('Enter your name').fill('John')
await page.getByText('Welcome back').isVisible()
```
3. Only use `data-testid` — when elements have no stable user-facing text
```typescript
// For icons, toggles, or dynamic content without text
await page.getByTestId('menu-toggle').click();
await page.getByTestId('loading-spinner').isVisible();
await page.getByTestId('menu-toggle').click()
await page.getByTestId('loading-spinner').isVisible()
```
This hierarchy mirrors how users actually interact with your application, making tests more reliable and meaningful.

View File

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

View File

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

View File

@@ -7,17 +7,17 @@ globs:
## Project Structure
- next.js react tailwind frontend `/web`
- broken down into pages, components, hooks, lib
- broken down into pages, components, hooks, lib
- express node api server `/backend/api`
- one off scripts, like migrations `/backend/scripts`
- supabase postgres. schema in `/backend/supabase`
- supabase-generated types in `/backend/supabase/schema.ts`
- supabase-generated types in `/backend/supabase/schema.ts`
- files shared between backend directories `/backend/shared`
- anything in `/backend` can import from `shared`, but not vice versa
- anything in `/backend` can import from `shared`, but not vice versa
- files shared between the frontend and backend in `/common`
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility
functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from
`/common`, but not vice versa.
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility
functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from
`/common`, but not vice versa.
## Deployment
@@ -52,18 +52,11 @@ export function HeadlineTabs(props: {
notSticky?: boolean
className?: string
}) {
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} =
props
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} = props
const user = useUser()
return (
<div
className={clsx(
className,
'bg-canvas-50 w-full',
!notSticky && 'sticky top-0 z-50'
)}
>
<div className={clsx(className, 'bg-canvas-50 w-full', !notSticky && 'sticky top-0 z-50')}>
<Carousel labelsParentClassName="gap-px">
{headlines.map(({id, slug, title}) => (
<Tab
@@ -73,9 +66,9 @@ export function HeadlineTabs(props: {
active={slug === currentSlug}
/>
))}
{user && <Tab label="More" href="/dashboard"/>}
{user && <Tab label="More" href="/dashboard" />}
{user && (isAdminId(user.id) || isModId(user.id)) && (
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines}/>
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
)}
</Carousel>
</div>
@@ -146,9 +139,7 @@ Here's the definition of usePersistentInMemoryState:
```ts
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
const [state, setState] = useStateCheckEquality<T>(
safeJsonParse(store[key]) ?? initialValue
)
const [state, setState] = useStateCheckEquality<T>(safeJsonParse(store[key]) ?? initialValue)
useEffect(() => {
const storedValue = safeJsonParse(store[key]) ?? initialValue
@@ -192,7 +183,7 @@ In `use-bets`, we have this hook to get live updates with useApiSubscription:
```ts
export const useContractBets = (
contractId: string,
opts?: APIParams<'bets'> & { enabled?: boolean }
opts?: APIParams<'bets'> & {enabled?: boolean},
) => {
const {enabled = true, ...apiOptions} = {
contractId,
@@ -200,17 +191,11 @@ export const useContractBets = (
}
const optionsKey = JSON.stringify(apiOptions)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
[],
`${optionsKey}-bets`
)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>([], `${optionsKey}-bets`)
const addBets = (bets: Bet[]) => {
setNewBets((currentBets) => {
const uniqueBets = sortBy(
uniqBy([...currentBets, ...bets], 'id'),
'createdTime'
)
const uniqueBets = sortBy(uniqBy([...currentBets, ...bets], 'id'), 'createdTime')
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
})
}
@@ -245,7 +230,7 @@ export function broadcastUpdatedPrivateUser(userId: string) {
broadcast(`private-user/${userId}`, {})
}
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
export function broadcastUpdatedUser(user: Partial<User> & {id: string}) {
broadcast(`user/${user.id}`, {user})
}
@@ -330,7 +315,7 @@ export const placeBet: APIHandler<'bet'> = async (props, auth) => {
const isApi = auth.creds.kind === 'key'
return await betsQueue.enqueueFn(
() => placeBetMain(props, auth.uid, isApi),
[props.contractId, auth.uid]
[props.contractId, auth.uid],
)
}
```
@@ -376,13 +361,10 @@ using the pg-promise library. The client (code in web) does not have permission
Another example using the direct client:
```ts
export const getUniqueBettorIds = async (
contractId: string,
pg: SupabaseDirectClient
) => {
export const getUniqueBettorIds = async (contractId: string, pg: SupabaseDirectClient) => {
const res = await pg.manyOrNone(
'select distinct user_id from contract_bets where contract_id = $1',
[contractId]
[contractId],
)
return res.map((r) => r.user_id as string)
}
@@ -437,7 +419,7 @@ const query = renderSql(
from('contract_bets'),
where('contract_id = ${id}', {id}),
orderBy('created_time desc'),
limitValue != null && limit(limitValue)
limitValue != null && limit(limitValue),
)
const res = await pg.manyOrNone(query)
@@ -476,7 +458,7 @@ can do so via this SQL command (change the type if not `TEXT`):
```sql
ALTER TABLE profiles
ADD COLUMN profile_field TEXT;
ADD COLUMN profile_field TEXT;
```
Store it in `add_profile_field.sql` in the [migrations](../backend/supabase/migrations) folder and
@@ -540,28 +522,28 @@ How we apply it here
This project uses three complementary test types. Use the right level for the job:
- Unit tests
- Purpose: Verify a single function/module in isolation; fast, deterministic.
- Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`,
etc.).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.unit.test.ts` (or `.tsx` for React in `web`).
- When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies.
- Purpose: Verify a single function/module in isolation; fast, deterministic.
- Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`,
etc.).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.unit.test.ts` (or `.tsx` for React in `web`).
- When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies.
- Integration tests
- Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without
spinning up the full app.
- Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.integration.test.ts` (or `.tsx` for React in `web`).
- When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB,
component trees with providers.
- Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without
spinning up the full app.
- Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.integration.test.ts` (or `.tsx` for React in `web`).
- When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB,
component trees with providers.
- End-to-End (E2E) tests
- Purpose: Validate real user flows across the full stack.
- Where: Top-level `tests/e2e` with separate areas for `web` and `backend`.
- Runner: Playwright (see root `playwright.config.ts`, `testDir: ./tests/e2e`).
- Naming: `*.e2e.spec.ts`.
- When to use: Critical journeys (signup, login, checkout), cross-service interactions, smoke tests for deployments.
- Purpose: Validate real user flows across the full stack.
- Where: Top-level `tests/e2e` with separate areas for `web` and `backend`.
- Runner: Playwright (see root `playwright.config.ts`, `testDir: ./tests/e2e`).
- Naming: `*.e2e.spec.ts`.
- When to use: Critical journeys (signup, login, checkout), cross-service interactions, smoke tests for deployments.
Quick commands
@@ -631,23 +613,23 @@ web/
- Unit and integration tests live in each packages `tests` folder and are executed by Jest via the root
`jest.config.js` projects array.
- Naming:
- Unit: `*.unit.test.ts` (or `.tsx` for React in `web`)
- Integration: `*.integration.test.ts`
- E2E (Playwright): `*.e2e.spec.ts`
- Unit: `*.unit.test.ts` (or `.tsx` for React in `web`)
- Integration: `*.integration.test.ts`
- E2E (Playwright): `*.e2e.spec.ts`
### Best Practices
* Test Behavior, Not Implementation. Dont test internal state or function calls unless youre testing utilities or very
- Test Behavior, Not Implementation. Dont test internal state or function calls unless youre testing utilities or very
critical behavior.
* Use msw to Mock APIs. Don't manually mock fetch—use msw to simulate realistic behavior, including network delays and
- Use msw to Mock APIs. Don't manually mock fetch—use msw to simulate realistic behavior, including network delays and
errors.
* Dont Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
- Dont Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
schemas).
* Prefer userEvent Over fireEvent. It simulates real user interactions more accurately.
* Avoid Testing Next.js Internals . You dont need to test getStaticProps, getServerSideProps themselves-test what they
- Prefer userEvent Over fireEvent. It simulates real user interactions more accurately.
- Avoid Testing Next.js Internals . You dont need to test getStaticProps, getServerSideProps themselves-test what they
render.
* Don't test just for coverage. Test to prevent regressions, document intent, and handle edge cases.
* Don't write end-to-end tests for features that change frequently unless absolutely necessary.
- Don't test just for coverage. Test to prevent regressions, document intent, and handle edge cases.
- Don't write end-to-end tests for features that change frequently unless absolutely necessary.
### Jest Unit Testing Guide
@@ -675,14 +657,14 @@ yarn test path/to/test.unit.test.ts
#### Test Standards
- Test file names should convey what to expect
- Follow the pattern: `<exact-filename>.[unit,integration].test.ts`. Examples:
- filename.unit.test.ts
- filename.integration.test.ts
- Follow the pattern: `<exact-filename>.[unit,integration].test.ts`. Examples:
- filename.unit.test.ts
- filename.integration.test.ts
- Group related tests using describe blocks
- Use descriptive test names that explain the expected behavior.
- Follow the pattern: "should `expected behavior` [relevant modifier]". Examples:
- should `ban user` [with matching user id]
- should `ban user` [with matching user name]
- Follow the pattern: "should `expected behavior` [relevant modifier]". Examples:
- should `ban user` [with matching user id]
- should `ban user` [with matching user name]
#### Mocking
@@ -726,15 +708,15 @@ When writing mocks, assert both outcome and interaction:
Why mocking is important?
- *Isolation* - Test your code independently of databases, APIs, and external systems. Tests only fail when your code
- _Isolation_ - Test your code independently of databases, APIs, and external systems. Tests only fail when your code
breaks, not when a server is down.
- *Speed* - Mocked tests run in milliseconds vs. seconds for real network/database calls. Run your suite constantly
- _Speed_ - Mocked tests run in milliseconds vs. seconds for real network/database calls. Run your suite constantly
without waiting.
- *Control* - Easily simulate edge cases like API errors, timeouts, or rare conditions that are difficult to reproduce
- _Control_ - Easily simulate edge cases like API errors, timeouts, or rare conditions that are difficult to reproduce
with real systems.
- *Reliability* - Eliminate unpredictable failures from network issues, rate limits, or changing external data. Same
- _Reliability_ - Eliminate unpredictable failures from network issues, rate limits, or changing external data. Same
inputs = same results, every time.
- *Focus* - Verify your function's logic and how it uses its dependencies, without requiring those dependencies to
- _Focus_ - Verify your function's logic and how it uses its dependencies, without requiring those dependencies to
actually work yet.
###### Use `jest.mock()`
@@ -747,40 +729,40 @@ functions return value isnt used, theres no need to mock it further.
```tsx
//Function and module mocks
jest.mock('path/to/module');
jest.mock('path/to/module')
//Function and module imports
import {functionUnderTest} from "path/to/function"
import {module} from "path/to/module"
import {functionUnderTest} from 'path/to/function'
import {module} from 'path/to/module'
describe('functionUnderTest', () => {
//Setup
beforeEach(() => {
//Run before each test
jest.resetAllMocks(); // Resets any mocks from previous tests
});
jest.resetAllMocks() // Resets any mocks from previous tests
})
afterEach(() => {
//Run after each test
jest.restoreAllMocks(); // Cleans up between tests
});
jest.restoreAllMocks() // Cleans up between tests
})
describe('when given valid input', () => {
it('should describe what is being tested', async () => {
//Arrange: Setup test data
const mockData = 'test';
const mockData = 'test'
//Act: Execute the function under test
const result = myFunction(mockData);
const result = myFunction(mockData)
//Assert: Verify the result
expect(result).toBe('expected');
});
});
expect(result).toBe('expected')
})
})
describe('when an error occurs', () => {
//Test cases for errors
});
});
})
})
```
###### Modules
@@ -790,27 +772,27 @@ called and what it was called with.
```tsx
//functionFile.ts
import {module as mockedDep} from "path/to/module"
import {module as mockedDep} from 'path/to/module'
export const functionUnderTest = async (param) => {
return await mockedDep(param);
};
return await mockedDep(param)
}
```
```tsx
//testFile.unit.test.ts
import {functionUnderTest} from "path/to/function";
import {module as mockedDep} from "path/to/module";
import {functionUnderTest} from 'path/to/function'
import {module as mockedDep} from 'path/to/module'
jest.mock('path/to/module');
jest.mock('path/to/module')
/**
* Inside the test case
* We create a mock for any information passed into the function that is being tested
* and if the function returns a result we create a mock to test the result
*/
const mockParam = "mockParam";
const mockReturnValue = "mockModuleValue";
const mockParam = 'mockParam'
const mockReturnValue = 'mockModuleValue'
/**
* use .mockResolvedValue when handling async/await modules that return values
@@ -818,15 +800,15 @@ const mockReturnValue = "mockModuleValue";
*/
describe('functionUnderTest', () => {
it('returns mocked module value and calls dependency correctly', async () => {
(mockedDep as jest.Mock).mockResolvedValue(mockReturnValue);
;(mockedDep as jest.Mock).mockResolvedValue(mockReturnValue)
const result = await functionUnderTest(mockParam);
const result = await functionUnderTest(mockParam)
expect(result).toBe(mockReturnValue);
expect(mockedDep).toHaveBeenCalledTimes(1);
expect(mockedDep).toHaveBeenCalledWith(mockParam);
});
});
expect(result).toBe(mockReturnValue)
expect(mockedDep).toHaveBeenCalledTimes(1)
expect(mockedDep).toHaveBeenCalledWith(mockParam)
})
})
```
Use namespace imports when you want to import everything a module exports under a single name.
@@ -834,37 +816,36 @@ Use namespace imports when you want to import everything a module exports under
```tsx
//moduleFile.ts
export const module = async (param) => {
const value = "module"
const value = 'module'
return value
};
}
export const moduleTwo = async (param) => {
const value = "moduleTwo"
const value = 'moduleTwo'
return value
};
}
```
```tsx
//functionFile.ts
import {module, moduleTwo} from "path/to/module"
import {module, moduleTwo} from 'path/to/module'
export const functionUnderTest = async (param) => {
const mockValue = await moduleTwo(param)
const returnValue = await module(mockValue)
return returnValue;
};
return returnValue
}
```
```tsx
//testFile.unit.test.ts
jest.mock('path/to/module');
jest.mock('path/to/module')
/**
* This creates an object containing all named exports from ./path/to/module
*/
import * as mockModule from "path/to/module"
(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue);
import * as mockModule from 'path/to/module'
;(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue)
```
When mocking modules, you can use `jest.spyOn()` instead of `jest.mock()`.
@@ -872,21 +853,21 @@ When mocking modules, you can use `jest.spyOn()` instead of `jest.mock()`.
- `jest.mock()` mocks the entire module, which is ideal for external dependencies like Axios or database clients.
- `jest.spyOn()` mocks specific methods while keeping the real implementation for others. It can also be used to observe
how a real method is called without changing its behavior.
- also replaces the need to have `jest.mock()` at the top of the file.
- also replaces the need to have `jest.mock()` at the top of the file.
```tsx
//testFile.unit.test.ts
import * as mockModule from "path/to/module"
import * as mockModule from 'path/to/module'
//Mocking the return value of the module
jest.spyOn(mockModule, 'module').mockResolvedValue(mockReturnValue);
jest.spyOn(mockModule, 'module').mockResolvedValue(mockReturnValue)
//Spying on the module to check functionality
jest.spyOn(mockModule, 'module');
jest.spyOn(mockModule, 'module')
//You can assert the module functionality with both of the above exactly like you would if you used jest.mock()
expect(mockModule.module).toBeCalledTimes(1);
expect(mockModule.module).toBeCalledWith(mockParam);
expect(mockModule.module).toBeCalledTimes(1)
expect(mockModule.module).toBeCalledWith(mockParam)
```
###### Dependencies
@@ -896,119 +877,114 @@ external functionality.
```tsx
//functionFile.ts
import {dependency} from "path/to/dependency"
import {dependency} from 'path/to/dependency'
export const functionUnderTest = async (param) => {
const depen = await dependency();
const value = depen.module();
const depen = await dependency()
const value = depen.module()
return value;
};
return value
}
```
```tsx
//testFile.unit.test.ts
jest.mock('path/to/dependency');
jest.mock('path/to/dependency')
import {dependency} from "path/to/dependency"
import {dependency} from 'path/to/dependency'
describe('functionUnderTest', () => {
/**
* Because the dependency has modules that are used we need to
* create a variable outside of scope that can be asserted on
*/
let mockDependency = {} as any;
let mockDependency = {} as any
beforeEach(() => {
mockDependency = {
module: jest.fn(),
};
jest.resetAllMocks(); // Resets any mocks from previous tests
});
}
jest.resetAllMocks() // Resets any mocks from previous tests
})
afterEach(() => {
//Run after each test
jest.restoreAllMocks(); // Cleans up between tests
});
jest.restoreAllMocks() // Cleans up between tests
})
//Inside the test case
(mockDependency.module as jest.Mock).mockResolvedValue(mockReturnValue);
;(mockDependency.module as jest.Mock).mockResolvedValue(mockReturnValue)
expect(mockDependency.module).toBeCalledTimes(1);
expect(mockDependency.module).toBeCalledWith(mockParam);
});
expect(mockDependency.module).toBeCalledTimes(1)
expect(mockDependency.module).toBeCalledWith(mockParam)
})
```
###### Error checking
```tsx
//function.ts
const result = await functionName(param);
const result = await functionName(param)
if (!result) {
throw new Error(403, 'Error text', error);
throw new Error(403, 'Error text', error)
}
;
```
```tsx
//testFile.unit.test.ts
const mockParam = {} as any;
const mockParam = {} as any
//This will check only the error message
expect(functionName(mockParam))
.rejects
.toThrowError('Error text');
expect(functionName(mockParam)).rejects.toThrowError('Error text')
//This will check the complete error
try {
await functionName(mockParam);
fail('Should have thrown');
await functionName(mockParam)
fail('Should have thrown')
} catch (error) {
const functionError = error as Error;
expect(functionError.code).toBe(403);
expect(functionError.message).toBe('Error text');
expect(functionError.details).toBe(mockParam);
expect(functionError.name).toBe('Error');
const functionError = error as Error
expect(functionError.code).toBe(403)
expect(functionError.message).toBe('Error text')
expect(functionError.details).toBe(mockParam)
expect(functionError.name).toBe('Error')
}
```
```tsx
//For console.error types
console.error('Error message', error);
console.error('Error message', error)
//Use spyOn to mock
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {
});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
expect(errorSpy).toHaveBeenCalledWith(
'Error message',
expect.objectContaining({name: 'Error'}) //The error 'name' refers to the error type
);
expect.objectContaining({name: 'Error'}), //The error 'name' refers to the error type
)
```
###### Mocking array return value
```tsx
//arrayFile.ts
const exampleArray = [1, 2, 3, 4, 5];
const exampleArray = [1, 2, 3, 4, 5]
const arrayResult = exampleArray.includes(2);
const arrayResult = exampleArray.includes(2)
```
```tsx
//testFile.unit.test.ts
//This will mock 'includes' for all arrays and force the return value to be true
jest.spyOn(Array.prototype, 'includes').mockReturnValue(true);
jest.spyOn(Array.prototype, 'includes').mockReturnValue(true)
// ---
//This will specify which 'includes' array to mock based on the args passed into the .includes()
jest.spyOn(Array.prototype, 'includes').mockImplementation(function (value) {
if (value === 2) {
return true;
return true
}
return false;
});
return false
})
```
### Playwright (E2E) Testing Guide
@@ -1034,23 +1010,26 @@ yarn test:db:reset
Use this priority order for selecting elements in Playwright tests:
1. Prefer `getByRole()` — use semantic roles that reflect how users interact
```typescript
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', {name: 'Submit'}).click()
```
If a meaningful ARIA role is not available, fall back to accessible text selectors (next point).
2. Use accessible text selectors — when roles don't apply, target user-facing text
```typescript
await page.getByLabel('Email').fill('user@example.com');
await page.getByPlaceholder('Enter your name').fill('John');
await page.getByText('Welcome back').isVisible();
await page.getByLabel('Email').fill('user@example.com')
await page.getByPlaceholder('Enter your name').fill('John')
await page.getByText('Welcome back').isVisible()
```
3. Only use `data-testid` — when elements have no stable user-facing text
```typescript
// For icons, toggles, or dynamic content without text
await page.getByTestId('menu-toggle').click();
await page.getByTestId('loading-spinner').isVisible();
await page.getByTestId('menu-toggle').click()
await page.getByTestId('loading-spinner').isVisible()
```
This hierarchy mirrors how users actually interact with your application, making tests more reliable and meaningful.

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,9 @@ name: E2E Tests
on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]
jobs:
e2e:
@@ -26,7 +26,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21' # Required for firebase-tools@15+
java-version: '21' # Required for firebase-tools@15+
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
@@ -41,8 +41,8 @@ jobs:
- name: Run E2E tests
env:
SKIP_DB_CLEANUP: true # Don't try to stop Docker in CI
FIREBASE_TOKEN: "dummy" # Suppresses auth warning
SKIP_DB_CLEANUP: true # Don't try to stop Docker in CI
FIREBASE_TOKEN: 'dummy' # Suppresses auth warning
# or
run: |
yarn test:e2e
@@ -61,4 +61,4 @@ jobs:
with:
name: test-results
path: test-results/
retention-days: 7
retention-days: 7

View File

@@ -2,9 +2,9 @@ name: Jest Tests
on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]
jobs:
ci:
@@ -29,22 +29,22 @@ jobs:
run: yarn lint
- name: Type check
run: npx tsc --noEmit
run: yarn typecheck
- name: Run Jest tests
env:
NEXT_PUBLIC_FIREBASE_ENV: DEV
run: |
yarn test:coverage
# npm install -g lcov-result-merger
# mkdir coverage
# lcov-result-merger \
# "backend/api/coverage/lcov.info" \
# "backend/shared/coverage/lcov.info" \
# "backend/email/coverage/lcov.info" \
# "common/coverage/lcov.info" \
# "web/coverage/lcov.info" \
# > coverage/lcov.info
# npm install -g lcov-result-merger
# mkdir coverage
# lcov-result-merger \
# "backend/api/coverage/lcov.info" \
# "backend/shared/coverage/lcov.info" \
# "backend/email/coverage/lcov.info" \
# "common/coverage/lcov.info" \
# "web/coverage/lcov.info" \
# > coverage/lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5

View File

@@ -1,23 +1,23 @@
---
trigger: always_on
description:
globs:
globs:
---
## Project Structure
- next.js react tailwind frontend `/web`
- broken down into pages, components, hooks, lib
- broken down into pages, components, hooks, lib
- express node api server `/backend/api`
- one off scripts, like migrations `/backend/scripts`
- supabase postgres. schema in `/backend/supabase`
- supabase-generated types in `/backend/supabase/schema.ts`
- supabase-generated types in `/backend/supabase/schema.ts`
- files shared between backend directories `/backend/shared`
- anything in `/backend` can import from `shared`, but not vice versa
- anything in `/backend` can import from `shared`, but not vice versa
- files shared between the frontend and backend in `/common`
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility
functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from
`/common`, but not vice versa.
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility
functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from
`/common`, but not vice versa.
## Deployment
@@ -52,18 +52,11 @@ export function HeadlineTabs(props: {
notSticky?: boolean
className?: string
}) {
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} =
props
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} = props
const user = useUser()
return (
<div
className={clsx(
className,
'bg-canvas-50 w-full',
!notSticky && 'sticky top-0 z-50'
)}
>
<div className={clsx(className, 'bg-canvas-50 w-full', !notSticky && 'sticky top-0 z-50')}>
<Carousel labelsParentClassName="gap-px">
{headlines.map(({id, slug, title}) => (
<Tab
@@ -73,9 +66,9 @@ export function HeadlineTabs(props: {
active={slug === currentSlug}
/>
))}
{user && <Tab label="More" href="/dashboard"/>}
{user && <Tab label="More" href="/dashboard" />}
{user && (isAdminId(user.id) || isModId(user.id)) && (
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines}/>
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
)}
</Carousel>
</div>
@@ -146,9 +139,7 @@ Here's the definition of usePersistentInMemoryState:
```ts
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
const [state, setState] = useStateCheckEquality<T>(
safeJsonParse(store[key]) ?? initialValue
)
const [state, setState] = useStateCheckEquality<T>(safeJsonParse(store[key]) ?? initialValue)
useEffect(() => {
const storedValue = safeJsonParse(store[key]) ?? initialValue
@@ -192,7 +183,7 @@ In `use-bets`, we have this hook to get live updates with useApiSubscription:
```ts
export const useContractBets = (
contractId: string,
opts?: APIParams<'bets'> & { enabled?: boolean }
opts?: APIParams<'bets'> & {enabled?: boolean},
) => {
const {enabled = true, ...apiOptions} = {
contractId,
@@ -200,17 +191,11 @@ export const useContractBets = (
}
const optionsKey = JSON.stringify(apiOptions)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
[],
`${optionsKey}-bets`
)
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>([], `${optionsKey}-bets`)
const addBets = (bets: Bet[]) => {
setNewBets((currentBets) => {
const uniqueBets = sortBy(
uniqBy([...currentBets, ...bets], 'id'),
'createdTime'
)
const uniqueBets = sortBy(uniqBy([...currentBets, ...bets], 'id'), 'createdTime')
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
})
}
@@ -245,7 +230,7 @@ export function broadcastUpdatedPrivateUser(userId: string) {
broadcast(`private-user/${userId}`, {})
}
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
export function broadcastUpdatedUser(user: Partial<User> & {id: string}) {
broadcast(`user/${user.id}`, {user})
}
@@ -330,7 +315,7 @@ export const placeBet: APIHandler<'bet'> = async (props, auth) => {
const isApi = auth.creds.kind === 'key'
return await betsQueue.enqueueFn(
() => placeBetMain(props, auth.uid, isApi),
[props.contractId, auth.uid]
[props.contractId, auth.uid],
)
}
```
@@ -376,13 +361,10 @@ using the pg-promise library. The client (code in web) does not have permission
Another example using the direct client:
```ts
export const getUniqueBettorIds = async (
contractId: string,
pg: SupabaseDirectClient
) => {
export const getUniqueBettorIds = async (contractId: string, pg: SupabaseDirectClient) => {
const res = await pg.manyOrNone(
'select distinct user_id from contract_bets where contract_id = $1',
[contractId]
[contractId],
)
return res.map((r) => r.user_id as string)
}
@@ -437,7 +419,7 @@ const query = renderSql(
from('contract_bets'),
where('contract_id = ${id}', {id}),
orderBy('created_time desc'),
limitValue != null && limit(limitValue)
limitValue != null && limit(limitValue),
)
const res = await pg.manyOrNone(query)
@@ -476,7 +458,7 @@ can do so via this SQL command (change the type if not `TEXT`):
```sql
ALTER TABLE profiles
ADD COLUMN profile_field TEXT;
ADD COLUMN profile_field TEXT;
```
Store it in `add_profile_field.sql` in the [migrations](../backend/supabase/migrations) folder and
@@ -540,28 +522,28 @@ How we apply it here
This project uses three complementary test types. Use the right level for the job:
- Unit tests
- Purpose: Verify a single function/module in isolation; fast, deterministic.
- Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`,
etc.).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.unit.test.ts` (or `.tsx` for React in `web`).
- When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies.
- Purpose: Verify a single function/module in isolation; fast, deterministic.
- Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`,
etc.).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.unit.test.ts` (or `.tsx` for React in `web`).
- When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies.
- Integration tests
- Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without
spinning up the full app.
- Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.integration.test.ts` (or `.tsx` for React in `web`).
- When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB,
component trees with providers.
- Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without
spinning up the full app.
- Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`).
- Runner: Jest (configured via root `jest.config.js`).
- Naming: `*.integration.test.ts` (or `.tsx` for React in `web`).
- When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB,
component trees with providers.
- End-to-End (E2E) tests
- Purpose: Validate real user flows across the full stack.
- Where: Top-level `tests/e2e` with separate areas for `web` and `backend`.
- Runner: Playwright (see root `playwright.config.ts`, `testDir: ./tests/e2e`).
- Naming: `*.e2e.spec.ts`.
- When to use: Critical journeys (signup, login, checkout), cross-service interactions, smoke tests for deployments.
- Purpose: Validate real user flows across the full stack.
- Where: Top-level `tests/e2e` with separate areas for `web` and `backend`.
- Runner: Playwright (see root `playwright.config.ts`, `testDir: ./tests/e2e`).
- Naming: `*.e2e.spec.ts`.
- When to use: Critical journeys (signup, login, checkout), cross-service interactions, smoke tests for deployments.
Quick commands
@@ -631,23 +613,23 @@ web/
- Unit and integration tests live in each packages `tests` folder and are executed by Jest via the root
`jest.config.js` projects array.
- Naming:
- Unit: `*.unit.test.ts` (or `.tsx` for React in `web`)
- Integration: `*.integration.test.ts`
- E2E (Playwright): `*.e2e.spec.ts`
- Unit: `*.unit.test.ts` (or `.tsx` for React in `web`)
- Integration: `*.integration.test.ts`
- E2E (Playwright): `*.e2e.spec.ts`
### Best Practices
* Test Behavior, Not Implementation. Dont test internal state or function calls unless youre testing utilities or very
- Test Behavior, Not Implementation. Dont test internal state or function calls unless youre testing utilities or very
critical behavior.
* Use msw to Mock APIs. Don't manually mock fetch—use msw to simulate realistic behavior, including network delays and
- Use msw to Mock APIs. Don't manually mock fetch—use msw to simulate realistic behavior, including network delays and
errors.
* Dont Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
- Dont Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
schemas).
* Prefer userEvent Over fireEvent. It simulates real user interactions more accurately.
* Avoid Testing Next.js Internals . You dont need to test getStaticProps, getServerSideProps themselves-test what they
- Prefer userEvent Over fireEvent. It simulates real user interactions more accurately.
- Avoid Testing Next.js Internals . You dont need to test getStaticProps, getServerSideProps themselves-test what they
render.
* Don't test just for coverage. Test to prevent regressions, document intent, and handle edge cases.
* Don't write end-to-end tests for features that change frequently unless absolutely necessary.
- Don't test just for coverage. Test to prevent regressions, document intent, and handle edge cases.
- Don't write end-to-end tests for features that change frequently unless absolutely necessary.
### Jest Unit Testing Guide
@@ -675,14 +657,14 @@ yarn test path/to/test.unit.test.ts
#### Test Standards
- Test file names should convey what to expect
- Follow the pattern: `<exact-filename>.[unit,integration].test.ts`. Examples:
- filename.unit.test.ts
- filename.integration.test.ts
- Follow the pattern: `<exact-filename>.[unit,integration].test.ts`. Examples:
- filename.unit.test.ts
- filename.integration.test.ts
- Group related tests using describe blocks
- Use descriptive test names that explain the expected behavior.
- Follow the pattern: "should `expected behavior` [relevant modifier]". Examples:
- should `ban user` [with matching user id]
- should `ban user` [with matching user name]
- Follow the pattern: "should `expected behavior` [relevant modifier]". Examples:
- should `ban user` [with matching user id]
- should `ban user` [with matching user name]
#### Mocking
@@ -726,15 +708,15 @@ When writing mocks, assert both outcome and interaction:
Why mocking is important?
- *Isolation* - Test your code independently of databases, APIs, and external systems. Tests only fail when your code
- _Isolation_ - Test your code independently of databases, APIs, and external systems. Tests only fail when your code
breaks, not when a server is down.
- *Speed* - Mocked tests run in milliseconds vs. seconds for real network/database calls. Run your suite constantly
- _Speed_ - Mocked tests run in milliseconds vs. seconds for real network/database calls. Run your suite constantly
without waiting.
- *Control* - Easily simulate edge cases like API errors, timeouts, or rare conditions that are difficult to reproduce
- _Control_ - Easily simulate edge cases like API errors, timeouts, or rare conditions that are difficult to reproduce
with real systems.
- *Reliability* - Eliminate unpredictable failures from network issues, rate limits, or changing external data. Same
- _Reliability_ - Eliminate unpredictable failures from network issues, rate limits, or changing external data. Same
inputs = same results, every time.
- *Focus* - Verify your function's logic and how it uses its dependencies, without requiring those dependencies to
- _Focus_ - Verify your function's logic and how it uses its dependencies, without requiring those dependencies to
actually work yet.
###### Use `jest.mock()`
@@ -747,40 +729,40 @@ functions return value isnt used, theres no need to mock it further.
```tsx
//Function and module mocks
jest.mock('path/to/module');
jest.mock('path/to/module')
//Function and module imports
import {functionUnderTest} from "path/to/function"
import {module} from "path/to/module"
import {functionUnderTest} from 'path/to/function'
import {module} from 'path/to/module'
describe('functionUnderTest', () => {
//Setup
beforeEach(() => {
//Run before each test
jest.resetAllMocks(); // Resets any mocks from previous tests
});
jest.resetAllMocks() // Resets any mocks from previous tests
})
afterEach(() => {
//Run after each test
jest.restoreAllMocks(); // Cleans up between tests
});
jest.restoreAllMocks() // Cleans up between tests
})
describe('when given valid input', () => {
it('should describe what is being tested', async () => {
//Arrange: Setup test data
const mockData = 'test';
const mockData = 'test'
//Act: Execute the function under test
const result = myFunction(mockData);
const result = myFunction(mockData)
//Assert: Verify the result
expect(result).toBe('expected');
});
});
expect(result).toBe('expected')
})
})
describe('when an error occurs', () => {
//Test cases for errors
});
});
})
})
```
###### Modules
@@ -790,27 +772,27 @@ called and what it was called with.
```tsx
//functionFile.ts
import {module as mockedDep} from "path/to/module"
import {module as mockedDep} from 'path/to/module'
export const functionUnderTest = async (param) => {
return await mockedDep(param);
};
return await mockedDep(param)
}
```
```tsx
//testFile.unit.test.ts
import {functionUnderTest} from "path/to/function";
import {module as mockedDep} from "path/to/module";
import {functionUnderTest} from 'path/to/function'
import {module as mockedDep} from 'path/to/module'
jest.mock('path/to/module');
jest.mock('path/to/module')
/**
* Inside the test case
* We create a mock for any information passed into the function that is being tested
* and if the function returns a result we create a mock to test the result
*/
const mockParam = "mockParam";
const mockReturnValue = "mockModuleValue";
const mockParam = 'mockParam'
const mockReturnValue = 'mockModuleValue'
/**
* use .mockResolvedValue when handling async/await modules that return values
@@ -818,15 +800,15 @@ const mockReturnValue = "mockModuleValue";
*/
describe('functionUnderTest', () => {
it('returns mocked module value and calls dependency correctly', async () => {
(mockedDep as jest.Mock).mockResolvedValue(mockReturnValue);
;(mockedDep as jest.Mock).mockResolvedValue(mockReturnValue)
const result = await functionUnderTest(mockParam);
const result = await functionUnderTest(mockParam)
expect(result).toBe(mockReturnValue);
expect(mockedDep).toHaveBeenCalledTimes(1);
expect(mockedDep).toHaveBeenCalledWith(mockParam);
});
});
expect(result).toBe(mockReturnValue)
expect(mockedDep).toHaveBeenCalledTimes(1)
expect(mockedDep).toHaveBeenCalledWith(mockParam)
})
})
```
Use namespace imports when you want to import everything a module exports under a single name.
@@ -834,37 +816,36 @@ Use namespace imports when you want to import everything a module exports under
```tsx
//moduleFile.ts
export const module = async (param) => {
const value = "module"
const value = 'module'
return value
};
}
export const moduleTwo = async (param) => {
const value = "moduleTwo"
const value = 'moduleTwo'
return value
};
}
```
```tsx
//functionFile.ts
import {module, moduleTwo} from "path/to/module"
import {module, moduleTwo} from 'path/to/module'
export const functionUnderTest = async (param) => {
const mockValue = await moduleTwo(param)
const returnValue = await module(mockValue)
return returnValue;
};
return returnValue
}
```
```tsx
//testFile.unit.test.ts
jest.mock('path/to/module');
jest.mock('path/to/module')
/**
* This creates an object containing all named exports from ./path/to/module
*/
import * as mockModule from "path/to/module"
(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue);
import * as mockModule from 'path/to/module'
;(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue)
```
When mocking modules, you can use `jest.spyOn()` instead of `jest.mock()`.
@@ -872,21 +853,21 @@ When mocking modules, you can use `jest.spyOn()` instead of `jest.mock()`.
- `jest.mock()` mocks the entire module, which is ideal for external dependencies like Axios or database clients.
- `jest.spyOn()` mocks specific methods while keeping the real implementation for others. It can also be used to observe
how a real method is called without changing its behavior.
- also replaces the need to have `jest.mock()` at the top of the file.
- also replaces the need to have `jest.mock()` at the top of the file.
```tsx
//testFile.unit.test.ts
import * as mockModule from "path/to/module"
import * as mockModule from 'path/to/module'
//Mocking the return value of the module
jest.spyOn(mockModule, 'module').mockResolvedValue(mockReturnValue);
jest.spyOn(mockModule, 'module').mockResolvedValue(mockReturnValue)
//Spying on the module to check functionality
jest.spyOn(mockModule, 'module');
jest.spyOn(mockModule, 'module')
//You can assert the module functionality with both of the above exactly like you would if you used jest.mock()
expect(mockModule.module).toBeCalledTimes(1);
expect(mockModule.module).toBeCalledWith(mockParam);
expect(mockModule.module).toBeCalledTimes(1)
expect(mockModule.module).toBeCalledWith(mockParam)
```
###### Dependencies
@@ -896,119 +877,114 @@ external functionality.
```tsx
//functionFile.ts
import {dependency} from "path/to/dependency"
import {dependency} from 'path/to/dependency'
export const functionUnderTest = async (param) => {
const depen = await dependency();
const value = depen.module();
const depen = await dependency()
const value = depen.module()
return value;
};
return value
}
```
```tsx
//testFile.unit.test.ts
jest.mock('path/to/dependency');
jest.mock('path/to/dependency')
import {dependency} from "path/to/dependency"
import {dependency} from 'path/to/dependency'
describe('functionUnderTest', () => {
/**
* Because the dependency has modules that are used we need to
* create a variable outside of scope that can be asserted on
*/
let mockDependency = {} as any;
let mockDependency = {} as any
beforeEach(() => {
mockDependency = {
module: jest.fn(),
};
jest.resetAllMocks(); // Resets any mocks from previous tests
});
}
jest.resetAllMocks() // Resets any mocks from previous tests
})
afterEach(() => {
//Run after each test
jest.restoreAllMocks(); // Cleans up between tests
});
jest.restoreAllMocks() // Cleans up between tests
})
//Inside the test case
(mockDependency.module as jest.Mock).mockResolvedValue(mockReturnValue);
;(mockDependency.module as jest.Mock).mockResolvedValue(mockReturnValue)
expect(mockDependency.module).toBeCalledTimes(1);
expect(mockDependency.module).toBeCalledWith(mockParam);
});
expect(mockDependency.module).toBeCalledTimes(1)
expect(mockDependency.module).toBeCalledWith(mockParam)
})
```
###### Error checking
```tsx
//function.ts
const result = await functionName(param);
const result = await functionName(param)
if (!result) {
throw new Error(403, 'Error text', error);
throw new Error(403, 'Error text', error)
}
;
```
```tsx
//testFile.unit.test.ts
const mockParam = {} as any;
const mockParam = {} as any
//This will check only the error message
expect(functionName(mockParam))
.rejects
.toThrowError('Error text');
expect(functionName(mockParam)).rejects.toThrowError('Error text')
//This will check the complete error
try {
await functionName(mockParam);
fail('Should have thrown');
await functionName(mockParam)
fail('Should have thrown')
} catch (error) {
const functionError = error as Error;
expect(functionError.code).toBe(403);
expect(functionError.message).toBe('Error text');
expect(functionError.details).toBe(mockParam);
expect(functionError.name).toBe('Error');
const functionError = error as Error
expect(functionError.code).toBe(403)
expect(functionError.message).toBe('Error text')
expect(functionError.details).toBe(mockParam)
expect(functionError.name).toBe('Error')
}
```
```tsx
//For console.error types
console.error('Error message', error);
console.error('Error message', error)
//Use spyOn to mock
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {
});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
expect(errorSpy).toHaveBeenCalledWith(
'Error message',
expect.objectContaining({name: 'Error'}) //The error 'name' refers to the error type
);
expect.objectContaining({name: 'Error'}), //The error 'name' refers to the error type
)
```
###### Mocking array return value
```tsx
//arrayFile.ts
const exampleArray = [1, 2, 3, 4, 5];
const exampleArray = [1, 2, 3, 4, 5]
const arrayResult = exampleArray.includes(2);
const arrayResult = exampleArray.includes(2)
```
```tsx
//testFile.unit.test.ts
//This will mock 'includes' for all arrays and force the return value to be true
jest.spyOn(Array.prototype, 'includes').mockReturnValue(true);
jest.spyOn(Array.prototype, 'includes').mockReturnValue(true)
// ---
//This will specify which 'includes' array to mock based on the args passed into the .includes()
jest.spyOn(Array.prototype, 'includes').mockImplementation(function (value) {
if (value === 2) {
return true;
return true
}
return false;
});
return false
})
```
### Playwright (E2E) Testing Guide
@@ -1034,23 +1010,26 @@ yarn test:db:reset
Use this priority order for selecting elements in Playwright tests:
1. Prefer `getByRole()` — use semantic roles that reflect how users interact
```typescript
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button', {name: 'Submit'}).click()
```
If a meaningful ARIA role is not available, fall back to accessible text selectors (next point).
2. Use accessible text selectors — when roles don't apply, target user-facing text
```typescript
await page.getByLabel('Email').fill('user@example.com');
await page.getByPlaceholder('Enter your name').fill('John');
await page.getByText('Welcome back').isVisible();
await page.getByLabel('Email').fill('user@example.com')
await page.getByPlaceholder('Enter your name').fill('John')
await page.getByText('Welcome back').isVisible()
```
3. Only use `data-testid` — when elements have no stable user-facing text
```typescript
// For icons, toggles, or dynamic content without text
await page.getByTestId('menu-toggle').click();
await page.getByTestId('loading-spinner').isVisible();
await page.getByTestId('menu-toggle').click()
await page.getByTestId('loading-spinner').isVisible()
```
This hierarchy mirrors how users actually interact with your application, making tests more reliable and meaningful.

35
.prettierignore Normal file
View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
---
trigger: manual
description:
globs:
globs:
---
### Translations
```typescript
import {useT} from "web/lib/locale";
import {useT} from 'web/lib/locale'
const t = useT()
t("common.key", "English translations")
t('common.key', 'English translations')
```
Translations should go to the JSON files in `web/messages` (`de.json` and `fr.json`, as of now).

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
![Vercel](https://deploy-badge.vercel.app/vercel/compass)
[![CD](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
[![CD API](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
@@ -34,14 +33,17 @@ No contribution is too small—whether its changing a color, resizing a butto
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
- Email hello@compassmeet.com
- Raise an issue on GitHub
If you want to add tasks without creating an account, you can simply email
```
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
```
Put the task title in the email subject and the task description in the email content.
Here is a tailored selection of things that would be very useful. If you want to help but dont know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
@@ -105,17 +107,20 @@ Below are the steps to contribute. If you have any trouble or questions, please
### Installation
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
```bash
git clone https://github.com/<your-username>/Compass.git
cd Compass
```
Install `yarn` (if not already installed):
```bash
npm install --global yarn
```
Then, install the dependencies for this project:
```bash
yarn install --frozen-lockfile
```
@@ -123,14 +128,17 @@ yarn install --frozen-lockfile
### Tests
Make sure the Jest tests pass:
```bash
yarn test
```
If they don't and you can't find out why, simply raise an issue! Sometimes it's something on our end that we overlooked.
### Running the Development Server
Start the development server:
```bash
yarn dev
```
@@ -227,6 +235,7 @@ looks and feels like the real thing.
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
@@ -239,6 +248,7 @@ If you are new to Typescript or the open-source space, you could start with smal
##### Resources
There is a lof of documentation in the [docs](docs) folder and across the repo, namely:
- [Next.js.md](docs/Next.js.md) for core fundamentals about our web / page-rendering framework.
- [knowledge.md](docs/knowledge.md) for general information about the project structure.
- [development.md](docs/development.md) for additional instructions, such as adding new profile fields or languages.
@@ -252,22 +262,26 @@ There are a lot of useful scripts you can use in the [scripts](scripts) folder.
### Submission
Add the original repo as upstream for syncing:
```bash
git remote add upstream https://github.com/CompassConnections/Compass.git
```
Create a new branch for your changes:
```bash
git checkout -b <branch-name>
```
Make changes, then stage and commit:
```bash
git add .
git commit -m "Describe your changes"
```
Push branch to your fork:
```bash
git push origin <branch-name>
```
@@ -279,6 +293,7 @@ Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `Compa
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the following services: email, geolocation.
We can't make the following information public, for security and privacy reasons:
- Database, otherwise anyone could access all the user data (including private messages)
- Firebase, otherwise anyone could remove users or modify the media files
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
@@ -289,4 +304,5 @@ Contributors should use the default keys for local development. Production uses
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
## Acknowledgements
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,22 +5,21 @@
"private": true,
"scripts": {
"watch:serve": "tsx watch src/serve.ts",
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
"watch:compile": "npx concurrently \"(cd ../../common && tsc --watch)\" \"(cd ../shared && tsc --watch)\" \"(cd ../email && tsc --watch)\" \"tsc --watch --preserveWatchOutput\" \"tsc-alias --watch\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\"",
"dev": "yarn watch:serve",
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
"build:fast": "yarn compile && yarn dist:copy",
"clean": "rm -rf lib && (cd ../../common && rm -rf lib) && (cd ../shared && rm -rf lib) && (cd ../email && rm -rf lib)",
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias) && cp -r src/public/ lib/",
"compile": "(cd ../../common && tsc) && (cd ../shared && tsc) && (cd ../email && tsc) && tsc && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias) && cp -r src/public/ lib/",
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
"dist": "yarn dist:clean && yarn dist:copy",
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api && cp metadata.json dist && cp metadata.json dist/backend/api",
"watch": "tsc -w",
"verify": "yarn --cwd=../.. verify",
"lint": "npx eslint . --max-warnings 0",
"lint-fix": "npx eslint . --fix",
"verify:dir": "npx eslint . --max-warnings 0",
"typecheck": "npx tsc --noEmit",
"regen-types": "cd ../supabase && make ENV=prod regen-types",
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev",
"test": "jest --config jest.config.js",

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {update} from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -15,7 +15,7 @@ export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
`SELECT id, creator_id, status
FROM events
WHERE id = $1`,
[body.eventId]
[body.eventId],
)
if (!event) {
@@ -35,7 +35,7 @@ export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
update(pg, 'events', 'id', {
status: 'cancelled',
id: body.eventId,
})
}),
)
if (error) {

View File

@@ -1,6 +1,6 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -13,7 +13,7 @@ export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
FROM events_participants
WHERE event_id = $1
AND user_id = $2`,
[body.eventId, auth.uid]
[body.eventId, auth.uid],
)
if (!rsvp) {
@@ -26,8 +26,8 @@ export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
`DELETE
FROM events_participants
WHERE id = $1`,
[rsvp.id]
)
[rsvp.id],
),
)
if (error) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
export const createEvent: APIHandler<'create-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -38,7 +38,7 @@ export const createEvent: APIHandler<'create-event'> = async (body, auth) => {
event_start_time: body.eventStartTime,
event_end_time: body.eventEndTime,
max_participants: body.maxParticipants,
})
}),
)
if (error) {

View File

@@ -1,9 +1,9 @@
import {createSupabaseDirectClient, SupabaseDirectClient,} from 'shared/supabase/init'
import {Notification} from 'common/notifications'
import {createBulkNotification, insertNotificationToSupabase,} from 'shared/supabase/notifications'
import {tryCatch} from 'common/util/try-catch'
import {Row} from 'common/supabase/utils'
import {ANDROID_APP_URL} from 'common/constants'
import {Notification} from 'common/notifications'
import {Row} from 'common/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {createBulkNotification, insertNotificationToSupabase} from 'shared/supabase/notifications'
export const createAndroidReleaseNotifications = async () => {
const createdTime = Date.now()
@@ -38,8 +38,7 @@ export const createAndroidTestNotifications = async () => {
sourceSlug: '/contact',
sourceUserAvatarUrl:
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
title:
'Android App Ready for Review — Help Us Unlock the Google Play Release',
title: 'Android App Ready for Review — Help Us Unlock the Google Play Release',
sourceText:
'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Playregistered email address so we can add you as a tester and complete the review process.',
}
@@ -60,8 +59,7 @@ export const createShareNotifications = async () => {
sourceUserAvatarUrl:
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Ficon-outreach-outstrip-outreach-272151502.jpg?alt=media&token=6d6fcecb-818c-4fca-a8e0-d2d0069b9445',
title: 'Give us tips to reach more people',
sourceText:
'250 members already! Tell us where and how we can best share Compass.',
sourceText: '250 members already! Tell us where and how we can best share Compass.',
}
return await createNotifications(notification)
}
@@ -87,9 +85,7 @@ export const createVoteNotifications = async () => {
export const createNotifications = async (notification: Notification) => {
const pg = createSupabaseDirectClient()
const {data: users, error} = await tryCatch(
pg.many<Row<'users'>>('select * from users')
)
const {data: users, error} = await tryCatch(pg.many<Row<'users'>>('select * from users'))
if (error) {
console.error('Error fetching users', error)
@@ -117,7 +113,7 @@ export const createNotifications = async (notification: Notification) => {
export const createNotification = async (
user: Row<'users'>,
notification: Notification,
pg: SupabaseDirectClient
pg: SupabaseDirectClient,
) => {
notification.userId = user.id
console.log('notification', user.username)
@@ -132,9 +128,7 @@ export const createEventsAvailableNotifications = async () => {
const pg = createSupabaseDirectClient()
// Fetch all users
const {data: users, error} = await tryCatch(
pg.many<Row<'users'>>('select id from users')
)
const {data: users, error} = await tryCatch(pg.many<Row<'users'>>('select id from users'))
if (error) {
console.error('Error fetching users', error)
@@ -161,12 +155,10 @@ export const createEventsAvailableNotifications = async () => {
sourceUpdateType: 'created',
},
userIds,
pg
pg,
)
console.log(
`Created events notification template ${templateId} for ${count} users`
)
console.log(`Created events notification template ${templateId} for ${count} users`)
return {
success: true,

View File

@@ -1,10 +1,10 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {addUsersToPrivateMessageChannel} from 'api/helpers/private-messages'
import {filterDefined} from 'common/util/array'
import * as admin from 'firebase-admin'
import {uniq} from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {addUsersToPrivateMessageChannel} from 'api/helpers/private-messages'
import {getPrivateUser, getUser} from 'shared/utils'
import * as admin from 'firebase-admin'
export const createPrivateUserMessageChannel: APIHandler<
'create-private-user-message-channel'
@@ -13,10 +13,7 @@ export const createPrivateUserMessageChannel: APIHandler<
const user = await admin.auth().getUser(auth.uid)
// console.log(JSON.stringify(user, null, 2))
if (!user?.emailVerified) {
throw new APIError(
403,
'You must verify your email to contact people.'
)
throw new APIError(403, 'You must verify your email to contact people.')
}
const userIds = uniq(body.userIds.concat(auth.uid))
@@ -27,27 +24,22 @@ export const createPrivateUserMessageChannel: APIHandler<
const creator = await getUser(creatorId)
if (!creator) throw new APIError(401, 'Your account was not found')
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
const toPrivateUsers = filterDefined(
await Promise.all(userIds.map((id) => getPrivateUser(id)))
)
const toPrivateUsers = filterDefined(await Promise.all(userIds.map((id) => getPrivateUser(id))))
if (toPrivateUsers.length !== userIds.length)
throw new APIError(
404,
`Private user ${userIds.find(
(uid) => !toPrivateUsers.map((p) => p.id).includes(uid)
)} not found`
(uid) => !toPrivateUsers.map((p) => p.id).includes(uid),
)} not found`,
)
if (
toPrivateUsers.some((user) =>
user.blockedUserIds.some((blockedId) => userIds.includes(blockedId))
user.blockedUserIds.some((blockedId) => userIds.includes(blockedId)),
)
) {
throw new APIError(
403,
'One of the users has blocked another user in the list'
)
throw new APIError(403, 'One of the users has blocked another user in the list')
}
const currentChannel = await pg.oneOrNone(
@@ -58,7 +50,7 @@ export const createPrivateUserMessageChannel: APIHandler<
having array_agg(user_id::text) @> array [$1]::text[]
and array_agg(user_id::text) <@ array [$1]::text[]
`,
[userIds]
[userIds],
)
if (currentChannel)
return {
@@ -69,14 +61,14 @@ export const createPrivateUserMessageChannel: APIHandler<
const channel = await pg.one(
`insert into private_user_message_channels default
values
returning id`
returning id`,
)
await pg.none(
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
values ($1, $2, 'creator', 'joined')
`,
[channel.id, creatorId]
[channel.id, creatorId],
)
const memberIds = userIds.filter((id) => id !== creatorId)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import {getUser} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import * as admin from 'firebase-admin'
import {deleteUserFiles} from 'shared/firebase-utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import * as admin from "firebase-admin";
import {deleteUserFiles} from "shared/firebase-utils";
import {getUser} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
const user = await getUser(auth.uid)

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {Row} from 'common/supabase/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export function shuffle<T>(array: T[]): T[] {
const arr = [...array] // copy to avoid mutating the original
@@ -11,9 +11,10 @@ export function shuffle<T>(array: T[]): T[] {
return arr
}
export const getCompatibilityQuestions: APIHandler<
'get-compatibility-questions'
> = async (props, _auth) => {
export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'> = async (
props,
_auth,
) => {
const {locale = 'en', keyword} = props
const pg = createSupabaseDirectClient()
@@ -40,7 +41,7 @@ export const getCompatibilityQuestions: APIHandler<
}
const questions = await pg.manyOrNone<
Row<'compatibility_prompts'> & { answer_count: number; score: number }
Row<'compatibility_prompts'> & {answer_count: number; score: number}
>(
`
SELECT cp.id,
@@ -82,7 +83,7 @@ export const getCompatibilityQuestions: APIHandler<
ORDER BY cp.importance_score
`,
params
params,
)
// console.debug({questions})

View File

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

View File

@@ -23,51 +23,53 @@ export const getEvents: APIHandler<'get-events'> = async () => {
FROM events
WHERE is_public = true
AND status = 'active'
ORDER BY event_start_time`
ORDER BY event_start_time`,
)
// Get participants for each event
const eventIds = events.map(e => e.id)
const participants = eventIds.length > 0
? await pg.manyOrNone<{
event_id: string
user_id: string
status: 'going' | 'maybe' | 'not_going'
}>(
`SELECT event_id, user_id, status
const eventIds = events.map((e) => e.id)
const participants =
eventIds.length > 0
? await pg.manyOrNone<{
event_id: string
user_id: string
status: 'going' | 'maybe' | 'not_going'
}>(
`SELECT event_id, user_id, status
FROM events_participants
WHERE event_id = ANY ($1)`,
[eventIds]
)
: []
[eventIds],
)
: []
// Get creator info for each event
const creatorIds = [...new Set(events.map(e => e.creator_id))]
const creators = creatorIds.length > 0
? await pg.manyOrNone<{
id: string
name: string
username: string
avatar_url: string | null
}>(
`SELECT id, name, username, data ->> 'avatarUrl' as avatar_url
const creatorIds = [...new Set(events.map((e) => e.creator_id))]
const creators =
creatorIds.length > 0
? await pg.manyOrNone<{
id: string
name: string
username: string
avatar_url: string | null
}>(
`SELECT id, name, username, data ->> 'avatarUrl' as avatar_url
FROM users
WHERE id = ANY ($1)`,
[creatorIds]
)
: []
[creatorIds],
)
: []
const now = new Date()
const eventsWithDetails = events.map(event => ({
const eventsWithDetails = events.map((event) => ({
...event,
participants: participants
.filter(p => p.event_id === event.id && p.status === 'going')
.map(p => p.user_id),
.filter((p) => p.event_id === event.id && p.status === 'going')
.map((p) => p.user_id),
maybe: participants
.filter(p => p.event_id === event.id && p.status === 'maybe')
.map(p => p.user_id),
creator: creators.find(c => c.id === event.creator_id),
.filter((p) => p.event_id === event.id && p.status === 'maybe')
.map((p) => p.user_id),
creator: creators.find((c) => c.id === event.creator_id),
}))
const upcoming: typeof eventsWithDetails = []

View File

@@ -3,16 +3,16 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getHiddenProfiles: APIHandler<'get-hidden-profiles'> = async (
{limit = 100, offset = 0},
auth
auth,
) => {
const pg = createSupabaseDirectClient()
// Count total hidden for pagination info
const countRes = await pg.one<{ count: string }>(
const countRes = await pg.one<{count: string}>(
`select count(*)::text as count
from hidden_profiles
where hider_user_id = $1`,
[auth.uid]
[auth.uid],
)
const count = Number(countRes.count) || 0
@@ -31,7 +31,7 @@ export const getHiddenProfiles: APIHandler<'get-hidden-profiles'> = async (
username: r.username as string,
avatarUrl: r.avatarUrl as string | null | undefined,
createdTime: r.createdTime as string | undefined,
})
}),
)
return {status: 'success', hidden: rows, count}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,9 @@
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIHandler} from 'api/helpers/endpoint'
import {Notification} from 'common/notifications'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getNotifications: APIHandler<'get-notifications'> = async (
props,
auth
) => {
const { limit, after } = props
export const getNotifications: APIHandler<'get-notifications'> = async (props, auth) => {
const {limit, after} = props
const pg = createSupabaseDirectClient()
const query = `
select case
@@ -47,6 +44,6 @@ export const getNotifications: APIHandler<'get-notifications'> = async (
return await pg.map(
query,
[auth.uid, limit, after],
(row) => row.notification_data as Notification
(row) => row.notification_data as Notification,
)
}

View File

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

View File

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

View File

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

View File

@@ -1,60 +1,68 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {OptionTableKey} from 'common/profiles/constants'
import {compact} from 'lodash'
import {convertRow} from 'shared/profiles/supabase'
import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {compact} from "lodash";
import {OptionTableKey} from "common/profiles/constants";
import {
from,
join,
leftJoin,
limit,
orderBy,
renderSql,
select,
where,
} from 'shared/supabase/sql-builder'
export type profileQueryType = {
limit?: number | undefined,
after?: string | undefined,
userId?: string | undefined,
name?: string | undefined,
genders?: string[] | undefined,
education_levels?: string[] | undefined,
pref_gender?: string[] | undefined,
pref_age_min?: number | undefined,
pref_age_max?: number | undefined,
drinks_min?: number | undefined,
drinks_max?: number | undefined,
big5_openness_min?: number | undefined,
big5_openness_max?: number | undefined,
big5_conscientiousness_min?: number | undefined,
big5_conscientiousness_max?: number | undefined,
big5_extraversion_min?: number | undefined,
big5_extraversion_max?: number | undefined,
big5_agreeableness_min?: number | undefined,
big5_agreeableness_max?: number | undefined,
big5_neuroticism_min?: number | undefined,
big5_neuroticism_max?: number | undefined,
pref_relation_styles?: string[] | undefined,
pref_romantic_styles?: string[] | undefined,
diet?: string[] | undefined,
political_beliefs?: string[] | undefined,
mbti?: string[] | undefined,
relationship_status?: string[] | undefined,
languages?: string[] | undefined,
religion?: string[] | undefined,
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
is_smoker?: boolean | undefined,
shortBio?: boolean | undefined,
geodbCityIds?: string[] | undefined,
lat?: number | undefined,
lon?: number | undefined,
radius?: number | undefined,
compatibleWithUserId?: string | undefined,
skipId?: string | undefined,
orderBy?: string | undefined,
lastModificationWithin?: string | undefined,
locale?: string | undefined,
limit?: number | undefined
after?: string | undefined
userId?: string | undefined
name?: string | undefined
genders?: string[] | undefined
education_levels?: string[] | undefined
pref_gender?: string[] | undefined
pref_age_min?: number | undefined
pref_age_max?: number | undefined
drinks_min?: number | undefined
drinks_max?: number | undefined
big5_openness_min?: number | undefined
big5_openness_max?: number | undefined
big5_conscientiousness_min?: number | undefined
big5_conscientiousness_max?: number | undefined
big5_extraversion_min?: number | undefined
big5_extraversion_max?: number | undefined
big5_agreeableness_min?: number | undefined
big5_agreeableness_max?: number | undefined
big5_neuroticism_min?: number | undefined
big5_neuroticism_max?: number | undefined
pref_relation_styles?: string[] | undefined
pref_romantic_styles?: string[] | undefined
diet?: string[] | undefined
political_beliefs?: string[] | undefined
mbti?: string[] | undefined
relationship_status?: string[] | undefined
languages?: string[] | undefined
religion?: string[] | undefined
wants_kids_strength?: number | undefined
has_kids?: number | undefined
is_smoker?: boolean | undefined
shortBio?: boolean | undefined
geodbCityIds?: string[] | undefined
lat?: number | undefined
lon?: number | undefined
radius?: number | undefined
compatibleWithUserId?: string | undefined
skipId?: string | undefined
orderBy?: string | undefined
lastModificationWithin?: string | undefined
locale?: string | undefined
} & {
[K in OptionTableKey]?: string[] | undefined
}
// const userActivityColumns = ['last_online_time']
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
console.debug('loadProfiles', props)
@@ -108,7 +116,12 @@ export const loadProfiles = async (props: profileQueryType) => {
const filterLocation = lat && lon && radius
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
const keywords = name
? name
.split(',')
.map((q) => q.trim())
.filter(Boolean)
: []
// console.debug('keywords:', keywords)
if (orderByParam === 'compatibility_score' && !compatibleWithUserId) {
@@ -116,11 +129,12 @@ export const loadProfiles = async (props: profileQueryType) => {
throw Error('Incompatible with user ID')
}
const tablePrefix = orderByParam === 'compatibility_score'
? 'compatibility_scores'
: orderByParam === 'last_online_time'
? 'user_activity'
: 'profiles'
const tablePrefix =
orderByParam === 'compatibility_score'
? 'compatibility_scores'
: orderByParam === 'last_online_time'
? 'user_activity'
: 'profiles'
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
@@ -144,7 +158,10 @@ export const loadProfiles = async (props: profileQueryType) => {
const causesJoin = getManyToManyJoin('causes')
const workJoin = getManyToManyJoin('work')
const compatibilityScoreJoin = pgp.as.format(`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`, {compatibleWithUserId})
const compatibilityScoreJoin = pgp.as.format(
`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`,
{compatibleWithUserId},
)
const joins = [
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
@@ -154,7 +171,8 @@ export const loadProfiles = async (props: profileQueryType) => {
joinWork && leftJoin(workJoin),
]
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
const _orderBy =
orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
const afterFilter = renderSql(
select(_orderBy),
from('profiles'),
@@ -189,125 +207,126 @@ export const loadProfiles = async (props: profileQueryType) => {
)`
}
const filters = [
where('looking_for_matches = true'),
where(`profiles.disabled != true`),
// where(`pinned_url is not null and pinned_url != ''`),
where(
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
),
where(`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`),
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
...keywords.map(word => where(
`lower(users.name) ilike '%' || lower($(word)) || '%'
...keywords.map((word) =>
where(
`lower(users.name) ilike '%' || lower($(word)) || '%'
or lower(search_text) ilike '%' || lower($(word)) || '%'
or search_tsv @@ phraseto_tsquery('english', $(word))
OR ${getOptionClauseKeyword('interests')}
OR ${getOptionClauseKeyword('causes')}
OR ${getOptionClauseKeyword('work')}
`,
{word, locale}
)),
{word, locale},
),
),
genders?.length && where(`gender = ANY($(genders))`, {genders}),
education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}),
education_levels?.length &&
where(`education_level = ANY($(education_levels))`, {education_levels}),
mbti?.length && where(`mbti = ANY($(mbti))`, {mbti}),
pref_gender?.length &&
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {pref_gender}),
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {
pref_gender,
}),
pref_age_min &&
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
pref_age_min && where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
pref_age_max &&
where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
pref_age_max && where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
drinks_min &&
where(`drinks_per_month >= $(drinks_min) or drinks_per_month is null`, {drinks_min}),
where(`drinks_per_month >= $(drinks_min) or drinks_per_month is null`, {drinks_min}),
drinks_max &&
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}),
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}),
big5_openness_min &&
where(`big5_openness >= $(big5_openness_min) or big5_openness is null`, {big5_openness_min}),
where(`big5_openness >= $(big5_openness_min) or big5_openness is null`, {big5_openness_min}),
big5_openness_max &&
where(`big5_openness <= $(big5_openness_max) or big5_openness is null`, {big5_openness_max}),
where(`big5_openness <= $(big5_openness_max) or big5_openness is null`, {big5_openness_max}),
big5_conscientiousness_min &&
where(
`big5_conscientiousness >= $(big5_conscientiousness_min) or big5_conscientiousness is null`,
{big5_conscientiousness_min}
),
where(
`big5_conscientiousness >= $(big5_conscientiousness_min) or big5_conscientiousness is null`,
{big5_conscientiousness_min},
),
big5_conscientiousness_max &&
where(
`big5_conscientiousness <= $(big5_conscientiousness_max) or big5_conscientiousness is null`,
{big5_conscientiousness_max}
),
where(
`big5_conscientiousness <= $(big5_conscientiousness_max) or big5_conscientiousness is null`,
{big5_conscientiousness_max},
),
big5_extraversion_min &&
where(`big5_extraversion >= $(big5_extraversion_min) or big5_extraversion is null`, {big5_extraversion_min}),
where(`big5_extraversion >= $(big5_extraversion_min) or big5_extraversion is null`, {
big5_extraversion_min,
}),
big5_extraversion_max &&
where(`big5_extraversion <= $(big5_extraversion_max) or big5_extraversion is null`, {big5_extraversion_max}),
where(`big5_extraversion <= $(big5_extraversion_max) or big5_extraversion is null`, {
big5_extraversion_max,
}),
big5_agreeableness_min &&
where(`big5_agreeableness >= $(big5_agreeableness_min) or big5_agreeableness is null`, {big5_agreeableness_min}),
where(`big5_agreeableness >= $(big5_agreeableness_min) or big5_agreeableness is null`, {
big5_agreeableness_min,
}),
big5_agreeableness_max &&
where(`big5_agreeableness <= $(big5_agreeableness_max) or big5_agreeableness is null`, {big5_agreeableness_max}),
where(`big5_agreeableness <= $(big5_agreeableness_max) or big5_agreeableness is null`, {
big5_agreeableness_max,
}),
big5_neuroticism_min &&
where(`big5_neuroticism >= $(big5_neuroticism_min) or big5_neuroticism is null`, {big5_neuroticism_min}),
where(`big5_neuroticism >= $(big5_neuroticism_min) or big5_neuroticism is null`, {
big5_neuroticism_min,
}),
big5_neuroticism_max &&
where(`big5_neuroticism <= $(big5_neuroticism_max) or big5_neuroticism is null`, {big5_neuroticism_max}),
where(`big5_neuroticism <= $(big5_neuroticism_max) or big5_neuroticism is null`, {
big5_neuroticism_max,
}),
pref_relation_styles?.length &&
where(
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
{pref_relation_styles}
),
where(
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
{pref_relation_styles},
),
pref_romantic_styles?.length &&
where(
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
{pref_romantic_styles}
),
where(
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
{pref_romantic_styles},
),
diet?.length &&
where(
`diet IS NULL OR diet = '{}' OR diet && $(diet)`,
{diet}
),
diet?.length && where(`diet IS NULL OR diet = '{}' OR diet && $(diet)`, {diet}),
political_beliefs?.length &&
where(
`political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`,
{political_beliefs}
),
where(
`political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`,
{political_beliefs},
),
relationship_status?.length &&
where(
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
{relationship_status}
),
where(
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
{relationship_status},
),
languages?.length &&
where(
`languages && $(languages)`,
{languages}
),
languages?.length && where(`languages && $(languages)`, {languages}),
religion?.length &&
where(
`religion IS NULL OR religion = '{}' OR religion && $(religion)`,
{religion}
),
where(`religion IS NULL OR religion = '{}' OR religion && $(religion)`, {religion}),
interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}),
@@ -316,27 +335,31 @@ export const loadProfiles = async (props: profileQueryType) => {
work?.length && where(getManyToManyClause('work'), {values: work.map(Number)}),
!!wants_kids_strength &&
wants_kids_strength !== -1 &&
where(
'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' + (wants_kids_strength >= 2 ? `wants_kids_strength >= $(wants_kids_strength)` : `wants_kids_strength <= $(wants_kids_strength)`),
{wants_kids_strength}
),
wants_kids_strength !== -1 &&
where(
'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' +
(wants_kids_strength >= 2
? `wants_kids_strength >= $(wants_kids_strength)`
: `wants_kids_strength <= $(wants_kids_strength)`),
{wants_kids_strength},
),
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
has_kids && has_kids > 0 && where(`has_kids > 0`),
is_smoker !== undefined && (
is_smoker !== undefined &&
where(
(is_smoker ? '' : 'is_smoker IS NULL OR ') + // smokers are rare, so we don't include the people who didn't answer if we're looking for smokers
`is_smoker = $(is_smoker)`, {is_smoker}
)
),
`is_smoker = $(is_smoker)`,
{is_smoker},
),
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
geodbCityIds?.length && where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
filterLocation && where(`
filterLocation &&
where(
`
city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0)
AND $(target_lat) + ($(radius) / 69.0)
AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
@@ -345,27 +368,36 @@ export const loadProfiles = async (props: profileQueryType) => {
POWER(city_latitude - $(target_lat), 2)
+ POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
) <= $(radius) / 69.0
`, {target_lat: lat, target_lon: lon, radius}),
`,
{target_lat: lat, target_lon: lon, radius},
),
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
!shortBio && where(
`bio_length >= ${100}
!shortBio &&
where(
`bio_length >= ${100}
OR array_length(profile_work.work, 1) > 0
OR array_length(profile_interests.interests, 1) > 0
OR occupation_title IS NOT NULL
`),
`,
),
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
lastModificationWithin &&
where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {
lastModificationWithin,
}),
// Exclude profiles that the requester has chosen to hide
userId && where(
`NOT EXISTS (
userId &&
where(
`NOT EXISTS (
SELECT 1 FROM hidden_profiles hp
WHERE hp.hider_user_id = $(userId)
AND hp.hidden_user_id = profiles.user_id
)`, {userId}
),
)`,
{userId},
),
]
let selectCols = 'profiles.*, users.name, users.username, users.data as user'
@@ -393,11 +425,7 @@ export const loadProfiles = async (props: profileQueryType) => {
// console.debug('profiles:', profiles)
const countQuery = renderSql(
select(`count(*) as count`),
...tableSelection,
...filters,
)
const countQuery = renderSql(select(`count(*) as count`), ...tableSelection, ...filters)
const count = await pg.one<number>(countQuery, [], (r) => Number(r.count))

View File

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

View File

@@ -1,22 +1,20 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {Row} from 'common/supabase/utils'
import {getLikesAndShipsMain} from './get-likes-and-ships'
import {parseJsonContentToText} from 'common/util/parse'
import {parseMessageObject} from "shared/supabase/messages";
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {parseMessageObject} from 'shared/supabase/messages'
import {getLikesAndShipsMain} from './get-likes-and-ships'
import {APIHandler} from './helpers/endpoint'
export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
const userId = auth.uid
const pg = createSupabaseDirectClient()
const user = await pg.oneOrNone<Row<'users'>>(
'select * from users where id = $1',
[userId]
)
const user = await pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [userId])
const privateUser = await pg.oneOrNone<Row<'private_users'>>(
'select * from private_users where id = $1',
[userId]
[userId],
)
const profile = await pg.oneOrNone(
@@ -47,7 +45,7 @@ export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
group by pw.profile_id) as profile_work on profile_work.profile_id = profiles.id
where profiles.user_id = $1
`,
[userId]
[userId],
)
if (profile.bio) {
@@ -68,17 +66,17 @@ export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
where a.creator_id = $1
order by a.created_time desc
`,
[userId]
[userId],
)
const userActivity = await pg.oneOrNone<Row<'user_activity'>>(
'select * from user_activity where user_id = $1',
[userId]
[userId],
)
const searchBookmarks = await pg.manyOrNone<Row<'bookmarked_searches'>>(
'select * from bookmarked_searches where creator_id = $1 order by id desc',
[userId]
[userId],
)
const hiddenProfiles = await pg.manyOrNone(
@@ -87,41 +85,36 @@ export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
join users u on u.id = hp.hidden_user_id
where hp.hider_user_id = $1
order by hp.id desc`,
[userId]
[userId],
)
const messageChannelMemberships = await pg.manyOrNone<
Row<'private_user_message_channel_members'>
>(
'select * from private_user_message_channel_members where user_id = $1',
[userId]
)
>('select * from private_user_message_channel_members where user_id = $1', [userId])
const channelIds = Array.from(
new Set(messageChannelMemberships.map((m) => m.channel_id))
)
const channelIds = Array.from(new Set(messageChannelMemberships.map((m) => m.channel_id)))
const messageChannels = channelIds.length
? await pg.manyOrNone<Row<'private_user_message_channels'>>(
'select * from private_user_message_channels where id = any($1)',
[channelIds]
)
'select * from private_user_message_channels where id = any($1)',
[channelIds],
)
: []
const messages = channelIds.length
? await pg.manyOrNone<Row<'private_user_messages'>>(
`select *
`select *
from private_user_messages
where channel_id = any ($1)
order by created_time`,
[channelIds]
)
[channelIds],
)
: []
for (const message of messages) parseMessageObject(message)
const membershipsWithUsernames = channelIds.length
? await pg.manyOrNone(
`
`
select m.*,
u.username
from private_user_message_channel_members m
@@ -129,8 +122,8 @@ export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
where m.channel_id = any ($1)
and m.user_id != $2
`,
[channelIds, userId]
)
[channelIds, userId],
)
: []
const endorsements = await getLikesAndShipsMain(userId)
@@ -153,17 +146,17 @@ export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
where r.user_id = $1
order by v.created_time desc
`,
[userId]
[userId],
)
const reports = await pg.manyOrNone<Row<'reports'>>(
'select * from reports where user_id = $1 order by created_time desc nulls last',
[userId]
[userId],
)
const contactMessages = await pg.manyOrNone<Row<'contact'>>(
'select * from contact where user_id = $1 order by created_time desc nulls last',
[userId]
[userId],
)
return {
@@ -185,4 +178,3 @@ export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
accountMetadata,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,106 +1,135 @@
@media (prefers-color-scheme: dark) {
body {
background-color: #1e1e1e !important;
color: #ffffff !important;
}
.swagger-ui p,
h1,
h2,
h3,
h4,
h5,
h6,
label,
.btn,
.parameter__name,
.parameter__type,
.parameter__in,
.response-control-media-type__title,
table thead tr td,
table thead tr th,
.tab li,
.response-col_links,
.opblock-summary-description {
color: #ffffff !important;
}
.swagger-ui .topbar, .opblock-body select, textarea {
background-color: #2b2b2b !important;
color: #ffffff !important;
}
.swagger-ui .opblock {
background-color: #2c2c2c !important;
border-color: #fff !important;
}
.swagger-ui .opblock .opblock-summary-method {
background-color: #1f1f1f !important;
color: #fff !important;
}
.swagger-ui .opblock .opblock-section-header {
background: #1f1f1f !important;
color: #fff !important;
}
.swagger-ui .responses-wrapper {
background-color: #1f1f1f !important;
}
.swagger-ui .response-col_status {
color: #fff !important;
}
.swagger-ui .scheme-container {
background-color: #1f1f1f !important;
}
.swagger-ui .modal-ux, input {
background-color: #1f1f1f !important;
color: #fff !important;
}
.swagger-ui svg path {
fill: white !important;
}
.swagger-ui .close-modal svg {
color: #1e90ff !important;
}
a {
color: #1e90ff !important;
}
body {
background-color: #1e1e1e !important;
color: #ffffff !important;
}
.swagger-ui p,
h1,
h2,
h3,
h4,
h5,
h6,
label,
.btn,
.parameter__name,
.parameter__type,
.parameter__in,
.response-control-media-type__title,
table thead tr td,
table thead tr th,
.tab li,
.response-col_links,
.opblock-summary-description {
color: #ffffff !important;
}
.swagger-ui .topbar,
.opblock-body select,
textarea {
background-color: #2b2b2b !important;
color: #ffffff !important;
}
.swagger-ui .opblock {
background-color: #2c2c2c !important;
border-color: #fff !important;
}
.swagger-ui .opblock .opblock-summary-method {
background-color: #1f1f1f !important;
color: #fff !important;
}
.swagger-ui .opblock .opblock-section-header {
background: #1f1f1f !important;
color: #fff !important;
}
.swagger-ui .responses-wrapper {
background-color: #1f1f1f !important;
}
.swagger-ui .response-col_status {
color: #fff !important;
}
.swagger-ui .scheme-container {
background-color: #1f1f1f !important;
}
.swagger-ui .modal-ux,
input {
background-color: #1f1f1f !important;
color: #fff !important;
}
.swagger-ui svg path {
fill: white !important;
}
.swagger-ui .close-modal svg {
color: #1e90ff !important;
}
a {
color: #1e90ff !important;
}
}
/* Increase font sizes on mobile for better readability */
/* Still not working though */
@media (max-width: 640px) {
html,
body,
.swagger-ui {
font-size: 32px !important;
line-height: 1.5 !important;
}
html,
body,
.swagger-ui {
font-size: 32px !important;
line-height: 1.5 !important;
}
.swagger-ui {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
.swagger-ui {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Common text elements */
.swagger-ui p,
.swagger-ui label,
.swagger-ui .btn,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type,
.swagger-ui .parameter__in,
.swagger-ui .response-control-media-type__title,
.swagger-ui table thead tr td,
.swagger-ui table thead tr th,
.swagger-ui table tbody tr td,
.swagger-ui .tab li,
.swagger-ui .response-col_links,
.swagger-ui .opblock-summary-path,
.swagger-ui .opblock-summary-description {
font-size: 32px !important;
}
/* Common text elements */
.swagger-ui p,
.swagger-ui label,
.swagger-ui .btn,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type,
.swagger-ui .parameter__in,
.swagger-ui .response-control-media-type__title,
.swagger-ui table thead tr td,
.swagger-ui table thead tr th,
.swagger-ui table tbody tr td,
.swagger-ui .tab li,
.swagger-ui .response-col_links,
.swagger-ui .opblock-summary-path,
.swagger-ui .opblock-summary-description {
font-size: 32px !important;
}
/* Headings scale */
.swagger-ui h1 { font-size: 1.75rem !important; }
.swagger-ui h2 { font-size: 1.5rem !important; }
.swagger-ui h3 { font-size: 1.25rem !important; }
.swagger-ui h4 { font-size: 1.125rem !important; }
.swagger-ui h5, .swagger-ui h6 { font-size: 1rem !important; }
/* Headings scale */
.swagger-ui h1 {
font-size: 1.75rem !important;
}
.swagger-ui h2 {
font-size: 1.5rem !important;
}
.swagger-ui h3 {
font-size: 1.25rem !important;
}
.swagger-ui h4 {
font-size: 1.125rem !important;
}
.swagger-ui h5,
.swagger-ui h6 {
font-size: 1rem !important;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert, update} from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -15,7 +15,7 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
`SELECT id, status, max_participants
FROM events
WHERE id = $1`,
[body.eventId]
[body.eventId],
)
if (!event) {
@@ -34,7 +34,7 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
FROM events_participants
WHERE event_id = $1
AND user_id = $2`,
[body.eventId, auth.uid]
[body.eventId, auth.uid],
)
if (existingRsvp) {
@@ -43,7 +43,7 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
update(pg, 'events_participants', 'id', {
status: body.status,
id: existingRsvp.id,
})
}),
)
if (error) {
@@ -52,12 +52,12 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
} else {
// Check max participants limit
if (event.max_participants && body.status === 'going') {
const count = await pg.one<{ count: number }>(
const count = await pg.one<{count: number}>(
`SELECT COUNT(*)
FROM events_participants
WHERE event_id = $1
AND status = 'going'`,
[body.eventId]
[body.eventId],
)
if (Number(count.count) >= event.max_participants) {
@@ -71,7 +71,7 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
event_id: body.eventId,
user_id: auth.uid,
status: body.status,
})
}),
)
if (error) {

View File

@@ -1,7 +1,11 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => {
import {APIError, APIHandler} from './helpers/endpoint'
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (
body,
auth,
) => {
const {token} = body
if (!token) {
@@ -12,17 +16,18 @@ export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = as
try {
const pg = createSupabaseDirectClient()
await pg.none(`
await pg.none(
`
insert into push_subscriptions_mobile(token, platform, user_id)
values ($1, $2, $3)
on conflict(token) do update set platform = excluded.platform,
user_id = excluded.user_id
`,
[token, 'android', userId]
);
return {success: true};
[token, 'android', userId],
)
return {success: true}
} catch (err) {
console.error('Error saving subscription', err);
console.error('Error saving subscription', err)
throw new APIError(500, `Failed to save subscription`)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import {constructPrefixTsQuery} from 'shared/helpers/search'
import {from, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {type APIHandler} from './helpers/endpoint'
import {convertUser} from 'common/supabase/users'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {toUserAPIResponse} from 'common/api/user-types'
import {convertUser} from 'common/supabase/users'
import {uniqBy} from 'lodash'
import {constructPrefixTsQuery} from 'shared/helpers/search'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, limit, orderBy, renderSql, select, where} from 'shared/supabase/sql-builder'
import {type APIHandler} from './helpers/endpoint'
export const searchUsers: APIHandler<'search-users'> = async (props, _auth) => {
const {term, page, limit} = props
@@ -45,19 +46,19 @@ function getSearchUserSQL(props: {
[select('*'), from('users')],
term
? [
where(
`name_username_vector @@ websearch_to_tsquery('english', $1)
where(
`name_username_vector @@ websearch_to_tsquery('english', $1)
or name_username_vector @@ to_tsquery('english', $2)`,
[term, constructPrefixTsQuery(term)]
),
[term, constructPrefixTsQuery(term)],
),
orderBy(
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
orderBy(
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
data->>'lastBetTime' desc nulls last`,
[term]
),
]
[term],
),
]
: orderBy(`data->'creatorTraders'->'allTime' desc nulls last`),
limit(props.limit, props.offset)
limit(props.limit, props.offset),
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import {sendTestEmail} from "email/functions/helpers";
import {sendTestEmail} from 'email/functions/helpers'
export const localSendTestEmail = async () => {
sendTestEmail('hello@compassmeet.com')
.then(() => console.debug('Email sent successfully!'))
.catch((error) => console.error('Failed to send email:', error))
return { message: 'Email sent successfully!'}
return {message: 'Email sent successfully!'}
}

View File

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

View File

@@ -1,7 +1,7 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {update} from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
export const updateEvent: APIHandler<'update-event'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -15,7 +15,7 @@ export const updateEvent: APIHandler<'update-event'> = async (body, auth) => {
`SELECT id, creator_id, status
FROM events
WHERE id = $1`,
[body.eventId]
[body.eventId],
)
if (!event) {
@@ -42,7 +42,7 @@ export const updateEvent: APIHandler<'update-event'> = async (body, auth) => {
event_end_time: body.eventEndTime,
max_participants: body.maxParticipants,
id: body.eventId,
})
}),
)
if (error) {

View File

@@ -1,14 +1,15 @@
import {toUserAPIResponse} from 'common/api/user-types'
import {RESERVED_PATHS} from 'common/envs/constants'
import {strip} from 'common/socials'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {removeUndefinedProps} from 'common/util/object'
import {cloneDeep, mapValues} from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {getUser, getUserByUsername} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import {updateUser} from 'shared/supabase/users'
import {getUser, getUserByUsername} from 'shared/utils'
import {broadcastUpdatedUser} from 'shared/websockets/helpers'
import {strip} from 'common/socials'
import {APIError, APIHandler} from './helpers/endpoint'
export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
const update = cloneDeep(props)
@@ -35,12 +36,9 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
const {name, username, avatarUrl, link = {}, ...rest} = update
await updateUser(pg, auth.uid, removeUndefinedProps(rest))
const stripped = mapValues(
link,
(value, site) => value && strip(site as any, value)
)
const stripped = mapValues(link, (value, site) => value && strip(site as any, value))
const adds = {} as { [key: string]: string }
const adds = {} as {[key: string]: string}
const removes = []
for (const [key, value] of Object.entries(stripped)) {
if (value === null || value === '') {
@@ -60,20 +58,26 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
)
where id = $(id)
returning data -> 'link' as link`,
{adds, removes, id: auth.uid}
{adds, removes, id: auth.uid},
)
newLinks = data?.link
}
if (name) {
await pg.none(`update users
await pg.none(
`update users
set name = $1
where id = $2`, [name, auth.uid])
where id = $2`,
[name, auth.uid],
)
}
if (username) {
await pg.none(`update users
await pg.none(
`update users
set username = $1
where id = $2`, [username, auth.uid])
where id = $2`,
[username, auth.uid],
)
}
if (avatarUrl) {
await updateUser(pg, auth.uid, {avatarUrl})
@@ -89,7 +93,7 @@ export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
username,
avatarUrl,
link: newLinks ?? undefined,
})
}),
)
}

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { log, getUser } from 'shared/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { millisToTs } from 'common/supabase/utils'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {millisToTs} from 'common/supabase/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {getUser, log} from 'shared/utils'
export const updatePrivateUserMessageChannel: APIHandler<
'update-private-user-message-channel'
> = async (body, auth) => {
const { channelId, notifyAfterTime } = body
const {channelId, notifyAfterTime} = body
const pg = createSupabaseDirectClient()
const user = await getUser(auth.uid)
if (!user) throw new APIError(401, 'Your account was not found')
@@ -14,10 +14,9 @@ export const updatePrivateUserMessageChannel: APIHandler<
const membershipStatus = await pg.oneOrNone(
`select status from private_user_message_channel_members
where channel_id = $1 and user_id = $2`,
[channelId, auth.uid]
[channelId, auth.uid],
)
if (!membershipStatus)
throw new APIError(403, 'You are not authorized to this channel')
if (!membershipStatus) throw new APIError(403, 'You are not authorized to this channel')
log('membershipStatus ' + membershipStatus)
await pg.none(
@@ -26,8 +25,8 @@ export const updatePrivateUserMessageChannel: APIHandler<
set notify_after_time = $3
where channel_id=$1 and user_id=$2;
`,
[channelId, auth.uid, millisToTs(notifyAfterTime)]
[channelId, auth.uid, millisToTs(notifyAfterTime)],
)
return { status: 'success', channelId: Number(channelId) }
return {status: 'success', channelId: Number(channelId)}
}

View File

@@ -1,32 +1,27 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {trimStrings} from 'common/parsing'
import {type Row} from 'common/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {updateUser} from 'shared/supabase/users'
import {log} from 'shared/utils'
import {tryCatch} from 'common/util/try-catch'
import {update} from 'shared/supabase/utils'
import {type Row} from 'common/supabase/utils'
import {trimStrings} from "common/parsing";
import {log} from 'shared/utils'
export const updateProfile: APIHandler<'update-profile'> = async (
parsedBody,
auth
) => {
export const updateProfile: APIHandler<'update-profile'> = async (parsedBody, auth) => {
trimStrings(parsedBody)
log('Updating profile', parsedBody)
const pg = createSupabaseDirectClient()
const { data: existingProfile } = await tryCatch(
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [
auth.uid,
])
const {data: existingProfile} = await tryCatch(
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [auth.uid]),
)
if (!existingProfile) {
throw new APIError(404, 'Profile not found')
}
log('Updating profile', { userId: auth.uid, parsedBody })
log('Updating profile', {userId: auth.uid, parsedBody})
await removePinnedUrlFromPhotoUrls(parsedBody)
@@ -34,8 +29,8 @@ export const updateProfile: APIHandler<'update-profile'> = async (
await updateUser(pg, auth.uid, {avatarUrl: parsedBody.pinned_url})
}
const { data, error } = await tryCatch(
update(pg, 'profiles', 'user_id', { user_id: auth.uid, ...parsedBody })
const {data, error} = await tryCatch(
update(pg, 'profiles', 'user_id', {user_id: auth.uid, ...parsedBody}),
)
if (error) {

View File

@@ -1,8 +1,9 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { getUser } from 'shared/utils'
import { APIHandler, APIError } from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {getUser} from 'shared/utils'
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
import {APIError, APIHandler} 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')
@@ -10,9 +11,9 @@ export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, aut
// Map string choice to smallint (-1, 0, 1)
const choiceMap: Record<string, number> = {
'for': 1,
'abstain': 0,
'against': -1,
for: 1,
abstain: 0,
against: -1,
}
const choiceVal = choiceMap[choice]
if (choiceVal === undefined) {
@@ -32,7 +33,7 @@ export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, aut
try {
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
return { data: result }
return {data: result}
} catch (e) {
throw new APIError(500, 'Error recording vote', e as any)
}

View File

@@ -5,120 +5,106 @@ jest.mock('shared/supabase/users')
jest.mock('shared/analytics')
jest.mock('shared/utils')
import { banUser } from "api/ban-user";
import * as supabaseInit from "shared/supabase/init";
import { throwErrorIfNotMod } from "shared/helpers/auth";
import * as constants from "common/envs/constants";
import * as supabaseUsers from "shared/supabase/users";
import * as sharedAnalytics from "shared/analytics";
import { AuthedUser } from "api/helpers/endpoint"
import {banUser} from 'api/ban-user'
import {AuthedUser} from 'api/helpers/endpoint'
import * as constants from 'common/envs/constants'
import * as sharedAnalytics from 'shared/analytics'
import {throwErrorIfNotMod} from 'shared/helpers/auth'
import * as supabaseInit from 'shared/supabase/init'
import * as supabaseUsers from 'shared/supabase/users'
describe('banUser', () => {
const mockPg = {} as any;
const mockPg = {} as any
beforeEach(() => {
jest.resetAllMocks();
beforeEach(() => {
jest.resetAllMocks()
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
})
afterEach(() => {
jest.restoreAllMocks()
})
(supabaseInit.createSupabaseDirectClient as jest.Mock)
.mockReturnValue(mockPg);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('when given valid input', () => {
it('should ban a user successfully', async () => {
const mockUser = {
userId: '123',
unban: false,
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
;(constants.isAdminId as jest.Mock).mockReturnValue(false)
describe('when given valid input', () => {
it('should ban a user successfully', async () => {
const mockUser = {
userId: '123',
unban: false
};
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
await banUser(mockUser, mockAuth, mockReq)
(constants.isAdminId as jest.Mock).mockReturnValue(false);
expect(throwErrorIfNotMod).toBeCalledTimes(1)
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid)
expect(constants.isAdminId).toBeCalledTimes(1)
expect(constants.isAdminId).toBeCalledWith(mockUser.userId)
expect(sharedAnalytics.trackPublicEvent).toBeCalledTimes(1)
expect(sharedAnalytics.trackPublicEvent).toBeCalledWith(mockAuth.uid, 'ban user', {
userId: mockUser.userId,
})
expect(supabaseUsers.updateUser).toBeCalledTimes(1)
expect(supabaseUsers.updateUser).toBeCalledWith(mockPg, mockUser.userId, {
isBannedFromPosting: true,
})
})
await banUser(mockUser, mockAuth, mockReq);
expect(throwErrorIfNotMod).toBeCalledTimes(1);
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid);
expect(constants.isAdminId).toBeCalledTimes(1);
expect(constants.isAdminId).toBeCalledWith(mockUser.userId);
expect(sharedAnalytics.trackPublicEvent).toBeCalledTimes(1);
expect(sharedAnalytics.trackPublicEvent).toBeCalledWith(
mockAuth.uid,
'ban user',
{userId: mockUser.userId}
);
expect(supabaseUsers.updateUser).toBeCalledTimes(1);
expect(supabaseUsers.updateUser).toBeCalledWith(
mockPg,
mockUser.userId,
{isBannedFromPosting: true}
);
});
it('should unban a user successfully', async () => {
const mockUser = {
userId: '123',
unban: true,
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
it('should unban a user successfully', async () => {
const mockUser = {
userId: '123',
unban: true
};
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
;(constants.isAdminId as jest.Mock).mockReturnValue(false)
(constants.isAdminId as jest.Mock).mockReturnValue(false);
await banUser(mockUser, mockAuth, mockReq)
await banUser(mockUser, mockAuth, mockReq);
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid);
expect(constants.isAdminId).toBeCalledWith(mockUser.userId);
expect(sharedAnalytics.trackPublicEvent).toBeCalledWith(
mockAuth.uid,
'ban user',
{userId: mockUser.userId}
);
expect(supabaseUsers.updateUser).toBeCalledWith(
mockPg,
mockUser.userId,
{isBannedFromPosting: false}
);
});
});
describe('when an error occurs', () => {
it('throw if the ban requester is not a mod or admin', async () => {
const mockUser = {
userId: '123',
unban: false
};
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
(throwErrorIfNotMod as jest.Mock).mockRejectedValue(
new Error(`User ${mockAuth.uid} must be an admin or trusted to perform this action.`)
);
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid)
expect(constants.isAdminId).toBeCalledWith(mockUser.userId)
expect(sharedAnalytics.trackPublicEvent).toBeCalledWith(mockAuth.uid, 'ban user', {
userId: mockUser.userId,
})
expect(supabaseUsers.updateUser).toBeCalledWith(mockPg, mockUser.userId, {
isBannedFromPosting: false,
})
})
})
describe('when an error occurs', () => {
it('throw if the ban requester is not a mod or admin', async () => {
const mockUser = {
userId: '123',
unban: false,
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
await expect(banUser(mockUser, mockAuth, mockReq))
.rejects
.toThrowError(`User ${mockAuth.uid} must be an admin or trusted to perform this action.`);
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid);
});
;(throwErrorIfNotMod as jest.Mock).mockRejectedValue(
new Error(`User ${mockAuth.uid} must be an admin or trusted to perform this action.`),
)
it('throw if the ban target is an admin', async () => {
const mockUser = {
userId: '123',
unban: false
};
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
await expect(banUser(mockUser, mockAuth, mockReq)).rejects.toThrowError(
`User ${mockAuth.uid} must be an admin or trusted to perform this action.`,
)
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid)
})
(constants.isAdminId as jest.Mock).mockReturnValue(true);
it('throw if the ban target is an admin', async () => {
const mockUser = {
userId: '123',
unban: false,
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
await expect(banUser(mockUser, mockAuth, mockReq))
.rejects
.toThrowError('Cannot ban admin');
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid);
expect(constants.isAdminId).toBeCalledWith(mockUser.userId);
});
});
});
;(constants.isAdminId as jest.Mock).mockReturnValue(true)
await expect(banUser(mockUser, mockAuth, mockReq)).rejects.toThrowError('Cannot ban admin')
expect(throwErrorIfNotMod).toBeCalledWith(mockAuth.uid)
expect(constants.isAdminId).toBeCalledWith(mockUser.userId)
})
})
})

View File

@@ -2,110 +2,105 @@ jest.mock('shared/supabase/init')
jest.mock('shared/supabase/users')
jest.mock('shared/supabase/utils')
import * as blockUserModule from "api/block-user";
import { AuthedUser } from "api/helpers/endpoint";
import * as supabaseInit from "shared/supabase/init";
import * as supabaseUsers from "shared/supabase/users";
import * as supabaseUtils from "shared/supabase/utils";
import * as blockUserModule from 'api/block-user'
import {AuthedUser} from 'api/helpers/endpoint'
import * as supabaseInit from 'shared/supabase/init'
import * as supabaseUsers from 'shared/supabase/users'
import * as supabaseUtils from 'shared/supabase/utils'
describe('blockUser', () => {
let mockPg: any;
let mockPg: any
beforeEach(() => {
jest.resetAllMocks()
mockPg = {
tx: jest.fn(async (cb) => {
const mockTx = {};
await cb(mockTx);
}),
};
beforeEach(() => {
jest.resetAllMocks()
mockPg = {
tx: jest.fn(async (cb) => {
const mockTx = {}
await cb(mockTx)
}),
}
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
})
afterEach(() => {
jest.restoreAllMocks()
})
(supabaseInit.createSupabaseDirectClient as jest.Mock)
.mockReturnValue(mockPg)
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('when given valid input', () => {
it('block the user successfully', async () => {
const mockParams = {id: '123'}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
describe('when given valid input', () => {
it('block the user successfully', async () => {
const mockParams = { id: '123' }
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
await blockUserModule.blockUser(mockParams, mockAuth, mockReq)
await blockUserModule.blockUser(mockParams, mockAuth, mockReq)
expect(mockPg.tx).toHaveBeenCalledTimes(1)
expect(supabaseUsers.updatePrivateUser).toBeCalledTimes(2)
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
1,
expect.any(Object),
mockAuth.uid,
{blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockParams.id)},
)
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
2,
expect.any(Object),
mockParams.id,
{blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockAuth.uid)},
)
})
})
describe('when an error occurs', () => {
it('throw an error if the user tries to block themselves', async () => {
const mockParams = {id: '123'}
const mockAuth = {uid: '123'} as AuthedUser
const mockReq = {} as any
expect(mockPg.tx).toHaveBeenCalledTimes(1);
expect(supabaseUsers.updatePrivateUser).toBeCalledTimes(2);
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
1,
expect.any(Object),
mockAuth.uid,
{ blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockParams.id)}
);
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
2,
expect.any(Object),
mockParams.id,
{ blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockAuth.uid)}
);
});
});
describe('when an error occurs', () => {
it('throw an error if the user tries to block themselves', async () => {
const mockParams = { id: '123' }
const mockAuth = {uid: '123'} as AuthedUser;
const mockReq = {} as any;
expect(blockUserModule.blockUser(mockParams, mockAuth, mockReq))
.rejects
.toThrowError('You cannot block yourself');
});
});
});
expect(blockUserModule.blockUser(mockParams, mockAuth, mockReq)).rejects.toThrowError(
'You cannot block yourself',
)
})
})
})
describe('unblockUser', () => {
let mockPg: any;
let mockPg: any
beforeEach(() => {
jest.resetAllMocks()
mockPg = {
tx: jest.fn(async (cb) => {
const mockTx = {};
await cb(mockTx);
}),
};
beforeEach(() => {
jest.resetAllMocks()
mockPg = {
tx: jest.fn(async (cb) => {
const mockTx = {}
await cb(mockTx)
}),
}
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
})
afterEach(() => {
jest.restoreAllMocks()
})
(supabaseInit.createSupabaseDirectClient as jest.Mock)
.mockReturnValue(mockPg)
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('when given valid input', () => {
it('should block the user successfully', async () => {
const mockParams = {id: '123'}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
describe('when given valid input', () => {
it('should block the user successfully', async () => {
const mockParams = { id: '123' }
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
await blockUserModule.unblockUser(mockParams, mockAuth, mockReq)
await blockUserModule.unblockUser(mockParams, mockAuth, mockReq)
expect(mockPg.tx).toHaveBeenCalledTimes(1);
expect(supabaseUsers.updatePrivateUser).toBeCalledTimes(2);
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
1,
expect.any(Object),
mockAuth.uid,
{ blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockParams.id)}
);
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
2,
expect.any(Object),
mockParams.id,
{ blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockAuth.uid)}
);
});
});
});
expect(mockPg.tx).toHaveBeenCalledTimes(1)
expect(supabaseUsers.updatePrivateUser).toBeCalledTimes(2)
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
1,
expect.any(Object),
mockAuth.uid,
{blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockParams.id)},
)
expect(supabaseUsers.updatePrivateUser).toHaveBeenNthCalledWith(
2,
expect.any(Object),
mockParams.id,
{blockedByUserIds: supabaseUtils.FieldVal.arrayConcat(mockAuth.uid)},
)
})
})
})

View File

@@ -1,41 +1,39 @@
jest.mock('shared/supabase/init');
import {getCompatibleProfiles} from "api/compatible-profiles";
import * as supabaseInit from "shared/supabase/init";
jest.mock('shared/supabase/init')
import {getCompatibleProfiles} from 'api/compatible-profiles'
import * as supabaseInit from 'shared/supabase/init'
describe('getCompatibleProfiles', () => {
let mockPg = {} as any;
let mockPg = {} as any
beforeEach(() => {
jest.resetAllMocks();
jest.resetAllMocks()
mockPg = {
map: jest.fn().mockResolvedValue([]),
};
(supabaseInit.createSupabaseDirectClient as jest.Mock)
.mockReturnValue(mockPg);
});
}
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
})
afterEach(() => {
jest.restoreAllMocks();
});
jest.restoreAllMocks()
})
describe('when given valid input', () => {
it('should successfully get compatible profiles', async () => {
const mockProps = '123';
const mockScores = ["abc", { score: 0.69 }];
const mockScoresFromEntries = {"abc": { score: 0.69 }};
const mockProps = '123'
const mockScores = ['abc', {score: 0.69}]
const mockScoresFromEntries = {abc: {score: 0.69}}
(mockPg.map as jest.Mock).mockResolvedValue([mockScores]);
;(mockPg.map as jest.Mock).mockResolvedValue([mockScores])
const results = await getCompatibleProfiles(mockProps);
const [sql, param, fn] = mockPg.map.mock.calls[0];
expect(results.status).toEqual('success');
expect(results.profileCompatibilityScores).toEqual(mockScoresFromEntries);
expect(mockPg.map).toBeCalledTimes(1);
expect(sql).toContain('select *');
expect(sql).toContain('from compatibility_scores');
expect(param).toStrictEqual([mockProps]);
expect(fn).toEqual(expect.any(Function));
});
});
});
const results = await getCompatibleProfiles(mockProps)
const [sql, param, fn] = mockPg.map.mock.calls[0]
expect(results.status).toEqual('success')
expect(results.profileCompatibilityScores).toEqual(mockScoresFromEntries)
expect(mockPg.map).toBeCalledTimes(1)
expect(sql).toContain('select *')
expect(sql).toContain('from compatibility_scores')
expect(param).toStrictEqual([mockProps])
expect(fn).toEqual(expect.any(Function))
})
})
})

View File

@@ -1,30 +1,28 @@
jest.mock('common/discord/core');
jest.mock('shared/supabase/utils');
jest.mock('shared/supabase/init');
jest.mock('common/util/try-catch');
jest.mock('common/discord/core')
jest.mock('shared/supabase/utils')
jest.mock('shared/supabase/init')
jest.mock('common/util/try-catch')
import {contact} from "api/contact";
import * as supabaseInit from "shared/supabase/init";
import * as supabaseUtils from "shared/supabase/utils";
import {tryCatch} from "common/util/try-catch";
import {sendDiscordMessage} from "common/discord/core";
import {AuthedUser} from "api/helpers/endpoint";
import {contact} from 'api/contact'
import {AuthedUser} from 'api/helpers/endpoint'
import {sendDiscordMessage} from 'common/discord/core'
import {sqlMatch} from 'common/test-utils'
import {tryCatch} from 'common/util/try-catch'
import * as supabaseInit from 'shared/supabase/init'
import * as supabaseUtils from 'shared/supabase/utils'
describe('contact', () => {
let mockPg: any;
let mockPg: any
beforeEach(() => {
jest.resetAllMocks();
jest.resetAllMocks()
mockPg = {
oneOrNone: jest.fn(),
};
(supabaseInit.createSupabaseDirectClient as jest.Mock)
.mockReturnValue(mockPg);
});
}
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
})
afterEach(() => {
jest.restoreAllMocks();
});
jest.restoreAllMocks()
})
describe('when given valid input', () => {
it('should send a discord message to the user', async () => {
@@ -37,53 +35,46 @@ describe('contact', () => {
content: [
{
type: 'text',
text: 'Error test message'
}
]
}
]
text: 'Error test message',
},
],
},
],
},
userId: '123'
};
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
const mockDbUser = {name: 'Humphrey Mocker'};
const mockReturnData = {} as any;
userId: '123',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockDbUser = {name: 'Humphrey Mocker'}
const mockReturnData = {} as any
(tryCatch as jest.Mock).mockResolvedValue({data: mockReturnData, error: null});
;(tryCatch as jest.Mock).mockResolvedValue({data: mockReturnData, error: null})
const results = await contact(mockProps, mockAuth, mockReq)
const results = await contact(mockProps, mockAuth, mockReq);
expect(results.success).toBe(true);
expect(results.result).toStrictEqual({});
expect(tryCatch).toBeCalledTimes(1);
expect(results.success).toBe(true)
expect(results.result).toStrictEqual({})
expect(tryCatch).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledWith(
expect.any(Object),
'contact',
{
user_id: mockProps.userId,
content: JSON.stringify(mockProps.content)
}
);
expect(supabaseUtils.insert).toBeCalledWith(expect.any(Object), 'contact', {
user_id: mockProps.userId,
content: JSON.stringify(mockProps.content),
})
;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockDbUser)
(mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockDbUser);
await results.continue()
await results.continue();
expect(mockPg.oneOrNone).toBeCalledTimes(1);
expect(mockPg.oneOrNone).toBeCalledWith(
sqlMatch('select name from users where id = $1'),
[mockProps.userId]
);
expect(sendDiscordMessage).toBeCalledTimes(1);
expect(mockPg.oneOrNone).toBeCalledTimes(1)
expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('select name from users where id = $1'), [
mockProps.userId,
])
expect(sendDiscordMessage).toBeCalledTimes(1)
expect(sendDiscordMessage).toBeCalledWith(
expect.stringContaining(`New message from ${mockDbUser.name}`),
'contact'
);
});
});
'contact',
)
})
})
describe('when an error occurs', () => {
it('should throw if the insert function fails', async () => {
@@ -96,23 +87,23 @@ describe('contact', () => {
content: [
{
type: 'text',
text: 'Error test message'
}
]
}
]
text: 'Error test message',
},
],
},
],
},
userId: '123'
};
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
userId: '123',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
(tryCatch as jest.Mock).mockResolvedValue({data: null, error: Error});
;(tryCatch as jest.Mock).mockResolvedValue({data: null, error: Error})
expect(contact(mockProps, mockAuth, mockReq))
.rejects
.toThrowError('Failed to submit contact message');
});
expect(contact(mockProps, mockAuth, mockReq)).rejects.toThrowError(
'Failed to submit contact message',
)
})
it('should throw if unable to send discord message', async () => {
const mockProps = {
@@ -124,48 +115,42 @@ describe('contact', () => {
content: [
{
type: 'text',
text: 'Error test message'
}
]
}
]
text: 'Error test message',
},
],
},
],
},
userId: '123'
};
const mockAuth = {uid: '321'} as AuthedUser;
const mockReq = {} as any;
const mockDbUser = {name: 'Humphrey Mocker'};
const mockReturnData = {} as any;
userId: '123',
}
const mockAuth = {uid: '321'} as AuthedUser
const mockReq = {} as any
const mockDbUser = {name: 'Humphrey Mocker'}
const mockReturnData = {} as any
(tryCatch as jest.Mock).mockResolvedValue({data: mockReturnData, error: null});
;(tryCatch as jest.Mock).mockResolvedValue({data: mockReturnData, error: null})
const results = await contact(mockProps, mockAuth, mockReq);
const results = await contact(mockProps, mockAuth, mockReq)
expect(results.success).toBe(true);
expect(results.result).toStrictEqual({});
expect(tryCatch).toBeCalledTimes(1);
expect(results.success).toBe(true)
expect(results.result).toStrictEqual({})
expect(tryCatch).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledTimes(1)
expect(supabaseUtils.insert).toBeCalledWith(
expect.any(Object),
'contact',
{
user_id: mockProps.userId,
content: JSON.stringify(mockProps.content)
}
);
expect(supabaseUtils.insert).toBeCalledWith(expect.any(Object), 'contact', {
user_id: mockProps.userId,
content: JSON.stringify(mockProps.content),
})
;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockDbUser)
;(sendDiscordMessage as jest.Mock).mockRejectedValue(new Error('Unable to send message'))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
(mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockDbUser);
(sendDiscordMessage as jest.Mock).mockRejectedValue(new Error('Unable to send message'));
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {
});
await results.continue()
await results.continue();
expect(errorSpy).toBeCalledTimes(1);
expect(errorSpy).toBeCalledTimes(1)
expect(errorSpy).toBeCalledWith(
expect.stringContaining('Failed to send discord contact'),
expect.objectContaining({name: 'Error'})
);
});
});
});
expect.objectContaining({name: 'Error'}),
)
})
})
})

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