mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-21 12:09:39 -06:00
feat(server): implement organization subscription and limits base (#164)
This commit is contained in:
committed by
GitHub
parent
51109c39f8
commit
b17f93b5e3
@@ -3,6 +3,7 @@ import type { IntakeEmail } from '../intake-emails.types';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
@@ -12,6 +13,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createQuery } from '@tanstack/solid-query';
|
||||
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
|
||||
@@ -149,7 +151,21 @@ export const IntakeEmailsPage: Component = () => {
|
||||
}));
|
||||
|
||||
const createEmail = async () => {
|
||||
await createIntakeEmail({ organizationId: params.organizationId });
|
||||
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||
createToast({
|
||||
message: 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await query.refetch();
|
||||
|
||||
createToast({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import * as v from 'valibot';
|
||||
import { organizationNameSchema } from '../organizations.schemas';
|
||||
|
||||
@@ -10,7 +12,15 @@ export const CreateOrganizationForm: Component<{
|
||||
initialOrganizationName?: string;
|
||||
}> = (props) => {
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: ({ organizationName }) => props.onSubmit({ organizationName }),
|
||||
onSubmit: async ({ organizationName }) => {
|
||||
const [, error] = await safely(props.onSubmit({ organizationName }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'user.max_organization_count_reached' })) {
|
||||
throw new Error('You have reached the maximum number of organizations you can create, if you need to create more, please contact support.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
schema: v.object({
|
||||
organizationName: organizationNameSchema,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE `organization_subscriptions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`plan_id` text NOT NULL,
|
||||
`stripe_subscription_id` text NOT NULL,
|
||||
`stripe_customer_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`current_period_end` integer NOT NULL,
|
||||
`current_period_start` integer NOT NULL,
|
||||
`cancel_at_period_end` integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX `organizations_slug_unique`;--> statement-breakpoint
|
||||
ALTER TABLE `organizations` DROP COLUMN `slug`;--> statement-breakpoint
|
||||
ALTER TABLE `organizations` DROP COLUMN `logo`;--> statement-breakpoint
|
||||
ALTER TABLE `organizations` DROP COLUMN `metadata`;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `customer_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `max_organization_count` integer;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_customer_id_unique` ON `users` (`customer_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_members_user_organization_unique` ON `organization_members` (`organization_id`,`user_id`);
|
||||
1215
apps/papra-server/migrations/meta/0008_snapshot.json
Normal file
1215
apps/papra-server/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1741343969980,
|
||||
"tag": "0007_better-auth-orgs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1742246019130,
|
||||
"tag": "0008_max-user-organization-count",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"keywords": [],
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=.env src/index.ts | crowlog-pretty",
|
||||
"dev": "tsx watch --env-file-if-exists=.env src/index.ts | crowlog-pretty",
|
||||
"build": "pnpm esbuild --bundle src/index.ts --platform=node --packages=external --format=esm --outfile=dist/index.js --minify",
|
||||
"start": "node dist/index.js",
|
||||
"start:with-migrations": "pnpm migrate:up && pnpm start",
|
||||
@@ -49,6 +49,7 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"node-cron": "^3.0.3",
|
||||
"resend": "^4.1.2",
|
||||
"stripe": "^17.7.0",
|
||||
"tsx": "^4.19.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Database } from './database.types';
|
||||
import { documentsTable } from '../../documents/documents.table';
|
||||
import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables';
|
||||
import { organizationMembersTable, organizationsTable } from '../../organizations/organizations.table';
|
||||
import { organizationSubscriptionsTable } from '../../subscriptions/subscriptions.tables';
|
||||
import { documentsTagsTable, tagsTable } from '../../tags/tags.table';
|
||||
import { usersTable } from '../../users/users.table';
|
||||
import { setupDatabase } from './database';
|
||||
@@ -29,6 +30,7 @@ const seedTables = {
|
||||
tags: tagsTable,
|
||||
documentsTags: documentsTagsTable,
|
||||
intakeEmails: intakeEmailsTable,
|
||||
organizationSubscriptions: organizationSubscriptionsTable,
|
||||
} as const;
|
||||
|
||||
type SeedTablesRows = {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { documentsConfig } from '../documents/documents.config';
|
||||
import { documentStorageConfig } from '../documents/storage/document-storage.config';
|
||||
import { emailsConfig } from '../emails/emails.config';
|
||||
import { intakeEmailsConfig } from '../intake-emails/intake-emails.config';
|
||||
import { organizationsConfig } from '../organizations/organizations.config';
|
||||
import { organizationPlansConfig } from '../plans/plans.config';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { tasksConfig } from '../tasks/tasks.config';
|
||||
|
||||
@@ -80,6 +82,8 @@ export const configDefinition = {
|
||||
tasks: tasksConfig,
|
||||
intakeEmails: intakeEmailsConfig,
|
||||
emails: emailsConfig,
|
||||
organizations: organizationsConfig,
|
||||
organizationPlans: organizationPlansConfig,
|
||||
} as const satisfies ConfigDefinition;
|
||||
|
||||
const logger = createLogger({ namespace: 'config' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { map } from 'lodash-es';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { ORGANIZATION_ROLE_MEMBER } from '../organizations/organizations.constants';
|
||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||
import { createDocumentAlreadyExistsError } from './documents.errors';
|
||||
import { createDocumentsRepository } from './documents.repository';
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('documents repository', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
@@ -110,7 +110,7 @@ describe('documents repository', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
documents: [
|
||||
{ id: 'doc-1', organizationId: 'organization-1', createdBy: 'user-1', name: 'Document 1', originalName: 'document-1.pdf', content: 'lorem ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash1' },
|
||||
{ id: 'doc-2', organizationId: 'organization-1', createdBy: 'user-1', name: 'File 2', originalName: 'document-2.pdf', content: 'lorem', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash2' },
|
||||
@@ -147,8 +147,8 @@ describe('documents repository', () => {
|
||||
{ id: 'organization-2', name: 'Organization 2' },
|
||||
],
|
||||
organizationMembers: [
|
||||
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER },
|
||||
{ organizationId: 'organization-2', userId: 'user-2', role: ORGANIZATION_ROLE_MEMBER },
|
||||
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
|
||||
{ organizationId: 'organization-2', userId: 'user-2', role: ORGANIZATION_ROLES.OWNER },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'doc-1', organizationId: 'organization-1', createdBy: 'user-1', name: 'Document 1', originalName: 'document-1.pdf', content: 'lorem ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSize: 200, originalSha256Hash: 'hash1' },
|
||||
@@ -179,7 +179,7 @@ describe('documents repository', () => {
|
||||
{ id: 'organization-1', name: 'Organization 1' },
|
||||
],
|
||||
organizationMembers: [
|
||||
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER },
|
||||
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { getUser } from '../app/auth/auth.models';
|
||||
import { organizationIdRegex } from '../organizations/organizations.constants';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
import { createPlansRepository } from '../plans/plans.repository';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { validateFormData, validateParams, validateQuery } from '../shared/validation/validation';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createDocumentIsNotDeletedError } from './documents.errors';
|
||||
import { createDocumentsRepository } from './documents.repository';
|
||||
import { createDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
|
||||
@@ -74,6 +76,8 @@ function setupCreateDocumentRoute({ app, config, db }: RouteDefinitionContext) {
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
const { document } = await createDocument({
|
||||
file,
|
||||
@@ -81,7 +85,8 @@ function setupCreateDocumentRoute({ app, config, db }: RouteDefinitionContext) {
|
||||
organizationId,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
return context.json({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { ORGANIZATION_ROLE_MEMBER } from '../organizations/organizations.constants';
|
||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||
import { documentsTable } from './documents.table';
|
||||
|
||||
describe('documents table', () => {
|
||||
@@ -11,7 +11,7 @@ describe('documents table', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
await db.insert(documentsTable).values([
|
||||
@@ -72,7 +72,7 @@ describe('documents table', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
await db.insert(documentsTable).values([
|
||||
@@ -135,7 +135,7 @@ describe('documents table', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
await db.insert(documentsTable).values([
|
||||
|
||||
@@ -28,6 +28,8 @@ export const documentsTable = sqliteTable('documents', {
|
||||
index('documents_organization_id_is_deleted_index').on(table.organizationId, table.isDeleted),
|
||||
// Unique document by organization and hash
|
||||
uniqueIndex('documents_organization_id_original_sha256_hash_unique').on(table.organizationId, table.originalSha256Hash),
|
||||
// To select document by hash
|
||||
// To verify if a document exists by hash
|
||||
index('documents_original_sha256_hash_index').on(table.originalSha256Hash),
|
||||
// To sum the size of documents by organization
|
||||
index('documents_organization_id_size_index').on(table.organizationId, table.originalSize),
|
||||
]);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { ORGANIZATION_ROLE_MEMBER } from '../organizations/organizations.constants';
|
||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||
import { createPlansRepository } from '../plans/plans.repository';
|
||||
import { collectReadableStreamToString } from '../shared/streams/readable-stream';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createDocumentAlreadyExistsError } from './documents.errors';
|
||||
import { createDocumentsRepository } from './documents.repository';
|
||||
import { documentsTable } from './documents.table';
|
||||
@@ -15,11 +17,13 @@ describe('documents usecases', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
const generateDocumentId = () => 'doc_1';
|
||||
|
||||
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||
@@ -33,6 +37,8 @@ describe('documents usecases', () => {
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
generateDocumentId,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
expect(document).to.include({
|
||||
@@ -64,11 +70,13 @@ describe('documents usecases', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
let documentIdIndex = 1;
|
||||
const generateDocumentId = () => `doc_${documentIdIndex++}`;
|
||||
|
||||
@@ -82,6 +90,8 @@ describe('documents usecases', () => {
|
||||
organizationId,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
generateDocumentId,
|
||||
});
|
||||
|
||||
@@ -105,6 +115,8 @@ describe('documents usecases', () => {
|
||||
organizationId,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
generateDocumentId,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
@@ -124,11 +136,13 @@ describe('documents usecases', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
const generateDocumentId = () => 'doc_1';
|
||||
|
||||
const file = new File(['content'], 'file.txt', { type: 'text/plain' });
|
||||
@@ -147,6 +161,8 @@ describe('documents usecases', () => {
|
||||
},
|
||||
},
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
generateDocumentId,
|
||||
}),
|
||||
).rejects.toThrow(new Error('Macron, explosion!'));
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { PlansRepository } from '../plans/plans.repository';
|
||||
import type { Logger } from '../shared/logger/logger';
|
||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import type { DocumentsRepository } from './documents.repository';
|
||||
import type { DocumentStorageService } from './storage/documents.storage.services';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { extractTextFromFile } from '@papra/lecture';
|
||||
import { checkIfOrganizationCanCreateNewDocument } from '../organizations/organizations.usecases';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { createDocumentAlreadyExistsError, createDocumentNotFoundError } from './documents.errors';
|
||||
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
|
||||
@@ -30,6 +33,8 @@ export async function createDocument({
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
generateDocumentId = generateDocumentIdImpl,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}: {
|
||||
file: File;
|
||||
userId?: string;
|
||||
@@ -37,6 +42,8 @@ export async function createDocument({
|
||||
documentsRepository: DocumentsRepository;
|
||||
documentsStorageService: DocumentStorageService;
|
||||
generateDocumentId?: () => string;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
}) {
|
||||
const {
|
||||
name: fileName,
|
||||
@@ -44,6 +51,14 @@ export async function createDocument({
|
||||
type: mimeType,
|
||||
} = file;
|
||||
|
||||
await checkIfOrganizationCanCreateNewDocument({
|
||||
organizationId,
|
||||
newDocumentSize: size,
|
||||
documentsRepository,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
const { hash } = await getFileSha256Hash({ file });
|
||||
|
||||
// Early check to avoid saving the file and then realizing it already exists with the db constraint
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createErrorFactory } from '../shared/errors/errors';
|
||||
|
||||
export const createIntakeEmailLimitReachedError = createErrorFactory({
|
||||
message: 'The maximum number of intake emails for this organization has been reached.',
|
||||
code: 'intake_email.limit_reached',
|
||||
statusCode: 403,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, count, eq } from 'drizzle-orm';
|
||||
import { omitUndefined } from '../shared/utils';
|
||||
import { intakeEmailsTable } from './intake-emails.tables';
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createIntakeEmailsRepository({ db }: { db: Database }) {
|
||||
getOrganizationIntakeEmails,
|
||||
getIntakeEmailByEmailAddress,
|
||||
deleteIntakeEmail,
|
||||
getOrganizationIntakeEmailsCount,
|
||||
},
|
||||
{ db },
|
||||
);
|
||||
@@ -87,3 +88,14 @@ async function deleteIntakeEmail({ intakeEmailId, organizationId, db }: { intake
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function getOrganizationIntakeEmailsCount({ organizationId, db }: { organizationId: string; db: Database }) {
|
||||
const [{ intakeEmailCount }] = await db
|
||||
.select({ intakeEmailCount: count() })
|
||||
.from(intakeEmailsTable)
|
||||
.where(
|
||||
eq(intakeEmailsTable.organizationId, organizationId),
|
||||
);
|
||||
|
||||
return { intakeEmailCount };
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { createDocumentStorageService } from '../documents/storage/documents.sto
|
||||
import { organizationIdRegex } from '../organizations/organizations.constants';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
import { createPlansRepository } from '../plans/plans.repository';
|
||||
import { createError } from '../shared/errors/errors';
|
||||
import { getHeader } from '../shared/headers/headers.models';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { validateFormData, validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
||||
import { intakeEmailsIngestionMetaSchema, parseJson } from './intake-emails.schemas';
|
||||
import { createIntakeEmailsServices } from './intake-emails.services';
|
||||
@@ -65,10 +67,18 @@ function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const intakeEmailsServices = createIntakeEmailsServices({ config });
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const { intakeEmail } = await createIntakeEmail({ organizationId, intakeEmailsRepository, intakeEmailsServices });
|
||||
const { intakeEmail } = await createIntakeEmail({
|
||||
organizationId,
|
||||
intakeEmailsRepository,
|
||||
intakeEmailsServices,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
return context.json({ intakeEmail });
|
||||
},
|
||||
@@ -176,6 +186,8 @@ function setupIngestIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config });
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
await processIntakeEmailIngestion({
|
||||
fromAddress: email.from.address,
|
||||
@@ -184,6 +196,8 @@ function setupIngestIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
return context.body(null, 202);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { PlansRepository } from '../plans/plans.repository';
|
||||
import { createInMemoryLoggerTransport } from '@crowlog/logger';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { pick } from 'lodash-es';
|
||||
@@ -7,9 +8,14 @@ import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
||||
import { documentsTable } from '../documents/documents.table';
|
||||
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
|
||||
import { PLUS_PLAN_ID } from '../plans/plans.constants';
|
||||
import { createPlansRepository } from '../plans/plans.repository';
|
||||
import { createLogger } from '../shared/logger/logger';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createIntakeEmailLimitReachedError } from './intake-emails.errors';
|
||||
import { createIntakeEmailsRepository } from './intake-emails.repository';
|
||||
import { ingestEmailForRecipient, processIntakeEmailIngestion } from './intake-emails.usecases';
|
||||
import { intakeEmailsTable } from './intake-emails.tables';
|
||||
import { checkIfOrganizationCanCreateNewIntakeEmail, ingestEmailForRecipient, processIntakeEmailIngestion } from './intake-emails.usecases';
|
||||
|
||||
describe('intake-emails usecases', () => {
|
||||
describe('ingestEmailForRecipient', () => {
|
||||
@@ -23,6 +29,8 @@ describe('intake-emails usecases', () => {
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
await ingestEmailForRecipient({
|
||||
fromAddress: 'foo@example.fr',
|
||||
@@ -34,9 +42,11 @@ describe('intake-emails usecases', () => {
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.organizationId));
|
||||
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.name));
|
||||
|
||||
expect(
|
||||
documents.map(doc => pick(doc, ['organizationId', 'name', 'mimeType', 'originalName', 'content'])),
|
||||
@@ -58,6 +68,8 @@ describe('intake-emails usecases', () => {
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
await ingestEmailForRecipient({
|
||||
fromAddress: 'foo@example.fr',
|
||||
@@ -66,6 +78,8 @@ describe('intake-emails usecases', () => {
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -84,6 +98,8 @@ describe('intake-emails usecases', () => {
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
await ingestEmailForRecipient({
|
||||
fromAddress: 'foo@example.fr',
|
||||
@@ -92,6 +108,8 @@ describe('intake-emails usecases', () => {
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -115,6 +133,8 @@ describe('intake-emails usecases', () => {
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
await ingestEmailForRecipient({
|
||||
fromAddress: 'a-non-allowed-adress@example.fr',
|
||||
@@ -123,6 +143,8 @@ describe('intake-emails usecases', () => {
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -157,6 +179,8 @@ describe('intake-emails usecases', () => {
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
const documentsStorageService = await createDocumentStorageService({ config: { documentsStorage: { driver: 'in-memory' } } as Config });
|
||||
const plansRepository = createPlansRepository({ config: { organizationPlans: { isFreePlanUnlimited: true } } as Config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
await processIntakeEmailIngestion({
|
||||
fromAddress: 'foo@example.fr',
|
||||
@@ -167,6 +191,8 @@ describe('intake-emails usecases', () => {
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
const documents = await db.select().from(documentsTable).orderBy(asc(documentsTable.organizationId));
|
||||
@@ -179,4 +205,61 @@ describe('intake-emails usecases', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfOrganizationCanCreateNewIntakeEmail', () => {
|
||||
test('the maximum amount of intake emails for an organization is defined by the organization plan, when the limit is reached, an error is thrown', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
organizations: [{ id: 'org-1', name: 'Organization 1' }],
|
||||
intakeEmails: [{ organizationId: 'org-1', emailAddress: 'email-1@papra.email' }],
|
||||
organizationSubscriptions: [{
|
||||
id: 'os-1',
|
||||
organizationId: 'org-1',
|
||||
status: 'active',
|
||||
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
|
||||
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
|
||||
stripeCustomerId: 'sc_123',
|
||||
planId: PLUS_PLAN_ID,
|
||||
stripeSubscriptionId: 'sub_123',
|
||||
}],
|
||||
});
|
||||
|
||||
const intakeEmailsRepository = createIntakeEmailsRepository({ db });
|
||||
const plansRepository = {
|
||||
getOrganizationPlanById: async () => ({
|
||||
organizationPlan: {
|
||||
id: PLUS_PLAN_ID,
|
||||
name: 'Plus',
|
||||
limits: {
|
||||
maxIntakeEmailsCount: 2,
|
||||
maxDocumentStorageBytes: 512,
|
||||
maxOrganizationsMembersCount: 100,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as PlansRepository;
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
// no throw as the intake email count is less than the allowed limit
|
||||
await checkIfOrganizationCanCreateNewIntakeEmail({
|
||||
organizationId: 'org-1',
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailsRepository,
|
||||
});
|
||||
|
||||
await db.insert(intakeEmailsTable).values({
|
||||
organizationId: 'org-1',
|
||||
emailAddress: 'email-2@papra.email',
|
||||
});
|
||||
|
||||
await expect(
|
||||
checkIfOrganizationCanCreateNewIntakeEmail({
|
||||
organizationId: 'org-1',
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailsRepository,
|
||||
}),
|
||||
).rejects.toThrow(createIntakeEmailLimitReachedError());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import type { DocumentsRepository } from '../documents/documents.repository';
|
||||
import type { DocumentStorageService } from '../documents/storage/documents.storage.services';
|
||||
import type { PlansRepository } from '../plans/plans.repository';
|
||||
import type { Logger } from '../shared/logger/logger';
|
||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import type { IntakeEmailsServices } from './drivers/intake-emails.drivers.models';
|
||||
import type { IntakeEmailsRepository } from './intake-emails.repository';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { createDocument } from '../documents/documents.usecases';
|
||||
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||
import { addLogContext, createLogger } from '../shared/logger/logger';
|
||||
import { createIntakeEmailLimitReachedError } from './intake-emails.errors';
|
||||
import { getIsFromAllowedOrigin } from './intake-emails.models';
|
||||
|
||||
export async function createIntakeEmail({
|
||||
organizationId,
|
||||
intakeEmailsRepository,
|
||||
intakeEmailsServices,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}: {
|
||||
organizationId: string;
|
||||
intakeEmailsRepository: IntakeEmailsRepository;
|
||||
intakeEmailsServices: IntakeEmailsServices;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
}) {
|
||||
await checkIfOrganizationCanCreateNewIntakeEmail({
|
||||
organizationId,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailsRepository,
|
||||
});
|
||||
|
||||
const { emailAddress } = await intakeEmailsServices.generateEmailAddress();
|
||||
|
||||
const { intakeEmail } = await intakeEmailsRepository.createIntakeEmail({ organizationId, emailAddress });
|
||||
@@ -31,6 +46,8 @@ export function processIntakeEmailIngestion({
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}: {
|
||||
fromAddress: string;
|
||||
recipientsAddresses: string[];
|
||||
@@ -38,6 +55,8 @@ export function processIntakeEmailIngestion({
|
||||
intakeEmailsRepository: IntakeEmailsRepository;
|
||||
documentsRepository: DocumentsRepository;
|
||||
documentsStorageService: DocumentStorageService;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
}) {
|
||||
return Promise.all(
|
||||
recipientsAddresses.map(recipientAddress => safely(
|
||||
@@ -48,6 +67,8 @@ export function processIntakeEmailIngestion({
|
||||
intakeEmailsRepository,
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}),
|
||||
)),
|
||||
);
|
||||
@@ -61,6 +82,8 @@ export async function ingestEmailForRecipient({
|
||||
documentsRepository,
|
||||
documentsStorageService,
|
||||
logger = createLogger({ namespace: 'intake-emails.ingest' }),
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}: {
|
||||
fromAddress: string;
|
||||
recipientAddress: string;
|
||||
@@ -68,6 +91,8 @@ export async function ingestEmailForRecipient({
|
||||
intakeEmailsRepository: IntakeEmailsRepository;
|
||||
documentsRepository: DocumentsRepository;
|
||||
documentsStorageService: DocumentStorageService;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const { intakeEmail } = await intakeEmailsRepository.getIntakeEmailByEmailAddress({ emailAddress: recipientAddress });
|
||||
@@ -103,6 +128,8 @@ export async function ingestEmailForRecipient({
|
||||
organizationId: intakeEmail.organizationId,
|
||||
documentsStorageService,
|
||||
documentsRepository,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}));
|
||||
|
||||
if (error) {
|
||||
@@ -112,3 +139,22 @@ export async function ingestEmailForRecipient({
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export async function checkIfOrganizationCanCreateNewIntakeEmail({
|
||||
organizationId,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
intakeEmailsRepository,
|
||||
}: {
|
||||
organizationId: string;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
intakeEmailsRepository: IntakeEmailsRepository;
|
||||
}) {
|
||||
const { intakeEmailCount } = await intakeEmailsRepository.getOrganizationIntakeEmailsCount({ organizationId });
|
||||
const { organizationPlan } = await getOrganizationPlan({ organizationId, plansRepository, subscriptionsRepository });
|
||||
|
||||
if (intakeEmailCount >= organizationPlan.limits.maxIntakeEmailsCount) {
|
||||
throw createIntakeEmailLimitReachedError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const organizationsConfig = {
|
||||
maxOrganizationCount: {
|
||||
doc: 'The maximum number of organizations a standard user can have',
|
||||
schema: z.coerce.number().int().positive(),
|
||||
default: 10,
|
||||
env: 'MAX_ORGANIZATION_COUNT_PER_USER',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
@@ -1,5 +1,7 @@
|
||||
export const organizationIdRegex = /^org_[a-z0-9]{24}$/;
|
||||
|
||||
export const ORGANIZATION_ROLE_MEMBER = 'member';
|
||||
export const ORGANIZATION_ROLE_OWNER = 'owner';
|
||||
export const ORGANIZATION_ROLE_ADMIN = 'admin';
|
||||
export const ORGANIZATION_ROLES = {
|
||||
MEMBER: 'member',
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
} as const;
|
||||
|
||||
@@ -11,3 +11,15 @@ export const createOrganizationNotFoundError = createErrorFactory({
|
||||
code: 'organization.not_found',
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
export const createUserMaxOrganizationCountReachedError = createErrorFactory({
|
||||
message: 'You have reached the maximum number of organizations.',
|
||||
code: 'user.max_organization_count_reached',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
export const createOrganizationDocumentStorageLimitReachedError = createErrorFactory({
|
||||
message: 'You have reached the maximum number of documents.',
|
||||
code: 'organization.document_storage_limit_reached',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import type { DbInsertableOrganization } from './organizations.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { and, eq, getTableColumns } from 'drizzle-orm';
|
||||
import { and, count, eq, getTableColumns } from 'drizzle-orm';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { organizationMembersTable, organizationsTable } from './organizations.table';
|
||||
|
||||
export type OrganizationsRepository = ReturnType<typeof createOrganizationsRepository>;
|
||||
@@ -16,6 +17,7 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
|
||||
updateOrganization,
|
||||
deleteOrganization,
|
||||
getOrganizationById,
|
||||
getUserOwnedOrganizationCount,
|
||||
},
|
||||
{ db },
|
||||
);
|
||||
@@ -85,3 +87,21 @@ async function getOrganizationById({ organizationId, db }: { organizationId: str
|
||||
organization,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserOwnedOrganizationCount({ userId, db }: { userId: string; db: Database }) {
|
||||
const [{ organizationCount }] = await db
|
||||
.select({
|
||||
organizationCount: count(organizationMembersTable.id),
|
||||
})
|
||||
.from(organizationMembersTable)
|
||||
.where(
|
||||
and(
|
||||
eq(organizationMembersTable.userId, userId),
|
||||
eq(organizationMembersTable.role, ORGANIZATION_ROLES.OWNER),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
organizationCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { RouteDefinitionContext } from '../app/server.types';
|
||||
import { z } from 'zod';
|
||||
import { getUser } from '../app/auth/auth.models';
|
||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { createUsersRepository } from '../users/users.repository';
|
||||
import { organizationIdRegex } from './organizations.constants';
|
||||
import { createOrganizationsRepository } from './organizations.repository';
|
||||
import { createOrganization, ensureUserIsInOrganization } from './organizations.usecases';
|
||||
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization } from './organizations.usecases';
|
||||
|
||||
export async function registerOrganizationsPrivateRoutes(context: RouteDefinitionContext) {
|
||||
setupGetOrganizationsRoute(context);
|
||||
@@ -28,7 +29,7 @@ function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) {
|
||||
});
|
||||
}
|
||||
|
||||
function setupCreateOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContext) {
|
||||
app.post(
|
||||
'/api/organizations',
|
||||
validateJsonBody(z.object({
|
||||
@@ -39,6 +40,9 @@ function setupCreateOrganizationRoute({ app, db }: RouteDefinitionContext) {
|
||||
const { name } = context.req.valid('json');
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const usersRepository = createUsersRepository({ db });
|
||||
|
||||
await checkIfUserCanCreateNewOrganization({ userId, config, organizationsRepository, usersRepository });
|
||||
|
||||
const { organization } = await createOrganization({ userId, name, organizationsRepository });
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { PlansRepository } from '../plans/plans.repository';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { ORGANIZATION_ROLE_MEMBER } from './organizations.constants';
|
||||
import { createUserNotInOrganizationError } from './organizations.errors';
|
||||
import { overrideConfig } from '../config/config.test-utils';
|
||||
import { createDocumentsRepository } from '../documents/documents.repository';
|
||||
import { PLUS_PLAN_ID } from '../plans/plans.constants';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { createUsersRepository } from '../users/users.repository';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { createOrganizationDocumentStorageLimitReachedError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError } from './organizations.errors';
|
||||
import { createOrganizationsRepository } from './organizations.repository';
|
||||
import { ensureUserIsInOrganization } from './organizations.usecases';
|
||||
import { organizationMembersTable, organizationsTable } from './organizations.table';
|
||||
import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization } from './organizations.usecases';
|
||||
|
||||
describe('organizations usecases', () => {
|
||||
describe('ensureUserIsInOrganization', () => {
|
||||
@@ -12,7 +19,7 @@ describe('organizations usecases', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
@@ -60,4 +67,159 @@ describe('organizations usecases', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfUserCanCreateNewOrganization', () => {
|
||||
test('by default the maximum number of organizations a user can create is defined in the config, if the user has reached the limit an error is thrown', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [
|
||||
{ id: 'organization-1', name: 'Organization 1' },
|
||||
// This organization is not owned by user-1
|
||||
{ id: 'organization-2', name: 'Organization 2' },
|
||||
],
|
||||
organizationMembers: [
|
||||
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
|
||||
{ organizationId: 'organization-2', userId: 'user-1', role: ORGANIZATION_ROLES.MEMBER },
|
||||
],
|
||||
});
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const usersRepository = createUsersRepository({ db });
|
||||
const config = overrideConfig({ organizations: { maxOrganizationCount: 2 } });
|
||||
|
||||
// no throw
|
||||
await checkIfUserCanCreateNewOrganization({
|
||||
userId: 'user-1',
|
||||
config,
|
||||
organizationsRepository,
|
||||
usersRepository,
|
||||
});
|
||||
|
||||
// add a second organization owned by the user
|
||||
await db.insert(organizationsTable).values({ id: 'organization-3', name: 'Organization 3' });
|
||||
await db.insert(organizationMembersTable).values({ organizationId: 'organization-3', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER });
|
||||
|
||||
// throw
|
||||
await expect(
|
||||
checkIfUserCanCreateNewOrganization({
|
||||
userId: 'user-1',
|
||||
config,
|
||||
organizationsRepository,
|
||||
usersRepository,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
createUserMaxOrganizationCountReachedError(),
|
||||
);
|
||||
});
|
||||
|
||||
test('an admin can individually allow a user to create more organizations by setting the maxOrganizationCount on the user', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com', maxOrganizationCount: 3 }],
|
||||
organizations: [
|
||||
{ id: 'organization-1', name: 'Organization 1' },
|
||||
{ id: 'organization-2', name: 'Organization 2' },
|
||||
],
|
||||
organizationMembers: [
|
||||
{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
|
||||
{ organizationId: 'organization-2', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
|
||||
],
|
||||
});
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const usersRepository = createUsersRepository({ db });
|
||||
const config = overrideConfig({ organizations: { maxOrganizationCount: 2 } });
|
||||
|
||||
// no throw
|
||||
await checkIfUserCanCreateNewOrganization({
|
||||
userId: 'user-1',
|
||||
config,
|
||||
organizationsRepository,
|
||||
usersRepository,
|
||||
});
|
||||
|
||||
// add a third organization owned by the user
|
||||
await db.insert(organizationsTable).values({ id: 'organization-3', name: 'Organization 3' });
|
||||
await db.insert(organizationMembersTable).values({ organizationId: 'organization-3', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER });
|
||||
|
||||
// throw
|
||||
await expect(
|
||||
checkIfUserCanCreateNewOrganization({
|
||||
userId: 'user-1',
|
||||
config,
|
||||
organizationsRepository,
|
||||
usersRepository,
|
||||
}),
|
||||
).rejects.toThrow(createUserMaxOrganizationCountReachedError());
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfOrganizationCanCreateNewDocument', () => {
|
||||
test('it is possible to create a new document if the organization has enough allowed storage space defined in the organization plan', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
organizationSubscriptions: [{
|
||||
id: 'org_sub_1',
|
||||
organizationId: 'organization-1',
|
||||
planId: PLUS_PLAN_ID,
|
||||
stripeSubscriptionId: 'sub_123',
|
||||
stripeCustomerId: 'cus_123',
|
||||
status: 'active',
|
||||
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
|
||||
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
|
||||
cancelAtPeriodEnd: false,
|
||||
}],
|
||||
documents: [{
|
||||
id: 'doc_1',
|
||||
organizationId: 'organization-1',
|
||||
originalSize: 100,
|
||||
mimeType: 'text/plain',
|
||||
originalName: 'test.txt',
|
||||
originalStorageKey: 'test.txt',
|
||||
originalSha256Hash: '123',
|
||||
name: 'test.txt',
|
||||
}],
|
||||
});
|
||||
|
||||
const plansRepository = {
|
||||
getOrganizationPlanById: async () => ({
|
||||
organizationPlan: {
|
||||
id: PLUS_PLAN_ID,
|
||||
name: 'Plus',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 512,
|
||||
maxIntakeEmailsCount: 10,
|
||||
maxOrganizationsMembersCount: 100,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as PlansRepository;
|
||||
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
const documentsRepository = createDocumentsRepository({ db });
|
||||
|
||||
// no throw as the document size is less than the allowed storage space
|
||||
await checkIfOrganizationCanCreateNewDocument({
|
||||
organizationId: 'organization-1',
|
||||
newDocumentSize: 100,
|
||||
documentsRepository,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
});
|
||||
|
||||
// throw as the document size is greater than the allowed storage space
|
||||
await expect(
|
||||
checkIfOrganizationCanCreateNewDocument({
|
||||
organizationId: 'organization-1',
|
||||
newDocumentSize: 413,
|
||||
documentsRepository,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
createOrganizationDocumentStorageLimitReachedError(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { DocumentsRepository } from '../documents/documents.repository';
|
||||
import type { PlansRepository } from '../plans/plans.repository';
|
||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import type { UsersRepository } from '../users/users.repository';
|
||||
import type { OrganizationsRepository } from './organizations.repository';
|
||||
import { ORGANIZATION_ROLE_OWNER } from './organizations.constants';
|
||||
import { createUserNotInOrganizationError } from './organizations.errors';
|
||||
import { getOrganizationPlan } from '../plans/plans.usecases';
|
||||
import { ORGANIZATION_ROLES } from './organizations.constants';
|
||||
import { createOrganizationDocumentStorageLimitReachedError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError } from './organizations.errors';
|
||||
|
||||
export async function createOrganization({ name, userId, organizationsRepository }: { name: string; userId: string; organizationsRepository: OrganizationsRepository }) {
|
||||
const { organization } = await organizationsRepository.saveOrganization({ organization: { name } });
|
||||
@@ -8,7 +14,7 @@ export async function createOrganization({ name, userId, organizationsRepository
|
||||
await organizationsRepository.addUserToOrganization({
|
||||
userId,
|
||||
organizationId: organization.id,
|
||||
role: ORGANIZATION_ROLE_OWNER,
|
||||
role: ORGANIZATION_ROLES.OWNER,
|
||||
});
|
||||
|
||||
return { organization };
|
||||
@@ -29,3 +35,46 @@ export async function ensureUserIsInOrganization({
|
||||
throw createUserNotInOrganizationError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkIfUserCanCreateNewOrganization({
|
||||
userId,
|
||||
config,
|
||||
organizationsRepository,
|
||||
usersRepository,
|
||||
}: {
|
||||
userId: string;
|
||||
config: Config;
|
||||
organizationsRepository: OrganizationsRepository;
|
||||
usersRepository: UsersRepository;
|
||||
}) {
|
||||
const { organizationCount } = await organizationsRepository.getUserOwnedOrganizationCount({ userId });
|
||||
const { user } = await usersRepository.getUserByIdOrThrow({ userId });
|
||||
|
||||
const maxOrganizationCount = user.maxOrganizationCount ?? config.organizations.maxOrganizationCount;
|
||||
|
||||
if (organizationCount >= maxOrganizationCount) {
|
||||
throw createUserMaxOrganizationCountReachedError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkIfOrganizationCanCreateNewDocument({
|
||||
organizationId,
|
||||
newDocumentSize,
|
||||
plansRepository,
|
||||
subscriptionsRepository,
|
||||
documentsRepository,
|
||||
}: {
|
||||
organizationId: string;
|
||||
newDocumentSize: number;
|
||||
plansRepository: PlansRepository;
|
||||
subscriptionsRepository: SubscriptionsRepository;
|
||||
documentsRepository: DocumentsRepository;
|
||||
}) {
|
||||
const { organizationPlan } = await getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository });
|
||||
|
||||
const { documentsSize } = await documentsRepository.getOrganizationStats({ organizationId });
|
||||
|
||||
if (documentsSize + newDocumentSize > organizationPlan.limits.maxDocumentStorageBytes) {
|
||||
throw createOrganizationDocumentStorageLimitReachedError();
|
||||
}
|
||||
}
|
||||
|
||||
22
apps/papra-server/src/modules/plans/plans.config.ts
Normal file
22
apps/papra-server/src/modules/plans/plans.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const organizationPlansConfig = {
|
||||
isFreePlanUnlimited: {
|
||||
doc: 'Whether the free plan is unlimited, meaning it has no limits on the number of documents, tags, and organizations, basically always true for self-hosted instances',
|
||||
schema: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.transform(x => x === 'true')
|
||||
.pipe(z.boolean()),
|
||||
default: 'true',
|
||||
env: 'IS_FREE_PLAN_UNLIMITED',
|
||||
},
|
||||
plusPlanPriceId: {
|
||||
doc: 'The price id of the plus plan',
|
||||
schema: z.string(),
|
||||
default: 'price_123456',
|
||||
env: 'PLANS_PLUS_PLAN_PRICE_ID',
|
||||
},
|
||||
} as const satisfies ConfigDefinition;
|
||||
2
apps/papra-server/src/modules/plans/plans.constants.ts
Normal file
2
apps/papra-server/src/modules/plans/plans.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
7
apps/papra-server/src/modules/plans/plans.errors.ts
Normal file
7
apps/papra-server/src/modules/plans/plans.errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createErrorFactory } from '../shared/errors/errors';
|
||||
|
||||
export const createPlanNotFoundError = createErrorFactory({
|
||||
code: 'plans.plan_not_found',
|
||||
message: 'Plan not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
39
apps/papra-server/src/modules/plans/plans.repository.test.ts
Normal file
39
apps/papra-server/src/modules/plans/plans.repository.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { overrideConfig } from '../config/config.test-utils';
|
||||
import { FREE_PLAN_ID } from './plans.constants';
|
||||
import { getOrganizationPlansRecords } from './plans.repository';
|
||||
|
||||
describe('plans repository', () => {
|
||||
describe('getOrganizationPlansRecords', () => {
|
||||
describe('generates a map of organization plans, used in the organization plan repository', () => {
|
||||
test('the key indexing the plans is the plan id', () => {
|
||||
const config = overrideConfig({});
|
||||
|
||||
const { organizationPlans } = getOrganizationPlansRecords({ config });
|
||||
const organizationPlanEntries = Object.entries(organizationPlans);
|
||||
|
||||
expect(organizationPlanEntries).to.have.length.greaterThan(0);
|
||||
|
||||
for (const [planId, plan] of organizationPlanEntries) {
|
||||
expect(planId).to.equal(plan.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('for self-hosted instances, it make no sense to have a limited free plan, so admin can set the free plan to unlimited using the isFreePlanUnlimited config', () => {
|
||||
const config = overrideConfig({
|
||||
organizationPlans: {
|
||||
isFreePlanUnlimited: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { organizationPlans } = getOrganizationPlansRecords({ config });
|
||||
|
||||
expect(organizationPlans[FREE_PLAN_ID].limits).to.deep.equal({
|
||||
maxDocumentStorageBytes: Number.POSITIVE_INFINITY,
|
||||
maxIntakeEmailsCount: Number.POSITIVE_INFINITY,
|
||||
maxOrganizationsMembersCount: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/papra-server/src/modules/plans/plans.repository.ts
Normal file
58
apps/papra-server/src/modules/plans/plans.repository.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Config } from '../config/config.types';
|
||||
import type { OrganizationPlanRecord } from './plans.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { FREE_PLAN_ID, PLUS_PLAN_ID } from './plans.constants';
|
||||
import { createPlanNotFoundError } from './plans.errors';
|
||||
|
||||
export type PlansRepository = ReturnType<typeof createPlansRepository>;
|
||||
|
||||
export function createPlansRepository({ config }: { config: Config }) {
|
||||
const { organizationPlans } = getOrganizationPlansRecords({ config });
|
||||
|
||||
return injectArguments(
|
||||
{
|
||||
getOrganizationPlanById,
|
||||
},
|
||||
{
|
||||
organizationPlans,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrganizationPlansRecords({ config }: { config: Config }) {
|
||||
const { isFreePlanUnlimited } = config.organizationPlans;
|
||||
|
||||
const organizationPlans: Record<string, OrganizationPlanRecord> = {
|
||||
[FREE_PLAN_ID]: {
|
||||
id: FREE_PLAN_ID,
|
||||
name: 'Free',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MB
|
||||
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
|
||||
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 10,
|
||||
},
|
||||
},
|
||||
[PLUS_PLAN_ID]: {
|
||||
id: PLUS_PLAN_ID,
|
||||
name: 'Plus',
|
||||
priceId: config.organizationPlans.plusPlanPriceId,
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GB
|
||||
maxIntakeEmailsCount: 10,
|
||||
maxOrganizationsMembersCount: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { organizationPlans };
|
||||
}
|
||||
|
||||
async function getOrganizationPlanById({ planId, organizationPlans }: { planId: string; organizationPlans: Record<string, OrganizationPlanRecord> }) {
|
||||
const organizationPlan = organizationPlans[planId];
|
||||
|
||||
if (!organizationPlan) {
|
||||
throw createPlanNotFoundError();
|
||||
}
|
||||
|
||||
return { organizationPlan };
|
||||
}
|
||||
11
apps/papra-server/src/modules/plans/plans.types.ts
Normal file
11
apps/papra-server/src/modules/plans/plans.types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type OrganizationPlanRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
priceId?: string;
|
||||
limits: {
|
||||
maxDocumentStorageBytes: number;
|
||||
maxIntakeEmailsCount: number;
|
||||
maxOrganizationsMembersCount: number;
|
||||
|
||||
};
|
||||
};
|
||||
65
apps/papra-server/src/modules/plans/plans.usecases.test.ts
Normal file
65
apps/papra-server/src/modules/plans/plans.usecases.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { overrideConfig } from '../config/config.test-utils';
|
||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import { FREE_PLAN_ID, PLUS_PLAN_ID } from './plans.constants';
|
||||
import { createPlansRepository } from './plans.repository';
|
||||
import { getOrganizationPlan } from './plans.usecases';
|
||||
|
||||
describe('plans usecases', () => {
|
||||
describe('getOrganizationPlan', () => {
|
||||
test('an organization may be subscribed to a plan', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
organizationSubscriptions: [{
|
||||
id: 'org_sub_1',
|
||||
organizationId: 'organization-1',
|
||||
planId: PLUS_PLAN_ID,
|
||||
stripeSubscriptionId: 'sub_123',
|
||||
stripeCustomerId: 'cus_123',
|
||||
status: 'active',
|
||||
currentPeriodStart: new Date('2025-03-18T00:00:00.000Z'),
|
||||
currentPeriodEnd: new Date('2025-04-18T00:00:00.000Z'),
|
||||
cancelAtPeriodEnd: false,
|
||||
}],
|
||||
});
|
||||
|
||||
const config = overrideConfig({
|
||||
organizationPlans: {
|
||||
plusPlanPriceId: 'price_123',
|
||||
},
|
||||
});
|
||||
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
const { organizationPlan } = await getOrganizationPlan({ organizationId: 'organization-1', subscriptionsRepository, plansRepository });
|
||||
|
||||
expect(organizationPlan.id).to.equal(PLUS_PLAN_ID);
|
||||
});
|
||||
|
||||
test('an organization may not have any subscription, in this case the organization is considered to be on the free plan', async () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
});
|
||||
|
||||
const config = overrideConfig({
|
||||
organizationPlans: {
|
||||
plusPlanPriceId: 'price_123',
|
||||
},
|
||||
});
|
||||
|
||||
const plansRepository = createPlansRepository({ config });
|
||||
const subscriptionsRepository = createSubscriptionsRepository({ db });
|
||||
|
||||
const { organizationPlan } = await getOrganizationPlan({ organizationId: 'organization-1', subscriptionsRepository, plansRepository });
|
||||
|
||||
expect(organizationPlan.id).to.equal(FREE_PLAN_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
apps/papra-server/src/modules/plans/plans.usecases.ts
Normal file
13
apps/papra-server/src/modules/plans/plans.usecases.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { SubscriptionsRepository } from '../subscriptions/subscriptions.repository';
|
||||
import type { PlansRepository } from './plans.repository';
|
||||
import { FREE_PLAN_ID } from './plans.constants';
|
||||
|
||||
export async function getOrganizationPlan({ organizationId, subscriptionsRepository, plansRepository }: { organizationId: string; subscriptionsRepository: SubscriptionsRepository; plansRepository: PlansRepository }) {
|
||||
const { subscription } = await subscriptionsRepository.getOrganizationSubscription({ organizationId });
|
||||
|
||||
const planId = subscription?.planId ?? FREE_PLAN_ID;
|
||||
|
||||
const { organizationPlan } = await plansRepository.getOrganizationPlanById({ planId });
|
||||
|
||||
return { organizationPlan };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
export function createStripeClient({ stripeApiSecretKey }: { stripeApiSecretKey: string }) {
|
||||
const stripeClient = new Stripe(stripeApiSecretKey);
|
||||
|
||||
return { stripeClient };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { organizationSubscriptionsTable } from './subscriptions.tables';
|
||||
|
||||
export type SubscriptionsRepository = ReturnType<typeof createSubscriptionsRepository>;
|
||||
|
||||
export function createSubscriptionsRepository({ db }: { db: Database }) {
|
||||
return injectArguments(
|
||||
{
|
||||
getOrganizationSubscription,
|
||||
},
|
||||
{
|
||||
db,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getOrganizationSubscription({ organizationId, db }: { organizationId: string; db: Database }) {
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(organizationSubscriptionsTable)
|
||||
.where(
|
||||
eq(organizationSubscriptionsTable.organizationId, organizationId),
|
||||
);
|
||||
|
||||
return { subscription };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { organizationsTable } from '../organizations/organizations.table';
|
||||
import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers';
|
||||
|
||||
export const organizationSubscriptionsTable = sqliteTable('organization_subscriptions', {
|
||||
...createPrimaryKeyField({ prefix: 'org_sub' }),
|
||||
...createTimestampColumns(),
|
||||
|
||||
organizationId: text('organization_id')
|
||||
.notNull()
|
||||
.references(() => organizationsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
|
||||
planId: text('plan_id').notNull(),
|
||||
stripeSubscriptionId: text('stripe_subscription_id').notNull(),
|
||||
stripeCustomerId: text('stripe_customer_id').notNull(),
|
||||
status: text('status').notNull(),
|
||||
currentPeriodEnd: integer('current_period_end', { mode: 'timestamp_ms' }).notNull(),
|
||||
currentPeriodStart: integer('current_period_start', { mode: 'timestamp_ms' }).notNull(),
|
||||
cancelAtPeriodEnd: integer('cancel_at_period_end', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createInMemoryDatabase } from '../app/database/database.test-utils';
|
||||
import { ORGANIZATION_ROLE_MEMBER } from '../organizations/organizations.constants';
|
||||
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
|
||||
import { createDocumentAlreadyHasTagError } from './tags.errors';
|
||||
import { createTagsRepository } from './tags.repository';
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('tags repository', () => {
|
||||
const { db } = await createInMemoryDatabase({
|
||||
users: [{ id: 'user-1', email: 'user-1@example.com' }],
|
||||
organizations: [{ id: 'organization-1', name: 'Organization 1' }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLE_MEMBER }],
|
||||
organizationMembers: [{ organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER }],
|
||||
documents: [
|
||||
{ id: 'document-1', organizationId: 'organization-1', createdBy: 'user-1', name: 'Document 1', originalName: 'document-1.pdf', content: 'lorem ipsum', originalStorageKey: '', mimeType: 'application/pdf', originalSha256Hash: 'hash' },
|
||||
],
|
||||
|
||||
@@ -9,8 +9,10 @@ export const usersTable = sqliteTable(
|
||||
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||
stripeCustomerId: text('customer_id').unique(),
|
||||
name: text('name'),
|
||||
image: text('image'),
|
||||
maxOrganizationCount: integer('max_organization_count', { mode: 'number' }),
|
||||
},
|
||||
table => [
|
||||
index('users_email_index').on(table.email),
|
||||
|
||||
80
pnpm-lock.yaml
generated
80
pnpm-lock.yaml
generated
@@ -271,6 +271,9 @@ importers:
|
||||
resend:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(react-dom@19.0.0(react@18.3.1))(react@18.3.1)
|
||||
stripe:
|
||||
specifier: ^17.7.0
|
||||
version: 17.7.0
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.19.3
|
||||
@@ -3300,6 +3303,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
callsites@3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4716,6 +4723,7 @@ packages:
|
||||
|
||||
libsql@0.4.7:
|
||||
resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==}
|
||||
cpu: [x64, arm64, wasm32]
|
||||
os: [darwin, linux, win32]
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
@@ -5169,6 +5177,10 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ofetch@1.4.1:
|
||||
resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==}
|
||||
|
||||
@@ -5424,6 +5436,10 @@ packages:
|
||||
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
qs@6.14.0:
|
||||
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quansync@0.2.8:
|
||||
resolution: {integrity: sha512-4+saucphJMazjt7iOM27mbFCk+D9dd/zmgMDCzRZ8MEoBfYp7lAvoN38et/phRQF6wOPMy/OROBGgoWeSKyluA==}
|
||||
|
||||
@@ -5696,6 +5712,22 @@ packages:
|
||||
shiki@1.29.2:
|
||||
resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.0:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -5859,6 +5891,10 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
stripe@17.7.0:
|
||||
resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==}
|
||||
engines: {node: '>=12.*'}
|
||||
|
||||
strnum@1.1.2:
|
||||
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
|
||||
|
||||
@@ -10327,6 +10363,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@8.0.0: {}
|
||||
@@ -13028,6 +13069,8 @@ snapshots:
|
||||
object-assign@4.1.1:
|
||||
optional: true
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
ofetch@1.4.1:
|
||||
dependencies:
|
||||
destr: 2.0.3
|
||||
@@ -13295,6 +13338,10 @@ snapshots:
|
||||
|
||||
pvutils@1.1.3: {}
|
||||
|
||||
qs@6.14.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
quansync@0.2.8: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
@@ -13696,6 +13743,34 @@ snapshots:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
side-channel@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.0
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@3.0.7:
|
||||
@@ -13871,6 +13946,11 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
stripe@17.7.0:
|
||||
dependencies:
|
||||
'@types/node': 22.13.10
|
||||
qs: 6.14.0
|
||||
|
||||
strnum@1.1.2: {}
|
||||
|
||||
style-to-js@1.1.16:
|
||||
|
||||
Reference in New Issue
Block a user