Compare commits

..

10 Commits

Author SHA1 Message Date
Yuu Ottosoka
636f0e7f23 refactor(payment): improve input validation and use asyncAndThen
Update validateInput to return Result type and use asyncAndThen for chaining async operations
2025-07-21 12:12:36 +03:00
Yuu Ottosoka
d59f0dd794 chore: remove generated zod schema file as it's no longer needed 2025-07-20 23:16:44 +03:00
Yuu Ottosoka
0ceba57012 feat(bot): implement conversation flow for start command
add conversation handler for start command with name input and welcome message
update locales for new conversation flow
add @grammyjs/conversations dependency
2025-07-12 17:15:32 +03:00
Yuu Ottosoka
df8bfa049f feat(bot): add i18n support with grammy-i18n package
Add internationalization support to the bot using grammy-i18n package.
Includes English and Russian locale files and updates to session handling.
Disabled db:generate in Dockerfile as it's not needed during build.
2025-07-12 16:37:09 +03:00
Yuu Ottosoka
701e9c0ef8 build(docker): add db:generate step before building
Ensure Prisma client and schemas are generated before building the application to prevent runtime errors
2025-07-12 16:17:39 +03:00
Yuu Ottosoka
8d6fc0c5e9 refactor(database): remove zod generator from prisma schema
The zod generator configuration was removed as it's no longer needed in the project, simplifying the schema file.
2025-07-12 16:16:23 +03:00
Yuu Ottosoka
f9ecac94f2 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
2025-07-12 16:01:57 +03:00
Yuu Ottosoka
c0c04bbdba feat(bot): add error handling middleware for telegram bot
Add onError middleware to handle and log errors gracefully, providing user feedback when errors occur
2025-07-11 14:03:03 +03:00
Yuu Ottosoka
084b100589 feat: implement payment service with error handling and validation
Add new payment service with repository, error handling, and validation
Add exceptions package with database and validation utilities
Update database package with new schema and zod types
Add turbo start script for development
2025-07-11 13:54:55 +03:00
Yuu Ottosoka
49f0385d00 feat(bot): add session management and user tracking
- Implement Prisma storage adapter for Telegram session management
- Add middleware to upsert Telegram user data
- Extend TelegramUser model with additional fields
- Add db:push command to turbo.json and package.json
- Extend Prisma client with exists helper method
2025-07-09 20:36:07 +03:00
74 changed files with 4078 additions and 683 deletions

View File

