feat: add file storage service and update configurations
refactor: migrate to tsup for builds and update tsconfigs fix: update error handling and validation in payment service chore: update package scripts and dependencies docs: update README and env examples
This commit is contained in:
@@ -1,20 +1,39 @@
|
||||
{
|
||||
"name": "@repo/services",
|
||||
"version": "0.0.0",
|
||||
"main": "./src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"files": ["dist"],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/db": "*",
|
||||
"@repo/exceptions": "*",
|
||||
"@repo/utils": "*",
|
||||
"neverthrow": "^8.2.0",
|
||||
"zod": "^4.0.2"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"typescript": "^5.0.0"
|
||||
"typescript": "^5.8.3",
|
||||
"tsup": "^8.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
61
packages/services/src/files/errors.ts
Normal file
61
packages/services/src/files/errors.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AppError, createAppError } from "@repo/exceptions";
|
||||
|
||||
export enum FileStorageErrorCode {
|
||||
FILE_NOT_FOUND = "FILE_NOT_FOUND",
|
||||
FILE_ALREADY_EXISTS = "FILE_ALREADY_EXISTS",
|
||||
INVALID_FILE_PATH = "INVALID_FILE_PATH",
|
||||
STORAGE_OPERATION_FAILED = "STORAGE_OPERATION_FAILED",
|
||||
DIRECTORY_CREATION_FAILED = "DIRECTORY_CREATION_FAILED",
|
||||
INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS",
|
||||
}
|
||||
|
||||
export type FileStorageError = AppError<FileStorageErrorCode>;
|
||||
|
||||
export const fileNotFound = (fileName: string): FileStorageError =>
|
||||
createAppError<FileStorageErrorCode>(
|
||||
FileStorageErrorCode.FILE_NOT_FOUND,
|
||||
`File with name ${fileName} not found`,
|
||||
);
|
||||
|
||||
export const fileAlreadyExists = (fileName: string): FileStorageError =>
|
||||
createAppError(
|
||||
FileStorageErrorCode.FILE_ALREADY_EXISTS,
|
||||
`File ${fileName} already exists`,
|
||||
);
|
||||
|
||||
export const invalidFilePath = (path: string): FileStorageError =>
|
||||
createAppError(
|
||||
FileStorageErrorCode.INVALID_FILE_PATH,
|
||||
`Invalid file path: ${path}`,
|
||||
);
|
||||
|
||||
export const storageOperationFailed = (
|
||||
operation: string,
|
||||
cause?: Error,
|
||||
): FileStorageError =>
|
||||
createAppError(
|
||||
FileStorageErrorCode.STORAGE_OPERATION_FAILED,
|
||||
`Storage operation '${operation}' failed`,
|
||||
undefined,
|
||||
cause,
|
||||
);
|
||||
|
||||
export const directoryCreationFailed = (
|
||||
path: string,
|
||||
cause?: Error,
|
||||
): FileStorageError =>
|
||||
createAppError(
|
||||
FileStorageErrorCode.DIRECTORY_CREATION_FAILED,
|
||||
`Failed to create directory: ${path}`,
|
||||
undefined,
|
||||
cause,
|
||||
);
|
||||
|
||||
export const insufficientPermissions = (
|
||||
operation: string,
|
||||
path: string,
|
||||
): FileStorageError =>
|
||||
createAppError(
|
||||
FileStorageErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
`Insufficient permissions for ${operation} at ${path}`,
|
||||
);
|
||||
49
packages/services/src/files/factory.ts
Normal file
49
packages/services/src/files/factory.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { err, ok } from "neverthrow";
|
||||
import { createZodValidationError } from "@repo/exceptions";
|
||||
import { FileService } from "./services/file-service";
|
||||
import { FileLinkService } from "./services/file-link-service";
|
||||
import {
|
||||
CreateFileLinkInput,
|
||||
FileServiceConfig,
|
||||
FileServiceConfigSchema,
|
||||
} from "./types";
|
||||
import { PrismaClient } from "@repo/db";
|
||||
|
||||
const validateConfig = (data: FileServiceConfig) => {
|
||||
const parseResult = FileServiceConfigSchema.safeParse(data);
|
||||
return parseResult.success
|
||||
? ok(parseResult.data)
|
||||
: err(createZodValidationError(parseResult.error));
|
||||
};
|
||||
|
||||
const createFileService = (config: FileServiceConfig) =>
|
||||
validateConfig(config).map((validatedConfig) => ({
|
||||
uploadFile: (file: Buffer, options: any) =>
|
||||
FileService.uploadFile(validatedConfig, file, options),
|
||||
downloadFile: (fileName: string) =>
|
||||
FileService.downloadFile(validatedConfig, fileName),
|
||||
deleteFile: (fileName: string) =>
|
||||
FileService.deleteFile(validatedConfig, fileName),
|
||||
getFileMetadata: (fileName: string) =>
|
||||
FileService.getFileMetadata(validatedConfig, fileName),
|
||||
fileExists: (fileName: string) =>
|
||||
FileService.fileExists(validatedConfig, fileName),
|
||||
}));
|
||||
|
||||
const createFileLinkService = (client: PrismaClient) => ({
|
||||
linkFileToRelation: (input: CreateFileLinkInput) =>
|
||||
FileLinkService.linkFileToRelation(client, input),
|
||||
getFilesByRelationId: (relationId: string) =>
|
||||
FileLinkService.getFilesByRelationId(client, relationId),
|
||||
unlinkFileFromRelation: (fileId: string, relationId: string) =>
|
||||
FileLinkService.unlinkFileFromRelation(client, fileId, relationId),
|
||||
unlinkAllFilesFromRelation: (relationId: string) =>
|
||||
FileLinkService.unlinkAllFilesFromRelation(client, relationId),
|
||||
getLinkedRelations: (fileId: string) =>
|
||||
FileLinkService.getLinkedRelations(client, fileId),
|
||||
});
|
||||
|
||||
export const StorageFactory = {
|
||||
createFileService,
|
||||
createFileLinkService,
|
||||
};
|
||||
129
packages/services/src/files/fs-utils.ts
Normal file
129
packages/services/src/files/fs-utils.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { promises as fs, Stats } from "fs";
|
||||
import { extname, basename } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { ResultAsync, ok, err, Result } from "neverthrow";
|
||||
import type { FileUploadOptions } from "./types";
|
||||
import {
|
||||
FileStorageError,
|
||||
fileNotFound,
|
||||
storageOperationFailed,
|
||||
directoryCreationFailed,
|
||||
insufficientPermissions,
|
||||
} from "./errors";
|
||||
|
||||
const handleFileSystemError = (
|
||||
error: unknown,
|
||||
operation: string,
|
||||
fileName: string,
|
||||
): FileStorageError => {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
|
||||
switch (nodeError.code) {
|
||||
case "ENOENT":
|
||||
return fileNotFound(fileName);
|
||||
case "EACCES":
|
||||
case "EPERM":
|
||||
return insufficientPermissions(operation, fileName);
|
||||
default:
|
||||
return storageOperationFailed(operation, nodeError);
|
||||
}
|
||||
};
|
||||
|
||||
export const validateUpload = (
|
||||
file: Buffer,
|
||||
options: FileUploadOptions,
|
||||
maxFileSize?: number,
|
||||
allowedExtensions?: string[],
|
||||
): Result<void, FileStorageError> => {
|
||||
if (maxFileSize && file.length > maxFileSize) {
|
||||
return err(
|
||||
storageOperationFailed(
|
||||
`File size ${file.length} exceeds maximum allowed size ${maxFileSize}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedExtensions) {
|
||||
const ext = extname(options.originalName).toLowerCase();
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
return err(
|
||||
storageOperationFailed(`File extension ${ext} is not allowed`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
};
|
||||
|
||||
export const generateFileName = (
|
||||
originalName: string,
|
||||
customGenerator?: (originalName: string) => string,
|
||||
): string =>
|
||||
customGenerator
|
||||
? customGenerator(originalName)
|
||||
: (() => {
|
||||
const ext = extname(originalName);
|
||||
const name = basename(originalName, ext);
|
||||
const uuid = randomUUID();
|
||||
return `${name}-${uuid}${ext}`;
|
||||
})();
|
||||
|
||||
export const ensureDirectoryExists = (
|
||||
dirPath: string,
|
||||
createDirectories: boolean,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
!createDirectories
|
||||
? ResultAsync.fromPromise(Promise.resolve(), () =>
|
||||
directoryCreationFailed("Directory creation is disabled"),
|
||||
)
|
||||
: ResultAsync.fromPromise(fs.mkdir(dirPath, { recursive: true }), (error) =>
|
||||
directoryCreationFailed(dirPath, error as Error),
|
||||
).andThen(() => ok(undefined));
|
||||
|
||||
export const writeFile = (
|
||||
filePath: string,
|
||||
file: Buffer,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
ResultAsync.fromPromise(fs.writeFile(filePath, file), (error) =>
|
||||
handleFileSystemError(error, "write", filePath),
|
||||
);
|
||||
|
||||
export const readFile = (
|
||||
filePath: string,
|
||||
): ResultAsync<Buffer, FileStorageError> =>
|
||||
ResultAsync.fromPromise(fs.readFile(filePath), (error) =>
|
||||
handleFileSystemError(error, "read", filePath),
|
||||
);
|
||||
|
||||
export const deleteFile = (
|
||||
filePath: string,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
ResultAsync.fromPromise(fs.unlink(filePath), (error) =>
|
||||
handleFileSystemError(error, "delete", filePath),
|
||||
);
|
||||
|
||||
export const checkFileExists = (
|
||||
filePath: string,
|
||||
): ResultAsync<boolean, FileStorageError> =>
|
||||
ResultAsync.fromPromise(
|
||||
fs.access(filePath).then(() => true),
|
||||
() => false,
|
||||
).orElse(() => ok(false));
|
||||
|
||||
export const getFileStats = (
|
||||
filePath: string,
|
||||
): ResultAsync<Stats, FileStorageError> =>
|
||||
ResultAsync.fromPromise(fs.stat(filePath), (error) =>
|
||||
handleFileSystemError(error, "stat", filePath),
|
||||
);
|
||||
|
||||
export const FsUtils = {
|
||||
validateUpload,
|
||||
generateFileName,
|
||||
ensureDirectoryExists,
|
||||
writeFile,
|
||||
readFile,
|
||||
deleteFile,
|
||||
checkFileExists,
|
||||
getFileStats,
|
||||
};
|
||||
2
packages/services/src/files/index.ts
Normal file
2
packages/services/src/files/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./factory";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ResultAsync, ok } from "neverthrow";
|
||||
import { PrismaClientArg, Prisma, FileLink, File } from "@repo/db";
|
||||
import { FileStorageError, storageOperationFailed } from "../errors";
|
||||
import { CreateFileLinkInput } from "../types";
|
||||
|
||||
const create = (
|
||||
client: PrismaClientArg,
|
||||
data: CreateFileLinkInput,
|
||||
): ResultAsync<FileLink, FileStorageError> => {
|
||||
const linkData: Prisma.FileLinkCreateInput = {
|
||||
relationId: data.relationId,
|
||||
file: {
|
||||
connect: { id: data.fileId },
|
||||
},
|
||||
};
|
||||
|
||||
return ResultAsync.fromPromise(
|
||||
client.fileLink.create({ data: linkData }),
|
||||
(error) => storageOperationFailed("createFileLink", error as Error),
|
||||
);
|
||||
};
|
||||
|
||||
const findByRelationId = (
|
||||
client: PrismaClientArg,
|
||||
relationId: string,
|
||||
): ResultAsync<(FileLink & { file: File })[], FileStorageError> =>
|
||||
ResultAsync.fromPromise(
|
||||
client.fileLink.findMany({
|
||||
where: { relationId },
|
||||
include: { file: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
(error) => storageOperationFailed("findFilesByRelationId", error as Error),
|
||||
);
|
||||
|
||||
const deleteByFileId = (
|
||||
client: PrismaClientArg,
|
||||
fileId: string,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
ResultAsync.fromPromise(
|
||||
client.fileLink.deleteMany({ where: { fileId } }),
|
||||
(error) => storageOperationFailed("deleteFileLinks", error as Error),
|
||||
).andThen(() => ok(undefined));
|
||||
|
||||
const deleteByRelationId = (
|
||||
client: PrismaClientArg,
|
||||
relationId: string,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
ResultAsync.fromPromise(
|
||||
client.fileLink.deleteMany({ where: { relationId } }),
|
||||
(error) => storageOperationFailed("deleteFileLinks", error as Error),
|
||||
).andThen(() => ok(undefined));
|
||||
|
||||
const deleteSpecificLink = (
|
||||
client: PrismaClientArg,
|
||||
fileId: string,
|
||||
relationId: string,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
ResultAsync.fromPromise(
|
||||
client.fileLink.deleteMany({ where: { fileId, relationId } }),
|
||||
(error) => storageOperationFailed("deleteFileLink", error as Error),
|
||||
).andThen(() => ok(undefined));
|
||||
|
||||
export const FileLinkRepository = {
|
||||
create,
|
||||
findByRelationId,
|
||||
deleteByFileId,
|
||||
deleteByRelationId,
|
||||
deleteSpecificLink,
|
||||
};
|
||||
71
packages/services/src/files/repositories/file-repository.ts
Normal file
71
packages/services/src/files/repositories/file-repository.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ResultAsync, ok } from "neverthrow";
|
||||
import { PrismaClientArg, Prisma, File } from "@repo/db";
|
||||
import { fileNotFound, FileStorageError } from "../errors";
|
||||
import {
|
||||
wrapDbOperation,
|
||||
wrapDbOperationWithNull,
|
||||
DatabaseError,
|
||||
databaseError,
|
||||
} from "@repo/exceptions";
|
||||
import { CreateFileInput } from "../types";
|
||||
|
||||
const create = (
|
||||
client: PrismaClientArg,
|
||||
data: CreateFileInput,
|
||||
): ResultAsync<File, DatabaseError> => {
|
||||
const fileData: Prisma.FileCreateInput = {
|
||||
filename: data.filename,
|
||||
originalName: data.originalName,
|
||||
mimeType: data.mimeType,
|
||||
size: data.size,
|
||||
path: data.path,
|
||||
};
|
||||
|
||||
return wrapDbOperation(
|
||||
() => client.file.create({ data: fileData }),
|
||||
(e) => databaseError("createFile", e as Error),
|
||||
);
|
||||
};
|
||||
|
||||
const findById = (
|
||||
client: PrismaClientArg,
|
||||
id: string,
|
||||
): ResultAsync<File, DatabaseError | FileStorageError> =>
|
||||
wrapDbOperationWithNull<File, DatabaseError | FileStorageError>(
|
||||
() => client.file.findUnique({ where: { id } }),
|
||||
fileNotFound(id),
|
||||
(e) => databaseError("findFileById", e as Error),
|
||||
);
|
||||
|
||||
const findByFilename = (
|
||||
client: PrismaClientArg,
|
||||
filename: string,
|
||||
): ResultAsync<File | null, DatabaseError> =>
|
||||
ResultAsync.fromPromise(
|
||||
client.file.findFirst({ where: { filename } }),
|
||||
(error) => databaseError("findByFilename", error as Error),
|
||||
);
|
||||
|
||||
const deleteById = (
|
||||
client: PrismaClientArg,
|
||||
id: string,
|
||||
): ResultAsync<void, DatabaseError> =>
|
||||
ResultAsync.fromPromise(client.file.delete({ where: { id } }), (error) =>
|
||||
databaseError("deleteFile", error as Error),
|
||||
).andThen(() => ok(undefined));
|
||||
|
||||
const exists = (
|
||||
client: PrismaClientArg,
|
||||
filename: string,
|
||||
): ResultAsync<boolean, DatabaseError> =>
|
||||
ResultAsync.fromPromise(client.file.exists({ filename }), (error) =>
|
||||
databaseError("checkFileExists", error as Error),
|
||||
);
|
||||
|
||||
export const FileRepository = {
|
||||
create,
|
||||
findById,
|
||||
findByFilename,
|
||||
deleteById,
|
||||
exists,
|
||||
};
|
||||
66
packages/services/src/files/services/file-link-service.ts
Normal file
66
packages/services/src/files/services/file-link-service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ResultAsync, ok, err } from "neverthrow";
|
||||
import { createZodValidationError } from "@repo/exceptions";
|
||||
import { PrismaClientArg, File } from "@repo/db";
|
||||
import { FileStorageError, fileNotFound } from "../errors";
|
||||
import { FileLinkRepository } from "../repositories/file-link-repository";
|
||||
import { FileRepository } from "../repositories/file-repository";
|
||||
import { CreateFileLinkInput, CreateFileLinkInputSchema } from "../types";
|
||||
|
||||
const validateLinkInput = (data: CreateFileLinkInput) => {
|
||||
const parseResult = CreateFileLinkInputSchema.safeParse(data);
|
||||
return parseResult.success
|
||||
? ok(parseResult.data)
|
||||
: err(createZodValidationError(parseResult.error));
|
||||
};
|
||||
|
||||
const linkFileToRelation = (
|
||||
client: PrismaClientArg,
|
||||
input: CreateFileLinkInput,
|
||||
) =>
|
||||
validateLinkInput(input).asyncAndThen((validInput) =>
|
||||
FileRepository.findById(client, validInput.fileId).andThen(() =>
|
||||
FileLinkRepository.create(client, validInput),
|
||||
),
|
||||
);
|
||||
|
||||
const getFilesByRelationId = (
|
||||
client: PrismaClientArg,
|
||||
relationId: string,
|
||||
): ResultAsync<File[], FileStorageError> =>
|
||||
FileLinkRepository.findByRelationId(client, relationId).andThen((links) =>
|
||||
ok(links.map((link) => link.file)),
|
||||
);
|
||||
|
||||
const unlinkFileFromRelation = (
|
||||
client: PrismaClientArg,
|
||||
fileId: string,
|
||||
relationId: string,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
FileLinkRepository.deleteSpecificLink(client, fileId, relationId);
|
||||
|
||||
const unlinkAllFilesFromRelation = (
|
||||
client: PrismaClientArg,
|
||||
relationId: string,
|
||||
): ResultAsync<void, FileStorageError> =>
|
||||
FileLinkRepository.deleteByRelationId(client, relationId);
|
||||
|
||||
const getLinkedRelations = (
|
||||
client: PrismaClientArg,
|
||||
fileId: string,
|
||||
): ResultAsync<string[], FileStorageError> =>
|
||||
ResultAsync.fromPromise(
|
||||
client.fileLink.findMany({
|
||||
where: { fileId },
|
||||
select: { relationId: true },
|
||||
}),
|
||||
() => fileNotFound(fileId),
|
||||
).andThen((links) => ok(links.map((link) => link.relationId)));
|
||||
|
||||
export const FileLinkService = {
|
||||
validateLinkInput,
|
||||
linkFileToRelation,
|
||||
getFilesByRelationId,
|
||||
unlinkFileFromRelation,
|
||||
unlinkAllFilesFromRelation,
|
||||
getLinkedRelations,
|
||||
};
|
||||
135
packages/services/src/files/services/file-service.ts
Normal file
135
packages/services/src/files/services/file-service.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { join, dirname } from "path";
|
||||
import { ResultAsync, ok, err } from "neverthrow";
|
||||
import { createZodValidationError } from "@repo/exceptions";
|
||||
import { fileAlreadyExists, fileNotFound } from "../errors";
|
||||
import { FsUtils } from "../fs-utils";
|
||||
import { FileRepository } from "../repositories/file-repository";
|
||||
import {
|
||||
FileServiceConfigSchema,
|
||||
CreateFileInput,
|
||||
CreateFileInputSchema,
|
||||
FileServiceConfig,
|
||||
FileUploadOptions,
|
||||
} from "../types";
|
||||
|
||||
const validateConfig = (data: unknown) => {
|
||||
const parseResult = FileServiceConfigSchema.safeParse(data);
|
||||
return parseResult.success
|
||||
? ok(parseResult.data)
|
||||
: err(createZodValidationError(parseResult.error));
|
||||
};
|
||||
|
||||
const validateFileInput = (data: CreateFileInput) => {
|
||||
const parseResult = CreateFileInputSchema.safeParse(data);
|
||||
return parseResult.success
|
||||
? ok(parseResult.data)
|
||||
: err(createZodValidationError(parseResult.error));
|
||||
};
|
||||
|
||||
const uploadFile = (
|
||||
config: FileServiceConfig,
|
||||
file: Buffer,
|
||||
options: FileUploadOptions,
|
||||
) => {
|
||||
const validation = FsUtils.validateUpload(
|
||||
file,
|
||||
options,
|
||||
config.maxFileSize,
|
||||
config.allowedExtensions,
|
||||
);
|
||||
|
||||
if (validation.isErr()) {
|
||||
return ResultAsync.fromSafePromise(Promise.resolve(validation));
|
||||
}
|
||||
|
||||
const fileName = FsUtils.generateFileName(
|
||||
options.originalName,
|
||||
options.generateFileName,
|
||||
);
|
||||
const filePath = join(config.basePath, fileName);
|
||||
|
||||
return FileRepository.exists(config.client, fileName)
|
||||
.andThen((exists) =>
|
||||
exists && !options.overwrite
|
||||
? err(fileAlreadyExists(fileName))
|
||||
: ok(undefined),
|
||||
)
|
||||
.andThen(() =>
|
||||
FsUtils.ensureDirectoryExists(
|
||||
dirname(filePath),
|
||||
config.createDirectories,
|
||||
),
|
||||
)
|
||||
.andThen(() => FsUtils.writeFile(filePath, file))
|
||||
.andThen(() => {
|
||||
const fileData: CreateFileInput = {
|
||||
filename: fileName,
|
||||
originalName: options.originalName,
|
||||
mimeType: options.mimeType,
|
||||
size: file.length,
|
||||
path: filePath,
|
||||
};
|
||||
return validateFileInput(fileData).asyncAndThen((validData) =>
|
||||
FileRepository.create(config.client, validData),
|
||||
);
|
||||
})
|
||||
.andThen((fileRecord) =>
|
||||
ok({
|
||||
id: fileRecord.id,
|
||||
originalName: fileRecord.originalName,
|
||||
fileName: fileRecord.filename,
|
||||
mimeType: fileRecord.mimeType,
|
||||
size: fileRecord.size,
|
||||
uploadedAt: fileRecord.createdAt,
|
||||
path: fileRecord.path,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const downloadFile = (config: FileServiceConfig, fileName: string) =>
|
||||
FileRepository.findByFilename(config.client, fileName).andThen(
|
||||
(fileRecord) =>
|
||||
!fileRecord
|
||||
? err(fileNotFound(fileName))
|
||||
: FsUtils.readFile(fileRecord.path),
|
||||
);
|
||||
|
||||
const deleteFile = (config: FileServiceConfig, fileName: string) =>
|
||||
FileRepository.findByFilename(config.client, fileName).andThen(
|
||||
(fileRecord) => {
|
||||
if (!fileRecord) {
|
||||
return err(fileNotFound(fileName));
|
||||
}
|
||||
return FsUtils.deleteFile(fileRecord.path).andThen(() =>
|
||||
FileRepository.deleteById(config.client, fileRecord.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const getFileMetadata = (config: FileServiceConfig, fileName: string) =>
|
||||
FileRepository.findByFilename(config.client, fileName).andThen(
|
||||
(fileRecord) =>
|
||||
!fileRecord
|
||||
? ok(null)
|
||||
: ok({
|
||||
id: fileRecord.id,
|
||||
originalName: fileRecord.originalName,
|
||||
fileName: fileRecord.filename,
|
||||
mimeType: fileRecord.mimeType,
|
||||
size: fileRecord.size,
|
||||
uploadedAt: fileRecord.createdAt,
|
||||
path: fileRecord.path,
|
||||
}),
|
||||
);
|
||||
|
||||
const fileExists = (config: FileServiceConfig, fileName: string) =>
|
||||
FileRepository.exists(config.client, fileName);
|
||||
|
||||
export const FileService = {
|
||||
validateConfig,
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
deleteFile,
|
||||
getFileMetadata,
|
||||
fileExists,
|
||||
};
|
||||
105
packages/services/src/files/types.ts
Normal file
105
packages/services/src/files/types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { z } from "zod";
|
||||
import { ResultAsync } from "neverthrow";
|
||||
import { FileStorageError } from "./errors";
|
||||
import { PrismaClientArg } from "@repo/db";
|
||||
|
||||
export type FileMetadata = {
|
||||
readonly id: string;
|
||||
readonly originalName: string;
|
||||
readonly fileName: string;
|
||||
readonly mimeType: string;
|
||||
readonly size: number;
|
||||
readonly uploadedAt: Date;
|
||||
readonly path: string;
|
||||
};
|
||||
|
||||
export type FileUploadOptions = {
|
||||
readonly originalName: string;
|
||||
readonly mimeType: string;
|
||||
readonly generateFileName?: (originalName: string) => string;
|
||||
readonly overwrite?: boolean;
|
||||
};
|
||||
|
||||
export type StorageConfig = {
|
||||
readonly basePath: string;
|
||||
readonly createDirectories: boolean;
|
||||
readonly maxFileSize?: number;
|
||||
readonly allowedExtensions?: string[];
|
||||
};
|
||||
|
||||
export type FileServiceConfig = StorageConfig & {
|
||||
readonly client: PrismaClientArg;
|
||||
};
|
||||
|
||||
export type CreateFileInput = {
|
||||
readonly filename: string;
|
||||
readonly originalName: string;
|
||||
readonly mimeType: string;
|
||||
readonly size: number;
|
||||
readonly path: string;
|
||||
};
|
||||
|
||||
export type CreateFileLinkInput = {
|
||||
readonly fileId: string;
|
||||
readonly relationId: string;
|
||||
};
|
||||
|
||||
export const StorageConfigSchema = z.object({
|
||||
basePath: z.string().min(1),
|
||||
createDirectories: z.boolean().default(true),
|
||||
maxFileSize: z.number().positive().optional(),
|
||||
allowedExtensions: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const FileServiceConfigSchema = StorageConfigSchema.extend({
|
||||
client: z.custom<PrismaClientArg>(),
|
||||
});
|
||||
|
||||
export const CreateFileInputSchema = z.object({
|
||||
filename: z.string().min(1),
|
||||
originalName: z.string().min(1),
|
||||
mimeType: z.string().min(1),
|
||||
size: z.number().positive(),
|
||||
path: z.string().min(1),
|
||||
});
|
||||
|
||||
export const CreateFileLinkInputSchema = z.object({
|
||||
fileId: z.string().uuid(),
|
||||
relationId: z.string().min(1),
|
||||
});
|
||||
|
||||
export interface StorageProvider {
|
||||
upload(
|
||||
file: Buffer,
|
||||
options: FileUploadOptions,
|
||||
): ResultAsync<FileMetadata, FileStorageError>;
|
||||
|
||||
download(fileName: string): ResultAsync<Buffer, FileStorageError>;
|
||||
|
||||
delete(fileName: string): ResultAsync<void, FileStorageError>;
|
||||
|
||||
exists(fileName: string): ResultAsync<boolean, FileStorageError>;
|
||||
|
||||
getMetadata(
|
||||
fileName: string,
|
||||
): ResultAsync<FileMetadata | null, FileStorageError>;
|
||||
}
|
||||
|
||||
export const LocalStorageConfigSchema = z.object({
|
||||
basePath: z.string().min(1),
|
||||
createDirectories: z.boolean().default(true),
|
||||
maxFileSize: z.number().positive().optional(),
|
||||
allowedExtensions: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const DatabaseStorageConfigSchema = z.object({
|
||||
client: z.custom<PrismaClientArg>(),
|
||||
});
|
||||
|
||||
export const HybridStorageConfigSchema = z.object({
|
||||
client: z.custom<PrismaClientArg>(),
|
||||
basePath: z.string().min(1),
|
||||
createDirectories: z.boolean().default(true),
|
||||
maxFileSize: z.number().positive().optional(),
|
||||
allowedExtensions: z.array(z.string()).optional(),
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export * from './payment';
|
||||
export * from "./payment";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AppError, createAppError } from '@repo/exceptions';
|
||||
import { AppError, createAppError } from "@repo/exceptions";
|
||||
|
||||
export enum PaymentErrorCode {
|
||||
PAYMENT_NOT_FOUND = 'PAYMENT_NOT_FOUND',
|
||||
PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED',
|
||||
PAYMENT_NOT_FOUND = "PAYMENT_NOT_FOUND",
|
||||
PAYMENT_ALREADY_PROCESSED = "PAYMENT_ALREADY_PROCESSED",
|
||||
}
|
||||
|
||||
export type PaymentError = AppError<PaymentErrorCode>;
|
||||
@@ -10,11 +10,11 @@ export type PaymentError = AppError<PaymentErrorCode>;
|
||||
export const paymentNotFound = (paymentId: string): PaymentError =>
|
||||
createAppError<PaymentErrorCode>(
|
||||
PaymentErrorCode.PAYMENT_NOT_FOUND,
|
||||
`Payment with id ${paymentId} not found`
|
||||
`Payment with id ${paymentId} not found`,
|
||||
) as PaymentError;
|
||||
|
||||
export const paymentAlreadyProcessed = (paymentId: string): PaymentError =>
|
||||
createAppError(
|
||||
PaymentErrorCode.PAYMENT_ALREADY_PROCESSED,
|
||||
`Payment ${paymentId} has already been processed`
|
||||
`Payment ${paymentId} has already been processed`,
|
||||
) as PaymentError;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './errors';
|
||||
export * from './types';
|
||||
export * from './repository';
|
||||
export * from './service';
|
||||
export * from "./errors";
|
||||
export * from "./types";
|
||||
export * from "./repository";
|
||||
export * from "./service";
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { PaymentStatus, Prisma, PrismaClientArg, Payment } from '@repo/db';
|
||||
import { paymentNotFound, PaymentError } from './errors';
|
||||
import { PaymentStatus, Prisma, PrismaClientArg, Payment } from "@repo/db";
|
||||
import { paymentNotFound, PaymentError } from "./errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
databaseError,
|
||||
wrapDbOperation,
|
||||
wrapDbOperationWithNull,
|
||||
} from '@repo/exceptions';
|
||||
import { ResultAsync } from 'neverthrow';
|
||||
} from "@repo/exceptions";
|
||||
import { ResultAsync } from "neverthrow";
|
||||
|
||||
export const PaymentRepository = {
|
||||
create(
|
||||
client: PrismaClientArg,
|
||||
data: Prisma.PaymentUncheckedCreateInput
|
||||
data: Prisma.PaymentUncheckedCreateInput,
|
||||
): ResultAsync<Payment, DatabaseError> {
|
||||
return wrapDbOperation(
|
||||
() => client.payment.create({ data }).then((payment: Payment) => payment),
|
||||
(e) => databaseError('create-error', e as Error)
|
||||
(e) => databaseError("create-error", e as Error),
|
||||
);
|
||||
},
|
||||
|
||||
findById(
|
||||
client: PrismaClientArg,
|
||||
id: string
|
||||
id: string,
|
||||
): ResultAsync<Payment, PaymentError | DatabaseError> {
|
||||
return wrapDbOperationWithNull<Payment, PaymentError | DatabaseError>(
|
||||
() => client.payment.findUnique({ where: { id } }),
|
||||
paymentNotFound(id),
|
||||
(e) => databaseError('find-by-id', e as Error)
|
||||
(e) => databaseError("find-by-id", e as Error),
|
||||
);
|
||||
},
|
||||
|
||||
updateStatus(
|
||||
client: PrismaClientArg,
|
||||
id: string,
|
||||
status: PaymentStatus
|
||||
status: PaymentStatus,
|
||||
): ResultAsync<Payment, DatabaseError> {
|
||||
return wrapDbOperation(
|
||||
() =>
|
||||
@@ -41,21 +41,21 @@ export const PaymentRepository = {
|
||||
where: { id },
|
||||
data: { status },
|
||||
}),
|
||||
() => databaseError(id)
|
||||
() => databaseError(id),
|
||||
);
|
||||
},
|
||||
|
||||
findByUserId(
|
||||
client: PrismaClientArg,
|
||||
telegramUserId: string
|
||||
telegramUserId: string,
|
||||
): ResultAsync<Payment[], DatabaseError> {
|
||||
return wrapDbOperation(
|
||||
() =>
|
||||
client.payment.findMany({
|
||||
where: { telegramUserId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
() => databaseError(`user-${telegramUserId}`)
|
||||
() => databaseError(`user-${telegramUserId}`),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { ok, err } from 'neverthrow';
|
||||
import { CreatePaymentInput, CreatePaymentInputSchema } from './types';
|
||||
import { paymentAlreadyProcessed } from './errors';
|
||||
import { PaymentRepository } from './repository';
|
||||
import { Payment, PaymentStatus, PrismaClient } from '@repo/db';
|
||||
import { safeParse } from '@repo/exceptions';
|
||||
import { ok, err } from "neverthrow";
|
||||
import { CreatePaymentInput, CreatePaymentInputSchema } from "./types";
|
||||
import { paymentAlreadyProcessed } from "./errors";
|
||||
import { PaymentRepository } from "./repository";
|
||||
import { Payment, PaymentStatus, PrismaClient } from "@repo/db";
|
||||
import { createZodValidationError } from "@repo/exceptions";
|
||||
|
||||
const validateInput = safeParse(CreatePaymentInputSchema);
|
||||
const validateInput = (data: unknown) => {
|
||||
const parseResult = CreatePaymentInputSchema.safeParse(data);
|
||||
|
||||
return parseResult.success
|
||||
? ok(parseResult.data)
|
||||
: err(createZodValidationError(parseResult.error));
|
||||
};
|
||||
|
||||
export const createPayment = (
|
||||
client: PrismaClient,
|
||||
input: CreatePaymentInput
|
||||
input: CreatePaymentInput,
|
||||
) =>
|
||||
validateInput(input).map((validatedInput) =>
|
||||
PaymentRepository.create(client, validatedInput)
|
||||
PaymentRepository.create(client, validatedInput),
|
||||
);
|
||||
|
||||
export const getPaymentById = (client: PrismaClient, id: string) =>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Payment, PaymentStatus, PrismaClient } from '@repo/db';
|
||||
import { z } from 'zod';
|
||||
import { ResultAsync } from 'neverthrow';
|
||||
import { PaymentError } from './errors';
|
||||
import { Payment, PaymentStatus, PrismaClient } from "@repo/db";
|
||||
import { z } from "zod";
|
||||
import { ResultAsync } from "neverthrow";
|
||||
import { PaymentError } from "./errors";
|
||||
|
||||
export enum Currency {
|
||||
USD = 'USD',
|
||||
EUR = 'EUR',
|
||||
RUB = 'RUB',
|
||||
XTR = 'XTR',
|
||||
USD = "USD",
|
||||
EUR = "EUR",
|
||||
RUB = "RUB",
|
||||
XTR = "XTR",
|
||||
}
|
||||
|
||||
export const CURRENCIES = Object.values(Currency);
|
||||
|
||||
export enum PaymentProvider {
|
||||
YOOKASSA = 'yookassa',
|
||||
TELEGRAM = 'telegram',
|
||||
YOOKASSA = "yookassa",
|
||||
TELEGRAM = "telegram",
|
||||
}
|
||||
|
||||
export const PAYMENT_PROVIDERS = Object.values(PaymentProvider);
|
||||
@@ -36,15 +36,15 @@ export type PaymentProviderResponse = {
|
||||
export type IPaymentProvider = {
|
||||
readonly createPayment: (
|
||||
client: PrismaClient,
|
||||
input: CreatePaymentInput
|
||||
input: CreatePaymentInput,
|
||||
) => ResultAsync<PaymentProviderResponse, PaymentError>;
|
||||
readonly verifyPayment: (
|
||||
client: PrismaClient,
|
||||
payment: Payment
|
||||
payment: Payment,
|
||||
) => ResultAsync<PaymentStatus, PaymentError>;
|
||||
readonly refundPayment: (
|
||||
client: PrismaClient,
|
||||
providerId: string,
|
||||
amount?: number
|
||||
amount?: number,
|
||||
) => ResultAsync<boolean, PaymentError>;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.*",
|
||||
"**/*.spec.*"
|
||||
]
|
||||
}
|
||||
14
packages/services/tsup.config.ts
Normal file
14
packages/services/tsup.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: false,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["@repo/db", "@repo/exceptions"],
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
});
|
||||
Reference in New Issue
Block a user