mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-21 12:09:39 -06:00
Compare commits
8 Commits
admin-orga
...
@papra/cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f903c33d26 | ||
|
|
4342b319ea | ||
|
|
815f6f94f8 | ||
|
|
96f29ba58f | ||
|
|
33e3de9b8f | ||
|
|
1c64bca297 | ||
|
|
f7bf202230 | ||
|
|
5b905a1714 |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@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.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Enforcing the auth secret to be at least 32 characters long for security reasons
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Now throw an error if AUTH_SECRET is not set in production mode
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added a platform administration dashboard
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Added support for Simplified Chinese language
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@papra/docker": patch
|
||||
---
|
||||
|
||||
Fixed an issue where the document icon didn't load for unknown file types
|
||||
@@ -25,11 +25,7 @@ export const adminRoutes: RouteDefinition = {
|
||||
},
|
||||
{
|
||||
path: '/organizations',
|
||||
component: lazy(() => import('./organizations/pages/list-organizations.page')),
|
||||
},
|
||||
{
|
||||
path: '/organizations/:organizationId',
|
||||
component: lazy(() => import('./organizations/pages/organization-detail.page')),
|
||||
component: () => <div class="p-6 text-muted-foreground">Not implemented yet.</div>,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { IntakeEmail } from '@/modules/intake-emails/intake-emails.types';
|
||||
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||
import type { Webhook } from '@/modules/webhooks/webhooks.types';
|
||||
import { apiClient } from '@/modules/shared/http/api-client';
|
||||
|
||||
export type OrganizationWithMemberCount = Organization & { memberCount: number };
|
||||
|
||||
export type OrganizationMember = {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
userEmail: string;
|
||||
userName: string | null;
|
||||
};
|
||||
|
||||
export type OrganizationStats = {
|
||||
documentsCount: number;
|
||||
documentsSize: number;
|
||||
deletedDocumentsCount: number;
|
||||
deletedDocumentsSize: number;
|
||||
totalDocumentsCount: number;
|
||||
totalDocumentsSize: number;
|
||||
};
|
||||
|
||||
export async function listOrganizations({ search, pageIndex = 0, pageSize = 25 }: { search?: string; pageIndex?: number; pageSize?: number }) {
|
||||
const { totalCount, organizations } = await apiClient<{
|
||||
organizations: OrganizationWithMemberCount[];
|
||||
totalCount: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: '/api/admin/organizations',
|
||||
query: { search, pageIndex, pageSize },
|
||||
});
|
||||
|
||||
return { pageIndex, pageSize, totalCount, organizations };
|
||||
}
|
||||
|
||||
export async function getOrganizationBasicInfo({ organizationId }: { organizationId: string }) {
|
||||
const { organization } = await apiClient<{
|
||||
organization: Organization;
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/admin/organizations/${organizationId}`,
|
||||
});
|
||||
|
||||
return { organization };
|
||||
}
|
||||
|
||||
export async function getOrganizationMembers({ organizationId }: { organizationId: string }) {
|
||||
const { members } = await apiClient<{
|
||||
members: OrganizationMember[];
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/admin/organizations/${organizationId}/members`,
|
||||
});
|
||||
|
||||
return { members };
|
||||
}
|
||||
|
||||
export async function getOrganizationIntakeEmails({ organizationId }: { organizationId: string }) {
|
||||
const { intakeEmails } = await apiClient<{
|
||||
intakeEmails: IntakeEmail[];
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/admin/organizations/${organizationId}/intake-emails`,
|
||||
});
|
||||
|
||||
return { intakeEmails };
|
||||
}
|
||||
|
||||
export async function getOrganizationWebhooks({ organizationId }: { organizationId: string }) {
|
||||
const { webhooks } = await apiClient<{
|
||||
webhooks: Webhook[];
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/admin/organizations/${organizationId}/webhooks`,
|
||||
});
|
||||
|
||||
return { webhooks };
|
||||
}
|
||||
|
||||
export async function getOrganizationStats({ organizationId }: { organizationId: string }) {
|
||||
const { stats } = await apiClient<{
|
||||
stats: OrganizationStats;
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/admin/organizations/${organizationId}/stats`,
|
||||
});
|
||||
|
||||
return { stats };
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { listOrganizations } from '../organizations.services';
|
||||
|
||||
export const AdminListOrganizationsPage: Component = () => {
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [pagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 25 });
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['admin', 'organizations', search(), pagination()],
|
||||
queryFn: () => listOrganizations({
|
||||
search: search() || undefined,
|
||||
pageIndex: pagination().pageIndex,
|
||||
pageSize: pagination().pageSize,
|
||||
}),
|
||||
}));
|
||||
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
return query.data?.organizations ?? [];
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: data => (
|
||||
<A
|
||||
href={`/admin/organizations/${data.getValue<string>()}`}
|
||||
class="font-mono text-xs hover:underline text-primary"
|
||||
>
|
||||
{data.getValue<string>()}
|
||||
</A>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: data => (
|
||||
<div class="font-medium">
|
||||
{data.getValue<string>()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Members',
|
||||
accessorKey: 'memberCount',
|
||||
cell: data => (
|
||||
<div class="text-center">
|
||||
{data.getValue<number>()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
||||
},
|
||||
{
|
||||
header: 'Updated',
|
||||
accessorKey: 'updatedAt',
|
||||
cell: data => <RelativeTime class="text-muted-foreground text-sm" date={new Date(data.getValue<string>())} />,
|
||||
},
|
||||
],
|
||||
get rowCount() {
|
||||
return query.data?.totalCount ?? 0;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
state: {
|
||||
get pagination() {
|
||||
return pagination();
|
||||
},
|
||||
},
|
||||
manualPagination: true,
|
||||
});
|
||||
|
||||
const handleSearch = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setSearch(target.value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination().pageSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-2xl mx-auto mt-4">
|
||||
<div class="border-b mb-6 pb-4">
|
||||
<h1 class="text-xl font-bold mb-1">
|
||||
Organization Management
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Manage and view all organizations in the system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<TextFieldRoot class="max-w-sm">
|
||||
<TextField
|
||||
type="text"
|
||||
placeholder="Search by name or ID..."
|
||||
value={search()}
|
||||
onInput={handleSearch}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!query.isLoading}
|
||||
fallback={<div class="text-center py-8 text-muted-foreground">Loading organizations...</div>}
|
||||
>
|
||||
<Show
|
||||
when={(query.data?.organizations.length ?? 0) > 0}
|
||||
fallback={(
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
{search() ? 'No organizations found matching your search.' : 'No organizations found.'}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<For each={table.getHeaderGroups()}>
|
||||
{headerGroup => (
|
||||
<TableRow>
|
||||
<For each={headerGroup.headers}>
|
||||
{header => (
|
||||
<TableHead>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)}
|
||||
</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={table.getRowModel().rows}>
|
||||
{row => (
|
||||
<TableRow>
|
||||
<For each={row.getVisibleCells()}>
|
||||
{cell => (
|
||||
<TableCell>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
)}
|
||||
</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Showing
|
||||
{' '}
|
||||
{table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
|
||||
{' '}
|
||||
to
|
||||
{' '}
|
||||
{Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, query.data?.totalCount ?? 0)}
|
||||
{' '}
|
||||
of
|
||||
{' '}
|
||||
{query.data?.totalCount ?? 0}
|
||||
{' '}
|
||||
organizations
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevrons-left" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevron-left" />
|
||||
</Button>
|
||||
<div class="text-sm whitespace-nowrap">
|
||||
Page
|
||||
{' '}
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
{' '}
|
||||
of
|
||||
{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevron-right" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<div class="size-4 i-tabler-chevrons-right" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminListOrganizationsPage;
|
||||
@@ -1,312 +0,0 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { For, Show, Suspense } from 'solid-js';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { Badge } from '@/modules/ui/components/badge';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import {
|
||||
getOrganizationBasicInfo,
|
||||
getOrganizationIntakeEmails,
|
||||
getOrganizationMembers,
|
||||
getOrganizationStats,
|
||||
getOrganizationWebhooks,
|
||||
} from '../organizations.services';
|
||||
|
||||
const OrganizationBasicInfo: Component<{ organizationId: string }> = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['admin', 'organizations', props.organizationId, 'basic'],
|
||||
queryFn: () => getOrganizationBasicInfo({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Show when={query.data}>
|
||||
{data => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organization Information</CardTitle>
|
||||
<CardDescription>Basic organization details</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">ID</span>
|
||||
<span class="font-mono text-xs">{data().organization.id}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">Name</span>
|
||||
<span class="text-sm font-medium">{data().organization.name}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">Created</span>
|
||||
<RelativeTime class="text-sm" date={new Date(data().organization.createdAt)} />
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">Updated</span>
|
||||
<RelativeTime class="text-sm" date={new Date(data().organization.updatedAt)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationMembers: Component<{ organizationId: string }> = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['admin', 'organizations', props.organizationId, 'members'],
|
||||
queryFn: () => getOrganizationMembers({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Members (
|
||||
{query.data?.members.length ?? 0}
|
||||
)
|
||||
</CardTitle>
|
||||
<CardDescription>Users who belong to this organization</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={query.data}>
|
||||
{data => (
|
||||
<Show
|
||||
when={data().members.length > 0}
|
||||
fallback={<p class="text-sm text-muted-foreground">No members found</p>}
|
||||
>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={data().members}>
|
||||
{member => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<A
|
||||
href={`/admin/users/${member.userId}`}
|
||||
class="hover:underline"
|
||||
>
|
||||
<div class="font-medium">{member.userName || member.userEmail}</div>
|
||||
<div class="text-xs text-muted-foreground">{member.userEmail}</div>
|
||||
</A>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" class="capitalize">
|
||||
{member.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RelativeTime class="text-muted-foreground text-sm" date={new Date(member.createdAt)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationIntakeEmails: Component<{ organizationId: string }> = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['admin', 'organizations', props.organizationId, 'intake-emails'],
|
||||
queryFn: () => getOrganizationIntakeEmails({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Intake Emails (
|
||||
{query.data?.intakeEmails.length ?? 0}
|
||||
)
|
||||
</CardTitle>
|
||||
<CardDescription>Email addresses for document ingestion</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={query.data}>
|
||||
{data => (
|
||||
<Show
|
||||
when={data().intakeEmails.length > 0}
|
||||
fallback={<p class="text-sm text-muted-foreground">No intake emails configured</p>}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<For each={data().intakeEmails}>
|
||||
{email => (
|
||||
<div class="flex items-center justify-between p-3 border rounded-md">
|
||||
<div>
|
||||
<div class="font-mono text-sm">{email.emailAddress}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{email.isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={email.isEnabled ? 'default' : 'outline'}>
|
||||
{email.isEnabled ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationWebhooks: Component<{ organizationId: string }> = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['admin', 'organizations', props.organizationId, 'webhooks'],
|
||||
queryFn: () => getOrganizationWebhooks({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Webhooks (
|
||||
{query.data?.webhooks.length ?? 0}
|
||||
)
|
||||
</CardTitle>
|
||||
<CardDescription>Configured webhook endpoints</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={query.data}>
|
||||
{data => (
|
||||
<Show
|
||||
when={data().webhooks.length > 0}
|
||||
fallback={<p class="text-sm text-muted-foreground">No webhooks configured</p>}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<For each={data().webhooks}>
|
||||
{webhook => (
|
||||
<div class="flex items-center justify-between p-3 border rounded-md">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{webhook.name}</div>
|
||||
<div class="font-mono text-xs text-muted-foreground truncate mt-1">{webhook.url}</div>
|
||||
</div>
|
||||
<Badge variant={webhook.enabled ? 'default' : 'outline'} class="ml-2 flex-shrink-0">
|
||||
{webhook.enabled ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationStats: Component<{ organizationId: string }> = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['admin', 'organizations', props.organizationId, 'stats'],
|
||||
queryFn: () => getOrganizationStats({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Statistics</CardTitle>
|
||||
<CardDescription>Document and storage statistics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={query.data}>
|
||||
{data => (
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">Active Documents</span>
|
||||
<span class="text-sm font-medium">{data().stats.documentsCount}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">Active Storage</span>
|
||||
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.documentsSize, base: 1000 })}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">Deleted Documents</span>
|
||||
<span class="text-sm font-medium">{data().stats.deletedDocumentsCount}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm text-muted-foreground">Deleted Storage</span>
|
||||
<span class="text-sm font-medium">{formatBytes({ bytes: data().stats.deletedDocumentsSize, base: 1000 })}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start pt-2 border-t">
|
||||
<span class="text-sm font-medium">Total Documents</span>
|
||||
<span class="text-sm font-bold">{data().stats.totalDocumentsCount}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="text-sm font-medium">Total Storage</span>
|
||||
<span class="text-sm font-bold">{formatBytes({ bytes: data().stats.totalDocumentsSize, base: 1000 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminOrganizationDetailPage: Component = () => {
|
||||
const params = useParams<{ organizationId: string }>();
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-lg mx-auto mt-4">
|
||||
<div class="mb-6">
|
||||
<Button as={A} href="/admin/organizations" variant="ghost" size="sm" class="mb-4">
|
||||
<div class="i-tabler-arrow-left size-4 mr-2" />
|
||||
Back to Organizations
|
||||
</Button>
|
||||
|
||||
<h1 class="text-2xl font-bold mb-1">
|
||||
Organization Details
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{params.organizationId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading organization info...</div>}>
|
||||
<OrganizationBasicInfo organizationId={params.organizationId} />
|
||||
</Suspense>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading stats...</div>}>
|
||||
<OrganizationStats organizationId={params.organizationId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading intake emails...</div>}>
|
||||
<OrganizationIntakeEmails organizationId={params.organizationId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading members...</div>}>
|
||||
<OrganizationMembers organizationId={params.organizationId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<div class="text-center py-4 text-muted-foreground">Loading webhooks...</div>}>
|
||||
<OrganizationWebhooks organizationId={params.organizationId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminOrganizationDetailPage;
|
||||
@@ -129,22 +129,8 @@ export const AdminUserDetailPage: Component = () => {
|
||||
<For each={data().organizations}>
|
||||
{org => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<A
|
||||
href={`/admin/organizations/${org.id}`}
|
||||
class="font-mono text-xs hover:underline text-primary"
|
||||
>
|
||||
{org.id}
|
||||
</A>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<A
|
||||
href={`/admin/organizations/${org.id}`}
|
||||
class="font-medium hover:underline"
|
||||
>
|
||||
{org.name}
|
||||
</A>
|
||||
</TableCell>
|
||||
<TableCell>{org.id}</TableCell>
|
||||
<TableCell class="font-medium">{org.name}</TableCell>
|
||||
<TableCell>
|
||||
<RelativeTime class="text-muted-foreground text-sm" date={new Date(org.createdAt)} />
|
||||
</TableCell>
|
||||
|
||||
@@ -85,7 +85,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
id: 'usr_1',
|
||||
email: 'jane.doe@papra.app',
|
||||
name: 'Jane Doe',
|
||||
roles: [],
|
||||
permissions: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -12,5 +12,11 @@ function baseHttpClient<A, R extends ResponseType = 'json'>({ url, baseUrl, ...r
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line antfu/no-top-level-await
|
||||
export const httpClient = buildTimeConfig.isDemoMode ? await import('@/modules/demo/demo-http-client').then(m => m.demoHttpClient) : baseHttpClient;
|
||||
export async function httpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>) {
|
||||
if (buildTimeConfig.isDemoMode) {
|
||||
const { demoHttpClient } = await import('@/modules/demo/demo-http-client');
|
||||
return demoHttpClient<A, R>(options);
|
||||
}
|
||||
|
||||
return baseHttpClient<A, R>(options);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { RouteDefinitionContext } from '../app/server.types';
|
||||
import { registerAnalyticsRoutes } from './analytics/analytics.routes';
|
||||
import { registerOrganizationManagementRoutes } from './organizations/organizations.routes';
|
||||
import { registerUserManagementRoutes } from './users/users.routes';
|
||||
|
||||
export function registerAdminRoutes(context: RouteDefinitionContext) {
|
||||
registerAnalyticsRoutes(context);
|
||||
registerUserManagementRoutes(context);
|
||||
registerOrganizationManagementRoutes(context);
|
||||
}
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../../../app/database/database.test-utils';
|
||||
import { createServer } from '../../../app/server';
|
||||
import { createTestServerDependencies } from '../../../app/server.test-utils';
|
||||
import { overrideConfig } from '../../../config/config.test-utils';
|
||||
|
||||
describe('admin organizations routes - permission protection', () => {
|
||||
describe('get /api/admin/organizations', () => {
|
||||
test('when the user has the VIEW_USERS permission, the request succeeds', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_123456789012345678901234', name: 'Organization 1' },
|
||||
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Organization 2' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = (await response.json()) as { organizations: unknown; totalCount: number };
|
||||
expect(body.organizations).to.have.length(2);
|
||||
expect(body.totalCount).to.eql(2);
|
||||
});
|
||||
|
||||
test('when using search parameter, it filters by name', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_alpha123456789012345678', name: 'Alpha Corporation' },
|
||||
{ id: 'org_beta1234567890123456789', name: 'Beta LLC' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations?search=Alpha',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = await response.json() as { organizations: { name: string }[]; totalCount: number };
|
||||
expect(body.organizations).to.have.length(1);
|
||||
expect(body.organizations[0]?.name).to.eql('Alpha Corporation');
|
||||
});
|
||||
|
||||
test('when using search parameter with organization ID, it returns exact match', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_123456789012345678901234', name: 'Alpha Corporation' },
|
||||
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations?search=org_abcdefghijklmnopqrstuvwx',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = await response.json() as { organizations: { id: string }[]; totalCount: number };
|
||||
expect(body.organizations).to.have.length(1);
|
||||
expect(body.organizations[0]?.id).to.eql('org_abcdefghijklmnopqrstuvwx');
|
||||
});
|
||||
|
||||
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'usr_regular', email: 'user@example.com' }],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_regular' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
expect(await response.json()).to.eql({
|
||||
error: {
|
||||
code: 'auth.unauthorized',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations',
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
expect(await response.json()).to.eql({
|
||||
error: {
|
||||
code: 'auth.unauthorized',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get /api/admin/organizations/:organizationId', () => {
|
||||
test('when the user has the VIEW_USERS permission, the request succeeds and returns organization basic info', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_test123456789012345678901',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = await response.json() as { organization: { id: string; name: string } };
|
||||
expect(body.organization.id).to.eql('org_test123456789012345678901');
|
||||
expect(body.organization.name).to.eql('Test Organization');
|
||||
});
|
||||
|
||||
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_999999999999999999999999',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(404);
|
||||
});
|
||||
|
||||
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_regular', email: 'user@example.com' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_test123456789012345678901',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_regular' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
expect(await response.json()).to.eql({
|
||||
error: {
|
||||
code: 'auth.unauthorized',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_test123456789012345678901',
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
expect(await response.json()).to.eql({
|
||||
error: {
|
||||
code: 'auth.unauthorized',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get /api/admin/organizations/:organizationId/members', () => {
|
||||
test('when the user has the VIEW_USERS permission, the request succeeds and returns members', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
{ id: 'usr_member', email: 'member@example.com', name: 'Member User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
organizationMembers: [
|
||||
{ userId: 'usr_member', organizationId: 'org_test123456789012345678901', role: 'member' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/members',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = await response.json() as { members: { userId: string; role: string }[] };
|
||||
expect(body.members).to.have.length(1);
|
||||
expect(body.members[0]?.userId).to.eql('usr_member');
|
||||
expect(body.members[0]?.role).to.eql('member');
|
||||
});
|
||||
|
||||
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_999999999999999999999999/members',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(404);
|
||||
});
|
||||
|
||||
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_regular', email: 'user@example.com' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/members',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_regular' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
|
||||
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/members',
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get /api/admin/organizations/:organizationId/intake-emails', () => {
|
||||
test('when the user has the VIEW_USERS permission, the request succeeds and returns intake emails', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
intakeEmails: [
|
||||
{ organizationId: 'org_test123456789012345678901', emailAddress: 'intake@example.com', isEnabled: true },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/intake-emails',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = await response.json() as { intakeEmails: { emailAddress: string; isEnabled: boolean }[] };
|
||||
expect(body.intakeEmails).to.have.length(1);
|
||||
expect(body.intakeEmails[0]?.emailAddress).to.eql('intake@example.com');
|
||||
});
|
||||
|
||||
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_999999999999999999999999/intake-emails',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(404);
|
||||
});
|
||||
|
||||
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_regular', email: 'user@example.com' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/intake-emails',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_regular' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
|
||||
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/intake-emails',
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get /api/admin/organizations/:organizationId/webhooks', () => {
|
||||
test('when the user has the VIEW_USERS permission, the request succeeds and returns webhooks', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
webhooks: [
|
||||
{ organizationId: 'org_test123456789012345678901', name: 'Test Webhook', url: 'https://example.com/webhook', enabled: true },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/webhooks',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = await response.json() as { webhooks: { name: string; url: string; enabled: boolean }[] };
|
||||
expect(body.webhooks).to.have.length(1);
|
||||
expect(body.webhooks[0]?.name).to.eql('Test Webhook');
|
||||
});
|
||||
|
||||
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_999999999999999999999999/webhooks',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(404);
|
||||
});
|
||||
|
||||
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_regular', email: 'user@example.com' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/webhooks',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_regular' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
|
||||
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/webhooks',
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get /api/admin/organizations/:organizationId/stats', () => {
|
||||
test('when the user has the VIEW_USERS permission, the request succeeds and returns stats', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/stats',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const body = await response.json() as { stats: { documentsCount: number; documentsSize: number } };
|
||||
expect(body.stats).to.have.property('documentsCount');
|
||||
expect(body.stats).to.have.property('documentsSize');
|
||||
});
|
||||
|
||||
test('when the organization does not exist, a 404 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_admin', email: 'admin@example.com', name: 'Admin User' },
|
||||
],
|
||||
userRoles: [
|
||||
{ userId: 'usr_admin', role: 'admin' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_999999999999999999999999/stats',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_admin' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(404);
|
||||
});
|
||||
|
||||
test('when the user does not have the VIEW_USERS permission, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_regular', email: 'user@example.com' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/stats',
|
||||
{ method: 'GET' },
|
||||
{ loggedInUserId: 'usr_regular' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
|
||||
test('when the user is not authenticated, a 401 error is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_test123456789012345678901', name: 'Test Organization' },
|
||||
],
|
||||
});
|
||||
|
||||
const { app } = createServer(createTestServerDependencies({ db, config: overrideConfig({ env: 'test' }) }));
|
||||
|
||||
const response = await app.request(
|
||||
'/api/admin/organizations/org_1/stats',
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
expect(response.status).to.eql(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { RouteDefinitionContext } from '../../app/server.types';
|
||||
import { z } from 'zod';
|
||||
import { createRoleMiddleware, requireAuthentication } from '../../app/auth/auth.middleware';
|
||||
import { createIntakeEmailsRepository } from '../../intake-emails/intake-emails.repository';
|
||||
import { organizationIdSchema } from '../../organizations/organization.schemas';
|
||||
import { createOrganizationNotFoundError } from '../../organizations/organizations.errors';
|
||||
import { createOrganizationsRepository } from '../../organizations/organizations.repository';
|
||||
import { PERMISSIONS } from '../../roles/roles.constants';
|
||||
import { validateParams, validateQuery } from '../../shared/validation/validation';
|
||||
import { createWebhookRepository } from '../../webhooks/webhook.repository';
|
||||
|
||||
export function registerOrganizationManagementRoutes(context: RouteDefinitionContext) {
|
||||
registerListOrganizationsRoute(context);
|
||||
registerGetOrganizationBasicInfoRoute(context);
|
||||
registerGetOrganizationMembersRoute(context);
|
||||
registerGetOrganizationIntakeEmailsRoute(context);
|
||||
registerGetOrganizationWebhooksRoute(context);
|
||||
registerGetOrganizationStatsRoute(context);
|
||||
}
|
||||
|
||||
function registerListOrganizationsRoute({ app, db }: RouteDefinitionContext) {
|
||||
const { requirePermissions } = createRoleMiddleware({ db });
|
||||
|
||||
app.get(
|
||||
'/api/admin/organizations',
|
||||
requireAuthentication(),
|
||||
requirePermissions({
|
||||
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||
}),
|
||||
validateQuery(
|
||||
z.object({
|
||||
search: z.string().optional(),
|
||||
pageIndex: z.coerce.number().min(0).int().optional().default(0),
|
||||
pageSize: z.coerce.number().min(1).max(100).int().optional().default(25),
|
||||
}),
|
||||
),
|
||||
async (context) => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
const { search, pageIndex, pageSize } = context.req.valid('query');
|
||||
|
||||
const { organizations, totalCount } = await organizationsRepository.listOrganizations({
|
||||
search,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return context.json({
|
||||
organizations,
|
||||
totalCount,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerGetOrganizationBasicInfoRoute({ app, db }: RouteDefinitionContext) {
|
||||
const { requirePermissions } = createRoleMiddleware({ db });
|
||||
|
||||
app.get(
|
||||
'/api/admin/organizations/:organizationId',
|
||||
requireAuthentication(),
|
||||
requirePermissions({
|
||||
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||
}),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
async (context) => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
const { organizationId } = context.req.valid('param');
|
||||
|
||||
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
|
||||
|
||||
if (!organization) {
|
||||
throw createOrganizationNotFoundError();
|
||||
}
|
||||
|
||||
return context.json({ organization });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerGetOrganizationMembersRoute({ app, db }: RouteDefinitionContext) {
|
||||
const { requirePermissions } = createRoleMiddleware({ db });
|
||||
|
||||
app.get(
|
||||
'/api/admin/organizations/:organizationId/members',
|
||||
requireAuthentication(),
|
||||
requirePermissions({
|
||||
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||
}),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
async (context) => {
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
|
||||
const { organizationId } = context.req.valid('param');
|
||||
|
||||
const { members } = await organizationsRepository.getOrganizationMembers({ organizationId });
|
||||
|
||||
return context.json({ members });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionContext) {
|
||||
const { requirePermissions } = createRoleMiddleware({ db });
|
||||
|
||||
app.get(
|
||||
'/api/admin/organizations/:organizationId/intake-emails',
|
||||
requireAuthentication(),
|
||||
requirePermissions({
|
||||
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||
}),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
async (context) => {
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
|
||||
const { organizationId } = context.req.valid('param');
|
||||
|
||||
const { intakeEmails } = await intakeEmailsRepository.getOrganizationIntakeEmails({ organizationId });
|
||||
|
||||
return context.json({ intakeEmails });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerGetOrganizationWebhooksRoute({ app, db }: RouteDefinitionContext) {
|
||||
const { requirePermissions } = createRoleMiddleware({ db });
|
||||
|
||||
app.get(
|
||||
'/api/admin/organizations/:organizationId/webhooks',
|
||||
requireAuthentication(),
|
||||
requirePermissions({
|
||||
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||
}),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
async (context) => {
|
||||
const webhookRepository = createWebhookRepository({ db });
|
||||
|
||||
const { organizationId } = context.req.valid('param');
|
||||
|
||||
const { webhooks } = await webhookRepository.getOrganizationWebhooks({ organizationId });
|
||||
|
||||
return context.json({ webhooks });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerGetOrganizationStatsRoute({ app, db }: RouteDefinitionContext) {
|
||||
const { requirePermissions } = createRoleMiddleware({ db });
|
||||
|
||||
app.get(
|
||||
'/api/admin/organizations/:organizationId/stats',
|
||||
requireAuthentication(),
|
||||
requirePermissions({
|
||||
requiredPermissions: [PERMISSIONS.VIEW_USERS],
|
||||
}),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
async (context) => {
|
||||
const { createDocumentsRepository } = await import('../../documents/documents.repository');
|
||||
|
||||
const { organizationId } = context.req.valid('param');
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const stats = await documentsRepository.getOrganizationStats({ organizationId });
|
||||
|
||||
return context.json({ stats });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { OrganizationInvitation } from './organizations.types';
|
||||
import { isAfter } from 'date-fns';
|
||||
import { eq, like } from 'drizzle-orm';
|
||||
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
|
||||
import { isNilOrEmptyString } from '../shared/utils';
|
||||
import { ORGANIZATION_ID_REGEX, ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
||||
import { organizationsTable } from './organizations.table';
|
||||
import { ORGANIZATION_INVITATION_STATUS } from './organizations.constants';
|
||||
|
||||
export function ensureInvitationStatus({ invitation, now = new Date() }: { invitation?: OrganizationInvitation | null | undefined; now?: Date }) {
|
||||
if (!invitation) {
|
||||
@@ -21,20 +17,3 @@ export function ensureInvitationStatus({ invitation, now = new Date() }: { invit
|
||||
|
||||
return { ...invitation, status: ORGANIZATION_INVITATION_STATUS.EXPIRED };
|
||||
}
|
||||
|
||||
export function createSearchOrganizationWhereClause({ search }: { search?: string }) {
|
||||
const trimmedSearch = search?.trim();
|
||||
|
||||
if (isNilOrEmptyString(trimmedSearch)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (ORGANIZATION_ID_REGEX.test(trimmedSearch)) {
|
||||
return eq(organizationsTable.id, trimmedSearch);
|
||||
}
|
||||
|
||||
const escapedSearch = escapeLikeWildcards(trimmedSearch);
|
||||
const likeSearch = `%${escapedSearch}%`;
|
||||
|
||||
return like(organizationsTable.name, likeSearch);
|
||||
}
|
||||
|
||||
@@ -250,167 +250,4 @@ describe('organizations repository', () => {
|
||||
expect(organizationCount).to.equal(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listOrganizations', () => {
|
||||
test('when no organizations exist, an empty list is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase();
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const result = await listOrganizations({});
|
||||
|
||||
expect(result).to.deep.equal({
|
||||
organizations: [],
|
||||
totalCount: 0,
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
});
|
||||
|
||||
test('when multiple organizations exist, all organizations are returned with member counts', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'user_1', email: 'user1@example.com', name: 'User 1' },
|
||||
{ id: 'user_2', email: 'user2@example.com', name: 'User 2' },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_1', name: 'Alpha Corp', createdAt: new Date('2025-01-02') },
|
||||
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-01') },
|
||||
],
|
||||
organizationMembers: [
|
||||
{ userId: 'user_1', organizationId: 'org_1', role: 'owner' },
|
||||
{ userId: 'user_2', organizationId: 'org_1', role: 'member' },
|
||||
],
|
||||
});
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const result = await listOrganizations({});
|
||||
|
||||
expect(result.organizations).to.have.length(2);
|
||||
expect(result.totalCount).to.equal(2);
|
||||
expect(result.pageIndex).to.equal(0);
|
||||
expect(result.pageSize).to.equal(25);
|
||||
|
||||
expect(
|
||||
result.organizations.map(org => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
memberCount: org.memberCount,
|
||||
})),
|
||||
).to.deep.equal([
|
||||
{ id: 'org_1', name: 'Alpha Corp', memberCount: 2 },
|
||||
{ id: 'org_2', name: 'Beta LLC', memberCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('when searching by organization ID, only the exact matching organization is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_123456789012345678901234', name: 'Alpha Corp' },
|
||||
{ id: 'org_abcdefghijklmnopqrstuvwx', name: 'Beta LLC' },
|
||||
],
|
||||
});
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const result = await listOrganizations({ search: 'org_abcdefghijklmnopqrstuvwx' });
|
||||
|
||||
expect(result.organizations).to.have.length(1);
|
||||
expect(result.organizations[0]?.id).to.equal('org_abcdefghijklmnopqrstuvwx');
|
||||
expect(result.totalCount).to.equal(1);
|
||||
});
|
||||
|
||||
test('when searching by partial name, matching organizations are returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_1', name: 'Alpha Corporation', createdAt: new Date('2025-01-02') },
|
||||
{ id: 'org_2', name: 'Beta LLC', createdAt: new Date('2025-01-03') },
|
||||
{ id: 'org_3', name: 'Alpha Industries', createdAt: new Date('2025-01-01') },
|
||||
],
|
||||
});
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const result = await listOrganizations({ search: 'Alpha' });
|
||||
|
||||
expect(result.organizations).to.have.length(2);
|
||||
expect(result.totalCount).to.equal(2);
|
||||
expect(result.organizations.map(org => org.name)).to.deep.equal([
|
||||
'Alpha Corporation',
|
||||
'Alpha Industries',
|
||||
]);
|
||||
});
|
||||
|
||||
test('when searching with an empty string, all organizations are returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_1', name: 'Alpha Corp' },
|
||||
{ id: 'org_2', name: 'Beta LLC' },
|
||||
],
|
||||
});
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const result = await listOrganizations({ search: ' ' });
|
||||
|
||||
expect(result.organizations).to.have.length(2);
|
||||
expect(result.totalCount).to.equal(2);
|
||||
});
|
||||
|
||||
test('when using pagination, only the requested page is returned', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_1', name: 'Org 1' },
|
||||
{ id: 'org_2', name: 'Org 2' },
|
||||
{ id: 'org_3', name: 'Org 3' },
|
||||
{ id: 'org_4', name: 'Org 4' },
|
||||
{ id: 'org_5', name: 'Org 5' },
|
||||
],
|
||||
});
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const firstPage = await listOrganizations({ pageIndex: 0, pageSize: 2 });
|
||||
const secondPage = await listOrganizations({ pageIndex: 1, pageSize: 2 });
|
||||
|
||||
expect(firstPage.organizations).to.have.length(2);
|
||||
expect(firstPage.totalCount).to.equal(5);
|
||||
expect(secondPage.organizations).to.have.length(2);
|
||||
expect(secondPage.totalCount).to.equal(5);
|
||||
expect(firstPage.organizations[0]?.id).to.not.equal(secondPage.organizations[0]?.id);
|
||||
});
|
||||
|
||||
test('when searching with pagination, the total count reflects the search results', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [
|
||||
{ id: 'org_1', name: 'Tech Corp 1' },
|
||||
{ id: 'org_2', name: 'Tech Corp 2' },
|
||||
{ id: 'org_3', name: 'Tech Corp 3' },
|
||||
{ id: 'org_4', name: 'Media LLC' },
|
||||
],
|
||||
});
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const result = await listOrganizations({ search: 'Tech', pageIndex: 0, pageSize: 2 });
|
||||
|
||||
expect(result.organizations).to.have.length(2);
|
||||
expect(result.totalCount).to.equal(3);
|
||||
});
|
||||
|
||||
test('when soft-deleted organizations exist, they are excluded from the results', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user_1', email: 'user1@test.com' }],
|
||||
organizations: [
|
||||
{ id: 'org_1', name: 'Active Org', createdAt: new Date('2025-01-02') },
|
||||
{ id: 'org_2', name: 'Deleted Org', createdAt: new Date('2025-01-03'), deletedAt: new Date('2025-05-15'), deletedBy: 'user_1', scheduledPurgeAt: new Date('2025-06-15') },
|
||||
{ id: 'org_3', name: 'Another Active Org', createdAt: new Date('2025-01-01') },
|
||||
],
|
||||
});
|
||||
const { listOrganizations } = createOrganizationsRepository({ db });
|
||||
|
||||
const result = await listOrganizations({});
|
||||
|
||||
expect(result.organizations).to.have.length(2);
|
||||
expect(result.totalCount).to.equal(2);
|
||||
expect(result.organizations.map(org => org.name)).to.deep.equal([
|
||||
'Active Org',
|
||||
'Another Active Org',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { Database } from '../app/database/database.types';
|
||||
import type { DbInsertableOrganization, OrganizationInvitationStatus, OrganizationRole } from './organizations.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { addDays, startOfDay } from 'date-fns';
|
||||
import { and, count, desc, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
|
||||
import { and, count, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm';
|
||||
import { omit } from 'lodash-es';
|
||||
import { withPagination } from '../shared/db/pagination';
|
||||
import { omitUndefined } from '../shared/utils';
|
||||
import { usersTable } from '../users/users.table';
|
||||
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { createOrganizationNotFoundError } from './organizations.errors';
|
||||
import { createSearchOrganizationWhereClause, ensureInvitationStatus } from './organizations.repository.models';
|
||||
import { ensureInvitationStatus } from './organizations.repository.models';
|
||||
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
|
||||
|
||||
export type OrganizationsRepository = ReturnType<typeof createOrganizationsRepository>;
|
||||
@@ -51,7 +50,6 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
|
||||
getUserDeletedOrganizations,
|
||||
getExpiredSoftDeletedOrganizations,
|
||||
getOrganizationCount,
|
||||
listOrganizations,
|
||||
},
|
||||
{ db },
|
||||
);
|
||||
@@ -555,52 +553,3 @@ async function getOrganizationCount({ db }: { db: Database }) {
|
||||
organizationCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function listOrganizations({
|
||||
db,
|
||||
search,
|
||||
pageIndex = 0,
|
||||
pageSize = 25,
|
||||
}: {
|
||||
db: Database;
|
||||
search?: string;
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const searchWhereClause = createSearchOrganizationWhereClause({ search });
|
||||
const whereClause = searchWhereClause
|
||||
? and(searchWhereClause, isNull(organizationsTable.deletedAt))
|
||||
: isNull(organizationsTable.deletedAt);
|
||||
|
||||
const query = db
|
||||
.select({
|
||||
...getTableColumns(organizationsTable),
|
||||
memberCount: count(organizationMembersTable.id),
|
||||
})
|
||||
.from(organizationsTable)
|
||||
.leftJoin(
|
||||
organizationMembersTable,
|
||||
eq(organizationsTable.id, organizationMembersTable.organizationId),
|
||||
)
|
||||
.where(whereClause)
|
||||
.groupBy(organizationsTable.id)
|
||||
.$dynamic();
|
||||
|
||||
const organizations = await withPagination(query, {
|
||||
orderByColumn: desc(organizationsTable.createdAt),
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
const [{ totalCount = 0 } = {}] = await db
|
||||
.select({ totalCount: count() })
|
||||
.from(organizationsTable)
|
||||
.where(whereClause);
|
||||
|
||||
return {
|
||||
organizations,
|
||||
totalCount,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { escapeLikeWildcards } from './sql.helpers';
|
||||
|
||||
describe('sql helpers', () => {
|
||||
describe('escapeLikeWildcards', () => {
|
||||
test('when input contains percent sign, it is escaped', () => {
|
||||
const result = escapeLikeWildcards('hello%world');
|
||||
|
||||
expect(result).to.equal('hello\\%world');
|
||||
});
|
||||
|
||||
test('when input contains underscore, it is escaped', () => {
|
||||
const result = escapeLikeWildcards('hello_world');
|
||||
|
||||
expect(result).to.equal('hello\\_world');
|
||||
});
|
||||
|
||||
test('when input contains both percent and underscore, both are escaped', () => {
|
||||
const result = escapeLikeWildcards('test%value_name');
|
||||
|
||||
expect(result).to.equal('test\\%value\\_name');
|
||||
});
|
||||
|
||||
test('when input contains multiple wildcards, all are escaped', () => {
|
||||
const result = escapeLikeWildcards('%%__%%');
|
||||
|
||||
expect(result).to.equal('\\%\\%\\_\\_\\%\\%');
|
||||
});
|
||||
|
||||
test('when input contains backslashes, they are escaped', () => {
|
||||
const result = escapeLikeWildcards('hello\\world');
|
||||
|
||||
expect(result).to.equal('hello\\\\world');
|
||||
});
|
||||
|
||||
test('when input contains backslashes and wildcards, all are escaped', () => {
|
||||
const result = escapeLikeWildcards('test\\%value');
|
||||
|
||||
expect(result).to.equal('test\\\\\\%value');
|
||||
});
|
||||
|
||||
test('when input contains no wildcards, it is returned unchanged', () => {
|
||||
const result = escapeLikeWildcards('hello world');
|
||||
|
||||
expect(result).to.equal('hello world');
|
||||
});
|
||||
|
||||
test('when input is empty string, empty string is returned', () => {
|
||||
const result = escapeLikeWildcards('');
|
||||
|
||||
expect(result).to.equal('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export function escapeLikeWildcards(input: string): string {
|
||||
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { eq, like, or } from 'drizzle-orm';
|
||||
import { escapeLikeWildcards } from '../shared/db/sql.helpers';
|
||||
import { isNilOrEmptyString } from '../shared/utils';
|
||||
import { USER_ID_REGEX } from './users.constants';
|
||||
import { usersTable } from './users.table';
|
||||
|
||||
export function escapeLikeWildcards(input: string) {
|
||||
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function createSearchUserWhereClause({ search }: { search?: string }) {
|
||||
const trimmedSearch = search?.trim();
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ describe('users repository', () => {
|
||||
test('when multiple users exist, all users are returned with organization counts', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [
|
||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice' },
|
||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob' },
|
||||
{ id: 'usr_1', email: 'alice@example.com', name: 'Alice', createdAt: new Date('2025-01-01') },
|
||||
{ id: 'usr_2', email: 'bob@example.com', name: 'Bob', createdAt: new Date('2025-01-02') },
|
||||
],
|
||||
organizations: [
|
||||
{ id: 'org_1', name: 'Org 1' },
|
||||
@@ -89,8 +89,8 @@ describe('users repository', () => {
|
||||
organizationCount: u.organizationCount,
|
||||
})),
|
||||
).to.deep.equal([
|
||||
{ id: 'usr_1', email: 'alice@example.com', organizationCount: 1 },
|
||||
{ id: 'usr_2', email: 'bob@example.com', organizationCount: 0 },
|
||||
{ id: 'usr_1', email: 'alice@example.com', organizationCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @papra/api-sdk
|
||||
|
||||
## 1.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#698](https://github.com/papra-hq/papra/pull/698) [`815f6f9`](https://github.com/papra-hq/papra/commit/815f6f94f84478fef049f9baea9b0b30b56906a2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed prepublishing assets
|
||||
|
||||
## 1.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/api-sdk",
|
||||
"type": "module",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"description": "Api SDK for Papra, the document archiving platform.",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -39,7 +39,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsdown",
|
||||
"build:watch": "tsdown --watch",
|
||||
"dev": "pnpm build:watch"
|
||||
"dev": "pnpm build:watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "catalog:",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @papra/cli
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`815f6f9`](https://github.com/papra-hq/papra/commit/815f6f94f84478fef049f9baea9b0b30b56906a2)]:
|
||||
- @papra/api-sdk@1.1.3
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#696](https://github.com/papra-hq/papra/pull/696) [`1c64bca`](https://github.com/papra-hq/papra/commit/1c64bca2971ed8f000dd91785a9f0dc5dfff4873) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed the publication of cli assets
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/cli",
|
||||
"type": "module",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"description": "Command line interface for Papra, the document archiving platform.",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -42,7 +42,8 @@
|
||||
"test:watch": "vitest watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsdown",
|
||||
"build:watch": "tsdown --watch"
|
||||
"build:watch": "tsdown --watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0-alpha.6",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# @papra/docker
|
||||
|
||||
## 25.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#685](https://github.com/papra-hq/papra/pull/685) [`cf91515`](https://github.com/papra-hq/papra/commit/cf91515cfe448176ac2f2c54f781495725678515) Thanks [@CorentinTh](https://github.com/CorentinTh)! - 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.
|
||||
|
||||
- [#686](https://github.com/papra-hq/papra/pull/686) [`95662d0`](https://github.com/papra-hq/papra/commit/95662d025f535bf0f4f48683c1f7cb1fffeff0a7) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Enforcing the auth secret to be at least 32 characters long for security reasons
|
||||
|
||||
- [#686](https://github.com/papra-hq/papra/pull/686) [`95662d0`](https://github.com/papra-hq/papra/commit/95662d025f535bf0f4f48683c1f7cb1fffeff0a7) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Now throw an error if AUTH_SECRET is not set in production mode
|
||||
|
||||
- [#689](https://github.com/papra-hq/papra/pull/689) [`d795798`](https://github.com/papra-hq/papra/commit/d7957989310693934fd6e30f6ce540d76f10c9a2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a platform administration dashboard
|
||||
|
||||
- [#675](https://github.com/papra-hq/papra/pull/675) [`17d6e9a`](https://github.com/papra-hq/papra/commit/17d6e9aa6a7152f3ceac3e829884cbd511166b99) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for Simplified Chinese language
|
||||
|
||||
- [#679](https://github.com/papra-hq/papra/pull/679) [`6f38659`](https://github.com/papra-hq/papra/commit/6f38659638f5b84cd3ca330e5c44cb3b452921ae) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue where the document icon didn't load for unknown file types
|
||||
|
||||
## 25.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@papra/docker",
|
||||
"version": "25.11.0",
|
||||
"version": "25.12.0",
|
||||
"private": true,
|
||||
"description": "Docker image version tracker for Papra, calver-ish versioned.",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @papra/webhooks
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#699](https://github.com/papra-hq/papra/pull/699) [`4342b31`](https://github.com/papra-hq/papra/commit/4342b319ea1b787b80d02090f3820797b928e115) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix mising prepublish script
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/webhooks",
|
||||
"type": "module",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "Webhooks helper library for Papra, the document archiving platform.",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -38,7 +38,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsdown",
|
||||
"build:watch": "tsdown --watch",
|
||||
"dev": "pnpm build:watch"
|
||||
"dev": "pnpm build:watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "catalog:",
|
||||
|
||||
Reference in New Issue
Block a user