mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-18 03:52:20 -06:00
Compare commits
20 Commits
@papra/lec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d795798931 | ||
|
|
95662d025f | ||
|
|
9d9be949b0 | ||
|
|
cf91515cfe | ||
|
|
d6f71ba5ec | ||
|
|
5bdb7c06bf | ||
|
|
2872c979fa | ||
|
|
23e66aeadf | ||
|
|
6f38659638 | ||
|
|
e3e0078673 | ||
|
|
2cf86e5968 | ||
|
|
76a72ace8d | ||
|
|
17d6e9aa6a | ||
|
|
f488e63c38 | ||
|
|
0092e530b7 | ||
|
|
364b58b74d | ||
|
|
d08cf2b195 | ||
|
|
fcd440cbbb | ||
|
|
d588e417c9 | ||
|
|
ca06919bb8 |
7
.changeset/chilly-queens-knock.md
Normal file
7
.changeset/chilly-queens-knock.md
Normal 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.
|
||||
|
||||
5
.changeset/fast-schools-pay.md
Normal file
5
.changeset/fast-schools-pay.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Enforcing the auth secret to be at least 32 characters long for security reasons
|
||||
5
.changeset/few-peaches-peel.md
Normal file
5
.changeset/few-peaches-peel.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Now throw an error if AUTH_SECRET is not set in production mode
|
||||
5
.changeset/late-planets-help.md
Normal file
5
.changeset/late-planets-help.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added a platform administration dashboard
|
||||
5
.changeset/nine-plums-nail.md
Normal file
5
.changeset/nine-plums-nail.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added support for Simplified Chinese language
|
||||
5
.changeset/petite-moose-return.md
Normal file
5
.changeset/petite-moose-return.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Fixed an issue where the document icon didn't load for unknown file types
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
3
apps/mobile/app/(app)/document/view.tsx
Normal file
3
apps/mobile/app/(app)/document/view.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import DocumentViewScreen from '@/modules/documents-actions/screens/document-view.screen';
|
||||
|
||||
export default DocumentViewScreen;
|
||||
@@ -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" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-native",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
|
||||
720
apps/papra-client/src/locales/zh.dictionary.ts
Normal file
720
apps/papra-client/src/locales/zh.dictionary.ts
Normal 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 }} 页',
|
||||
};
|
||||
35
apps/papra-client/src/modules/admin/admin.routes.tsx
Normal file
35
apps/papra-client/src/modules/admin/admin.routes.tsx
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
102
apps/papra-client/src/modules/admin/layouts/admin.layout.tsx
Normal file
102
apps/papra-client/src/modules/admin/layouts/admin.layout.tsx
Normal 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 ©
|
||||
{' '}
|
||||
{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;
|
||||
@@ -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;
|
||||
@@ -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({}),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,7 +3,7 @@ export type UserMe = {
|
||||
email: string;
|
||||
planId: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export type User = {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -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 );"
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
6
apps/papra-server/src/modules/admin/admin.routes.ts
Normal file
6
apps/papra-server/src/modules/admin/admin.routes.ts
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
14
apps/papra-server/src/modules/app/auth/auth.config.models.ts
Normal file
14
apps/papra-server/src/modules/app/auth/auth.config.models.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
apps/papra-server/src/modules/app/auth/auth.constants.ts
Normal file
1
apps/papra-server/src/modules/app/auth/auth.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DEFAULT_AUTH_SECRET = 'papra-default-auth-secret-change-me';
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
199
apps/papra-server/src/modules/app/auth/auth.middleware.test.ts
Normal file
199
apps/papra-server/src/modules/app/auth/auth.middleware.test.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
27
apps/papra-server/src/modules/app/events/events.handlers.ts
Normal file
27
apps/papra-server/src/modules/app/events/events.handlers.ts
Normal 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);
|
||||
}
|
||||
193
apps/papra-server/src/modules/app/events/events.services.test.ts
Normal file
193
apps/papra-server/src/modules/app/events/events.services.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
83
apps/papra-server/src/modules/app/events/events.services.ts
Normal file
83
apps/papra-server/src/modules/app/events/events.services.ts
Normal 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');
|
||||
});
|
||||
}));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
4
apps/papra-server/src/modules/app/events/events.types.ts
Normal file
4
apps/papra-server/src/modules/app/events/events.types.ts
Normal 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;
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
36
apps/papra-server/src/modules/app/process.models.test.ts
Normal file
36
apps/papra-server/src/modules/app/process.models.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
9
apps/papra-server/src/modules/app/process.models.ts
Normal file
9
apps/papra-server/src/modules/app/process.models.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
47
apps/papra-server/src/modules/app/server.test-utils.ts
Normal file
47
apps/papra-server/src/modules/app/server.test-utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -31,7 +31,7 @@ async function saveDocumentActivity({
|
||||
documentId: string;
|
||||
event: DocumentActivityEvent;
|
||||
eventData?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
userId?: string | null;
|
||||
tagId?: string;
|
||||
db: Database;
|
||||
}) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }) });
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user