Compare commits

...

1 Commits

Author SHA1 Message Date
Corentin Thomasset
13889c1c42 wip 2025-06-29 15:28:17 +02:00
8 changed files with 316 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
export const DOCUMENTS_REQUESTS_ID_PREFIX = 'dr';
export const DOCUMENTS_REQUESTS_FILES_ID_PREFIX = 'dr_files';
export const DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX = 'dr_file_tags';
export const DOCUMENTS_REQUESTS_TOKEN_LENGTH = 32;

View File

@@ -0,0 +1,76 @@
import type { Database } from '../app/database/database.types';
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { randomBytes } from 'node:crypto';
import { injectArguments } from '@corentinth/chisels';
import { generateId } from '../shared/random/ids';
import { DOCUMENTS_REQUESTS_FILES_ID_PREFIX, DOCUMENTS_REQUESTS_ID_PREFIX } from './documents-requests.constants';
import { documentsRequestsFilesTable, documentsRequestsFileTagsTable, documentsRequestsTable } from './documents-requests.tables';
export function createDocumentsRequestsRepository({ db }: { db: Database }) {
return injectArguments(
{
createDocumentsRequest,
},
{
db,
},
);
}
async function createDocumentsRequest({
documentsRequest,
files,
db,
}: {
documentsRequest: {
token: string;
organizationId: string;
createdBy: string | null;
title: string;
description?: string;
useLimit?: number;
expiresAt?: Date;
accessLevel: DocumentsRequestAccessLevel;
isEnabled?: boolean;
};
files: {
title: string;
description?: string;
allowedMimeTypes: string[];
sizeLimit?: number;
tags: string[];
}[];
db: Database;
}) {
const [createdDocumentsRequest] = await db
.insert(documentsRequestsTable)
.values(documentsRequest)
.returning();
for (const file of files) {
const [createdFile] = await db
.insert(documentsRequestsFilesTable)
.values({
documentsRequestId: createdDocumentsRequest.id,
title: file.title,
description: file.description,
allowedMimeTypes: file.allowedMimeTypes,
sizeLimit: file.sizeLimit,
})
.returning();
for (const tag of file.tags) {
await db
.insert(documentsRequestsFileTagsTable)
.values({
documentsRequestId: createdDocumentsRequest.id,
fileId: createdFile.id,
tagId: tag,
});
}
}
return { documentsRequest: createdDocumentsRequest };
}

View File

@@ -0,0 +1,68 @@
import type { RouteDefinitionContext } from '../app/server.types';
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { tagIdSchema } from '../tags/tags.schemas';
import { createDocumentsRequestsRepository } from './documents-requests.repository';
import { createDocumentsRequest } from './documents-requests.usecases';
export function registerDocumentsRequestsRoutes(context: RouteDefinitionContext) {
setupCreateDocumentsRequestRoute(context);
}
function setupCreateDocumentsRequestRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/documents-requests',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
validateJsonBody(z.object({
title: z.string().min(1).max(100),
description: z.string().max(512).optional(),
useLimit: z.number().positive().optional(),
expiresAt: z.date().optional(),
accessLevel: z.enum(['organization_members', 'authenticated_users', 'public'] as const),
isEnabled: z.boolean().optional().default(true),
files: z.array(z.object({
title: z.string().min(1).max(100),
description: z.string().max(512).optional(),
allowedMimeTypes: z.array(z.string()).optional().default(['*/*']),
sizeLimit: z.number().positive().optional(),
tags: z.array(tagIdSchema).optional().default([]),
})).min(1).max(32),
})),
async (context) => {
const { userId } = getUser({ context });
const { organizationId } = context.req.valid('param');
const { title, description, useLimit, expiresAt, accessLevel, isEnabled, files } = context.req.valid('json');
const organizationsRepository = createOrganizationsRepository({ db });
const documentsRequestsRepository = createDocumentsRequestsRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
const { documentsRequest } = await createDocumentsRequest({
organizationId,
createdBy: userId,
title,
description,
useLimit,
expiresAt,
accessLevel: accessLevel as DocumentsRequestAccessLevel,
isEnabled,
documentsRequestsRepository,
files,
});
return context.json({
documentsRequest,
});
},
);
}

View File

@@ -0,0 +1,8 @@
import { generateToken } from '../shared/random/random.services';
import { DOCUMENTS_REQUESTS_TOKEN_LENGTH } from './documents-requests.constants';
export function generateDocumentsRequestToken() {
const { token } = generateToken({ length: DOCUMENTS_REQUESTS_TOKEN_LENGTH });
return { token };
}

View File

