Compare commits
10 Commits
84760349c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636f0e7f23 | ||
|
|
d59f0dd794 | ||
|
|
0ceba57012 | ||
|
|
df8bfa049f | ||
|
|
701e9c0ef8 | ||
|
|
8d6fc0c5e9 | ||
|
|
f9ecac94f2 | ||
|
|
c0c04bbdba | ||
|
|
084b100589 | ||
|
|
49f0385d00 |
@@ -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 }}"
|
||||
# }
|
||||
# }'
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=copy from packages/database/.env
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# `api`
|
||||
|
||||
Апи клиент
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.*"
|
||||
]
|
||||
}
|
||||
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"
|
||||
PORT=3000
|
||||
MODE=polling or webhook
|
||||
DATABASE_URL=copy from packages/database/.env
|
||||
@@ -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
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
|
||||
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 { 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();
|
||||
}
|
||||
|
||||
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": {
|
||||
"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
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": {
|
||||
"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"
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
{
|
||||
"name": "@repo/config",
|
||||
"version": "0.0.0",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc --watch",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint src/",
|
||||
"check-types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"znv": "^0.5.0",
|
||||
@@ -18,6 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"typescript": "^5.0.0"
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { parseEnv } from 'znv';
|
||||
import { z } from 'zod';
|
||||
import type { ConfigSchema, ConfigOptions, InferConfig } from './types';
|
||||
import { parseEnv } from "znv";
|
||||
import { z } from "zod";
|
||||
import type { ConfigSchema, ConfigOptions, InferConfig } from "./types";
|
||||
|
||||
export const createConfig = <T extends ConfigSchema>(
|
||||
options: ConfigOptions<T>,
|
||||
): InferConfig<T> => {
|
||||
const { schema, env = process.env, prefix = '' } = options;
|
||||
const { schema, env = process.env, prefix = "" } = options;
|
||||
|
||||
// Add prefix to schema keys if provided
|
||||
const prefixedSchema = prefix
|
||||
@@ -14,7 +14,7 @@ export const createConfig = <T extends ConfigSchema>(
|
||||
acc[`${prefix}${key}`] = zodSchema;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, z.ZodTypeAny>
|
||||
{} as Record<string, z.ZodTypeAny>,
|
||||
)
|
||||
: schema;
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './create-config';
|
||||
export * from './types';
|
||||
export * from "./create-config";
|
||||
export * from "./types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
export type ConfigSchema = Record<string, z.ZodTypeAny>;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
"include": ["src/**/*", "tsup.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
packages/config/tsup.config.ts
Normal file
13
packages/config/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
});
|
||||
16
packages/database/.eslintrc.js
Normal file
16
packages/database/.eslintrc.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ['@repo/eslint-config/library.js'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
rules: {
|
||||
'turbo/no-undeclared-env-vars': [
|
||||
'error',
|
||||
{
|
||||
allowList: ['NODE_ENV'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
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",
|
||||
"scripts": {
|
||||
"dev": "tsc --watch",
|
||||
"build": "tsc"
|
||||
},
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint src/",
|
||||
"check-types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"typescript": "latest"
|
||||
"typescript": "^5.8.3",
|
||||
"tsup": "^8.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './logger'
|
||||
export * from "./logger";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src/**/*", "tsup.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
packages/logger/tsup.config.ts
Normal file
13
packages/logger/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
});
|
||||
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",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"incremental": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"preserveWatchOutput": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
}
|
||||
"strict": false,
|
||||
"target": "ES2022",
|
||||
"noImplicitReturns": false,
|
||||
"noImplicitAny": false,
|
||||
"noErrorTruncation": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
11
packages/typescript-config/library.json
Normal file
11
packages/typescript-config/library.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"incremental": false,
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||
}
|
||||
10
packages/typescript-config/node.json
Normal file
10
packages/typescript-config/node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2022"],
|
||||
"noEmit": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
"name": "@repo/typescript-config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"files": ["*.json"],
|
||||
"exports": {
|
||||
"./base.json": "./base.json",
|
||||
"./library.json": "./library.json",
|
||||
"./node.json": "./node.json"
|
||||
}
|
||||
}
|
||||
|
||||
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": {
|
||||
"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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user