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,16 +1,32 @@
|
||||
{
|
||||
"name": "@repo/config",
|
||||
"version": "0.0.0",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint src/",
|
||||
"check-types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"znv": "^0.5.0",
|
||||
@@ -18,6 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"typescript": "^5.0.0"
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { parseEnv } from 'znv';
|
||||
import { z } from 'zod';
|
||||
import type { ConfigSchema, ConfigOptions, InferConfig } from './types';
|
||||
import { parseEnv } from "znv";
|
||||
import { z } from "zod";
|
||||
import type { ConfigSchema, ConfigOptions, InferConfig } from "./types";
|
||||
|
||||
export const createConfig = <T extends ConfigSchema>(
|
||||
options: ConfigOptions<T>,
|
||||
): InferConfig<T> => {
|
||||
const { schema, env = process.env, prefix = '' } = options;
|
||||
const { schema, env = process.env, prefix = "" } = options;
|
||||
|
||||
// Add prefix to schema keys if provided
|
||||
const prefixedSchema = prefix
|
||||
@@ -14,7 +14,7 @@ export const createConfig = <T extends ConfigSchema>(
|
||||
acc[`${prefix}${key}`] = zodSchema;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, z.ZodTypeAny>
|
||||
{} as Record<string, z.ZodTypeAny>,
|
||||
)
|
||||
: schema;
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './create-config';
|
||||
export * from './types';
|
||||
export * from "./create-config";
|
||||
export * from "./types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
export type ConfigSchema = Record<string, z.ZodTypeAny>;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
"include": ["src/**/*", "tsup.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
13
packages/config/tsup.config.ts
Normal file
13
packages/config/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
});
|
||||
16
packages/database/.eslintrc.js
Normal file
16
packages/database/.eslintrc.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ['@repo/eslint-config/library.js'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
rules: {
|
||||
'turbo/no-undeclared-env-vars': [
|
||||
'error',
|
||||
{
|
||||
allowList: ['NODE_ENV'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -87,6 +87,10 @@ export const PlanScalarFieldEnumSchema = z.enum(['id','code','name','description
|
||||
|
||||
export const SubscriptionScalarFieldEnumSchema = z.enum(['id','telegramUserId','planId','startedAt','expiresAt','canceledAt','createdAt','updatedAt']);
|
||||
|
||||
export const FileScalarFieldEnumSchema = z.enum(['id','filename','originalName','mimeType','size','path','createdAt','updatedAt']);
|
||||
|
||||
export const FileLinkScalarFieldEnumSchema = z.enum(['id','fileId','relationId','createdAt','updatedAt']);
|
||||
|
||||
export const SortOrderSchema = z.enum(['asc','desc']);
|
||||
|
||||
export const JsonNullValueInputSchema = z.enum(['JsonNull',]).transform((value) => (value === 'JsonNull' ? Prisma.JsonNull : value));
|
||||
@@ -190,3 +194,34 @@ export const SubscriptionSchema = z.object({
|
||||
})
|
||||
|
||||
export type Subscription = z.infer<typeof SubscriptionSchema>
|
||||
|
||||
/////////////////////////////////////////
|
||||
// FILE SCHEMA
|
||||
/////////////////////////////////////////
|
||||
|
||||
export const FileSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
filename: z.string(),
|
||||
originalName: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int(),
|
||||
path: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
})
|
||||
|
||||
export type File = z.infer<typeof FileSchema>
|
||||
|
||||
/////////////////////////////////////////
|
||||
// FILE LINK SCHEMA
|
||||
/////////////////////////////////////////
|
||||
|
||||
export const FileLinkSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
fileId: z.string(),
|
||||
relationId: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
})
|
||||
|
||||
export type FileLink = z.infer<typeof FileLinkSchema>
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
{
|
||||
"name": "@repo/db",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.cjs",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.cjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev --skip-generate",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
@@ -10,14 +33,14 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.11.1",
|
||||
"@repo/exceptions": "*",
|
||||
"prisma-json-types-generator": "^3.5.1",
|
||||
"zod-prisma-types": "^3.2.4"
|
||||
"prisma-json-types-generator": "^3.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@types/node": "^24.0.10",
|
||||
"prisma": "^6.11.1"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
"prisma": "^6.11.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ model TelegramUser {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
payments Payment[]
|
||||
payments Payment[]
|
||||
subscription Subscription?
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ model Payment {
|
||||
id String @id @default(cuid())
|
||||
telegramUserId String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currency String
|
||||
currency String
|
||||
status PaymentStatus @default(PENDING)
|
||||
provider String
|
||||
providerId String?
|
||||
@@ -78,10 +78,10 @@ model Plan {
|
||||
code String @unique()
|
||||
name String
|
||||
description String?
|
||||
durationDays Int
|
||||
durationDays Int
|
||||
dailyGenerations Int
|
||||
price Int
|
||||
currency String
|
||||
currency String
|
||||
|
||||
subscriptions Subscription[]
|
||||
|
||||
@@ -89,20 +89,44 @@ model Plan {
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
telegramUserId String @unique
|
||||
planId String
|
||||
id String @id @default(uuid())
|
||||
telegramUserId String @unique
|
||||
planId String
|
||||
|
||||
startedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
canceledAt DateTime?
|
||||
startedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
canceledAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
telegramUser TelegramUser @relation(fields: [telegramUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([telegramUserId])
|
||||
@@index([planId])
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(uuid())
|
||||
filename String
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
path String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
links FileLink[]
|
||||
}
|
||||
|
||||
model FileLink {
|
||||
id String @id @default(uuid())
|
||||
fileId String
|
||||
relationId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Prisma, PrismaClient as PrismaClientBase } from '../generated/prisma';
|
||||
import { Prisma, PrismaClient as PrismaClientBase } from "../generated/prisma";
|
||||
|
||||
export const prisma = new PrismaClientBase().$extends({
|
||||
model: {
|
||||
$allModels: {
|
||||
async exists<T>(
|
||||
this: T,
|
||||
where?: Prisma.Args<T, 'findFirst'>['where']
|
||||
where?: Prisma.Args<T, "findFirst">["where"],
|
||||
): Promise<boolean> {
|
||||
const context = Prisma.getExtensionContext(this);
|
||||
const result = await (context as any).findFirst({ where });
|
||||
@@ -18,5 +18,5 @@ export const prisma = new PrismaClientBase().$extends({
|
||||
export type PrismaClient = typeof prisma;
|
||||
|
||||
export type PrismaClientArg =
|
||||
| Parameters<Parameters<PrismaClient['$transaction']>[0]>[0]
|
||||
| Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0]
|
||||
| PrismaClient;
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
export { prisma, type PrismaClientArg, type PrismaClient } from './client';
|
||||
export * from '../generated/prisma';
|
||||
export {
|
||||
PaymentSchema,
|
||||
TelegramUserSchema,
|
||||
TelegramSessionSchema,
|
||||
} from '../generated/zod';
|
||||
|
||||
16
packages/database/tsconfig.json
Normal file
16
packages/database/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": true,
|
||||
"allowJs": false,
|
||||
"noImplicitAny": false,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"exactOptionalPropertyTypes": false
|
||||
},
|
||||
"include": ["src/**/*", "tsup.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "generated/**/*"]
|
||||
}
|
||||
16
packages/database/tsup.config.ts
Normal file
16
packages/database/tsup.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs"],
|
||||
dts: false,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["@prisma/client"],
|
||||
treeshake: false,
|
||||
minify: false,
|
||||
target: "node18",
|
||||
platform: "node",
|
||||
bundle: true,
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { AppError, DatabaseError, ErrorCode, ValidationError } from './types';
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
AppError,
|
||||
DatabaseError,
|
||||
ErrorCode,
|
||||
ValidationError,
|
||||
ZodValidationError,
|
||||
} from "./types";
|
||||
|
||||
export const createAppError = <T>(
|
||||
code: T,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
cause?: Error
|
||||
cause?: Error,
|
||||
): AppError<T> => ({
|
||||
code,
|
||||
message,
|
||||
@@ -15,7 +22,7 @@ export const createAppError = <T>(
|
||||
export const createValidationError = (
|
||||
field: string,
|
||||
value: unknown,
|
||||
message: string
|
||||
message: string,
|
||||
): ValidationError => ({
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message,
|
||||
@@ -25,11 +32,34 @@ export const createValidationError = (
|
||||
|
||||
export const databaseError = (
|
||||
operation: string,
|
||||
cause?: Error
|
||||
cause?: Error,
|
||||
): DatabaseError =>
|
||||
createAppError(
|
||||
ErrorCode.DATABASE_ERROR,
|
||||
`Database error during ${operation}`,
|
||||
undefined,
|
||||
cause
|
||||
cause,
|
||||
);
|
||||
|
||||
export const createZodValidationError = (
|
||||
zodError: ZodError,
|
||||
context?: string,
|
||||
): ZodValidationError => {
|
||||
const issues = zodError.issues.map((issue) => ({
|
||||
path: issue.path as ReadonlyArray<string | number>,
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}));
|
||||
|
||||
const contextMessage = context ? ` in ${context}` : "";
|
||||
const message = `Validation failed${contextMessage}: ${zodError.issues
|
||||
.map((issue) => `${issue.path.join(".")} - ${issue.message}`)
|
||||
.join(", ")}`;
|
||||
|
||||
return {
|
||||
code: ErrorCode.ZOD_VALIDATION_ERROR,
|
||||
message,
|
||||
zodError,
|
||||
issues,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './constructors';
|
||||
export * from './utils';
|
||||
export * from "./types";
|
||||
export * from "./constructors";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export enum ErrorCode {
|
||||
// System errors
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||||
DATABASE_ERROR = "DATABASE_ERROR",
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
ZOD_VALIDATION_ERROR = "ZOD_VALIDATION_ERROR",
|
||||
}
|
||||
|
||||
export type AppError<T> = {
|
||||
@@ -19,3 +22,14 @@ export type ValidationError = AppError<ErrorCode.VALIDATION_ERROR> & {
|
||||
};
|
||||
|
||||
export type DatabaseError = AppError<ErrorCode.DATABASE_ERROR>;
|
||||
|
||||
export interface ZodValidationError
|
||||
extends AppError<ErrorCode.ZOD_VALIDATION_ERROR> {
|
||||
readonly code: ErrorCode.ZOD_VALIDATION_ERROR;
|
||||
readonly zodError: ZodError;
|
||||
readonly issues: ReadonlyArray<{
|
||||
readonly path: ReadonlyArray<string | number>;
|
||||
readonly message: string;
|
||||
readonly code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ResultAsync, err, ok } from 'neverthrow';
|
||||
import { AppError } from '../types';
|
||||
import { ResultAsync, err, ok } from "neverthrow";
|
||||
import { AppError } from "../types";
|
||||
|
||||
/**
|
||||
* Wraps a database operation in a Result type to handle errors gracefully
|
||||
@@ -9,7 +9,7 @@ import { AppError } from '../types';
|
||||
*/
|
||||
export const wrapDbOperation = <T, E extends AppError<unknown>>(
|
||||
operation: () => Promise<T>,
|
||||
errorFactory: (error: unknown) => E
|
||||
errorFactory: (error: unknown) => E,
|
||||
): ResultAsync<T, E> => {
|
||||
return ResultAsync.fromPromise(operation(), errorFactory);
|
||||
};
|
||||
@@ -17,9 +17,9 @@ export const wrapDbOperation = <T, E extends AppError<unknown>>(
|
||||
export const wrapDbOperationWithNull = <T, E extends AppError<unknown>>(
|
||||
operation: () => Promise<T | null>,
|
||||
notFoundError: E,
|
||||
dbErrorFactory: (error: unknown) => E
|
||||
dbErrorFactory: (error: unknown) => E,
|
||||
): ResultAsync<T, E> => {
|
||||
return ResultAsync.fromPromise(operation(), (error) =>
|
||||
dbErrorFactory(error)
|
||||
dbErrorFactory(error),
|
||||
).andThen((result) => (result === null ? err(notFoundError) : ok(result)));
|
||||
};
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './database';
|
||||
export * from './validation';
|
||||
export * from "./database";
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { err, ok, Result } from 'neverthrow';
|
||||
import { output, ZodError, ZodObject } from 'zod';
|
||||
|
||||
export const safeParse =
|
||||
<T extends ZodObject>(schema: T) =>
|
||||
<D>(data: D): Result<output<T>, ZodError<output<T>>> => {
|
||||
const parseResult = schema.safeParse(data);
|
||||
|
||||
return parseResult.success ? ok(parseResult.data) : err(parseResult.error);
|
||||
};
|
||||
@@ -1,14 +1,35 @@
|
||||
{
|
||||
"name": "@repo/logger",
|
||||
"scripts": {
|
||||
"dev": "tsc --watch",
|
||||
"build": "tsc"
|
||||
},
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint src/",
|
||||
"check-types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"typescript": "latest"
|
||||
"typescript": "^5.8.3",
|
||||
"tsup": "^8.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './logger'
|
||||
export * from "./logger";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src/**/*", "tsup.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
packages/logger/tsup.config.ts
Normal file
13
packages/logger/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -1,19 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"incremental": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"preserveWatchOutput": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
}
|
||||
"strict": false,
|
||||
"target": "ES2022",
|
||||
"noImplicitReturns": false,
|
||||
"noImplicitAny": false,
|
||||
"noErrorTruncation": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
11
packages/typescript-config/library.json
Normal file
11
packages/typescript-config/library.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"incremental": false,
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||
}
|
||||
10
packages/typescript-config/node.json
Normal file
10
packages/typescript-config/node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2022"],
|
||||
"noEmit": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
"name": "@repo/typescript-config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"files": ["*.json"],
|
||||
"exports": {
|
||||
"./base.json": "./base.json",
|
||||
"./library.json": "./library.json",
|
||||
"./node.json": "./node.json"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user