mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-23 10:26:16 -05:00
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:
@@ -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 package’s `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. Don’t test internal state or function calls unless you’re testing utilities or very
|
||||
- Test Behavior, Not Implementation. Don’t test internal state or function calls unless you’re 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.
|
||||
* Don’t Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
|
||||
- Don’t 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 don’t 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 don’t 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 @@ function’s return value isn’t used, there’s 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.
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/other.yml
vendored
6
.github/ISSUE_TEMPLATE/other.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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 -->
|
||||
|
||||
323
.github/copilot-instructions.md
vendored
323
.github/copilot-instructions.md
vendored
@@ -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 package’s `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. Don’t test internal state or function calls unless you’re testing utilities or very
|
||||
- Test Behavior, Not Implementation. Don’t test internal state or function calls unless you’re 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.
|
||||
* Don’t Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
|
||||
- Don’t 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 don’t 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 don’t 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 @@ function’s return value isn’t used, there’s 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.
|
||||
|
||||
8
.github/workflows/cd-android-live-update.yml
vendored
8
.github/workflows/cd-android-live-update.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/cd-api.yml
vendored
8
.github/workflows/cd-api.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/cd.yml
vendored
11
.github/workflows/cd.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/ci-e2e.yml
vendored
12
.github/workflows/ci-e2e.yml
vendored
@@ -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
|
||||
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 package’s `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. Don’t test internal state or function calls unless you’re testing utilities or very
|
||||
- Test Behavior, Not Implementation. Don’t test internal state or function calls unless you’re 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.
|
||||
* Don’t Overuse Snapshots. Snapshots are fragile and often meaningless unless used sparingly (e.g., for JSON response
|
||||
- Don’t 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 don’t 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 don’t 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 @@ function’s return value isn’t used, there’s 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
35
.prettierignore
Normal 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
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
* [ ] I’ve added or updated tests
|
||||
* [ ] I’ve run all tests and they pass
|
||||
* [ ] I’ve 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
|
||||
- [ ] I’ve added or updated tests
|
||||
- [ ] I’ve run all tests and they pass
|
||||
- [ ] I’ve 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
|
||||
|
||||
* Don’t commit directly to `main`
|
||||
* Don’t submit multiple unrelated changes in a single PR
|
||||
* Don’t ignore CI/test failures
|
||||
* Don’t expect hand-holding—read the docs and the source first
|
||||
- Don’t commit directly to `main`
|
||||
- Don’t submit multiple unrelated changes in a single PR
|
||||
- Don’t ignore CI/test failures
|
||||
- Don’t 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.
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -1,4 +1,3 @@
|
||||
|
||||

|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
|
||||
@@ -34,14 +33,17 @@ No contribution is too small—whether it’s 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 don’t 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
34
backend/api/.prettierignore
Normal file
34
backend/api/.prettierignore
Normal 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
|
||||
@@ -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 isn’t 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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. It’s 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') {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 Play–registered 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
}
|
||||
|
||||
@@ -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!'}
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user