feat(server): implement organization subscription and limits base (#164)

This commit is contained in:
Corentin Thomasset
2025-03-18 21:01:54 +01:00
committed by GitHub
parent 51109c39f8
commit b17f93b5e3
40 changed files with 2130 additions and 38 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,2 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';

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

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

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

View File

@@ -0,0 +1,11 @@
export type OrganizationPlanRecord = {
id: string;
name: string;
priceId?: string;
limits: {
maxDocumentStorageBytes: number;
maxIntakeEmailsCount: number;
maxOrganizationsMembersCount: number;
};
};

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

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

View File

@@ -0,0 +1,7 @@
import Stripe from 'stripe';
export function createStripeClient({ stripeApiSecretKey }: { stripeApiSecretKey: string }) {
const stripeClient = new Stripe(stripeApiSecretKey);
return { stripeClient };
}

View File

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

View File

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

View File

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

View File

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

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