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:
Yuu Ottosoka
2025-07-12 16:01:57 +03:00
parent c0c04bbdba
commit f9ecac94f2
67 changed files with 3308 additions and 695 deletions

View File

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

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

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

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

View File

@@ -0,0 +1,2 @@
export * from "./types";
export * from "./factory";

View File

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

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

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

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

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

View File

@@ -1 +1 @@
export * from './payment';
export * from "./payment";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.*"
]
}

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