feat(demo): added custom properties in demo (#967)

This commit is contained in:
Corentin Thomasset 2026-03-25 17:16:55 +01:00 committed by GitHub
parent d47d6b29a6
commit b900a1d947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 509 additions and 8 deletions

View file

@ -1,7 +1,9 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import type { CustomPropertyDefinition } from '../custom-properties/custom-properties.types';
import type { Document } from '../documents/documents.types';
import type { Webhook } from '../webhooks/webhooks.types';
import type {
DocumentCustomPropertyValueStorage,
DocumentFile,
} from './demo.storage';
import { FetchError } from 'ofetch';
@ -11,6 +13,8 @@ import { defineHandler } from './demo-api-mock.models';
import { createId, randomString } from './demo.models';
import {
apiKeyStorage,
customPropertyDefinitionStorage,
documentCustomPropertyValueStorage,
documentFileStorage,
documentStorage,
organizationStorage,
@ -65,6 +69,61 @@ async function deserializeFile(storageInfo: DocumentFile): Promise<File> {
return new File([await fromBase64(base64Content)], name, { type });
}
function hydratePropertyValue({ value, definition }: { value: unknown; definition: CustomPropertyDefinition }): unknown {
if (value == null) {
return null;
}
if (definition.type === 'select') {
const optionId = String(value);
const option = definition.options.find(o => o.id === optionId || o.key === optionId);
if (!option) {
return null;
}
return { optionId: option.id, name: option.name };
}
if (definition.type === 'multi_select') {
const ids = Array.isArray(value) ? value.map(String) : [String(value)];
return ids
.map((id) => {
const option = definition.options.find(o => o.id === id || o.key === id);
return option ? { optionId: option.id, name: option.name } : null;
})
.filter(v => v !== null);
}
return value;
}
function buildCustomPropertiesResponse({
definitions,
storedValues,
documentId,
}: {
definitions: CustomPropertyDefinition[];
storedValues: DocumentCustomPropertyValueStorage[];
documentId: string;
}) {
const documentValues = storedValues.filter(v => v.documentId === documentId);
const valuesByDefinitionId = Object.fromEntries(documentValues.map(v => [v.propertyDefinitionId, v.value]));
return definitions
.toSorted((a, b) => a.displayOrder - b.displayOrder)
.map(def => ({
propertyDefinitionId: def.id,
key: def.key,
name: def.name,
type: def.type,
displayOrder: def.displayOrder,
value: hydratePropertyValue({ value: valuesByDefinitionId[def.id] ?? null, definition: def }),
}));
}
const inMemoryApiMock: Record<string, { handler: any }> = {
...defineHandler({
path: '/api/config',
@ -166,23 +225,32 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 });
const searchQuery = rawSearchQuery.trim();
const [organizationDocuments, allTags, tagDocuments] = await Promise.all([
const [organizationDocuments, allTags, tagDocuments, allDefinitions, allPropertyValues] = await Promise.all([
findMany(documentStorage, document => document?.organizationId === organizationId && !document?.deletedAt),
getValues(tagStorage),
getValues(tagDocumentStorage),
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
getValues(documentCustomPropertyValueStorage),
]);
const documentsWithTags = organizationDocuments.map((document) => {
const documentsWithTagsAndProperties = organizationDocuments.map((document) => {
const documentTagDocuments = tagDocuments.filter(tagDocument => tagDocument?.documentId === document?.id);
const tags = allTags.filter(tag => documentTagDocuments.some(tagDocument => tagDocument?.tagId === tag?.id));
const customProperties = buildCustomPropertiesResponse({
definitions: allDefinitions,
storedValues: allPropertyValues,
documentId: document.id,
});
return {
...document,
tags,
customProperties,
};
});
const filteredDocuments = searchDemoDocuments({ query: searchQuery, documents: documentsWithTags as Document[] });
const filteredDocuments = searchDemoDocuments({ query: searchQuery, documents: documentsWithTagsAndProperties as unknown as Document[] });
const paginatedDocuments = filteredDocuments
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
@ -223,13 +291,25 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(document, { status: 404 });
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument.documentId === documentId);
const [tagDocuments, allDefinitions, allPropertyValues] = await Promise.all([
findMany(tagDocumentStorage, tagDocument => tagDocument.documentId === documentId),
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
findMany(documentCustomPropertyValueStorage, v => v.documentId === documentId),
]);
const tags = await findMany(tagStorage, tag => tagDocuments.some(tagDocument => tagDocument.tagId === tag.id));
const customProperties = buildCustomPropertiesResponse({
definitions: allDefinitions,
storedValues: allPropertyValues,
documentId,
});
return {
document: {
...document,
tags,
customProperties,
},
};
},
@ -862,6 +942,173 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
};
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const propertyDefinitions = await findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId);
return { propertyDefinitions };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties',
method: 'POST',
handler: async ({ params: { organizationId }, body }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const existingDefinitions = await findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId);
const propertyDefinition = {
id: createId({ prefix: 'cpd' }),
organizationId,
name: get(body, ['name']) as string,
key: (get(body, ['name']) as string).toLowerCase().replace(/\s+/g, '_'),
description: (get(body, ['description']) ?? null) as string | null,
type: get(body, ['type']) as string,
displayOrder: existingDefinitions.length,
options: (get(body, ['options']) as { name: string }[] ?? []).map((option, index) => ({
id: createId({ prefix: 'opt' }),
name: option.name,
key: option.name.toLowerCase().replace(/\s+/g, '_'),
displayOrder: index,
})),
createdAt: new Date(),
updatedAt: new Date(),
};
await customPropertyDefinitionStorage.setItem(propertyDefinition.id, propertyDefinition as any);
return { propertyDefinition };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
method: 'GET',
handler: async ({ params: { organizationId, propertyDefinitionId } }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const definition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
assert(definition, { status: 404 });
return { definition };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
method: 'PUT',
handler: async ({ params: { organizationId, propertyDefinitionId }, body }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const propertyDefinition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
assert(propertyDefinition, { status: 404 });
const updatedDefinition = Object.assign(propertyDefinition, body, { updatedAt: new Date() });
await customPropertyDefinitionStorage.setItem(propertyDefinitionId, updatedDefinition);
return { propertyDefinition: updatedDefinition };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
method: 'DELETE',
handler: async ({ params: { organizationId, propertyDefinitionId } }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
await customPropertyDefinitionStorage.removeItem(propertyDefinitionId);
// Remove all values associated with this definition
const values = await findMany(documentCustomPropertyValueStorage, v => v.propertyDefinitionId === propertyDefinitionId);
await Promise.all(values.map(v => documentCustomPropertyValueStorage.removeItem(`${v.documentId}:${propertyDefinitionId}`)));
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties',
method: 'GET',
handler: async ({ params: { organizationId, documentId } }) => {
const key = `${organizationId}:${documentId}`;
const document = await documentStorage.getItem(key);
assert(document, { status: 404 });
const [allDefinitions, allValues] = await Promise.all([
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
findMany(documentCustomPropertyValueStorage, v => v.documentId === documentId),
]);
const customProperties = buildCustomPropertiesResponse({
definitions: allDefinitions,
storedValues: allValues,
documentId,
});
return { customProperties };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties/:propertyDefinitionId',
method: 'PUT',
handler: async ({ params: { organizationId, documentId, propertyDefinitionId }, body }) => {
const docKey = `${organizationId}:${documentId}`;
const document = await documentStorage.getItem(docKey);
assert(document, { status: 404 });
const definition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
assert(definition, { status: 404 });
const valueKey = `${documentId}:${propertyDefinitionId}`;
const existing = await documentCustomPropertyValueStorage.getItem(valueKey);
const value = get(body, ['value']);
await documentCustomPropertyValueStorage.setItem(valueKey, {
id: existing?.id ?? createId({ prefix: 'dcpv' }),
documentId,
propertyDefinitionId,
value,
});
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties/:propertyDefinitionId',
method: 'DELETE',
handler: async ({ params: { organizationId, documentId, propertyDefinitionId } }) => {
const docKey = `${organizationId}:${documentId}`;
const document = await documentStorage.getItem(docKey);
assert(document, { status: 404 });
const valueKey = `${documentId}:${propertyDefinitionId}`;
await documentCustomPropertyValueStorage.removeItem(valueKey);
},
}),
};
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View file

@ -1,4 +1,5 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import type { CustomPropertyDefinition } from '../custom-properties/custom-properties.types';
import type { Document } from '../documents/documents.types';
import type { Organization } from '../organizations/organizations.types';
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
@ -9,6 +10,7 @@ import localStorageDriver from 'unstorage/drivers/localstorage';
import { trackingServices } from '../tracking/tracking.services';
import { DEMO_IS_SEEDED_KEY } from './demo.constants';
import { createId } from './demo.models';
import { customPropertyDefinitionsFixtures } from './seed/custom-property-definitions.fixtures';
import { documentFixtures } from './seed/documents.fixtures';
import { tagsFixtures } from './seed/tags.fixtures';
@ -20,6 +22,13 @@ export type DocumentFileStoredFile = { name: string; size: number; type: string;
export type DocumentFileRemoteFile = { name: string; path: string };
export type DocumentFile = DocumentFileStoredFile | DocumentFileRemoteFile;
export type DocumentCustomPropertyValueStorage = {
id: string;
documentId: string;
propertyDefinitionId: string;
value: unknown;
};
export const organizationStorage = prefixStorage<Organization>(storage, 'organizations');
export const documentStorage = prefixStorage<Document>(storage, 'documents');
export const documentFileStorage = prefixStorage<DocumentFile>(storage, 'documentFiles');
@ -28,6 +37,8 @@ export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: str
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
export const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks');
export const customPropertyDefinitionStorage = prefixStorage<CustomPropertyDefinition>(storage, 'customPropertyDefinitions');
export const documentCustomPropertyValueStorage = prefixStorage<DocumentCustomPropertyValueStorage>(storage, 'documentCustomPropertyValues');
export async function clearDemoStorage() {
await storage.clear();
@ -90,6 +101,24 @@ export async function seedDemoStorage() {
const tagsPromises = tagStorage.setItems(tags.map(tag => ({ key: tag.id, value: tag })));
// Create custom property definitions
const customPropertyDefinitions = customPropertyDefinitionsFixtures.map((fixture, index) => ({
id: createId({ prefix: 'cpd' }),
organizationId,
name: fixture.name,
key: fixture.key,
description: fixture.description ?? null,
type: fixture.type,
displayOrder: index,
options: fixture.options ?? [],
createdAt: lastMonth,
updatedAt: lastMonth,
}));
const customPropertyDefinitionsPromises = customPropertyDefinitionStorage.setItems(
customPropertyDefinitions.map(def => ({ key: def.id, value: def })),
);
const documentsPromises = documentFixtures.flatMap((fixture) => {
const documentId = createId({ prefix: 'doc' });
@ -126,11 +155,30 @@ export async function seedDemoStorage() {
};
}));
return [documentPromise, documentFilePromise, tagDocumentPromise];
const customPropertyValuePromises = (fixture.customProperties ?? []).map((prop) => {
const definition = customPropertyDefinitions.find(def => def.key === prop.key);
if (!definition) {
return Promise.resolve();
}
const id = createId({ prefix: 'dcpv' });
const valueKey = `${documentId}:${definition.id}`;
return documentCustomPropertyValueStorage.setItem(valueKey, {
id,
documentId,
propertyDefinitionId: definition.id,
value: prop.value,
});
});
return [documentPromise, documentFilePromise, tagDocumentPromise, ...customPropertyValuePromises];
});
await Promise.all([
tagsPromises,
customPropertyDefinitionsPromises,
...documentsPromises,
]);
}

View file

@ -5,6 +5,7 @@ import { parseSearchQuery } from '@papra/search-parser';
type DocumentCondition = (params: { document: Document }) => boolean;
const falseCondition: DocumentCondition = () => false;
const trueCondition: DocumentCondition = () => true;
export function someCorpusTokenStartsWith({ corpus, prefix }: { corpus: string | string []; prefix: string }): boolean {
const lowerPrefix = prefix.toLowerCase();
@ -149,6 +150,113 @@ function buildHasDateFilter({ expression }: { expression: FilterExpression }): D
return ({ document }) => document.documentDate != null;
}
function buildHasCustomPropertyFilter({ propertyKey }: { propertyKey: string }): DocumentCondition {
return ({ document }) => {
const prop = document.customProperties?.find(p => p.key === propertyKey);
return prop !== undefined && prop.value != null;
};
}
function buildCustomPropertyFilterCondition({ field, expression }: { field: string; expression: FilterExpression }): DocumentCondition {
const { value, operator } = expression;
return ({ document }) => {
const prop = document.customProperties?.find(p => p.key === field);
if (!prop || prop.value == null) {
return false;
}
switch (prop.type) {
case 'boolean': {
if (operator !== '=') {
return false;
}
const boolValue = ['true', 'yes', '1', 'on', 'enabled'].includes(value.toLowerCase());
return prop.value === boolValue;
}
case 'number': {
const numValue = Number(value);
if (Number.isNaN(numValue)) {
return false;
}
const propNum = Number(prop.value);
switch (operator) {
case '=': return propNum === numValue;
case '<': return propNum < numValue;
case '<=': return propNum <= numValue;
case '>': return propNum > numValue;
case '>=': return propNum >= numValue;
default: return false;
}
}
case 'date': {
const dateValue = getDateValue({ value });
if (Number.isNaN(dateValue.getTime())) {
return false;
}
const propDate = new Date(prop.value as string);
switch (operator) {
case '=': return propDate.toDateString() === dateValue.toDateString();
case '<': return propDate < dateValue;
case '<=': return propDate <= dateValue;
case '>': return propDate > dateValue;
case '>=': return propDate >= dateValue;
default: return false;
}
}
case 'text': {
if (operator !== '=') {
return false;
}
return someCorpusTokenStartsWith({ corpus: String(prop.value), prefix: value });
}
case 'select': {
if (operator !== '=') {
return false;
}
const propValue = prop.value as { key: string; name: string } | string;
const propKey = typeof propValue === 'object' ? propValue.key : propValue;
return propKey.toLowerCase() === value.toLowerCase();
}
case 'multi_select': {
if (operator !== '=') {
return false;
}
const propValues = prop.value as Array<{ key: string; name: string } | string>;
return propValues.some((v) => {
const optKey = typeof v === 'object' ? v.key : v;
return optKey.toLowerCase() === value.toLowerCase();
});
}
default: {
if (operator !== '=') {
return false;
}
return String(prop.value).toLowerCase() === value.toLowerCase();
}
}
};
}
const KNOWN_FILTER_FIELDS = new Set(['tag', 'name', 'content', 'created', 'date', 'has']);
function buildExpressionCondition({ expression }: { expression: Expression }): DocumentCondition {
switch (expression.type) {
case 'text': return buildTextCondition({ expression });
@ -166,11 +274,19 @@ function buildExpressionCondition({ expression }: { expression: Expression }): D
switch (expression.value) {
case 'tags': return buildHasTagsFilter({ expression });
case 'date': return buildHasDateFilter({ expression });
default: return falseCondition;
default:
// has:<customPropertyKey> — check if document has a non-null value for this property
return buildHasCustomPropertyFilter({ propertyKey: expression.value });
}
default: return falseCondition;
default:
// Unknown field — treat as a custom property key filter
if (!KNOWN_FILTER_FIELDS.has(expression.field)) {
return buildCustomPropertyFilterCondition({ field: expression.field, expression });
}
return falseCondition;
}
case 'empty': return falseCondition;
case 'empty': return trueCondition;
default: return falseCondition;
}
}

View file

@ -0,0 +1,53 @@
import type { CustomPropertySelectOption, CustomPropertyType } from '../../custom-properties/custom-properties.types';
export type DemoCustomPropertyDefinitionFixture = {
name: string;
key: string;
description?: string;
type: CustomPropertyType;
options?: CustomPropertySelectOption[];
};
export const customPropertyDefinitionsFixtures: DemoCustomPropertyDefinitionFixture[] = [
{
name: 'Status',
key: 'status',
description: 'Current processing status of the document',
type: 'select',
options: [
{ id: 'opt_status_pending', key: 'pending', name: 'Pending', displayOrder: 0 },
{ id: 'opt_status_reviewed', key: 'reviewed', name: 'Reviewed', displayOrder: 1 },
{ id: 'opt_status_archived', key: 'archived', name: 'Archived', displayOrder: 2 },
],
},
{
name: 'Priority',
key: 'priority',
description: 'Investigation priority level',
type: 'select',
options: [
{ id: 'opt_priority_low', key: 'low', name: 'Low', displayOrder: 0 },
{ id: 'opt_priority_medium', key: 'medium', name: 'Medium', displayOrder: 1 },
{ id: 'opt_priority_high', key: 'high', name: 'High', displayOrder: 2 },
{ id: 'opt_priority_critical', key: 'critical', name: 'Critical', displayOrder: 3 },
],
},
{
name: 'Amount',
key: 'amount',
description: 'Monetary amount in GBP',
type: 'number',
},
{
name: 'Confidential',
key: 'confidential',
description: 'Whether this document is confidential',
type: 'boolean',
},
{
name: 'Reference',
key: 'reference',
description: 'External reference number or identifier',
type: 'text',
},
] as const satisfies DemoCustomPropertyDefinitionFixture[];

View file

@ -8,6 +8,12 @@ const demoDocumentFixture: DemoDocumentFixture = {
mimeType: 'application/pdf',
size: 31564,
tags: ['Cases'],
customProperties: [
{ key: 'status', value: 'reviewed' },
{ key: 'priority', value: 'critical' },
{ key: 'confidential', value: true },
{ key: 'reference', value: 'CASE-2026-001' },
],
content: `
blackmail letter from reginald thornton fiennes date january 20 2026
reggie com to sherlock holmes sleuth subject the secrets you

View file

@ -8,6 +8,13 @@ const demoDocumentFixture: DemoDocumentFixture = {
mimeType: 'application/pdf',
size: 54921,
tags: ['Legal', 'Cases'],
customProperties: [
{ key: 'status', value: 'reviewed' },
{ key: 'priority', value: 'high' },
{ key: 'amount', value: 5000 },
{ key: 'confidential', value: true },
{ key: 'reference', value: 'SH-2024-087' },
],
content: `
contract private investigation pemberton number sh 2024 087 date 22nd
september parties service provider sherlock holmes consulting detective 221b baker

View file

@ -8,6 +8,10 @@ const demoDocumentFixture: DemoDocumentFixture = {
mimeType: 'application/pdf',
size: 50374,
tags: ['Receipts'],
customProperties: [
{ key: 'status', value: 'archived' },
{ key: 'amount', value: 19.56 },
],
content: `
receipt for groceries date january 20 2026 store tesco superstore
123 regent st london sw1e 7na order number tcgro000023 item

View file

@ -8,6 +8,10 @@ const demoDocumentFixture: DemoDocumentFixture = {
mimeType: 'application/pdf',
size: 38421,
tags: ['Receipts', 'Property'],
customProperties: [
{ key: 'status', value: 'archived' },
{ key: 'amount', value: 2000 },
],
content: `
rent receipt april 2025 221b baker street marylebone london nw1
6xe date 1st received from mr sherlock holmes for first

View file

@ -8,6 +8,10 @@ const demoDocumentFixture: DemoDocumentFixture = {
mimeType: 'application/pdf',
size: 37805,
tags: ['Receipts', 'Property'],
customProperties: [
{ key: 'status', value: 'archived' },
{ key: 'amount', value: 2000 },
],
content: `
rent receipt july 2025 221b baker street marylebone london nw1
6xe date 1st received from mr sherlock holmes for first

View file

@ -8,6 +8,11 @@ const demoDocumentFixture: DemoDocumentFixture = {
mimeType: 'application/pdf',
size: 51229,
tags: ['Receipts'],
customProperties: [
{ key: 'status', value: 'archived' },
{ key: 'amount', value: 3260 },
{ key: 'reference', value: 'SL-2025-1108' },
],
content: `
violin receipt vendor information transaction details item description price quantity
customized the baker 2 500 00 1 hard case with

View file

@ -1,5 +1,11 @@
import type { DemoCustomPropertyDefinitionFixture } from './custom-property-definitions.fixtures';
import type { DemoTagFixtureNames } from './tags.fixtures';
export type DemoDocumentCustomPropertyValue = {
key: DemoCustomPropertyDefinitionFixture['key'];
value: unknown;
};
export type DemoDocumentFixture = {
name: string;
date: Date;
@ -8,4 +14,5 @@ export type DemoDocumentFixture = {
tags: DemoTagFixtureNames[];
mimeType: string;
size: number;
customProperties?: DemoDocumentCustomPropertyValue[];
};