@@ -1,126 +1,126 @@
name: release-tag
# name: release-tag
on:
push:
branches:
- main
# on:
# push:
# branches:
# - main
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
bot: ${{ steps.filter.outputs.bot }}
api: ${{ steps.filter.outputs.api }}
steps:
- name: Checkout code
uses: actions/checkout@v4
# jobs:
# detect-changes:
# runs-on: ubuntu-latest
# outputs:
# bot: ${{ steps.filter.outputs.bot }}
# api: ${{ steps.filter.outputs.api }}
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
- name: Check for changes
uses: dorny/paths-filter@v2
id: filter
with:
filters: |
bot:
- 'apps/bot/**'
- 'packages/**'
api:
- 'apps/api/**'
- 'packages/**'
# - name: Check for changes
# uses: dorny/paths-filter@v2
# id: filter
# with:
# filters: |
# bot:
# - 'apps/bot/**'
# - 'packages/**'
# api:
# - 'apps/api/**'
# - 'packages/**'
build-and-deploy-bot:
needs: detect-changes
if: needs.detect-changes.outputs.bot == 'true'
env:
DOCKER_LATEST: latest
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
# build-and-deploy-bot:
# needs: detect-changes
# if: needs.detect-changes.outputs.bot == 'true'
# env:
# DOCKER_LATEST: latest
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v1
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
# - name: Get Meta
# id: meta
# run: |
# echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
# echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: Log in to Docker registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Log in to Docker registry
# uses: docker/login-action@v3
# with:
# registry: ${{ vars.DOCKER_REGISTRY }}
# username: ${{ secrets.DOCKER_USERNAME }}
# password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push bot
uses: docker/build-push-action@v5
with:
context: .
file: ./apps/bot/Dockerfile
push: true
tags: |
${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-bot:${{ steps.meta.outputs.REPO_VERSION }}
${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-bot:${{ env.DOCKER_LATEST }}
# - name: Build and push bot
# uses: docker/build-push-action@v5
# with:
# context: .
# file: ./apps/bot/Dockerfile
# push: true
# tags: |
# ${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-bot:${{ steps.meta.outputs.REPO_VERSION }}
# ${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-bot:${{ env.DOCKER_LATEST }}
- name: Trigger Dokploy Bot Deployment
run: |
curl -X 'POST' \
'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
-H 'accept: application/json' \
-H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
-H 'Content-Type: application/json' \
-d '{
"json":{
"applicationId": "${{ vars.DOKPLOY_BOT_APPLICATION_ID }}"
}
}'
# - name: Trigger Dokploy Bot Deployment
# run: |
# curl -X 'POST' \
# 'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
# -H 'accept: application/json' \
# -H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
# -H 'Content-Type: application/json' \
# -d '{
# "json":{
# "applicationId": "${{ vars.DOKPLOY_BOT_APPLICATION_ID }}"
# }
# }'
build-and-deploy-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
env:
DOCKER_LATEST: latest
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
# build-and-deploy-api:
# needs: detect-changes
# if: needs.detect-changes.outputs.api == 'true'
# env:
# DOCKER_LATEST: latest
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v1
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
# - name: Get Meta
# id: meta
# run: |
# echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
# echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: Log in to Docker registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Log in to Docker registry
# uses: docker/login-action@v3
# with:
# registry: ${{ vars.DOCKER_REGISTRY }}
# username: ${{ secrets.DOCKER_USERNAME }}
# password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push api
uses: docker/build-push-action@v5
with:
context: .
file: ./apps/api/Dockerfile
push: true
tags: |
${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-api:${{ steps.meta.outputs.REPO_VERSION }}
${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-api:${{ env.DOCKER_LATEST }}
# - name: Build and push api
# uses: docker/build-push-action@v5
# with:
# context: .
# file: ./apps/api/Dockerfile
# push: true
# tags: |
# ${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-api:${{ steps.meta.outputs.REPO_VERSION }}
# ${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-api:${{ env.DOCKER_LATEST }}
- name: Trigger Dokploy API Deployment
run: |
curl -X 'POST' \
'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
-H 'accept: application/json' \
-H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
-H 'Content-Type: application/json' \
-d '{
"json":{
"applicationId": "${{ vars.DOKPLOY_API_APPLICATION_ID }}"
}
}'
# - name: Trigger Dokploy API Deployment
# run: |
# curl -X 'POST' \
# 'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
# -H 'accept: application/json' \
# -H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
# -H 'Content-Type: application/json' \
# -d '{
# "json":{
# "applicationId": "${{ vars.DOKPLOY_API_APPLICATION_ID }}"
# }
# }'

View File

@@ -1,2 +1,3 @@
PORT=3001
NODE_ENV=development
DATABASE_URL=copy from packages/database/.env

View File

@@ -35,6 +35,10 @@ RUN npm ci
# Build the project
COPY --from=pruner /app/out/full/ .
# Generate Prisma client and schemas before building
RUN npm run db:generate --workspace=@repo/db
RUN turbo run build --filter=api...
FROM base AS runner

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",
@@ -15,6 +18,7 @@
"@repo/config": "*",
"@repo/logger": "*",
"@repo/typescript-config": "*",
"@repo/db": "*",
"dotenv": "^17.1.0",
"hono": "^4.8.4",
"hono-openapi": "^0.4.8",
@@ -22,7 +26,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

@@ -35,6 +35,10 @@ RUN npm ci
# Build the project
COPY --from=pruner /app/out/full/ .
# Generate Prisma client and schemas before building
RUN npm run db:generate --workspace=@repo/db
RUN turbo run build --filter=bot...
FROM base AS runner

2
apps/bot/locales/en.ftl Normal file
View File

@@ -0,0 +1,2 @@
start-ask-name = Hello, what is your name?
start-welcome = Welcome, { $user }!

2
apps/bot/locales/ru.ftl Normal file
View File

@@ -0,0 +1,2 @@
start-ask-name = Привет, как тебя зовут
start-welcome = Привет, { $user }!

View File

@@ -2,24 +2,31 @@
"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": {
"@grammyjs/conversations": "^2.1.0",
"@grammyjs/i18n": "^1.1.2",
"@hono/node-server": "^1.15.0",
"@repo/config": "*",
"@repo/db": "*",
"@repo/typescript-config": "*",
"@repo/logger": "*",
"@repo/typescript-config": "*",
"dotenv": "^17.1.0",
"grammy": "^1.37.0",
"hono": "^4.8.4",
"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

@@ -0,0 +1,3 @@
export enum ConversationSlug {
START = 'start',
}

View File

@@ -0,0 +1,2 @@
export * from './consts';
export * from './start.conversation';

View File

@@ -0,0 +1,23 @@
import { Conversation, createConversation } from '@grammyjs/conversations';
import { BotContext } from '../types';
import { ConversationSlug } from './consts';
async function enterConversation(conversation: Conversation, ctx: BotContext) {
const askNameText = await conversation.external((ctx: BotContext) =>
ctx.t('start-ask-name')
);
await ctx.reply(askNameText);
const { message } = await conversation.waitFor('message:text');
const welcomeText = await conversation.external((ctx: BotContext) =>
ctx.t('start-welcome', { user: message.text })
);
await ctx.reply(welcomeText);
}
export const startConversation = createConversation(
enterConversation,
ConversationSlug.START
);

View File

@@ -1,12 +1,41 @@
import { serve } from '@hono/node-server';
import { config } from './config';
import { Bot, webhookCallback } from 'grammy';
import { Bot, session, webhookCallback } from 'grammy';
import { Hono } from 'hono';
import { logger } from '@repo/logger';
import { PrismaStorageAdapter } from './session-storage';
import { BotContext } from './types';
import { upsertUser } from './middlewares/upsert-user';
import { onError } from './middlewares/on-error';
import { I18n } from '@grammyjs/i18n';
import { conversations } from '@grammyjs/conversations';
import { ConversationSlug, startConversation } from './conversations';
const bot = new Bot(config.BOT_TOKEN);
const bot = new Bot<BotContext>(config.BOT_TOKEN);
const storage = new PrismaStorageAdapter();
bot.command('start', (ctx) => ctx.reply('Привет, мир'));
bot.use(onError);
bot.use(session({ storage, initial: () => ({}) }));
bot.use(upsertUser);
const i18n = new I18n<BotContext>({
defaultLocale: 'ru',
useSession: true, // whether to store user language in session
directory: 'locales', // Load all translation files from locales/.
fluentBundleOptions: { useIsolating: false },
localeNegotiator: async (ctx) => {
const session = await ctx.session;
return session.__language_code ?? ctx.from?.language_code ?? 'ru';
},
});
bot.use(i18n);
bot.use(conversations());
bot.use(startConversation);
bot.command('start', async (ctx) => {
await ctx.conversation.enter(ConversationSlug.START);
});
if (config.MODE === 'webhook') {
logger.info('Starting in webhook mode...');
@@ -25,9 +54,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

@@ -0,0 +1,16 @@
import { MiddlewareFn } from 'grammy';
import { BotContext } from '../types';
import { logger } from '@repo/logger';
const errorReply =
'Извините, произошла ошибка при обработке вашего запроса. Пожалуйста, попробуйте позже.';
const replyErrorLog = 'Не удалось отправить сообщение об ошибке пользователю';
export const onError: MiddlewareFn<BotContext> = async (ctx, next) => {
return await next().catch(async (e) => {
logger.error('Ошибка в onError', e);
await ctx.reply(errorReply).catch((err) => {
logger.error(replyErrorLog, err);
});
});
};

View File

@@ -0,0 +1,30 @@
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();
if (!telegramId) {
return next();
}
const payload: Prisma.TelegramUserUncheckedCreateInput = {
id: telegramId,
username: ctx.from?.username,
firstName: ctx.from?.first_name,
lastName: ctx.from?.last_name,
languageCode: ctx.from?.language_code,
isPremium: ctx.from?.is_premium,
};
await prisma.telegramUser.upsert({
where: {
id: telegramId,
},
create: payload,
update: payload,
});
return next();
};

View File

@@ -0,0 +1,36 @@
import { prisma } from '@repo/db';
import { StorageAdapter } from 'grammy';
export class PrismaStorageAdapter<T extends object>
implements StorageAdapter<T>
{
async read(key: string): Promise<T | undefined> {
return prisma.telegramSession
.findUnique({
where: {
id: key,
},
})
.then((session) => (session?.data as T) || undefined);
}
async write(key: string, data: T): Promise<void> {
await prisma.telegramSession.upsert({
where: {
id: key,
},
create: {
id: key,
data,
},
update: {
data,
},
});
}
async delete(key: string): Promise<void> {
await prisma.telegramSession.delete({ where: { id: key } });
}
async has(key: string): Promise<boolean> {
return prisma.telegramSession.exists({ id: key });
}
}

9
apps/bot/src/types.ts Normal file
View File

@@ -0,0 +1,9 @@
import { ConversationFlavor } from '@grammyjs/conversations';
import { I18nFlavor } from '@grammyjs/i18n';
import { Context, LazySessionFlavor } from 'grammy';
// config your session data
export type SessionData = { __language_code?: string };
export type BotContext = ConversationFlavor<
Context & LazySessionFlavor<SessionData> & I18nFlavor
>;

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

2659
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

@@ -1,19 +1,46 @@
{
"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"
"db:deploy": "prisma migrate deploy",
"db:push": "prisma db push"
},
"dependencies": {
"@prisma/client": "^6.11.1"
"@prisma/client": "^6.11.1",
"@repo/exceptions": "*",
"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

@@ -9,6 +9,10 @@ generator client {
output = "../generated/prisma"
}
generator json {
provider = "prisma-json-types-generator"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
@@ -22,9 +26,95 @@ model TelegramSession {
}
model TelegramUser {
id String @id
username String?
isBlocked Boolean @default(false)
id String @id
username String?
isBlocked Boolean @default(false)
firstName String?
lastName String?
languageCode String?
isPremium Boolean?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
subscription Subscription?
}
model Payment {
id String @id @default(cuid())
telegramUserId String
amount Decimal @db.Decimal(10, 2)
currency String
status PaymentStatus @default(PENDING)
provider String
providerId String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
telegramUser TelegramUser @relation(fields: [telegramUserId], references: [id])
}
enum PaymentStatus {
PENDING
COMPLETED
FAILED
REFUNDED
}
model Plan {
id String @id @default(uuid())
code String @unique()
name String
description String?
durationDays Int
dailyGenerations Int
price Int
currency String
subscriptions Subscription[]
@@index([code])
}
model Subscription {
id String @id @default(uuid())
telegramUserId String @unique
planId String
startedAt DateTime @default(now())
expiresAt DateTime
canceledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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,8 +1,22 @@
import { PrismaClient } from "../generated/prisma";
import { Prisma, PrismaClient as PrismaClientBase } from "../generated/prisma";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = new PrismaClientBase().$extends({
model: {
$allModels: {
async exists<T>(
this: T,
where?: Prisma.Args<T, "findFirst">["where"],
): Promise<boolean> {
const context = Prisma.getExtensionContext(this);
const result = await (context as any).findFirst({ where });
return result !== null;
},
},
},
});
export const prisma =
globalForPrisma.prisma || new PrismaClient()
export type PrismaClient = typeof prisma;
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export type PrismaClientArg =
| Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0]
| PrismaClient;

View File

@@ -1,2 +1,2 @@
export { prisma } from "./client"; // exports instance of prisma
export * from "../generated/prisma"; // exports generated types from prisma
export { prisma, type PrismaClientArg, type PrismaClient } from './client';
export * from '../generated/prisma';

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

@@ -0,0 +1,17 @@
{
"name": "@repo/exceptions",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"neverthrow": "^8.2.0",
"zod": "^4.0.2"
},
"devDependencies": {
"@repo/typescript-config": "*",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,65 @@
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,
): AppError<T> => ({
code,
message,
details,
cause,
});
export const createValidationError = (
field: string,
value: unknown,
message: string,
): ValidationError => ({
code: ErrorCode.VALIDATION_ERROR,
message,
field,
value,
});
export const databaseError = (
operation: string,
cause?: Error,
): DatabaseError =>
createAppError(
ErrorCode.DATABASE_ERROR,
`Database error during ${operation}`,
undefined,
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

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

View File

@@ -0,0 +1,35 @@
import { ZodError } from "zod";
export enum ErrorCode {
// System errors
DATABASE_ERROR = "DATABASE_ERROR",
VALIDATION_ERROR = "VALIDATION_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
ZOD_VALIDATION_ERROR = "ZOD_VALIDATION_ERROR",
}
export type AppError<T> = {
readonly code: T;
readonly message: string;
readonly details?: Record<string, unknown>;
readonly cause?: Error;
};
export type ValidationError = AppError<ErrorCode.VALIDATION_ERROR> & {
readonly code: ErrorCode.VALIDATION_ERROR;
readonly field: string;
readonly value: unknown;
};
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

@@ -0,0 +1,25 @@
import { ResultAsync, err, ok } from "neverthrow";
import { AppError } from "../types";
/**
* Wraps a database operation in a Result type to handle errors gracefully
* @param operation - The database operation to execute
* @param errorFactory - Function to create a custom error if the operation fails
* @returns Result containing the operation result or error
*/
export const wrapDbOperation = <T, E extends AppError<unknown>>(
operation: () => Promise<T>,
errorFactory: (error: unknown) => E,
): ResultAsync<T, E> => {
return ResultAsync.fromPromise(operation(), errorFactory);
};
export const wrapDbOperationWithNull = <T, E extends AppError<unknown>>(
operation: () => Promise<T | null>,
notFoundError: E,
dbErrorFactory: (error: unknown) => E,
): ResultAsync<T, E> => {
return ResultAsync.fromPromise(operation(), (error) =>
dbErrorFactory(error),
).andThen((result) => (result === null ? err(notFoundError) : ok(result)));
};

View File

@@ -0,0 +1 @@
export * from "./database";

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

@@ -0,0 +1,39 @@
{
"name": "@repo/services",
"version": "0.0.0",
"type": "module",
"private": true,
"files": ["dist"],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./src/index.ts",
"exports": {
".": {
"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": "*",
"neverthrow": "^8.2.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@repo/typescript-config": "*",
"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

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

View File

@@ -0,0 +1,20 @@
import { AppError, createAppError } from "@repo/exceptions";
export enum PaymentErrorCode {
PAYMENT_NOT_FOUND = "PAYMENT_NOT_FOUND",
PAYMENT_ALREADY_PROCESSED = "PAYMENT_ALREADY_PROCESSED",
}
export type PaymentError = AppError<PaymentErrorCode>;
export const paymentNotFound = (paymentId: string): PaymentError =>
createAppError<PaymentErrorCode>(
PaymentErrorCode.PAYMENT_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`,
) as PaymentError;

View File

@@ -0,0 +1,4 @@
export * from "./errors";
export * from "./types";
export * from "./repository";
export * from "./service";

View File

@@ -0,0 +1,61 @@
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";
export const PaymentRepository = {
create(
client: PrismaClientArg,
data: Prisma.PaymentUncheckedCreateInput,
): ResultAsync<Payment, DatabaseError> {
return wrapDbOperation(
() => client.payment.create({ data }).then((payment: Payment) => payment),
(e) => databaseError("create-error", e as Error),
);
},
findById(
client: PrismaClientArg,
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),
);
},
updateStatus(
client: PrismaClientArg,
id: string,
status: PaymentStatus,
): ResultAsync<Payment, DatabaseError> {
return wrapDbOperation(
() =>
client.payment.update({
where: { id },
data: { status },
}),
() => databaseError(id),
);
},
findByUserId(
client: PrismaClientArg,
telegramUserId: string,
): ResultAsync<Payment[], DatabaseError> {
return wrapDbOperation(
() =>
client.payment.findMany({
where: { telegramUserId },
orderBy: { createdAt: "desc" },
}),
() => databaseError(`user-${telegramUserId}`),
);
},
};

View File

@@ -0,0 +1,43 @@
import { ok, err, Result } from 'neverthrow';
import { CreatePaymentInput, CreatePaymentInputSchema } from './types';
import { paymentAlreadyProcessed } from './errors';
import { PaymentRepository } from './repository';
import { Payment, PaymentStatus, PrismaClient } from '@repo/db';
import { createZodValidationError, ZodValidationError } from '@repo/exceptions';
const validateInput = (
data: unknown
): Result<CreatePaymentInput, ZodValidationError> => {
const parseResult = CreatePaymentInputSchema.safeParse(data);
return parseResult.success
? ok(parseResult.data)
: err(createZodValidationError(parseResult.error));
};
export const createPayment = (
client: PrismaClient,
input: CreatePaymentInput
) =>
validateInput(input).asyncAndThen((validatedInput) =>
PaymentRepository.create(client, validatedInput)
);
export const getPaymentById = (client: PrismaClient, id: string) =>
PaymentRepository.findById(client, id);
export const getUserPayments = (client: PrismaClient, telegramUserId: string) =>
PaymentRepository.findByUserId(client, telegramUserId);
export const validatePaymentForProcessing = (payment: Payment) =>
payment.status === PaymentStatus.PENDING
? ok(payment)
: err(paymentAlreadyProcessed(payment.id));
export const PaymentService = {
validateInput,
createPayment,
getPaymentById,
getUserPayments,
validatePaymentForProcessing,
};

View File

@@ -0,0 +1,50 @@
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",
}
export const CURRENCIES = Object.values(Currency);
export enum PaymentProvider {
YOOKASSA = "yookassa",
TELEGRAM = "telegram",
}
export const PAYMENT_PROVIDERS = Object.values(PaymentProvider);
export const CreatePaymentInputSchema = z.object({
telegramUserId: z.uuid(),
amount: z.number().positive(),
currency: z.enum(Currency),
provider: z.enum(PaymentProvider),
});
export type CreatePaymentInput = z.infer<typeof CreatePaymentInputSchema>;
export type PaymentProviderResponse = {
readonly paymentUrl: string;
readonly providerId: string;
};
export type IPaymentProvider = {
readonly createPayment: (
client: PrismaClient,
input: CreatePaymentInput,
) => ResultAsync<PaymentProviderResponse, PaymentError>;
readonly verifyPayment: (
client: PrismaClient,
payment: Payment,
) => ResultAsync<PaymentStatus, PaymentError>;
readonly refundPayment: (
client: PrismaClient,
providerId: string,
amount?: number,
) => ResultAsync<boolean, PaymentError>;
};

View File

@@ -0,0 +1,17 @@
{
"extends": "@repo/typescript-config/library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"skipLibCheck": true,
"declaration": false,
"declarationMap": false
},
"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,28 +1,50 @@
{
"$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,
"env": ["DATABASE_URL", "BOT_TOKEN", "NODE_ENV"]
},
"start": {
"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
},
"db:push": {
"cache": false
}
}
},
"globalEnv": ["NODE_ENV"],
"globalPassThroughEnv": ["NODE_ENV", "CI", "VERCEL", "npm_lifecycle_event"]
}