Compare commits
10 Commits
84760349c0
...
636f0e7f23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636f0e7f23 | ||
|
|
d59f0dd794 | ||
|
|
0ceba57012 | ||
|
|
df8bfa049f | ||
|
|
701e9c0ef8 | ||
|
|
8d6fc0c5e9 | ||
|
|
f9ecac94f2 | ||
|
|
c0c04bbdba | ||
|
|
084b100589 | ||
|
|
49f0385d00 |
@@ -1,126 +1,126 @@
|
|||||||
name: release-tag
|
# name: release-tag
|
||||||
|
|
||||||
on:
|
# on:
|
||||||
push:
|
# push:
|
||||||
branches:
|
# branches:
|
||||||
- main
|
# - main
|
||||||
|
|
||||||
jobs:
|
# jobs:
|
||||||
detect-changes:
|
# detect-changes:
|
||||||
runs-on: ubuntu-latest
|
# runs-on: ubuntu-latest
|
||||||
outputs:
|
# outputs:
|
||||||
bot: ${{ steps.filter.outputs.bot }}
|
# bot: ${{ steps.filter.outputs.bot }}
|
||||||
api: ${{ steps.filter.outputs.api }}
|
# api: ${{ steps.filter.outputs.api }}
|
||||||
steps:
|
# steps:
|
||||||
- name: Checkout code
|
# - name: Checkout code
|
||||||
uses: actions/checkout@v4
|
# uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Check for changes
|
# - name: Check for changes
|
||||||
uses: dorny/paths-filter@v2
|
# uses: dorny/paths-filter@v2
|
||||||
id: filter
|
# id: filter
|
||||||
with:
|
# with:
|
||||||
filters: |
|
# filters: |
|
||||||
bot:
|
# bot:
|
||||||
- 'apps/bot/**'
|
# - 'apps/bot/**'
|
||||||
- 'packages/**'
|
# - 'packages/**'
|
||||||
api:
|
# api:
|
||||||
- 'apps/api/**'
|
# - 'apps/api/**'
|
||||||
- 'packages/**'
|
# - 'packages/**'
|
||||||
|
|
||||||
build-and-deploy-bot:
|
# build-and-deploy-bot:
|
||||||
needs: detect-changes
|
# needs: detect-changes
|
||||||
if: needs.detect-changes.outputs.bot == 'true'
|
# if: needs.detect-changes.outputs.bot == 'true'
|
||||||
env:
|
# env:
|
||||||
DOCKER_LATEST: latest
|
# DOCKER_LATEST: latest
|
||||||
runs-on: ubuntu-latest
|
# runs-on: ubuntu-latest
|
||||||
steps:
|
# steps:
|
||||||
- name: Checkout code
|
# - name: Checkout code
|
||||||
uses: actions/checkout@v2
|
# uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
# - name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
# uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
- name: Get Meta
|
# - name: Get Meta
|
||||||
id: meta
|
# id: meta
|
||||||
run: |
|
# run: |
|
||||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
# echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
|
# echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Log in to Docker registry
|
# - name: Log in to Docker registry
|
||||||
uses: docker/login-action@v3
|
# uses: docker/login-action@v3
|
||||||
with:
|
# with:
|
||||||
registry: ${{ vars.DOCKER_REGISTRY }}
|
# registry: ${{ vars.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
# username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
# password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push bot
|
# - name: Build and push bot
|
||||||
uses: docker/build-push-action@v5
|
# uses: docker/build-push-action@v5
|
||||||
with:
|
# with:
|
||||||
context: .
|
# context: .
|
||||||
file: ./apps/bot/Dockerfile
|
# file: ./apps/bot/Dockerfile
|
||||||
push: true
|
# push: true
|
||||||
tags: |
|
# tags: |
|
||||||
${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-bot:${{ steps.meta.outputs.REPO_VERSION }}
|
# ${{ 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 }}
|
# ${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-bot:${{ env.DOCKER_LATEST }}
|
||||||
|
|
||||||
- name: Trigger Dokploy Bot Deployment
|
# - name: Trigger Dokploy Bot Deployment
|
||||||
run: |
|
# run: |
|
||||||
curl -X 'POST' \
|
# curl -X 'POST' \
|
||||||
'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
|
# 'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
|
||||||
-H 'accept: application/json' \
|
# -H 'accept: application/json' \
|
||||||
-H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
|
# -H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
|
||||||
-H 'Content-Type: application/json' \
|
# -H 'Content-Type: application/json' \
|
||||||
-d '{
|
# -d '{
|
||||||
"json":{
|
# "json":{
|
||||||
"applicationId": "${{ vars.DOKPLOY_BOT_APPLICATION_ID }}"
|
# "applicationId": "${{ vars.DOKPLOY_BOT_APPLICATION_ID }}"
|
||||||
}
|
# }
|
||||||
}'
|
# }'
|
||||||
|
|
||||||
build-and-deploy-api:
|
# build-and-deploy-api:
|
||||||
needs: detect-changes
|
# needs: detect-changes
|
||||||
if: needs.detect-changes.outputs.api == 'true'
|
# if: needs.detect-changes.outputs.api == 'true'
|
||||||
env:
|
# env:
|
||||||
DOCKER_LATEST: latest
|
# DOCKER_LATEST: latest
|
||||||
runs-on: ubuntu-latest
|
# runs-on: ubuntu-latest
|
||||||
steps:
|
# steps:
|
||||||
- name: Checkout code
|
# - name: Checkout code
|
||||||
uses: actions/checkout@v2
|
# uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
# - name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
# uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
- name: Get Meta
|
# - name: Get Meta
|
||||||
id: meta
|
# id: meta
|
||||||
run: |
|
# run: |
|
||||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
# echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
|
# echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Log in to Docker registry
|
# - name: Log in to Docker registry
|
||||||
uses: docker/login-action@v3
|
# uses: docker/login-action@v3
|
||||||
with:
|
# with:
|
||||||
registry: ${{ vars.DOCKER_REGISTRY }}
|
# registry: ${{ vars.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
# username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
# password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push api
|
# - name: Build and push api
|
||||||
uses: docker/build-push-action@v5
|
# uses: docker/build-push-action@v5
|
||||||
with:
|
# with:
|
||||||
context: .
|
# context: .
|
||||||
file: ./apps/api/Dockerfile
|
# file: ./apps/api/Dockerfile
|
||||||
push: true
|
# push: true
|
||||||
tags: |
|
# tags: |
|
||||||
${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-api:${{ steps.meta.outputs.REPO_VERSION }}
|
# ${{ 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 }}
|
# ${{ vars.DOCKER_REGISTRY }}/${{ steps.meta.outputs.REPO_NAME }}-api:${{ env.DOCKER_LATEST }}
|
||||||
|
|
||||||
- name: Trigger Dokploy API Deployment
|
# - name: Trigger Dokploy API Deployment
|
||||||
run: |
|
# run: |
|
||||||
curl -X 'POST' \
|
# curl -X 'POST' \
|
||||||
'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
|
# 'https://${{ vars.DOKPLOY_DOMAIN }}/api/trpc/application.deploy' \
|
||||||
-H 'accept: application/json' \
|
# -H 'accept: application/json' \
|
||||||
-H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
|
# -H 'x-api-key:${{ secrets.DOKPLOY_API_KEY }}' \
|
||||||
-H 'Content-Type: application/json' \
|
# -H 'Content-Type: application/json' \
|
||||||
-d '{
|
# -d '{
|
||||||
"json":{
|
# "json":{
|
||||||
"applicationId": "${{ vars.DOKPLOY_API_APPLICATION_ID }}"
|
# "applicationId": "${{ vars.DOKPLOY_API_APPLICATION_ID }}"
|
||||||
}
|
# }
|
||||||
}'
|
# }'
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
DATABASE_URL=copy from packages/database/.env
|
||||||
@@ -35,6 +35,10 @@ RUN npm ci
|
|||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
COPY --from=pruner /app/out/full/ .
|
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...
|
RUN turbo run build --filter=api...
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
# `api`
|
# `api`
|
||||||
|
|
||||||
Апи клиент
|
Апи клиент
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
"name": "api",
|
"name": "api",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_PATH=./src tsnd --respawn --transpile-only --exit-child src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsup",
|
||||||
"start": "NODE_PATH=./dist node ./dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"check-types": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.15.0",
|
"@hono/node-server": "^1.15.0",
|
||||||
@@ -15,6 +18,7 @@
|
|||||||
"@repo/config": "*",
|
"@repo/config": "*",
|
||||||
"@repo/logger": "*",
|
"@repo/logger": "*",
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
|
"@repo/db": "*",
|
||||||
"dotenv": "^17.1.0",
|
"dotenv": "^17.1.0",
|
||||||
"hono": "^4.8.4",
|
"hono": "^4.8.4",
|
||||||
"hono-openapi": "^0.4.8",
|
"hono-openapi": "^0.4.8",
|
||||||
@@ -22,7 +26,9 @@
|
|||||||
"zod-openapi": "^4.2.4"
|
"zod-openapi": "^4.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"ts-node-dev": "^2.0.0",
|
"@types/node": "^22.10.2",
|
||||||
"typescript": "latest"
|
"tsup": "^8.3.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createConfig } from '@repo/config';
|
import { createConfig } from "@repo/config";
|
||||||
import z from 'zod';
|
import z from "zod";
|
||||||
import 'dotenv/config';
|
import "dotenv/config";
|
||||||
|
|
||||||
export const config = createConfig({
|
export const config = createConfig({
|
||||||
schema: {
|
schema: {
|
||||||
PORT: z.string().default('3001'),
|
PORT: z.string().default("3001"),
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
NODE_ENV: z
|
||||||
|
.enum(["development", "production", "test"])
|
||||||
|
.default("development"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from "@hono/node-server";
|
||||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||||
import { swaggerUI } from '@hono/swagger-ui';
|
import { swaggerUI } from "@hono/swagger-ui";
|
||||||
import { config } from './config';
|
import { config } from "./config";
|
||||||
import { logger } from '@repo/logger';
|
import { logger } from "@repo/logger";
|
||||||
|
|
||||||
const app = new OpenAPIHono();
|
const app = new OpenAPIHono();
|
||||||
|
|
||||||
// OpenAPI documentation
|
// OpenAPI documentation
|
||||||
app.doc('/doc', {
|
app.doc("/doc", {
|
||||||
openapi: '3.0.0',
|
openapi: "3.0.0",
|
||||||
info: {
|
info: {
|
||||||
version: '1.0.0',
|
version: "1.0.0",
|
||||||
title: 'API Documentation',
|
title: "API Documentation",
|
||||||
description: 'API built with Hono and Zod OpenAPI',
|
description: "API built with Hono and Zod OpenAPI",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Swagger UI
|
// Swagger UI
|
||||||
app.get('/ui', swaggerUI({ url: '/doc' }));
|
app.get("/ui", swaggerUI({ url: "/doc" }));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (c) => {
|
app.get("/health", (c) => {
|
||||||
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
return c.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = parseInt(config.PORT);
|
const port = parseInt(config.PORT);
|
||||||
@@ -29,7 +29,24 @@ const port = parseInt(config.PORT);
|
|||||||
logger.info(`Starting API server on port ${port}`);
|
logger.info(`Starting API server on port ${port}`);
|
||||||
logger.info(`Swagger UI available at http://localhost:${port}/ui`);
|
logger.info(`Swagger UI available at http://localhost:${port}/ui`);
|
||||||
|
|
||||||
serve({
|
const server = serve({
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port,
|
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);
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "@repo/typescript-config/node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": ".",
|
||||||
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.*",
|
||||||
|
"**/*.spec.*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
1
apps/api/tsconfig.tsbuildinfo
Normal file
1
apps/api/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
15
apps/api/tsup.config.ts
Normal file
15
apps/api/tsup.config.ts
Normal 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",
|
||||||
|
});
|
||||||
@@ -2,3 +2,4 @@ BOT_TOKEN="your-bot-token"
|
|||||||
WEBHOOK_URL="your-webhook-url"
|
WEBHOOK_URL="your-webhook-url"
|
||||||
PORT=3000
|
PORT=3000
|
||||||
MODE=polling or webhook
|
MODE=polling or webhook
|
||||||
|
DATABASE_URL=copy from packages/database/.env
|
||||||
@@ -35,6 +35,10 @@ RUN npm ci
|
|||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
COPY --from=pruner /app/out/full/ .
|
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...
|
RUN turbo run build --filter=bot...
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
2
apps/bot/locales/en.ftl
Normal file
2
apps/bot/locales/en.ftl
Normal 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
2
apps/bot/locales/ru.ftl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
start-ask-name = Привет, как тебя зовут
|
||||||
|
start-welcome = Привет, { $user }!
|
||||||
@@ -2,24 +2,31 @@
|
|||||||
"name": "bot",
|
"name": "bot",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_PATH=./src tsnd --respawn --transpile-only --exit-child src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "NODE_PATH=./dist node ./dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"build": "tsc"
|
"build": "tsup",
|
||||||
|
"check-types": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@grammyjs/conversations": "^2.1.0",
|
||||||
|
"@grammyjs/i18n": "^1.1.2",
|
||||||
"@hono/node-server": "^1.15.0",
|
"@hono/node-server": "^1.15.0",
|
||||||
"@repo/config": "*",
|
"@repo/config": "*",
|
||||||
"@repo/db": "*",
|
"@repo/db": "*",
|
||||||
"@repo/typescript-config": "*",
|
|
||||||
"@repo/logger": "*",
|
"@repo/logger": "*",
|
||||||
|
"@repo/typescript-config": "*",
|
||||||
"dotenv": "^17.1.0",
|
"dotenv": "^17.1.0",
|
||||||
"grammy": "^1.37.0",
|
"grammy": "^1.37.0",
|
||||||
"hono": "^4.8.4",
|
"hono": "^4.8.4",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"ts-node-dev": "^2.0.0",
|
"@types/node": "^22.10.2",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createConfig } from '@repo/config';
|
import { createConfig } from "@repo/config";
|
||||||
import z from 'zod';
|
import z from "zod";
|
||||||
import 'dotenv/config';
|
import "dotenv/config";
|
||||||
|
|
||||||
export const config = createConfig({
|
export const config = createConfig({
|
||||||
schema: {
|
schema: {
|
||||||
BOT_TOKEN: z.string(),
|
BOT_TOKEN: z.string(),
|
||||||
WEBHOOK_URI: z.string(),
|
WEBHOOK_URI: z.string(),
|
||||||
PORT: z.string().default('3000'),
|
PORT: z.string().default("3000"),
|
||||||
MODE: z.enum(['polling', 'webhook']).default('polling'),
|
MODE: z.enum(["polling", "webhook"]).default("polling"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
3
apps/bot/src/conversations/consts.ts
Normal file
3
apps/bot/src/conversations/consts.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export enum ConversationSlug {
|
||||||
|
START = 'start',
|
||||||
|
}
|
||||||
2
apps/bot/src/conversations/index.ts
Normal file
2
apps/bot/src/conversations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './consts';
|
||||||
|
export * from './start.conversation';
|
||||||
23
apps/bot/src/conversations/start.conversation.ts
Normal file
23
apps/bot/src/conversations/start.conversation.ts
Normal 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
|
||||||
|
);
|
||||||
@@ -1,12 +1,41 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { Bot, webhookCallback } from 'grammy';
|
import { Bot, session, webhookCallback } from 'grammy';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { logger } from '@repo/logger';
|
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') {
|
if (config.MODE === 'webhook') {
|
||||||
logger.info('Starting in webhook mode...');
|
logger.info('Starting in webhook mode...');
|
||||||
@@ -25,9 +54,15 @@ if (config.MODE === 'webhook') {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info('Starting in polling mode...');
|
logger.info('Starting in polling mode...');
|
||||||
bot.start({
|
|
||||||
onStart: () => {
|
const startBot = async () => {
|
||||||
logger.info('Bot started polling...');
|
bot.start({
|
||||||
},
|
drop_pending_updates: true,
|
||||||
});
|
onStart: () => {
|
||||||
|
logger.info('Bot started polling...');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
startBot();
|
||||||
}
|
}
|
||||||
|
|||||||
16
apps/bot/src/middlewares/on-error.ts
Normal file
16
apps/bot/src/middlewares/on-error.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
30
apps/bot/src/middlewares/upsert-user.ts
Normal file
30
apps/bot/src/middlewares/upsert-user.ts
Normal 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();
|
||||||
|
};
|
||||||
36
apps/bot/src/session-storage.ts
Normal file
36
apps/bot/src/session-storage.ts
Normal 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
9
apps/bot/src/types.ts
Normal 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
|
||||||
|
>;
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "@repo/typescript-config/node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": ".",
|
||||||
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.*",
|
||||||
|
"**/*.spec.*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
20
apps/bot/tsup.config.ts
Normal file
20
apps/bot/tsup.config.ts
Normal 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
2659
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -4,14 +4,18 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"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",
|
"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": {
|
"devDependencies": {
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"turbo": "^2.5.4",
|
"turbo": "^2.5.4",
|
||||||
"typescript": "5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/config",
|
"name": "@repo/config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "./src/index.ts",
|
"type": "module",
|
||||||
"types": "./src/index.ts",
|
"private": true,
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"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": {
|
"scripts": {
|
||||||
"dev": "tsc --watch",
|
"build": "tsup",
|
||||||
"build": "tsc",
|
"dev": "tsup --watch",
|
||||||
"clean": "rm -rf dist",
|
"lint": "eslint src/",
|
||||||
"typecheck": "tsc --noEmit"
|
"check-types": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"znv": "^0.5.0",
|
"znv": "^0.5.0",
|
||||||
@@ -18,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
"typescript": "^5.0.0"
|
"tsup": "^8.3.5",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { parseEnv } from 'znv';
|
import { parseEnv } from "znv";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import type { ConfigSchema, ConfigOptions, InferConfig } from './types';
|
import type { ConfigSchema, ConfigOptions, InferConfig } from "./types";
|
||||||
|
|
||||||
export const createConfig = <T extends ConfigSchema>(
|
export const createConfig = <T extends ConfigSchema>(
|
||||||
options: ConfigOptions<T>,
|
options: ConfigOptions<T>,
|
||||||
): InferConfig<T> => {
|
): InferConfig<T> => {
|
||||||
const { schema, env = process.env, prefix = '' } = options;
|
const { schema, env = process.env, prefix = "" } = options;
|
||||||
|
|
||||||
// Add prefix to schema keys if provided
|
// Add prefix to schema keys if provided
|
||||||
const prefixedSchema = prefix
|
const prefixedSchema = prefix
|
||||||
@@ -14,7 +14,7 @@ export const createConfig = <T extends ConfigSchema>(
|
|||||||
acc[`${prefix}${key}`] = zodSchema;
|
acc[`${prefix}${key}`] = zodSchema;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, z.ZodTypeAny>
|
{} as Record<string, z.ZodTypeAny>,
|
||||||
)
|
)
|
||||||
: schema;
|
: schema;
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './create-config';
|
export * from "./create-config";
|
||||||
export * from './types';
|
export * from "./types";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
export type ConfigSchema = Record<string, z.ZodTypeAny>;
|
export type ConfigSchema = Record<string, z.ZodTypeAny>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "@repo/typescript-config/library.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "tsup.config.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
13
packages/config/tsup.config.ts
Normal file
13
packages/config/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: true,
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
treeshake: true,
|
||||||
|
minify: false,
|
||||||
|
target: "es2022",
|
||||||
|
});
|
||||||
16
packages/database/.eslintrc.js
Normal file
16
packages/database/.eslintrc.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import("eslint").Linter.Config} */
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@repo/eslint-config/library.js'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'turbo/no-undeclared-env-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
allowList: ['NODE_ENV'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,19 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/db",
|
"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": {
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"check-types": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev --skip-generate",
|
"db:migrate": "prisma migrate dev --skip-generate",
|
||||||
"db:deploy": "prisma migrate deploy"
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"db:push": "prisma db push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.11.1"
|
"@prisma/client": "^6.11.1",
|
||||||
|
"@repo/exceptions": "*",
|
||||||
|
"prisma-json-types-generator": "^3.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@repo/eslint-config": "*",
|
||||||
|
"@repo/typescript-config": "*",
|
||||||
"@types/node": "^24.0.10",
|
"@types/node": "^24.0.10",
|
||||||
"prisma": "^6.11.1"
|
"prisma": "^6.11.1",
|
||||||
},
|
"tsup": "^8.3.5",
|
||||||
"exports": {
|
"typescript": "^5.8.3"
|
||||||
".": "./src/index.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ generator client {
|
|||||||
output = "../generated/prisma"
|
output = "../generated/prisma"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generator json {
|
||||||
|
provider = "prisma-json-types-generator"
|
||||||
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
@@ -22,9 +26,95 @@ model TelegramSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TelegramUser {
|
model TelegramUser {
|
||||||
id String @id
|
id String @id
|
||||||
username String?
|
username String?
|
||||||
isBlocked Boolean @default(false)
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
export type PrismaClient = typeof prisma;
|
||||||
globalForPrisma.prisma || new PrismaClient()
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
export type PrismaClientArg =
|
||||||
|
| Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0]
|
||||||
|
| PrismaClient;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { prisma } from "./client"; // exports instance of prisma
|
export { prisma, type PrismaClientArg, type PrismaClient } from './client';
|
||||||
export * from "../generated/prisma"; // exports generated types from prisma
|
export * from '../generated/prisma';
|
||||||
|
|||||||
16
packages/database/tsconfig.json
Normal file
16
packages/database/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@repo/typescript-config/library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"exactOptionalPropertyTypes": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "tsup.config.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "generated/**/*"]
|
||||||
|
}
|
||||||
16
packages/database/tsup.config.ts
Normal file
16
packages/database/tsup.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs"],
|
||||||
|
dts: false,
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
external: ["@prisma/client"],
|
||||||
|
treeshake: false,
|
||||||
|
minify: false,
|
||||||
|
target: "node18",
|
||||||
|
platform: "node",
|
||||||
|
bundle: true,
|
||||||
|
});
|
||||||
17
packages/exceptions/package.json
Normal file
17
packages/exceptions/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/exceptions/src/constructors.ts
Normal file
65
packages/exceptions/src/constructors.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
3
packages/exceptions/src/index.ts
Normal file
3
packages/exceptions/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./constructors";
|
||||||
|
export * from "./utils";
|
||||||
35
packages/exceptions/src/types.ts
Normal file
35
packages/exceptions/src/types.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
25
packages/exceptions/src/utils/database.ts
Normal file
25
packages/exceptions/src/utils/database.ts
Normal 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)));
|
||||||
|
};
|
||||||
1
packages/exceptions/src/utils/index.ts
Normal file
1
packages/exceptions/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./database";
|
||||||
@@ -1,14 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/logger",
|
"name": "@repo/logger",
|
||||||
"scripts": {
|
"type": "module",
|
||||||
"dev": "tsc --watch",
|
"private": true,
|
||||||
"build": "tsc"
|
"files": [
|
||||||
},
|
"dist"
|
||||||
|
],
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"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": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
"typescript": "latest"
|
"typescript": "^5.8.3",
|
||||||
|
"tsup": "^8.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './logger'
|
export * from "./logger";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "@repo/typescript-config/library.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src/**/*", "tsup.config.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
13
packages/logger/tsup.config.ts
Normal file
13
packages/logger/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: true,
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
treeshake: true,
|
||||||
|
minify: false,
|
||||||
|
target: "es2022",
|
||||||
|
});
|
||||||
39
packages/services/package.json
Normal file
39
packages/services/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
packages/services/src/files/errors.ts
Normal file
61
packages/services/src/files/errors.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { AppError, createAppError } from "@repo/exceptions";
|
||||||
|
|
||||||
|
export enum FileStorageErrorCode {
|
||||||
|
FILE_NOT_FOUND = "FILE_NOT_FOUND",
|
||||||
|
FILE_ALREADY_EXISTS = "FILE_ALREADY_EXISTS",
|
||||||
|
INVALID_FILE_PATH = "INVALID_FILE_PATH",
|
||||||
|
STORAGE_OPERATION_FAILED = "STORAGE_OPERATION_FAILED",
|
||||||
|
DIRECTORY_CREATION_FAILED = "DIRECTORY_CREATION_FAILED",
|
||||||
|
INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileStorageError = AppError<FileStorageErrorCode>;
|
||||||
|
|
||||||
|
export const fileNotFound = (fileName: string): FileStorageError =>
|
||||||
|
createAppError<FileStorageErrorCode>(
|
||||||
|
FileStorageErrorCode.FILE_NOT_FOUND,
|
||||||
|
`File with name ${fileName} not found`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fileAlreadyExists = (fileName: string): FileStorageError =>
|
||||||
|
createAppError(
|
||||||
|
FileStorageErrorCode.FILE_ALREADY_EXISTS,
|
||||||
|
`File ${fileName} already exists`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const invalidFilePath = (path: string): FileStorageError =>
|
||||||
|
createAppError(
|
||||||
|
FileStorageErrorCode.INVALID_FILE_PATH,
|
||||||
|
`Invalid file path: ${path}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const storageOperationFailed = (
|
||||||
|
operation: string,
|
||||||
|
cause?: Error,
|
||||||
|
): FileStorageError =>
|
||||||
|
createAppError(
|
||||||
|
FileStorageErrorCode.STORAGE_OPERATION_FAILED,
|
||||||
|
`Storage operation '${operation}' failed`,
|
||||||
|
undefined,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const directoryCreationFailed = (
|
||||||
|
path: string,
|
||||||
|
cause?: Error,
|
||||||
|
): FileStorageError =>
|
||||||
|
createAppError(
|
||||||
|
FileStorageErrorCode.DIRECTORY_CREATION_FAILED,
|
||||||
|
`Failed to create directory: ${path}`,
|
||||||
|
undefined,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const insufficientPermissions = (
|
||||||
|
operation: string,
|
||||||
|
path: string,
|
||||||
|
): FileStorageError =>
|
||||||
|
createAppError(
|
||||||
|
FileStorageErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||||
|
`Insufficient permissions for ${operation} at ${path}`,
|
||||||
|
);
|
||||||
49
packages/services/src/files/factory.ts
Normal file
49
packages/services/src/files/factory.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { err, ok } from "neverthrow";
|
||||||
|
import { createZodValidationError } from "@repo/exceptions";
|
||||||
|
import { FileService } from "./services/file-service";
|
||||||
|
import { FileLinkService } from "./services/file-link-service";
|
||||||
|
import {
|
||||||
|
CreateFileLinkInput,
|
||||||
|
FileServiceConfig,
|
||||||
|
FileServiceConfigSchema,
|
||||||
|
} from "./types";
|
||||||
|
import { PrismaClient } from "@repo/db";
|
||||||
|
|
||||||
|
const validateConfig = (data: FileServiceConfig) => {
|
||||||
|
const parseResult = FileServiceConfigSchema.safeParse(data);
|
||||||
|
return parseResult.success
|
||||||
|
? ok(parseResult.data)
|
||||||
|
: err(createZodValidationError(parseResult.error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFileService = (config: FileServiceConfig) =>
|
||||||
|
validateConfig(config).map((validatedConfig) => ({
|
||||||
|
uploadFile: (file: Buffer, options: any) =>
|
||||||
|
FileService.uploadFile(validatedConfig, file, options),
|
||||||
|
downloadFile: (fileName: string) =>
|
||||||
|
FileService.downloadFile(validatedConfig, fileName),
|
||||||
|
deleteFile: (fileName: string) =>
|
||||||
|
FileService.deleteFile(validatedConfig, fileName),
|
||||||
|
getFileMetadata: (fileName: string) =>
|
||||||
|
FileService.getFileMetadata(validatedConfig, fileName),
|
||||||
|
fileExists: (fileName: string) =>
|
||||||
|
FileService.fileExists(validatedConfig, fileName),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createFileLinkService = (client: PrismaClient) => ({
|
||||||
|
linkFileToRelation: (input: CreateFileLinkInput) =>
|
||||||
|
FileLinkService.linkFileToRelation(client, input),
|
||||||
|
getFilesByRelationId: (relationId: string) =>
|
||||||
|
FileLinkService.getFilesByRelationId(client, relationId),
|
||||||
|
unlinkFileFromRelation: (fileId: string, relationId: string) =>
|
||||||
|
FileLinkService.unlinkFileFromRelation(client, fileId, relationId),
|
||||||
|
unlinkAllFilesFromRelation: (relationId: string) =>
|
||||||
|
FileLinkService.unlinkAllFilesFromRelation(client, relationId),
|
||||||
|
getLinkedRelations: (fileId: string) =>
|
||||||
|
FileLinkService.getLinkedRelations(client, fileId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StorageFactory = {
|
||||||
|
createFileService,
|
||||||
|
createFileLinkService,
|
||||||
|
};
|
||||||
129
packages/services/src/files/fs-utils.ts
Normal file
129
packages/services/src/files/fs-utils.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { promises as fs, Stats } from "fs";
|
||||||
|
import { extname, basename } from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { ResultAsync, ok, err, Result } from "neverthrow";
|
||||||
|
import type { FileUploadOptions } from "./types";
|
||||||
|
import {
|
||||||
|
FileStorageError,
|
||||||
|
fileNotFound,
|
||||||
|
storageOperationFailed,
|
||||||
|
directoryCreationFailed,
|
||||||
|
insufficientPermissions,
|
||||||
|
} from "./errors";
|
||||||
|
|
||||||
|
const handleFileSystemError = (
|
||||||
|
error: unknown,
|
||||||
|
operation: string,
|
||||||
|
fileName: string,
|
||||||
|
): FileStorageError => {
|
||||||
|
const nodeError = error as NodeJS.ErrnoException;
|
||||||
|
|
||||||
|
switch (nodeError.code) {
|
||||||
|
case "ENOENT":
|
||||||
|
return fileNotFound(fileName);
|
||||||
|
case "EACCES":
|
||||||
|
case "EPERM":
|
||||||
|
return insufficientPermissions(operation, fileName);
|
||||||
|
default:
|
||||||
|
return storageOperationFailed(operation, nodeError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateUpload = (
|
||||||
|
file: Buffer,
|
||||||
|
options: FileUploadOptions,
|
||||||
|
maxFileSize?: number,
|
||||||
|
allowedExtensions?: string[],
|
||||||
|
): Result<void, FileStorageError> => {
|
||||||
|
if (maxFileSize && file.length > maxFileSize) {
|
||||||
|
return err(
|
||||||
|
storageOperationFailed(
|
||||||
|
`File size ${file.length} exceeds maximum allowed size ${maxFileSize}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedExtensions) {
|
||||||
|
const ext = extname(options.originalName).toLowerCase();
|
||||||
|
if (!allowedExtensions.includes(ext)) {
|
||||||
|
return err(
|
||||||
|
storageOperationFailed(`File extension ${ext} is not allowed`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateFileName = (
|
||||||
|
originalName: string,
|
||||||
|
customGenerator?: (originalName: string) => string,
|
||||||
|
): string =>
|
||||||
|
customGenerator
|
||||||
|
? customGenerator(originalName)
|
||||||
|
: (() => {
|
||||||
|
const ext = extname(originalName);
|
||||||
|
const name = basename(originalName, ext);
|
||||||
|
const uuid = randomUUID();
|
||||||
|
return `${name}-${uuid}${ext}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export const ensureDirectoryExists = (
|
||||||
|
dirPath: string,
|
||||||
|
createDirectories: boolean,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
!createDirectories
|
||||||
|
? ResultAsync.fromPromise(Promise.resolve(), () =>
|
||||||
|
directoryCreationFailed("Directory creation is disabled"),
|
||||||
|
)
|
||||||
|
: ResultAsync.fromPromise(fs.mkdir(dirPath, { recursive: true }), (error) =>
|
||||||
|
directoryCreationFailed(dirPath, error as Error),
|
||||||
|
).andThen(() => ok(undefined));
|
||||||
|
|
||||||
|
export const writeFile = (
|
||||||
|
filePath: string,
|
||||||
|
file: Buffer,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(fs.writeFile(filePath, file), (error) =>
|
||||||
|
handleFileSystemError(error, "write", filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const readFile = (
|
||||||
|
filePath: string,
|
||||||
|
): ResultAsync<Buffer, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(fs.readFile(filePath), (error) =>
|
||||||
|
handleFileSystemError(error, "read", filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteFile = (
|
||||||
|
filePath: string,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(fs.unlink(filePath), (error) =>
|
||||||
|
handleFileSystemError(error, "delete", filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const checkFileExists = (
|
||||||
|
filePath: string,
|
||||||
|
): ResultAsync<boolean, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(
|
||||||
|
fs.access(filePath).then(() => true),
|
||||||
|
() => false,
|
||||||
|
).orElse(() => ok(false));
|
||||||
|
|
||||||
|
export const getFileStats = (
|
||||||
|
filePath: string,
|
||||||
|
): ResultAsync<Stats, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(fs.stat(filePath), (error) =>
|
||||||
|
handleFileSystemError(error, "stat", filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FsUtils = {
|
||||||
|
validateUpload,
|
||||||
|
generateFileName,
|
||||||
|
ensureDirectoryExists,
|
||||||
|
writeFile,
|
||||||
|
readFile,
|
||||||
|
deleteFile,
|
||||||
|
checkFileExists,
|
||||||
|
getFileStats,
|
||||||
|
};
|
||||||
2
packages/services/src/files/index.ts
Normal file
2
packages/services/src/files/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./factory";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ResultAsync, ok } from "neverthrow";
|
||||||
|
import { PrismaClientArg, Prisma, FileLink, File } from "@repo/db";
|
||||||
|
import { FileStorageError, storageOperationFailed } from "../errors";
|
||||||
|
import { CreateFileLinkInput } from "../types";
|
||||||
|
|
||||||
|
const create = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
data: CreateFileLinkInput,
|
||||||
|
): ResultAsync<FileLink, FileStorageError> => {
|
||||||
|
const linkData: Prisma.FileLinkCreateInput = {
|
||||||
|
relationId: data.relationId,
|
||||||
|
file: {
|
||||||
|
connect: { id: data.fileId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return ResultAsync.fromPromise(
|
||||||
|
client.fileLink.create({ data: linkData }),
|
||||||
|
(error) => storageOperationFailed("createFileLink", error as Error),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findByRelationId = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
relationId: string,
|
||||||
|
): ResultAsync<(FileLink & { file: File })[], FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(
|
||||||
|
client.fileLink.findMany({
|
||||||
|
where: { relationId },
|
||||||
|
include: { file: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
(error) => storageOperationFailed("findFilesByRelationId", error as Error),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteByFileId = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
fileId: string,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(
|
||||||
|
client.fileLink.deleteMany({ where: { fileId } }),
|
||||||
|
(error) => storageOperationFailed("deleteFileLinks", error as Error),
|
||||||
|
).andThen(() => ok(undefined));
|
||||||
|
|
||||||
|
const deleteByRelationId = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
relationId: string,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(
|
||||||
|
client.fileLink.deleteMany({ where: { relationId } }),
|
||||||
|
(error) => storageOperationFailed("deleteFileLinks", error as Error),
|
||||||
|
).andThen(() => ok(undefined));
|
||||||
|
|
||||||
|
const deleteSpecificLink = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
fileId: string,
|
||||||
|
relationId: string,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(
|
||||||
|
client.fileLink.deleteMany({ where: { fileId, relationId } }),
|
||||||
|
(error) => storageOperationFailed("deleteFileLink", error as Error),
|
||||||
|
).andThen(() => ok(undefined));
|
||||||
|
|
||||||
|
export const FileLinkRepository = {
|
||||||
|
create,
|
||||||
|
findByRelationId,
|
||||||
|
deleteByFileId,
|
||||||
|
deleteByRelationId,
|
||||||
|
deleteSpecificLink,
|
||||||
|
};
|
||||||
71
packages/services/src/files/repositories/file-repository.ts
Normal file
71
packages/services/src/files/repositories/file-repository.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { ResultAsync, ok } from "neverthrow";
|
||||||
|
import { PrismaClientArg, Prisma, File } from "@repo/db";
|
||||||
|
import { fileNotFound, FileStorageError } from "../errors";
|
||||||
|
import {
|
||||||
|
wrapDbOperation,
|
||||||
|
wrapDbOperationWithNull,
|
||||||
|
DatabaseError,
|
||||||
|
databaseError,
|
||||||
|
} from "@repo/exceptions";
|
||||||
|
import { CreateFileInput } from "../types";
|
||||||
|
|
||||||
|
const create = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
data: CreateFileInput,
|
||||||
|
): ResultAsync<File, DatabaseError> => {
|
||||||
|
const fileData: Prisma.FileCreateInput = {
|
||||||
|
filename: data.filename,
|
||||||
|
originalName: data.originalName,
|
||||||
|
mimeType: data.mimeType,
|
||||||
|
size: data.size,
|
||||||
|
path: data.path,
|
||||||
|
};
|
||||||
|
|
||||||
|
return wrapDbOperation(
|
||||||
|
() => client.file.create({ data: fileData }),
|
||||||
|
(e) => databaseError("createFile", e as Error),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findById = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
id: string,
|
||||||
|
): ResultAsync<File, DatabaseError | FileStorageError> =>
|
||||||
|
wrapDbOperationWithNull<File, DatabaseError | FileStorageError>(
|
||||||
|
() => client.file.findUnique({ where: { id } }),
|
||||||
|
fileNotFound(id),
|
||||||
|
(e) => databaseError("findFileById", e as Error),
|
||||||
|
);
|
||||||
|
|
||||||
|
const findByFilename = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
filename: string,
|
||||||
|
): ResultAsync<File | null, DatabaseError> =>
|
||||||
|
ResultAsync.fromPromise(
|
||||||
|
client.file.findFirst({ where: { filename } }),
|
||||||
|
(error) => databaseError("findByFilename", error as Error),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteById = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
id: string,
|
||||||
|
): ResultAsync<void, DatabaseError> =>
|
||||||
|
ResultAsync.fromPromise(client.file.delete({ where: { id } }), (error) =>
|
||||||
|
databaseError("deleteFile", error as Error),
|
||||||
|
).andThen(() => ok(undefined));
|
||||||
|
|
||||||
|
const exists = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
filename: string,
|
||||||
|
): ResultAsync<boolean, DatabaseError> =>
|
||||||
|
ResultAsync.fromPromise(client.file.exists({ filename }), (error) =>
|
||||||
|
databaseError("checkFileExists", error as Error),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FileRepository = {
|
||||||
|
create,
|
||||||
|
findById,
|
||||||
|
findByFilename,
|
||||||
|
deleteById,
|
||||||
|
exists,
|
||||||
|
};
|
||||||
66
packages/services/src/files/services/file-link-service.ts
Normal file
66
packages/services/src/files/services/file-link-service.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { ResultAsync, ok, err } from "neverthrow";
|
||||||
|
import { createZodValidationError } from "@repo/exceptions";
|
||||||
|
import { PrismaClientArg, File } from "@repo/db";
|
||||||
|
import { FileStorageError, fileNotFound } from "../errors";
|
||||||
|
import { FileLinkRepository } from "../repositories/file-link-repository";
|
||||||
|
import { FileRepository } from "../repositories/file-repository";
|
||||||
|
import { CreateFileLinkInput, CreateFileLinkInputSchema } from "../types";
|
||||||
|
|
||||||
|
const validateLinkInput = (data: CreateFileLinkInput) => {
|
||||||
|
const parseResult = CreateFileLinkInputSchema.safeParse(data);
|
||||||
|
return parseResult.success
|
||||||
|
? ok(parseResult.data)
|
||||||
|
: err(createZodValidationError(parseResult.error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkFileToRelation = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
input: CreateFileLinkInput,
|
||||||
|
) =>
|
||||||
|
validateLinkInput(input).asyncAndThen((validInput) =>
|
||||||
|
FileRepository.findById(client, validInput.fileId).andThen(() =>
|
||||||
|
FileLinkRepository.create(client, validInput),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFilesByRelationId = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
relationId: string,
|
||||||
|
): ResultAsync<File[], FileStorageError> =>
|
||||||
|
FileLinkRepository.findByRelationId(client, relationId).andThen((links) =>
|
||||||
|
ok(links.map((link) => link.file)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlinkFileFromRelation = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
fileId: string,
|
||||||
|
relationId: string,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
FileLinkRepository.deleteSpecificLink(client, fileId, relationId);
|
||||||
|
|
||||||
|
const unlinkAllFilesFromRelation = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
relationId: string,
|
||||||
|
): ResultAsync<void, FileStorageError> =>
|
||||||
|
FileLinkRepository.deleteByRelationId(client, relationId);
|
||||||
|
|
||||||
|
const getLinkedRelations = (
|
||||||
|
client: PrismaClientArg,
|
||||||
|
fileId: string,
|
||||||
|
): ResultAsync<string[], FileStorageError> =>
|
||||||
|
ResultAsync.fromPromise(
|
||||||
|
client.fileLink.findMany({
|
||||||
|
where: { fileId },
|
||||||
|
select: { relationId: true },
|
||||||
|
}),
|
||||||
|
() => fileNotFound(fileId),
|
||||||
|
).andThen((links) => ok(links.map((link) => link.relationId)));
|
||||||
|
|
||||||
|
export const FileLinkService = {
|
||||||
|
validateLinkInput,
|
||||||
|
linkFileToRelation,
|
||||||
|
getFilesByRelationId,
|
||||||
|
unlinkFileFromRelation,
|
||||||
|
unlinkAllFilesFromRelation,
|
||||||
|
getLinkedRelations,
|
||||||
|
};
|
||||||
135
packages/services/src/files/services/file-service.ts
Normal file
135
packages/services/src/files/services/file-service.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { join, dirname } from "path";
|
||||||
|
import { ResultAsync, ok, err } from "neverthrow";
|
||||||
|
import { createZodValidationError } from "@repo/exceptions";
|
||||||
|
import { fileAlreadyExists, fileNotFound } from "../errors";
|
||||||
|
import { FsUtils } from "../fs-utils";
|
||||||
|
import { FileRepository } from "../repositories/file-repository";
|
||||||
|
import {
|
||||||
|
FileServiceConfigSchema,
|
||||||
|
CreateFileInput,
|
||||||
|
CreateFileInputSchema,
|
||||||
|
FileServiceConfig,
|
||||||
|
FileUploadOptions,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const validateConfig = (data: unknown) => {
|
||||||
|
const parseResult = FileServiceConfigSchema.safeParse(data);
|
||||||
|
return parseResult.success
|
||||||
|
? ok(parseResult.data)
|
||||||
|
: err(createZodValidationError(parseResult.error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateFileInput = (data: CreateFileInput) => {
|
||||||
|
const parseResult = CreateFileInputSchema.safeParse(data);
|
||||||
|
return parseResult.success
|
||||||
|
? ok(parseResult.data)
|
||||||
|
: err(createZodValidationError(parseResult.error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = (
|
||||||
|
config: FileServiceConfig,
|
||||||
|
file: Buffer,
|
||||||
|
options: FileUploadOptions,
|
||||||
|
) => {
|
||||||
|
const validation = FsUtils.validateUpload(
|
||||||
|
file,
|
||||||
|
options,
|
||||||
|
config.maxFileSize,
|
||||||
|
config.allowedExtensions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validation.isErr()) {
|
||||||
|
return ResultAsync.fromSafePromise(Promise.resolve(validation));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = FsUtils.generateFileName(
|
||||||
|
options.originalName,
|
||||||
|
options.generateFileName,
|
||||||
|
);
|
||||||
|
const filePath = join(config.basePath, fileName);
|
||||||
|
|
||||||
|
return FileRepository.exists(config.client, fileName)
|
||||||
|
.andThen((exists) =>
|
||||||
|
exists && !options.overwrite
|
||||||
|
? err(fileAlreadyExists(fileName))
|
||||||
|
: ok(undefined),
|
||||||
|
)
|
||||||
|
.andThen(() =>
|
||||||
|
FsUtils.ensureDirectoryExists(
|
||||||
|
dirname(filePath),
|
||||||
|
config.createDirectories,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.andThen(() => FsUtils.writeFile(filePath, file))
|
||||||
|
.andThen(() => {
|
||||||
|
const fileData: CreateFileInput = {
|
||||||
|
filename: fileName,
|
||||||
|
originalName: options.originalName,
|
||||||
|
mimeType: options.mimeType,
|
||||||
|
size: file.length,
|
||||||
|
path: filePath,
|
||||||
|
};
|
||||||
|
return validateFileInput(fileData).asyncAndThen((validData) =>
|
||||||
|
FileRepository.create(config.client, validData),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.andThen((fileRecord) =>
|
||||||
|
ok({
|
||||||
|
id: fileRecord.id,
|
||||||
|
originalName: fileRecord.originalName,
|
||||||
|
fileName: fileRecord.filename,
|
||||||
|
mimeType: fileRecord.mimeType,
|
||||||
|
size: fileRecord.size,
|
||||||
|
uploadedAt: fileRecord.createdAt,
|
||||||
|
path: fileRecord.path,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = (config: FileServiceConfig, fileName: string) =>
|
||||||
|
FileRepository.findByFilename(config.client, fileName).andThen(
|
||||||
|
(fileRecord) =>
|
||||||
|
!fileRecord
|
||||||
|
? err(fileNotFound(fileName))
|
||||||
|
: FsUtils.readFile(fileRecord.path),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteFile = (config: FileServiceConfig, fileName: string) =>
|
||||||
|
FileRepository.findByFilename(config.client, fileName).andThen(
|
||||||
|
(fileRecord) => {
|
||||||
|
if (!fileRecord) {
|
||||||
|
return err(fileNotFound(fileName));
|
||||||
|
}
|
||||||
|
return FsUtils.deleteFile(fileRecord.path).andThen(() =>
|
||||||
|
FileRepository.deleteById(config.client, fileRecord.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFileMetadata = (config: FileServiceConfig, fileName: string) =>
|
||||||
|
FileRepository.findByFilename(config.client, fileName).andThen(
|
||||||
|
(fileRecord) =>
|
||||||
|
!fileRecord
|
||||||
|
? ok(null)
|
||||||
|
: ok({
|
||||||
|
id: fileRecord.id,
|
||||||
|
originalName: fileRecord.originalName,
|
||||||
|
fileName: fileRecord.filename,
|
||||||
|
mimeType: fileRecord.mimeType,
|
||||||
|
size: fileRecord.size,
|
||||||
|
uploadedAt: fileRecord.createdAt,
|
||||||
|
path: fileRecord.path,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileExists = (config: FileServiceConfig, fileName: string) =>
|
||||||
|
FileRepository.exists(config.client, fileName);
|
||||||
|
|
||||||
|
export const FileService = {
|
||||||
|
validateConfig,
|
||||||
|
uploadFile,
|
||||||
|
downloadFile,
|
||||||
|
deleteFile,
|
||||||
|
getFileMetadata,
|
||||||
|
fileExists,
|
||||||
|
};
|
||||||
105
packages/services/src/files/types.ts
Normal file
105
packages/services/src/files/types.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ResultAsync } from "neverthrow";
|
||||||
|
import { FileStorageError } from "./errors";
|
||||||
|
import { PrismaClientArg } from "@repo/db";
|
||||||
|
|
||||||
|
export type FileMetadata = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly originalName: string;
|
||||||
|
readonly fileName: string;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly size: number;
|
||||||
|
readonly uploadedAt: Date;
|
||||||
|
readonly path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileUploadOptions = {
|
||||||
|
readonly originalName: string;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly generateFileName?: (originalName: string) => string;
|
||||||
|
readonly overwrite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StorageConfig = {
|
||||||
|
readonly basePath: string;
|
||||||
|
readonly createDirectories: boolean;
|
||||||
|
readonly maxFileSize?: number;
|
||||||
|
readonly allowedExtensions?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileServiceConfig = StorageConfig & {
|
||||||
|
readonly client: PrismaClientArg;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateFileInput = {
|
||||||
|
readonly filename: string;
|
||||||
|
readonly originalName: string;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly size: number;
|
||||||
|
readonly path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateFileLinkInput = {
|
||||||
|
readonly fileId: string;
|
||||||
|
readonly relationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StorageConfigSchema = z.object({
|
||||||
|
basePath: z.string().min(1),
|
||||||
|
createDirectories: z.boolean().default(true),
|
||||||
|
maxFileSize: z.number().positive().optional(),
|
||||||
|
allowedExtensions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileServiceConfigSchema = StorageConfigSchema.extend({
|
||||||
|
client: z.custom<PrismaClientArg>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateFileInputSchema = z.object({
|
||||||
|
filename: z.string().min(1),
|
||||||
|
originalName: z.string().min(1),
|
||||||
|
mimeType: z.string().min(1),
|
||||||
|
size: z.number().positive(),
|
||||||
|
path: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateFileLinkInputSchema = z.object({
|
||||||
|
fileId: z.string().uuid(),
|
||||||
|
relationId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface StorageProvider {
|
||||||
|
upload(
|
||||||
|
file: Buffer,
|
||||||
|
options: FileUploadOptions,
|
||||||
|
): ResultAsync<FileMetadata, FileStorageError>;
|
||||||
|
|
||||||
|
download(fileName: string): ResultAsync<Buffer, FileStorageError>;
|
||||||
|
|
||||||
|
delete(fileName: string): ResultAsync<void, FileStorageError>;
|
||||||
|
|
||||||
|
exists(fileName: string): ResultAsync<boolean, FileStorageError>;
|
||||||
|
|
||||||
|
getMetadata(
|
||||||
|
fileName: string,
|
||||||
|
): ResultAsync<FileMetadata | null, FileStorageError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocalStorageConfigSchema = z.object({
|
||||||
|
basePath: z.string().min(1),
|
||||||
|
createDirectories: z.boolean().default(true),
|
||||||
|
maxFileSize: z.number().positive().optional(),
|
||||||
|
allowedExtensions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DatabaseStorageConfigSchema = z.object({
|
||||||
|
client: z.custom<PrismaClientArg>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HybridStorageConfigSchema = z.object({
|
||||||
|
client: z.custom<PrismaClientArg>(),
|
||||||
|
basePath: z.string().min(1),
|
||||||
|
createDirectories: z.boolean().default(true),
|
||||||
|
maxFileSize: z.number().positive().optional(),
|
||||||
|
allowedExtensions: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
1
packages/services/src/index.ts
Normal file
1
packages/services/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./payment";
|
||||||
20
packages/services/src/payment/errors.ts
Normal file
20
packages/services/src/payment/errors.ts
Normal 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;
|
||||||
4
packages/services/src/payment/index.ts
Normal file
4
packages/services/src/payment/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./errors";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./repository";
|
||||||
|
export * from "./service";
|
||||||
61
packages/services/src/payment/repository.ts
Normal file
61
packages/services/src/payment/repository.ts
Normal 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}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
43
packages/services/src/payment/service.ts
Normal file
43
packages/services/src/payment/service.ts
Normal 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,
|
||||||
|
};
|
||||||
50
packages/services/src/payment/types.ts
Normal file
50
packages/services/src/payment/types.ts
Normal 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>;
|
||||||
|
};
|
||||||
17
packages/services/tsconfig.json
Normal file
17
packages/services/tsconfig.json
Normal 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.*"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
packages/services/tsup.config.ts
Normal file
14
packages/services/tsup.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: false,
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
external: ["@repo/db", "@repo/exceptions"],
|
||||||
|
treeshake: true,
|
||||||
|
minify: false,
|
||||||
|
target: "es2022",
|
||||||
|
});
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"incremental": false,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"incremental": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "NodeNext",
|
"module": "ESNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "Bundler",
|
||||||
|
"noEmit": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"preserveWatchOutput": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": false,
|
||||||
"target": "ES2022"
|
"target": "ES2022",
|
||||||
}
|
"noImplicitReturns": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noErrorTruncation": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
11
packages/typescript-config/library.json
Normal file
11
packages/typescript-config/library.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"incremental": false,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||||
|
}
|
||||||
10
packages/typescript-config/node.json
Normal file
10
packages/typescript-config/node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es2022"],
|
||||||
|
"noEmit": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
"name": "@repo/typescript-config",
|
"name": "@repo/typescript-config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"files": ["*.json"],
|
||||||
"publishConfig": {
|
"exports": {
|
||||||
"access": "public"
|
"./base.json": "./base.json",
|
||||||
|
"./library.json": "./library.json",
|
||||||
|
"./node.json": "./node.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
turbo.json
42
turbo.json
@@ -1,28 +1,50 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turborepo.com/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"ui": "tui",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build", "^db:generate"],
|
"dependsOn": ["^build", "^db:generate"],
|
||||||
"outputs": ["dist/**"],
|
"outputs": ["dist/**"],
|
||||||
"env": ["DATABASE_URL", "BOT_TOKEN"]
|
"env": ["NODE_ENV"]
|
||||||
},
|
|
||||||
"check-types": {
|
|
||||||
"dependsOn": ["^check-types"]
|
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"dependsOn": ["^db:generate"],
|
"dependsOn": ["^build", "^db:generate"],
|
||||||
"persistent": true,
|
"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
|
"cache": false
|
||||||
},
|
},
|
||||||
"db:generate": {
|
"db:generate": {
|
||||||
"cache": false
|
"cache": false,
|
||||||
|
"outputs": ["generated/**"]
|
||||||
},
|
},
|
||||||
"db:migrate": {
|
"db:migrate": {
|
||||||
"cache": false,
|
"cache": false
|
||||||
"persistent": true
|
|
||||||
},
|
},
|
||||||
"db:deploy": {
|
"db:deploy": {
|
||||||
"cache": false
|
"cache": false
|
||||||
|
},
|
||||||
|
"db:push": {
|
||||||
|
"cache": false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"globalEnv": ["NODE_ENV"],
|
||||||
|
"globalPassThroughEnv": ["NODE_ENV", "CI", "VERCEL", "npm_lifecycle_event"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user