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,2 +1,3 @@
PORT=3001
NODE_ENV=development
DATABASE_URL=copy from packages/database/.env

View File

@@ -1,2 +1,3 @@
# `api`
Апи клиент

View File

@@ -2,10 +2,13 @@
"name": "api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "NODE_PATH=./src tsnd --respawn --transpile-only --exit-child src/index.ts",
"build": "tsc",
"start": "NODE_PATH=./dist node ./dist/index.js"
"dev": "tsx watch src/index.ts",
"build": "tsup",
"start": "node dist/index.js",
"check-types": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@hono/node-server": "^1.15.0",
@@ -22,7 +25,9 @@
"zod-openapi": "^4.2.4"
},
"devDependencies": {
"ts-node-dev": "^2.0.0",
"typescript": "latest"
"@types/node": "^22.10.2",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.8.3"
}
}

View File

@@ -1,10 +1,12 @@
import { createConfig } from '@repo/config';
import z from 'zod';
import 'dotenv/config';
import { createConfig } from "@repo/config";
import z from "zod";
import "dotenv/config";
export const config = createConfig({
schema: {
PORT: z.string().default('3001'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default("3001"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
},
});

View File

@@ -1,27 +1,27 @@
import { serve } from '@hono/node-server';
import { OpenAPIHono } from '@hono/zod-openapi';
import { swaggerUI } from '@hono/swagger-ui';
import { config } from './config';
import { logger } from '@repo/logger';
import { serve } from "@hono/node-server";
import { OpenAPIHono } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
import { config } from "./config";
import { logger } from "@repo/logger";
const app = new OpenAPIHono();
// OpenAPI documentation
app.doc('/doc', {
openapi: '3.0.0',
app.doc("/doc", {
openapi: "3.0.0",
info: {
version: '1.0.0',
title: 'API Documentation',
description: 'API built with Hono and Zod OpenAPI',
version: "1.0.0",
title: "API Documentation",
description: "API built with Hono and Zod OpenAPI",
},
});
// Swagger UI
app.get('/ui', swaggerUI({ url: '/doc' }));
app.get("/ui", swaggerUI({ url: "/doc" }));
// Health check
app.get('/health', (c) => {
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
app.get("/health", (c) => {
return c.json({ status: "ok", timestamp: new Date().toISOString() });
});
const port = parseInt(config.PORT);
@@ -29,7 +29,24 @@ const port = parseInt(config.PORT);
logger.info(`Starting API server on port ${port}`);
logger.info(`Swagger UI available at http://localhost:${port}/ui`);
serve({
const server = serve({
fetch: app.fetch,
port,
});
const tearDown = () => {
logger.info("Server shutting down...");
if (server) {
server.close(() => {
logger.info("Server closed");
process.exit(0);
});
} else {
process.exit(0);
}
};
process.on("SIGINT", tearDown);
process.on("SIGTERM", tearDown);
process.on("SIGUSR1", tearDown);
process.on("SIGUSR2", tearDown);

View File

@@ -1,9 +1,15 @@
{
"extends": "@repo/typescript-config/base.json",
"extends": "@repo/typescript-config/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": ".",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"**/*.test.*",
"**/*.spec.*"
]
}

File diff suppressed because one or more lines are too long

15
apps/api/tsup.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: false,
splitting: false,
sourcemap: true,
clean: true,
external: ["@repo/config", "@repo/logger", "@repo/typescript-config"],
treeshake: true,
minify: false,
target: "node18",
platform: "node",
});

View File

@@ -2,3 +2,4 @@ BOT_TOKEN="your-bot-token"
WEBHOOK_URL="your-webhook-url"
PORT=3000
MODE=polling or webhook
DATABASE_URL=copy from packages/database/.env

View File

@@ -2,10 +2,13 @@
"name": "bot",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "NODE_PATH=./src tsnd --respawn --transpile-only --exit-child src/index.ts",
"start": "NODE_PATH=./dist node ./dist/index.js",
"build": "tsc"
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "tsup",
"check-types": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@hono/node-server": "^1.15.0",
@@ -19,7 +22,9 @@
"zod": "^3.25.76"
},
"devDependencies": {
"ts-node-dev": "^2.0.0",
"@types/node": "^22.10.2",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.8.3"
}
}

View File

@@ -1,12 +1,12 @@
import { createConfig } from '@repo/config';
import z from 'zod';
import 'dotenv/config';
import { createConfig } from "@repo/config";
import z from "zod";
import "dotenv/config";
export const config = createConfig({
schema: {
BOT_TOKEN: z.string(),
WEBHOOK_URI: z.string(),
PORT: z.string().default('3000'),
MODE: z.enum(['polling', 'webhook']).default('polling'),
PORT: z.string().default("3000"),
MODE: z.enum(["polling", "webhook"]).default("polling"),
},
});

View File

@@ -37,9 +37,15 @@ if (config.MODE === 'webhook') {
);
} else {
logger.info('Starting in polling mode...');
bot.start({
onStart: () => {
logger.info('Bot started polling...');
},
});
const startBot = async () => {
bot.start({
drop_pending_updates: true,
onStart: () => {
logger.info('Bot started polling...');
},
});
};
startBot();
}

View File

@@ -7,7 +7,8 @@ const errorReply =
const replyErrorLog = 'Не удалось отправить сообщение об ошибке пользователю';
export const onError: MiddlewareFn<BotContext> = async (ctx, next) => {
return await next().catch(async () => {
return await next().catch(async (e) => {
logger.error('Ошибка в onError', e);
await ctx.reply(errorReply).catch((err) => {
logger.error(replyErrorLog, err);
});

View File

@@ -1,6 +1,6 @@
import { MiddlewareFn } from 'grammy';
import { BotContext } from '../types';
import { Prisma, prisma } from '@repo/db';
import { MiddlewareFn } from "grammy";
import { BotContext } from "../types";
import { Prisma, prisma } from "@repo/db";
export const upsertUser: MiddlewareFn<BotContext> = async (ctx, next) => {
const telegramId = ctx.from?.id.toString();

View File

@@ -1,5 +1,5 @@
import { prisma } from '@repo/db';
import { StorageAdapter } from 'grammy';
import { prisma } from "@repo/db";
import { StorageAdapter } from "grammy";
export class PrismaStorageAdapter<T extends object>
implements StorageAdapter<T>
@@ -14,7 +14,7 @@ export class PrismaStorageAdapter<T extends object>
.then((session) => (session?.data as T) || undefined);
}
async write(key: string, data: T): Promise<void> {
console.log('write', data);
console.log("write", data);
await prisma.telegramSession.upsert({
where: {
id: key,

View File

@@ -1,5 +1,5 @@
import { Context, LazySessionFlavor } from 'grammy';
import { Context, LazySessionFlavor } from "grammy";
// config your session data
export type SessionData = { };
export type SessionData = {};
export type BotContext = Context & LazySessionFlavor<SessionData>;

View File

@@ -1,9 +1,15 @@
{
"extends": "@repo/typescript-config/base.json",
"extends": "@repo/typescript-config/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": ".",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"**/*.test.*",
"**/*.spec.*"
]
}

20
apps/bot/tsup.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: false,
splitting: false,
sourcemap: true,
clean: true,
external: [
"@repo/config",
"@repo/db",
"@repo/logger",
"@repo/typescript-config",
],
treeshake: true,
minify: false,
target: "node18",
platform: "node",
});

2468
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,18 @@
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"start": "turbo run start",
"start:api": "turbo run start --filter=api",
"start:bot": "turbo run start --filter=bot",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"
"check-types": "turbo run check-types",
"clean": "turbo run clean",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.5.4",
"typescript": "5.8.3"
"typescript": "^5.8.3"
},
"engines": {
"node": ">=18"

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
export * from './create-config';
export * from './types';
export * from "./create-config";
export * from "./types";

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import { z } from "zod";
export type ConfigSchema = Record<string, z.ZodTypeAny>;

View File

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

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

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ model TelegramUser {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
payments Payment[]
subscription Subscription?
}
@@ -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)
}

View File

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

View File

@@ -1,7 +1,2 @@
export { prisma, type PrismaClientArg, type PrismaClient } from './client';
export * from '../generated/prisma';
export {
PaymentSchema,
TelegramUserSchema,
TelegramSessionSchema,
} from '../generated/zod';

View 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/**/*"]
}

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

View File

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

View File

@@ -1,3 +1,3 @@
export * from './types';
export * from './constructors';
export * from './utils';
export * from "./types";
export * from "./constructors";
export * from "./utils";

View File

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

View File

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

View File

@@ -1,2 +1 @@
export * from './database';
export * from './validation';
export * from "./database";

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -1,30 +1,42 @@
{
"$schema": "https://turborepo.com/schema.json",
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build", "^db:generate"],
"outputs": ["dist/**"],
"env": ["DATABASE_URL", "BOT_TOKEN"]
},
"check-types": {
"dependsOn": ["^check-types"]
"env": ["NODE_ENV"]
},
"dev": {
"dependsOn": ["^db:generate"],
"dependsOn": ["^build", "^db:generate"],
"persistent": true,
"cache": false
"cache": false,
"env": ["DATABASE_URL", "BOT_TOKEN", "NODE_ENV"]
},
"start": {
"dependsOn": ["^build"],
"dependsOn": ["build", "^db:generate"],
"persistent": true,
"cache": false,
"env": ["DATABASE_URL", "BOT_TOKEN", "NODE_ENV"],
"outputLogs": "new-only"
},
"check-types": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"dependsOn": ["^build"],
"outputs": []
},
"clean": {
"cache": false
},
"db:generate": {
"cache": false
"cache": false,
"outputs": ["generated/**"]
},
"db:migrate": {
"cache": false,
"persistent": true
"cache": false
},
"db:deploy": {
"cache": false
@@ -32,5 +44,7 @@
"db:push": {
"cache": false
}
}
},
"globalEnv": ["NODE_ENV"],
"globalPassThroughEnv": ["NODE_ENV", "CI", "VERCEL", "npm_lifecycle_event"]
}