Compare commits

..

5 Commits

Author SHA1 Message Date
Corentin Thomasset
96f29ba58f chore(release): update versions (#676)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-21 18:51:43 +01:00
Corentin Thomasset
33e3de9b8f chore(changesets): marked docker updates minor for monthly upgrade (#697) 2025-12-21 17:50:09 +00:00
Corentin Thomasset
1c64bca297 fix(cli): prepublish script (#696) 2025-12-21 17:46:00 +00:00
Corentin Thomasset
f7bf202230 fix(tests): add createdAt field to user for deterministic ordering (#694) 2025-12-20 23:38:21 +00:00
Corentin Thomasset
5b905a1714 fix(demo): properly lazy load demo http client mock (#693) 2025-12-20 15:36:40 +01:00
27 changed files with 49 additions and 1775 deletions

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -85,7 +85,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
id: 'usr_1',
email: 'jane.doe@papra.app',
name: 'Jane Doe',
roles: [],
permissions: [],
},
}),
}),

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
});
});
});

View File

@@ -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 });
},
);
}

View File

@@ -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);
}

View File

@@ -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',
]);
});
});
});

View File

@@ -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,
};
}

View File

@@ -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('');
});
});
});

View File

@@ -1,3 +0,0 @@
export function escapeLikeWildcards(input: string): string {
return input.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&');
}

View File

@@ -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();

View File

@@ -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 },
]);
});

View File

@@ -1,5 +1,11 @@
# @papra/cli
## 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

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/cli",
"type": "module",
"version": "0.2.0",
"version": "0.2.1",
"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",

View File

@@ -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

View File

@@ -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": {