Compare commits

...

20 Commits

Author SHA1 Message Date
Corentin Thomasset
d795798931 feat(app): admin pages (#689) 2025-12-16 23:50:25 +00:00
Corentin Thomasset
95662d025f refactor(auth): update better auth to 1.4 (#686) 2025-12-12 17:33:32 +01:00
Jibran Iqbal
9d9be949b0 feat(mobile): in-app document viewer (#667)
* Keep the phone logged in

* Added document sheet

* Make button outline

* Fix colors

* Design accroding to base design

* Design accroding to base design

* Added download and share

* Added view document screen

* Added view document screen

* Screen is launching

* fix toolbar issue

* Fix the button

* fix all the conflicts

* revert unncessary bloat

* remove packages

* pnpnm i

* Fixed some issues

* Added error state

* added header in loading and error state

* Removed duplication  in render

* added correct name of app

* Update apps/mobile/app.json

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

---------

Co-authored-by: jibraniqbal666 <jibran.iqbal@protonmail.com>
Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-12-12 17:27:36 +01:00
Corentin Thomasset
cf91515cfe feat(search): implement asynchronous document indexing and synchronization (#685)
* feat(search): implement asynchronous document indexing and synchronization

* Update .changeset/chilly-queens-knock.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .changeset/chilly-queens-knock.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 15:06:59 +00:00
Corentin Thomasset
d6f71ba5ec refactor(search): removed database fts triggers (#683) 2025-12-11 15:13:40 +01:00
Corentin Thomasset
5bdb7c06bf feat(documents): add document.deleted event (#684) 2025-12-11 01:30:58 +01:00
Corentin Thomasset
2872c979fa refactor(server): add restore document usecase with event (#681) 2025-12-07 23:24:27 +01:00
Corentin Thomasset
23e66aeadf fix(ci): run linters in quiet mode to reduce output noise (#680) 2025-12-06 22:29:37 +00:00
Corentin Thomasset
6f38659638 fix(client): properly load default fie icon (#679) 2025-12-06 22:40:01 +01:00
Corentin Thomasset
e3e0078673 feat(documents): use update usecase when content extraction (#678) 2025-12-06 21:51:09 +01:00
Corentin Thomasset
2cf86e5968 feat(documents): add document.updated internal event (#674) 2025-12-06 17:31:44 +01:00
Corentin Thomasset
76a72ace8d fix(i18n): correct capitalization in zh changeset (#677) 2025-12-06 14:03:14 +00:00
Corentin Thomasset
17d6e9aa6a feat(i18n): add Chinese (zh) translations (#673) (#675)
* feat(i18n): add Chinese (zh) translations (#673)

* feat(i18n): add Chinese (zh) translations

* chore(changeset): chinese language support

Updated the translation support to include simplified Chinese.

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* refactor(client): linted locales

---------

Co-authored-by: TMs <tms@imtms.com>
2025-12-06 13:55:37 +00:00
Corentin Thomasset
f488e63c38 feat(documents): added document.trashed internal event (#671) 2025-12-04 16:34:59 +01:00
Corentin Thomasset
0092e530b7 refactor(server): introduced document.created event (#670) 2025-12-03 23:57:55 +01:00
Corentin Thomasset
364b58b74d chore(server): updated hono related deps (#669) 2025-12-02 22:55:32 +01:00
Corentin Thomasset
d08cf2b195 chore(deps): updated linting dependencies (#668) 2025-12-02 19:57:32 +01:00
Corentin Thomasset
fcd440cbbb feat(server): refactor server initialization and DI management (#666) 2025-12-02 18:22:25 +01:00
Jibran Iqbal
d588e417c9 feat(mobile): add view document screen (#651)
* Keep the phone logged in

* Added document sheet

* Make button outline

* Fix colors

* Design accroding to base design

* Design accroding to base design

* Added download and share

* fix the login issue again

* fix the login issue again

* Fixed copilot suggestions

* Update document-action-sheet.tsx

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* Update documents-list.screen.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* change to cacheDirectory

* rewrite auth logic

* Ran pnpm lint:fix

* Update apps/mobile/src/modules/documents/documents.services.ts

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* fix all lint

* fix type issues

* fix lint issues

* fix types issues

* fix types issues

* fix lint issues

* fix type issues

* fix type issues

---------

Co-authored-by: jibraniqbal666 <jibran.iqbal@protonmail.com>
Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 18:19:07 +01:00
Corentin Thomasset
ca06919bb8 feat(server): introduced event driven pattern (#665) 2025-12-02 13:59:05 +01:00
134 changed files with 6049 additions and 1615 deletions

View File

@@ -0,0 +1,7 @@
---
"@papra/docker": patch
---
Document search indexing and synchronization is now asynchronous, and no longer relies on database triggers.
This significantly improves the responsiveness of the application when adding, updating, trashing, restoring, or deleting documents. It's even more noticeable when dealing with a large number of documents or on low-end hardware.

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Enforcing the auth secret to be at least 32 characters long for security reasons

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Now throw an error if AUTH_SECRET is not set in production mode

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Added a platform administration dashboard

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Added support for Simplified Chinese language

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Fixed an issue where the document icon didn't load for unknown file types

View File

@@ -29,7 +29,7 @@ jobs:
run: pnpm -r --parallel -F "./packages/*" build
- name: Run linters
run: pnpm -r --parallel lint
run: pnpm -r --parallel lint --quiet
- name: Type check
# Exclude docs as their are some typing issues we are ok with for now

View File

@@ -1,7 +1,7 @@
{
"expo": {
"name": "mobile",
"slug": "mobile",
"name": "Papra",
"slug": "papra",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./src/assets/images/icon.png",
@@ -19,7 +19,8 @@
"monochromeImage": "./src/assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"package": "app.papra.android"
},
"web": {
"output": "static",

View File

@@ -11,6 +11,14 @@ export default function RootLayout() {
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="auth/signup" options={{ headerShown: false }} />
<Stack.Screen name="(with-organizations)" options={{ headerShown: false }} />
<Stack.Screen
name="document/view"
options={{
headerShown: false,
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
</Stack>
<StatusBar style="auto" />
</ApiProvider>

View File

@@ -0,0 +1,3 @@
import DocumentViewScreen from '@/modules/documents-actions/screens/document-view.screen';
export default DocumentViewScreen;

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Redirect } from 'expo-router';
import { createAuthClient } from '@/modules/auth/auth.client';
import { configLocalStorage } from '@/modules/config/config.local-storage';
export default function Index() {
@@ -17,6 +18,11 @@ export default function Index() {
return <Redirect href="/config/server-selection" />;
}
const authClient = createAuthClient({ baseUrl: query.data });
if (authClient.getCookie()) {
return <Redirect href="/(app)/(with-organizations)/(tabs)/list" />;
}
return <Redirect href="/auth/login" />;
};

View File

@@ -1,13 +1,14 @@
{
"name": "mobile",
"type": "module",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "pnpm start",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@@ -36,6 +37,7 @@
"expo-linking": "~8.0.8",
"expo-router": "~6.0.14",
"expo-secure-store": "^15.0.7",
"expo-sharing": "^14.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
@@ -46,6 +48,7 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-pdf": "^7.0.3",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",

View File

@@ -1,12 +1,12 @@
type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt' | 'expiresAt' | 'lastTriggeredAt' | 'lastUsedAt' | 'scheduledPurgeAt';
type CoerceDate<T> = T extends string | Date
export type CoerceDate<T> = T extends string | Date
? Date
: T extends string | Date | null | undefined
? Date | undefined
: T;
type CoerceDates<T> = {
export type CoerceDates<T> = {
[K in keyof T]: K extends DateKeys ? CoerceDate<T[K]> : T[K];
};

View File

@@ -0,0 +1,335 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as Sharing from 'expo-sharing';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { fetchDocumentFile } from '@/modules/documents/documents.services';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentActionSheetProps = {
visible: boolean;
document: CoerceDates<Document> | undefined;
onClose: () => void;
};
export function DocumentActionSheet({
visible,
document,
onClose,
}: DocumentActionSheetProps) {
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const authClient = useAuthClient();
if (document === undefined) {
return null;
}
// Check if document can be viewed in DocumentViewerScreen
// Supported types: images (image/*) and PDFs (application/pdf)
const isViewable
= document.mimeType.startsWith('image/')
|| document.mimeType.startsWith('application/pdf');
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const handleView = async () => {
onClose();
router.push({
pathname: '/(app)/document/view',
params: {
documentId: document.id,
organizationId: document.organizationId,
},
});
};
const handleDownloadAndShare = async () => {
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
showAlert({
title: 'Error',
message: 'Base URL not found',
});
return;
}
const canShare = await Sharing.isAvailableAsync();
if (!canShare) {
showAlert({
title: 'Sharing Failed',
message: 'Sharing is not available on this device. Please share the document manually.',
});
return;
}
try {
const fileUri = await fetchDocumentFile({
document,
organizationId: document.organizationId,
baseUrl,
authClient,
});
await Sharing.shareAsync(fileUri);
} catch (error) {
console.error('Error downloading document file:', error);
showAlert({
title: 'Error',
message: 'Failed to download document file',
});
}
};
// Extract MIME type subtype, fallback to full MIME type if subtype is missing
const mimeParts = document.mimeType.split('/');
const mimeSubtype = mimeParts[1];
const displayMimeType = mimeSubtype != null && mimeSubtype !== '' ? mimeSubtype : document.mimeType;
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.overlay}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
{/* Handle bar */}
<View style={styles.handleBar} />
{/* Document info */}
<View style={styles.documentInfo}>
<Text style={styles.documentName} numberOfLines={2}>
{document.name}
</Text>
{/* Document details */}
<View style={styles.detailsContainer}>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="file"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText}>{formatFileSize(document.originalSize)}</Text>
</View>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="calendar"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText}>{formatDate(document.createdAt.toISOString())}</Text>
</View>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="file-document-outline"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText} numberOfLines={1}>
{displayMimeType}
</Text>
</View>
</View>
</View>
{/* Action buttons */}
<View style={styles.actions}>
{isViewable && (
<TouchableOpacity
style={styles.actionButton}
onPress={async () => {
onClose();
await handleView();
}}
activeOpacity={0.7}
>
<View style={[styles.actionIcon, styles.viewIcon]}>
<MaterialCommunityIcons
name="eye"
size={20}
color={themeColors.primary}
/>
</View>
<Text style={styles.actionText}>View</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.actionButton}
onPress={async () => {
onClose();
await handleDownloadAndShare();
}}
activeOpacity={0.7}
>
<View style={[styles.actionIcon, styles.downloadIcon]}>
<MaterialCommunityIcons
name="download"
size={20}
color={themeColors.primary}
/>
</View>
<Text style={styles.actionText}>Share</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<TouchableOpacity
style={styles.cancelButton}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: themeColors.secondaryBackground,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 34, // Safe area for bottom
paddingTop: 16,
},
handleBar: {
width: 40,
height: 4,
backgroundColor: themeColors.border,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
},
documentInfo: {
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
documentName: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
textAlign: 'center',
marginBottom: 12,
},
detailsContainer: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
gap: 16,
marginTop: 8,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
detailIcon: {
marginRight: 2,
},
detailText: {
fontSize: 12,
color: themeColors.mutedForeground,
},
actions: {
flexDirection: 'row',
paddingHorizontal: 24,
paddingVertical: 16,
gap: 16,
},
actionButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 12,
},
actionIcon: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
viewIcon: {
backgroundColor: `${themeColors.primary}15`,
},
downloadIcon: {
backgroundColor: `${themeColors.primary}15`,
},
actionText: {
fontSize: 14,
fontWeight: '500',
color: themeColors.foreground,
},
cancelButton: {
marginHorizontal: 24,
marginTop: 12,
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 12,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
},
});
}

View File

@@ -0,0 +1,257 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React from 'react';
import {
ActivityIndicator,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Pdf from 'react-native-pdf';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient, useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { fetchDocument, fetchDocumentFile } from '@/modules/documents/documents.services';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentFile = {
uri: string;
doc: CoerceDates<Document>;
};
export default function DocumentViewScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ documentId: string; organizationId: string }>();
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const apiClient = useApiClient();
const authClient = useAuthClient();
const { documentId, organizationId } = params;
const documentQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId],
queryFn: async () => {
if (organizationId == null || documentId == null) {
throw new Error('Organization ID and Document ID are required');
}
return fetchDocument({ organizationId, documentId, apiClient });
},
enabled: organizationId != null && documentId != null,
});
const documentFileQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId, 'file'],
queryFn: async () => {
if (documentQuery.data == null) {
throw new Error('Document not loaded');
}
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
throw new Error('Base URL not found');
}
const fileUri = await fetchDocumentFile({
document: documentQuery.data.document,
organizationId,
baseUrl,
authClient,
});
return {
uri: fileUri,
doc: documentQuery.data.document,
} as DocumentFile;
},
enabled: documentQuery.isSuccess && documentQuery.data != null,
});
const renderHeader = (documentName: string) => {
return (
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<MaterialCommunityIcons
name="close"
size={24}
color={themeColors.foreground}
/>
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>
{documentName}
</Text>
<View style={styles.headerSpacer} />
</View>
);
};
const renderDocumentFile = (file: DocumentFile) => {
if (file.doc.mimeType.startsWith('image/')) {
return (
<Image
source={{ uri: file.uri }}
style={styles.pdfViewer}
/>
);
}
if (file.doc.mimeType.startsWith('application/pdf')) {
return (
<Pdf
source={{ uri: file.uri, cache: true }}
style={styles.pdfViewer}
onError={(error) => {
console.error('PDF error:', error);
showAlert({
title: 'Error',
message: 'Failed to load PDF',
});
}}
enablePaging={true}
horizontal={false}
enableAnnotationRendering={true}
fitPolicy={0}
spacing={10}
/>
);
}
return <View style={styles.pdfViewer} />;
};
const isLoading = documentQuery.isLoading || documentFileQuery.isLoading;
const error = documentQuery.error ?? documentFileQuery.error;
const documentFile = documentFileQuery.data;
const documentName = documentFile?.doc.name ?? 'Document';
return (
<SafeAreaView style={styles.container}>
{renderHeader(documentName)}
{isLoading
? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={themeColors.primary} />
<Text style={styles.loadingText}>Loading document...</Text>
</View>
)
: error != null
? (
<View style={styles.errorContainer}>
<MaterialCommunityIcons
name="file-pdf-box"
size={64}
color={themeColors.mutedForeground}
/>
<Text style={styles.errorText}>Failed to load document</Text>
<TouchableOpacity
style={styles.errorButton}
onPress={() => {
void documentQuery.refetch();
}}
>
<Text style={styles.errorButtonText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.errorButton}
onPress={() => router.back()}
>
<Text style={styles.errorButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
)
: documentFile != null
? (
<View style={styles.pdfContainer}>
{renderDocumentFile(documentFile)}
</View>
)
: null}
</SafeAreaView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: themeColors.secondaryBackground,
justifyContent: 'center',
alignItems: 'flex-start',
},
headerTitle: {
flex: 1,
fontSize: 18,
fontWeight: 'bold',
color: themeColors.foreground,
marginHorizontal: 16,
},
headerSpacer: {
width: 40,
},
pdfContainer: {
flex: 1,
backgroundColor: themeColors.background,
},
pdfViewer: {
flex: 1,
width: '100%',
height: '100%',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: themeColors.mutedForeground,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
errorText: {
fontSize: 18,
color: themeColors.foreground,
marginTop: 16,
marginBottom: 24,
},
errorButton: {
paddingHorizontal: 24,
paddingVertical: 16,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 12,
marginTop: 16,
},
errorButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.primary,
},
});
}

View File

@@ -1,5 +1,8 @@
import type { ApiClient } from '../api/api.client';
import type { CoerceDates } from '../api/api.models';
import type { AuthClient } from '../auth/auth.client';
import type { Document } from './documents.types';
import * as FileSystem from 'expo-file-system/legacy';
import { coerceDates } from '../api/api.models';
export function getFormData(pojo: Record<string, string | Blob>): FormData {
@@ -72,3 +75,53 @@ export async function fetchOrganizationDocuments({
throw error;
}
}
export async function fetchDocument({
organizationId,
documentId,
apiClient,
}: {
organizationId: string;
documentId: string;
apiClient: ApiClient;
}) {
const { document } = await apiClient<{ document: Document }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
});
return {
document: coerceDates(document),
};
}
export async function fetchDocumentFile({
document,
organizationId,
baseUrl,
authClient,
}: {
document: CoerceDates<Document>;
organizationId: string;
baseUrl: string;
authClient: AuthClient;
}) {
const cookies = authClient.getCookie();
const uri = `${baseUrl}/api/organizations/${organizationId}/documents/${document.id}/file`;
const headers = {
'Cookie': cookies,
'Content-Type': 'application/json',
};
// Use cacheDirectory for better app compatibility
const fileUri = `${FileSystem.cacheDirectory}${document.name}`;
// Download the file with authentication headers
const downloadResult = await FileSystem.downloadAsync(uri, fileUri, {
headers,
});
if (downloadResult.status === 200) {
return downloadResult.uri;
} else {
throw new Error(`Download failed with status: ${downloadResult.status}`);
}
}

View File

@@ -1,3 +1,5 @@
import type { Document } from '../documents.types';
import type { CoerceDates } from '@/modules/api/api.models';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
@@ -7,10 +9,12 @@ import {
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient } from '@/modules/api/providers/api.provider';
import { DocumentActionSheet } from '@/modules/documents-actions/components/document-action-sheet';
import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button';
import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer';
import { useOrganizations } from '@/modules/organizations/organizations.provider';
@@ -22,6 +26,7 @@ export function DocumentsListScreen() {
const themeColors = useThemeColor();
const apiClient = useApiClient();
const { currentOrganizationId, isLoading: isLoadingOrganizations } = useOrganizations();
const [onDocumentActionSheet, setOnDocumentActionSheet] = useState<CoerceDates<Document> | undefined>(undefined);
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
const pagination = { pageIndex: 0, pageSize: 20 };
@@ -75,6 +80,13 @@ export function DocumentsListScreen() {
return (
<SafeAreaView style={styles.container}>
{onDocumentActionSheet && (
<DocumentActionSheet
visible={true}
document={onDocumentActionSheet}
onClose={() => setOnDocumentActionSheet(undefined)}
/>
)}
<View style={styles.header}>
<Text style={styles.title}>Documents</Text>
<OrganizationPickerButton onPress={() => setIsDrawerVisible(true)} />
@@ -91,39 +103,40 @@ export function DocumentsListScreen() {
data={documentsQuery.data?.documents ?? []}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<View style={styles.documentCard}>
<View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
<Icon name="file-text" size={24} color={themeColors.primary} />
</View>
<View>
<Text style={styles.documentTitle} numberOfLines={2}>
{item.name}
</Text>
<View style={styles.documentMeta}>
<Text style={styles.metaText}>{formatFileSize(item.originalSize)}</Text>
<Text style={styles.metaSplitter}>-</Text>
<Text style={styles.metaText}>{formatDate(item.createdAt)}</Text>
{item.tags.length > 0 && (
<View style={styles.tagsContainer}>
{item.tags.map(tag => (
<View
key={tag.id}
style={[
styles.tag,
{ backgroundColor: `${tag.color}10` },
]}
>
<Text style={[styles.tagText, { color: tag.color }]}>
{tag.name}
</Text>
</View>
))}
</View>
)}
<TouchableOpacity onPress={() => setOnDocumentActionSheet(item)}>
<View style={styles.documentCard}>
<View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
<Icon name="file-text" size={24} color={themeColors.primary} />
</View>
<View>
<Text style={styles.documentTitle} numberOfLines={2}>
{item.name}
</Text>
<View style={styles.documentMeta}>
<Text style={styles.metaText}>{formatFileSize(item.originalSize)}</Text>
<Text style={styles.metaSplitter}>-</Text>
<Text style={styles.metaText}>{formatDate(item.createdAt)}</Text>
{item.tags.length > 0 && (
<View style={styles.tagsContainer}>
{item.tags.map(tag => (
<View
key={tag.id}
style={[
styles.tag,
{ backgroundColor: `${tag.color}10` },
]}
>
<Text style={[styles.tagText, { color: tag.color }]}>
{tag.name}
</Text>
</View>
))}
</View>
)}
</View>
</View>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={(
<View style={styles.emptyContainer}>

View File

@@ -1,6 +1,7 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"jsx": "react-native",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]

View File

@@ -0,0 +1,720 @@
import type { TranslationsDictionary } from '@/modules/i18n/locales.types';
export const translations: Partial<TranslationsDictionary> = {
// Authentication
'auth.request-password-reset.title': '重置您的密码',
'auth.request-password-reset.description': '输入您的电子邮件以重置密码。',
'auth.request-password-reset.requested': '如果该电子邮件有对应账户,我们将发送重置密码的邮件。',
'auth.request-password-reset.back-to-login': '返回登录页面',
'auth.request-password-reset.form.email.label': '电子邮件',
'auth.request-password-reset.form.email.placeholder': '示例: ada@papra.app',
'auth.request-password-reset.form.email.required': '请输入您的电子邮件地址',
'auth.request-password-reset.form.email.invalid': '该电子邮件地址无效',
'auth.request-password-reset.form.submit': '请求重置密码',
'auth.reset-password.title': '重置您的密码',
'auth.reset-password.description': '输入您的新密码以重置密码。',
'auth.reset-password.reset': '您的密码已重置。',
'auth.reset-password.back-to-login': '返回登录',
'auth.reset-password.form.new-password.label': '新密码',
'auth.reset-password.form.new-password.placeholder': '示例: **********',
'auth.reset-password.form.new-password.required': '请输入您的新密码',
'auth.reset-password.form.new-password.min-length': '密码长度至少为 {{ minLength }} 个字符',
'auth.reset-password.form.new-password.max-length': '密码长度不能超过 {{ maxLength }} 个字符',
'auth.reset-password.form.submit': '重置密码',
'auth.email-provider.open': '打开 {{ provider }}',
'auth.login.title': '登录 Papra',
'auth.login.description': '输入您的电子邮件或使用社交账户登录访问您的 Papra 账户。',
'auth.login.login-with-provider': '使用 {{ provider }} 登录',
'auth.login.no-account': '没有账户?',
'auth.login.register': '注册',
'auth.login.form.email.label': '电子邮件',
'auth.login.form.email.placeholder': '示例: ada@papra.app',
'auth.login.form.email.required': '请输入您的电子邮件地址',
'auth.login.form.email.invalid': '该电子邮件地址无效',
'auth.login.form.password.label': '密码',
'auth.login.form.password.placeholder': '设置密码',
'auth.login.form.password.required': '请输入您的密码',
'auth.login.form.remember-me.label': '记住我',
'auth.login.form.forgot-password.label': '忘记密码?',
'auth.login.form.submit': '登录',
'auth.register.title': '注册 Papra',
'auth.register.description': '创建一个账户以开始使用 Papra。',
'auth.register.register-with-email': '使用电子邮件注册',
'auth.register.register-with-provider': '使用 {{ provider }} 注册',
'auth.register.providers.google': 'Google',
'auth.register.providers.github': 'GitHub',
'auth.register.have-account': '已有账户?',
'auth.register.login': '登录',
'auth.register.registration-disabled.title': '注册被禁用',
'auth.register.registration-disabled.description': '当前 Papra 实例已禁用新账户的创建。只有已有账户的用户可以登录。如果您认为这是错误,请联系该实例的管理员。',
'auth.register.form.email.label': '电子邮件',
'auth.register.form.email.placeholder': '示例: ada@papra.app',
'auth.register.form.email.required': '请输入您的电子邮件地址',
'auth.register.form.email.invalid': '该电子邮件地址无效',
'auth.register.form.password.label': '密码',
'auth.register.form.password.placeholder': '设置密码',
'auth.register.form.password.required': '请输入您的密码',
'auth.register.form.password.min-length': '密码长度至少为 {{ minLength }} 个字符',
'auth.register.form.password.max-length': '密码长度不能超过 {{ maxLength }} 个字符',
'auth.register.form.name.label': '姓名',
'auth.register.form.name.placeholder': '示例: Ada Lovelace',
'auth.register.form.name.required': '请输入您的姓名',
'auth.register.form.name.max-length': '姓名长度不能超过 {{ maxLength }} 个字符',
'auth.register.form.submit': '注册',
'auth.email-validation-required.title': '验证您的电子邮件',
'auth.email-validation-required.description': '一封验证邮件已发送到您的电子邮件地址。请通过点击邮件中的链接来验证您的电子邮件地址。',
'auth.email-verification.success.title': '电子邮件已验证',
'auth.email-verification.success.description': '您的电子邮件已成功验证。您现在可以登录您的账户。',
'auth.email-verification.success.login': '前往登录',
'auth.email-verification.error.title': '验证失败',
'auth.email-verification.error.description': '验证链接已过期或无效。请通过登录请求新的验证邮件。',
'auth.email-verification.error.back': '返回登录',
'auth.legal-links.description': '继续即表示您已了解并同意{{ terms }}和{{ privacy }}。',
'auth.legal-links.terms': '服务条款',
'auth.legal-links.privacy': '隐私政策',
'auth.no-auth-provider.title': '无身份验证提供者',
'auth.no-auth-provider.description': '此 Papra 实例未启用任何身份验证提供者。请联系该实例的管理员以启用它们。',
// User settings
'user.settings.title': '用户设置',
'user.settings.description': '在此管理您的账户设置。',
'user.settings.email.title': '电子邮件地址',
'user.settings.email.description': '您的电子邮件地址无法更改。',
'user.settings.email.label': '电子邮件地址',
'user.settings.name.title': '全名',
'user.settings.name.description': '您的全名会显示给其他组织成员。',
'user.settings.name.label': '全名',
'user.settings.name.placeholder': '例如: 张三',
'user.settings.name.update': '更新姓名',
'user.settings.name.updated': '您的全名已更新',
'user.settings.logout.title': '登出',
'user.settings.logout.description': '从您的账户登出。您可以稍后再次登录。',
'user.settings.logout.button': '登出',
// Organizations
'organizations.list.title': '您的组织',
'organizations.list.description': '组织是一种将您的文档分组并管理访问权限的方式。您可以创建多个组织并邀请您的团队成员进行协作。',
'organizations.list.create-new': '创建新组织',
'organizations.list.back': '返回组织列表',
'organizations.list.deleted.title': '已删除的组织',
'organizations.list.deleted.description': '已删除的组织将在 {{ days }} 天内保留,之后将被永久删除。您可以在此期间恢复它们。',
'organizations.list.deleted.empty': '没有已删除的组织',
'organizations.list.deleted.empty-description': '当您删除一个组织时,它将在此处显示 {{ days }} 天,然后被永久删除。',
'organizations.list.deleted.restore': '恢复',
'organizations.list.deleted.restore-success': '组织已成功恢复',
'organizations.list.deleted.restore-confirm.title': '恢复组织',
'organizations.list.deleted.restore-confirm.message': '您确定要恢复此组织吗?它将被移回您的活动组织列表。',
'organizations.list.deleted.restore-confirm.confirm-button': '恢复组织',
'organizations.list.deleted.deleted-at': '已删除 {{ date }}',
'organizations.list.deleted.purge-at': '将于 {{ date }} 被永久删除',
'organizations.list.deleted.days-remaining': '(剩余 {{ daysUntilPurge, =1:{daysUntilPurge} 天, {daysUntilPurge} 天 }}',
'organizations.details.no-documents.title': '没有文档',
'organizations.details.no-documents.description': '该组织中尚无文档。您可以开始上传一些文档。',
'organizations.details.upload-documents': '上传文档',
'organizations.details.documents-count': '共计文档',
'organizations.details.total-size': '总大小',
'organizations.details.latest-documents': '最新导入的文档',
'organizations.create.title': '创建新组织',
'organizations.create.description': '您的文档将按组织分组。您可以创建多个组织来分隔您的文档,例如,个人和工作文档。',
'organizations.create.back': '返回',
'organizations.create.error.max-count-reached': '您已达到可创建的组织数量上限,如果需要创建更多组织,请联系支持。',
'organizations.create.form.name.label': '组织名称',
'organizations.create.form.name.placeholder': '例如: Acme Inc.',
'organizations.create.form.name.required': '请输入组织名称',
'organizations.create.form.submit': '创建组织',
'organizations.create.success': '组织创建成功',
'organizations.create-first.title': '创建您的组织',
'organizations.create-first.description': '您的文档将按组织分组。您可以创建多个组织来分隔您的文档,例如,个人和工作文档。',
'organizations.create-first.default-name': '我的组织',
'organizations.create-first.user-name': '{{ name }}的组织',
'organization.settings.title': '组织设置',
'organization.settings.page.title': '组织设置',
'organization.settings.page.description': '在此管理您的组织设置。',
'organization.settings.name.title': '组织名称',
'organization.settings.name.update': '更新名称',
'organization.settings.name.placeholder': '例如: Acme Inc.',
'organization.settings.name.updated': '组织名称已更新',
'organization.settings.subscription.title': '订阅',
'organization.settings.subscription.description': '管理您的账单、发票和付款方式。',
'organization.settings.subscription.manage': '管理订阅',
'organization.settings.subscription.error': '获取客户门户 URL 失败',
'organization.settings.delete.title': '删除组织',
'organization.settings.delete.description': '删除此组织将永久移除与其相关的所有数据。',
'organization.settings.delete.confirm.title': '删除组织',
'organization.settings.delete.confirm.message': '您确定要删除此组织吗?该组织将被标记为删除,并在 {{ days }} 天后永久移除。在此期间,您可以从您的组织列表中恢复它。所有文档和数据将在此延迟后永久删除。',
'organization.settings.delete.confirm.confirm-button': '删除组织',
'organization.settings.delete.confirm.cancel-button': '取消',
'organization.settings.delete.success': '组织已删除',
'organization.settings.delete.only-owner': '只有组织所有者可以删除此组织。',
'organization.settings.delete.has-active-subscription': '无法删除有有效订阅的组织,请先取消您的订阅。',
'organization.usage.page.title': '使用情况',
'organization.usage.page.description': '查看您组织的当前使用情况和限制。',
'organization.usage.storage.title': '文档存储',
'organization.usage.storage.description': '您的文档使用的总存储空间',
'organization.usage.intake-emails.title': '接收邮箱',
'organization.usage.intake-emails.description': '接收邮箱地址的数量',
'organization.usage.members.title': '成员',
'organization.usage.members.description': '组织中的成员数量',
'organization.usage.unlimited': '无限制',
'organizations.members.title': '成员',
'organizations.members.description': '管理您的组织成员',
'organizations.members.invite-member': '邀请成员',
'organizations.members.invite-member-disabled-tooltip': '只有管理员或所有者可以邀请成员加入组织',
'organizations.members.remove-from-organization': '从组织中移除',
'organizations.members.role': '角色',
'organizations.members.roles.owner': '所有者',
'organizations.members.roles.admin': '管理员',
'organizations.members.roles.member': '成员',
'organizations.members.delete.confirm.title': '移除成员',
'organizations.members.delete.confirm.message': '您确定要将此成员从组织中移除吗?',
'organizations.members.delete.confirm.confirm-button': '移除',
'organizations.members.delete.confirm.cancel-button': '取消',
'organizations.members.delete.success': '成员已从组织中移除',
'organizations.members.update-role.success': '成员角色已更新',
'organizations.members.table.headers.name': '姓名',
'organizations.members.table.headers.email': '电子邮件',
'organizations.members.table.headers.role': '角色',
'organizations.members.table.headers.created': '创建时间',
'organizations.members.table.headers.actions': '操作',
'organizations.invite-member.title': '邀请成员',
'organizations.invite-member.description': '邀请成员加入您的组织',
'organizations.invite-member.form.email.label': '电子邮件',
'organizations.invite-member.form.email.placeholder': '例如: ada@papra.app',
'organizations.invite-member.form.email.required': '请输入有效的电子邮件地址',
'organizations.invite-member.form.role.label': '角色',
'organizations.invite-member.form.submit': '邀请加入组织',
'organizations.invite-member.success.message': '成员已被邀请',
'organizations.invite-member.success.description': '该电子邮件已被邀请加入组织。',
'organizations.invite-member.error.message': '邀请成员失败',
'organizations.invitations.title': '邀请',
'organizations.invitations.description': '管理您的组织邀请',
'organizations.invitations.list.cta': '邀请成员',
'organizations.invitations.list.empty.title': '没有待处理的邀请',
'organizations.invitations.list.empty.description': '您还没有被邀请加入任何组织。',
'organizations.invitations.status.pending': '待处理',
'organizations.invitations.status.accepted': '已接受',
'organizations.invitations.status.rejected': '已拒绝',
'organizations.invitations.status.expired': '已过期',
'organizations.invitations.status.cancelled': '已取消',
'organizations.invitations.resend': '重新发送邀请',
'organizations.invitations.cancel.title': '取消邀请',
'organizations.invitations.cancel.description': '您确定要取消此邀请吗?',
'organizations.invitations.cancel.confirm': '取消邀请',
'organizations.invitations.cancel.cancel': '取消',
'organizations.invitations.resend.title': '重新发送邀请',
'organizations.invitations.resend.description': '您确定要重新发送此邀请吗?这将向收件人发送一封新电子邮件。',
'organizations.invitations.resend.confirm': '重新发送邀请',
'organizations.invitations.resend.cancel': '取消',
'invitations.list.title': '邀请',
'invitations.list.description': '管理您的组织邀请',
'invitations.list.empty.title': '没有待处理的邀请',
'invitations.list.empty.description': '您还没有被邀请加入任何组织。',
'invitations.list.headers.organization': '组织',
'invitations.list.headers.status': '状态',
'invitations.list.headers.created': '创建时间',
'invitations.list.headers.actions': '操作',
'invitations.list.actions.accept': '接受',
'invitations.list.actions.reject': '拒绝',
'invitations.list.actions.accept.success.message': '邀请已接受',
'invitations.list.actions.accept.success.description': '该邀请已被接受。',
'invitations.list.actions.reject.success.message': '邀请已拒绝',
'invitations.list.actions.reject.success.description': '该邀请已被拒绝。',
// Documents
'documents.list.title': '文档',
'documents.list.no-documents.title': '没有文档',
'documents.list.no-documents.description': '该组织中尚无文档。您可以开始上传一些文档。',
'documents.list.no-results': '未找到文档',
'documents.list.table.headers.file-name': '文件名',
'documents.list.table.headers.created': '创建时间',
'documents.list.table.headers.deleted': '删除时间',
'documents.list.table.headers.actions': '操作',
'documents.list.table.headers.tags': '标签',
'documents.tabs.info': '信息',
'documents.tabs.content': '内容',
'documents.tabs.activity': '活动',
'documents.deleted.message': '该文档已被删除,将在 {{ days }} 天内被永久移除。',
'documents.actions.download': '下载',
'documents.actions.open-in-new-tab': '在新标签页中打开',
'documents.actions.restore': '恢复',
'documents.actions.delete': '删除',
'documents.actions.edit': '编辑',
'documents.actions.cancel': '取消',
'documents.actions.save': '保存',
'documents.actions.saving': '保存中...',
'documents.content.alert': '文档内容在上传时自动提取,仅用于搜索和索引。',
'documents.content.empty-placeholder': '该文档没有提取的内容,您可以在此手动设置。',
'documents.info.id': 'ID',
'documents.info.name': '名称',
'documents.info.type': '类型',
'documents.info.size': '大小',
'documents.info.created-at': '创建时间',
'documents.info.updated-at': '更新时间',
'documents.info.never': '从不',
'documents.rename.title': '重命名文档',
'documents.rename.form.name.label': '名称',
'documents.rename.form.name.placeholder': '示例:发票 2024',
'documents.rename.form.name.required': '请输入文档名称',
'documents.rename.form.name.max-length': '名称必须少于 255 个字符',
'documents.rename.form.submit': '重命名文档',
'documents.rename.success': '文档重命名成功',
'documents.rename.cancel': '取消',
'import-documents.title.error': '{{ count }} 个文档导入失败',
'import-documents.title.success': '{{ count }} 个文档已导入',
'import-documents.title.pending': '{{ count }} / {{ total }} 个文档已导入',
'import-documents.title.none': '导入文档',
'import-documents.no-import-in-progress': '没有正在进行的文档导入',
'documents.deleted.title': '已删除的文档',
'documents.deleted.empty.title': '没有已删除的文档',
'documents.deleted.empty.description': '您没有已删除的文档。被删除的文档将在 {{ days }} 天内移至回收站。',
'documents.deleted.retention-notice': '所有已删除的文档将在回收站中保存 {{ days }} 天。超过此期限,文档将被永久删除,且无法恢复。',
'documents.deleted.deleted-at': '删除时间',
'documents.deleted.restoring': '恢复中...',
'documents.deleted.deleting': '删除中...',
'documents.preview.unknown-file-type': '此文件类型暂无预览',
'documents.preview.binary-file': '该文件似乎是二进制文件,无法以文本形式显示',
'trash.delete-all.button': '全部删除',
'trash.delete-all.confirm.title': '永久删除所有文档?',
'trash.delete-all.confirm.description': '您确定要永久删除回收站中的所有文档吗?此操作无法撤销。',
'trash.delete-all.confirm.label': '删除',
'trash.delete-all.confirm.cancel': '取消',
'trash.delete.button': '删除',
'trash.delete.confirm.title': '永久删除文档?',
'trash.delete.confirm.description': '您确定要永久删除回收站中的此文档吗?此操作无法撤销。',
'trash.delete.confirm.label': '删除',
'trash.delete.confirm.cancel': '取消',
'trash.deleted.success.title': '文档已删除',
'trash.deleted.success.description': '该文档已被永久删除。',
'activity.document.created': '文档已创建',
'activity.document.updated.single': '{{ field }} 已更新',
'activity.document.updated.multiple': '{{ fields }} 已更新',
'activity.document.updated': '文档已更新',
'activity.document.deleted': '文档已被删除',
'activity.document.restored': '文档已恢复',
'activity.document.tagged': '已添加标签 {{ tag }}',
'activity.document.untagged': '已移除标签 {{ tag }}',
'activity.document.user.name': '由 {{ name }}',
'activity.load-more': '加载更多',
'activity.no-more-activities': '该文档没有更多活动记录',
// Tags
'tags.no-tags.title': '暂无标签',
'tags.no-tags.description': '该组织尚无标签。标签用于对文档进行分类,便于查找和组织。',
'tags.no-tags.create-tag': '创建标签',
'tags.title': '文档标签',
'tags.description': '标签用于对文档进行分类,便于查找和组织。',
'tags.create': '创建标签',
'tags.update': '更新标签',
'tags.delete': '删除标签',
'tags.delete.confirm.title': '删除标签',
'tags.delete.confirm.message': '确定要删除此标签吗?删除后该标签将从所有文档中移除。',
'tags.delete.confirm.confirm-button': '删除',
'tags.delete.confirm.cancel-button': '取消',
'tags.delete.success': '标签已删除',
'tags.create.success': '标签 "{{ name }}" 创建成功。',
'tags.update.success': '标签 "{{ name }}" 更新成功。',
'tags.form.name.label': '名称',
'tags.form.name.placeholder': '例如:合同',
'tags.form.name.required': '请输入标签名称',
'tags.form.name.max-length': '标签名称必须少于 64 个字符',
'tags.form.color.label': '颜色',
'tags.form.color.required': '请选择颜色',
'tags.form.color.invalid': '十六进制颜色格式不正确。',
'tags.form.description.label': '描述',
'tags.form.description.optional': '(可选)',
'tags.form.description.placeholder': '例如:公司签署的所有合同',
'tags.form.description.max-length': '描述必须少于 256 个字符',
'tags.form.no-description': '无描述',
'tags.table.headers.tag': '标签',
'tags.table.headers.description': '描述',
'tags.table.headers.documents': '文档',
'tags.table.headers.created': '创建时间',
'tags.table.headers.actions': '操作',
// Tagging rules
'tagging-rules.field.name': '文档名称',
'tagging-rules.field.content': '文档内容',
'tagging-rules.operator.equals': '等于',
'tagging-rules.operator.not-equals': '不等于',
'tagging-rules.operator.contains': '包含',
'tagging-rules.operator.not-contains': '不包含',
'tagging-rules.operator.starts-with': '开始于',
'tagging-rules.operator.ends-with': '结束于',
'tagging-rules.list.title': '标签规则',
'tagging-rules.list.description': '管理组织的标签规则,根据您定义的条件自动为文档打标签。',
'tagging-rules.list.demo-warning': '注意:此为演示环境(无服务器),标签规则不会应用于新添加的文档。',
'tagging-rules.list.no-tagging-rules.title': '暂无标签规则',
'tagging-rules.list.no-tagging-rules.description': '创建标签规则,根据设定条件自动为添加的文档打标签。',
'tagging-rules.list.no-tagging-rules.create-tagging-rule': '创建标签规则',
'tagging-rules.list.card.no-conditions': '无条件',
'tagging-rules.list.card.one-condition': '1 条条件',
'tagging-rules.list.card.conditions': '{{ count }} 条条件',
'tagging-rules.list.card.delete': '删除规则',
'tagging-rules.list.card.edit': '编辑规则',
'tagging-rules.create.title': '创建标签规则',
'tagging-rules.create.success': '标签规则创建成功',
'tagging-rules.create.error': '创建标签规则失败',
'tagging-rules.create.submit': '创建规则',
'tagging-rules.form.name.label': '名称',
'tagging-rules.form.name.placeholder': '例如:为发票打标签',
'tagging-rules.form.name.min-length': '请输入规则名称',
'tagging-rules.form.name.max-length': '名称必须少于 64 个字符',
'tagging-rules.form.description.label': '描述',
'tagging-rules.form.description.placeholder': '例如:名称中包含 \'invoice\' 的文档将被打标签',
'tagging-rules.form.description.max-length': '描述必须少于 256 个字符',
'tagging-rules.form.conditions.label': '条件',
'tagging-rules.form.conditions.description': '定义规则适用的条件。若无条件,规则将应用于所有文档',
'tagging-rules.form.conditions.add-condition': '添加条件',
'tagging-rules.form.conditions.connector.when': '当',
'tagging-rules.form.conditions.connector.and': '且',
'tagging-rules.form.conditions.connector.or': '或',
'tagging-rules.condition-match-mode.all': '所有条件都需匹配',
'tagging-rules.condition-match-mode.any': '任一条件匹配即可',
'tagging-rules.form.conditions.no-conditions.title': '无条件',
'tagging-rules.form.conditions.no-conditions.description': '您未为该规则添加条件。此规则将对所有文档应用其标签。',
'tagging-rules.form.conditions.no-conditions.confirm': '在无条件下应用规则',
'tagging-rules.form.conditions.no-conditions.cancel': '取消',
'tagging-rules.form.conditions.value.placeholder': '例如invoice',
'tagging-rules.form.conditions.value.min-length': '请输入条件值',
'tagging-rules.form.tags.label': '标签',
'tagging-rules.form.tags.description': '选择要应用于匹配条件的文档的标签',
'tagging-rules.form.tags.min-length': '至少需要选择一个标签',
'tagging-rules.form.tags.add-tag': '创建标签',
'tagging-rules.form.submit': '创建规则',
'tagging-rules.update.title': '更新标签规则',
'tagging-rules.update.error': '更新标签规则失败',
'tagging-rules.update.submit': '更新规则',
'tagging-rules.update.cancel': '取消',
'tagging-rules.apply.button': '应用于现有文档',
'tagging-rules.apply.confirm.title': '将规则应用于现有文档?',
'tagging-rules.apply.confirm.description': '这将检查组织内的所有现有文档,并对匹配条件的文档应用标签。处理将在后台进行。',
'tagging-rules.apply.confirm.button': '应用规则',
'tagging-rules.apply.success': '规则应用已在后台启动',
'tagging-rules.apply.error': '启动规则应用失败',
'tagging-rules.apply.processing': '开始中...',
// Intake emails
'intake-emails.title': '接收邮箱',
'intake-emails.description': '接收邮箱地址用于将电子邮件自动导入到 Papra。只需将邮件转发至接收地址其附件将被添加到组织的文档中。',
'intake-emails.disabled.title': '接收邮箱已禁用',
'intake-emails.disabled.description': '此实例已禁用接收邮箱。请联系管理员以启用。更多信息请参阅 {{ documentation }}。',
'intake-emails.disabled.documentation': '文档',
'intake-emails.info': '只有来自允许来源且已启用的接收邮箱的邮件会被处理。您可以随时启用或禁用接收邮箱。',
'intake-emails.empty.title': '暂无接收邮箱',
'intake-emails.empty.description': '生成接收地址以便轻松导入邮件附件。',
'intake-emails.empty.generate': '生成接收邮箱',
'intake-emails.count': '组织共有 {{ count }} 个接收邮箱',
'intake-emails.new': '新建接收邮箱',
'intake-emails.disabled-label': '(已禁用)',
'intake-emails.no-origins': '无允许的发件来源',
'intake-emails.allowed-origins': '允许来自 {{ count }} 个地址',
'intake-emails.actions.enable': '启用',
'intake-emails.actions.disable': '禁用',
'intake-emails.actions.manage-origins': '管理来源地址',
'intake-emails.actions.delete': '删除',
'intake-emails.delete.confirm.title': '删除接收邮箱?',
'intake-emails.delete.confirm.message': '确定要删除此接收邮箱吗?此操作不可撤销。',
'intake-emails.delete.confirm.confirm-button': '删除接收邮箱',
'intake-emails.delete.confirm.cancel-button': '取消',
'intake-emails.delete.success': '接收邮箱已删除',
'intake-emails.create.success': '接收邮箱已创建',
'intake-emails.update.success.enabled': '接收邮箱已启用',
'intake-emails.update.success.disabled': '接收邮箱已禁用',
'intake-emails.allowed-origins.title': '允许的来源',
'intake-emails.allowed-origins.description': '只有来自这些来源并发送到 {{ email }} 的邮件会被处理。若未指定来源,所有邮件将被丢弃。',
'intake-emails.allowed-origins.add.label': '添加允许的来源邮箱',
'intake-emails.allowed-origins.add.placeholder': '例如ada@papra.app',
'intake-emails.allowed-origins.add.button': '添加',
'intake-emails.allowed-origins.add.error.exists': '该邮箱已在此接收邮箱的允许来源列表中',
// API keys
'api-keys.permissions.select-all': '全选',
'api-keys.permissions.deselect-all': '取消全选',
'api-keys.permissions.organizations.title': '组织',
'api-keys.permissions.organizations.organizations:create': '创建组织',
'api-keys.permissions.organizations.organizations:read': '读取组织',
'api-keys.permissions.organizations.organizations:update': '更新组织',
'api-keys.permissions.organizations.organizations:delete': '删除组织',
'api-keys.permissions.documents.title': '文档',
'api-keys.permissions.documents.documents:create': '创建文档',
'api-keys.permissions.documents.documents:read': '读取文档',
'api-keys.permissions.documents.documents:update': '更新文档',
'api-keys.permissions.documents.documents:delete': '删除文档',
'api-keys.permissions.tags.title': '标签',
'api-keys.permissions.tags.tags:create': '创建标签',
'api-keys.permissions.tags.tags:read': '读取标签',
'api-keys.permissions.tags.tags:update': '更新标签',
'api-keys.permissions.tags.tags:delete': '删除标签',
'api-keys.create.title': '创建 API 密钥',
'api-keys.create.description': '创建新的 API 密钥以访问 Papra API。',
'api-keys.create.success': 'API 密钥创建成功。',
'api-keys.create.back': '返回 API 密钥',
'api-keys.create.form.name.label': '名称',
'api-keys.create.form.name.placeholder': '例如:我的 API 密钥',
'api-keys.create.form.name.required': '请输入 API 密钥名称',
'api-keys.create.form.permissions.label': '权限',
'api-keys.create.form.permissions.required': '请至少选择一个权限',
'api-keys.create.form.submit': '创建 API 密钥',
'api-keys.create.created.title': 'API 密钥已创建',
'api-keys.create.created.description': 'API 密钥已创建。请妥善保存,后续将无法再次查看。',
'api-keys.list.title': 'API 密钥',
'api-keys.list.description': '在此管理您的 API 密钥。',
'api-keys.list.create': '创建 API 密钥',
'api-keys.list.empty.title': '暂无 API 密钥',
'api-keys.list.empty.description': '创建 API 密钥以访问 Papra API。',
'api-keys.list.card.last-used': '最后使用',
'api-keys.list.card.never': '从未',
'api-keys.list.card.created': '创建时间',
'api-keys.delete.success': 'API 密钥已删除',
'api-keys.delete.confirm.title': '删除 API 密钥',
'api-keys.delete.confirm.message': '确定要删除此 API 密钥吗?此操作不可撤销。',
'api-keys.delete.confirm.confirm-button': '删除',
'api-keys.delete.confirm.cancel-button': '取消',
// Webhooks
'webhooks.list.title': 'Webhook',
'webhooks.list.description': '管理组织的 Webhook',
'webhooks.list.empty.title': '暂无 Webhook',
'webhooks.list.empty.description': '创建第一个 Webhook 开始接收事件',
'webhooks.list.create': '创建 Webhook',
'webhooks.list.card.last-triggered': '最近触发',
'webhooks.list.card.never': '从未',
'webhooks.list.card.created': '创建时间',
'webhooks.create.title': '创建 Webhook',
'webhooks.create.description': '创建新的 Webhook 以接收事件',
'webhooks.create.success': 'Webhook 创建成功',
'webhooks.create.back': '返回',
'webhooks.create.form.submit': '创建 Webhook',
'webhooks.create.form.name.label': 'Webhook 名称',
'webhooks.create.form.name.placeholder': '请输入 Webhook 名称',
'webhooks.create.form.name.required': '名称为必填项',
'webhooks.create.form.url.label': 'Webhook URL',
'webhooks.create.form.url.placeholder': '请输入 Webhook URL',
'webhooks.create.form.url.required': 'URL 为必填项',
'webhooks.create.form.url.invalid': 'URL 无效',
'webhooks.create.form.secret.label': '密钥',
'webhooks.create.form.secret.placeholder': '请输入 Webhook 密钥',
'webhooks.create.form.events.label': '事件',
'webhooks.create.form.events.required': '至少选择一个事件',
'webhooks.update.title': '编辑 Webhook',
'webhooks.update.description': '更新 Webhook 信息',
'webhooks.update.success': 'Webhook 更新成功',
'webhooks.update.submit': '更新 Webhook',
'webhooks.update.cancel': '取消',
'webhooks.update.form.secret.placeholder': '输入新密钥',
'webhooks.update.form.secret.placeholder-redacted': '[已隐藏的密钥]',
'webhooks.update.form.rotate-secret.button': '轮换密钥',
'webhooks.delete.success': 'Webhook 已删除',
'webhooks.delete.confirm.title': '删除 Webhook',
'webhooks.delete.confirm.message': '确定要删除此 Webhook 吗?',
'webhooks.delete.confirm.confirm-button': '删除',
'webhooks.delete.confirm.cancel-button': '取消',
'webhooks.events.documents.title': '文档事件',
'webhooks.events.documents.document:created.description': '文档已创建',
'webhooks.events.documents.document:deleted.description': '文档已删除',
'webhooks.events.documents.document:updated.description': '文档已更新',
'webhooks.events.documents.document:tag:added.description': '文档已添加标签',
'webhooks.events.documents.document:tag:removed.description': '文档已移除标签',
// Navigation
'layout.menu.home': '首页',
'layout.menu.documents': '文档',
'layout.menu.tags': '标签',
'layout.menu.tagging-rules': '标签规则',
'layout.menu.deleted-documents': '已删除文档',
'layout.menu.organization-settings': '设置',
'layout.menu.api-keys': 'API 密钥',
'layout.menu.settings': '设置',
'layout.menu.account': '账户',
'layout.menu.general-settings': '常规设置',
'layout.menu.usage': '使用情况',
'layout.menu.intake-emails': '接收邮箱',
'layout.menu.webhooks': 'Webhook',
'layout.menu.members': '成员',
'layout.menu.invitations': '邀请',
'layout.upgrade-cta.title': '需要更多空间?',
'layout.upgrade-cta.description': '获得 10 倍存储和团队协作功能',
'layout.upgrade-cta.button': '立即升级',
'layout.theme.light': '浅色模式',
'layout.theme.dark': '深色模式',
'layout.theme.system': '跟随系统',
'layout.search.placeholder': '搜索...',
'layout.menu.import-document': '导入文档',
'user-menu.account-settings': '账户设置',
'user-menu.api-keys': 'API 密钥',
'user-menu.invitations': '邀请',
'user-menu.language': '语言',
'user-menu.logout': '登出',
// Command palette
'command-palette.search.placeholder': '搜索命令或文档',
'command-palette.no-results': '未找到结果',
'command-palette.sections.documents': '文档',
'command-palette.sections.theme': '主题',
// API errors
'api-errors.document.already_exists': '文档已存在',
'api-errors.document.size_too_large': '文件大小过大',
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',
'api-errors.intake_email.limit_reached': '该组织的接收邮箱数量已达到上限。请升级您的方案以创建更多接收邮箱。',
'api-errors.user.max_organization_count_reached': '您已达到可创建的组织数量上限,如需创建更多,请联系支持。',
'api-errors.default': '处理请求时发生错误。',
'api-errors.organization.invitation_already_exists': '此邮箱的邀请在该组织中已存在。',
'api-errors.user.already_in_organization': '该用户已在此组织中。',
'api-errors.user.organization_invitation_limit_reached': '今日邀请次数已达上限,请明天再试。',
'api-errors.demo.not_available': '此功能在演示环境中不可用',
'api-errors.tags.already_exists': '该组织已存在同名标签',
'api-errors.internal.error': '处理请求时发生错误。请稍后重试。',
'api-errors.auth.invalid_origin': '应用来源无效。如果您自托管 Papra请确保 APP_BASE_URL 环境变量与当前 URL 匹配。详情见 https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': '该组织的成员和待处理邀请数量已达上限。请升级方案以添加更多成员。',
'api-errors.organization.has_active_subscription': '无法删除有有效订阅的组织。请先通过上方“管理订阅”取消订阅。',
// Better auth api errors
'api-errors.USER_NOT_FOUND': '未找到用户',
'api-errors.FAILED_TO_CREATE_USER': '创建用户失败',
'api-errors.FAILED_TO_CREATE_SESSION': '创建会话失败',
'api-errors.FAILED_TO_UPDATE_USER': '更新用户失败',
'api-errors.FAILED_TO_GET_SESSION': '获取会话失败',
'api-errors.INVALID_PASSWORD': '密码无效',
'api-errors.INVALID_EMAIL': '电子邮件无效',
'api-errors.INVALID_EMAIL_OR_PASSWORD': '邮箱或密码不正确,或账户不存在。',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': '社交账户已关联',
'api-errors.PROVIDER_NOT_FOUND': '未找到提供者',
'api-errors.INVALID_TOKEN': '令牌无效',
'api-errors.ID_TOKEN_NOT_SUPPORTED': '不支持 ID 令牌',
'api-errors.FAILED_TO_GET_USER_INFO': '获取用户信息失败',
'api-errors.USER_EMAIL_NOT_FOUND': '未找到用户邮箱',
'api-errors.EMAIL_NOT_VERIFIED': '邮箱未验证',
'api-errors.PASSWORD_TOO_SHORT': '密码太短',
'api-errors.PASSWORD_TOO_LONG': '密码太长',
'api-errors.USER_ALREADY_EXISTS': '该邮箱的用户已存在',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': '邮箱无法更新',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': '未找到凭证账户',
'api-errors.SESSION_EXPIRED': '会话已过期',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': '无法解除最后一个账户的关联',
'api-errors.ACCOUNT_NOT_FOUND': '账户未找到',
'api-errors.USER_ALREADY_HAS_PASSWORD': '用户已设置密码',
// Not found
'not-found.title': '404 - 未找到',
'not-found.description': '抱歉,您访问的页面不存在。请检查 URL 并重试。',
'not-found.back-to-home': '返回首页',
// Demo
'demo.popup.description': '这是一个演示环境,所有数据保存在浏览器本地存储。',
'demo.popup.discord': '加入 {{ discordLink }} 获取支持、建议功能或聊天。',
'demo.popup.discord-link-label': 'Discord 服务器',
'demo.popup.reset': '重置演示数据',
'demo.popup.hide': '隐藏',
// Color picker
'color-picker.hue': '色相',
'color-picker.saturation': '饱和度',
'color-picker.lightness': '亮度',
'color-picker.select-color': '选择颜色',
'color-picker.select-a-color': '选择一个颜色',
// Subscriptions
'subscriptions.checkout-success.title': '支付成功!',
'subscriptions.checkout-success.description': '您的订阅已成功激活。',
'subscriptions.checkout-success.thank-you': '感谢升级到 Papra Plus。您现在可以使用所有高级功能。',
'subscriptions.checkout-success.go-to-organizations': '前往组织',
'subscriptions.checkout-success.redirecting': '将在 {{ count }} 秒后跳转...',
'subscriptions.checkout-cancel.title': '支付已取消',
'subscriptions.checkout-cancel.description': '订阅升级已取消。',
'subscriptions.checkout-cancel.no-charges': '您的账户未被扣款。您可以随时重试。',
'subscriptions.checkout-cancel.back-to-organizations': '返回组织',
'subscriptions.checkout-cancel.need-help': '需要帮助?',
'subscriptions.checkout-cancel.contact-support': '联系客服',
'subscriptions.upgrade-dialog.title': '升级此组织',
'subscriptions.upgrade-dialog.description': '为组织解锁强大功能',
'subscriptions.upgrade-dialog.contact-us': '联系我们',
'subscriptions.upgrade-dialog.enterprise-plans': '如需定制企业方案请联系。',
'subscriptions.upgrade-dialog.current-plan': '当前方案',
'subscriptions.upgrade-dialog.recommended': '推荐',
'subscriptions.upgrade-dialog.per-month': '/月',
'subscriptions.upgrade-dialog.billed-annually': '按年计费:${{ price }}',
'subscriptions.upgrade-dialog.upgrade-now': '立即升级',
'subscriptions.upgrade-dialog.promo-banner.title': '限时优惠',
'subscriptions.upgrade-dialog.promo-banner.description': '作为早期采用者,组织可永久获得所有方案 {{ percent }}% 折扣!优惠于 {{ days, >1:{days} 天, =1:1 天, 少于 1 天 }} 到期。',
'subscriptions.plan.free.name': '免费方案',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.plan.pro.name': 'Pro',
'subscriptions.features.storage-size': '文档存储空间',
'subscriptions.features.members': '组织成员',
'subscriptions.features.members-count': '{{ count }} 名成员',
'subscriptions.features.email-intakes': '接收邮箱',
'subscriptions.features.email-intakes-count-singular': '{{ count }} 个地址',
'subscriptions.features.email-intakes-count-plural': '{{ count }} 个地址',
'subscriptions.features.max-upload-size': '最大上传文件大小',
'subscriptions.features.support': '支持',
'subscriptions.features.support-community': '社区支持',
'subscriptions.features.support-email': '邮件支持',
'subscriptions.features.support-priority': '优先支持',
'subscriptions.billing-interval.monthly': '按月',
'subscriptions.billing-interval.annual': '按年',
'subscriptions.usage-warning.message': '您的文档存储已使用 {{ percent }}%,考虑升级方案以获得更多空间。',
'subscriptions.usage-warning.upgrade-button': '升级方案',
// Common / Shared
'common.confirm-modal.type-to-confirm': '输入 "{{ text }}" 以确认',
'common.tables.rows-per-page': '每页行数',
'common.tables.pagination-info': '第 {{ currentPage }} 页,共 {{ totalPages }} 页',
};

View File

@@ -0,0 +1,35 @@
import type { RouteDefinition } from '@solidjs/router';
import { Navigate } from '@solidjs/router';
import { lazy } from 'solid-js';
import { NotFoundPage } from '../shared/pages/not-found.page';
export const adminRoutes: RouteDefinition = {
path: '/admin/*',
component: lazy(() => import('./layouts/admin.layout')),
children: [
{
path: '/',
component: () => <Navigate href="/admin/analytics" />,
},
{
path: '/users',
component: lazy(() => import('./pages/list-users.page')),
},
{
path: '/analytics',
component: lazy(() => import('./analytics/pages/analytics.page')),
},
{
path: '/organizations',
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
},
{
path: '/settings',
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
},
{
path: '/*404',
component: NotFoundPage,
},
],
};

View File

@@ -0,0 +1,35 @@
import { apiClient } from '@/modules/shared/http/api-client';
export async function getUserCount() {
const { userCount } = await apiClient<{ userCount: number }>({
method: 'GET',
path: '/api/admin/users/count',
});
return { userCount };
}
export async function getDocumentStats() {
const stats = await apiClient<{
documentsCount: number;
documentsSize: number;
deletedDocumentsCount: number;
deletedDocumentsSize: number;
totalDocumentsCount: number;
totalDocumentsSize: number;
}>({
method: 'GET',
path: '/api/admin/documents/stats',
});
return stats;
}
export async function getOrganizationCount() {
const { organizationCount } = await apiClient<{ organizationCount: number }>({
method: 'GET',
path: '/api/admin/organizations/count',
});
return { organizationCount };
}

View File

@@ -0,0 +1,103 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { useQuery } from '@tanstack/solid-query';
import { Suspense } from 'solid-js';
import { getDocumentStats, getOrganizationCount, getUserCount } from '../analytics.services';
const AnalyticsCard: Component<{
icon: string;
title: string;
value: () => number | undefined;
formatValue?: (value: number) => string;
}> = (props) => {
const formattedValue = () => {
const value = props.value();
if (value === undefined) {
return '';
}
return props.formatValue ? props.formatValue(value) : value.toLocaleString();
};
return (
<div class="bg-card rounded-lg px-6 py-4 border">
<div class="flex flex-row items-center mb-4 gap-2">
<div class="flex items-center justify-center size-6 bg-muted rounded">
<div class={`${props.icon} text-muted-foreground size-4`} />
</div>
<h2 class="text-sm font-light">{props.title}</h2>
</div>
<Suspense fallback={<div class="h-8 w-16 animate-pulse bg-muted rounded" />}>
<div class="text-3xl font-light">
{formattedValue()}
</div>
</Suspense>
</div>
);
};
export const AdminAnalyticsPage: Component = () => {
const userCountQuery = useQuery(() => ({
queryKey: ['admin', 'users', 'count'],
queryFn: getUserCount,
}));
const documentStatsQuery = useQuery(() => ({
queryKey: ['admin', 'documents', 'stats'],
queryFn: getDocumentStats,
}));
const organizationCountQuery = useQuery(() => ({
queryKey: ['admin', 'organizations', 'count'],
queryFn: getOrganizationCount,
}));
return (
<div class="px-6 pt-4">
<h1 class="text-2xl font-medium mb-1">Dashboard</h1>
<p class="text-muted-foreground">Insights and analytics about Papra usage.</p>
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnalyticsCard
icon="i-tabler-users"
title="User count"
value={() => userCountQuery.data?.userCount}
/>
<AnalyticsCard
icon="i-tabler-building"
title="Organization count"
value={() => organizationCountQuery.data?.organizationCount}
/>
<AnalyticsCard
icon="i-tabler-file"
title="Document count"
value={() => documentStatsQuery.data?.documentsCount}
/>
<AnalyticsCard
icon="i-tabler-database"
title="Documents storage"
value={() => documentStatsQuery.data?.documentsSize}
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
/>
<AnalyticsCard
icon="i-tabler-file-x"
title="Deleted documents"
value={() => documentStatsQuery.data?.deletedDocumentsCount}
/>
<AnalyticsCard
icon="i-tabler-database-x"
title="Deleted storage"
value={() => documentStatsQuery.data?.deletedDocumentsSize}
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
/>
</div>
</div>
);
};
export default AdminAnalyticsPage;

View File

@@ -0,0 +1,102 @@
import type { ParentComponent } from 'solid-js';
import { A, Navigate } from '@solidjs/router';
import { Show } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { Sheet, SheetContent, SheetTrigger } from '@/modules/ui/components/sheet';
import { SideNav } from '@/modules/ui/layouts/sidenav.layout';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
const AdminLayout: ParentComponent = (props) => {
const getNavigationMenu = () => [
{
label: 'Analytics',
href: '/admin/analytics',
icon: 'i-tabler-chart-bar',
},
{
label: 'Users',
href: '/admin/users',
icon: 'i-tabler-users',
},
{
label: 'Organizations',
href: '/admin/organizations',
icon: 'i-tabler-building-community',
},
{
label: 'Settings',
href: '/admin/settings',
icon: 'i-tabler-settings',
},
];
const sidenav = () => (
<SideNav
header={() => (
<A href="/admin" class="flex items-center gap-2 pl-6 h-14 w-260px">
<div class="i-tabler-layout-dashboard text-primary size-7" />
<div class="font-medium text-base">
Papra admin
</div>
</A>
)}
mainMenu={getNavigationMenu()}
footer={() => (
<div class="px-4 text-sm text-muted-foreground text-center">
Papra &copy;
{' '}
{new Date().getFullYear()}
</div>
)}
/>
);
return (
<div class="h-screen bg-card flex flex-row flex-1 min-h-0">
<div class="w-280px flex-shrink-0 hidden md:block">
{sidenav()}
</div>
<div class="flex-1 flex flex-col min-h-0">
<header class="h-14 flex items-center px-4 justify-between">
<Sheet>
<SheetTrigger>
<Button variant="ghost" size="icon" class="md:hidden mr-2">
<div class="i-tabler-menu-2 size-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="bg-card p-0!">
{sidenav()}
</SheetContent>
</Sheet>
<Button
variant="outline"
as={A}
href="/"
>
Back to App
</Button>
</header>
<div class="flex-1 min-h-0 flex flex-col md:rounded-tl-lg md:border-l border-t bg-background">
{props.children}
</div>
</div>
</div>
);
};
export const GuardedAdminLayout: ParentComponent = (props) => {
const { hasPermission } = useCurrentUser();
return (
<Show
when={hasPermission('bo:access')}
fallback={<Navigate href="/" />}
>
<AdminLayout {...props} />
</Show>
);
};
export default GuardedAdminLayout;

View File

@@ -0,0 +1,11 @@
import type { Component } from 'solid-js';
export const AdminListUsersPage: Component = () => {
return (
<div class="p-6 text-muted-foreground">
Not implemented yet.
</div>
);
};
export default AdminListUsersPage;

View File

@@ -17,7 +17,7 @@ export function createDemoAuthClient() {
},
signOut: () => Promise.resolve({}),
signUp: () => Promise.resolve({}),
forgetPassword: () => Promise.resolve({}),
requestPasswordReset: () => Promise.resolve({}),
resetPassword: () => Promise.resolve({}),
sendVerificationEmail: () => Promise.resolve({}),
};

View File

@@ -20,7 +20,7 @@ export function createAuthClient() {
// we can't spread the client because it is a proxy object
signIn: client.signIn,
signUp: client.signUp,
forgetPassword: client.forgetPassword,
requestPasswordReset: client.requestPasswordReset,
resetPassword: client.resetPassword,
sendVerificationEmail: client.sendVerificationEmail,
useSession: client.useSession,
@@ -41,7 +41,7 @@ export const {
signIn,
signUp,
signOut,
forgetPassword,
requestPasswordReset,
resetPassword,
sendVerificationEmail,
} = buildTimeConfig.isDemoMode

View File

@@ -9,7 +9,7 @@ import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { forgetPassword } from '../auth.services';
import { requestPasswordReset } from '../auth.services';
import { OpenEmailProvider } from '../components/open-email-provider.component';
export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string }) => Promise<void> }> = (props) => {
@@ -64,7 +64,7 @@ export const RequestPasswordResetPage: Component = () => {
});
const onPasswordResetRequested = async ({ email }: { email: string }) => {
const { error } = await forgetPassword({
const { error } = await requestPasswordReset({
email,
redirectTo: buildUrl({
path: '/reset-password',

View File

@@ -1,5 +1,6 @@
import type { DocumentActivityEvent } from './documents.types';
import { IN_MS } from '../shared/utils/units';
import { DEFAULT_DOCUMENT_ICON } from './documents.constants';
export const fileIcons: { mimeTypes: string[]; extensions: string[]; icon: string }[] = [
{
@@ -88,7 +89,7 @@ export function getDocumentIcon({
document,
iconByMimeTypeMap = iconByFileType,
iconByExtensionMap = iconByExtension,
defaultIcon = 'i-tabler-file',
defaultIcon = DEFAULT_DOCUMENT_ICON,
}: { document: {
mimeType?: string;
name?: string;

View File

@@ -10,3 +10,5 @@ export const DOCUMENT_ACTIVITY_EVENTS = {
export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS);
export const MAX_CONCURRENT_DOCUMENT_UPLOADS = 3;
export const DEFAULT_DOCUMENT_ICON = 'i-tabler-file';

View File

@@ -9,4 +9,5 @@ export const locales = [
{ key: 'es', name: 'Español' },
{ key: 'it', name: 'Italiano' },
{ key: 'nl', name: 'Nederlands' },
{ key: 'zh', name: '简体中文' },
] as const;

View File

@@ -1,10 +1,20 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { A, useLocation } from '@solidjs/router';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const NotFoundPage: Component = () => {
const { t } = useI18n();
const location = useLocation();
const getRedirectionUrl = () => {
if (location.pathname.startsWith('/admin/') || location.pathname === '/admin') {
return '/admin';
}
return '/';
};
return (
<div class="h-screen flex flex-col items-center justify-center p-6">
@@ -14,7 +24,7 @@ export const NotFoundPage: Component = () => {
<p class="text-muted-foreground">
{t('not-found.description')}
</p>
<Button as={A} href="/" class="mt-4" variant="default">
<Button as={A} href={getRedirectionUrl()} class="mt-4" variant="default">
<div class="i-tabler-arrow-left mr-2" />
{t('not-found.back-to-home')}
</Button>

View File

@@ -1,170 +0,0 @@
import type { Component, ParentComponent } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { For, Show, Suspense } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { cn } from '@/modules/shared/style/cn';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
type MenuItem = {
label: string;
icon: string;
href?: string;
onClick?: () => void;
};
const MenuItemButton: Component<MenuItem> = (props) => {
return (
<>
<Show when={props.onClick}>
<Button class="block" onClick={props.onClick} variant="ghost">
<div class="flex items-center gap-2">
<div class={cn(props.icon, 'size-5 text-muted-foreground')} />
<div>{props.label}</div>
</div>
</Button>
</Show>
<Show when={!props.onClick}>
<Button class="block" as={A} href={props.href!} variant="ghost" activeClass="bg-accent/50! text-accent-foreground!">
<div class="flex items-center gap-2">
<div class={cn(props.icon, 'size-5 text-muted-foreground')} />
<div>{props.label}</div>
</div>
</Button>
</Show>
</>
);
};
const SideNav: Component = () => {
const navigate = useNavigate();
const getMainMenuItems = () => [
{
label: 'Users',
icon: 'i-tabler-users',
href: '/admin/users',
},
];
const getFooterMenuItems = () => [
{
label: 'Settings',
icon: 'i-tabler-settings',
href: '/settings',
},
{
label: 'Logout',
icon: 'i-tabler-logout',
onClick: async () => {
await signOut();
navigate('/login');
},
},
];
return (
<div class="h-full flex flex-col pb-6">
<div class="h-60px flex items-center">
<Button href="/admin" class="text-lg font-bold hover:no-underline gap-1" variant="link" as={A}>
Papra
<span class="font-normal text-base text-muted-foreground">
Admin
</span>
</Button>
</div>
<nav class="flex flex-col gap-0.5 mt-4 text-muted-foreground">
<For each={getMainMenuItems()}>
{menuItem => <MenuItemButton {...menuItem} />}
</For>
</nav>
<div class="flex-1" />
<nav class="flex flex-col gap-0.5 text-muted-foreground">
<For each={getFooterMenuItems()}>
{menuItem => <MenuItemButton {...menuItem} />}
</For>
</nav>
</div>
);
};
const ThemeSwitcher: Component = () => {
const themeStore = useThemeStore();
return (
<>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'light' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-sun text-lg" />
Light Mode
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'dark' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-moon text-lg" />
Dark Mode
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'system' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-device-laptop text-lg" />
System Mode
</DropdownMenuItem>
</>
);
};
export const AdminLayout: ParentComponent = (props) => {
const themeStore = useThemeStore();
return (
<div class="flex flex-row h-screen min-h-0">
<div class="w-64 border-r border-r-border px-2 flex-shrink-0 hidden md:block">
<SideNav />
</div>
<div class="flex-1 min-h-0 flex flex-col">
<div class="h-60px border-b flex items-center justify-between px-6">
<div>
<Sheet>
<SheetTrigger>
<Button variant="ghost" size="icon" class="md:hidden">
<div class="i-tabler-menu-2 size-6" />
</Button>
</SheetTrigger>
<SheetContent side="left">
<SideNav />
</SheetContent>
</Sheet>
</div>
<div class="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger as={Button} class="text-base" variant="outline" aria-label="Theme switcher">
<div classList={{ 'i-tabler-moon': themeStore.getColorMode() === 'dark', 'i-tabler-sun': themeStore.getColorMode() === 'light' }} />
<div class="ml-2 i-tabler-chevron-down text-muted-foreground text-sm" />
</DropdownMenuTrigger>
<DropdownMenuContent class="w-42">
<ThemeSwitcher />
</DropdownMenuContent>
</DropdownMenu>
<Button as={A} href="/renderings">
<div class="i-tabler-home size-4 mr-1" />
App
</Button>
</div>
</div>
<div class="flex-1 overflow-auto max-w-screen">
<Suspense>
{props.children}
</Suspense>
</div>
</div>
</div>
);
};

View File

@@ -13,6 +13,7 @@ import { cn } from '@/modules/shared/style/cn';
import { UsageWarningCard } from '@/modules/subscriptions/components/usage-warning-card';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { useCurrentUser } from '@/modules/users/composables/useCurrentUser';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
@@ -131,6 +132,7 @@ export const SidenavLayout: ParentComponent<{
const navigate = useNavigate();
const { getPendingInvitationsCount } = usePendingInvitationsCount();
const { t } = useI18n();
const { hasPermission } = useCurrentUser();
const { promptImport, uploadDocuments } = useDocumentUpload();
@@ -240,6 +242,12 @@ export const SidenavLayout: ParentComponent<{
</DropdownMenuContent>
</DropdownMenu>
<Show when={hasPermission('bo:access')}>
<Button as={A} href="/admin" variant="outline" class="hidden sm:flex" size="icon">
<div class="i-tabler-settings size-4.5" />
</Button>
</Show>
</div>
</div>
<div class="flex-1 overflow-auto max-w-screen">

View File

@@ -11,6 +11,7 @@ const currentUserContext = createContext<{
getLatestOrganizationId: () => string | null;
setLatestOrganizationId: (organizationId: string) => void;
hasPermission: (permission: string) => boolean;
}>();
export function useCurrentUser() {
@@ -42,6 +43,7 @@ export const CurrentUserProvider: ParentComponent = (props) => {
getLatestOrganizationId,
setLatestOrganizationId,
hasPermission: (permission: string) => query.data?.user.permissions?.includes(permission) ?? false,
}}
>
{props.children}

View File

@@ -3,7 +3,7 @@ export type UserMe = {
email: string;
planId: string;
name: string;
roles: string[];
permissions: string[];
};
export type User = {

View File

@@ -2,6 +2,7 @@ import type { RouteDefinition } from '@solidjs/router';
import { Navigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { Match, Show, Suspense, Switch } from 'solid-js';
import { adminRoutes } from './modules/admin/admin.routes';
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page';
import { authPagesPaths } from './modules/auth/auth.constants';
@@ -197,6 +198,7 @@ export const routes: RouteDefinition[] = [
},
],
},
adminRoutes,
],
},
{

View File

@@ -9,6 +9,7 @@ import {
import { presetAnimations } from 'unocss-preset-animations';
import { ssoProviders } from './src/modules/auth/auth.constants';
import { documentActivityIcon, fileIcons } from './src/modules/documents/document.models';
import { DEFAULT_DOCUMENT_ICON } from './src/modules/documents/documents.constants';
export default defineConfig({
presets: [
@@ -116,6 +117,7 @@ export default defineConfig({
},
safelist: [
...new Set([
DEFAULT_DOCUMENT_ICON,
...fileIcons.map(({ icon }) => icon),
...Object.values(documentActivityIcon),
...ssoProviders.map(({ icon }) => icon),

View File

@@ -35,6 +35,7 @@
"dev:reset": "pnpm clean:all && pnpm migrate:up",
"script:send-intake-email": "tsx --env-file-if-exists=.env src/scripts/send-intake-email.script.ts | crowlog-pretty",
"stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook",
"script:make-user-admin": "tsx --env-file-if-exists=.env src/scripts/make-user-admin.script.ts",
"maintenance:encrypt-all-documents": "tsx --env-file-if-exists=.env src/scripts/encrypt-all-documents.script.ts"
},
"dependencies": {
@@ -47,9 +48,9 @@
"@cadence-mq/driver-memory": "^0.2.0",
"@corentinth/chisels": "catalog:",
"@corentinth/friendly-ids": "^0.0.1",
"@crowlog/async-context-plugin": "^2.0.0",
"@crowlog/async-context-plugin": "^2.1.0",
"@crowlog/logger": "^2.1.0",
"@hono/node-server": "^1.14.4",
"@hono/node-server": "^1.19.6",
"@libsql/client": "^0.14.0",
"@owlrelay/api-sdk": "^0.0.2",
"@owlrelay/webhook": "^0.0.3",
@@ -65,7 +66,7 @@
"drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.4",
"figue": "^3.1.1",
"hono": "^4.8.2",
"hono": "^4.10.7",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",
"nanoid": "^5.1.5",

View File

@@ -1,98 +1,6 @@
/* eslint-disable antfu/no-top-level-await */
import process, { env } from 'node:process';
import { serve } from '@hono/node-server';
import { setupDatabase } from './modules/app/database/database';
import { ensureLocalDatabaseDirectoryExists } from './modules/app/database/database.services';
import { createGracefulShutdownService } from './modules/app/graceful-shutdown/graceful-shutdown.services';
import { createServer } from './modules/app/server';
import { parseConfig } from './modules/config/config';
import { createDocumentStorageService } from './modules/documents/storage/documents.storage.services';
import { createIngestionFolderWatcher } from './modules/ingestion-folders/ingestion-folders.usecases';
import { addToGlobalLogContext, createLogger } from './modules/shared/logger/logger';
import { registerTaskDefinitions } from './modules/tasks/tasks.definitions';
import { createTaskServices } from './modules/tasks/tasks.services';
import { registerShutdownHooks } from './modules/app/graceful-shutdown/graceful-shutdown.usecases';
import { startApp } from './start';
const logger = createLogger({ namespace: 'app-server' });
const { config } = await parseConfig({ env });
addToGlobalLogContext({ processMode: config.processMode });
const isWebMode = config.processMode === 'all' || config.processMode === 'web';
const isWorkerMode = config.processMode === 'all' || config.processMode === 'worker';
logger.info({ processMode: config.processMode, isWebMode, isWorkerMode }, 'Starting application');
// Shutdown callback collector
const shutdownService = createGracefulShutdownService({ logger });
const { registerShutdownHandler } = shutdownService;
await ensureLocalDatabaseDirectoryExists({ config });
const { db } = setupDatabase({ ...config.database, registerShutdownHandler });
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taskServices = createTaskServices({ config });
await taskServices.initialize();
if (isWebMode) {
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
const server = serve(
{
fetch: app.fetch,
port: config.server.port,
hostname: config.server.hostname,
},
({ port }) => logger.info({ port }, 'Server started'),
);
registerShutdownHandler({
id: 'web-server-close',
handler: () => {
server.close();
},
});
}
if (isWorkerMode) {
if (config.ingestionFolder.isEnabled) {
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
taskServices,
config,
db,
documentsStorageService,
});
await startWatchingIngestionFolders();
}
await registerTaskDefinitions({ taskServices, db, config, documentsStorageService });
taskServices.start();
logger.info('Worker started');
}
// Global error handlers
process.on('uncaughtException', (error) => {
logger.error({ error }, 'Uncaught exception');
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
});
process.on('unhandledRejection', (error) => {
logger.error({ error }, 'Unhandled promise rejection');
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
logger.info({ signal }, 'Received shutdown signal, shutting down gracefully...');
await shutdownService.executeShutdownHandlers();
logger.info('Shutdown complete, exiting process');
process.exit(0);
}
process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
const { shutdownServices } = await startApp();
registerShutdownHooks({ shutdownServices });

View File

@@ -0,0 +1,34 @@
import type { Migration } from '../migrations.types';
import { sql } from 'drizzle-orm';
export const dropFts5TriggersMigration = {
name: 'drop-fts-5-triggers',
up: async ({ db }) => {
await db.batch([
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_insert`),
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_update`),
db.run(sql`DROP TRIGGER IF EXISTS trigger_documents_fts_delete`),
]);
},
down: async ({ db }) => {
await db.batch([
db.run(sql`
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_insert AFTER INSERT ON documents BEGIN
INSERT INTO documents_fts(id, name, original_name, content) VALUES (new.id, new.name, new.original_name, new.content);
END
`),
db.run(sql`
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_update AFTER UPDATE ON documents BEGIN
UPDATE documents_fts SET name = new.name, original_name = new.original_name, content = new.content WHERE id = new.id;
END
`),
db.run(sql`
CREATE TRIGGER IF NOT EXISTS trigger_documents_fts_delete AFTER DELETE ON documents BEGIN
DELETE FROM documents_fts WHERE id = old.id;
END
`),
]);
},
} satisfies Migration;

View File

@@ -121,10 +121,7 @@ describe('migrations registry', () => {
CREATE TABLE "users" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "email" text NOT NULL, "email_verified" integer DEFAULT false NOT NULL, "name" text, "image" text, "max_organization_count" integer );
CREATE TABLE "webhook_deliveries" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, "request_payload" text NOT NULL, "response_payload" text NOT NULL, "response_status" integer NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "webhook_events" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "webhook_id" text NOT NULL, "event_name" text NOT NULL, FOREIGN KEY ("webhook_id") REFERENCES "webhooks"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TABLE "webhooks" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "url" text NOT NULL, "secret" text, "enabled" integer DEFAULT true NOT NULL, "created_by" text, "organization_id" text, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );
CREATE TRIGGER trigger_documents_fts_delete AFTER DELETE ON documents BEGIN DELETE FROM documents_fts WHERE id = old.id; END;
CREATE TRIGGER trigger_documents_fts_insert AFTER INSERT ON documents BEGIN INSERT INTO documents_fts(id, name, original_name, content) VALUES (new.id, new.name, new.original_name, new.content); END;
CREATE TRIGGER trigger_documents_fts_update AFTER UPDATE ON documents BEGIN UPDATE documents_fts SET name = new.name, original_name = new.original_name, content = new.content WHERE id = new.id; END;"
CREATE TABLE "webhooks" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "url" text NOT NULL, "secret" text, "enabled" integer DEFAULT true NOT NULL, "created_by" text, "organization_id" text, FOREIGN KEY ("created_by") REFERENCES "users"("id") ON UPDATE cascade ON DELETE set null, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade );"
`);
});

View File

@@ -15,6 +15,8 @@ import { documentFileEncryptionMigration } from './list/0010-document-file-encry
import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration';
import { taggingRuleConditionMatchModeMigration } from './list/0012-tagging-rule-condition-match-mode.migration';
import { dropFts5TriggersMigration } from './list/0013-drop-fts-5-triggers.migration';
export const migrations: Migration[] = [
initialSchemaSetupMigration,
documentsFtsMigration,
@@ -28,4 +30,5 @@ export const migrations: Migration[] = [
documentFileEncryptionMigration,
softDeleteOrganizationsMigration,
taggingRuleConditionMatchModeMigration,
dropFts5TriggersMigration,
];

View File

@@ -0,0 +1,6 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { registerAnalyticsRoutes } from './analytics/analytics.routes';
export function registerAdminRoutes(context: RouteDefinitionContext) {
registerAnalyticsRoutes(context);
}

View File

@@ -0,0 +1,69 @@
import type { RouteDefinitionContext } from '../../app/server.types';
import { createRoleMiddleware, requireAuthentication } from '../../app/auth/auth.middleware';
import { createDocumentsRepository } from '../../documents/documents.repository';
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
import { PERMISSIONS } from '../../roles/roles.constants';
import { createUsersRepository } from '../../users/users.repository';
export function registerAnalyticsRoutes(context: RouteDefinitionContext) {
registerGetUserCountAdminRoute(context);
registerGetDocumentStatsAdminRoute(context);
registerGetOrganizationCountAdminRoute(context);
}
function registerGetUserCountAdminRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/users/count',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_ANALYTICS],
}),
async (context) => {
const usersRepository = createUsersRepository({ db });
const { userCount } = await usersRepository.getUserCount();
return context.json({ userCount });
},
);
}
function registerGetDocumentStatsAdminRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/documents/stats',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_ANALYTICS],
}),
async (context) => {
const documentsRepository = createDocumentsRepository({ db });
const stats = await documentsRepository.getGlobalDocumentsStats();
return context.json(stats);
},
);
}
function registerGetOrganizationCountAdminRoute({ app, db }: RouteDefinitionContext) {
const { requirePermissions } = createRoleMiddleware({ db });
app.get(
'/api/admin/organizations/count',
requireAuthentication(),
requirePermissions({
requiredPermissions: [PERMISSIONS.VIEW_ANALYTICS],
}),
async (context) => {
const organizationsRepository = createOrganizationsRepository({ db });
const { organizationCount } = await organizationsRepository.getOrganizationCount();
return context.json({ organizationCount });
},
);
}

View File

@@ -0,0 +1,74 @@
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('analytics routes - permission protection', () => {
describe('get /api/admin/users/count', () => {
test('when the user has the VIEW_ANALYTICS permission, the request succeeds', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'usr_admin', email: 'admin@example.com' },
{ id: 'usr_regular', email: 'user@example.com' },
],
userRoles: [
{ userId: 'usr_admin', role: 'admin' },
],
});
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
const response = await app.request(
'/api/admin/users/count',
{ method: 'GET' },
{ loggedInUserId: 'usr_admin' },
);
expect(response.status).to.eql(200);
const body = await response.json();
expect(body).to.eql({ userCount: 2 });
});
test('when the user does not have the VIEW_ANALYTICS 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/users/count',
{ 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/users/count',
{ method: 'GET' },
);
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
});

View File

@@ -2,6 +2,7 @@ import type { Document } from '../../documents/documents.types';
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';
@@ -13,7 +14,7 @@ describe('api-key e2e', () => {
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
});
const { app } = await createServer({
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
@@ -21,7 +22,7 @@ describe('api-key e2e', () => {
driver: 'in-memory',
},
}),
});
}));
const createApiKeyResponse = await app.request(
'/api/api-keys',

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from 'vitest';
import { ensureAuthSecretIsNotDefaultInProduction } from './auth.config.models';
import { createAuthSecretIsDefaultError } from './auth.errors';
describe('auth config models', () => {
describe('ensureAuthSecretIsNotDefaultInProduction', () => {
const defaultAuthSecret = 'papra-default-auth-secret-change-me';
test('throws an error if in production and auth secret is the default one', () => {
expect(() =>
ensureAuthSecretIsNotDefaultInProduction({
config: { auth: { secret: defaultAuthSecret }, env: 'production' },
defaultAuthSecret,
}),
).toThrow(createAuthSecretIsDefaultError());
expect(() =>
ensureAuthSecretIsNotDefaultInProduction({
config: { auth: { secret: defaultAuthSecret }, env: 'dev' },
defaultAuthSecret,
}),
).not.toThrow();
expect(() =>
ensureAuthSecretIsNotDefaultInProduction({
config: { auth: { secret: 'a-non-default-secure-secret' }, env: 'production' },
defaultAuthSecret,
}),
).not.toThrow();
});
});
});

View File

@@ -0,0 +1,14 @@
import { DEFAULT_AUTH_SECRET } from './auth.constants';
import { createAuthSecretIsDefaultError } from './auth.errors';
export function ensureAuthSecretIsNotDefaultInProduction({
config,
defaultAuthSecret = DEFAULT_AUTH_SECRET,
}: {
config: { auth: { secret: string }; env: string };
defaultAuthSecret?: string;
}) {
if (config.env === 'production' && config.auth.secret === defaultAuthSecret) {
throw createAuthSecretIsDefaultError();
}
}

View File

@@ -2,6 +2,7 @@ import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
import { booleanishSchema } from '../../config/config.schemas';
import { parseJson } from '../../intake-emails/intake-emails.schemas';
import { DEFAULT_AUTH_SECRET } from './auth.constants';
const customOAuthProviderSchema = z.object({
providerId: z.string(),
@@ -26,9 +27,9 @@ const customOAuthProviderSchema = z.object({
export const authConfig = {
secret: {
doc: 'The secret for the auth',
schema: z.string(),
default: 'change-me-for-god-sake',
doc: 'The secret for the auth, it should be at least 32 characters long, you can generate a secure one using `openssl rand -hex 48`',
schema: z.string({ required_error: 'Please provide an auth secret using the AUTH_SECRET environment variable, you can use `openssl rand -hex 48` to generate a secure one' }).min(32),
default: DEFAULT_AUTH_SECRET,
env: 'AUTH_SECRET',
},
isRegistrationEnabled: {

View File

@@ -0,0 +1,10 @@
import { describe, expect, test } from 'vitest';
import { DEFAULT_AUTH_SECRET } from './auth.constants';
describe('auth constants', () => {
describe('default auth secret', () => {
test('the default auth secret should be at least 32 characters long', () => {
expect(DEFAULT_AUTH_SECRET.length).toBeGreaterThanOrEqual(32);
});
});
});

View File

@@ -0,0 +1 @@
export const DEFAULT_AUTH_SECRET = 'papra-default-auth-secret-change-me';

View File

@@ -17,3 +17,10 @@ export const createForbiddenEmailDomainError = createErrorFactory({
code: 'auth.forbidden_email_domain',
statusCode: 403,
});
export const createAuthSecretIsDefaultError = createErrorFactory({
code: 'auth.config.secret_is_default',
message: 'In production, the auth secret must not be the default one. Please set a secure auth secret using the AUTH_SECRET environment variable.',
statusCode: 500,
isInternal: true,
});

View File

@@ -0,0 +1,199 @@
import type { ServerInstanceGenerics } from '../server.types';
import { Hono } from 'hono';
import { describe, expect, test } from 'vitest';
import { PERMISSIONS } from '../../roles/roles.constants';
import { createInMemoryDatabase } from '../database/database.test-utils';
import { registerErrorMiddleware } from '../middlewares/errors.middleware';
import { createRoleMiddleware } from './auth.middleware';
describe('createRoleMiddleware', () => {
const permissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS],
};
const createTestServer = ({ loggedInUserId}: { loggedInUserId: string | null }) => {
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
// Mock the context variables before the middleware
app.use('*', async (c, next) => {
c.set('userId', loggedInUserId);
await next();
});
return { app };
};
describe('when the user has the required permission', () => {
test('the request is allowed to proceed', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(200);
expect(await response.json()).to.eql({ success: true });
});
});
describe('when the user does not have the required permission', () => {
test('a 401 unauthorized error is returned', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
describe('when the user is not authenticated', () => {
test('a 401 unauthorized error is returned', async () => {
const { db } = await createInMemoryDatabase();
const { app } = createTestServer({ loggedInUserId: null });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
describe('when the user has multiple permissions and one matches', () => {
test('the request is allowed to proceed', async () => {
const extendedPermissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS, PERMISSIONS.USERS_LIST],
};
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole: extendedPermissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.USERS_LIST] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(200);
expect(await response.json()).to.eql({ success: true });
});
});
describe('when multiple permissions are required', () => {
test('the request is allowed if the user has all required permissions', async () => {
const extendedPermissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS, PERMISSIONS.USERS_LIST],
};
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole: extendedPermissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS, PERMISSIONS.USERS_LIST] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(200);
expect(await response.json()).to.eql({ success: true });
});
test('a 401 error is returned if the user is missing one of the required permissions', async () => {
const extendedPermissionsByRole = {
admin: [PERMISSIONS.BO_ACCESS],
};
const { db } = await createInMemoryDatabase({
users: [{ id: 'user-1', email: 'user@example.com' }],
userRoles: [
{ userId: 'user-1', role: 'admin' },
],
});
const { app } = createTestServer({ loggedInUserId: 'user-1' });
const { requirePermissions } = createRoleMiddleware({ db, permissionsByRole: extendedPermissionsByRole });
app.get(
'/protected',
requirePermissions({ requiredPermissions: [PERMISSIONS.BO_ACCESS, PERMISSIONS.USERS_LIST] }),
async c => c.json({ success: true }),
);
const response = await app.request('/protected', { method: 'GET' });
expect(response.status).to.eql(401);
expect(await response.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});
});

View File

@@ -1,6 +1,12 @@
import type { ApiKeyPermissions } from '../../api-keys/api-keys.types';
import type { Permission, Role } from '../../roles/roles.types';
import type { Database } from '../database/database.types';
import type { Context } from '../server.types';
import { createMiddleware } from 'hono/factory';
import { PERMISSIONS_BY_ROLE } from '../../roles/roles.constants';
import { getPermissionsForRoles } from '../../roles/roles.methods';
import { createRolesRepository } from '../../roles/roles.repository';
import { isNil } from '../../shared/utils';
import { createUnauthorizedError } from './auth.errors';
import { isAuthenticationValid } from './auth.models';
@@ -20,3 +26,33 @@ export function requireAuthentication({ apiKeyPermissions }: { apiKeyPermissions
await next();
});
}
/**
* Middleware to require specific permissions for the authenticated user.
*/
export function createRoleMiddleware({ db, permissionsByRole = PERMISSIONS_BY_ROLE }: { db: Database; permissionsByRole?: Record<Role, Readonly<Permission[]>> }) {
const rolesRepository = createRolesRepository({ db });
return {
requirePermissions: ({ requiredPermissions }: { requiredPermissions: Permission[] }) =>
createMiddleware(async (context: Context, next) => {
const userId = context.get('userId');
if (isNil(userId)) {
throw createUnauthorizedError();
}
const { roles } = await rolesRepository.getUserRoles({ userId });
const { permissions } = getPermissionsForRoles({ roles, permissionsByRole });
const hasAllPermissions = requiredPermissions.every(permission => permissions.includes(permission));
if (!hasAllPermissions) {
throw createUnauthorizedError();
}
await next();
}),
};
}

View File

@@ -1,6 +1,6 @@
import type { Config } from '../../config/config.types';
import type { TrackingServices } from '../../tracking/tracking.services';
import type { Database } from '../database/database.types';
import type { EventServices } from '../events/events.services';
import type { AuthEmailsServices } from './auth.emails.services';
import { expo } from '@better-auth/expo';
import { betterAuth } from 'better-auth';
@@ -21,12 +21,12 @@ export function getAuth({
db,
config,
authEmailsServices,
trackingServices,
eventServices,
}: {
db: Database;
config: Config;
authEmailsServices: AuthEmailsServices;
trackingServices: TrackingServices;
eventServices: EventServices;
}) {
const { secret } = config.auth;
@@ -86,9 +86,13 @@ export function getAuth({
throw createForbiddenEmailDomainError();
}
},
after: async ({ id: userId, email }) => {
after: async ({ id: userId, email, createdAt }) => {
logger.info({ userId }, 'User signed up');
trackingServices.captureUserEvent({ userId, event: 'User signed up', properties: { $set: { email } } });
eventServices.emitEvent({
eventName: 'user.created',
payload: { userId, email, createdAt },
});
},
},
},

View File

@@ -1,7 +1,9 @@
import { describe, expect, test, vi } from 'vitest';
import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../../../config/config.test-utils';
import { createInMemoryDatabase } from '../../database/database.test-utils';
import { createEventServices } from '../../events/events.services';
import { createServer } from '../../server';
import { createTestServerDependencies } from '../../server.test-utils';
import { createAuthEmailsServices } from '../auth.emails.services';
import { getAuth } from '../auth.services';
@@ -34,9 +36,9 @@ describe('email verification e2e', () => {
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth });
const { app } = createServer(createTestServerDependencies({ db, config, auth }));
const response = await app.request('/api/auth/sign-up/email', {
method: 'POST',
@@ -75,9 +77,9 @@ describe('email verification e2e', () => {
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth });
const { app } = createServer(createTestServerDependencies({ db, config, auth }));
// First, sign up
await app.request('/api/auth/sign-up/email', {
@@ -135,9 +137,9 @@ describe('email verification e2e', () => {
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth });
const { app } = createServer(createTestServerDependencies({ db, config, auth }));
const response = await app.request('/api/auth/sign-up/email', {
method: 'POST',
@@ -166,9 +168,9 @@ describe('email verification e2e', () => {
});
const authEmailsServices = createAuthEmailsServices({ emailsServices: mockEmailsServices });
const { auth } = getAuth({ db, config, authEmailsServices, trackingServices: { captureUserEvent: vi.fn(), shutdown: vi.fn() } });
const { auth } = getAuth({ db, config, authEmailsServices, eventServices: createEventServices() });
const { app } = await createServer({ db, config, auth });
const { app } = createServer(createTestServerDependencies({ db, config, auth }));
// Sign up
await app.request('/api/auth/sign-up/email', {

View File

@@ -6,6 +6,7 @@ import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.
import { documentsTable } from '../../documents/documents.table';
import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from '../../organizations/organizations.table';
import { userRolesTable } from '../../roles/roles.table';
import { organizationSubscriptionsTable } from '../../subscriptions/subscriptions.tables';
import { taggingRuleActionsTable, taggingRuleConditionsTable, taggingRulesTable } from '../../tagging-rules/tagging-rules.tables';
import { documentsTagsTable, tagsTable } from '../../tags/tags.table';
@@ -49,6 +50,7 @@ const seedTables = {
webhookEvents: webhookEventsTable,
webhookDeliveries: webhookDeliveriesTable,
organizationInvitations: organizationInvitationsTable,
userRoles: userRolesTable,
} as const;
type SeedTablesRows = {

View File

@@ -1,4 +1,4 @@
import type { ShutdownHandlerRegistration } from '../graceful-shutdown/graceful-shutdown.services';
import type { ShutdownServices } from '../graceful-shutdown/graceful-shutdown.services';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
@@ -8,18 +8,18 @@ function setupDatabase({
url,
authToken,
encryptionKey,
registerShutdownHandler,
shutdownServices,
}: {
url: string;
authToken?: string;
encryptionKey?: string;
registerShutdownHandler?: ShutdownHandlerRegistration;
shutdownServices?: ShutdownServices;
}) {
const client = createClient({ url, authToken, encryptionKey });
const db = drizzle(client);
registerShutdownHandler?.({
shutdownServices?.registerShutdownHandler({
id: 'database-client-close',
handler: () => client.close(),
});

View File

@@ -0,0 +1,27 @@
import type { DocumentSearchServices } from '../../documents/document-search/document-search.types';
import type { TrackingServices } from '../../tracking/tracking.services';
import type { Database } from '../database/database.types';
import type { EventServices } from './events.services';
import { registerSyncDocumentSearchEventHandlers } from '../../documents/document-search/events/sync-document-search.handlers';
import { registerInsertActivityLogOnDocumentCreatedHandler } from '../../documents/events/activity-log.document-created';
import { registerInsertActivityLogOnDocumentRestoredHandler } from '../../documents/events/activity-log.document-restored';
import { registerInsertActivityLogOnDocumentTrashedHandler } from '../../documents/events/activity-log.document-trashed';
import { registerInsertActivityLogOnDocumentUpdatedHandler } from '../../documents/events/activity-log.document-updated';
import { registerTrackDocumentCreatedHandler } from '../../documents/events/tracking.document-created';
import { registerTriggerWebhooksOnDocumentCreatedHandler } from '../../documents/events/webhook.document-created';
import { registerTriggerWebhooksOnDocumentTrashedHandler } from '../../documents/events/webhook.document-trashed';
import { registerTriggerWebhooksOnDocumentUpdatedHandler } from '../../documents/events/webhook.document-updated';
import { registerTrackingUserCreatedEventHandler } from '../../users/event-handlers/tracking.user-created';
export function registerEventHandlers(deps: { trackingServices: TrackingServices; eventServices: EventServices; db: Database; documentSearchServices: DocumentSearchServices }) {
registerTrackingUserCreatedEventHandler(deps);
registerTriggerWebhooksOnDocumentCreatedHandler(deps);
registerInsertActivityLogOnDocumentCreatedHandler(deps);
registerTrackDocumentCreatedHandler(deps);
registerTriggerWebhooksOnDocumentTrashedHandler(deps);
registerInsertActivityLogOnDocumentTrashedHandler(deps);
registerInsertActivityLogOnDocumentRestoredHandler(deps);
registerTriggerWebhooksOnDocumentUpdatedHandler(deps);
registerInsertActivityLogOnDocumentUpdatedHandler(deps);
registerSyncDocumentSearchEventHandlers(deps);
}

View File

@@ -0,0 +1,193 @@
import { createNoopLogger } from '@crowlog/logger';
import { describe, expect, test } from 'vitest';
import { nextTick } from '../../shared/async/defer.test-utils';
import { createEventServices } from './events.services';
type TestEvents = {
'user.created': { userId: string; email: string };
'user.deleted': { userId: string };
};
describe('events services', () => {
describe('emitEvent', () => {
test('registered handlers are called with the event payload when an event is emitted', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'test-handler',
handler: async (payload, meta) => {
handlerCalls.push({ payload, meta });
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '123', email: 'test@example.com' },
eventId: 'evt_test',
now: new Date('2024-01-01'),
});
expect(handlerCalls).to.deep.equal([]);
await nextTick();
expect(handlerCalls).to.deep.equal([{
payload: { userId: '123', email: 'test@example.com' },
meta: { emittedAt: new Date('2024-01-01'), eventId: 'evt_test' },
}]);
});
test('multiple handlers registered for the same event are all called', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'handler-1',
handler: async (payload) => {
handlerCalls.push({ handler: 'handler-1', payload });
},
});
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'handler-2',
handler: async (payload) => {
handlerCalls.push({ handler: 'handler-2', payload });
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '456', email: 'test2@example.com' },
eventId: 'evt_multi',
now: new Date('2024-02-01'),
});
await nextTick();
expect(handlerCalls).to.have.length(2);
expect(handlerCalls).to.deep.include({ handler: 'handler-1', payload: { userId: '456', email: 'test2@example.com' } });
expect(handlerCalls).to.deep.include({ handler: 'handler-2', payload: { userId: '456', email: 'test2@example.com' } });
});
test('emitting an event with no registered handlers does not throw an error', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
expect(async () => {
eventsServices.emitEvent({
eventName: 'user.deleted',
payload: { userId: '789' },
eventId: 'evt_no_handlers',
now: new Date('2024-03-01'),
});
await nextTick();
}).to.not.throw();
});
test('handlers are only called for their specific registered event', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'created-handler',
handler: async () => {
handlerCalls.push('created');
},
});
eventsServices.onEvent({
eventName: 'user.deleted',
handlerName: 'deleted-handler',
handler: async () => {
handlerCalls.push('deleted');
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '111', email: 'test@example.com' },
eventId: 'evt_created',
now: new Date('2024-04-01'),
});
await nextTick();
expect(handlerCalls).to.deep.equal(['created']);
});
test('handler errors are caught and do not crash the application', async () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
const handlerCalls: unknown[] = [];
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'failing-handler',
handler: async () => {
throw new Error('Handler failed');
},
});
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'successful-handler',
handler: async () => {
handlerCalls.push('success');
},
});
eventsServices.emitEvent({
eventName: 'user.created',
payload: { userId: '222', email: 'error@example.com' },
eventId: 'evt_error',
now: new Date('2024-05-01'),
});
await nextTick();
expect(handlerCalls).to.deep.equal(['success']);
});
});
describe('onEvent', () => {
test('registering a handler with a duplicate name for the same event throws an error', () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'duplicate-handler',
handler: async () => {},
});
expect(() => {
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'duplicate-handler',
handler: async () => {},
});
}).to.throw('Duplicate handler name "duplicate-handler" for event "user.created"');
});
test('the same handler name can be used for different events', () => {
const eventsServices = createEventServices<TestEvents>({ logger: createNoopLogger() });
eventsServices.onEvent({
eventName: 'user.created',
handlerName: 'shared-handler-name',
handler: async () => {},
});
expect(() => {
eventsServices.onEvent({
eventName: 'user.deleted',
handlerName: 'shared-handler-name',
handler: async () => {},
});
}).to.not.throw();
});
});
});

View File

@@ -0,0 +1,83 @@
import type { Logger } from '../../shared/logger/logger';
import type { AppEvents } from './events.types';
import { safely } from '@corentinth/chisels';
import { createError } from '../../shared/errors/errors';
import { createLogger, wrapWithLoggerContext } from '../../shared/logger/logger';
import { generateId } from '../../shared/random/ids';
import { isNil } from '../../shared/utils';
type HandlerMeta = {
emittedAt: Date;
eventId: string;
};
export type EventServices = ReturnType<typeof createEventServices<AppEvents>>;
export function createEventServices<T extends Record<string, Record<string, unknown>> = AppEvents>({ logger = createLogger({ namespace: 'events-services' }) }: { logger?: Logger } = {}) {
const handlers = new Map<keyof T, { handlerName: string; handler: (payload: T[keyof T], meta: HandlerMeta) => Promise<void> | void }[]>();
return {
onEvent<K extends keyof T>({ eventName, handlerName, handler }: {
eventName: K;
handlerName: string;
handler: (payload: T[K], meta: HandlerMeta) => Promise<void>;
}) {
const isDuplicateName = handlers.get(eventName)?.some(h => h.handlerName === handlerName) ?? false;
if (isDuplicateName) {
throw createError({
message: `Duplicate handler name "${handlerName}" for event "${String(eventName)}"`,
code: 'events.duplicate_handler_name',
statusCode: 500,
isInternal: true,
});
}
handlers.set(eventName, [
...(handlers.get(eventName) ?? []),
{ handlerName, handler: handler as (payload: T[keyof T], meta: HandlerMeta) => Promise<void> },
]);
},
emitEvent<K extends keyof T>({
eventName,
payload,
eventId = generateId({ prefix: 'evt' }),
now = new Date(),
}: {
eventName: K;
payload: T[K];
eventId?: string;
now?: Date;
}) {
const eventHandlers = handlers.get(eventName);
if (isNil(eventHandlers) || eventHandlers.length === 0) {
logger.debug(`No handlers for event: ${String(eventName)}`);
return;
}
logger.debug({
eventName,
eventId,
handlerCount: eventHandlers.length,
handlerNames: eventHandlers.map(({ handlerName }) => handlerName),
}, 'Event emitted');
setImmediate(async () => {
await Promise.allSettled(eventHandlers.map(async ({ handlerName, handler }) => {
await wrapWithLoggerContext({ eventId, eventName, handlerName }, async () => {
const [, error] = await safely(async () => handler(payload, { emittedAt: now, eventId }));
if (error) {
logger.error({ error }, 'Error in event handler');
return;
}
logger.info('Event handler executed successfully');
});
}));
});
},
};
}

View File

@@ -0,0 +1,19 @@
import type { EventServices } from './events.services';
export function createTestEventServices() {
const emittedEvents: { eventName: string; payload: Record<string, unknown> }[] = [];
const services = {
onEvent() {},
emitEvent({ eventName, payload }) {
emittedEvents.push({ eventName, payload });
},
} satisfies EventServices;
return {
...services,
getEmittedEvents() {
return emittedEvents;
},
};
}

View File

@@ -0,0 +1,4 @@
import type { DocumentEvents } from '../../documents/documents.events.types';
import type { UserEvents } from '../../users/users.events.types';
export type AppEvents = UserEvents & DocumentEvents;

View File

@@ -7,6 +7,8 @@ export type ShutdownHandlerConfig = {
};
export type ShutdownHandlerRegistration = (handlerConfig: ShutdownHandlerConfig) => void;
export type ShutdownServices = ReturnType<typeof createGracefulShutdownService>;
export function createGracefulShutdownService({ logger = createLogger({ namespace: 'graceful-shutdown' }) }: { logger?: Logger } = {}) {
const shutdownHandlers: ShutdownHandlerConfig[] = [];

View File

@@ -0,0 +1,30 @@
import type { Logger } from '../../shared/logger/logger';
import type { ShutdownServices } from './graceful-shutdown.services';
import process from 'node:process';
import { createLogger } from '../../shared/logger/logger';
async function gracefulShutdown({ signal, cause, shutdownServices, logger, exitCode = 0 }: { signal?: string; cause: string; shutdownServices: ShutdownServices; logger: Logger; exitCode?: number }) {
logger.info({ signal, cause }, 'Shutting down gracefully...');
await shutdownServices.executeShutdownHandlers();
logger.info('Shutdown complete, exiting process');
// Let logs flush
setTimeout(() => process.exit(exitCode), 500);
}
export function registerShutdownHooks({ shutdownServices, logger = createLogger({ namespace: 'graceful-shutdown' }) }: { shutdownServices: ShutdownServices; logger?: Logger }) {
process.on('uncaughtException', (error) => {
logger.error({ error }, 'Uncaught exception');
void gracefulShutdown({ cause: 'uncaughtException', shutdownServices, logger, exitCode: 1 });
});
process.on('unhandledRejection', (error) => {
logger.error({ error }, 'Unhandled promise rejection');
void gracefulShutdown({ cause: 'unhandledRejection', shutdownServices, logger, exitCode: 1 });
});
process.on('SIGINT', () => void gracefulShutdown({ cause: 'Interrupt signal', signal: 'SIGINT', shutdownServices, logger }));
process.on('SIGTERM', () => void gracefulShutdown({ cause: 'Termination signal', signal: 'SIGTERM', shutdownServices, logger }));
}

View File

@@ -3,13 +3,14 @@ import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../../../config/config.test-utils';
import { createInMemoryDatabase } from '../../database/database.test-utils';
import { createServer } from '../../server';
import { createTestServerDependencies } from '../../server.test-utils';
describe('health check routes e2e', () => {
describe('health check', () => {
describe('the /api/health is a publicly accessible route that provides health information about the server', () => {
test('when the database is healthy, the /api/health returns 200', async () => {
const { db } = await createInMemoryDatabase();
const { app } = await createServer({ db, config: overrideConfig() });
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const response = await app.request('/api/health');
@@ -28,7 +29,7 @@ describe('health check routes e2e', () => {
},
} as unknown as Database;
const { app } = await createServer({ db, config: overrideConfig() });
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const response = await app.request('/api/health');

View File

@@ -2,11 +2,12 @@ import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../../../config/config.test-utils';
import { createInMemoryDatabase } from '../../database/database.test-utils';
import { createServer } from '../../server';
import { createTestServerDependencies } from '../../server.test-utils';
describe('ping routes e2e', () => {
test('the /api/ping is a publicly accessible route that always returns a 200 with a status ok', async () => {
const { db } = await createInMemoryDatabase();
const { app } = await createServer({ db, config: overrideConfig() });
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const response = await app.request('/api/ping');

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { getProcessMode } from './process.models';
describe('process models', () => {
describe('getProcessMode', () => {
test('when the process mode is set to all, the api server and worker should be enabled', () => {
expect(
getProcessMode({ config: { processMode: 'all' } }),
).to.eql({
isWebMode: true,
isWorkerMode: true,
processMode: 'all',
});
});
test('when the process mode is set to web, only the api server should be enabled', () => {
expect(
getProcessMode({ config: { processMode: 'web' } }),
).to.eql({
isWebMode: true,
isWorkerMode: false,
processMode: 'web',
});
});
test('when the process mode is set to worker, only the worker should be enabled', () => {
expect(
getProcessMode({ config: { processMode: 'worker' } }),
).to.eql({
isWebMode: false,
isWorkerMode: true,
processMode: 'worker',
});
});
});
});

View File

@@ -0,0 +1,9 @@
export function getProcessMode({ config }: { config: { processMode: 'web' | 'worker' | 'all' } }) {
const { processMode } = config;
return {
isWebMode: processMode === 'all' || processMode === 'web',
isWorkerMode: processMode === 'all' || processMode === 'worker',
processMode,
};
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../config/config.test-utils';
import { createInMemoryDatabase } from './database/database.test-utils';
import { createServer } from './server';
import { createTestServerDependencies } from './server.test-utils';
function setValidParams(path: string) {
const newPath = path
@@ -28,7 +29,7 @@ function setValidParams(path: string) {
describe('server routes', () => {
test('all routes should respond with a 401 when non-authenticated, except for public and auth-related routes', async () => {
const { db } = await createInMemoryDatabase();
const { app } = await createServer({ db, config: overrideConfig() });
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig() }));
const publicRoutes = [
'GET /api/ping',

View File

@@ -1,4 +1,5 @@
import type { RouteDefinitionContext } from './server.types';
import { registerAdminRoutes } from '../admin/admin.routes';
import { registerApiKeysRoutes } from '../api-keys/api-keys.routes';
import { registerConfigRoutes } from '../config/config.routes';
import { registerDocumentActivityRoutes } from '../documents/document-activity/document-activity.routes';
@@ -29,4 +30,5 @@ export function registerRoutes(context: RouteDefinitionContext) {
registerWebhooksRoutes(context);
registerInvitationsRoutes(context);
registerDocumentActivityRoutes(context);
registerAdminRoutes(context);
}

View File

@@ -0,0 +1,47 @@
import type { GlobalDependencies } from './server.types';
import { overrideConfig } from '../config/config.test-utils';
import { createDocumentSearchServices } from '../documents/document-search/document-search.registry';
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
import { createEmailsServices } from '../emails/emails.services';
import { createSubscriptionsServices } from '../subscriptions/subscriptions.services';
import { createInMemoryTaskServices } from '../tasks/tasks.test-utils';
import { createDummyTrackingServices } from '../tracking/tracking.services';
import { createAuthEmailsServices } from './auth/auth.emails.services';
import { getAuth } from './auth/auth.services';
import { setupDatabase } from './database/database';
import { registerEventHandlers } from './events/events.handlers';
import { createEventServices } from './events/events.services';
import { createGracefulShutdownService } from './graceful-shutdown/graceful-shutdown.services';
export function createTestServerDependencies(overrides: Partial<GlobalDependencies> = {}): GlobalDependencies {
const config = overrides.config ?? overrideConfig();
const shutdownServices = overrides.shutdownServices ?? createGracefulShutdownService();
const db = overrides.db ?? setupDatabase({ ...config.database, shutdownServices }).db;
const documentsStorageService = overrides.documentsStorageService ?? createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taskServices = overrides.taskServices ?? createInMemoryTaskServices();
const trackingServices = overrides.trackingServices ?? createDummyTrackingServices();
const eventServices = overrides.eventServices ?? createEventServices();
const emailsServices = overrides.emailsServices ?? createEmailsServices({ config });
const authEmailsServices = createAuthEmailsServices({ emailsServices });
const auth = overrides.auth ?? getAuth({ db, config, authEmailsServices, eventServices }).auth;
const subscriptionsServices = overrides.subscriptionsServices ?? createSubscriptionsServices({ config });
const documentSearchServices = overrides.documentSearchServices ?? createDocumentSearchServices({ db, config });
registerEventHandlers({ eventServices, trackingServices, db, documentSearchServices });
return {
config,
db,
shutdownServices,
documentsStorageService,
taskServices,
trackingServices,
eventServices,
emailsServices,
auth,
subscriptionsServices,
documentSearchServices,
};
}

View File

@@ -1,51 +1,17 @@
import type { GlobalDependencies, ServerInstanceGenerics } from './server.types';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { secureHeaders } from 'hono/secure-headers';
import { createApiKeyMiddleware } from '../api-keys/api-keys.middlewares';
import { parseConfig } from '../config/config';
import { createDocumentSearchServices } from '../documents/document-search/document-search.registry';
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
import { createEmailsServices } from '../emails/emails.services';
import { createLoggerMiddleware } from '../shared/logger/logger.middleware';
import { createSubscriptionsServices } from '../subscriptions/subscriptions.services';
import { createTaskServices } from '../tasks/tasks.services';
import { createTrackingServices } from '../tracking/tracking.services';
import { createAuthEmailsServices } from './auth/auth.emails.services';
import { getAuth } from './auth/auth.services';
import { setupDatabase } from './database/database';
import { createCorsMiddleware } from './middlewares/cors.middleware';
import { registerErrorMiddleware } from './middlewares/errors.middleware';
import { createTimeoutMiddleware } from './middlewares/timeout.middleware';
import { registerRoutes } from './server.routes';
import { registerStaticAssetsRoutes } from './static-assets/static-assets.routes';
async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>): Promise<GlobalDependencies> {
const config = partialDeps.config ?? (await parseConfig()).config;
const db = partialDeps.db ?? setupDatabase(config.database).db;
const emailsServices = createEmailsServices({ config });
const trackingServices = createTrackingServices({ config });
const auth = partialDeps.auth ?? getAuth({ db, config, authEmailsServices: createAuthEmailsServices({ emailsServices }), trackingServices }).auth;
const subscriptionsServices = createSubscriptionsServices({ config });
const taskServices = partialDeps.taskServices ?? createTaskServices({ config });
const documentsStorageService = partialDeps.documentsStorageService ?? createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const documentSearchServices = partialDeps.documentSearchServices ?? createDocumentSearchServices({ db, config });
return {
documentsStorageService,
config,
db,
auth,
emailsServices,
subscriptionsServices,
trackingServices,
taskServices,
documentSearchServices,
};
}
export async function createServer(initialDeps: Partial<GlobalDependencies> = {}) {
const dependencies = await createGlobalDependencies(initialDeps);
const { config, trackingServices, db } = dependencies;
export function createServer(dependencies: GlobalDependencies) {
const { config, db, shutdownServices } = dependencies;
const app = new Hono<ServerInstanceGenerics>({ strict: true });
@@ -63,8 +29,22 @@ export async function createServer(initialDeps: Partial<GlobalDependencies> = {}
return {
app,
shutdown: async () => {
await trackingServices.shutdown();
start: ({ onStarted }: { onStarted?: (args: { port: number }) => void }) => {
const server = serve(
{
fetch: app.fetch,
port: config.server.port,
hostname: config.server.hostname,
},
onStarted,
);
shutdownServices.registerShutdownHandler({
id: 'web-server-close',
handler: () => {
server.close();
},
});
},
};
}

View File

@@ -10,6 +10,8 @@ import type { TrackingServices } from '../tracking/tracking.services';
import type { Auth } from './auth/auth.services';
import type { Session } from './auth/auth.types';
import type { Database } from './database/database.types';
import type { EventServices } from './events/events.services';
import type { ShutdownServices } from './graceful-shutdown/graceful-shutdown.services';
export type ServerInstanceGenerics = {
Variables: {
@@ -34,6 +36,8 @@ export type GlobalDependencies = {
taskServices: TaskServices;
documentsStorageService: DocumentStorageService;
documentSearchServices: DocumentSearchServices;
eventServices: EventServices;
shutdownServices: ShutdownServices;
};
export type RouteDefinitionContext = { app: ServerInstance } & GlobalDependencies;

View File

@@ -1,3 +1,4 @@
import type { Context } from 'hono';
import type { Config } from '../../config/config.types';
import type { ServerInstance } from '../server.types';
import { readFile } from 'node:fs/promises';
@@ -26,7 +27,7 @@ export function registerStaticAssetsRoutes({ app, config }: { app: ServerInstanc
index: `unexisting-file-${Math.random().toString(36).substring(2)}`,
});
return staticMiddleware(context, next);
return staticMiddleware(context as Context<any, string>, next);
},
)
.use(

View File

@@ -1,5 +1,8 @@
import type { DeepPartial } from '@corentinth/chisels';
import type { Logger } from '@crowlog/logger';
import type { Config } from './config.types';
import process from 'node:process';
import { safelySync } from '@corentinth/chisels';
import { merge, pick } from 'lodash-es';
export function getPublicConfig({ config }: { config: Config }) {
@@ -45,3 +48,18 @@ export function getClientBaseUrl({ config }: { config: Config }) {
clientBaseUrl: config.appBaseUrl ?? config.client.baseUrl,
};
}
export function exitProcessDueToConfigError({ error, logger }: { error: Error; logger: Logger }): never {
logger.error({ error }, `Invalid configuration: ${error.message}`);
process.exit(1);
}
export function validateParsedConfig({ config, logger, validators }: { config: Config; logger: Logger; validators: ((args: { config: Config }) => void)[] }) {
for (const validator of validators) {
const [,error] = safelySync(() => validator({ config }));
if (error) {
exitProcessDueToConfigError({ error, logger });
}
}
}

View File

@@ -1,11 +1,11 @@
import type { ConfigDefinition } from 'figue';
import process from 'node:process';
import { safelySync } from '@corentinth/chisels';
import { memoizeOnce, safelySync } from '@corentinth/chisels';
import { loadConfig } from 'c12';
import { defineConfig } from 'figue';
import { memoize } from 'lodash-es';
import { z } from 'zod';
import { authConfig } from '../app/auth/auth.config';
import { ensureAuthSecretIsNotDefaultInProduction } from '../app/auth/auth.config.models';
import { databaseConfig } from '../app/database/database.config';
import { documentSearchConfig } from '../documents/document-search/document-search.config';
import { documentsConfig } from '../documents/documents.config';
@@ -20,6 +20,7 @@ import { isString } from '../shared/utils';
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
import { tasksConfig } from '../tasks/tasks.config';
import { trackingConfig } from '../tracking/tracking.config';
import { exitProcessDueToConfigError, validateParsedConfig } from './config.models';
import { booleanishSchema, trustedOriginsSchema } from './config.schemas';
export const configDefinition = {
@@ -137,18 +138,25 @@ export async function parseConfig({ env = process.env }: { env?: Record<string,
const [configResult, configError] = safelySync(() => defineConfig(configDefinition, { envSource: env, defaults: configFromFile }));
if (configError) {
logger.error({ error: configError }, `Invalid config: ${configError.message}`);
process.exit(1);
exitProcessDueToConfigError({ error: configError, logger });
}
const { config } = configResult;
validateParsedConfig({
config,
logger,
validators: [
ensureAuthSecretIsNotDefaultInProduction,
],
});
return { config };
}
// Permit to load the default config, regardless of environment variables, and config files
// memoized to avoid re-parsing the config definition
export const loadDryConfig = memoize(() => {
export const loadDryConfig = memoizeOnce(() => {
const { config } = defineConfig(configDefinition);
return { config };

View File

@@ -31,7 +31,7 @@ async function saveDocumentActivity({
documentId: string;
event: DocumentActivityEvent;
eventData?: Record<string, unknown>;
userId?: string;
userId?: string | null;
tagId?: string;
db: Database;
}) {

View File

@@ -16,7 +16,7 @@ export async function registerDocumentActivityLog({
documentId: string;
event: DocumentActivityEvent;
eventData?: Record<string, unknown>;
userId?: string;
userId?: string | null;
tagId?: string;
documentActivityRepository: DocumentActivityRepository;
logger?: Logger;

View File

@@ -8,6 +8,7 @@ export function createDatabaseFts5DocumentSearchServices({ db }: { db: Database
return {
name: DATABASE_FTS5_DOCUMENT_SEARCH_PROVIDER_NAME,
searchDocuments: async ({ searchQuery, organizationId, pageIndex, pageSize }) => {
const { documents } = await documentsSearchRepository.searchOrganizationDocuments({ organizationId, searchQuery, pageIndex, pageSize });
@@ -17,5 +18,18 @@ export function createDatabaseFts5DocumentSearchServices({ db }: { db: Database
},
};
},
indexDocument: async ({ document }) => {
await documentsSearchRepository.indexDocument({ document });
},
updateDocument: async ({ document, documentId }) => {
await documentsSearchRepository.updateDocument({ documentId, document });
},
deleteDocument: async ({ documentId }) => {
await documentsSearchRepository.deleteDocument({ documentId });
},
};
}

View File

@@ -1,36 +1,219 @@
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../../../app/database/database.test-utils';
import { ORGANIZATION_ROLES } from '../../../organizations/organizations.constants';
import { documentsTable } from '../../documents.table';
import { createDocumentSearchRepository } from './database-fts5.repository';
describe('database-fts5 repository', () => {
describe('searchOrganizationDocuments', () => {
test('provides full text search on document name, original name, and content', async () => {
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 }],
documents: [
{ id: 'doc-1', organizationId: 'organization-1', createdBy: 'user-1', name: 'Document 1', originalName: 'document-1.pdf', content: 'lorem ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash1' },
{ id: 'doc-2', organizationId: 'organization-1', createdBy: 'user-1', name: 'File 2', originalName: 'document-2.pdf', content: 'lorem', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash2' },
{ id: 'doc-3', organizationId: 'organization-1', createdBy: 'user-1', name: 'File 3', originalName: 'document-3.pdf', content: 'ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash3' },
],
});
const documents = [
{ id: 'doc-1', organizationId: 'organization-1', name: 'Document 1', originalName: 'document-1.pdf', content: 'lorem ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash1', isDeleted: false },
{ id: 'doc-2', organizationId: 'organization-1', name: 'File 2', originalName: 'document-2.pdf', content: 'lorem', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash2', isDeleted: false },
{ id: 'doc-3', organizationId: 'organization-1', name: 'File 3', originalName: 'document-3.pdf', content: 'ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash3', isDeleted: false },
];
// Rebuild the FTS index since we are using an in-memory database
await db.$client.execute(`INSERT INTO documents_fts(documents_fts) VALUES('rebuild');`);
const { db } = await createInMemoryDatabase({
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
documents,
});
const documentsSearchRepository = createDocumentSearchRepository({ db });
const { documents } = await documentsSearchRepository.searchOrganizationDocuments({
await Promise.all(
documents.map(async document => documentsSearchRepository.indexDocument({ document })),
);
const { documents: searchResults } = await documentsSearchRepository.searchOrganizationDocuments({
organizationId: 'organization-1',
searchQuery: 'lorem',
pageIndex: 0,
pageSize: 10,
});
expect(documents).to.have.length(2);
expect(documents.map(doc => doc.id)).to.eql(['doc-2', 'doc-1']);
expect(searchResults).to.have.length(2);
expect(searchResults.map(doc => doc.id)).to.eql(['doc-2', 'doc-1']);
});
});
describe('indexDocument', () => {
test('adds a document to the FTS index and makes it searchable', async () => {
const { db } = await createInMemoryDatabase({
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
});
const documentsSearchRepository = createDocumentSearchRepository({ db });
// Insert a document in the documents table (no automatic FTS indexing after migration)
await db.insert(documentsTable).values({
id: 'doc-1',
organizationId: 'organization-1',
name: 'New Document',
originalName: 'new-doc.pdf',
content: 'searchable content here',
originalStorageKey: 'storage-key',
mimeType: 'application/pdf',
originalSha256Hash: 'hash1',
}).execute();
// Manually index the document to FTS
await documentsSearchRepository.indexDocument({
document: {
id: 'doc-1',
name: 'New Document',
isDeleted: false,
organizationId: 'organization-1',
originalName: 'new-doc.pdf',
content: 'searchable content here',
},
});
// Verify document is searchable
const { documents } = await documentsSearchRepository.searchOrganizationDocuments({
organizationId: 'organization-1',
searchQuery: 'searchable',
pageIndex: 0,
pageSize: 10,
});
expect(documents).to.have.length(1);
expect(documents[0]?.id).to.equal('doc-1');
});
});
describe('updateDocument', () => {
test('updates document fields in the FTS index to reflect changes in search results', async () => {
const { db } = await createInMemoryDatabase({
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
});
const documentsSearchRepository = createDocumentSearchRepository({ db });
// Insert document and manually index it with original content
await db.insert(documentsTable).values({
id: 'doc-1',
organizationId: 'organization-1',
name: 'Original Name',
originalName: 'original.pdf',
content: 'original content',
originalStorageKey: '',
mimeType: 'application/pdf',
originalSha256Hash: 'hash1',
}).execute();
await documentsSearchRepository.indexDocument({
document: {
id: 'doc-1',
name: 'Original Name',
originalName: 'original.pdf',
content: 'original content',
isDeleted: false,
organizationId: 'organization-1',
},
});
// Update the FTS index with new content
await documentsSearchRepository.updateDocument({
documentId: 'doc-1',
document: {
name: 'Updated Name',
originalName: 'updated.pdf',
content: 'updated content with new keywords',
},
});
const resultsWithOldKeyword = await documentsSearchRepository.searchOrganizationDocuments({
organizationId: 'organization-1',
searchQuery: 'original',
pageIndex: 0,
pageSize: 10,
});
const resultsWithNewKeyword = await documentsSearchRepository.searchOrganizationDocuments({
organizationId: 'organization-1',
searchQuery: 'updated',
pageIndex: 0,
pageSize: 10,
});
expect(resultsWithOldKeyword.documents).to.have.length(0);
expect(resultsWithNewKeyword.documents).to.have.length(1);
expect(resultsWithNewKeyword.documents[0]?.id).to.equal('doc-1');
});
});
describe('deleteDocument', () => {
test('removes a document from the FTS index and makes it unsearchable', async () => {
const { db } = await createInMemoryDatabase({
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
});
const documentsSearchRepository = createDocumentSearchRepository({ db });
// Insert documents and manually index them
await db.insert(documentsTable).values([
{
id: 'doc-1',
organizationId: 'organization-1',
name: 'Document to Delete',
originalName: 'delete-me.pdf',
content: 'this will be deleted',
originalStorageKey: '',
mimeType: 'application/pdf',
originalSha256Hash: 'hash1',
},
{
id: 'doc-2',
organizationId: 'organization-1',
name: 'Document to Keep',
originalName: 'keep-me.pdf',
content: 'this will stay',
originalStorageKey: '',
mimeType: 'application/pdf',
originalSha256Hash: 'hash2',
},
]).execute();
await documentsSearchRepository.indexDocument({
document: {
id: 'doc-1',
name: 'Document to Delete',
originalName: 'delete-me.pdf',
content: 'this will be deleted',
isDeleted: false,
organizationId: 'organization-1',
},
});
await documentsSearchRepository.indexDocument({
document: {
id: 'doc-2',
name: 'Document to Keep',
originalName: 'keep-me.pdf',
content: 'this will stay',
isDeleted: false,
organizationId: 'organization-1',
},
});
const beforeDelete = await documentsSearchRepository.searchOrganizationDocuments({
organizationId: 'organization-1',
searchQuery: 'deleted',
pageIndex: 0,
pageSize: 10,
});
expect(beforeDelete.documents).to.have.length(1);
await documentsSearchRepository.deleteDocument({ documentId: 'doc-1' });
const afterDelete = await documentsSearchRepository.searchOrganizationDocuments({
organizationId: 'organization-1',
searchQuery: 'deleted',
pageIndex: 0,
pageSize: 10,
});
expect(afterDelete.documents).to.have.length(0);
});
});
});

View File

@@ -1,13 +1,19 @@
import type { Database } from '../../../app/database/database.types';
import type { DocumentSearchableData } from '../document-search.types';
import { injectArguments } from '@corentinth/chisels';
import { sql } from 'drizzle-orm';
import { and, eq, getTableColumns, sql } from 'drizzle-orm';
import { omitUndefined } from '../../../shared/utils';
import { documentsTable } from '../../documents.table';
import { documentsFtsTable } from './database-fts5.tables';
export type DocumentSearchRepository = ReturnType<typeof createDocumentSearchRepository>;
export function createDocumentSearchRepository({ db }: { db: Database }) {
return injectArguments({
searchOrganizationDocuments,
indexDocument,
updateDocument,
deleteDocument,
}, { db });
}
@@ -17,18 +23,54 @@ async function searchOrganizationDocuments({ organizationId, searchQuery, pageIn
const cleanedSearchQuery = searchQuery.replace(/"/g, '').replace(/\*/g, '').trim();
const formattedSearchQuery = cleanedSearchQuery.includes(' ') ? cleanedSearchQuery : `${cleanedSearchQuery}*`;
const result = await db.run(sql`
SELECT * FROM ${documentsTable}
JOIN documents_fts ON documents_fts.id = ${documentsTable.id}
WHERE ${documentsTable.organizationId} = ${organizationId}
AND ${documentsTable.isDeleted} = 0
AND documents_fts MATCH ${formattedSearchQuery}
ORDER BY rank
LIMIT ${pageSize}
OFFSET ${pageIndex * pageSize}
`);
const documents = await db.select(getTableColumns(documentsTable))
.from(documentsTable)
.innerJoin(
documentsFtsTable,
eq(documentsFtsTable.id, documentsTable.id),
)
.where(and(
eq(documentsTable.organizationId, organizationId),
eq(documentsTable.isDeleted, false),
eq(documentsFtsTable, formattedSearchQuery), // Match and eq works the same for FTS5 virtual tables
))
.orderBy(sql`rank`)
.limit(pageSize)
.offset(pageIndex * pageSize);
return {
documents: result.rows as unknown as (typeof documentsTable.$inferSelect)[],
};
return { documents };
}
async function indexDocument({ document, db }: { document: DocumentSearchableData; db: Database }) {
await db
.insert(documentsFtsTable)
.values({
id: document.id,
name: document.name,
originalName: document.originalName,
content: document.content,
});
}
async function updateDocument({ documentId, document, db }: { documentId: string; document: { content?: string; name?: string; originalName?: string }; db: Database }) {
const dataToUpdate = omitUndefined({
name: document.name,
originalName: document.originalName,
content: document.content,
});
if (Object.keys(dataToUpdate).length === 0) {
return;
}
await db
.update(documentsFtsTable)
.set(dataToUpdate)
.where(eq(documentsFtsTable.id, documentId));
}
async function deleteDocument({ documentId, db }: { documentId: string; db: Database }) {
await db
.delete(documentsFtsTable)
.where(eq(documentsFtsTable.id, documentId));
}

View File

@@ -0,0 +1,8 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const documentsFtsTable = sqliteTable('documents_fts', {
id: text('id').notNull(),
name: text('name').notNull(),
originalName: text('original_name').notNull(),
content: text('content').notNull(),
});

View File

@@ -1,3 +1,12 @@
export type DocumentSearchableData = {
id: string;
name: string;
originalName: string;
content: string;
isDeleted: boolean;
organizationId: string;
};
export type DocumentSearchServices = {
name: string;
searchDocuments: (args: {
@@ -6,4 +15,8 @@ export type DocumentSearchServices = {
pageIndex: number;
pageSize: number;
}) => Promise<{ searchResults: { documents: { id: string; name: string }[] } }>;
indexDocument: (args: { document: DocumentSearchableData }) => Promise<void>;
updateDocument: (args: { documentId: string; document: Partial<Omit<DocumentSearchableData, 'id'>> }) => Promise<void>;
deleteDocument: (args: { documentId: string }) => Promise<void>;
};

View File

@@ -0,0 +1,311 @@
import type { Document } from '../../documents.types';
import type { DocumentSearchServices } from '../document-search.types';
import { createNoopLogger } from '@crowlog/logger';
import { describe, expect, test } from 'vitest';
import { createEventServices } from '../../../app/events/events.services';
import { nextTick } from '../../../shared/async/defer.test-utils';
import { registerSyncDocumentSearchEventHandlers } from './sync-document-search.handlers';
function createTestSearchServices() {
const methodsArgs = {
searchDocuments: [] as Parameters<DocumentSearchServices['searchDocuments']>[0][],
indexDocument: [] as Parameters<DocumentSearchServices['indexDocument']>[0][],
updateDocument: [] as Parameters<DocumentSearchServices['updateDocument']>[0][],
deleteDocument: [] as Parameters<DocumentSearchServices['deleteDocument']>[0][],
};
const searchServices: DocumentSearchServices = {
name: 'test-search-service',
searchDocuments: async (args) => {
methodsArgs.searchDocuments.push(args);
return { searchResults: { documents: [] } };
},
indexDocument: async (args) => {
methodsArgs.indexDocument.push(args);
},
updateDocument: async (args) => {
methodsArgs.updateDocument.push(args);
},
deleteDocument: async (args) => {
methodsArgs.deleteDocument.push(args);
},
};
return {
...searchServices,
getMethodsArguments: () => methodsArgs,
};
}
describe('sync-document-search event handlers', () => {
describe('registerSyncDocumentSearchEventHandlers', () => {
test('when document.created event fires, the document is indexed in the search service', async () => {
const eventServices = createEventServices({ logger: createNoopLogger() });
const documentSearchServices = createTestSearchServices();
registerSyncDocumentSearchEventHandlers({ eventServices, documentSearchServices });
const document: Document = {
id: 'doc-1',
organizationId: 'organization-1',
name: 'Test Document',
originalName: 'test-document.pdf',
content: 'searchable content',
mimeType: 'application/pdf',
originalStorageKey: 'storage-key',
originalSha256Hash: 'hash1',
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'user-1',
originalSize: 1024,
deletedAt: null,
deletedBy: null,
fileEncryptionAlgorithm: null,
fileEncryptionKekVersion: null,
fileEncryptionKeyWrapped: null,
};
eventServices.emitEvent({
eventName: 'document.created',
payload: { document },
});
await nextTick();
expect(documentSearchServices.getMethodsArguments()).to.eql({
searchDocuments: [],
indexDocument: [{ document }],
updateDocument: [],
deleteDocument: [],
});
});
test('when document.updated event fires, the document is updated in the search service with the changes', async () => {
const eventServices = createEventServices({ logger: createNoopLogger() });
const documentSearchServices = createTestSearchServices();
registerSyncDocumentSearchEventHandlers({ eventServices, documentSearchServices });
const document: Document = {
id: 'doc-1',
organizationId: 'organization-1',
name: 'Updated Document',
originalName: 'updated-document.pdf',
content: 'updated content',
mimeType: 'application/pdf',
originalStorageKey: 'storage-key',
originalSha256Hash: 'hash1',
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'user-1',
originalSize: 1024,
deletedAt: null,
deletedBy: null,
fileEncryptionAlgorithm: null,
fileEncryptionKekVersion: null,
fileEncryptionKeyWrapped: null,
};
const changes = {
name: 'Updated Document',
content: 'updated content',
};
eventServices.emitEvent({
eventName: 'document.updated',
payload: { document, changes, userId: 'user-1' },
});
await nextTick();
expect(documentSearchServices.getMethodsArguments()).to.eql({
searchDocuments: [],
indexDocument: [],
updateDocument: [{
documentId: 'doc-1',
document: changes,
}],
deleteDocument: [],
});
});
test('when document.trashed event fires, the document is marked as deleted in the search service', async () => {
const eventServices = createEventServices({ logger: createNoopLogger() });
const documentSearchServices = createTestSearchServices();
registerSyncDocumentSearchEventHandlers({ eventServices, documentSearchServices });
eventServices.emitEvent({
eventName: 'document.trashed',
payload: {
documentId: 'doc-1',
organizationId: 'organization-1',
trashedBy: 'user-1',
},
});
await nextTick();
expect(documentSearchServices.getMethodsArguments()).to.eql({
searchDocuments: [],
indexDocument: [],
updateDocument: [{
documentId: 'doc-1',
document: { isDeleted: true },
}],
deleteDocument: [],
});
});
test('when document.restored event fires, the document is marked as not deleted in the search service', async () => {
const eventServices = createEventServices({ logger: createNoopLogger() });
const documentSearchServices = createTestSearchServices();
registerSyncDocumentSearchEventHandlers({ eventServices, documentSearchServices });
eventServices.emitEvent({
eventName: 'document.restored',
payload: {
documentId: 'doc-1',
organizationId: 'organization-1',
restoredBy: 'user-1',
},
});
await nextTick();
expect(documentSearchServices.getMethodsArguments()).to.eql({
searchDocuments: [],
indexDocument: [],
updateDocument: [{
documentId: 'doc-1',
document: { isDeleted: false },
}],
deleteDocument: [],
});
});
test('when document.deleted event fires, the document is removed from the search service', async () => {
const eventServices = createEventServices({ logger: createNoopLogger() });
const documentSearchServices = createTestSearchServices();
registerSyncDocumentSearchEventHandlers({ eventServices, documentSearchServices });
eventServices.emitEvent({
eventName: 'document.deleted',
payload: {
documentId: 'doc-1',
organizationId: 'organization-1',
},
});
await nextTick();
expect(documentSearchServices.getMethodsArguments()).to.eql({
searchDocuments: [],
indexDocument: [],
updateDocument: [],
deleteDocument: [{ documentId: 'doc-1' }],
});
});
test('multiple events are handled independently and in sequence', async () => {
const eventServices = createEventServices({ logger: createNoopLogger() });
const documentSearchServices = createTestSearchServices();
registerSyncDocumentSearchEventHandlers({ eventServices, documentSearchServices });
const document: Document = {
id: 'doc-1',
organizationId: 'organization-1',
name: 'Test Document',
originalName: 'test-document.pdf',
content: 'searchable content',
mimeType: 'application/pdf',
originalStorageKey: 'storage-key',
originalSha256Hash: 'hash1',
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'user-1',
originalSize: 1024,
deletedAt: null,
deletedBy: null,
fileEncryptionAlgorithm: null,
fileEncryptionKekVersion: null,
fileEncryptionKeyWrapped: null,
};
// Emit multiple events in sequence
eventServices.emitEvent({
eventName: 'document.created',
payload: { document },
});
eventServices.emitEvent({
eventName: 'document.updated',
payload: {
document,
changes: { name: 'Updated Name' },
userId: 'user-1',
},
});
eventServices.emitEvent({
eventName: 'document.trashed',
payload: {
documentId: 'doc-1',
organizationId: 'organization-1',
trashedBy: 'user-1',
},
});
eventServices.emitEvent({
eventName: 'document.restored',
payload: {
documentId: 'doc-1',
organizationId: 'organization-1',
restoredBy: 'user-1',
},
});
eventServices.emitEvent({
eventName: 'document.deleted',
payload: {
documentId: 'doc-1',
organizationId: 'organization-1',
},
});
await nextTick();
expect(documentSearchServices.getMethodsArguments()).to.eql({
searchDocuments: [],
indexDocument: [{ document }],
updateDocument: [
{
documentId: 'doc-1',
document: { name: 'Updated Name' },
},
{
documentId: 'doc-1',
document: { isDeleted: true },
},
{
documentId: 'doc-1',
document: { isDeleted: false },
},
],
deleteDocument: [{ documentId: 'doc-1' }],
});
});
});
});

View File

@@ -0,0 +1,50 @@
import type { EventServices } from '../../../app/events/events.services';
import type { DocumentSearchServices } from '../document-search.types';
/**
* Wires up document events to the search service to asynchronously synchronize the service with document changes.
*/
export function registerSyncDocumentSearchEventHandlers({ eventServices, documentSearchServices }: {
eventServices: EventServices;
documentSearchServices: DocumentSearchServices;
}) {
eventServices.onEvent({
eventName: 'document.created',
handlerName: 'index-document-in-search-service',
async handler({ document }) {
await documentSearchServices.indexDocument({ document });
},
});
eventServices.onEvent({
eventName: 'document.updated',
handlerName: 'update-document-in-search-service',
async handler({ document, changes }) {
await documentSearchServices.updateDocument({ document: changes, documentId: document.id });
},
});
eventServices.onEvent({
eventName: 'document.trashed',
handlerName: 'mark-document-deleted-in-search-service',
async handler({ documentId }) {
await documentSearchServices.updateDocument({ documentId, document: { isDeleted: true } });
},
});
eventServices.onEvent({
eventName: 'document.restored',
handlerName: 'restore-document-in-search-service',
async handler({ documentId }) {
await documentSearchServices.updateDocument({ documentId, document: { isDeleted: false } });
},
});
eventServices.onEvent({
eventName: 'document.deleted',
handlerName: 'remove-document-from-search-service',
async handler({ documentId }) {
await documentSearchServices.deleteDocument({ documentId });
},
});
}

View File

@@ -0,0 +1,16 @@
import type { Document } from './documents.types';
export type DocumentEvents = {
'document.created': { document: Document };
'document.trashed': { documentId: string; organizationId: string; trashedBy: string }; // Soft deleted by moving to trash
'document.restored': { documentId: string; organizationId: string; restoredBy: string };
'document.updated': {
userId?: string;
document: Document;
changes: {
name?: string;
content?: string;
};
};
'document.deleted': { documentId: string; organizationId: string }; // Hard deleted from trash
};

View File

@@ -36,6 +36,7 @@ export function createDocumentsRepository({ db }: { db: Database }) {
getAllOrganizationDocumentsIterator,
getAllOrganizationUndeletedDocumentsIterator,
updateDocument,
getGlobalDocumentsStats,
},
{ db },
);
@@ -294,6 +295,7 @@ async function getExpiredDeletedDocuments({ db, expirationDelayInDays, now = new
const documents = await db.select({
id: documentsTable.id,
originalStorageKey: documentsTable.originalStorageKey,
organizationId: documentsTable.organizationId,
}).from(documentsTable).where(
and(
eq(documentsTable.isDeleted, true),
@@ -343,6 +345,7 @@ async function getAllOrganizationTrashDocuments({ organizationId, db }: { organi
const documents = await db.select({
id: documentsTable.id,
originalStorageKey: documentsTable.originalStorageKey,
organizationId: documentsTable.organizationId,
}).from(documentsTable).where(
and(
eq(documentsTable.organizationId, organizationId),
@@ -413,9 +416,43 @@ async function updateDocument({ documentId, organizationId, name, content, db }:
.returning();
if (isNil(document)) {
// This should never happen, but for type safety
throw createDocumentNotFoundError();
}
return { document };
}
async function getGlobalDocumentsStats({ db }: { db: Database }) {
const [record] = await db
.select({
totalDocumentsCount: count(documentsTable.id),
totalDocumentsSize: sql<number>`COALESCE(SUM(${documentsTable.originalSize}), 0)`.as('totalDocumentsSize'),
deletedDocumentsCount: sql<number>`COUNT(${documentsTable.id}) FILTER (WHERE ${documentsTable.isDeleted} = true)`.as('deletedDocumentsCount'),
documentsCount: sql<number>`COUNT(${documentsTable.id}) FILTER (WHERE ${documentsTable.isDeleted} = false)`.as('documentsCount'),
documentsSize: sql<number>`COALESCE(SUM(${documentsTable.originalSize}) FILTER (WHERE ${documentsTable.isDeleted} = false), 0)`.as('documentsSize'),
deletedDocumentsSize: sql<number>`COALESCE(SUM(${documentsTable.originalSize}) FILTER (WHERE ${documentsTable.isDeleted} = true), 0)`.as('deletedDocumentsSize'),
})
.from(documentsTable);
if (isNil(record)) {
return {
documentsCount: 0,
documentsSize: 0,
deletedDocumentsCount: 0,
deletedDocumentsSize: 0,
totalDocumentsCount: 0,
totalDocumentsSize: 0,
};
}
const { documentsCount, documentsSize, deletedDocumentsCount, deletedDocumentsSize, totalDocumentsCount, totalDocumentsSize } = record;
return {
documentsCount,
documentsSize: Number(documentsSize ?? 0),
deletedDocumentsCount,
deletedDocumentsSize: Number(deletedDocumentsSize ?? 0),
totalDocumentsCount,
totalDocumentsSize: Number(totalDocumentsSize ?? 0),
};
}

View File

@@ -8,15 +8,11 @@ import { createOrganizationsRepository } from '../organizations/organizations.re
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
import { getFileStreamFromMultipartForm } from '../shared/streams/file-upload';
import { validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
import { createDocumentIsNotDeletedError } from './documents.errors';
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
import { documentIdSchema } from './documents.schemas';
import { createDocumentCreationUsecase, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
import { createDocumentCreationUsecase, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow, restoreDocument, trashDocument, updateDocument } from './documents.usecases';
export function registerDocumentsRoutes(context: RouteDefinitionContext) {
setupCreateDocumentRoute(context);
@@ -177,7 +173,7 @@ function setupGetDocumentRoute({ app, db }: RouteDefinitionContext) {
);
}
function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
function setupDeleteDocumentRoute({ app, db, eventServices }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/:documentId',
requireAuthentication({ apiKeyPermissions: ['documents:delete'] }),
@@ -192,26 +188,16 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const webhookRepository = createWebhookRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
await documentsRepository.softDeleteDocument({ documentId, organizationId, userId });
deferTriggerWebhooks({
webhookRepository,
organizationId,
event: 'document:deleted',
payload: { documentId, organizationId },
});
deferRegisterDocumentActivityLog({
await trashDocument({
documentId,
event: 'deleted',
organizationId,
userId,
documentActivityRepository,
documentsRepository,
eventServices,
});
return context.json({
@@ -221,7 +207,7 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
);
}
function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
function setupRestoreDocumentRoute({ app, db, eventServices }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/documents/:documentId/restore',
requireAuthentication(),
@@ -236,7 +222,6 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
@@ -246,13 +231,12 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
throw createDocumentIsNotDeletedError();
}
await documentsRepository.restoreDocument({ documentId, organizationId });
deferRegisterDocumentActivityLog({
await restoreDocument({
documentId,
event: 'restored',
organizationId,
userId,
documentActivityRepository,
documentsRepository,
eventServices,
});
return context.body(null, 204);
@@ -377,7 +361,7 @@ function setupGetOrganizationDocumentsStatsRoute({ app, db }: RouteDefinitionCon
);
}
function setupDeleteTrashDocumentRoute({ app, db, documentsStorageService }: RouteDefinitionContext) {
function setupDeleteTrashDocumentRoute({ app, db, documentsStorageService, eventServices }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/trash/:documentId',
requireAuthentication(),
@@ -395,7 +379,7 @@ function setupDeleteTrashDocumentRoute({ app, db, documentsStorageService }: Rou
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await deleteTrashDocument({ documentId, organizationId, documentsRepository, documentsStorageService });
await deleteTrashDocument({ documentId, organizationId, documentsRepository, documentsStorageService, eventServices });
return context.json({
success: true,
@@ -404,7 +388,7 @@ function setupDeleteTrashDocumentRoute({ app, db, documentsStorageService }: Rou
);
}
function setupDeleteAllTrashDocumentsRoute({ app, db, documentsStorageService }: RouteDefinitionContext) {
function setupDeleteAllTrashDocumentsRoute({ app, db, documentsStorageService, eventServices }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/trash',
requireAuthentication(),
@@ -421,14 +405,14 @@ function setupDeleteAllTrashDocumentsRoute({ app, db, documentsStorageService }:
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await deleteAllTrashDocuments({ organizationId, documentsRepository, documentsStorageService });
await deleteAllTrashDocuments({ organizationId, documentsRepository, documentsStorageService, eventServices });
return context.body(null, 204);
},
);
}
function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
function setupUpdateDocumentRoute({ app, db, eventServices }: RouteDefinitionContext) {
app.patch(
'/api/organizations/:organizationId/documents/:documentId',
requireAuthentication({ apiKeyPermissions: ['documents:update'] }),
@@ -445,37 +429,21 @@ function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
async (context) => {
const { userId } = getUser({ context });
const { organizationId, documentId } = context.req.valid('param');
const updateData = context.req.valid('json');
const changes = context.req.valid('json');
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
const webhookRepository = createWebhookRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await ensureDocumentExists({ documentId, organizationId, documentsRepository });
const { document } = await documentsRepository.updateDocument({
const { document } = await updateDocument({
documentId,
organizationId,
...updateData,
});
deferTriggerWebhooks({
webhookRepository,
organizationId,
event: 'document:updated',
payload: { documentId, organizationId, ...updateData },
});
deferRegisterDocumentActivityLog({
documentId,
event: 'updated',
userId,
documentActivityRepository,
eventData: {
updatedFields: Object.entries(updateData).filter(([_, value]) => value !== undefined).map(([key]) => key),
},
documentsRepository,
eventServices,
changes,
});
return context.json({ document: formatDocumentForApi({ document }) });

View File

@@ -1,185 +0,0 @@
import { eq, sql } from 'drizzle-orm';
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../app/database/database.test-utils';
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
import { documentsTable } from './documents.table';
describe('documents table', () => {
describe('table documents_fts', () => {
describe('the documents_fts table is synchronized with the documents table using triggers', async () => {
test('when inserting a document, a corresponding row is inserted in the documents_fts table', async () => {
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 }],
});
await db.insert(documentsTable).values([
{
id: 'document-1',
organizationId: 'organization-1',
createdBy: 'user-1',
mimeType: 'application/pdf',
name: 'Document 1',
originalName: 'document-1.pdf',
originalStorageKey: 'document-1.pdf',
content: 'lorem ipsum',
originalSha256Hash: 'hash1',
},
{
id: 'document-2',
organizationId: 'organization-1',
createdBy: 'user-1',
mimeType: 'application/pdf',
name: 'Photo 1',
originalName: 'photo-1.jpg',
originalStorageKey: 'photo-1.jpg',
content: 'dolor sit amet',
originalSha256Hash: 'hash2',
},
]);
const { rows } = await db.run(sql`SELECT * FROM documents_fts;`);
expect(rows).to.eql([
{
id: 'document-1',
name: 'Document 1',
content: 'lorem ipsum',
original_name: 'document-1.pdf',
},
{
id: 'document-2',
name: 'Photo 1',
content: 'dolor sit amet',
original_name: 'photo-1.jpg',
},
]);
const { rows: searchResults } = await db.run(sql`SELECT * FROM documents_fts WHERE documents_fts MATCH 'lorem';`);
expect(searchResults).to.eql([
{
id: 'document-1',
name: 'Document 1',
content: 'lorem ipsum',
original_name: 'document-1.pdf',
},
]);
});
test('when updating a document, the corresponding row in the documents_fts table is updated', async () => {
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 }],
});
await db.insert(documentsTable).values([
{
id: 'document-1',
organizationId: 'organization-1',
createdBy: 'user-1',
mimeType: 'application/pdf',
name: 'Document 1',
originalName: 'document-1.pdf',
originalStorageKey: 'document-1.pdf',
content: 'lorem ipsum',
originalSha256Hash: 'hash1',
},
{
id: 'document-2',
organizationId: 'organization-1',
createdBy: 'user-1',
mimeType: 'application/pdf',
name: 'Photo 1',
originalName: 'photo-1.jpg',
originalStorageKey: 'photo-1.jpg',
content: 'dolor sit amet',
originalSha256Hash: 'hash2',
},
]);
await db.update(documentsTable).set({ content: 'foo bar baz' }).where(eq(documentsTable.id, 'document-1'));
const { rows } = await db.run(sql`SELECT * FROM documents_fts;`);
expect(rows).to.eql([
{
id: 'document-1',
name: 'Document 1',
content: 'foo bar baz',
original_name: 'document-1.pdf',
},
{
id: 'document-2',
name: 'Photo 1',
content: 'dolor sit amet',
original_name: 'photo-1.jpg',
},
]);
const { rows: searchResults } = await db.run(sql`SELECT * FROM documents_fts WHERE documents_fts MATCH 'foo';`);
expect(searchResults).to.eql([
{
id: 'document-1',
name: 'Document 1',
content: 'foo bar baz',
original_name: 'document-1.pdf',
},
]);
});
test('when deleting a document, the corresponding row in the documents_fts table is deleted', async () => {
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 }],
});
await db.insert(documentsTable).values([
{
id: 'document-1',
organizationId: 'organization-1',
createdBy: 'user-1',
mimeType: 'application/pdf',
name: 'Document 1',
originalName: 'document-1.pdf',
originalStorageKey: 'document-1.pdf',
content: 'lorem ipsum',
originalSha256Hash: 'hash1',
},
{
id: 'document-2',
organizationId: 'organization-1',
createdBy: 'user-1',
mimeType: 'application/pdf',
name: 'Photo 1',
originalName: 'photo-1.jpg',
originalStorageKey: 'photo-1.jpg',
content: 'dolor sit amet',
originalSha256Hash: 'hash2',
},
]);
await db.delete(documentsTable).where(eq(documentsTable.id, 'document-1'));
const { rows } = await db.run(sql`SELECT * FROM documents_fts;`);
expect(rows).to.eql([
{
id: 'document-2',
name: 'Photo 1',
content: 'dolor sit amet',
original_name: 'photo-1.jpg',
},
]);
const { rows: searchResults } = await db.run(sql`SELECT * FROM documents_fts WHERE documents_fts MATCH 'lorem';`);
expect(searchResults).to.eql([]);
});
});
});
});

View File

@@ -2,10 +2,10 @@ import type { PlansRepository } from '../plans/plans.repository';
import type { DocumentStorageService } from './storage/documents.storage.services';
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../app/database/database.test-utils';
import { createTestEventServices } from '../app/events/events.test-utils';
import { overrideConfig } from '../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
import { nextTick } from '../shared/async/defer.test-utils';
import { collectReadableStreamToString, createReadableStream } from '../shared/streams/readable-stream';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { createTagsRepository } from '../tags/tags.repository';
@@ -13,11 +13,10 @@ import { documentsTagsTable } from '../tags/tags.table';
import { createInMemoryTaskServices } from '../tasks/tasks.test-utils';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
import { documentActivityLogTable } from './document-activity/document-activity.table';
import { createDocumentAlreadyExistsError, createDocumentSizeTooLargeError } from './documents.errors';
import { createDocumentsRepository } from './documents.repository';
import { documentsTable } from './documents.table';
import { createDocumentCreationUsecase, extractAndSaveDocumentFileContent } from './documents.usecases';
import { createDocumentCreationUsecase, extractAndSaveDocumentFileContent, restoreDocument, trashDocument, updateDocument } from './documents.usecases';
import { createDocumentStorageService } from './storage/documents.storage.services';
import { inMemoryStorageDriverFactory } from './storage/drivers/memory/memory.storage-driver';
@@ -43,6 +42,7 @@ describe('documents usecases', () => {
generateDocumentId: () => 'doc_1',
documentsStorageService,
taskServices,
eventServices: createTestEventServices(),
});
const userId = 'user-1';
@@ -104,6 +104,7 @@ describe('documents usecases', () => {
generateDocumentId: () => `doc_${documentIdIndex++}`,
documentsStorageService,
taskServices,
eventServices: createTestEventServices(),
});
const userId = 'user-1';
@@ -208,6 +209,7 @@ describe('documents usecases', () => {
config,
taskServices,
documentsStorageService: inMemoryStorageDriverFactory(),
eventServices: createTestEventServices(),
});
// 3. Re-create the document
@@ -270,6 +272,7 @@ describe('documents usecases', () => {
},
},
taskServices,
eventServices: createTestEventServices(),
});
const userId = 'user-1';
@@ -294,63 +297,6 @@ describe('documents usecases', () => {
).rejects.toThrow('File not found');
});
test('when a document is created by a user, a document activity log is registered with the user id', 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 },
});
let documentIdIndex = 1;
const createDocument = createDocumentCreationUsecase({
db,
config,
generateDocumentId: () => `doc_${documentIdIndex++}`,
documentsStorageService: inMemoryStorageDriverFactory(),
taskServices,
});
await createDocument({
fileStream: createReadableStream({ content: 'content-1' }),
fileName: 'file.txt',
mimeType: 'text/plain',
userId: 'user-1',
organizationId: 'organization-1',
});
await createDocument({
fileStream: createReadableStream({ content: 'content-2' }),
fileName: 'file.txt',
mimeType: 'text/plain',
organizationId: 'organization-1',
});
await nextTick();
const documentActivityLogRecords = await db.select().from(documentActivityLogTable);
expect(documentActivityLogRecords.length).to.eql(2);
expect(documentActivityLogRecords[0]).to.deep.include({
event: 'created',
eventData: null,
userId: 'user-1',
documentId: 'doc_1',
});
expect(documentActivityLogRecords[1]).to.deep.include({
event: 'created',
eventData: null,
userId: null,
documentId: 'doc_2',
});
});
test('if the document size exceeds the organization storage limit, an error is thrown and nothing is saved in the db', async () => {
const taskServices = createInMemoryTaskServices();
const { db } = await createInMemoryDatabase({
@@ -377,6 +323,7 @@ describe('documents usecases', () => {
taskServices,
plansRepository,
documentsStorageService: inMemoryDocumentsStorageService,
eventServices: createTestEventServices(),
});
await expect(
@@ -442,6 +389,7 @@ describe('documents usecases', () => {
taskServices,
plansRepository,
documentsStorageService,
eventServices: createTestEventServices(),
});
const [result1, result2] = await Promise.allSettled([
@@ -500,6 +448,7 @@ describe('documents usecases', () => {
taskServices,
plansRepository,
documentsStorageService: inMemoryDocumentsStorageService,
eventServices: createTestEventServices(),
});
await expect(
@@ -519,6 +468,54 @@ describe('documents usecases', () => {
// Ensure no file is saved in the storage
expect(inMemoryDocumentsStorageService._getStorage().size).to.eql(0);
});
test('when a document is added, a "document.created" event is triggered', 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 },
});
let documentIdIndex = 1;
const eventServices = createTestEventServices();
const createDocument = createDocumentCreationUsecase({
db,
config,
generateDocumentId: () => `doc_${documentIdIndex++}`,
documentsStorageService: inMemoryStorageDriverFactory(),
taskServices,
eventServices,
});
await createDocument({
fileStream: createReadableStream({ content: 'content-1' }),
fileName: 'file.txt',
mimeType: 'text/plain',
userId: 'user-1',
organizationId: 'organization-1',
});
const emittedEvents = eventServices.getEmittedEvents();
expect(emittedEvents.length).to.eql(1);
const { eventName, payload } = emittedEvents[0]!;
expect(eventName).to.eql('document.created');
expect(payload.document).to.include({
id: 'doc_1',
organizationId: 'organization-1',
createdBy: 'user-1',
name: 'file.txt',
originalName: 'file.txt',
originalSize: 9,
mimeType: 'text/plain',
});
});
});
describe('extractAndSaveDocumentFileContent', () => {
@@ -568,6 +565,7 @@ describe('documents usecases', () => {
tagsRepository,
webhookRepository,
documentActivityRepository,
eventServices: createTestEventServices(),
});
const documentRecords = await db.select().from(documentsTable);
@@ -579,5 +577,295 @@ describe('documents usecases', () => {
content: 'hello world', // The content is extracted and saved in the db
});
});
test('a document.updated event is emitted when the document content is extracted and saved', async () => {
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 documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
await db.insert(documentsTable).values({
id: 'document-1',
organizationId: 'organization-1',
originalStorageKey: 'organization-1/originals/document-1.txt',
mimeType: 'text/plain',
name: 'file-1.txt',
originalName: 'file-1.txt',
originalSha256Hash: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
});
await documentsStorageService.saveFile({
fileStream: createReadableStream({ content: 'hello world' }),
fileName: 'file-1.txt',
mimeType: 'text/plain',
storageKey: 'organization-1/originals/document-1.txt',
});
const webhookRepository = createWebhookRepository({ db });
const documentActivityRepository = createDocumentActivityRepository({ db });
const eventServices = createTestEventServices();
await extractAndSaveDocumentFileContent({
documentId: 'document-1',
organizationId: 'organization-1',
documentsRepository,
documentsStorageService,
taggingRulesRepository,
tagsRepository,
webhookRepository,
documentActivityRepository,
eventServices,
});
expect(
eventServices.getEmittedEvents().map(({ eventName }) => (eventName)),
).to.eql([
'document.updated',
]);
});
});
describe('trashDocument', () => {
test('users can soft delete a document by moving it to the trash', async () => {
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 }],
documents: [{
id: 'document-1',
organizationId: 'organization-1',
mimeType: 'text/plain',
originalStorageKey: 'organization-1/originals/document-1.txt',
name: 'file-1.txt',
originalName: 'file-1.txt',
originalSha256Hash: 'hash',
}],
});
const documentsRepository = createDocumentsRepository({ db });
await trashDocument({
documentId: 'document-1',
organizationId: 'organization-1',
userId: 'user-1',
documentsRepository,
eventServices: createTestEventServices(),
});
const documentRecords = await db.select().from(documentsTable);
expect(documentRecords.length).to.eql(1);
expect(documentRecords[0]).to.deep.include({
id: 'document-1',
organizationId: 'organization-1',
isDeleted: true,
deletedBy: 'user-1',
});
});
test('when a document is trashed, a "document.trashed" event is triggered', async () => {
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 }],
documents: [{
id: 'document-1',
organizationId: 'organization-1',
mimeType: 'text/plain',
originalStorageKey: 'organization-1/originals/document-1.txt',
name: 'file-1.txt',
originalName: 'file-1.txt',
originalSha256Hash: 'hash',
}],
});
const documentsRepository = createDocumentsRepository({ db });
const eventServices = createTestEventServices();
await trashDocument({
documentId: 'document-1',
organizationId: 'organization-1',
userId: 'user-1',
documentsRepository,
eventServices,
});
const emittedEvents = eventServices.getEmittedEvents();
expect(emittedEvents.length).to.eql(1);
const { eventName, payload } = emittedEvents[0]!;
expect(eventName).to.eql('document.trashed');
expect(payload).to.deep.include({
documentId: 'document-1',
organizationId: 'organization-1',
trashedBy: 'user-1',
});
});
});
describe('restoreDocument', () => {
test('users can restore a document from the trash, the document is no longer marked as deleted and the deletedBy and deletedAt fields are cleared', async () => {
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 }],
documents: [{
id: 'document-1',
organizationId: 'organization-1',
mimeType: 'text/plain',
originalStorageKey: 'organization-1/originals/document-1.txt',
name: 'file-1.txt',
originalName: 'file-1.txt',
originalSha256Hash: 'hash',
isDeleted: true,
deletedBy: 'user-1',
}],
});
const documentsRepository = createDocumentsRepository({ db });
await restoreDocument({
documentId: 'document-1',
organizationId: 'organization-1',
userId: 'user-1',
documentsRepository,
eventServices: createTestEventServices(),
});
const documentRecords = await db.select().from(documentsTable);
expect(documentRecords.length).to.eql(1);
expect(documentRecords[0]).to.deep.include({
id: 'document-1',
organizationId: 'organization-1',
isDeleted: false,
deletedBy: null,
deletedAt: null,
});
});
test('when a document is restored, a "document.restored" event is triggered', async () => {
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 }],
documents: [{
id: 'document-1',
organizationId: 'organization-1',
mimeType: 'text/plain',
originalStorageKey: 'organization-1/originals/document-1.txt',
name: 'file-1.txt',
originalName: 'file-1.txt',
originalSha256Hash: 'hash',
isDeleted: true,
deletedBy: 'user-1',
}],
});
const documentsRepository = createDocumentsRepository({ db });
const eventServices = createTestEventServices();
await restoreDocument({
documentId: 'document-1',
organizationId: 'organization-1',
userId: 'user-1',
documentsRepository,
eventServices,
});
expect(
eventServices.getEmittedEvents(),
).to.eql([{
eventName: 'document.restored',
payload: {
documentId: 'document-1',
organizationId: 'organization-1',
restoredBy: 'user-1',
},
}]);
});
});
describe('updateDocument', () => {
test('when a document is updated, a "document.updated" event is triggered', async () => {
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 }],
documents: [{
id: 'document-1',
organizationId: 'organization-1',
mimeType: 'text/plain',
originalStorageKey: 'organization-1/originals/document-1.txt',
name: 'file-1.txt',
originalName: 'file-1.txt',
originalSha256Hash: 'hash',
content: 'Original content',
createdAt: new Date('2025-12-10'),
updatedAt: new Date('2025-12-11'),
}],
});
const documentsRepository = createDocumentsRepository({ db });
const eventServices = createTestEventServices();
await updateDocument({
documentId: 'document-1',
organizationId: 'organization-1',
userId: 'user-1',
documentsRepository,
eventServices,
changes: { name: 'new-name.txt', content: 'Updated content' },
});
expect(
eventServices.getEmittedEvents(),
).to.eql(
[
{
eventName: 'document.updated',
payload: {
changes: {
content: 'Updated content',
name: 'new-name.txt',
},
document: {
content: 'Updated content',
createdAt: new Date('2025-12-10'),
createdBy: null,
deletedAt: null,
deletedBy: null,
fileEncryptionAlgorithm: null,
fileEncryptionKekVersion: null,
fileEncryptionKeyWrapped: null,
id: 'document-1',
isDeleted: false,
mimeType: 'text/plain',
name: 'new-name.txt',
organizationId: 'organization-1',
originalName: 'file-1.txt',
originalSha256Hash: 'hash',
originalSize: 0,
originalStorageKey: 'organization-1/originals/document-1.txt',
updatedAt: new Date('2025-12-11'),
},
userId: 'user-1',
},
},
],
);
});
});
});

View File

@@ -1,5 +1,6 @@
import type { Readable } from 'node:stream';
import type { Database } from '../app/database/database.types';
import type { EventServices } from '../app/events/events.services';
import type { Config } from '../config/config.types';
import type { PlansRepository } from '../plans/plans.repository';
import type { Logger } from '../shared/logger/logger';
@@ -7,7 +8,6 @@ import type { SubscriptionsRepository } from '../subscriptions/subscriptions.rep
import type { TaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import type { TagsRepository } from '../tags/tags.repository';
import type { TaskServices } from '../tasks/tasks.services';
import type { TrackingServices } from '../tracking/tracking.services';
import type { WebhookRepository } from '../webhooks/webhook.repository';
import type { DocumentActivityRepository } from './document-activity/document-activity.repository';
import type { DocumentsRepository } from './documents.repository';
@@ -25,16 +25,13 @@ import { createLogger } from '../shared/logger/logger';
import { createByteCounter } from '../shared/streams/byte-counter';
import { createSha256HashTransformer } from '../shared/streams/stream-hash';
import { collectStreamToFile } from '../shared/streams/stream.convertion';
import { isDefined, isNil } from '../shared/utils';
import { isNil } from '../shared/utils';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { applyTaggingRules } from '../tagging-rules/tagging-rules.usecases';
import { createTagsRepository } from '../tags/tags.repository';
import { createTrackingServices } from '../tracking/tracking.services';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
import { createDocumentAlreadyExistsError, createDocumentNotDeletedError, createDocumentNotFoundError, createDocumentSizeTooLargeError } from './documents.errors';
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
@@ -56,11 +53,11 @@ export async function createDocument({
generateDocumentId = generateDocumentIdImpl,
plansRepository,
subscriptionsRepository,
trackingServices,
taggingRulesRepository,
tagsRepository,
webhookRepository,
documentActivityRepository,
eventServices,
taskServices,
logger = createLogger({ namespace: 'documents:usecases' }),
}: {
@@ -75,11 +72,11 @@ export async function createDocument({
generateDocumentId?: () => string;
plansRepository: PlansRepository;
subscriptionsRepository: SubscriptionsRepository;
trackingServices: TrackingServices;
taggingRulesRepository: TaggingRulesRepository;
tagsRepository: TagsRepository;
webhookRepository: WebhookRepository;
documentActivityRepository: DocumentActivityRepository;
eventServices: EventServices;
taskServices: TaskServices;
logger?: Logger;
}) {
@@ -158,30 +155,14 @@ export async function createDocument({
plansRepository,
subscriptionsRepository,
documentId,
trackingServices,
taskServices,
ocrLanguages,
logger,
});
deferRegisterDocumentActivityLog({
documentId: document.id,
event: 'created',
userId,
documentActivityRepository,
});
deferTriggerWebhooks({
webhookRepository,
organizationId,
event: 'document:created',
payload: {
documentId: document.id,
organizationId,
name: document.name,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
},
eventServices.emitEvent({
eventName: 'document.created',
payload: { document },
});
return { document };
@@ -195,18 +176,19 @@ export function createDocumentCreationUsecase({
config,
taskServices,
documentsStorageService,
eventServices,
...initialDeps
}: {
db: Database;
taskServices: TaskServices;
documentsStorageService: DocumentStorageService;
eventServices: EventServices;
config: Config;
} & Partial<DocumentUsecaseDependencies>) {
const deps = {
documentsRepository: initialDeps.documentsRepository ?? createDocumentsRepository({ db }),
plansRepository: initialDeps.plansRepository ?? createPlansRepository({ config }),
subscriptionsRepository: initialDeps.subscriptionsRepository ?? createSubscriptionsRepository({ db }),
trackingServices: initialDeps.trackingServices ?? createTrackingServices({ config }),
taggingRulesRepository: initialDeps.taggingRulesRepository ?? createTaggingRulesRepository({ db }),
tagsRepository: initialDeps.tagsRepository ?? createTagsRepository({ db }),
webhookRepository: initialDeps.webhookRepository ?? createWebhookRepository({ db }),
@@ -223,7 +205,7 @@ export function createDocumentCreationUsecase({
mimeType: string;
userId?: string;
organizationId: string;
}) => createDocument({ taskServices, documentsStorageService, ...args, ...deps });
}) => createDocument({ taskServices, documentsStorageService, eventServices, ...args, ...deps });
}
async function handleExistingDocument({
@@ -284,7 +266,6 @@ async function createNewDocument({
documentsStorageService,
newFileStorageContext,
documentId,
trackingServices,
taskServices,
ocrLanguages = [],
logger,
@@ -300,7 +281,6 @@ async function createNewDocument({
documentsStorageService: DocumentStorageService;
plansRepository: PlansRepository;
subscriptionsRepository: SubscriptionsRepository;
trackingServices: TrackingServices;
newFileStorageContext: DocumentStorageContext;
taskServices: TaskServices;
ocrLanguages?: string[];
@@ -344,18 +324,16 @@ async function createNewDocument({
throw error;
}
const { document } = result;
await taskServices.scheduleJob({
taskName: 'extract-document-file-content',
data: { documentId, organizationId, ocrLanguages },
});
if (isDefined(userId)) {
trackingServices.captureUserEvent({ userId, event: 'Document created' });
}
logger.info({ documentId, userId, organizationId, mimeType }, 'Document created');
return { document: result.document };
return { document };
}
export async function getDocumentOrThrow({
@@ -392,26 +370,35 @@ export async function hardDeleteDocument({
document,
documentsRepository,
documentsStorageService,
eventServices,
}: {
document: Pick<Document, 'id' | 'originalStorageKey'>;
document: Pick<Document, 'id' | 'originalStorageKey' | 'organizationId'>;
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
eventServices: EventServices;
}) {
await Promise.all([
documentsRepository.hardDeleteDocument({ documentId: document.id }),
documentsStorageService.deleteFile({ storageKey: document.originalStorageKey }),
]);
eventServices.emitEvent({
eventName: 'document.deleted',
payload: { documentId: document.id, organizationId: document.organizationId },
});
}
export async function deleteExpiredDocuments({
documentsRepository,
documentsStorageService,
eventServices,
config,
now = new Date(),
logger = createLogger({ namespace: 'documents:deleteExpiredDocuments' }),
}: {
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
eventServices: EventServices;
config: Config;
now?: Date;
logger?: Logger;
@@ -425,7 +412,7 @@ export async function deleteExpiredDocuments({
await Promise.all(
documents.map(async document => limit(async () => {
const [, error] = await safely(hardDeleteDocument({ document, documentsRepository, documentsStorageService }));
const [, error] = await safely(hardDeleteDocument({ document, documentsRepository, documentsStorageService, eventServices }));
if (error) {
logger.error({ document, error }, 'Error while deleting expired document');
@@ -443,11 +430,13 @@ export async function deleteTrashDocument({
organizationId,
documentsRepository,
documentsStorageService,
eventServices,
}: {
documentId: string;
organizationId: string;
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
eventServices: EventServices;
}) {
const { document } = await documentsRepository.getDocumentById({ documentId, organizationId });
@@ -459,17 +448,19 @@ export async function deleteTrashDocument({
throw createDocumentNotDeletedError();
}
await hardDeleteDocument({ document, documentsRepository, documentsStorageService });
await hardDeleteDocument({ document, documentsRepository, documentsStorageService, eventServices });
}
export async function deleteAllTrashDocuments({
organizationId,
documentsRepository,
documentsStorageService,
eventServices,
}: {
organizationId: string;
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
eventServices: EventServices;
}) {
const { documents } = await documentsRepository.getAllOrganizationTrashDocuments({ organizationId });
@@ -478,7 +469,9 @@ export async function deleteAllTrashDocuments({
const limit = pLimit(10);
await Promise.all(
documents.map(async document => limit(async () => hardDeleteDocument({ document, documentsRepository, documentsStorageService }))),
documents.map(async document => limit(async () => {
await hardDeleteDocument({ document, documentsRepository, documentsStorageService, eventServices });
})),
);
}
@@ -492,6 +485,7 @@ export async function extractAndSaveDocumentFileContent({
tagsRepository,
webhookRepository,
documentActivityRepository,
eventServices,
}: {
documentId: string;
ocrLanguages?: string[];
@@ -502,6 +496,7 @@ export async function extractAndSaveDocumentFileContent({
tagsRepository: TagsRepository;
webhookRepository: WebhookRepository;
documentActivityRepository: DocumentActivityRepository;
eventServices: EventServices;
}) {
const { document } = await documentsRepository.getDocumentById({ documentId, organizationId });
@@ -520,7 +515,7 @@ export async function extractAndSaveDocumentFileContent({
const { text } = await extractDocumentText({ file, ocrLanguages });
const { document: updatedDocument } = await documentsRepository.updateDocument({ documentId, organizationId, content: text });
const { document: updatedDocument } = await updateDocument({ documentId, organizationId, changes: { content: text }, documentsRepository, eventServices });
if (isNil(updatedDocument)) {
// This should never happen, but for type safety
@@ -531,3 +526,74 @@ export async function extractAndSaveDocumentFileContent({
return { document: updatedDocument };
}
export async function trashDocument({
documentId,
organizationId,
userId,
documentsRepository,
eventServices,
}: {
documentId: string;
organizationId: string;
userId: string;
documentsRepository: DocumentsRepository;
eventServices: EventServices;
}) {
await documentsRepository.softDeleteDocument({ documentId, organizationId, userId });
eventServices.emitEvent({
eventName: 'document.trashed',
payload: { documentId, organizationId, trashedBy: userId },
});
}
export async function restoreDocument({
documentId,
organizationId,
userId,
documentsRepository,
eventServices,
}: {
documentId: string;
organizationId: string;
userId: string;
documentsRepository: DocumentsRepository;
eventServices: EventServices;
}) {
await documentsRepository.restoreDocument({ documentId, organizationId });
eventServices.emitEvent({
eventName: 'document.restored',
payload: { documentId, organizationId, restoredBy: userId },
});
}
export async function updateDocument({
documentId,
organizationId,
userId,
documentsRepository,
eventServices,
changes,
}: {
documentId: string;
organizationId: string;
userId?: string;
documentsRepository: DocumentsRepository;
eventServices: EventServices;
changes: {
name?: string;
content?: string;
};
}) {
// It throws if the document does not exist
const { document } = await documentsRepository.updateDocument({ documentId, organizationId, ...changes });
eventServices.emitEvent({
eventName: 'document.updated',
payload: { userId, changes, document },
});
return { document };
}

View File

@@ -2,6 +2,7 @@ import type { Document } from '../documents.types';
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 { documentsTable } from '../documents.table';
@@ -16,7 +17,7 @@ describe('documents e2e', () => {
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
});
const { app } = await createServer({
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
@@ -24,7 +25,7 @@ describe('documents e2e', () => {
driver: 'in-memory',
},
}),
});
}));
const formData = new FormData();
formData.append('file', new File(['this is an invoice'], 'invoice.txt', { type: 'text/plain' }));
@@ -85,7 +86,7 @@ describe('documents e2e', () => {
const documentsStorageService = inMemoryStorageDriverFactory();
const { app } = await createServer({
const { app } = createServer(createTestServerDependencies({
db,
documentsStorageService,
config: overrideConfig({
@@ -94,7 +95,7 @@ describe('documents e2e', () => {
maxUploadSize: 100,
},
}),
});
}));
const formData = new FormData();
formData.append('file', new File(['a'.repeat(101)], 'invoice.txt', { type: 'text/plain' }));
@@ -141,7 +142,7 @@ describe('documents e2e', () => {
],
});
const { app } = await createServer({
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
@@ -149,7 +150,7 @@ describe('documents e2e', () => {
driver: 'in-memory',
},
}),
});
}));
const formData = new FormData();
formData.append('file', new File(['sensitive document'], 'document.txt', { type: 'text/plain' }));
@@ -189,7 +190,7 @@ describe('documents e2e', () => {
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
});
const { app } = await createServer({
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
@@ -197,7 +198,7 @@ describe('documents e2e', () => {
driver: 'in-memory',
},
}),
});
}));
// Various UTF-8 characters that cause encoding issues
const testCases = [

View File

@@ -0,0 +1,78 @@
import { createNoopLogger } from '@crowlog/logger';
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
import { createEventServices } from '../../app/events/events.services';
import { overrideConfig } from '../../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
import { nextTick } from '../../shared/async/defer.test-utils';
import { createDeterministicIdGenerator } from '../../shared/random/ids';
import { createReadableStream } from '../../shared/streams/readable-stream';
import { createInMemoryTaskServices } from '../../tasks/tasks.test-utils';
import { documentActivityLogTable } from '../document-activity/document-activity.table';
import { createDocumentCreationUsecase } from '../documents.usecases';
import { inMemoryStorageDriverFactory } from '../storage/drivers/memory/memory.storage-driver';
import { registerInsertActivityLogOnDocumentCreatedHandler } from './activity-log.document-created';
describe('activity-log document-created', () => {
describe('registerInsertActivityLogOnDocumentCreatedHandler', () => {
test('when a document is created, a document activity log is registered', 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 },
});
const eventServices = createEventServices({ logger: createNoopLogger() });
registerInsertActivityLogOnDocumentCreatedHandler({ eventServices, db });
const createDocument = createDocumentCreationUsecase({
db,
config,
generateDocumentId: createDeterministicIdGenerator({ prefix: 'doc' }),
documentsStorageService: inMemoryStorageDriverFactory(),
taskServices,
eventServices,
});
await createDocument({
fileStream: createReadableStream({ content: 'content-1' }),
fileName: 'file.txt',
mimeType: 'text/plain',
userId: 'user-1',
organizationId: 'organization-1',
});
await createDocument({
fileStream: createReadableStream({ content: 'content-2' }),
fileName: 'file.txt',
mimeType: 'text/plain',
organizationId: 'organization-1',
});
await nextTick();
const documentActivityLogRecords = await db.select().from(documentActivityLogTable);
expect(documentActivityLogRecords.length).to.eql(2);
expect(documentActivityLogRecords[0]).to.deep.include({
event: 'created',
eventData: null,
userId: 'user-1',
documentId: 'doc_000000000000000000000001',
});
expect(documentActivityLogRecords[1]).to.deep.include({
event: 'created',
eventData: null,
userId: null,
documentId: 'doc_000000000000000000000002',
});
});
});
});

View File

@@ -0,0 +1,27 @@
import type { Database } from '../../app/database/database.types';
import type { EventServices } from '../../app/events/events.services';
import { createDocumentActivityRepository } from '../document-activity/document-activity.repository';
import { registerDocumentActivityLog } from '../document-activity/document-activity.usecases';
export function registerInsertActivityLogOnDocumentCreatedHandler({
eventServices,
db,
}: {
eventServices: EventServices;
db: Database;
}) {
const documentActivityRepository = createDocumentActivityRepository({ db });
eventServices.onEvent({
eventName: 'document.created',
handlerName: 'insert-activity-log',
async handler({ document }) {
await registerDocumentActivityLog({
documentId: document.id,
event: 'created',
userId: document.createdBy,
documentActivityRepository,
});
},
});
}

View File

@@ -0,0 +1,27 @@
import type { Database } from '../../app/database/database.types';
import type { EventServices } from '../../app/events/events.services';
import { createDocumentActivityRepository } from '../document-activity/document-activity.repository';
import { registerDocumentActivityLog } from '../document-activity/document-activity.usecases';
export function registerInsertActivityLogOnDocumentRestoredHandler({
eventServices,
db,
}: {
eventServices: EventServices;
db: Database;
}) {
const documentActivityRepository = createDocumentActivityRepository({ db });
eventServices.onEvent({
eventName: 'document.restored',
handlerName: 'insert-activity-log',
async handler({ documentId, restoredBy }) {
await registerDocumentActivityLog({
documentId,
event: 'restored',
userId: restoredBy,
documentActivityRepository,
});
},
});
}

View File

@@ -0,0 +1,27 @@
import type { Database } from '../../app/database/database.types';
import type { EventServices } from '../../app/events/events.services';
import { createDocumentActivityRepository } from '../document-activity/document-activity.repository';
import { registerDocumentActivityLog } from '../document-activity/document-activity.usecases';
export function registerInsertActivityLogOnDocumentTrashedHandler({
eventServices,
db,
}: {
eventServices: EventServices;
db: Database;
}) {
const documentActivityRepository = createDocumentActivityRepository({ db });
eventServices.onEvent({
eventName: 'document.trashed',
handlerName: 'insert-activity-log',
async handler({ documentId, trashedBy }) {
await registerDocumentActivityLog({
documentId,
event: 'deleted',
userId: trashedBy,
documentActivityRepository,
});
},
});
}

View File

@@ -0,0 +1,30 @@
import type { Database } from '../../app/database/database.types';
import type { EventServices } from '../../app/events/events.services';
import { createDocumentActivityRepository } from '../document-activity/document-activity.repository';
import { registerDocumentActivityLog } from '../document-activity/document-activity.usecases';
export function registerInsertActivityLogOnDocumentUpdatedHandler({
eventServices,
db,
}: {
eventServices: EventServices;
db: Database;
}) {
const documentActivityRepository = createDocumentActivityRepository({ db });
eventServices.onEvent({
eventName: 'document.updated',
handlerName: 'insert-activity-log',
async handler({ document, changes, userId }) {
await registerDocumentActivityLog({
documentId: document.id,
event: 'updated',
userId,
documentActivityRepository,
eventData: {
updatedFields: Object.keys(changes).filter(key => changes[key as keyof typeof changes] !== undefined),
},
});
},
});
}

View File

@@ -0,0 +1,21 @@
import type { EventServices } from '../../app/events/events.services';
import type { TrackingServices } from '../../tracking/tracking.services';
import { isDefined } from '../../shared/utils';
export function registerTrackDocumentCreatedHandler({
eventServices,
trackingServices,
}: {
eventServices: EventServices;
trackingServices: TrackingServices;
}) {
eventServices.onEvent({
eventName: 'document.created',
handlerName: 'track-document-created',
async handler({ document }) {
if (isDefined(document.createdBy)) {
trackingServices.captureUserEvent({ userId: document.createdBy, event: 'Document created' });
}
},
});
}

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