mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-31 09:00:00 -06:00
Compare commits
12 Commits
@papra/doc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d70a7b3c3 | ||
|
|
7448a170af | ||
|
|
b8c14d0f44 | ||
|
|
4878a3f8dd | ||
|
|
a213f0683b | ||
|
|
6a5bcef5ad | ||
|
|
607ba9496c | ||
|
|
ec34cf1788 | ||
|
|
e52287d04f | ||
|
|
f903c33d26 | ||
|
|
4342b319ea | ||
|
|
815f6f94f8 |
5
.changeset/khaki-glasses-draw.md
Normal file
5
.changeset/khaki-glasses-draw.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added a dedicated increased timeout for the document upload route
|
||||||
5
.changeset/metal-buttons-mate.md
Normal file
5
.changeset/metal-buttons-mate.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added a feedback message upon request timeout
|
||||||
5
.changeset/proud-rivers-film.md
Normal file
5
.changeset/proud-rivers-film.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Organizations listing and details in the admin dashboard
|
||||||
5
.changeset/silent-gifts-enjoy.md
Normal file
5
.changeset/silent-gifts-enjoy.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Properly cleanup orphan file when the same document exists in trash
|
||||||
5
.changeset/ten-friends-shine.md
Normal file
5
.changeset/ten-friends-shine.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Changed config key `config.server.routeTimeoutMs` to `config.server.defaultRouteTimeoutMs` (env variable remains the same)
|
||||||
5
.changeset/true-olives-beam.md
Normal file
5
.changeset/true-olives-beam.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added api endpoint to check current API key (GET /api/api-keys/current)
|
||||||
@@ -66,6 +66,19 @@ When creating an API key, you can select from the following permissions:
|
|||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
### Check current API key
|
||||||
|
|
||||||
|
**GET** `/api/api-keys/current`
|
||||||
|
|
||||||
|
Get information about the currently used API key.
|
||||||
|
|
||||||
|
- Required API key permissions: none
|
||||||
|
- Response (JSON)
|
||||||
|
- `apiKey`: The current API key information.
|
||||||
|
- `id`: The API key ID.
|
||||||
|
- `name`: The API key name.
|
||||||
|
- `permissions`: The list of permissions associated with the API key.
|
||||||
|
|
||||||
### List organizations
|
### List organizations
|
||||||
|
|
||||||
**GET** `/api/organizations`
|
**GET** `/api/organizations`
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "app.papra.ios"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
5
apps/mobile/app/ReactotronConfig.ts
Normal file
5
apps/mobile/app/ReactotronConfig.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Reactotron from 'reactotron-react-native';
|
||||||
|
|
||||||
|
Reactotron.configure({ name: 'Papra' }) // controls connection & communication settings
|
||||||
|
.useReactNative() // add all built-in react native plugins
|
||||||
|
.connect(); // let's connect!
|
||||||
@@ -3,6 +3,11 @@ import { Redirect } from 'expo-router';
|
|||||||
import { createAuthClient } from '@/modules/auth/auth.client';
|
import { createAuthClient } from '@/modules/auth/auth.client';
|
||||||
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
// eslint-disable-next-line ts/no-require-imports
|
||||||
|
require('./ReactotronConfig');
|
||||||
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['api-server-url'],
|
queryKey: ['api-server-url'],
|
||||||
|
|||||||
@@ -29,12 +29,13 @@
|
|||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"expo": "~54.0.22",
|
"expo": "~54.0.22",
|
||||||
"expo-constants": "~18.0.10",
|
"expo-constants": "~18.0.10",
|
||||||
"expo-document-picker": "^14.0.7",
|
"expo-document-picker": "^14.0.8",
|
||||||
"expo-file-system": "^19.0.19",
|
"expo-file-system": "^19.0.19",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-network": "^8.0.8",
|
||||||
"expo-router": "~6.0.14",
|
"expo-router": "~6.0.14",
|
||||||
"expo-secure-store": "^15.0.7",
|
"expo-secure-store": "^15.0.7",
|
||||||
"expo-sharing": "^14.0.7",
|
"expo-sharing": "^14.0.7",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"eas-cli": "^16.27.0",
|
"eas-cli": "^16.27.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"reactotron-react-native": "^5.1.18",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,9 @@ export function coerceDates<T extends Record<string, unknown>>(obj: T): CoerceDa
|
|||||||
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
|
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
|
||||||
} as CoerceDates<T>;
|
} as CoerceDates<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LocalDocument = {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
type: string | undefined;
|
||||||
|
};
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
|||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
backgroundColor: themeColors.secondaryBackground,
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { LocalDocument } from '@/modules/api/api.models';
|
||||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import { File } from 'expo-file-system';
|
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -58,12 +58,16 @@ export function ImportDrawer({ visible, onClose }: ImportDrawerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [pickerFile] = result.assets;
|
const pickerFile = result.assets[0];
|
||||||
if (!pickerFile) {
|
if (!pickerFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = new File(pickerFile.uri);
|
const file: LocalDocument = {
|
||||||
|
uri: pickerFile.uri,
|
||||||
|
name: pickerFile.name,
|
||||||
|
type: pickerFile.mimeType,
|
||||||
|
};
|
||||||
|
|
||||||
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
|
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });
|
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
import type { ApiClient } from '../api/api.client';
|
import type { ApiClient } from '../api/api.client';
|
||||||
import type { CoerceDates } from '../api/api.models';
|
import type { CoerceDates, LocalDocument } from '../api/api.models';
|
||||||
import type { AuthClient } from '../auth/auth.client';
|
import type { AuthClient } from '../auth/auth.client';
|
||||||
import type { Document } from './documents.types';
|
import type { Document } from './documents.types';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { coerceDates } from '../api/api.models';
|
import { coerceDates } from '../api/api.models';
|
||||||
|
|
||||||
export function getFormData(pojo: Record<string, string | Blob>): FormData {
|
export function getFormData(pojo: Record<string, string | FormDataValue | Blob>): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
|
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadDocument({
|
export async function uploadDocument({
|
||||||
file,
|
file,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|
||||||
apiClient,
|
apiClient,
|
||||||
}: {
|
}: {
|
||||||
file: Blob;
|
file: LocalDocument;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
|
||||||
apiClient: ApiClient;
|
apiClient: ApiClient;
|
||||||
}) {
|
}) {
|
||||||
const { document } = await apiClient<{ document: Document }>({
|
const { document } = await apiClient<{ document: Document }>({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: `/api/organizations/${organizationId}/documents`,
|
path: `/api/organizations/${organizationId}/documents`,
|
||||||
body: getFormData({ file }),
|
body: getFormData({
|
||||||
|
file: {
|
||||||
|
uri: file.uri,
|
||||||
|
// to avoid %20 in file name it is issue in react native that upload file name replaces spaces with %20
|
||||||
|
name: file.name.replace(/ /g, '_'),
|
||||||
|
type: file.type ?? 'application/json',
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
17
apps/mobile/src/types/formdata.d.ts
vendored
Normal file
17
apps/mobile/src/types/formdata.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/* eslint-disable ts/consistent-type-definitions */
|
||||||
|
/* eslint-disable ts/method-signature-style */
|
||||||
|
|
||||||
|
// Source - https://stackoverflow.com/a
|
||||||
|
// Posted by Patrick Roberts, modified by community. See post 'Timeline' for change history
|
||||||
|
// Retrieved 2025-12-19, License - CC BY-SA 4.0
|
||||||
|
|
||||||
|
interface FormDataValue {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
append(name: string, value: string | FormDataValue | Blob, fileName?: string): void;
|
||||||
|
set(name: string, value: string | FormDataValue | Blob, fileName?: string): void;
|
||||||
|
}
|
||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Die Anfrage hat zu lange gedauert und ist abgelaufen. Bitte versuchen Sie es erneut.',
|
||||||
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
||||||
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
||||||
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ export const translations = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'The request took too long and timed out. Please try again.',
|
||||||
'api-errors.document.already_exists': 'The document already exists',
|
'api-errors.document.already_exists': 'The document already exists',
|
||||||
'api-errors.document.size_too_large': 'The file size is too large',
|
'api-errors.document.size_too_large': 'The file size is too large',
|
||||||
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'La solicitud tardó demasiado y se agotó el tiempo. Por favor, inténtalo de nuevo.',
|
||||||
'api-errors.document.already_exists': 'El documento ya existe',
|
'api-errors.document.already_exists': 'El documento ya existe',
|
||||||
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'La requête a pris trop de temps et a expiré. Veuillez réessayer.',
|
||||||
'api-errors.document.already_exists': 'Le document existe déjà',
|
'api-errors.document.already_exists': 'Le document existe déjà',
|
||||||
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
||||||
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'La richiesta ha impiegato troppo tempo ed è scaduta. Riprova.',
|
||||||
'api-errors.document.already_exists': 'Il documento esiste già',
|
'api-errors.document.already_exists': 'Il documento esiste già',
|
||||||
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Het verzoek duurde te lang en is verlopen. Probeer het opnieuw.',
|
||||||
'api-errors.document.already_exists': 'Het document bestaat al',
|
'api-errors.document.already_exists': 'Het document bestaat al',
|
||||||
'api-errors.document.size_too_large': 'Het bestand is te groot',
|
'api-errors.document.size_too_large': 'Het bestand is te groot',
|
||||||
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',
|
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Żądanie trwało zbyt długo i przekroczyło limit czasu. Spróbuj ponownie.',
|
||||||
'api-errors.document.already_exists': 'Dokument już istnieje',
|
'api-errors.document.already_exists': 'Dokument już istnieje',
|
||||||
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
||||||
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'A solicitação demorou muito e expirou. Por favor, tente novamente.',
|
||||||
'api-errors.document.already_exists': 'O documento já existe',
|
'api-errors.document.already_exists': 'O documento já existe',
|
||||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'O pedido demorou muito tempo e expirou. Por favor, tente novamente.',
|
||||||
'api-errors.document.already_exists': 'O documento já existe',
|
'api-errors.document.already_exists': 'O documento já existe',
|
||||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': 'Cererea a durat prea mult și a expirat. Vă rugăm să încercați din nou.',
|
||||||
'api-errors.document.already_exists': 'Documentul există deja',
|
'api-errors.document.already_exists': 'Documentul există deja',
|
||||||
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
||||||
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
||||||
|
|||||||
@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
|
|
||||||
// API errors
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.api.timeout': '请求耗时过长已超时。请重试。',
|
||||||
'api-errors.document.already_exists': '文档已存在',
|
'api-errors.document.already_exists': '文档已存在',
|
||||||
'api-errors.document.size_too_large': '文件大小过大',
|
'api-errors.document.size_too_large': '文件大小过大',
|
||||||
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',
|
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export const adminRoutes: RouteDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/organizations',
|
path: '/organizations',
|
||||||
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
|
component: lazy(() => import('./organizations/pages/list-organizations.page')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/organizations/:organizationId',
|
||||||
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
|
component: lazy(() => import('./organizations/pages/organization-detail.page')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/*404',
|
path: '/*404',
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ const AdminLayout: ParentComponent = (props) => {
|
|||||||
href: '/admin/organizations',
|
href: '/admin/organizations',
|
||||||
icon: 'i-tabler-building-community',
|
icon: 'i-tabler-building-community',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Settings',
|
|
||||||
href: '/admin/settings',
|
|
||||||
icon: 'i-tabler-settings',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const sidenav = () => (
|
const sidenav = () => (
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { IntakeEmail } from '@/modules/intake-emails/intake-emails.types';
|
||||||
|
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||||
|
import type { User } from '@/modules/users/users.types';
|
||||||
|
import type { Webhook } from '@/modules/webhooks/webhooks.types';
|
||||||
|
import { apiClient } from '@/modules/shared/http/api-client';
|
||||||
|
|
||||||
|
export type OrganizationWithMemberCount = Organization & { memberCount: number };
|
||||||
|
|
||||||
|
export type OrganizationMember = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationStats = {
|
||||||
|
documentsCount: number;
|
||||||
|
documentsSize: number;
|
||||||
|
deletedDocumentsCount: number;
|
||||||
|
deletedDocumentsSize: number;
|
||||||
|
totalDocumentsCount: number;
|
||||||
|
totalDocumentsSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listOrganizations({ search, pageIndex = 0, pageSize = 25 }: { search?: string; pageIndex?: number; pageSize?: number }) {
|
||||||
|
const { totalCount, organizations } = await apiClient<{
|
||||||
|
organizations: OrganizationWithMemberCount[];
|
||||||
|
totalCount: number;
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/admin/organizations',
|
||||||
|
query: { search, pageIndex, pageSize },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pageIndex, pageSize, totalCount, organizations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationBasicInfo({ organizationId }: { organizationId: string }) {
|
||||||
|
const { organization } = await apiClient<{
|
||||||
|
organization: Organization;
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { organization };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationMembers({ organizationId }: { organizationId: string }) {
|
||||||
|
const { members } = await apiClient<{
|
||||||
|
members: OrganizationMember[];
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/members`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { members };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationIntakeEmails({ organizationId }: { organizationId: string }) {
|
||||||
|
const { intakeEmails } = await apiClient<{
|
||||||
|
intakeEmails: IntakeEmail[];
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/intake-emails`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { intakeEmails };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationWebhooks({ organizationId }: { organizationId: string }) {
|
||||||
|
const { webhooks } = await apiClient<{
|
||||||
|
webhooks: Webhook[];
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/webhooks`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { webhooks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationStats({ organizationId }: { organizationId: string }) {
|
||||||
|
const { stats } = await apiClient<{
|
||||||
|
stats: OrganizationStats;
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/admin/organizations/${organizationId}/stats`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stats };
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { A } from '@solidjs/router';
|
||||||
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
|
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||||
|
import { createSignal, For, Show } from 'solid-js';
|
||||||
|
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||||
|
import { Button } from '@/modules/ui/components/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||||
|
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
|
import { listOrganizations } from '../organizations.services';
|
||||||
|
|
||||||
|
export const AdminListOrganizationsPage: Component = () => {
|
||||||
|
const [search, setSearch] = createSignal('');
|
||||||
|
const [pagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 25 });
|
||||||
|
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', search(), pagination()],
|
||||||
|
queryFn: () => listOrganizations({
|
||||||
|
search: search() || undefined,
|
||||||
|
pageIndex: pagination().pageIndex,
|
||||||
|
pageSize: pagination().pageSize,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const table = createSolidTable({
|
||||||
|
get data() {
|
||||||
|
return query.data?.organizations ?? [];
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: data => (
|
||||||
|
<A
|
||||||
|
href={`/admin/organizations/${data.getValue<string>()}`}
|
||||||
|
class="font-mono hover:underline text-primary"
|
||||||
|
>
|
||||||
|
{data.getValue<string>()}
|
||||||
|
</A>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: data => (
|
||||||
|
<div class="font-medium">
|
||||||
|
{data.getValue<string>()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Members',
|
||||||
|
accessorKey: 'memberCount',
|
||||||
|
cell: data => (
|
||||||
|
<div class="text-center">
|
||||||
|
{data.getValue<number>()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Updated',
|
||||||
|
accessorKey: 'updatedAt',
|
||||||
|
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
get rowCount() {
|
||||||
|
return query.data?.totalCount ?? 0;
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
state: {
|
||||||
|
get pagination() {
|
||||||
|
return pagination();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualPagination: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
setSearch(target.value);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination().pageSize });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="border-b mb-6 pb-4">
|
||||||
|
<h1 class="text-xl font-bold mb-1">
|
||||||
|
Organization Management
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Manage and view all organizations in the system
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<TextFieldRoot class="max-w-sm">
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or ID..."
|
||||||
|
value={search()}
|
||||||
|
onInput={handleSearch}
|
||||||
|
/>
|
||||||
|
</TextFieldRoot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={!query.isLoading}
|
||||||
|
fallback={<div class="text-center py-8 text-muted-foreground">Loading organizations...</div>}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={(query.data?.organizations.length ?? 0) > 0}
|
||||||
|
fallback={(
|
||||||
|
<div class="text-center py-8 text-muted-foreground">
|
||||||
|
{search() ? 'No organizations found matching your search.' : 'No organizations found.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="border-y">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<For each={table.getHeaderGroups()}>
|
||||||
|
{headerGroup => (
|
||||||
|
<TableRow>
|
||||||
|
<For each={headerGroup.headers}>
|
||||||
|
{header => (
|
||||||
|
<TableHead>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<For each={table.getRowModel().rows}>
|
||||||
|
{row => (
|
||||||
|
<TableRow>
|
||||||
|
<For each={row.getVisibleCells()}>
|
||||||
|
{cell => (
|
||||||
|
<TableCell>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
Showing
|
||||||
|
{' '}
|
||||||
|
{table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
|
||||||
|
{' '}
|
||||||
|
to
|
||||||
|
{' '}
|
||||||
|
{Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, query.data?.totalCount ?? 0)}
|
||||||
|
{' '}
|
||||||
|
of
|
||||||
|
{' '}
|
||||||
|
{query.data?.totalCount ?? 0}
|
||||||
|
{' '}
|
||||||
|
organizations
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevrons-left" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevron-left" />
|
||||||
|
</Button>
|
||||||
|
<div class="text-sm whitespace-nowrap">
|
||||||
|
Page
|
||||||
|
{' '}
|
||||||
|
{table.getState().pagination.pageIndex + 1}
|
||||||
|
{' '}
|
||||||
|
of
|
||||||
|
{' '}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevron-right" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-8"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<div class="size-4 i-tabler-chevrons-right" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminListOrganizationsPage;
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { formatBytes } from '@corentinth/chisels';
|
||||||
|
import { A, useParams } from '@solidjs/router';
|
||||||
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
|
import { For, Show, Suspense } from 'solid-js';
|
||||||
|
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||||
|
import { Badge } from '@/modules/ui/components/badge';
|
||||||
|
import { Button } from '@/modules/ui/components/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||||
|
import { UserListDetail } from '../../users/components/user-list-detail.component';
|
||||||
|
import {
|
||||||
|
getOrganizationBasicInfo,
|
||||||
|
getOrganizationIntakeEmails,
|
||||||
|
getOrganizationMembers,
|
||||||
|
getOrganizationStats,
|
||||||
|
getOrganizationWebhooks,
|
||||||
|
} from '../organizations.services';
|
||||||
|
|
||||||
|
const OrganizationBasicInfo: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'basic'],
|
||||||
|
queryFn: () => getOrganizationBasicInfo({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Organization Information</CardTitle>
|
||||||
|
<CardDescription>Basic organization details</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">ID</span>
|
||||||
|
<span class="font-mono text-xs">{data().organization.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">Name</span>
|
||||||
|
<span class="text-sm font-medium">{data().organization.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">Created</span>
|
||||||
|
<RelativeTime class="text-sm" date={new Date(data().organization.createdAt)} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">Updated</span>
|
||||||
|
<RelativeTime class="text-sm" date={new Date(data().organization.updatedAt)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationMembers: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'members'],
|
||||||
|
queryFn: () => getOrganizationMembers({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Members (
|
||||||
|
{query.data?.members.length ?? 0}
|
||||||
|
)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Users who belong to this organization</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Show
|
||||||
|
when={data().members.length > 0}
|
||||||
|
fallback={<p class="text-sm text-muted-foreground">No members found</p>}
|
||||||
|
>
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Id</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Joined</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<For each={data().members}>
|
||||||
|
{member => (
|
||||||
|
<TableRow>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<UserListDetail {...member.user} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<A
|
||||||
|
href={`/admin/users/${member.userId}`}
|
||||||
|
class="font-mono hover:underline"
|
||||||
|
>
|
||||||
|
<div class="font-mono text-sm">{member.userId}</div>
|
||||||
|
</A>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" class="capitalize">
|
||||||
|
{member.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RelativeTime class="text-muted-foreground text-sm" date={new Date(member.createdAt)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationIntakeEmails: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'intake-emails'],
|
||||||
|
queryFn: () => getOrganizationIntakeEmails({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Intake Emails (
|
||||||
|
{query.data?.intakeEmails.length ?? 0}
|
||||||
|
)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Email addresses for document ingestion</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Show
|
||||||
|
when={data().intakeEmails.length > 0}
|
||||||
|
fallback={<p class="text-sm text-muted-foreground">No intake emails configured</p>}
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={data().intakeEmails}>
|
||||||
|
{email => (
|
||||||
|
<div class="flex items-center justify-between p-3 border rounded-md">
|
||||||
|
<div>
|
||||||
|
<div class="font-mono text-sm">{email.emailAddress}</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
|
{email.isEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={email.isEnabled ? 'default' : 'outline'}>
|
||||||
|
{email.isEnabled ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationWebhooks: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'webhooks'],
|
||||||
|
queryFn: () => getOrganizationWebhooks({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Webhooks (
|
||||||
|
{query.data?.webhooks.length ?? 0}
|
||||||
|
)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configured webhook endpoints</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<Show
|
||||||
|
when={data().webhooks.length > 0}
|
||||||
|
fallback={<p class="text-sm text-muted-foreground">No webhooks configured</p>}
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={data().webhooks}>
|
||||||
|
{webhook => (
|
||||||
|
<div class="flex items-center justify-between p-3 border rounded-md">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-sm truncate">{webhook.name}</div>
|
||||||
|
<div class="font-mono text-xs text-muted-foreground truncate mt-1">{webhook.url}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={webhook.enabled ? 'default' : 'outline'} class="ml-2 flex-shrink-0">
|
||||||
|
{webhook.enabled ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationStats: Component<{ organizationId: string }> = (props) => {
|
||||||
|
const query = useQuery(() => ({
|
||||||
|
queryKey: ['admin', 'organizations', props.organizationId, 'stats'],
|
||||||
|
queryFn: () => getOrganizationStats({ organizationId: props.organizationId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usage Statistics</CardTitle>
|
||||||
|
<CardDescription>Document and storage statistics</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Show when={query.data}>
|
||||||
|
{data => (
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">Active Documents</span>
|
||||||
|
<span class="text-sm font-medium">{data().stats.documentsCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">Active Storage</span>
|
||||||
|
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.documentsSize, base: 1000 })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">Deleted Documents</span>
|
||||||
|
<span class="text-sm font-medium">{data().stats.deletedDocumentsCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-muted-foreground">Deleted Storage</span>
|
||||||
|
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.deletedDocumentsSize, base: 1000 })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start pt-2 border-t">
|
||||||
|
<span class="text-sm font-medium">Total Documents</span>
|
||||||
|
<span class="text-sm font-bold">{data().stats.totalDocumentsCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm font-medium">Total Storage</span>
|
||||||
|
<span class="text-sm font-bold">{formatBytes({ bytes: data().stats.totalDocumentsSize, base: 1000 })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminOrganizationDetailPage: Component = () => {
|
||||||
|
const params = useParams<{ organizationId: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="p-6 mt-4">
|
||||||
|
<div class="mb-6">
|
||||||
|
<Button as={A} href="/admin/organizations" variant="ghost" size="sm" class="mb-4">
|
||||||
|
<div class="i-tabler-arrow-left size-4 mr-2" />
|
||||||
|
Back to Organizations
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-1">
|
||||||
|
Organization Details
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{params.organizationId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading organization info...</div>}>
|
||||||
|
<OrganizationBasicInfo organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading stats...</div>}>
|
||||||
|
<OrganizationStats organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading intake emails...</div>}>
|
||||||
|
<OrganizationIntakeEmails organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading webhooks...</div>}>
|
||||||
|
<OrganizationWebhooks organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading members...</div>}>
|
||||||
|
<OrganizationMembers organizationId={params.organizationId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminOrganizationDetailPage;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { A } from '@solidjs/router';
|
||||||
|
|
||||||
|
export const UserListDetail: Component<{ id: string; name?: string | null; email: string; href?: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<A href={props.href ?? `/admin/users/${props.id}`} class="flex items-center gap-2 group">
|
||||||
|
<div class="size-9 flex items-center justify-center rounded bg-muted">
|
||||||
|
<div class="i-tabler-user size-5 group-hover:text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div class="font-medium group-hover:text-primary transition">
|
||||||
|
{props.name || '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted-foreground text-xs">
|
||||||
|
{props.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { Badge } from '@/modules/ui/components/badge';
|
|||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||||
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||||
|
import { UserListDetail } from '../components/user-list-detail.component';
|
||||||
import { listUsers } from '../users.services';
|
import { listUsers } from '../users.services';
|
||||||
|
|
||||||
export const AdminListUsersPage: Component = () => {
|
export const AdminListUsersPage: Component = () => {
|
||||||
@@ -27,36 +28,24 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
return query.data?.users ?? [];
|
return query.data?.users ?? [];
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
|
|
||||||
|
{
|
||||||
|
header: 'User',
|
||||||
|
accessorKey: 'email',
|
||||||
|
cell: data => <UserListDetail {...data.row.original} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'ID',
|
header: 'ID',
|
||||||
accessorKey: 'id',
|
accessorKey: 'id',
|
||||||
cell: data => (
|
cell: data => (
|
||||||
<A
|
<A
|
||||||
href={`/admin/users/${data.getValue<string>()}`}
|
href={`/admin/users/${data.getValue<string>()}`}
|
||||||
class="font-mono hover:underline text-primary"
|
class="font-mono hover:underline text-muted-foreground"
|
||||||
>
|
>
|
||||||
{data.getValue<string>()}
|
{data.getValue<string>()}
|
||||||
</A>
|
</A>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: 'Email',
|
|
||||||
accessorKey: 'email',
|
|
||||||
cell: data => (
|
|
||||||
<div class="font-medium">
|
|
||||||
{data.getValue<string>()}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: data => (
|
|
||||||
<div class="text-muted-foreground">
|
|
||||||
{data.getValue<string | null>() || '-'}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'emailVerified',
|
accessorKey: 'emailVerified',
|
||||||
@@ -102,7 +91,7 @@ export const AdminListUsersPage: Component = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 mt-4">
|
<div class="p-6">
|
||||||
<div class="border-b mb-6 pb-4">
|
<div class="border-b mb-6 pb-4">
|
||||||
<h1 class="text-xl font-bold mb-1">
|
<h1 class="text-xl font-bold mb-1">
|
||||||
User Management
|
User Management
|
||||||
|
|||||||
@@ -129,8 +129,22 @@ export const AdminUserDetailPage: Component = () => {
|
|||||||
<For each={data().organizations}>
|
<For each={data().organizations}>
|
||||||
{org => (
|
{org => (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{org.id}</TableCell>
|
<TableCell>
|
||||||
<TableCell class="font-medium">{org.name}</TableCell>
|
<A
|
||||||
|
href={`/admin/organizations/${org.id}`}
|
||||||
|
class="font-mono text-xs hover:underline text-primary"
|
||||||
|
>
|
||||||
|
{org.id}
|
||||||
|
</A>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<A
|
||||||
|
href={`/admin/organizations/${org.id}`}
|
||||||
|
class="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
</A>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<RelativeTime class="text-muted-foreground text-sm" date={new Date(org.createdAt)} />
|
<RelativeTime class="text-muted-foreground text-sm" date={new Date(org.createdAt)} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { RouteDefinitionContext } from '../app/server.types';
|
import type { RouteDefinitionContext } from '../app/server.types';
|
||||||
import { registerAnalyticsRoutes } from './analytics/analytics.routes';
|
import { registerAnalyticsRoutes } from './analytics/analytics.routes';
|
||||||
|
import { registerOrganizationManagementRoutes } from './organizations/organizations.routes';
|
||||||
import { registerUserManagementRoutes } from './users/users.routes';
|
import { registerUserManagementRoutes } from './users/users.routes';
|
||||||
|
|
||||||
export function registerAdminRoutes(context: RouteDefinitionContext) {
|
export function registerAdminRoutes(context: RouteDefinitionContext) {
|
||||||
registerAnalyticsRoutes(context);
|
registerAnalyticsRoutes(context);
|
||||||
registerUserManagementRoutes(context);
|
registerUserManagementRoutes(context);
|
||||||
|
registerOrganizationManagementRoutes(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { createInMemoryDatabase } from '../../../app/database/database.test-utils';
|
||||||
|
import { createServer } from '../../../app/server';
|
||||||
|
import { createTestServerDependencies } from '../../../app/server.test-utils';
|
||||||
|
import { overrideConfig } from '../../../config/config.test-utils';
|
||||||
|
|
||||||
|
describe('admin organizations routes - permission protection', () => {
|
||||||
|
describe('get /api/admin/organizations', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Organization 1' },
|
||||||
|
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Organization 2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = (await response.json()) as { organizations: unknown; totalCount: number };
|
||||||
|
expect(body.organizations).to.have.length(2);
|
||||||
|
expect(body.totalCount).to.eql(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when using search parameter, it filters by name', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_alpha123456789012345678', name: 'Alpha Corporation' },
|
||||||
|
{ id: 'org_beta1234567890123456789', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations?search=Alpha',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { organizations: { name: string }[]; totalCount: number };
|
||||||
|
expect(body.organizations).to.have.length(1);
|
||||||
|
expect(body.organizations[0]?.name).to.eql('Alpha Corporation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when using search parameter with organization ID, it returns exact match', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Alpha Corporation' },
|
||||||
|
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations?search=org_abcdefghijklmnopqrstuvwx',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { organizations: { id: string }[]; totalCount: number };
|
||||||
|
expect(body.organizations).to.have.length(1);
|
||||||
|
expect(body.organizations[0]?.id).to.eql('org_abcdefghijklmnopqrstuvwx');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_regular', email: 'user@example.com' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns organization basic info', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { organization: { id: string; name: string } };
|
||||||
|
expect(body.organization.id).to.eql('org_123456789012345678901234');
|
||||||
|
expect(body.organization.name).to.eql('Test Organization');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
expect(await response.json()).to.eql({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/members', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns members', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
{ id: 'usr_member', email: 'member@example.com', name: 'Member User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
organizationMembers: [
|
||||||
|
{ userId: 'usr_member', organizationId: 'org_123456789012345678901234', role: 'member' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { members: { userId: string; role: string }[] };
|
||||||
|
expect(body.members).to.have.length(1);
|
||||||
|
expect(body.members[0]?.userId).to.eql('usr_member');
|
||||||
|
expect(body.members[0]?.role).to.eql('member');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/members',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/intake-emails', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns intake emails', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
intakeEmails: [
|
||||||
|
{ organizationId: 'org_123456789012345678901234', emailAddress: 'intake@example.com', isEnabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { intakeEmails: { emailAddress: string; isEnabled: boolean }[] };
|
||||||
|
expect(body.intakeEmails).to.have.length(1);
|
||||||
|
expect(body.intakeEmails[0]?.emailAddress).to.eql('intake@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/intake-emails',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/webhooks', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns webhooks', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
webhooks: [
|
||||||
|
{ organizationId: 'org_123456789012345678901234', name: 'Test Webhook', url: 'https://example.com/webhook', enabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { webhooks: { name: string; url: string; enabled: boolean }[] };
|
||||||
|
expect(body.webhooks).to.have.length(1);
|
||||||
|
expect(body.webhooks[0]?.name).to.eql('Test Webhook');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/webhooks',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get /api/admin/organizations/:organizationId/stats', () => {
|
||||||
|
test('when the user has the VIEW_USERS permission, the request succeeds and returns stats', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(200);
|
||||||
|
const body = await response.json() as { stats: { documentsCount: number; documentsSize: number } };
|
||||||
|
expect(body.stats).to.have.property('documentsCount');
|
||||||
|
expect(body.stats).to.have.property('documentsSize');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||||
|
],
|
||||||
|
userRoles: [
|
||||||
|
{ userId: 'usr_admin', role: 'admin' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_999999999999999999999999/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_admin' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'usr_regular', email: 'user@example.com' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
{ loggedInUserId: 'usr_regular' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Test Organization' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/api/admin/organizations/org_123456789012345678901234/stats',
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).to.eql(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import type { RouteDefinitionContext } from '../../app/server.types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createRoleMiddleware, requireAuthentication } from '../../app/auth/auth.middleware';
|
||||||
|
import { createIntakeEmailsRepository } from '../../intake-emails/intake-emails.repository';
|
||||||
|
import { organizationIdSchema } from '../../organizations/organization.schemas';
|
||||||
|
import { createOrganizationNotFoundError } from '../../organizations/organizations.errors';
|
||||||
|
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
|
||||||
|
import { PERMISSIONS } from '../../roles/roles.constants';
|
||||||
|
import { validateParams, validateQuery } from '../../shared/validation/validation';
|
||||||
|
import { createWebhookRepository } from '../../webhooks/webhook.repository';
|
||||||
|
|
||||||
|
export function registerOrganizationManagementRoutes(context: RouteDefinitionContext) {
|
||||||
|
registerListOrganizationsRoute(context);
|
||||||
|
registerGetOrganizationBasicInfoRoute(context);
|
||||||
|
registerGetOrganizationMembersRoute(context);
|
||||||
|
registerGetOrganizationIntakeEmailsRoute(context);
|
||||||
|
registerGetOrganizationWebhooksRoute(context);
|
||||||
|
registerGetOrganizationStatsRoute(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerListOrganizationsRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateQuery(
|
||||||
|
z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
pageIndex: z.coerce.number().min(0).int().optional().default(0),
|
||||||
|
pageSize: z.coerce.number().min(1).max(100).int().optional().default(25),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { search, pageIndex, pageSize } = context.req.valid('query');
|
||||||
|
|
||||||
|
const { organizations, totalCount } = await organizationsRepository.listOrganizations({
|
||||||
|
search,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
organizations,
|
||||||
|
totalCount,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationBasicInfoRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json({ organization });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationMembersRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/members',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { members } = await organizationsRepository.getOrganizationMembers({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ members });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/intake-emails',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { intakeEmails } = await intakeEmailsRepository.getOrganizationIntakeEmails({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ intakeEmails });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationWebhooksRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/webhooks',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
const webhookRepository = createWebhookRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { webhooks } = await webhookRepository.getOrganizationWebhooks({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ webhooks });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerGetOrganizationStatsRoute({ app, db }: RouteDefinitionContext) {
|
||||||
|
const { requirePermissions } = createRoleMiddleware({ db });
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/organizations/:organizationId/stats',
|
||||||
|
requireAuthentication(),
|
||||||
|
requirePermissions({
|
||||||
|
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||||
|
}),
|
||||||
|
validateParams(z.object({
|
||||||
|
organizationId: organizationIdSchema,
|
||||||
|
})),
|
||||||
|
async (context) => {
|
||||||
|
const { createDocumentsRepository } = await import('../../documents/documents.repository');
|
||||||
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationId } = context.req.valid('param');
|
||||||
|
|
||||||
|
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw createOrganizationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentsRepository = createDocumentsRepository({ db });
|
||||||
|
const stats = await documentsRepository.getOrganizationStats({ organizationId });
|
||||||
|
|
||||||
|
return context.json({ stats });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createErrorFactory } from '../shared/errors/errors';
|
||||||
|
|
||||||
|
// Error when the authentication is not using an API key but the route is api-key only
|
||||||
|
export const createNotApiKeyAuthError = createErrorFactory({
|
||||||
|
code: 'api_keys.authentication_not_api_key',
|
||||||
|
message: 'Authentication must be done using an API key to access this resource',
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import type { RouteDefinitionContext } from '../app/server.types';
|
import type { RouteDefinitionContext } from '../app/server.types';
|
||||||
import type { ApiKeyPermissions } from './api-keys.types';
|
import type { ApiKeyPermissions } from './api-keys.types';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { createUnauthorizedError } from '../app/auth/auth.errors';
|
||||||
import { requireAuthentication } from '../app/auth/auth.middleware';
|
import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||||
import { getUser } from '../app/auth/auth.models';
|
import { getUser } from '../app/auth/auth.models';
|
||||||
import { createError } from '../shared/errors/errors';
|
import { createError } from '../shared/errors/errors';
|
||||||
|
import { isNil } from '../shared/utils';
|
||||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||||
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
|
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
|
||||||
|
import { createNotApiKeyAuthError } from './api-keys.errors';
|
||||||
import { createApiKeysRepository } from './api-keys.repository';
|
import { createApiKeysRepository } from './api-keys.repository';
|
||||||
import { apiKeyIdSchema } from './api-keys.schemas';
|
import { apiKeyIdSchema } from './api-keys.schemas';
|
||||||
import { createApiKey } from './api-keys.usecases';
|
import { createApiKey } from './api-keys.usecases';
|
||||||
|
|
||||||
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
|
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
|
||||||
setupCreateApiKeyRoute(context);
|
setupCreateApiKeyRoute(context);
|
||||||
|
setupGetCurrentApiKeyRoute(context); // Should be before the get api keys route otherwise it conflicts ("current" as apiKeyId)
|
||||||
setupGetApiKeysRoute(context);
|
setupGetApiKeysRoute(context);
|
||||||
setupDeleteApiKeyRoute(context);
|
setupDeleteApiKeyRoute(context);
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,38 @@ function setupGetApiKeysRoute({ app, db }: RouteDefinitionContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mainly use for authentication verification in client SDKs
|
||||||
|
function setupGetCurrentApiKeyRoute({ app }: RouteDefinitionContext) {
|
||||||
|
app.get(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
async (context) => {
|
||||||
|
const authType = context.get('authType');
|
||||||
|
const apiKey = context.get('apiKey');
|
||||||
|
|
||||||
|
if (isNil(authType)) {
|
||||||
|
throw createUnauthorizedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType !== 'api-key') {
|
||||||
|
throw createNotApiKeyAuthError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(apiKey)) {
|
||||||
|
// Should not happen as authType is 'api-key', but for type safety
|
||||||
|
throw createUnauthorizedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json({
|
||||||
|
apiKey: {
|
||||||
|
id: apiKey.id,
|
||||||
|
name: apiKey.name,
|
||||||
|
permissions: apiKey.permissions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
|
function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
|
||||||
app.delete(
|
app.delete(
|
||||||
'/api/api-keys/:apiKeyId',
|
'/api/api-keys/:apiKeyId',
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
|
||||||
|
import { createServer } from '../../app/server';
|
||||||
|
import { createTestServerDependencies } from '../../app/server.test-utils';
|
||||||
|
import { overrideConfig } from '../../config/config.test-utils';
|
||||||
|
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
||||||
|
import { API_KEY_ID_PREFIX, API_KEY_TOKEN_LENGTH } from '../api-keys.constants';
|
||||||
|
|
||||||
|
describe('api-key e2e', () => {
|
||||||
|
describe('get /api/api-keys/current', () => {
|
||||||
|
test('when using an api key, one can request the /api/api-keys/current route to check that the api key is valid', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Test API Key',
|
||||||
|
permissions: ['documents:create'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createApiKeyResponse.status).toBe(200);
|
||||||
|
const { token, apiKey } = await createApiKeyResponse.json() as { token: string; apiKey: { id: string } };
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
apiKey: {
|
||||||
|
id: apiKey.id,
|
||||||
|
name: 'Test API Key',
|
||||||
|
permissions: ['documents:create'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when not using an api key, requesting the /api/api-keys/current route returns an error', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
error: {
|
||||||
|
code: 'api_keys.authentication_not_api_key',
|
||||||
|
message: 'Authentication must be done using an API key to access this resource',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when not authenticated at all, requesting the /api/api-keys/current route returns an error', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if the api key used is invalid, requesting the /api/api-keys/current route returns an error', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
const invalidButLegitApiKeyToken = `${API_KEY_ID_PREFIX}_${'x'.repeat(API_KEY_TOKEN_LENGTH)}`;
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCurrentApiKeyResponse = await app.request(
|
||||||
|
'/api/api-keys/current',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${invalidButLegitApiKeyToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getCurrentApiKeyResponse.status).toBe(401);
|
||||||
|
const response = await getCurrentApiKeyResponse.json();
|
||||||
|
|
||||||
|
expect(response).to.deep.equal({
|
||||||
|
error: {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { createTimeoutMiddleware } from './timeout.middleware';
|
|||||||
describe('middlewares', () => {
|
describe('middlewares', () => {
|
||||||
describe('timeoutMiddleware', () => {
|
describe('timeoutMiddleware', () => {
|
||||||
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
|
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
|
||||||
const config = overrideConfig({ server: { routeTimeoutMs: 50 } });
|
const config = overrideConfig({ server: { defaultRouteTimeoutMs: 50 } });
|
||||||
|
|
||||||
const app = new Hono<ServerInstanceGenerics>();
|
const app = new Hono<ServerInstanceGenerics>();
|
||||||
registerErrorMiddleware({ app });
|
registerErrorMiddleware({ app });
|
||||||
@@ -45,5 +45,107 @@ describe('middlewares', () => {
|
|||||||
expect(response2.status).to.eql(200);
|
expect(response2.status).to.eql(200);
|
||||||
expect(await response2.json()).to.eql({ status: 'ok' });
|
expect(await response2.json()).to.eql({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('route-specific timeout overrides default timeout for matching routes', async () => {
|
||||||
|
const config = overrideConfig({
|
||||||
|
server: {
|
||||||
|
defaultRouteTimeoutMs: 50,
|
||||||
|
routeTimeouts: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
route: '/api/upload/:id',
|
||||||
|
timeoutMs: 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<ServerInstanceGenerics>();
|
||||||
|
registerErrorMiddleware({ app });
|
||||||
|
|
||||||
|
// POST to matching route with longer timeout - should not timeout
|
||||||
|
app.post(
|
||||||
|
'/api/upload/:id',
|
||||||
|
createTimeoutMiddleware({ config }),
|
||||||
|
async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET to same route - should timeout with default
|
||||||
|
app.get(
|
||||||
|
'/api/upload/:id',
|
||||||
|
createTimeoutMiddleware({ config }),
|
||||||
|
async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Different route - should timeout with default
|
||||||
|
app.post(
|
||||||
|
'/api/other',
|
||||||
|
createTimeoutMiddleware({ config }),
|
||||||
|
async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST to matching pattern should succeed
|
||||||
|
const response1 = await app.request('/api/upload/123', { method: 'POST' });
|
||||||
|
expect(response1.status).to.eql(200);
|
||||||
|
|
||||||
|
// GET to same path should timeout (method mismatch)
|
||||||
|
const response2 = await app.request('/api/upload/123', { method: 'GET' });
|
||||||
|
expect(response2.status).to.eql(504);
|
||||||
|
|
||||||
|
// POST to different path should timeout (path mismatch)
|
||||||
|
const response3 = await app.request('/api/other', { method: 'POST' });
|
||||||
|
expect(response3.status).to.eql(504);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when registered globally with .use(), route-specific timeouts should work', async () => {
|
||||||
|
const config = overrideConfig({
|
||||||
|
server: {
|
||||||
|
defaultRouteTimeoutMs: 50,
|
||||||
|
routeTimeouts: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
route: '/api/organizations/:orgId/documents',
|
||||||
|
timeoutMs: 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<ServerInstanceGenerics>();
|
||||||
|
registerErrorMiddleware({ app });
|
||||||
|
|
||||||
|
// Register middleware globally (like in server.ts)
|
||||||
|
app.use(createTimeoutMiddleware({ config }));
|
||||||
|
|
||||||
|
// Route that should have extended timeout
|
||||||
|
app.post('/api/organizations/:orgId/documents', async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'upload ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route that should use default timeout
|
||||||
|
app.get('/api/other', async (context) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return context.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST to upload route should succeed (extended timeout)
|
||||||
|
const response1 = await app.request('/api/organizations/org-123/documents', { method: 'POST' });
|
||||||
|
expect(response1.status).to.eql(200);
|
||||||
|
expect(await response1.json()).to.eql({ status: 'upload ok' });
|
||||||
|
|
||||||
|
// GET to other route should timeout (default timeout)
|
||||||
|
const response2 = await app.request('/api/other', { method: 'GET' });
|
||||||
|
expect(response2.status).to.eql(504);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
import type { Config } from '../../config/config.types';
|
import type { Config } from '../../config/config.types';
|
||||||
import type { Context } from '../server.types';
|
import type { Context } from '../server.types';
|
||||||
import { createMiddleware } from 'hono/factory';
|
import { createMiddleware } from 'hono/factory';
|
||||||
|
import { routePath } from 'hono/route';
|
||||||
import { createError } from '../../shared/errors/errors';
|
import { createError } from '../../shared/errors/errors';
|
||||||
|
|
||||||
|
function getTimeoutForRoute({
|
||||||
|
defaultRouteTimeoutMs,
|
||||||
|
routeTimeouts,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
defaultRouteTimeoutMs: number;
|
||||||
|
routeTimeouts: { method: string; route: string; timeoutMs: number }[];
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
}): number {
|
||||||
|
const matchingRoute = routeTimeouts.find((routeConfig) => {
|
||||||
|
if (routeConfig.method !== method) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeConfig.route !== path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return matchingRoute?.timeoutMs ?? defaultRouteTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function createTimeoutMiddleware({ config }: { config: Config }) {
|
export function createTimeoutMiddleware({ config }: { config: Config }) {
|
||||||
return createMiddleware(async (context: Context, next) => {
|
return createMiddleware(async (context: Context, next) => {
|
||||||
const { server: { routeTimeoutMs } } = config;
|
const method = context.req.method;
|
||||||
|
const path = routePath(context, -1); // Get the last matched route path, without the -1 we get /* for all routes
|
||||||
|
const { defaultRouteTimeoutMs, routeTimeouts } = config.server;
|
||||||
|
|
||||||
|
const timeoutMs = getTimeoutForRoute({ defaultRouteTimeoutMs, routeTimeouts, method, path });
|
||||||
|
|
||||||
let timerId: NodeJS.Timeout | undefined;
|
let timerId: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
@@ -16,7 +47,7 @@ export function createTimeoutMiddleware({ config }: { config: Config }) {
|
|||||||
message: 'The request timed out',
|
message: 'The request timed out',
|
||||||
statusCode: 504,
|
statusCode: 504,
|
||||||
}),
|
}),
|
||||||
), routeTimeoutMs);
|
), timeoutMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { intakeEmailsConfig } from '../intake-emails/intake-emails.config';
|
|||||||
import { organizationsConfig } from '../organizations/organizations.config';
|
import { organizationsConfig } from '../organizations/organizations.config';
|
||||||
import { organizationPlansConfig } from '../plans/plans.config';
|
import { organizationPlansConfig } from '../plans/plans.config';
|
||||||
import { createLogger } from '../shared/logger/logger';
|
import { createLogger } from '../shared/logger/logger';
|
||||||
|
import { IN_MS } from '../shared/units';
|
||||||
import { isString } from '../shared/utils';
|
import { isString } from '../shared/utils';
|
||||||
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
|
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
|
||||||
import { tasksConfig } from '../tasks/tasks.config';
|
import { tasksConfig } from '../tasks/tasks.config';
|
||||||
@@ -84,12 +85,29 @@ export const configDefinition = {
|
|||||||
default: '0.0.0.0',
|
default: '0.0.0.0',
|
||||||
env: 'SERVER_HOSTNAME',
|
env: 'SERVER_HOSTNAME',
|
||||||
},
|
},
|
||||||
routeTimeoutMs: {
|
defaultRouteTimeoutMs: {
|
||||||
doc: 'The maximum time in milliseconds for a route to complete before timing out',
|
doc: 'The maximum time in milliseconds for a route to complete before timing out',
|
||||||
schema: z.coerce.number().int().positive(),
|
schema: z.coerce.number().int().positive(),
|
||||||
default: 20_000,
|
default: 20 * IN_MS.SECOND,
|
||||||
env: 'SERVER_API_ROUTES_TIMEOUT_MS',
|
env: 'SERVER_API_ROUTES_TIMEOUT_MS',
|
||||||
},
|
},
|
||||||
|
routeTimeouts: {
|
||||||
|
doc: 'Route-specific timeout overrides. Allows setting different timeouts for specific HTTP method and route paths.',
|
||||||
|
schema: z.array(
|
||||||
|
z.object({
|
||||||
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']),
|
||||||
|
route: z.string(),
|
||||||
|
timeoutMs: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
default: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
route: '/api/organizations/:organizationId/documents',
|
||||||
|
timeoutMs: 5 * IN_MS.MINUTE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
corsOrigins: {
|
corsOrigins: {
|
||||||
doc: 'The CORS origin for the api server',
|
doc: 'The CORS origin for the api server',
|
||||||
schema: z.union([
|
schema: z.union([
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { getUser } from '../app/auth/auth.models';
|
|||||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||||
|
import { createPlansRepository } from '../plans/plans.repository';
|
||||||
|
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||||
import { getFileStreamFromMultipartForm } from '../shared/streams/file-upload';
|
import { getFileStreamFromMultipartForm } from '../shared/streams/file-upload';
|
||||||
import { validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
import { validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
|
||||||
|
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||||
import { createDocumentIsNotDeletedError } from './documents.errors';
|
import { createDocumentIsNotDeletedError } from './documents.errors';
|
||||||
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
|
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
|
||||||
import { createDocumentsRepository } from './documents.repository';
|
import { createDocumentsRepository } from './documents.repository';
|
||||||
@@ -45,12 +48,17 @@ function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
|
|||||||
const organizationsRepository = createOrganizationsRepository({ db });
|
const organizationsRepository = createOrganizationsRepository({ db });
|
||||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||||
|
|
||||||
const { maxUploadSize } = config.documentsStorage;
|
// Get organization's plan-specific upload limit
|
||||||
|
const plansRepository = createPlansRepository({ config });
|
||||||
|
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||||
|
|
||||||
|
const { organizationPlan } = await getOrganizationPlan({ organizationId, plansRepository, subscriptionsRepository });
|
||||||
|
const { maxFileSize } = organizationPlan.limits;
|
||||||
|
|
||||||
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
|
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
|
||||||
body: context.req.raw.body,
|
body: context.req.raw.body,
|
||||||
headers: context.req.header(),
|
headers: context.req.header(),
|
||||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : undefined,
|
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize: maxFileSize }) ? maxFileSize : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createDocument = createDocumentCreationUsecase({ ...deps });
|
const createDocument = createDocumentCreationUsecase({ ...deps });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createTestEventServices } from '../app/events/events.test-utils';
|
|||||||
import { overrideConfig } from '../config/config.test-utils';
|
import { overrideConfig } from '../config/config.test-utils';
|
||||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||||
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
|
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
|
||||||
|
import { createDeterministicIdGenerator } from '../shared/random/ids';
|
||||||
import { collectReadableStreamToString, createReadableStream } from '../shared/streams/readable-stream';
|
import { collectReadableStreamToString, createReadableStream } from '../shared/streams/readable-stream';
|
||||||
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
|
||||||
import { createTagsRepository } from '../tags/tags.repository';
|
import { createTagsRepository } from '../tags/tags.repository';
|
||||||
@@ -244,6 +245,83 @@ describe('documents usecases', () => {
|
|||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('when restoring a deleted document via duplicate upload, the optimistically saved new file should be cleaned up to prevent orphan files', async () => {
|
||||||
|
const taskServices = createInMemoryTaskServices();
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||||
|
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||||
|
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = overrideConfig({
|
||||||
|
organizationPlans: { isFreePlanUnlimited: true },
|
||||||
|
documentsStorage: { driver: 'in-memory' },
|
||||||
|
});
|
||||||
|
const documentsRepository = createDocumentsRepository({ db });
|
||||||
|
const inMemoryDocumentsStorageService = inMemoryStorageDriverFactory();
|
||||||
|
|
||||||
|
const createDocument = createDocumentCreationUsecase({
|
||||||
|
db,
|
||||||
|
config,
|
||||||
|
generateDocumentId: createDeterministicIdGenerator({ prefix: 'doc' }),
|
||||||
|
documentsStorageService: inMemoryDocumentsStorageService,
|
||||||
|
taskServices,
|
||||||
|
eventServices: createTestEventServices(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = 'user-1';
|
||||||
|
const organizationId = 'organization-1';
|
||||||
|
|
||||||
|
// Step 1: Upload a file
|
||||||
|
const { document: document1 } = await createDocument({
|
||||||
|
fileStream: createReadableStream({ content: 'Hello, world!' }),
|
||||||
|
fileName: 'file.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document1.id).to.eql('doc_000000000000000000000001');
|
||||||
|
expect(
|
||||||
|
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
|
||||||
|
).to.eql([
|
||||||
|
'organization-1/originals/doc_000000000000000000000001.pdf',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 2: Delete the document (soft delete)
|
||||||
|
await trashDocument({
|
||||||
|
documentId: document1.id,
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
documentsRepository,
|
||||||
|
eventServices: createTestEventServices(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { document: trashedDoc } = await documentsRepository.getDocumentById({ documentId: document1.id, organizationId });
|
||||||
|
expect(trashedDoc?.isDeleted).to.eql(true);
|
||||||
|
|
||||||
|
// Step 3: Upload the same file again - this should restore the original document
|
||||||
|
const { document: restoredDocument } = await createDocument({
|
||||||
|
fileStream: createReadableStream({ content: 'Hello, world!' }),
|
||||||
|
fileName: 'file.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The document should be restored (same ID)
|
||||||
|
expect(restoredDocument.id).to.eql('doc_000000000000000000000001');
|
||||||
|
expect(restoredDocument.isDeleted).to.eql(false);
|
||||||
|
|
||||||
|
// Step 5: Verify no orphan files remain in storage
|
||||||
|
// The optimistically saved file (doc_2.pdf) should have been cleaned up during restoration
|
||||||
|
expect(
|
||||||
|
Array.from(inMemoryDocumentsStorageService._getStorage().keys()),
|
||||||
|
).to.eql([
|
||||||
|
'organization-1/originals/doc_000000000000000000000001.pdf',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('when there is an issue when inserting the document in the db, the file should not be saved in the storage', async () => {
|
test('when there is an issue when inserting the document in the db, the file should not be saved in the storage', async () => {
|
||||||
const taskServices = createInMemoryTaskServices();
|
const taskServices = createInMemoryTaskServices();
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
|
|||||||
@@ -235,9 +235,10 @@ async function handleExistingDocument({
|
|||||||
newDocumentStorageKey: string;
|
newDocumentStorageKey: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}) {
|
}) {
|
||||||
if (!existingDocument.isDeleted) {
|
// Delete the newly uploaded file since we'll be using the existing document's file
|
||||||
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
|
await documentsStorageService.deleteFile({ storageKey: newDocumentStorageKey });
|
||||||
|
|
||||||
|
if (!existingDocument.isDeleted) {
|
||||||
throw createDocumentAlreadyExistsError();
|
throw createDocumentAlreadyExistsError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createServer } from '../../app/server';
|
|||||||
import { createTestServerDependencies } from '../../app/server.test-utils';
|
import { createTestServerDependencies } from '../../app/server.test-utils';
|
||||||
import { overrideConfig } from '../../config/config.test-utils';
|
import { overrideConfig } from '../../config/config.test-utils';
|
||||||
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
|
||||||
|
import { PLUS_PLAN_ID, PRO_PLAN_ID } from '../../plans/plans.constants';
|
||||||
import { documentsTable } from '../documents.table';
|
import { documentsTable } from '../documents.table';
|
||||||
import { inMemoryStorageDriverFactory } from '../storage/drivers/memory/memory.storage-driver';
|
import { inMemoryStorageDriverFactory } from '../storage/drivers/memory/memory.storage-driver';
|
||||||
|
|
||||||
@@ -247,5 +248,123 @@ describe('documents e2e', () => {
|
|||||||
expect(retrievedDocument).to.eql({ ...document, tags: [] });
|
expect(retrievedDocument).to.eql({ ...document, tags: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('organizations on Plus plan should be able to upload files up to 100 MiB (not limited by global config)', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_222222222222222222222222', name: 'Plus Org', customerId: 'cus_plus123' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
organizationSubscriptions: [{
|
||||||
|
id: 'sub_plus123',
|
||||||
|
customerId: 'cus_plus123',
|
||||||
|
organizationId: 'org_222222222222222222222222',
|
||||||
|
planId: PLUS_PLAN_ID,
|
||||||
|
status: 'active',
|
||||||
|
seatsCount: 5,
|
||||||
|
currentPeriodStart: new Date('2024-01-01'),
|
||||||
|
currentPeriodEnd: new Date('2024-02-01'),
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
// Global config set to 10 MiB (simulating free tier limit)
|
||||||
|
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// File size: 50 MiB - exceeds global config (10 MiB) but within Plus plan limit (100 MiB)
|
||||||
|
const fileSizeBytes = 1024 * 1024 * 50; // 50 MiB
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'large-document.txt', { type: 'text/plain' }));
|
||||||
|
const body = new Response(formData);
|
||||||
|
|
||||||
|
const createDocumentResponse = await app.request(
|
||||||
|
'/api/organizations/org_222222222222222222222222/documents',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(body.headers.entries()),
|
||||||
|
},
|
||||||
|
body: await body.arrayBuffer(),
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should succeed because Plus plan allows 100 MiB
|
||||||
|
expect(createDocumentResponse.status).to.eql(200);
|
||||||
|
const { document } = (await createDocumentResponse.json()) as { document: Document };
|
||||||
|
|
||||||
|
expect(document).to.include({
|
||||||
|
name: 'large-document.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
originalSize: fileSizeBytes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('organizations on Pro plan should be able to upload files up to 500 MiB (not limited by global config)', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
|
||||||
|
organizations: [{ id: 'org_333333333333333333333333', name: 'Pro Org', customerId: 'cus_pro123' }],
|
||||||
|
organizationMembers: [{ organizationId: 'org_333333333333333333333333', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
|
||||||
|
organizationSubscriptions: [{
|
||||||
|
id: 'sub_pro123',
|
||||||
|
customerId: 'cus_pro123',
|
||||||
|
organizationId: 'org_333333333333333333333333',
|
||||||
|
planId: PRO_PLAN_ID,
|
||||||
|
status: 'active',
|
||||||
|
seatsCount: 20,
|
||||||
|
currentPeriodStart: new Date('2024-01-01'),
|
||||||
|
currentPeriodEnd: new Date('2024-02-01'),
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { app } = createServer(createTestServerDependencies({
|
||||||
|
db,
|
||||||
|
config: overrideConfig({
|
||||||
|
env: 'test',
|
||||||
|
documentsStorage: {
|
||||||
|
driver: 'in-memory',
|
||||||
|
// Global config set to 10 MiB (simulating free tier limit)
|
||||||
|
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// File size: 200 MiB - exceeds global config (10 MiB) but within Pro plan limit (500 MiB)
|
||||||
|
const fileSizeBytes = 1024 * 1024 * 200; // 200 MiB
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'very-large-document.txt', { type: 'text/plain' }));
|
||||||
|
const body = new Response(formData);
|
||||||
|
|
||||||
|
const createDocumentResponse = await app.request(
|
||||||
|
'/api/organizations/org_333333333333333333333333/documents',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(body.headers.entries()),
|
||||||
|
},
|
||||||
|
body: await body.arrayBuffer(),
|
||||||
|
},
|
||||||
|
{ loggedInUserId: 'usr_111111111111111111111111' },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should succeed because Pro plan allows 500 MiB
|
||||||
|
expect(createDocumentResponse.status).to.eql(200);
|
||||||
|
const { document } = (await createDocumentResponse.json()) as { document: Document };
|
||||||
|
|
||||||
|
expect(document).to.include({
|
||||||
|
name: 'very-large-document.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
originalSize: fileSizeBytes,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ConfigDefinition } from 'figue';
|
import type { ConfigDefinition } from 'figue';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { booleanishSchema } from '../config/config.schemas';
|
import { booleanishSchema } from '../config/config.schemas';
|
||||||
|
import { IN_MS } from '../shared/units';
|
||||||
import { isString } from '../shared/utils';
|
import { isString } from '../shared/utils';
|
||||||
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
|
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export const ingestionFolderConfig = {
|
|||||||
pollingInterval: {
|
pollingInterval: {
|
||||||
doc: 'When polling is used, this is the interval at which the watcher checks for changes in the ingestion folder (in milliseconds)',
|
doc: 'When polling is used, this is the interval at which the watcher checks for changes in the ingestion folder (in milliseconds)',
|
||||||
schema: z.coerce.number().int().positive(),
|
schema: z.coerce.number().int().positive(),
|
||||||
default: 2_000,
|
default: 2 * IN_MS.SECOND,
|
||||||
env: 'INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS',
|
env: 'INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { OrganizationInvitation } from './organizations.types';
|
import type { OrganizationInvitation } from './organizations.types';
|
||||||
import { isAfter } from 'date-fns';
|
import { isAfter } from 'date-fns';
|
||||||
import { ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
import { eq, like } from 'drizzle-orm';
|
||||||
|
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
|
||||||
|
import { isNilOrEmptyString } from '../shared/utils';
|
||||||
|
import { ORGANIZATION_ID_REGEX, ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
||||||
|
import { organizationsTable } from './organizations.table';
|
||||||
|
|
||||||
export function ensureInvitationStatus({ invitation, now = new Date() }: { invitation?: OrganizationInvitation | null | undefined; now?: Date }) {
|
export function ensureInvitationStatus({ invitation, now = new Date() }: { invitation?: OrganizationInvitation | null | undefined; now?: Date }) {
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
@@ -17,3 +21,20 @@ export function ensureInvitationStatus({ invitation, now = new Date() }: { invit
|
|||||||
|
|
||||||
return { ...invitation, status: ORGANIZATION_INVITATION_STATUS.EXPIRED };
|
return { ...invitation, status: ORGANIZATION_INVITATION_STATUS.EXPIRED };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSearchOrganizationWhereClause({ search }: { search?: string }) {
|
||||||
|
const trimmedSearch = search?.trim();
|
||||||
|
|
||||||
|
if (isNilOrEmptyString(trimmedSearch)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ORGANIZATION_ID_REGEX.test(trimmedSearch)) {
|
||||||
|
return eq(organizationsTable.id, trimmedSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedSearch = escapeLikeWildcards(trimmedSearch);
|
||||||
|
const likeSearch = `%${escapedSearch}%`;
|
||||||
|
|
||||||
|
return like(organizationsTable.name, likeSearch);
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,4 +250,167 @@ describe('organizations repository', () => {
|
|||||||
expect(organizationCount).to.equal(2);
|
expect(organizationCount).to.equal(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listOrganizations', () => {
|
||||||
|
test('when no organizations exist, an empty list is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase();
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({});
|
||||||
|
|
||||||
|
expect(result).to.deep.equal({
|
||||||
|
organizations: [],
|
||||||
|
totalCount: 0,
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when multiple organizations exist, all organizations are returned with member counts', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [
|
||||||
|
{ id: 'user_1', email: 'user1@example.com', name: 'User 1' },
|
||||||
|
{ id: 'user_2', email: 'user2@example.com', name: 'User 2' },
|
||||||
|
],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Alpha Corp', createdAt: new Date('2025-01-02') },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-01') },
|
||||||
|
],
|
||||||
|
organizationMembers: [
|
||||||
|
{ userId: 'user_1', organizationId: 'org_1', role: 'owner' },
|
||||||
|
{ userId: 'user_2', organizationId: 'org_1', role: 'member' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({});
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
expect(result.pageIndex).to.equal(0);
|
||||||
|
expect(result.pageSize).to.equal(25);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.organizations.map(org => ({
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
memberCount: org.memberCount,
|
||||||
|
})),
|
||||||
|
).to.deep.equal([
|
||||||
|
{ id: 'org_1', name: 'Alpha Corp', memberCount: 2 },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC', memberCount: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching by organization ID, only the exact matching organization is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_123456789012345678901234', name: 'Alpha Corp' },
|
||||||
|
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: 'org_abcdefghijklmnopqrstuvwx' });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(1);
|
||||||
|
expect(result.organizations[0]?.id).to.equal('org_abcdefghijklmnopqrstuvwx');
|
||||||
|
expect(result.totalCount).to.equal(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching by partial name, matching organizations are returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Alpha Corporation', createdAt: new Date('2025-01-02') },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-03') },
|
||||||
|
{ id: 'org_3', name: 'Alpha Industries', createdAt: new Date('2025-01-01') },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: 'Alpha' });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
expect(result.organizations.map(org => org.name)).to.deep.equal([
|
||||||
|
'Alpha Corporation',
|
||||||
|
'Alpha Industries',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching with an empty string, all organizations are returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Alpha Corp' },
|
||||||
|
{ id: 'org_2', name: 'Beta LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: ' ' });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when using pagination, only the requested page is returned', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Org 1' },
|
||||||
|
{ id: 'org_2', name: 'Org 2' },
|
||||||
|
{ id: 'org_3', name: 'Org 3' },
|
||||||
|
{ id: 'org_4', name: 'Org 4' },
|
||||||
|
{ id: 'org_5', name: 'Org 5' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const firstPage = await listOrganizations({ pageIndex: 0, pageSize: 2 });
|
||||||
|
const secondPage = await listOrganizations({ pageIndex: 1, pageSize: 2 });
|
||||||
|
|
||||||
|
expect(firstPage.organizations).to.have.length(2);
|
||||||
|
expect(firstPage.totalCount).to.equal(5);
|
||||||
|
expect(secondPage.organizations).to.have.length(2);
|
||||||
|
expect(secondPage.totalCount).to.equal(5);
|
||||||
|
expect(firstPage.organizations[0]?.id).to.not.equal(secondPage.organizations[0]?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when searching with pagination, the total count reflects the search results', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Tech Corp 1' },
|
||||||
|
{ id: 'org_2', name: 'Tech Corp 2' },
|
||||||
|
{ id: 'org_3', name: 'Tech Corp 3' },
|
||||||
|
{ id: 'org_4', name: 'Media LLC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({ search: 'Tech', pageIndex: 0, pageSize: 2 });
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when soft-deleted organizations exist, they are excluded from the results', async () => {
|
||||||
|
const { db } = await createInMemoryDatabase({
|
||||||
|
users: [{ id: 'user_1', email: 'user1@test.com' }],
|
||||||
|
organizations: [
|
||||||
|
{ id: 'org_1', name: 'Active Org', createdAt: new Date('2025-01-02') },
|
||||||
|
{ id: 'org_2', name: 'Deleted Org', createdAt: new Date('2025-01-03'), deletedAt: new Date('2025-05-15'), deletedBy: 'user_1', scheduledPurgeAt: new Date('2025-06-15') },
|
||||||
|
{ id: 'org_3', name: 'Another Active Org', createdAt: new Date('2025-01-01') },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||||
|
|
||||||
|
const result = await listOrganizations({});
|
||||||
|
|
||||||
|
expect(result.organizations).to.have.length(2);
|
||||||
|
expect(result.totalCount).to.equal(2);
|
||||||
|
expect(result.organizations.map(org => org.name)).to.deep.equal([
|
||||||
|
'Active Org',
|
||||||
|
'Another Active Org',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import type { Database } from '../app/database/database.types';
|
|||||||
import type { DbInsertableOrganization, OrganizationInvitationStatus, OrganizationRole } from './organizations.types';
|
import type { DbInsertableOrganization, OrganizationInvitationStatus, OrganizationRole } from './organizations.types';
|
||||||
import { injectArguments } from '@corentinth/chisels';
|
import { injectArguments } from '@corentinth/chisels';
|
||||||
import { addDays, startOfDay } from 'date-fns';
|
import { addDays, startOfDay } from 'date-fns';
|
||||||
import { and, count, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
|
import { and, count, desc, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
|
||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
|
import { withPagination } from '../shared/db/pagination';
|
||||||
import { omitUndefined } from '../shared/utils';
|
import { omitUndefined } from '../shared/utils';
|
||||||
import { usersTable } from '../users/users.table';
|
import { usersTable } from '../users/users.table';
|
||||||
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
|
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
|
||||||
import { createOrganizationNotFoundError } from './organizations.errors';
|
import { createOrganizationNotFoundError } from './organizations.errors';
|
||||||
import { ensureInvitationStatus } from './organizations.repository.models';
|
import { createSearchOrganizationWhereClause, ensureInvitationStatus } from './organizations.repository.models';
|
||||||
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
|
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
|
||||||
|
|
||||||
export type OrganizationsRepository = ReturnType<typeof createOrganizationsRepository>;
|
export type OrganizationsRepository = ReturnType<typeof createOrganizationsRepository>;
|
||||||
@@ -50,6 +51,7 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
|
|||||||
getUserDeletedOrganizations,
|
getUserDeletedOrganizations,
|
||||||
getExpiredSoftDeletedOrganizations,
|
getExpiredSoftDeletedOrganizations,
|
||||||
getOrganizationCount,
|
getOrganizationCount,
|
||||||
|
listOrganizations,
|
||||||
},
|
},
|
||||||
{ db },
|
{ db },
|
||||||
);
|
);
|
||||||
@@ -553,3 +555,52 @@ async function getOrganizationCount({ db }: { db: Database }) {
|
|||||||
organizationCount,
|
organizationCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listOrganizations({
|
||||||
|
db,
|
||||||
|
search,
|
||||||
|
pageIndex = 0,
|
||||||
|
pageSize = 25,
|
||||||
|
}: {
|
||||||
|
db: Database;
|
||||||
|
search?: string;
|
||||||
|
pageIndex?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
|
const searchWhereClause = createSearchOrganizationWhereClause({ search });
|
||||||
|
const whereClause = searchWhereClause
|
||||||
|
? and(searchWhereClause, isNull(organizationsTable.deletedAt))
|
||||||
|
: isNull(organizationsTable.deletedAt);
|
||||||
|
|
||||||
|
const query = db
|
||||||
|
.select({
|
||||||
|
...getTableColumns(organizationsTable),
|
||||||
|
memberCount: count(organizationMembersTable.id),
|
||||||
|
})
|
||||||
|
.from(organizationsTable)
|
||||||
|
.leftJoin(
|
||||||
|
organizationMembersTable,
|
||||||
|
eq(organizationsTable.id, organizationMembersTable.organizationId),
|
||||||
|
)
|
||||||
|
.where(whereClause)
|
||||||
|
.groupBy(organizationsTable.id)
|
||||||
|
.$dynamic();
|
||||||
|
|
||||||
|
const organizations = await withPagination(query, {
|
||||||
|
orderByColumn: desc(organizationsTable.createdAt),
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ totalCount = 0 } = {}] = await db
|
||||||
|
.select({ totalCount: count() })
|
||||||
|
.from(organizationsTable)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizations,
|
||||||
|
totalCount,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Config } from '../config/config.types';
|
|||||||
import type { OrganizationPlanRecord } from './plans.types';
|
import type { OrganizationPlanRecord } from './plans.types';
|
||||||
import { injectArguments } from '@corentinth/chisels';
|
import { injectArguments } from '@corentinth/chisels';
|
||||||
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
|
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
|
||||||
|
import { IN_BYTES } from '../shared/units';
|
||||||
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from './plans.constants';
|
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from './plans.constants';
|
||||||
import { createPlanNotFoundError } from './plans.errors';
|
import { createPlanNotFoundError } from './plans.errors';
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
|||||||
id: FREE_PLAN_ID,
|
id: FREE_PLAN_ID,
|
||||||
name: 'Free',
|
name: 'Free',
|
||||||
limits: {
|
limits: {
|
||||||
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MiB
|
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 500 * IN_BYTES.MEGABYTE,
|
||||||
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
|
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
|
||||||
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
|
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
|
||||||
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
|
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
|
||||||
@@ -42,10 +43,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
|||||||
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
|
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
|
||||||
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
|
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
|
||||||
limits: {
|
limits: {
|
||||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
|
maxDocumentStorageBytes: 5 * IN_BYTES.GIGABYTE, // 5 GiB
|
||||||
maxIntakeEmailsCount: 10,
|
maxIntakeEmailsCount: 10,
|
||||||
maxOrganizationsMembersCount: 10,
|
maxOrganizationsMembersCount: 10,
|
||||||
maxFileSize: 1024 * 1024 * 100, // 100 MiB
|
maxFileSize: 100 * IN_BYTES.MEGABYTE, // 100 MiB
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[PRO_PLAN_ID]: {
|
[PRO_PLAN_ID]: {
|
||||||
@@ -54,10 +55,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
|||||||
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
|
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
|
||||||
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
|
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
|
||||||
limits: {
|
limits: {
|
||||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 50, // 50 GiB
|
maxDocumentStorageBytes: 50 * IN_BYTES.GIGABYTE, // 50 GiB
|
||||||
maxIntakeEmailsCount: 100,
|
maxIntakeEmailsCount: 100,
|
||||||
maxOrganizationsMembersCount: 50,
|
maxOrganizationsMembersCount: 50,
|
||||||
maxFileSize: 1024 * 1024 * 500, // 500 MiB
|
maxFileSize: 500 * IN_BYTES.MEGABYTE, // 500 MiB
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
54
apps/papra-server/src/modules/shared/db/sql.helpers.test.ts
Normal file
54
apps/papra-server/src/modules/shared/db/sql.helpers.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { escapeLikeWildcards } from './sql.helpers';
|
||||||
|
|
||||||
|
describe('sql helpers', () => {
|
||||||
|
describe('escapeLikeWildcards', () => {
|
||||||
|
test('when input contains percent sign, it is escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('hello%world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello\\%world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains underscore, it is escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('hello_world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello\\_world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains both percent and underscore, both are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('test%value_name');
|
||||||
|
|
||||||
|
expect(result).to.equal('test\\%value\\_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains multiple wildcards, all are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('%%__%%');
|
||||||
|
|
||||||
|
expect(result).to.equal('\\%\\%\\_\\_\\%\\%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains backslashes, they are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('hello\\world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello\\\\world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains backslashes and wildcards, all are escaped', () => {
|
||||||
|
const result = escapeLikeWildcards('test\\%value');
|
||||||
|
|
||||||
|
expect(result).to.equal('test\\\\\\%value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input contains no wildcards, it is returned unchanged', () => {
|
||||||
|
const result = escapeLikeWildcards('hello world');
|
||||||
|
|
||||||
|
expect(result).to.equal('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when input is empty string, empty string is returned', () => {
|
||||||
|
const result = escapeLikeWildcards('');
|
||||||
|
|
||||||
|
expect(result).to.equal('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
3
apps/papra-server/src/modules/shared/db/sql.helpers.ts
Normal file
3
apps/papra-server/src/modules/shared/db/sql.helpers.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function escapeLikeWildcards(input: string): string {
|
||||||
|
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
|
||||||
|
}
|
||||||
16
apps/papra-server/src/modules/shared/units.ts
Normal file
16
apps/papra-server/src/modules/shared/units.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const IN_MS = {
|
||||||
|
SECOND: 1_000,
|
||||||
|
MINUTE: 60_000, // 60 * 1_000
|
||||||
|
HOUR: 3_600_000, // 60 * 60 * 1_000
|
||||||
|
DAY: 86_400_000, // 24 * 60 * 60 * 1_000
|
||||||
|
WEEK: 604_800_000, // 7 * 24 * 60 * 60 * 1_000
|
||||||
|
MONTH: 2_630_016_000, // 30.44 * 24 * 60 * 60 * 1_000 -- approximation using average month length
|
||||||
|
YEAR: 31_556_736_000, // 365.24 * 24 * 60 * 60 * 1_000 -- approximation using average year length
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IN_BYTES = {
|
||||||
|
KILOBYTE: 1_024,
|
||||||
|
MEGABYTE: 1_048_576, // 1_024 * 1_024
|
||||||
|
GIGABYTE: 1_073_741_824, // 1_024 * 1_024 * 1_024
|
||||||
|
TERABYTE: 1_099_511_627_776, // 1_024 * 1_024 * 1_024 * 1_024
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { ConfigDefinition } from 'figue';
|
|||||||
import type { TasksDriverName } from './drivers/tasks-driver.constants';
|
import type { TasksDriverName } from './drivers/tasks-driver.constants';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { booleanishSchema } from '../config/config.schemas';
|
import { booleanishSchema } from '../config/config.schemas';
|
||||||
|
import { IN_MS } from '../shared/units';
|
||||||
import { tasksDriverNames } from './drivers/tasks-driver.constants';
|
import { tasksDriverNames } from './drivers/tasks-driver.constants';
|
||||||
|
|
||||||
export const tasksConfig = {
|
export const tasksConfig = {
|
||||||
@@ -35,7 +36,7 @@ export const tasksConfig = {
|
|||||||
pollIntervalMs: {
|
pollIntervalMs: {
|
||||||
doc: 'The interval at which the task persistence driver polls for new tasks',
|
doc: 'The interval at which the task persistence driver polls for new tasks',
|
||||||
schema: z.coerce.number().int().positive(),
|
schema: z.coerce.number().int().positive(),
|
||||||
default: 1_000,
|
default: 1 * IN_MS.SECOND,
|
||||||
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
|
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import { escapeLikeWildcards } from './users.repository.models';
|
|
||||||
|
|
||||||
describe('users repository models', () => {
|
|
||||||
describe('escapeLikeWildcards', () => {
|
|
||||||
test('escape % and _ characters by prefixing them with a backslash', () => {
|
|
||||||
expect(escapeLikeWildcards('100%_sure')).to.eql('100\\%\\_sure');
|
|
||||||
|
|
||||||
expect(escapeLikeWildcards('hello')).to.eql('hello');
|
|
||||||
expect(escapeLikeWildcards(' ')).to.eql(' ');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('backslashes are also escaped', () => {
|
|
||||||
expect(escapeLikeWildcards('C:\\path\\to\\file_%')).to.eql('C:\\\\path\\\\to\\\\file\\_\\%');
|
|
||||||
expect(escapeLikeWildcards('\\%_')).to.eql('\\\\\\%\\_');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { eq, like, or } from 'drizzle-orm';
|
import { eq, like, or } from 'drizzle-orm';
|
||||||
|
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
|
||||||
import { isNilOrEmptyString } from '../shared/utils';
|
import { isNilOrEmptyString } from '../shared/utils';
|
||||||
import { USER_ID_REGEX } from './users.constants';
|
import { USER_ID_REGEX } from './users.constants';
|
||||||
import { usersTable } from './users.table';
|
import { usersTable } from './users.table';
|
||||||
|
|
||||||
export function escapeLikeWildcards(input: string) {
|
|
||||||
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSearchUserWhereClause({ search }: { search?: string }) {
|
export function createSearchUserWhereClause({ search }: { search?: string }) {
|
||||||
const trimmedSearch = search?.trim();
|
const trimmedSearch = search?.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ describe('users repository', () => {
|
|||||||
test('when searching by user ID, only the exact matching user is returned', async () => {
|
test('when searching by user ID, only the exact matching user is returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_123456789012345678901234', email: 'alice@example.com', name: 'Alice' },
|
{ id: 'usr_123456789012345678901234', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_abcdefghijklmnopqrstuvwx', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_abcdefghijklmnopqrstuvwx', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -113,9 +113,9 @@ describe('users repository', () => {
|
|||||||
test('when searching by partial email, matching users are returned', async () => {
|
test('when searching by partial email, matching users are returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice' },
|
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'alice.smith@test.com', name: 'Alice Smith' },
|
{ id: 'usr_3', email: 'alice.smith@test.com', name: 'Alice Smith', createdAt: new Date('2025-01-03') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -125,17 +125,17 @@ describe('users repository', () => {
|
|||||||
expect(result.users).to.have.length(2);
|
expect(result.users).to.have.length(2);
|
||||||
expect(result.totalCount).to.equal(2);
|
expect(result.totalCount).to.equal(2);
|
||||||
expect(result.users.map(u => u.email)).to.deep.equal([
|
expect(result.users.map(u => u.email)).to.deep.equal([
|
||||||
'alice@example.com',
|
|
||||||
'alice.smith@test.com',
|
'alice.smith@test.com',
|
||||||
|
'alice@example.com',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when searching by partial name, matching users are returned', async () => {
|
test('when searching by partial name, matching users are returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice Johnson' },
|
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice Johnson', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob Smith' },
|
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob Smith', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'charlie@example.com', name: 'Charlie Johnson' },
|
{ id: 'usr_3', email: 'charlie@example.com', name: 'Charlie Johnson', createdAt: new Date('2025-01-03') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -145,16 +145,16 @@ describe('users repository', () => {
|
|||||||
expect(result.users).to.have.length(2);
|
expect(result.users).to.have.length(2);
|
||||||
expect(result.totalCount).to.equal(2);
|
expect(result.totalCount).to.equal(2);
|
||||||
expect(result.users.map(u => u.name)).to.deep.equal([
|
expect(result.users.map(u => u.name)).to.deep.equal([
|
||||||
'Alice Johnson',
|
|
||||||
'Charlie Johnson',
|
'Charlie Johnson',
|
||||||
|
'Alice Johnson',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when searching with an empty string, all users are returned', async () => {
|
test('when searching with an empty string, all users are returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice' },
|
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -168,11 +168,11 @@ describe('users repository', () => {
|
|||||||
test('when using pagination, only the requested page is returned', async () => {
|
test('when using pagination, only the requested page is returned', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'user1@example.com', name: 'User 1' },
|
{ id: 'usr_1', email: 'user1@example.com', name: 'User 1', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'user2@example.com', name: 'User 2' },
|
{ id: 'usr_2', email: 'user2@example.com', name: 'User 2', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'user3@example.com', name: 'User 3' },
|
{ id: 'usr_3', email: 'user3@example.com', name: 'User 3', createdAt: new Date('2025-01-03') },
|
||||||
{ id: 'usr_4', email: 'user4@example.com', name: 'User 4' },
|
{ id: 'usr_4', email: 'user4@example.com', name: 'User 4', createdAt: new Date('2025-01-04') },
|
||||||
{ id: 'usr_5', email: 'user5@example.com', name: 'User 5' },
|
{ id: 'usr_5', email: 'user5@example.com', name: 'User 5', createdAt: new Date('2025-01-05') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
@@ -190,10 +190,10 @@ describe('users repository', () => {
|
|||||||
test('when searching with pagination, the total count reflects the search results', async () => {
|
test('when searching with pagination, the total count reflects the search results', async () => {
|
||||||
const { db } = await createInMemoryDatabase({
|
const { db } = await createInMemoryDatabase({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'usr_1', email: 'alice1@example.com', name: 'Alice 1' },
|
{ id: 'usr_1', email: 'alice1@example.com', name: 'Alice 1', createdAt: new Date('2025-01-01') },
|
||||||
{ id: 'usr_2', email: 'alice2@example.com', name: 'Alice 2' },
|
{ id: 'usr_2', email: 'alice2@example.com', name: 'Alice 2', createdAt: new Date('2025-01-02') },
|
||||||
{ id: 'usr_3', email: 'alice3@example.com', name: 'Alice 3' },
|
{ id: 'usr_3', email: 'alice3@example.com', name: 'Alice 3', createdAt: new Date('2025-01-03') },
|
||||||
{ id: 'usr_4', email: 'bob@example.com', name: 'Bob' },
|
{ id: 'usr_4', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-04') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const { listUsers } = createUsersRepository({ db });
|
const { listUsers } = createUsersRepository({ db });
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @papra/api-sdk
|
# @papra/api-sdk
|
||||||
|
|
||||||
|
## 1.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#698](https://github.com/papra-hq/papra/pull/698) [`815f6f9`](https://github.com/papra-hq/papra/commit/815f6f94f84478fef049f9baea9b0b30b56906a2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed prepublishing assets
|
||||||
|
|
||||||
## 1.1.2
|
## 1.1.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@papra/api-sdk",
|
"name": "@papra/api-sdk",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"description": "Api SDK for Papra, the document archiving platform.",
|
"description": "Api SDK for Papra, the document archiving platform.",
|
||||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsdown",
|
"build": "tsdown",
|
||||||
"build:watch": "tsdown --watch",
|
"build:watch": "tsdown --watch",
|
||||||
"dev": "pnpm build:watch"
|
"dev": "pnpm build:watch",
|
||||||
|
"prepublishOnly": "pnpm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corentinth/chisels": "catalog:",
|
"@corentinth/chisels": "catalog:",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# @papra/cli
|
# @papra/cli
|
||||||
|
|
||||||
|
## 0.2.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [[`815f6f9`](https://github.com/papra-hq/papra/commit/815f6f94f84478fef049f9baea9b0b30b56906a2)]:
|
||||||
|
- @papra/api-sdk@1.1.3
|
||||||
|
|
||||||
## 0.2.1
|
## 0.2.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@papra/cli",
|
"name": "@papra/cli",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"description": "Command line interface for Papra, the document archiving platform.",
|
"description": "Command line interface for Papra, the document archiving platform.",
|
||||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @papra/webhooks
|
# @papra/webhooks
|
||||||
|
|
||||||
|
## 0.3.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#699](https://github.com/papra-hq/papra/pull/699) [`4342b31`](https://github.com/papra-hq/papra/commit/4342b319ea1b787b80d02090f3820797b928e115) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix mising prepublish script
|
||||||
|
|
||||||
## 0.3.1
|
## 0.3.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@papra/webhooks",
|
"name": "@papra/webhooks",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "Webhooks helper library for Papra, the document archiving platform.",
|
"description": "Webhooks helper library for Papra, the document archiving platform.",
|
||||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsdown",
|
"build": "tsdown",
|
||||||
"build:watch": "tsdown --watch",
|
"build:watch": "tsdown --watch",
|
||||||
"dev": "pnpm build:watch"
|
"dev": "pnpm build:watch",
|
||||||
|
"prepublishOnly": "pnpm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corentinth/chisels": "catalog:",
|
"@corentinth/chisels": "catalog:",
|
||||||
|
|||||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@@ -134,7 +134,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/expo':
|
'@better-auth/expo':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))
|
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))
|
||||||
'@corentinth/chisels':
|
'@corentinth/chisels':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
@@ -169,8 +169,8 @@ importers:
|
|||||||
specifier: ~18.0.10
|
specifier: ~18.0.10
|
||||||
version: 18.0.10(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
version: 18.0.10(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
||||||
expo-document-picker:
|
expo-document-picker:
|
||||||
specifier: ^14.0.7
|
specifier: ^14.0.8
|
||||||
version: 14.0.7(expo@54.0.23)
|
version: 14.0.8(expo@54.0.23)
|
||||||
expo-file-system:
|
expo-file-system:
|
||||||
specifier: ^19.0.19
|
specifier: ^19.0.19
|
||||||
version: 19.0.19(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
version: 19.0.19(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
||||||
@@ -186,6 +186,9 @@ importers:
|
|||||||
expo-linking:
|
expo-linking:
|
||||||
specifier: ~8.0.8
|
specifier: ~8.0.8
|
||||||
version: 8.0.8(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
version: 8.0.8(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
|
expo-network:
|
||||||
|
specifier: ^8.0.8
|
||||||
|
version: 8.0.8(expo@54.0.23)(react@19.1.0)
|
||||||
expo-router:
|
expo-router:
|
||||||
specifier: ~6.0.14
|
specifier: ~6.0.14
|
||||||
version: 6.0.14(49a2902d0574a0052a669c80d9729e84)
|
version: 6.0.14(49a2902d0574a0052a669c80d9729e84)
|
||||||
@@ -262,6 +265,9 @@ importers:
|
|||||||
eslint-config-expo:
|
eslint-config-expo:
|
||||||
specifier: ~10.0.0
|
specifier: ~10.0.0
|
||||||
version: 10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
version: 10.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
reactotron-react-native:
|
||||||
|
specifier: ^5.1.18
|
||||||
|
version: 5.1.18(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -397,7 +403,7 @@ importers:
|
|||||||
version: 12.27.0
|
version: 12.27.0
|
||||||
'@better-auth/expo':
|
'@better-auth/expo':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.67))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))
|
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.67))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))
|
||||||
'@cadence-mq/core':
|
'@cadence-mq/core':
|
||||||
specifier: ^0.2.1
|
specifier: ^0.2.1
|
||||||
version: 0.2.1
|
version: 0.2.1
|
||||||
@@ -7339,8 +7345,8 @@ packages:
|
|||||||
expo: '*'
|
expo: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
expo-document-picker@14.0.7:
|
expo-document-picker@14.0.8:
|
||||||
resolution: {integrity: sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==}
|
resolution: {integrity: sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
@@ -7395,6 +7401,12 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
expo-network@8.0.8:
|
||||||
|
resolution: {integrity: sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
react: '*'
|
||||||
|
|
||||||
expo-router@6.0.14:
|
expo-router@6.0.14:
|
||||||
resolution: {integrity: sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw==}
|
resolution: {integrity: sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9303,6 +9315,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
mitt@3.0.1:
|
||||||
|
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||||
|
|
||||||
mkdirp-classic@0.5.3:
|
mkdirp-classic@0.5.3:
|
||||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||||
|
|
||||||
@@ -10171,6 +10186,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reactotron-core-client@2.9.9:
|
||||||
|
resolution: {integrity: sha512-caI6ZWpfV/gNeCBhm6alxuCpOxYEFxg9FsgVaOXJWZeKjK2egjxkXcUEFWeS3b9mC6Kwi7Wuu79zfeGtkbdAYw==}
|
||||||
|
|
||||||
|
reactotron-core-contract@0.3.2:
|
||||||
|
resolution: {integrity: sha512-39ZNLfxxV7WEOszekIRg56F6VFZrQk7+Lei+8MccNESX3VbQzs1uex/GNesxk6CMTS6ytwqYIBAgLyc9WX/PaA==}
|
||||||
|
|
||||||
|
reactotron-react-native@5.1.18:
|
||||||
|
resolution: {integrity: sha512-Nyb/w7tRQICtfvjAJSN6Q/3aRO8L6eTfEcWpDR/atUyoINkyzducMx6yXA+sz1mazxivY16N3VPkmQ1XohQAcg==}
|
||||||
|
peerDependencies:
|
||||||
|
react-native: '>=0.40.0'
|
||||||
|
|
||||||
read-yaml-file@1.1.0:
|
read-yaml-file@1.1.0:
|
||||||
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
|
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -13692,7 +13718,7 @@ snapshots:
|
|||||||
nanostores: 1.0.1
|
nanostores: 1.0.1
|
||||||
zod: 4.1.12
|
zod: 4.1.12
|
||||||
|
|
||||||
'@better-auth/expo@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.67))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))':
|
'@better-auth/expo@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.67))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.67))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.25.67))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
||||||
'@better-fetch/fetch': 1.1.18
|
'@better-fetch/fetch': 1.1.18
|
||||||
@@ -13702,9 +13728,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
expo-constants: 18.0.10(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
expo-constants: 18.0.10(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
||||||
expo-linking: 8.0.8(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
expo-linking: 8.0.8(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
|
expo-network: 8.0.8(expo@54.0.23)(react@19.1.0)
|
||||||
expo-web-browser: 15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
expo-web-browser: 15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
||||||
|
|
||||||
'@better-auth/expo@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))':
|
'@better-auth/expo@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))(better-auth@1.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.9)(vue@3.5.13(typescript@5.9.3)))(expo-constants@18.0.10)(expo-linking@8.0.8)(expo-network@8.0.8(expo@54.0.23)(react@19.1.0))(expo-web-browser@15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
'@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
||||||
'@better-fetch/fetch': 1.1.18
|
'@better-fetch/fetch': 1.1.18
|
||||||
@@ -13714,6 +13741,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
expo-constants: 18.0.10(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
expo-constants: 18.0.10(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
||||||
expo-linking: 8.0.8(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
expo-linking: 8.0.8(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
|
expo-network: 8.0.8(expo@54.0.23)(react@19.1.0)
|
||||||
expo-web-browser: 15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
expo-web-browser: 15.0.9(expo@54.0.23)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))
|
||||||
|
|
||||||
'@better-auth/telemetry@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))':
|
'@better-auth/telemetry@1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1))':
|
||||||
@@ -20405,7 +20433,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
expo-document-picker@14.0.7(expo@54.0.23):
|
expo-document-picker@14.0.8(expo@54.0.23):
|
||||||
dependencies:
|
dependencies:
|
||||||
expo: 54.0.23(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.14)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
expo: 54.0.23(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.14)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
|
|
||||||
@@ -20462,6 +20490,11 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-native: 0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)
|
react-native: 0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
|
||||||
|
expo-network@8.0.8(expo@54.0.23)(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
expo: 54.0.23(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.14)(graphql@16.8.1)(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
expo-router@6.0.14(49a2902d0574a0052a669c80d9729e84):
|
expo-router@6.0.14(49a2902d0574a0052a669c80d9729e84):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@expo/metro-runtime': 6.1.2(expo@54.0.23)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
'@expo/metro-runtime': 6.1.2(expo@54.0.23)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
@@ -23121,6 +23154,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minipass: 7.1.2
|
minipass: 7.1.2
|
||||||
|
|
||||||
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
mkdirp-classic@0.5.3: {}
|
mkdirp-classic@0.5.3: {}
|
||||||
|
|
||||||
mkdirp@0.5.6:
|
mkdirp@0.5.6:
|
||||||
@@ -24053,6 +24088,18 @@ snapshots:
|
|||||||
|
|
||||||
react@19.1.0: {}
|
react@19.1.0: {}
|
||||||
|
|
||||||
|
reactotron-core-client@2.9.9:
|
||||||
|
dependencies:
|
||||||
|
reactotron-core-contract: 0.3.2
|
||||||
|
|
||||||
|
reactotron-core-contract@0.3.2: {}
|
||||||
|
|
||||||
|
reactotron-react-native@5.1.18(react-native@0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)):
|
||||||
|
dependencies:
|
||||||
|
mitt: 3.0.1
|
||||||
|
react-native: 0.81.5(@babel/core@7.28.4)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
reactotron-core-client: 2.9.9
|
||||||
|
|
||||||
read-yaml-file@1.1.0:
|
read-yaml-file@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|||||||
Reference in New Issue
Block a user