mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 12:15:22 -06:00
Compare commits
1 Commits
@papra/app
...
documents-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13889c1c42 |
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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' }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type DocumentsRequestAccessLevel = 'organization_members' | 'authenticated_users' | 'public';
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user