@@ -0,0 +1,59 @@
Here's the complete specification for implementing the **Document Request Feature** in Papra, including the required database schema adjustments:
## Feature Overview
The **Document Request Feature** allows Papra users to create links through which others can upload documents directly into an organization's document archive. These links can be configured for multiple specific file types, restricted to one-time or multiple uses, pre-assigned tags per file type, and configured access levels (organization members only, authenticated users, or public access).
---
## Functional Requirements
### 1. Creating a Document Request
Users should be able to configure the following when creating a request:
* **Title**: Descriptive title for the request (e.g., "Quarterly Reports Submission").
* **Description** (optional): Brief context/instructions.
* **File Types Configuration**: Define multiple specific file types that can be uploaded:
* **File Title**: Descriptive name for each file type (e.g., "Financial Report", "Supporting Documents").
* **File Description** (optional): Specific instructions for each file type.
* **Allowed MIME Types**: Specify accepted file formats (e.g., `['application/pdf', 'image/jpeg']` or `['*/*']` for all types).
* **Size Limit** (optional): Maximum file size in bytes for each file type.
* **Predefined Tags**: Tags automatically applied to uploaded documents of this specific file type.
* **Use Limit**:
* Single-use: Link is valid for only one submission.
* Multi-use: Link allows multiple submissions.
* Unlimited submissions option (toggle on/off).
* **Expiration Date** (optional): Request becomes invalid after a specific date.
* **Access Restrictions**:
* **Org Members Only**: Only current organization members can submit.
* **Authenticated Users**: Any logged-in user can submit.
* **Public Access**: Anyone with the link can submit.
### 2. Document Upload via Request Link
When a recipient accesses the link:
* They see the request details (title, description, required file types).
* If access restricted, validation occurs based on the specified type.
* User uploads documents for each configured file type:
* Each file type shows its specific title, description, and requirements.
* Files are validated against the configured MIME types and size limits.
* Users can see which tags will be automatically applied to each file type.
* Documents are tagged automatically based on predefined tags for each file type.
### 3. Managing Requests
* Creator can view active/inactive requests.
* Creator can disable, edit, or delete a request (deletion/archive keeps submitted docs intact).
* Creator can modify file type configurations, including adding/removing file types.
### 4. Notifications & Tracking
* Optional notifications via email or app notifications upon document upload.
* Request creator receives updates about submissions.
## Conclusion
This spec outlines a robust document request feature with multi-file type support, integrating smoothly with Papra's existing architecture, providing flexibility, security, and ease-of-use, fulfilling both individual and organizational needs effectively.

View File

@@ -0,0 +1,45 @@
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { organizationsTable } from '../organizations/organizations.table';
import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers';
import { tagsTable } from '../tags/tags.table';
import { usersTable } from '../users/users.table';
import { DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX, DOCUMENTS_REQUESTS_FILES_ID_PREFIX, DOCUMENTS_REQUESTS_ID_PREFIX } from './documents-requests.constants';
export const documentsRequestsTable = sqliteTable('documents_requests', {
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_ID_PREFIX }),
...createTimestampColumns(),
token: text('token').notNull().unique(),
organizationId: text('organization_id').notNull().references(() => organizationsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
createdBy: text('created_by').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
useLimit: integer('use_limit').default(1), // null means unlimited
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
accessLevel: text('access_level').notNull().$type<DocumentsRequestAccessLevel>().default('organization_members'),
isEnabled: integer('is_enabled', { mode: 'boolean' }).notNull().default(true),
});
// To store the files that are allowed to be uploaded to the documents request
export const documentsRequestsFilesTable = sqliteTable('documents_requests_files', {
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_FILES_ID_PREFIX }),
...createTimestampColumns(),
documentsRequestId: text('documents_request_id').notNull().references(() => documentsRequestsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
allowedMimeTypes: text('allowed_mime_types', { mode: 'json' }).notNull().$type<string[]>().default(['*/*']),
sizeLimit: integer('size_limit'), // null for no limit
});
export const documentsRequestsFileTagsTable = sqliteTable('documents_requests_file_tags', {
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX }),
...createTimestampColumns(),
documentsRequestId: text('documents_request_id').notNull().references(() => documentsRequestsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
fileId: text('file_id').notNull().references(() => documentsRequestsFilesTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
tagId: text('tag_id').notNull().references(() => tagsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
});

View File

@@ -0,0 +1 @@
export type DocumentsRequestAccessLevel = 'organization_members' | 'authenticated_users' | 'public';

View File

@@ -0,0 +1,55 @@
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { generateDocumentsRequestToken } from './documents-requests.services';
export type DocumentsRequestsRepository = ReturnType<typeof import('./documents-requests.repository').createDocumentsRequestsRepository>;
export async function createDocumentsRequest({
organizationId,
createdBy,
title,
description,
useLimit,
expiresAt,
accessLevel,
isEnabled,
documentsRequestsRepository,
files,
generateToken = generateDocumentsRequestToken,
}: {
organizationId: string;
createdBy: string | null;
title: string;
description?: string;
useLimit?: number;
expiresAt?: Date;
accessLevel: DocumentsRequestAccessLevel;
isEnabled?: boolean;
documentsRequestsRepository: DocumentsRequestsRepository;
files: {
title: string;
description?: string;
allowedMimeTypes: string[];
sizeLimit?: number;
tags: string[];
}[];
generateToken?: () => { token: string };
}) {
const { token } = generateToken();
const { documentsRequest } = await documentsRequestsRepository.createDocumentsRequest({
documentsRequest: {
token,
organizationId,
createdBy,
title,
description,
useLimit,
expiresAt,
accessLevel,
isEnabled,
},
files,
});
return { documentsRequest };
}