chore(lint): enabled type-aware linting (#398)

This commit is contained in:
Corentin Thomasset
2025-07-04 22:55:42 +02:00
committed by GitHub
parent f28d8245bf
commit a188af1f88
96 changed files with 1918 additions and 894 deletions

View File

@@ -5,6 +5,11 @@ export default antfu({
semi: true,
},
ignores: [
// Generated file
'src/modules/i18n/locales.types.ts',
],
rules: {
// To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],

View File

@@ -2,8 +2,8 @@
import type { HttpClientOptions, ResponseType } from '../shared/http/http-client';
import { joinUrlPaths } from '@corentinth/chisels';
type ExtractRouteParams<Path extends string> =
Path extends `${infer _Start}:${infer Param}/${infer Rest}`
type ExtractRouteParams<Path extends string>
= Path extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [k in Param | keyof ExtractRouteParams<`/${Rest}`>]: string }
: Path extends `${infer _Start}:${infer Param}`
? { [k in Param]: string }

View File

@@ -24,8 +24,8 @@ export const alertVariants = cva(
},
);
type alertProps<T extends ValidComponent = 'div'> = AlertRootProps<T> &
VariantProps<typeof alertVariants> & {
type alertProps<T extends ValidComponent = 'div'> = AlertRootProps<T>
& VariantProps<typeof alertVariants> & {
class?: string;
};

View File

@@ -33,8 +33,8 @@ export const buttonVariants = cva(
},
);
type buttonProps<T extends ValidComponent = 'button'> = ButtonRootProps<T> &
VariantProps<typeof buttonVariants> & {
type buttonProps<T extends ValidComponent = 'button'> = ButtonRootProps<T>
& VariantProps<typeof buttonVariants> & {
class?: string;
isLoading?: boolean;
children?: JSX.Element;

View File

@@ -88,8 +88,8 @@ export function ComboboxTrigger<T extends ValidComponent = 'button'>(props: Poly
);
}
type comboboxContentProps<T extends ValidComponent = 'div'> =
ComboboxContentProps<T> & {
type comboboxContentProps<T extends ValidComponent = 'div'>
= ComboboxContentProps<T> & {
class?: string;
};

View File

@@ -77,8 +77,8 @@ export function DialogTitle<T extends ValidComponent = 'h2'>(props: PolymorphicP
);
}
type dialogDescriptionProps<T extends ValidComponent = 'p'> =
DialogDescriptionProps<T> & {
type dialogDescriptionProps<T extends ValidComponent = 'p'>
= DialogDescriptionProps<T> & {
class?: string;
};

View File

@@ -26,8 +26,8 @@ export function DropdownMenu(props: DropdownMenuRootProps) {
return <DropdownMenuPrimitive {...merge} />;
}
type dropdownMenuContentProps<T extends ValidComponent = 'div'> =
DropdownMenuContentProps<T> & {
type dropdownMenuContentProps<T extends ValidComponent = 'div'>
= DropdownMenuContentProps<T> & {
class?: string;
};
@@ -49,8 +49,8 @@ export function DropdownMenuContent<T extends ValidComponent = 'div'>(props: Pol
);
}
type dropdownMenuItemProps<T extends ValidComponent = 'div'> =
DropdownMenuItemProps<T> & {
type dropdownMenuItemProps<T extends ValidComponent = 'div'>
= DropdownMenuItemProps<T> & {
class?: string;
inset?: boolean;
};
@@ -73,8 +73,8 @@ export function DropdownMenuItem<T extends ValidComponent = 'div'>(props: Polymo
);
}
type dropdownMenuGroupLabelProps<T extends ValidComponent = 'span'> =
DropdownMenuGroupLabelProps<T> & {
type dropdownMenuGroupLabelProps<T extends ValidComponent = 'span'>
= DropdownMenuGroupLabelProps<T> & {
class?: string;
};
@@ -92,8 +92,8 @@ export function DropdownMenuGroupLabel<T extends ValidComponent = 'span'>(props:
);
}
type dropdownMenuItemLabelProps<T extends ValidComponent = 'div'> =
DropdownMenuItemLabelProps<T> & {
type dropdownMenuItemLabelProps<T extends ValidComponent = 'div'>
= DropdownMenuItemLabelProps<T> & {
class?: string;
};
@@ -111,8 +111,8 @@ export function DropdownMenuItemLabel<T extends ValidComponent = 'div'>(props: P
);
}
type dropdownMenuSeparatorProps<T extends ValidComponent = 'hr'> =
DropdownMenuSeparatorProps<T> & {
type dropdownMenuSeparatorProps<T extends ValidComponent = 'hr'>
= DropdownMenuSeparatorProps<T> & {
class?: string;
};
@@ -178,8 +178,8 @@ export function DropdownMenuSubTrigger<T extends ValidComponent = 'div'>(props:
);
}
type dropdownMenuSubContentProps<T extends ValidComponent = 'div'> =
DropdownMenuSubTriggerProps<T> & {
type dropdownMenuSubContentProps<T extends ValidComponent = 'div'>
= DropdownMenuSubTriggerProps<T> & {
class?: string;
};

View File

@@ -60,8 +60,8 @@ export function NumberFieldErrorMessage<T extends ValidComponent = 'div'>(props:
);
}
type numberFieldProps<T extends ValidComponent = 'div'> =
NumberFieldRootProps<T> & {
type numberFieldProps<T extends ValidComponent = 'div'>
= NumberFieldRootProps<T> & {
class?: string;
};
@@ -87,8 +87,8 @@ export function NumberFieldGroup(props: ComponentProps<'div'>) {
);
}
type numberFieldInputProps<T extends ValidComponent = 'input'> =
NumberFieldInputProps<T> & {
type numberFieldInputProps<T extends ValidComponent = 'input'>
= NumberFieldInputProps<T> & {
class?: string;
};

View File

@@ -55,8 +55,8 @@ export function SelectTrigger<T extends ValidComponent = 'button'>(props: Polymo
);
}
type selectContentProps<T extends ValidComponent = 'div'> =
SelectContentProps<T> & {
type selectContentProps<T extends ValidComponent = 'div'>
= SelectContentProps<T> & {
class?: string;
};

View File

@@ -32,8 +32,8 @@ export const sheetVariants = cva(
);
type sheetContentProps<T extends ValidComponent = 'div'> = ParentProps<
DialogContentProps<T> &
VariantProps<typeof sheetVariants> & {
DialogContentProps<T>
& VariantProps<typeof sheetVariants> & {
class?: string;
}
>;
@@ -96,8 +96,8 @@ export function SheetTitle<T extends ValidComponent = 'h2'>(props: PolymorphicPr
);
}
type sheetDescriptionProps<T extends ValidComponent = 'p'> =
DialogDescriptionProps<T> & {
type sheetDescriptionProps<T extends ValidComponent = 'p'>
= DialogDescriptionProps<T> & {
class?: string;
};

View File

@@ -46,8 +46,8 @@ export function TabsList<T extends ValidComponent = 'div'>(props: PolymorphicPro
);
}
type tabsContentProps<T extends ValidComponent = 'div'> =
TabsContentProps<T> & {
type tabsContentProps<T extends ValidComponent = 'div'>
= TabsContentProps<T> & {
class?: string;
};
@@ -65,8 +65,8 @@ export function TabsContent<T extends ValidComponent = 'div'>(props: Polymorphic
);
}
type tabsTriggerProps<T extends ValidComponent = 'button'> =
TabsTriggerProps<T> & {
type tabsTriggerProps<T extends ValidComponent = 'button'>
= TabsTriggerProps<T> & {
class?: string;
};
@@ -100,8 +100,8 @@ const tabsIndicatorVariants = cva(
);
type tabsIndicatorProps<T extends ValidComponent = 'div'> = VoidProps<
TabsIndicatorProps<T> &
VariantProps<typeof tabsIndicatorVariants> & {
TabsIndicatorProps<T>
& VariantProps<typeof tabsIndicatorVariants> & {
class?: string;
}
>;

View File

@@ -12,8 +12,8 @@ import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type textFieldProps<T extends ValidComponent = 'div'> =
TextFieldRootProps<T> & {
type textFieldProps<T extends ValidComponent = 'div'>
= TextFieldRootProps<T> & {
class?: string;
};
@@ -43,8 +43,8 @@ export const textfieldLabel = cva(
},
);
type textFieldLabelProps<T extends ValidComponent = 'label'> =
TextFieldLabelProps<T> & {
type textFieldLabelProps<T extends ValidComponent = 'label'>
= TextFieldLabelProps<T> & {
class?: string;
};
@@ -59,8 +59,8 @@ export function TextFieldLabel<T extends ValidComponent = 'label'>(props: Polymo
);
}
type textFieldErrorMessageProps<T extends ValidComponent = 'div'> =
TextFieldErrorMessageProps<T> & {
type textFieldErrorMessageProps<T extends ValidComponent = 'div'>
= TextFieldErrorMessageProps<T> & {
class?: string;
};
@@ -77,8 +77,8 @@ export function TextFieldErrorMessage<T extends ValidComponent = 'div'>(props: P
);
}
type textFieldDescriptionProps<T extends ValidComponent = 'div'> =
TextFieldDescriptionProps<T> & {
type textFieldDescriptionProps<T extends ValidComponent = 'div'>
= TextFieldDescriptionProps<T> & {
class?: string;
};

View File

@@ -25,8 +25,8 @@ function useToggleGroup() {
}
type toggleGroupProps<T extends ValidComponent = 'div'> = ParentProps<
ToggleGroupRootProps<T> &
VariantProps<typeof toggleVariants> & {
ToggleGroupRootProps<T>
& VariantProps<typeof toggleVariants> & {
class?: string;
}
>;
@@ -56,8 +56,8 @@ export function ToggleGroup<T extends ValidComponent = 'div'>(props: Polymorphic
);
}
type toggleGroupItemProps<T extends ValidComponent = 'button'> =
ToggleGroupItemProps<T> & {
type toggleGroupItemProps<T extends ValidComponent = 'button'>
= ToggleGroupItemProps<T> & {
class?: string;
};

View File

@@ -28,11 +28,11 @@ export const toggleVariants = cva(
},
);
type toggleButtonProps<T extends ValidComponent = 'button'> =
ToggleButtonRootProps<T> &
VariantProps<typeof toggleVariants> & {
class?: string;
};
type toggleButtonProps<T extends ValidComponent = 'button'>
= ToggleButtonRootProps<T>
& VariantProps<typeof toggleVariants> & {
class?: string;
};
export function ToggleButton<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, toggleButtonProps<T>>) {
const [local, rest] = splitProps(props as toggleButtonProps, [

View File

@@ -22,8 +22,8 @@ export function Tooltip(props: TooltipRootProps) {
return <TooltipPrimitive {...merge} />;
}
type tooltipContentProps<T extends ValidComponent = 'div'> =
TooltipContentProps<T> & {
type tooltipContentProps<T extends ValidComponent = 'div'>
= TooltipContentProps<T> & {
class?: string;
};

View File

@@ -2,8 +2,11 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { cwd as getCwd } from 'node:process';
import { fileURLToPath } from 'node:url';
import { parse } from 'yaml';
const filename = fileURLToPath(import.meta.url);
export async function generateI18nTypes({ cwd = getCwd() }: { cwd?: string } = {}) {
try {
const yamlPath = path.join(cwd, 'src/locales/en.yml');
@@ -17,7 +20,7 @@ export async function generateI18nTypes({ cwd = getCwd() }: { cwd?: string } = {
// Do not manually edit this file.
// This file is dynamically generated when the dev server runs (or using the \`pnpm script:generate-i18n-types\` command).
// Keys are extracted from the en.yml file.
// Source code : ${path.relative(cwd, __filename)}
// Source code : ${path.relative(cwd, filename)}
export type LocaleKeys =\n${localKeys.map(key => ` | '${key}'`).join('\n')};
`.trimStart();

View File

@@ -1,6 +1,14 @@
import antfu from '@antfu/eslint-config';
export default antfu({
typescript: {
tsconfigPath: './tsconfig.json',
overridesTypeAware: {
'ts/no-misused-promises': ['error', { checksVoidReturn: false }],
'ts/strict-boolean-expressions': ['error', { allowNullableObject: true }],
},
},
stylistic: {
semi: true,
},

View File

@@ -1,3 +1,8 @@
import { createPrefixedIdRegex } from '../shared/random/ids';
export const API_KEY_ID_PREFIX = 'ak';
export const API_KEY_ID_REGEX = createPrefixedIdRegex({ prefix: API_KEY_ID_PREFIX });
export const API_KEY_PREFIX = 'ppapi';
export const API_KEY_TOKEN_LENGTH = 64;

View File

@@ -3,6 +3,7 @@ import type { Context } from '../app/server.types';
import { createMiddleware } from 'hono/factory';
import { createUnauthorizedError } from '../app/auth/auth.errors';
import { getAuthorizationHeader } from '../shared/headers/headers.models';
import { isNil } from '../shared/utils';
import { createApiKeysRepository } from './api-keys.repository';
import { getApiKey } from './api-keys.usecases';
@@ -14,7 +15,7 @@ export function createApiKeyMiddleware({ db }: { db: Database }) {
return createMiddleware(async (context: Context, next) => {
const { authorizationHeader } = getAuthorizationHeader({ context });
if (!authorizationHeader) {
if (isNil(authorizationHeader)) {
return next();
}
@@ -30,6 +31,11 @@ export function createApiKeyMiddleware({ db }: { db: Database }) {
throw createUnauthorizedError();
}
if (isNil(token)) {
// For type safety
throw createUnauthorizedError();
}
const { apiKey } = await getApiKey({ token, apiKeyRepository });
if (apiKey) {

View File

@@ -5,6 +5,7 @@ import { injectArguments } from '@corentinth/chisels';
import { and, eq, getTableColumns, inArray } from 'drizzle-orm';
import { omit, pick } from 'lodash-es';
import { organizationMembersTable, organizationsTable } from '../organizations/organizations.table';
import { createError } from '../shared/errors/errors';
import { createLogger } from '../shared/logger/logger';
import { apiKeyOrganizationsTable, apiKeysTable } from './api-keys.tables';
@@ -58,6 +59,16 @@ async function saveApiKey({
})
.returning();
if (!apiKey) {
// Very unlikely to happen as the insertion should throw an issue, it's for type safety
throw createError({
message: 'Error while creating api key',
code: 'api-keys.create_error',
statusCode: 500,
isInternal: true,
});
}
if (organizationIds && organizationIds.length > 0) {
const apiKeyId = apiKey.id;

View File

@@ -4,9 +4,10 @@ import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { createError } from '../shared/errors/errors';
import { validateJsonBody } from '../shared/validation/validation';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
import { createApiKeysRepository } from './api-keys.repository';
import { apiKeyIdSchema } from './api-keys.schemas';
import { createApiKey } from './api-keys.usecases';
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
@@ -85,11 +86,14 @@ function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/api-keys/:apiKeyId',
requireAuthentication(),
validateParams(z.object({
apiKeyId: apiKeyIdSchema,
})),
async (context) => {
const { userId } = getUser({ context });
const apiKeyRepository = createApiKeysRepository({ db });
const { apiKeyId } = context.req.param();
const { apiKeyId } = context.req.valid('param');
await apiKeyRepository.deleteUserApiKey({ apiKeyId, userId });

View File

@@ -0,0 +1,4 @@
import { z } from 'zod';
import { API_KEY_ID_REGEX } from './api-keys.constants';
export const apiKeyIdSchema = z.string().regex(API_KEY_ID_REGEX);

View File

@@ -4,11 +4,12 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { organizationMembersTable } from '../organizations/organizations.table';
import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers';
import { usersTable } from '../users/users.table';
import { API_KEY_ID_PREFIX } from './api-keys.constants';
export const apiKeysTable = sqliteTable(
'api_keys',
{
...createPrimaryKeyField({ prefix: 'ak' }),
...createPrimaryKeyField({ prefix: API_KEY_ID_PREFIX }),
...createTimestampColumns(),
name: text('name').notNull(),

View File

@@ -48,7 +48,7 @@ describe('api-keys usecases', () => {
const { apiKey } = await getApiKey({ token: 'ppapi_HT2Hj5V8A3WHMQtVcMDB9UucqUxPU15o1aI6qOc1Oy5qBvbSEr4jZzsjuFYPqCP0', apiKeyRepository });
expect(apiKey.id).to.eql('api_key_1');
expect(apiKey?.id).to.eql('api_key_1');
});
});
});

View File

@@ -4,11 +4,12 @@ import type { Context } from '../server.types';
import type { Session } from './auth.types';
import { uniq } from 'lodash-es';
import { createError } from '../../shared/errors/errors';
import { isNil } from '../../shared/utils';
export function getUser({ context }: { context: Context }) {
const userId = context.get('userId');
if (!userId) {
if (isNil(userId)) {
// This should never happen as getUser is called in authenticated routes
// just for proper type safety
throw createError({

View File

@@ -1,12 +1,13 @@
import type { Context, RouteDefinitionContext } from '../server.types';
import type { Session } from './auth.types';
import { get } from 'lodash-es';
import { isDefined } from '../../shared/utils';
export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext) {
app.on(
['POST', 'GET'],
'/api/auth/*',
context => auth.handler(context.req.raw),
async context => auth.handler(context.req.raw),
);
app.use('*', async (context: Context, next) => {
@@ -23,9 +24,9 @@ export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext
if (config.env === 'test') {
app.use('*', async (context: Context, next) => {
const overrideUserId = get(context.env, 'loggedInUserId') as string | undefined;
const overrideUserId: unknown = get(context.env, 'loggedInUserId');
if (overrideUserId) {
if (isDefined(overrideUserId) && typeof overrideUserId === 'string') {
context.set('userId', overrideUserId);
context.set('session', {} as Session);
context.set('authType', 'session');

View File

@@ -36,9 +36,7 @@ export function getAuth({
logger: {
disabled: false,
log: (baseLevel, message) => {
const level = (baseLevel in logger ? baseLevel : 'info') as keyof typeof logger;
logger[level](message);
logger[baseLevel ?? 'info'](message);
},
},
emailAndPassword: {

View File

@@ -53,7 +53,7 @@ async function seedDatabase({ db, ...seedRows }: { db: Database } & SeedTablesRo
await Promise.all(
Object
.entries(seedRows)
.map(([table, rows]) => db
.map(async ([table, rows]) => db
.insert(seedTables[table as keyof typeof seedTables])
.values(rows)
.execute(),

View File

@@ -5,5 +5,5 @@ import { sql } from 'drizzle-orm';
export async function isDatabaseHealthy({ db }: { db: Database }) {
const [result, error] = await safely(db.run(sql`SELECT 1;`));
return error === null && result.rows.length > 0 && result.rows[0]['1'] === 1;
return error === null && result.rows.length > 0 && result.rows[0]?.['1'] === 1;
}

View File

@@ -1,3 +1,4 @@
import type { ServerInstanceGenerics } from '../server.types';
import { Hono } from 'hono';
import { describe, expect, test } from 'vitest';
import { createError } from '../../shared/errors/errors';
@@ -6,8 +7,8 @@ import { registerErrorMiddleware } from './errors.middleware';
describe('errors middleware', () => {
describe('registerErrorMiddleware', () => {
test('when a non-internal custom error is thrown with a status code, the error is returned', async () => {
const app = new Hono();
registerErrorMiddleware({ app: app as any });
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
app.get('/error', async () => {
throw createError({
@@ -29,8 +30,8 @@ describe('errors middleware', () => {
});
test('when an unknown error is thrown, a 500 error is returned with a generic message', async () => {
const app = new Hono();
registerErrorMiddleware({ app: app as any });
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
app.get('/error', async () => {
throw new Error('Unknown error');
@@ -48,8 +49,8 @@ describe('errors middleware', () => {
});
test('when a custom error is marked as internal, a 500 error is returned with a generic message', async () => {
const app = new Hono();
registerErrorMiddleware({ app: app as any });
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
app.get('/error', async () => {
throw createError({
@@ -72,8 +73,8 @@ describe('errors middleware', () => {
});
test('when querying an unknown route, a 404 error is returned', async () => {
const app = new Hono();
registerErrorMiddleware({ app: app as any });
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
const response = await app.request('/unknown-route', { method: 'GET' });

View File

@@ -1,3 +1,4 @@
import type { ServerInstanceGenerics } from '../server.types';
import { Hono } from 'hono';
import { describe, expect, test } from 'vitest';
import { overrideConfig } from '../../config/config.test-utils';
@@ -9,8 +10,8 @@ describe('middlewares', () => {
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
const config = overrideConfig({ server: { routeTimeoutMs: 50 } });
const app = new Hono<{ Variables: { config: any } }>();
registerErrorMiddleware({ app: app as any });
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
app.get(
'/should-timeout',

View File

@@ -17,7 +17,7 @@ function setValidParams(path: string) {
.replaceAll(':invitationId', 'inv_101010101010101010101010');
// throw if there are any remaining params
if (newPath.match(/:(\w+)/g)) {
if (newPath.match(/:\w+/g)) {
throw new Error(`Add a dummy value for the params in ${path}`);
}

View File

@@ -1,7 +1,7 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { getPublicConfig } from './config.models';
export async function registerConfigRoutes(context: RouteDefinitionContext) {
export function registerConfigRoutes(context: RouteDefinitionContext) {
setupGetPublicConfigRoute(context);
}

View File

@@ -1,5 +1,6 @@
import { getExtension } from '../shared/files/file-names';
import { generateId } from '../shared/random/ids';
import { isDefined } from '../shared/utils';
import { ORIGINAL_DOCUMENTS_STORAGE_KEY } from './documents.constants';
export function joinStorageKeyParts(...parts: string[]) {
@@ -9,7 +10,7 @@ export function joinStorageKeyParts(...parts: string[]) {
export function buildOriginalDocumentKey({ documentId, organizationId, fileName }: { documentId: string; organizationId: string; fileName: string }) {
const { extension } = getExtension({ fileName });
const newFileName = extension ? `${documentId}.${extension}` : documentId;
const newFileName = isDefined(extension) ? `${documentId}.${extension}` : documentId;
const originalDocumentStorageKey = joinStorageKeyParts(organizationId, ORIGINAL_DOCUMENTS_STORAGE_KEY, newFileName);

View File

@@ -4,11 +4,13 @@ import { injectArguments, safely } from '@corentinth/chisels';
import { subDays } from 'date-fns';
import { and, count, desc, eq, getTableColumns, lt, sql, sum } from 'drizzle-orm';
import { omit } from 'lodash-es';
import { createOrganizationNotFoundError } from '../organizations/organizations.errors';
import { isUniqueConstraintError } from '../shared/db/constraints.models';
import { withPagination } from '../shared/db/pagination';
import { omitUndefined } from '../shared/utils';
import { createError } from '../shared/errors/errors';
import { isDefined, isNil, omitUndefined } from '../shared/utils';
import { documentsTagsTable, tagsTable } from '../tags/tags.table';
import { createDocumentAlreadyExistsError } from './documents.errors';
import { createDocumentAlreadyExistsError, createDocumentNotFoundError } from './documents.errors';
import { documentsTable } from './documents.table';
export type DocumentsRepository = ReturnType<typeof createDocumentsRepository>;
@@ -57,13 +59,27 @@ async function saveOrganizationDocument({ db, ...documentToInsert }: { db: Datab
throw createDocumentAlreadyExistsError();
}
if (error) {
throw error;
}
const [document] = documents ?? [];
if (isNil(document)) {
// Very unlikely to happen as the insertion throws an issue, it's for type safety
throw createError({
message: 'Error while saving document',
code: 'documents.save_error',
statusCode: 500,
isInternal: true,
});
}
return { document };
}
async function getOrganizationDocumentsCount({ organizationId, filters, db }: { organizationId: string; filters?: { tags?: string[] }; db: Database }) {
const [{ documentsCount }] = await db
const [record] = await db
.select({
documentsCount: count(documentsTable.id),
})
@@ -77,11 +93,17 @@ async function getOrganizationDocumentsCount({ organizationId, filters, db }: {
),
);
if (isNil(record)) {
throw createOrganizationNotFoundError();
}
const { documentsCount } = record;
return { documentsCount };
}
async function getOrganizationDeletedDocumentsCount({ organizationId, db }: { organizationId: string; db: Database }) {
const [{ documentsCount }] = await db
const [record] = await db
.select({
documentsCount: count(documentsTable.id),
})
@@ -93,6 +115,12 @@ async function getOrganizationDeletedDocumentsCount({ organizationId, db }: { or
),
);
if (isNil(record)) {
throw createOrganizationNotFoundError();
}
const { documentsCount } = record;
return { documentsCount };
}
@@ -145,7 +173,7 @@ async function getOrganizationDocuments({
}
if (tag) {
acc[document.id].tags.push(tag);
acc[document.id]!.tags.push(tag);
}
return acc;
@@ -235,8 +263,8 @@ async function restoreDocument({ documentId, organizationId, name, userId, db }:
isDeleted: false,
deletedBy: null,
deletedAt: null,
...(name ? { name, originalName: name } : {}),
...(userId ? { createdBy: userId } : {}),
...(isDefined(name) ? { name, originalName: name } : {}),
...(isDefined(userId) ? { createdBy: userId } : {}),
})
.where(
and(
@@ -246,6 +274,10 @@ async function restoreDocument({ documentId, organizationId, name, userId, db }:
)
.returning();
if (isNil(document)) {
throw createDocumentNotFoundError();
}
return { document };
}
@@ -294,7 +326,7 @@ async function searchOrganizationDocuments({ organizationId, searchQuery, pageIn
}
async function getOrganizationStats({ organizationId, db }: { organizationId: string; db: Database }) {
const [{ documentsCount, documentsSize }] = await db
const [record] = await db
.select({
documentsCount: count(documentsTable.id),
documentsSize: sum(documentsTable.originalSize),
@@ -307,6 +339,12 @@ async function getOrganizationStats({ organizationId, db }: { organizationId: st
),
);
if (isNil(record)) {
throw createOrganizationNotFoundError();
}
const { documentsCount, documentsSize } = record;
return {
documentsCount,
documentsSize: Number(documentsSize ?? 0),

View File

@@ -7,6 +7,7 @@ import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
import { createError } from '../shared/errors/errors';
import { isNil } from '../shared/utils';
import { validateFormData, validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
import { createWebhookRepository } from '../webhooks/webhook.repository';
import { triggerWebhooks } from '../webhooks/webhook.usecases';
@@ -38,7 +39,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
app.post(
'/api/organizations/:organizationId/documents',
requireAuthentication({ apiKeyPermissions: ['documents:create'] }),
(context, next) => {
async (context, next) => {
const { maxUploadSize } = config.documentsStorage;
if (!isDocumentSizeLimitEnabled({ maxUploadSize })) {
@@ -56,6 +57,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
},
});
// eslint-disable-next-line ts/no-unsafe-argument
return middleware(context, next);
},
@@ -72,7 +74,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
const { file, ocrLanguages } = context.req.valid('form');
const { organizationId } = context.req.valid('param');
if (!file) {
if (isNil(file)) {
throw createError({
message: 'No file provided, please upload a file using the "file" key.',
code: 'document.no_file',

View File

@@ -17,6 +17,7 @@ import pLimit from 'p-limit';
import { checkIfOrganizationCanCreateNewDocument } from '../organizations/organizations.usecases';
import { createPlansRepository } from '../plans/plans.repository';
import { createLogger } from '../shared/logger/logger';
import { isDefined } from '../shared/utils';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createTaggingRulesRepository } from '../tagging-rules/tagging-rules.repository';
import { applyTaggingRules } from '../tagging-rules/tagging-rules.usecases';
@@ -100,28 +101,28 @@ export async function createDocument({
const { document } = existingDocument
? await handleExistingDocument({
existingDocument,
fileName,
organizationId,
documentsRepository,
tagsRepository,
logger,
})
existingDocument,
fileName,
organizationId,
documentsRepository,
tagsRepository,
logger,
})
: await createNewDocument({
file,
fileName,
size,
mimeType,
hash,
userId,
organizationId,
documentsRepository,
documentsStorageService,
generateDocumentId,
trackingServices,
ocrLanguages,
logger,
});
file,
fileName,
size,
mimeType,
hash,
userId,
organizationId,
documentsRepository,
documentsStorageService,
generateDocumentId,
trackingServices,
ocrLanguages,
logger,
});
deferRegisterDocumentActivityLog({
documentId: document.id,
@@ -195,7 +196,7 @@ async function handleExistingDocument({
tagsRepository: TagsRepository;
logger: Logger;
}) {
if (existingDocument && !existingDocument.isDeleted) {
if (!existingDocument.isDeleted) {
throw createDocumentAlreadyExistsError();
}
@@ -277,7 +278,7 @@ async function createNewDocument({
throw error;
}
if (userId) {
if (isDefined(userId)) {
trackingServices.captureUserEvent({ userId, event: 'Document created' });
}
@@ -354,7 +355,7 @@ export async function deleteExpiredDocuments({
const limit = pLimit(10);
await Promise.all(
documents.map(document => limit(async () => {
documents.map(async document => limit(async () => {
const [, error] = await safely(hardDeleteDocument({ document, documentsRepository, documentsStorageService }));
if (error) {
@@ -408,6 +409,6 @@ export async function deleteAllTrashDocuments({
const limit = pLimit(10);
await Promise.all(
documents.map(document => limit(() => hardDeleteDocument({ document, documentsRepository, documentsStorageService }))),
documents.map(async document => limit(async () => hardDeleteDocument({ document, documentsRepository, documentsStorageService }))),
);
}

View File

@@ -1,5 +1,6 @@
import type { Config } from '../../config/config.types';
import { createError } from '../../shared/errors/errors';
import { isNil } from '../../shared/utils';
import { AZ_BLOB_STORAGE_DRIVER_NAME, azBlobStorageDriverFactory } from './drivers/az-blob/az-blob.storage-driver';
import { B2_STORAGE_DRIVER_NAME, b2StorageDriverFactory } from './drivers/b2/b2.storage-driver';
import { FS_STORAGE_DRIVER_NAME, fsStorageDriverFactory } from './drivers/fs/fs.storage-driver';
@@ -21,7 +22,7 @@ export async function createDocumentStorageService({ config }: { config: Config
const storageDriverFactory = storageDriverFactories[storageDriverName];
if (!storageDriverFactory) {
if (isNil(storageDriverFactory)) {
throw createError({
message: `Unknown storage driver: ${storageDriverName}`,
code: 'storage_driver.unknown_driver',

View File

@@ -1,6 +1,7 @@
import { Buffer } from 'node:buffer';
import B2 from 'backblaze-b2';
import { isNil } from '../../../../shared/utils';
import { createFileNotFoundError } from '../../document-storage.errors';
import { defineStorageDriver } from '../drivers.models';
@@ -22,8 +23,10 @@ export const b2StorageDriverFactory = defineStorageDriver(async ({ config }) =>
bucketId,
});
const upload = await b2Client.uploadFile({
uploadUrl: getUploadUrl.data.uploadUrl,
uploadAuthToken: getUploadUrl.data.authorizationToken,
// eslint-disable-next-line ts/no-unsafe-member-access
uploadUrl: getUploadUrl.data?.uploadUrl as string,
// eslint-disable-next-line ts/no-unsafe-member-access
uploadAuthToken: getUploadUrl.data?.authorizationToken as string,
fileName: storageKey,
data: Buffer.from(await file.arrayBuffer()),
});
@@ -34,15 +37,18 @@ export const b2StorageDriverFactory = defineStorageDriver(async ({ config }) =>
},
getFileStream: async ({ storageKey }) => {
await b2Client.authorize();
const response = await b2Client.downloadFileByName({
bucketName,
fileName: storageKey,
responseType: 'stream',
});
if (!response.data) {
if (isNil(response.data)) {
throw createFileNotFoundError();
}
return { fileStream: response.data };
return { fileStream: response.data as ReadableStream };
},
deleteFile: async ({ storageKey }) => {
await b2Client.hideFile({

View File

@@ -94,7 +94,7 @@ describe('storage driver', () => {
const { fileStream } = await fsStorageDriver.getFileStream({ storageKey: 'org_1/text-file.txt' });
const chunks: Uint8Array[] = [];
const chunks: unknown[] = [];
for await (const chunk of fileStream) {
chunks.push(chunk);
}

View File

@@ -43,7 +43,7 @@ describe('memory storage-driver', () => {
const entries = Array.from(storage.entries());
expect(entries).to.have.length(1);
const [key, file] = entries[0];
const [key, file] = entries[0] as [string, File];
expect(key).to.eql('org_1/text-file.txt');
expect(file).to.be.a('File');

View File

@@ -1,7 +1,9 @@
import type { Config } from '../config/config.types';
import type { EmailDriverName } from './drivers/email-driver';
import type { EmailDriverFactory } from './emails.types';
import { createError } from '../shared/errors/errors';
import { createLogger } from '../shared/logger/logger';
import { isNil } from '../shared/utils';
import { emailDrivers } from './drivers/email-driver';
export type EmailsServices = ReturnType<typeof createEmailsServices>;
@@ -9,9 +11,9 @@ export type EmailsServices = ReturnType<typeof createEmailsServices>;
export function createEmailsServices({ config }: { config: Config }) {
const { driverName } = config.emails;
const emailDriver = emailDrivers[driverName as EmailDriverName];
const emailDriver: EmailDriverFactory | undefined = emailDrivers[driverName as EmailDriverName];
if (!emailDriver) {
if (isNil(emailDriver)) {
throw createError({
message: `Invalid email driver ${driverName}`,
code: 'emails.invalid_driver',

View File

@@ -1,5 +1,6 @@
import { isAbsolute, join, parse, sep as pathSeparator, relative } from 'node:path';
import { ORGANIZATION_ID_REGEX } from '../organizations/organizations.constants';
import { isNil } from '../shared/utils';
export function normalizeFilePathToIngestionFolder({
filePath,
@@ -16,7 +17,7 @@ export function normalizeFilePathToIngestionFolder({
export function getOrganizationIdFromFilePath({ relativeFilePath }: { relativeFilePath: string }) {
const [maybeOrganizationId] = relativeFilePath.split(pathSeparator);
if (!maybeOrganizationId || !ORGANIZATION_ID_REGEX.test(maybeOrganizationId)) {
if (isNil(maybeOrganizationId) || !ORGANIZATION_ID_REGEX.test(maybeOrganizationId)) {
return { organizationId: undefined };
}

View File

@@ -15,8 +15,10 @@ export async function getFile({
fs?: Pick<FsServices, 'readFile'>;
}) {
const buffer = await fs.readFile({ filePath });
// OR pipes since lookup returns false if the mime type is not found
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
// lookup returns false if the mime type is not found
const lookedUpMimeType = mime.lookup(filePath);
const mimeType = lookedUpMimeType === false ? 'application/octet-stream' : lookedUpMimeType;
const { base: fileName } = parse(filePath);
const file = new File([buffer], fileName, { type: mimeType });

View File

@@ -74,7 +74,7 @@ describe('ingestion-folders usecases', () => {
expect(files).to.have.length(1);
const [file] = files;
const [file] = files as [File];
expect(file.name).to.equal('hello.md');
expect(file.size).to.equal(11);
@@ -175,7 +175,7 @@ describe('ingestion-folders usecases', () => {
expect(files).to.have.length(1);
const [file] = files;
const [file] = files as [File];
expect(file.name).to.equal('hello.md');
expect(file.size).to.equal(11);
@@ -232,6 +232,7 @@ describe('ingestion-folders usecases', () => {
ingestionFolder: {
folderRootPath: '/apps/papra/ingestion',
postProcessing: {
// eslint-disable-next-line ts/no-unsafe-assignment
strategy: 'unknown' as any,
},
},
@@ -278,7 +279,7 @@ describe('ingestion-folders usecases', () => {
expect(files).to.have.length(1);
const [file] = files;
const [file] = files as [File];
expect(file.name).to.equal('hello.md');
expect(file.size).to.equal(11);
@@ -303,6 +304,7 @@ describe('ingestion-folders usecases', () => {
ingestionFolder: {
folderRootPath: '/apps/papra/ingestion',
postProcessing: {
// eslint-disable-next-line ts/no-unsafe-assignment
strategy: 'unknown' as any,
},
},
@@ -477,7 +479,7 @@ describe('ingestion-folders usecases', () => {
const documents = await db.select().from(documentsTable);
expect(documents).to.have.length(1);
expect(documents[0].id).to.equal('doc_1');
expect(documents[0]?.id).to.equal('doc_1');
// Check fs
expect(vol.toJSON()).to.deep.equal({
@@ -553,7 +555,7 @@ describe('ingestion-folders usecases', () => {
const documents = await db.select().from(documentsTable);
expect(documents).to.have.length(1);
expect(documents[0].id).to.equal('doc_1');
expect(documents[0]?.id).to.equal('doc_1');
// Check fs
expect(vol.toJSON()).to.deep.equal({

View File

@@ -18,6 +18,7 @@ import { isErrorWithCode } from '../shared/errors/errors';
import { createFsServices } from '../shared/fs/fs.services';
import { createLogger } from '../shared/logger/logger';
import { getRootDirPath } from '../shared/path';
import { isNil } from '../shared/utils';
import { addTimestampToFilename, getAbsolutePathFromFolderRelativeToOrganizationIngestionFolder, getOrganizationIdFromFilePath, isFileInDoneFolder, isFileInErrorFolder, normalizeFilePathToIngestionFolder } from './ingestion-folder.models';
import { createInvalidPostProcessingStrategyError } from './ingestion-folders.errors';
import { getFile } from './ingestion-folders.services';
@@ -58,8 +59,8 @@ export function createIngestionFolderWatcher({
ignored,
},
)
.on('add', (fileMaybeCwdRelativePath) => {
processingQueue.add(async () => {
.on('add', async (fileMaybeCwdRelativePath) => {
await processingQueue.add(async () => {
const filePath = isAbsolute(fileMaybeCwdRelativePath) ? fileMaybeCwdRelativePath : join(cwd, fileMaybeCwdRelativePath);
logger.info({ filePath }, 'Processing file');
@@ -116,7 +117,7 @@ export async function processFile({
const { organizationId } = await getFileOrganizationId({ filePath, ingestionFolderPath, organizationsRepository });
if (!organizationId) {
if (isNil(organizationId)) {
logger.warn({ filePath }, 'A file in the ingestion folder is not located in an organization ingestion folder, skipping');
return;
}
@@ -149,7 +150,7 @@ export async function processFile({
logger.info({ filePath }, 'Document not inserted because it already exists');
}
if (result) {
if (result?.document) {
const { document } = result;
logger.info({ documentId: document.id }, 'Document imported from ingestion folder');
@@ -195,12 +196,16 @@ async function getFileOrganizationId({ filePath, ingestionFolderPath, organizati
const { organizationId } = getOrganizationIdFromFilePath({ relativeFilePath });
if (!organizationId) {
if (isNil(organizationId)) {
return { organizationId: undefined };
}
const { organization } = await organizationsRepository.getOrganizationById({ organizationId });
if (isNil(organization)) {
return { organizationId: undefined };
}
return { organizationId: organization.id };
}

View File

@@ -1,3 +1,6 @@
import { createError } from '../shared/errors/errors';
import { isDefined, isNil } from '../shared/utils';
export function buildEmailAddress({
username,
domain,
@@ -7,11 +10,20 @@ export function buildEmailAddress({
domain: string;
plusPart?: string;
}) {
return `${username}${plusPart ? `+${plusPart}` : ''}@${domain}`;
return `${username}${isDefined(plusPart) ? `+${plusPart}` : ''}@${domain}`;
}
export function parseEmailAddress({ email }: { email: string }) {
const [fullUsername, domain] = email.split('@');
if (isNil(fullUsername) || isNil(domain)) {
throw createError({
message: 'Invalid email address',
code: 'intake_emails.invalid_email_address',
statusCode: 400,
});
}
const [username, ...plusParts] = fullUsername.split('+');
const plusPart = plusParts.length > 0 ? plusParts.join('+') : undefined;
@@ -19,7 +31,7 @@ export function parseEmailAddress({ email }: { email: string }) {
}
export function getEmailUsername({ email }: { email: string | undefined }) {
if (!email) {
if (isNil(email)) {
return { username: undefined };
}

View File

@@ -1,7 +1,9 @@
import type { Database } from '../app/database/database.types';
import { injectArguments } from '@corentinth/chisels';
import { and, count, eq } from 'drizzle-orm';
import { createError } from '../shared/errors/errors';
import { omitUndefined } from '../shared/utils';
import { createIntakeEmailNotFoundError } from './intake-emails.errors';
import { intakeEmailsTable } from './intake-emails.tables';
export type IntakeEmailsRepository = ReturnType<typeof createIntakeEmailsRepository>;
@@ -24,6 +26,16 @@ export function createIntakeEmailsRepository({ db }: { db: Database }) {
async function createIntakeEmail({ organizationId, emailAddress, db }: { organizationId: string; emailAddress: string; db: Database }) {
const [intakeEmail] = await db.insert(intakeEmailsTable).values({ organizationId, emailAddress }).returning();
if (!intakeEmail) {
// Very unlikely to happen as the insertion should throw an issue, it's for type safety
throw createError({
message: 'Error while creating intake email',
code: 'intake-emails.create_error',
statusCode: 500,
isInternal: true,
});
}
return { intakeEmail };
}
@@ -44,6 +56,10 @@ async function updateIntakeEmail({ intakeEmailId, organizationId, isEnabled, all
)
.returning();
if (!intakeEmail) {
throw createIntakeEmailNotFoundError();
}
return { intakeEmail };
}
@@ -93,12 +109,18 @@ async function deleteIntakeEmail({ intakeEmailId, organizationId, db }: { intake
}
async function getOrganizationIntakeEmailsCount({ organizationId, db }: { organizationId: string; db: Database }) {
const [{ intakeEmailCount }] = await db
const [record] = await db
.select({ intakeEmailCount: count() })
.from(intakeEmailsTable)
.where(
eq(intakeEmailsTable.organizationId, organizationId),
);
if (!record) {
throw createIntakeEmailNotFoundError();
}
const { intakeEmailCount } = record;
return { intakeEmailCount };
}

View File

@@ -12,6 +12,7 @@ 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 { isNil } from '../shared/utils';
import { validateFormData, validateJsonBody, validateParams } from '../shared/validation/validation';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { INTAKE_EMAILS_INGEST_ROUTE } from './intake-emails.constants';
@@ -166,7 +167,7 @@ function setupIngestIntakeEmailRoute({ app, db, config, trackingServices }: Rout
const bodyBuffer = await context.req.arrayBuffer();
const signature = getHeader({ context, name: 'X-Signature' });
if (!signature) {
if (isNil(signature)) {
throw createError({
message: 'Signature header is required',
code: 'intake_emails.signature_header_required',

View File

@@ -1,12 +1,14 @@
import type { Config } from '../config/config.types';
import type { IntakeEmailDriverName } from './drivers/intake-emails.drivers';
import type { IntakeEmailDriverFactory } from './drivers/intake-emails.drivers.models';
import { createError } from '../shared/errors/errors';
import { isNil } from '../shared/utils';
import { intakeEmailDrivers } from './drivers/intake-emails.drivers';
export function createIntakeEmailsServices({ config }: { config: Config }) {
const intakeEmailDriver = intakeEmailDrivers[config.intakeEmails.driver as IntakeEmailDriverName];
const intakeEmailDriver: IntakeEmailDriverFactory | undefined = intakeEmailDrivers[config.intakeEmails.driver as IntakeEmailDriverName];
if (!intakeEmailDriver) {
if (isNil(intakeEmailDriver)) {
throw createError({
message: `Invalid intake email driver ${config.intakeEmails.driver}`,
code: 'intake-emails.invalid_driver',

View File

@@ -37,7 +37,7 @@ export async function createIntakeEmail({
return { intakeEmail };
}
export function processIntakeEmailIngestion({
export async function processIntakeEmailIngestion({
fromAddress,
recipientsAddresses,
attachments,
@@ -51,7 +51,7 @@ export function processIntakeEmailIngestion({
createDocument: CreateDocumentUsecase;
}) {
return Promise.all(
recipientsAddresses.map(recipientAddress => safely(
recipientsAddresses.map(async recipientAddress => safely(
ingestEmailForRecipient({
fromAddress,
recipientAddress,

View File

@@ -1,12 +1,15 @@
import type { RouteDefinitionContext } from '../app/server.types';
import z from 'zod';
import { createForbiddenError } from '../app/auth/auth.errors';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { invitationIdSchema } from '../organizations/organization.schemas';
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from '../organizations/organizations.constants';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
import { resendOrganizationInvitation } from '../organizations/organizations.usecases';
import { createError } from '../shared/errors/errors';
import { createLogger } from '../shared/logger/logger';
import { validateParams } from '../shared/validation/validation';
import { createUsersRepository } from '../users/users.repository';
const logger = createLogger({ namespace: 'invitations' });
@@ -62,8 +65,11 @@ function setupAcceptInvitationRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/invitations/:invitationId/accept',
requireAuthentication(),
validateParams(z.object({
invitationId: invitationIdSchema,
})),
async (context) => {
const { invitationId } = context.req.param();
const { invitationId } = context.req.valid('param');
const { userId } = getUser({ context });
const organizationsRepository = createOrganizationsRepository({ db });
@@ -111,8 +117,11 @@ function setupRejectInvitationRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/invitations/:invitationId/reject',
requireAuthentication(),
validateParams(z.object({
invitationId: invitationIdSchema,
})),
async (context) => {
const { invitationId } = context.req.param();
const { invitationId } = context.req.valid('param');
const { userId } = getUser({ context });
const organizationsRepository = createOrganizationsRepository({ db });
@@ -146,8 +155,11 @@ function setupCancelInvitationRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/invitations/:invitationId/cancel',
requireAuthentication(),
validateParams(z.object({
invitationId: invitationIdSchema,
})),
async (context) => {
const { invitationId } = context.req.param();
const { invitationId } = context.req.valid('param');
const { userId } = getUser({ context });
const organizationsRepository = createOrganizationsRepository({ db });
@@ -179,8 +191,11 @@ function setupResendInvitationRoute({ app, db, config, emailsServices }: RouteDe
app.post(
'/api/invitations/:invitationId/resend',
requireAuthentication(),
validateParams(z.object({
invitationId: invitationIdSchema,
})),
async (context) => {
const { invitationId } = context.req.param();
const { invitationId } = context.req.valid('param');
const { userId } = getUser({ context });
const organizationsRepository = createOrganizationsRepository({ db });

View File

@@ -1,5 +1,6 @@
import { z } from 'zod';
import { ORGANIZATION_ID_REGEX, ORGANIZATION_MEMBER_ID_REGEX } from './organizations.constants';
import { ORGANIZATION_ID_REGEX, ORGANIZATION_INVITATION_ID_REGEX, ORGANIZATION_MEMBER_ID_REGEX } from './organizations.constants';
export const organizationIdSchema = z.string().regex(ORGANIZATION_ID_REGEX);
export const memberIdSchema = z.string().regex(ORGANIZATION_MEMBER_ID_REGEX);
export const invitationIdSchema = z.string().regex(ORGANIZATION_INVITATION_ID_REGEX);

View File

@@ -6,6 +6,9 @@ export const ORGANIZATION_ID_REGEX = createPrefixedIdRegex({ prefix: ORGANIZATIO
export const ORGANIZATION_MEMBER_ID_PREFIX = 'org_mem';
export const ORGANIZATION_MEMBER_ID_REGEX = createPrefixedIdRegex({ prefix: ORGANIZATION_MEMBER_ID_PREFIX });
export const ORGANIZATION_INVITATION_ID_PREFIX = 'org_inv';
export const ORGANIZATION_INVITATION_ID_REGEX = createPrefixedIdRegex({ prefix: ORGANIZATION_INVITATION_ID_PREFIX });
export const ORGANIZATION_ROLES = {
MEMBER: 'member',
OWNER: 'owner',

View File

@@ -11,7 +11,7 @@ export function ensureInvitationStatus({ invitation, now = new Date() }: { invit
return invitation;
}
if (invitation.expiresAt && isAfter(invitation.expiresAt, now)) {
if (isAfter(invitation.expiresAt, now)) {
return invitation;
}

View File

@@ -7,6 +7,7 @@ import { omit } from 'lodash-es';
import { omitUndefined } from '../shared/utils';
import { usersTable } from '../users/users.table';
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganizationNotFoundError } from './organizations.errors';
import { ensureInvitationStatus } from './organizations.repository.models';
import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table';
@@ -49,6 +50,12 @@ export function createOrganizationsRepository({ db }: { db: Database }) {
async function saveOrganization({ organization: organizationToInsert, db }: { organization: DbInsertableOrganization; db: Database }) {
const [organization] = await db.insert(organizationsTable).values(organizationToInsert).returning();
if (!organization) {
// This should never happen, as the database should always return the inserted organization
// guard for type safety
throw new Error('Failed to save organization');
}
return { organization };
}
@@ -110,7 +117,7 @@ async function getOrganizationById({ organizationId, db }: { organizationId: str
}
async function getUserOwnedOrganizationCount({ userId, db }: { userId: string; db: Database }) {
const [{ organizationCount }] = await db
const [record] = await db
.select({
organizationCount: count(organizationMembersTable.id),
})
@@ -122,13 +129,19 @@ async function getUserOwnedOrganizationCount({ userId, db }: { userId: string; d
),
);
if (!record) {
throw createOrganizationNotFoundError();
}
const { organizationCount } = record;
return {
organizationCount,
};
}
async function getOrganizationOwner({ organizationId, db }: { organizationId: string; db: Database }) {
const [{ organizationOwner }] = await db
const [record] = await db
.select({
organizationOwner: getTableColumns(usersTable),
})
@@ -141,11 +154,17 @@ async function getOrganizationOwner({ organizationId, db }: { organizationId: st
),
);
if (!record) {
throw createOrganizationNotFoundError();
}
const { organizationOwner } = record;
return { organizationOwner };
}
async function getOrganizationMembersCount({ organizationId, db }: { organizationId: string; db: Database }) {
const [{ membersCount }] = await db
const [record] = await db
.select({
membersCount: count(organizationMembersTable.id),
})
@@ -154,6 +173,12 @@ async function getOrganizationMembersCount({ organizationId, db }: { organizatio
eq(organizationMembersTable.organizationId, organizationId),
);
if (!record) {
throw createOrganizationNotFoundError();
}
const { membersCount } = record;
return {
membersCount,
};
@@ -268,7 +293,7 @@ async function saveOrganizationInvitation({
}
async function getTodayUserInvitationCount({ userId, db, now = new Date() }: { userId: string; db: Database; now?: Date }) {
const [{ userInvitationCount }] = await db
const [record] = await db
.select({
userInvitationCount: count(organizationInvitationsTable.id),
})
@@ -280,6 +305,12 @@ async function getTodayUserInvitationCount({ userId, db, now = new Date() }: { u
),
);
if (!record) {
throw createOrganizationNotFoundError();
}
const { userInvitationCount } = record;
return {
userInvitationCount,
};
@@ -335,7 +366,7 @@ async function updateOrganizationInvitation({ invitationId, status, expiresAt, d
}
async function getPendingInvitationsCount({ email, db, now = new Date() }: { email: string; db: Database; now?: Date }) {
const [{ pendingInvitationsCount }] = await db
const [record] = await db
.select({
pendingInvitationsCount: count(organizationInvitationsTable.id),
})
@@ -349,6 +380,12 @@ async function getPendingInvitationsCount({ email, db, now = new Date() }: { ema
),
);
if (!record) {
throw createOrganizationNotFoundError();
}
const { pendingInvitationsCount } = record;
return {
pendingInvitationsCount,
};

View File

@@ -10,7 +10,7 @@ import { ORGANIZATION_ROLES } from './organizations.constants';
import { createOrganizationsRepository } from './organizations.repository';
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases';
export async function registerOrganizationsRoutes(context: RouteDefinitionContext) {
export function registerOrganizationsRoutes(context: RouteDefinitionContext) {
setupGetOrganizationsRoute(context);
setupCreateOrganizationRoute(context);
setupGetOrganizationRoute(context);

View File

@@ -3,7 +3,7 @@ import type { OrganizationInvitationStatus, OrganizationRole } from './organizat
import { integer, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core';
import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers';
import { usersTable } from '../users/users.table';
import { ORGANIZATION_ID_PREFIX, ORGANIZATION_INVITATION_STATUS, ORGANIZATION_INVITATION_STATUS_LIST, ORGANIZATION_MEMBER_ID_PREFIX, ORGANIZATION_ROLES_LIST } from './organizations.constants';
import { ORGANIZATION_ID_PREFIX, ORGANIZATION_INVITATION_ID_PREFIX, ORGANIZATION_INVITATION_STATUS, ORGANIZATION_INVITATION_STATUS_LIST, ORGANIZATION_MEMBER_ID_PREFIX, ORGANIZATION_ROLES_LIST } from './organizations.constants';
export const organizationsTable = sqliteTable('organizations', {
...createPrimaryKeyField({ prefix: ORGANIZATION_ID_PREFIX }),
@@ -31,7 +31,7 @@ export const organizationMembersTable = sqliteTable('organization_members', {
]);
export const organizationInvitationsTable = sqliteTable('organization_invitations', {
...createPrimaryKeyField({ prefix: 'org_inv' }),
...createPrimaryKeyField({ prefix: ORGANIZATION_INVITATION_ID_PREFIX }),
...createTimestampColumns(),
organizationId: text('organization_id').notNull().references(() => organizationsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),

View File

@@ -379,7 +379,7 @@ describe('organizations usecases', () => {
const remainingMembers = await db.select().from(organizationMembersTable);
expect(remainingMembers.length).to.equal(1);
expect(remainingMembers[0].id).to.equal('member-1');
expect(remainingMembers[0]?.id).to.equal('member-1');
});
test('a member (not admin nor owner) cannot remove anyone from the organization', async () => {

View File

@@ -14,6 +14,7 @@ import { createForbiddenError } from '../app/auth/auth.errors';
import { getOrganizationPlan } from '../plans/plans.usecases';
import { sanitize } from '../shared/html/html';
import { createLogger } from '../shared/logger/logger';
import { isDefined } from '../shared/utils';
import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants';
import {
createOrganizationDocumentStorageLimitReachedError,
@@ -113,7 +114,7 @@ export async function getOrCreateOrganizationCustomerId({
throw createOrganizationNotFoundError();
}
if (organization.customerId) {
if (isDefined(organization.customerId)) {
return { customerId: organization.customerId };
}

View File

@@ -29,7 +29,7 @@ describe('plans repository', () => {
const { organizationPlans } = getOrganizationPlansRecords({ config });
expect(organizationPlans[FREE_PLAN_ID].limits).to.deep.equal({
expect(organizationPlans[FREE_PLAN_ID]!.limits).to.deep.equal({
maxDocumentStorageBytes: Number.POSITIVE_INFINITY,
maxIntakeEmailsCount: Number.POSITIVE_INFINITY,
maxOrganizationsMembersCount: Number.POSITIVE_INFINITY,

View File

@@ -1,55 +0,0 @@
import { describe, expect, test } from 'vitest';
import { areSomeRolesInJwtPayload } from './roles.models';
describe('roles models', () => {
describe('areSomeRolesInJwtPayload', () => {
test('check if at least one roles in the jwtPayload match at least one of the expected roles', () => {
expect(areSomeRolesInJwtPayload({
roles: ['admin'],
jwtPayload: {
roles: ['admin', 'user'],
},
})).to.eql(true);
expect(areSomeRolesInJwtPayload({
roles: ['admin'],
jwtPayload: {
roles: [],
},
})).to.eql(false);
});
test('when the roles field in the jwtPayload is not a string array, it should return false', () => {
expect(areSomeRolesInJwtPayload({
roles: ['admin'],
jwtPayload: {
roles: 'admin',
},
})).to.eql(false);
expect(areSomeRolesInJwtPayload({
roles: ['admin'],
jwtPayload: {
roles: undefined,
},
})).to.eql(false);
expect(areSomeRolesInJwtPayload({
roles: ['admin'],
jwtPayload: {
roles: ['admin', 123],
},
})).to.eql(false);
expect(areSomeRolesInJwtPayload({
roles: ['admin'],
jwtPayload: {},
})).to.eql(false);
expect(areSomeRolesInJwtPayload({
roles: ['admin'],
jwtPayload: undefined,
})).to.eql(false);
});
});
});

View File

@@ -1,15 +0,0 @@
import { get, isArray, isString } from 'lodash-es';
export function areSomeRolesInJwtPayload({ roles, jwtPayload }: { roles: string[]; jwtPayload?: Record<string, unknown> }) {
const rolesInJwt = get(jwtPayload, 'roles');
if (!rolesInJwt) {
return false;
}
if (!isArray(rolesInJwt) || !rolesInJwt.every(isString)) {
return false;
}
return rolesInJwt.some(role => roles.includes(role));
}

View File

@@ -1 +1 @@
export const nextTick = () => new Promise(resolve => setImmediate(resolve));
export const nextTick = async () => new Promise(resolve => setImmediate(resolve));

View File

@@ -13,5 +13,5 @@ export function safelyDefer(fn: () => Promise<void>, { logger = createLogger({ n
}
export function createDeferable<Args extends unknown[]>(fn: (...args: Args) => Promise<void>) {
return (...args: Args) => safelyDefer(() => fn(...args));
return (...args: Args) => safelyDefer(async () => fn(...args));
}

View File

@@ -19,6 +19,7 @@ describe('errors', () => {
expect(error.message).to.eql('foo');
expect(error.code).to.eql('bar');
expect(error.statusCode).to.eql(500);
expect(error.isInternal).to.eql(false);
});
test('accepts an optional cause property to attach the original error that caused the custom error', () => {

View File

@@ -16,9 +16,9 @@ class CustomError extends Error {
cause?: Error | null;
statusCode: ContentfulStatusCode;
isCustomError = true;
isInternal?: boolean;
isInternal: boolean = false;
constructor({ message, code, cause, statusCode, isInternal }: ErrorOptions) {
constructor({ message, code, cause, statusCode, isInternal = false }: ErrorOptions) {
super(message);
this.code = code;

View File

@@ -51,7 +51,7 @@ export async function moveFile({ sourceFilePath, destinationFilePath, fs = fsNat
}
export async function readFile({ filePath, fs = fsNative }: { filePath: string; fs?: FsNative }) {
return await fs.readFile(filePath);
return fs.readFile(filePath);
}
export async function areFilesContentIdentical({ file1, file2, fs = fsNative }: { file1: string; file2: string; fs?: FsNative }): Promise<boolean> {

View File

@@ -1,3 +1,4 @@
import type { Context } from '../../app/server.types';
import { createMiddleware } from 'hono/factory';
import { getHeader } from '../headers/headers.models';
import { generateId } from '../random/ids';
@@ -6,7 +7,7 @@ import { createLogger, wrapWithLoggerContext } from './logger';
const logger = createLogger({ namespace: 'app' });
export function createLoggerMiddleware() {
return createMiddleware(async (context, next) => {
return createMiddleware(async (context: Context, next) => {
const requestId = getHeader({ context, name: 'x-request-id' });
await wrapWithLoggerContext(

View File

@@ -6,7 +6,7 @@ export const getRootDirPath = memoize(() => process.cwd());
// Working with libsql, file url can be relative, which is not correct according to RFC 8089, which standardizes the `file` scheme
// see https://github.com/tursodatabase/libsql-client-ts/blob/ee036574f5c23335c8b2d6d0c0e117cbe14bf376/packages/libsql-core/src/uri.ts
export function fileUrlToPath({ fileUrl }: { fileUrl: string }) {
const rawPath = fileUrl.replace(/^file:(\/\/)?/, '');
const rawPath = fileUrl.replace(/^file:(?:\/\/)?/, '');
return decodeURIComponent(rawPath);
}

View File

@@ -6,7 +6,7 @@ const createId = init({ length: ID_RANDOM_PART_LENGTH });
export function generateId({ prefix, getRandomPart = createId }: { prefix?: string; getRandomPart?: () => string } = {}) {
const id = getRandomPart();
return prefix ? `${prefix}_${id}` : id;
return prefix !== undefined ? `${prefix}_${id}` : id;
}
export function createPrefixedIdRegex({ prefix }: { prefix: string }) {

View File

@@ -1,3 +1,3 @@
export async function collectReadableStreamToString({ stream }: { stream: ReadableStream }) {
return await new Response(stream).text();
return new Response(stream).text();
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { omitUndefined } from './utils';
import { isDefined, isNil, omitUndefined } from './utils';
describe('utils', () => {
describe('omitUndefined', () => {
@@ -22,4 +22,29 @@ describe('utils', () => {
});
});
});
describe('isNil', () => {
test('a value is considered nil if it is either undefined or null', () => {
expect(isNil(undefined)).toBe(true);
expect(isNil(null)).toBe(true);
expect(isNil(0)).toBe(false);
expect(isNil('')).toBe(false);
expect(isNil(false)).toBe(false);
expect(isNil({})).toBe(false);
expect(isNil([])).toBe(false);
});
});
describe('isDefined', () => {
test('a value is considered defined if it is not undefined or null', () => {
expect(isDefined(undefined)).toBe(false);
expect(isDefined(null)).toBe(false);
expect(isDefined(0)).toBe(true);
expect(isDefined('')).toBe(true);
expect(isDefined(false)).toBe(true);
expect(isDefined({})).toBe(true);
});
});
});

View File

@@ -7,3 +7,11 @@ type OmitUndefined<T> = {
export function omitUndefined<T extends Record<string, any>>(obj: T): OmitUndefined<T> {
return omitBy(obj, isUndefined) as OmitUndefined<T>;
}
export function isNil(value: unknown): value is undefined | null {
return value === undefined || value === null;
}
export function isDefined<T>(value: T): value is Exclude<T, undefined | null> {
return !isNil(value);
}

View File

@@ -17,11 +17,13 @@ function buildValidator<Target extends keyof ValidationTargets>({ target, error
return <Schema extends z.ZodTypeAny>(schema: Schema, { allowAdditionalFields = false }: { allowAdditionalFields?: boolean } = {}) => {
return validator(target, (value, context) => {
// @ts-expect-error try to enforce strict mode
const refinedSchema = allowAdditionalFields ? schema : (schema.strict?.() ?? schema);
// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-call
const refinedSchema: Schema = allowAdditionalFields ? schema : (schema.strict?.() ?? schema);
const result = refinedSchema.safeParse(value);
if (result.success) {
// eslint-disable-next-line ts/no-unsafe-return
return result.data as z.infer<Schema>;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { coerceStripeTimestampToDate } from './subscriptions.models';
import { coerceStripeTimestampToDate, isSignatureHeaderFormatValid } from './subscriptions.models';
describe('subscriptions models', () => {
describe('coerceStripeTimestampToDate', () => {
@@ -10,4 +10,13 @@ describe('subscriptions models', () => {
expect(date).to.deep.equal(new Date('2024-05-19T20:26:23.000Z'));
});
});
describe('isSignatureHeaderFormatValid', () => {
test('the signature value should be a non empty string', () => {
expect(isSignatureHeaderFormatValid(undefined)).toBe(false);
expect(isSignatureHeaderFormatValid('')).toBe(false);
expect(isSignatureHeaderFormatValid('v1_1234567890')).toBe(true);
});
});
});

View File

@@ -1,3 +1,13 @@
import { isNil } from '../shared/utils';
export function coerceStripeTimestampToDate(timestamp: number) {
return new Date(timestamp * 1000);
}
export function isSignatureHeaderFormatValid(signature: string | undefined): signature is string {
if (isNil(signature)) {
return false;
}
return typeof signature === 'string' && signature.length > 0;
}

View File

@@ -13,8 +13,10 @@ import { getOrganizationPlan } from '../plans/plans.usecases';
import { createError } from '../shared/errors/errors';
import { getHeader } from '../shared/headers/headers.models';
import { createLogger } from '../shared/logger/logger';
import { isNil } from '../shared/utils';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { createInvalidWebhookPayloadError, createOrganizationAlreadyHasSubscriptionError } from './subscriptions.errors';
import { isSignatureHeaderFormatValid } from './subscriptions.models';
import { createSubscriptionsRepository } from './subscriptions.repository';
import { handleStripeWebhookEvent } from './subscriptions.usecases';
@@ -31,7 +33,7 @@ function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: Rou
app.post('/api/stripe/webhook', async (context) => {
const signature = getHeader({ context, name: 'stripe-signature' });
if (!signature) {
if (!isSignatureHeaderFormatValid(signature)) {
throw createInvalidWebhookPayloadError();
}
@@ -59,7 +61,7 @@ function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: Rou
});
}
async function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsServices }: RouteDefinitionContext) {
function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsServices }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/checkout-session',
requireAuthentication(),
@@ -99,7 +101,7 @@ async function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsS
const { organizationPlan: organizationPlanToSubscribeTo } = await plansRepository.getOrganizationPlanById({ planId });
if (!organizationPlanToSubscribeTo.priceId) {
if (isNil(organizationPlanToSubscribeTo.priceId)) {
// Very unlikely to happen, as only the free plan does not have a price ID, and we check for the plans in the route validation
// but for type safety, we assert that the price ID is set
throw createError({

View File

@@ -1,3 +1,4 @@
import type { Buffer } from 'node:buffer';
import type { Config } from '../config/config.types';
import { buildUrl, injectArguments } from '@corentinth/chisels';
import Stripe from 'stripe';
@@ -69,7 +70,7 @@ export async function createCheckoutUrl({
return { checkoutUrl: session.url };
}
async function parseWebhookEvent({ stripeClient, payload, signature, config }: { stripeClient: Stripe; payload: any; signature: string; config: Config }) {
async function parseWebhookEvent({ stripeClient, payload, signature, config }: { stripeClient: Stripe; payload: string | Buffer; signature: string; config: Config }) {
const event = await stripeClient.webhooks.constructEventAsync(payload, signature, config.subscriptions.stripeWebhookSecret);
return { event };

View File

@@ -3,6 +3,7 @@ import type { PlansRepository } from '../plans/plans.repository';
import type { SubscriptionsRepository } from './subscriptions.repository';
import { get } from 'lodash-es';
import { createOrganizationNotFoundError } from '../organizations/organizations.errors';
import { isNil } from '../shared/utils';
import { coerceStripeTimestampToDate } from './subscriptions.models';
export async function handleStripeWebhookEvent({
@@ -23,7 +24,7 @@ export async function handleStripeWebhookEvent({
const cancelAtPeriodEnd = get(event, 'data.object.cancel_at_period_end');
const status = get(event, 'data.object.status');
if (!organizationId) {
if (isNil(organizationId)) {
throw createOrganizationNotFoundError();
}
@@ -52,7 +53,7 @@ export async function handleStripeWebhookEvent({
const cancelAtPeriodEnd = get(event, 'data.object.cancel_at_period_end');
const status = get(event, 'data.object.status');
if (!organizationId) {
if (isNil(organizationId)) {
throw createOrganizationNotFoundError();
}

View File

@@ -2,7 +2,7 @@ import type { Document } from '../documents/documents.types';
import { get } from 'lodash-es';
export function getDocumentFieldValue({ document, field }: { document: Document; field: string }) {
const fieldValue = get(document, field);
const fieldValue: unknown = get(document, field);
return { fieldValue };
return { fieldValue: String(fieldValue ?? '') };
}

View File

@@ -69,6 +69,12 @@ async function getOrganizationEnabledTaggingRules({ organizationId, db }: { orga
async function createTaggingRule({ taggingRule, db }: { taggingRule: DbInsertableTaggingRule; db: Database }) {
const [createdTaggingRule] = await db.insert(taggingRulesTable).values(taggingRule).returning();
if (!createdTaggingRule) {
// Very unlikely to happen as the query will throw an error if the tagging rule is not created
// it's for type safety
throw new Error('Failed to create tagging rule');
}
return { taggingRule: createdTaggingRule };
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../app/database/database.test-utils';
import { documentsTable } from '../documents/documents.table';
import { createTestLogger } from '../shared/logger/logger.test-utils';
import { isNil } from '../shared/utils';
import { createTagsRepository } from '../tags/tags.repository';
import { documentsTagsTable } from '../tags/tags.table';
import { createTaggingRulesRepository } from './tagging-rules.repository';
@@ -25,6 +26,11 @@ describe('tagging-rules usecases', () => {
const [document] = await db.select().from(documentsTable).where(eq(documentsTable.id, 'doc_1'));
if (isNil(document)) {
// type safety
throw new Error('Document not found');
}
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
@@ -60,6 +66,11 @@ describe('tagging-rules usecases', () => {
const [document] = await db.select().from(documentsTable).where(eq(documentsTable.id, 'doc_1'));
if (isNil(document)) {
// type safety
throw new Error('Document not found');
}
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });
@@ -78,6 +89,11 @@ describe('tagging-rules usecases', () => {
const [document] = await db.select().from(documentsTable).where(eq(documentsTable.id, 'doc_1'));
if (isNil(document)) {
// type safety
throw new Error('Document not found');
}
const taggingRulesRepository = createTaggingRulesRepository({ db });
const tagsRepository = createTagsRepository({ db });

View File

@@ -85,7 +85,7 @@ export async function applyTaggingRules({
const tagIdsToApply: string[] = uniq(taggingRulesToApplyActions.flatMap(taggingRule => taggingRule.actions.map(action => action.tagId)));
const appliedTagIds = await Promise.all(tagIdsToApply.map(async (tagId) => {
const [, error] = await safely(() => tagsRepository.addTagToDocument({ tagId, documentId: document.id }));
const [, error] = await safely(async () => tagsRepository.addTagToDocument({ tagId, documentId: document.id }));
if (error) {
logger.error({ error, tagId, documentId: document.id }, 'Failed to add tag to document');

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../app/database/database.test-utils';
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
import { isNil } from '../shared/utils';
import { createDocumentAlreadyHasTagError, createTagAlreadyExistsError } from './tags.errors';
import { createTagsRepository } from './tags.repository';
@@ -17,6 +18,11 @@ describe('tags repository', () => {
tag: { organizationId: 'organization-1', name: 'Tag 1', color: '#aa0000' },
});
if (isNil(tag1)) {
// type safety
throw new Error('Tag 1 not found');
}
expect(tag1).to.include({
organizationId: 'organization-1',
name: 'Tag 1',

View File

@@ -29,12 +29,12 @@ function createTaskScheduler({
const task = cron.schedule(
cronSchedule,
() => wrapWithLoggerContext(
async () => wrapWithLoggerContext(
{
taskId: generateId({ prefix: 'task' }),
taskName: taskDefinition.taskName,
},
() => taskDefinition.run({ ...tasksArgs, config }),
async () => taskDefinition.run({ ...tasksArgs, config }),
),
{
scheduled: false,

View File

@@ -14,7 +14,7 @@ export type TrackingServices = {
export function createDummyTrackingServices(): TrackingServices {
return {
captureUserEvent: () => {},
shutdown: () => Promise.resolve(),
shutdown: async () => Promise.resolve(),
};
}
@@ -37,6 +37,6 @@ export function createTrackingServices({ config }: { config: Config }): Tracking
captureUserEvent: ({ userId, event, properties }) => {
trackingClient.capture({ distinctId: userId, event, properties });
},
shutdown: () => trackingClient.shutdown(),
shutdown: async () => trackingClient.shutdown(),
};
}

View File

@@ -7,7 +7,7 @@ import { createRolesRepository } from '../roles/roles.repository';
import { validateJsonBody } from '../shared/validation/validation';
import { createUsersRepository } from './users.repository';
export async function registerUsersRoutes(context: RouteDefinitionContext) {
export function registerUsersRoutes(context: RouteDefinitionContext) {
setupGetCurrentUserRoute(context);
setupUpdateUserRoute(context);
}

View File

@@ -26,7 +26,13 @@ export function createWebhookRepository({ db }: { db: Database }) {
async function createOrganizationWebhook({ db, ...webhook }: { db: Database } & { name: string; url: string; secret?: string; enabled?: boolean; events?: EventName[]; organizationId: string; createdBy: string }) {
const [createdWebhook] = await db.insert(webhooksTable).values(webhook).returning();
if (webhook.events?.length) {
if (!createdWebhook) {
// Very unlikely to happen as the query will throw an error if the webhook is not created
// it's for type safety
throw new Error('Failed to create webhook');
}
if (webhook.events && webhook.events.length > 0) {
await db
.insert(webhookEventsTable)
.values(
@@ -87,6 +93,10 @@ async function getOrganizationWebhookById({ db, webhookId, organizationId }: { d
),
);
if (!records.length) {
return { webhook: undefined };
}
const [{ webhook } = {}] = records;
const events = records.map(record => record.webhookEvents?.eventName);

View File

@@ -99,8 +99,8 @@ export async function triggerWebhooks({
const limit = pLimit(10);
await Promise.all(
webhooks.map(webhook =>
limit(() =>
webhooks.map(async webhook =>
limit(async () =>
triggerWebhook({ webhook, webhookRepository, now, ...webhookData, logger, triggerWebhookService }),
),
),

View File

@@ -15,7 +15,7 @@ async function runScript(
) {
const isDryRun = process.argv.includes('--dry-run');
wrapWithLoggerContext(
await wrapWithLoggerContext(
{
scriptName,
isDryRun,

View File

@@ -1,7 +1,7 @@
import { runMigrations } from '../modules/app/database/database.services';
import { runScript } from './commons/run-script';
runScript(
await runScript(
{ scriptName: 'migrate-up' },
async ({ db }) => {
// Drizzle kit config don't support encryption yet so we cannot use npx drizzle-kit migrate

View File

@@ -3,7 +3,7 @@ import { triggerWebhook } from '@owlrelay/webhook';
import { INTAKE_EMAILS_INGEST_ROUTE } from '../modules/intake-emails/intake-emails.constants';
import { runScript } from './commons/run-script';
runScript(
await runScript(
{ scriptName: 'simulate-intake-email' },
async ({ config }) => {
const { baseUrl } = config.server;

View File

@@ -9,6 +9,7 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
}

2037
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,9 @@ packages:
- apps/*
catalog:
'@antfu/eslint-config': ^4.12.0
'@antfu/eslint-config': ^4.16.2
'@types/node': ^22.15.21
eslint: ^9.25.1
eslint: ^9.30.1
tsx: ^4.17.0
typescript: ^5.6.2
vitest: ^3.0.5