From f2de9827ff77243880972933322bcec3192288b7 Mon Sep 17 00:00:00 2001 From: Lanskikh Date: Thu, 27 Feb 2025 10:40:29 +0500 Subject: [PATCH] upd --- .env | 16 ++++ .vscode/settings.json | 3 + bun.config.ts | 8 ++ bun.lockb | Bin 0 -> 102211 bytes drizzle.config.ts | 10 +++ env.d.ts | 20 +++++ package.json | 23 ++++- src/config/s3client.ts | 10 +++ src/controllers/articles.ts | 116 +++++++++++++++++++++++++ src/controllers/auth.ts | 41 +++++++++ src/controllers/companies.ts | 63 ++++++++++++++ src/controllers/getRegionName.ts | 17 ++++ src/controllers/index.ts | 4 + src/controllers/mail.ts | 51 +++++++++++ src/controllers/mapVideos.ts | 49 +++++++++++ src/controllers/projects.ts | 105 +++++++++++++++++++++++ src/controllers/stories.ts | 123 +++++++++++++++++++++++++++ src/controllers/upload.ts | 43 ++++++++++ src/db/index.ts | 7 ++ src/db/schema/admins.ts | 13 +++ src/db/schema/articles.ts | 20 +++++ src/db/schema/companies.ts | 15 ++++ src/db/schema/index.ts | 7 ++ src/db/schema/mapVideos.ts | 17 ++++ src/db/schema/projects.ts | 29 +++++++ src/db/schema/stories.ts | 15 ++++ src/db/schema/tokens.ts | 19 +++++ src/index.ts | 42 +++++++-- src/middlewares/auth.ts | 54 ++++++++++++ src/services/admins/getByUsername.ts | 21 +++++ src/services/articles/create.ts | 20 +++++ src/services/articles/getAll.ts | 20 +++++ src/services/articles/getCount.ts | 20 +++++ src/services/articles/getDrafted.ts | 24 ++++++ src/services/articles/getOne.ts | 17 ++++ src/services/articles/index.ts | 5 ++ src/services/articles/remove.ts | 26 ++++++ src/services/articles/update.ts | 31 +++++++ src/services/auth/generateTokens.ts | 44 ++++++++++ src/services/auth/index.ts | 3 + src/services/auth/login.ts | 25 ++++++ src/services/auth/logout.ts | 38 +++++++++ src/services/auth/refresh.ts | 31 +++++++ src/services/companies/create.ts | 11 +++ src/services/companies/getByCity.ts | 22 +++++ src/services/companies/getCount.ts | 21 +++++ src/services/companies/getMany.ts | 46 ++++++++++ src/services/companies/index.ts | 5 ++ src/services/companies/remove.ts | 19 +++++ src/services/companies/update.ts | 23 +++++ src/services/mapVideos/create.ts | 16 ++++ src/services/mapVideos/delete.ts | 18 ++++ src/services/mapVideos/getAll.ts | 11 +++ src/services/mapVideos/update.ts | 24 ++++++ src/services/projects/create.ts | 15 ++++ src/services/projects/getCount.ts | 27 ++++++ src/services/projects/getMany.ts | 27 ++++++ src/services/projects/getOne.ts | 22 +++++ src/services/projects/index.ts | 6 ++ src/services/projects/remove.ts | 30 +++++++ src/services/projects/update.ts | 23 +++++ src/types/article.ts | 44 ++++++++++ src/utils/aggregateOneToMany.ts | 21 +++++ src/utils/generateToken.ts | 23 +++++ src/utils/uploadMedia.ts | 0 src/utils/verifyToken.ts | 18 ++++ tsconfig.json | 31 ++++--- 67 files changed, 1749 insertions(+), 19 deletions(-) create mode 100644 .env create mode 100644 .vscode/settings.json create mode 100644 bun.config.ts create mode 100644 bun.lockb create mode 100644 drizzle.config.ts create mode 100644 env.d.ts create mode 100644 src/config/s3client.ts create mode 100644 src/controllers/articles.ts create mode 100644 src/controllers/auth.ts create mode 100644 src/controllers/companies.ts create mode 100644 src/controllers/getRegionName.ts create mode 100644 src/controllers/index.ts create mode 100644 src/controllers/mail.ts create mode 100644 src/controllers/mapVideos.ts create mode 100644 src/controllers/projects.ts create mode 100644 src/controllers/stories.ts create mode 100644 src/controllers/upload.ts create mode 100644 src/db/index.ts create mode 100644 src/db/schema/admins.ts create mode 100644 src/db/schema/articles.ts create mode 100644 src/db/schema/companies.ts create mode 100644 src/db/schema/index.ts create mode 100644 src/db/schema/mapVideos.ts create mode 100644 src/db/schema/projects.ts create mode 100644 src/db/schema/stories.ts create mode 100644 src/db/schema/tokens.ts create mode 100644 src/middlewares/auth.ts create mode 100644 src/services/admins/getByUsername.ts create mode 100644 src/services/articles/create.ts create mode 100644 src/services/articles/getAll.ts create mode 100644 src/services/articles/getCount.ts create mode 100644 src/services/articles/getDrafted.ts create mode 100644 src/services/articles/getOne.ts create mode 100644 src/services/articles/index.ts create mode 100644 src/services/articles/remove.ts create mode 100644 src/services/articles/update.ts create mode 100644 src/services/auth/generateTokens.ts create mode 100644 src/services/auth/index.ts create mode 100644 src/services/auth/login.ts create mode 100644 src/services/auth/logout.ts create mode 100644 src/services/auth/refresh.ts create mode 100644 src/services/companies/create.ts create mode 100644 src/services/companies/getByCity.ts create mode 100644 src/services/companies/getCount.ts create mode 100644 src/services/companies/getMany.ts create mode 100644 src/services/companies/index.ts create mode 100644 src/services/companies/remove.ts create mode 100644 src/services/companies/update.ts create mode 100644 src/services/mapVideos/create.ts create mode 100644 src/services/mapVideos/delete.ts create mode 100644 src/services/mapVideos/getAll.ts create mode 100644 src/services/mapVideos/update.ts create mode 100644 src/services/projects/create.ts create mode 100644 src/services/projects/getCount.ts create mode 100644 src/services/projects/getMany.ts create mode 100644 src/services/projects/getOne.ts create mode 100644 src/services/projects/index.ts create mode 100644 src/services/projects/remove.ts create mode 100644 src/services/projects/update.ts create mode 100644 src/types/article.ts create mode 100644 src/utils/aggregateOneToMany.ts create mode 100644 src/utils/generateToken.ts create mode 100644 src/utils/uploadMedia.ts create mode 100644 src/utils/verifyToken.ts diff --git a/.env b/.env new file mode 100644 index 0000000..25b8521 --- /dev/null +++ b/.env @@ -0,0 +1,16 @@ +POSTGRES_URI=postgres://postgres:admin@192.168.1.250:5432/postgres +DB_HOST=192.168.1.250:5432 +DB_USER=postgres +DB_PASSWORD=admin +DB_DATABASE=postgres +PORT=3001 +JWT_ACCESS_SECRET=aboba +JWT_REFRESH_SECRET=aboba +JWT_ACCESS_EXP_TIME=30d +JWT_REFRESH_EXP_TIME=30d +NODE_ENV=development +S3_REGION=ru-central1 +S3_ENDPOINT=https://storage.yandexcloud.net +S3_ACCESS_KEY_ID=YCAJE7XefUV51hyi9GEdld8S3 +S3_ACCESS_KEY=YCPY__ni1vs95aDjhutAlF8xX0kg3XP6Lbj9PifZ +S3_BUCKET=dult-faib-knac-fint \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d3cb2ac --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "postman.settings.dotenv-detection-notification-visibility": false +} diff --git a/bun.config.ts b/bun.config.ts new file mode 100644 index 0000000..53cdc8d --- /dev/null +++ b/bun.config.ts @@ -0,0 +1,8 @@ +import { Glob } from 'bun'; + +for (const entrypoint of new Glob('src/**/*.ts').scanSync()) + await Bun.build({ + entrypoints: [entrypoint], + target: 'bun', + outdir: './dist/' + entrypoint.split('\\').slice(1, -1).join('\\'), + }); diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..654d87bf59bd55197b10b5a0c5b43b7e9146e7b6 GIT binary patch literal 102211 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
  • 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 J$^XLt`#+8AQe^-D literal 0 HcmV?d00001 diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..8a4ec96 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'postgresql', + schema: './src/db/schema/index.ts', + out: './drizzle', + dbCredentials: { + url: process.env.POSTGRES_URI!, + }, +}); diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 0000000..89fb8f6 --- /dev/null +++ b/env.d.ts @@ -0,0 +1,20 @@ +declare namespace NodeJS { + interface ProcessEnv { + readonly POSTGRES_URI: string; + readonly DB_HOST: string; + readonly DB_USER: string; + readonly DB_PASSWORD: string; + readonly DB_DATABASE: string; + readonly PORT: string; + readonly JWT_ACCESS_EXP_TIME: string; + readonly JWT_REFRESH_EXP_TIME: string; + readonly JWT_ACCESS_SECRET: string; + readonly JWT_REFRESH_SECRET: string; + readonly NODE_ENV: string; + readonly S3_REGION: string; + readonly S3_ENDPOINT: string; + readonly S3_ACCESS_KEY_ID: string; + readonly S3_ACCESS_KEY: string; + readonly S3_BUCKET: string; + } +} diff --git a/package.json b/package.json index 7bed343..4119a41 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,28 @@ "version": "1.0.50", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "bun run --watch src/index.ts" + "dev": "bun --hot ./src", + "push": "drizzle-kit push" }, "dependencies": { - "elysia": "latest" + "@aws-sdk/client-s3": "^3.709.0", + "@elysiajs/cors": "1.1.1", + "cron": "^3.3.1", + "drizzle-orm": "^0.38.4", + "drizzle-typebox": "^0.2.0", + "elysia": "latest", + "jose": "^5.9.6", + "nodemailer": "^6.10.0", + "postgres": "^3.4.5", + "sharp": "^0.33.5", + "transliteration": "^2.3.5" }, "devDependencies": { - "bun-types": "latest" + "@types/bun": "^1.2.2", + "@types/nodemailer": "^6.4.17", + "bun-types": "latest", + "drizzle-kit": "^0.30.0", + "typescript": "^5.7.2" }, "module": "src/index.js" -} \ No newline at end of file +} diff --git a/src/config/s3client.ts b/src/config/s3client.ts new file mode 100644 index 0000000..aae57f8 --- /dev/null +++ b/src/config/s3client.ts @@ -0,0 +1,10 @@ +import { S3Client } from '@aws-sdk/client-s3'; + +export const s3client = new S3Client({ + region: process.env.S3_REGION, + endpoint: process.env.S3_ENDPOINT, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_ACCESS_KEY, + }, +}); diff --git a/src/controllers/articles.ts b/src/controllers/articles.ts new file mode 100644 index 0000000..84e6f43 --- /dev/null +++ b/src/controllers/articles.ts @@ -0,0 +1,116 @@ +import Elysia, { t } from 'elysia'; +import { authMiddleware } from '../middlewares/auth'; +import { getAll, getOne, create, remove, update } from '../services/articles'; +import { Block } from '../types/article'; +import { getCount } from '../services/articles/getCount'; +import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; +import { articlesTable } from '../db/schema'; +import { getDrafted } from '../services/articles/getDrafted'; + +const getArticle = createSelectSchema(articlesTable); + +const insertArticle = createInsertSchema(articlesTable, { + createdAt: (_) => t.Date(), + blocks: t.String(), +}); + +export const articlesController = new Elysia({ prefix: '/articles' }) + .get( + '/', + async ({ query: { tags = [], offset = 0, limit = 10 } }) => + await getAll(tags, offset, limit), + { + query: t.Partial( + t.Object({ + tags: t.Array(t.String()), + offset: t.Number({ default: 0 }), + limit: t.Number({ default: 10 }), + }) + ), + response: { + 200: t.Array(getArticle), + 500: t.ObjectString({}), + }, + } + ) + .get('/:slug', async ({ params: { slug } }) => await getOne(slug), { + params: t.Object({ slug: t.String() }), + response: { + 200: getArticle, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }) + .get('/count', async ({ query: { tags = [] } }) => await getCount(tags), { + query: t.Partial( + t.Object({ + tags: t.Array(t.String()), + }) + ), + response: { + 200: t.Number(), + 500: t.ObjectString({}), + }, + }) + .use(authMiddleware) + .get( + '/drafted', + async ({ query: { limit = 100, offset = 0, tags = [] } }) => + await getDrafted(tags, offset, limit), + { + query: t.Partial( + t.Object({ + tags: t.Array(t.String()), + offset: t.Number({ default: 0 }), + limit: t.Number({ default: 100 }), + }) + ), + response: { + 200: t.Array(getArticle), + 500: t.ObjectString({}), + }, + } + ) + .post( + '/', + async ({ body }) => + await create({ + ...body, + blocks: JSON.parse(body.blocks), + }), + { + body: insertArticle, + response: { + 200: getArticle, + 400: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ) + .delete('/:id', async ({ params: { id } }) => await remove(id), { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + response: { + 200: getArticle, + 400: t.ObjectString({}), + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }) + .put( + '/:id', + async ({ params: { id }, body }) => + await update(id, { + ...body, + blocks: JSON.parse(body.blocks), + }), + { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: insertArticle, + response: { + 200: getArticle, + 400: t.ObjectString({}), + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ); diff --git a/src/controllers/auth.ts b/src/controllers/auth.ts new file mode 100644 index 0000000..22d0d08 --- /dev/null +++ b/src/controllers/auth.ts @@ -0,0 +1,41 @@ +import Elysia, { t } from 'elysia'; +import { authMiddleware } from '../middlewares/auth'; +import { login, logout, refresh } from '../services/auth'; + +export const authController = new Elysia({ prefix: '/auth' }) + .post('/login', async ({ body, cookie }) => await login(body, cookie), { + body: t.Object({ + username: t.String(), + password: t.String({ minLength: 6 }), + }), + response: { + 200: t.Object({ accessToken: t.String(), refreshToken: t.String() }), + 401: t.ObjectString({}), + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }) + .use(authMiddleware) + .get('/check', async (context) => { + return { + auth: 'adminId' in context && context.adminId, + }; + }) + .get( + '/logout', + async ({ cookie, adminId }) => await logout(cookie, adminId), + { + cookie: t.Cookie({ accessToken: t.String(), refreshToken: t.String() }), + adminId: t.String(), + response: { + 200: t.Object({ success: t.Boolean() }), + 400: t.ObjectString({}), + 401: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ) + .get( + '/refresh', + async ({ cookie, adminId }) => await refresh(cookie, adminId) + ); diff --git a/src/controllers/companies.ts b/src/controllers/companies.ts new file mode 100644 index 0000000..2f46227 --- /dev/null +++ b/src/controllers/companies.ts @@ -0,0 +1,63 @@ +import Elysia, { t } from 'elysia'; +import { + getMany, + getCount, + create, + update, + remove, +} from '../services/companies'; +import { authMiddleware } from '../middlewares/auth'; +import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; +import { companiesTable } from '../db/schema'; +import { getByCity } from '../services/companies/getByCity'; + +const createCompany = createInsertSchema(companiesTable); + +const getCompany = createSelectSchema(companiesTable, { + id: t.String({ + format: 'uuid', + default: '00000000-0000-0000-0000-000000000000', + }), +}); + +export const companiesController = new Elysia({ prefix: '/companies' }) + .get( + '/', + async ({ query: { city } }) => + city ? await getByCity(city) : await getMany(), + { + query: t.Partial(t.Object({ city: t.String() })), + response: { + 200: t.Array(getCompany), + 500: t.ObjectString({}), + }, + } + ) + .get('/count', async () => await getCount(), { + response: { 200: t.Number(), 500: t.ObjectString({}) }, + }) + .use(authMiddleware) + .post('/', async ({ body }) => await create(body), { + body: createCompany, + response: { + 200: getCompany, + 500: t.ObjectString({}), + }, + }) + .put('/:id', async ({ params: { id }, body }) => await update(id, body), { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: createCompany, + response: { + 200: getCompany, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }) + .delete('/:id', async ({ params: { id } }) => await remove(id), { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + response: { + 200: getCompany, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }); diff --git a/src/controllers/getRegionName.ts b/src/controllers/getRegionName.ts new file mode 100644 index 0000000..0ae5f5b --- /dev/null +++ b/src/controllers/getRegionName.ts @@ -0,0 +1,17 @@ +import Elysia, { t } from 'elysia'; + +export const getReionNameController = new Elysia({ + prefix: '/getRegionName', +}).get( + '/', + async ({ headers }) => { + const ip = headers['X-Forwarded-For']; + try { + const res = (await fetch(`http://ip-api.com/json/${ip}?lang=ru`)).json(); + console.log(res); + } catch (error) {} + }, + { + headers: t.Object({ 'X-Forwarded-For': t.String({ format: 'ipv4' }) }), + } +); diff --git a/src/controllers/index.ts b/src/controllers/index.ts new file mode 100644 index 0000000..bf0ea7a --- /dev/null +++ b/src/controllers/index.ts @@ -0,0 +1,4 @@ +export * from './articles'; +export * from './auth'; +export * from './projects'; +export * from './upload'; diff --git a/src/controllers/mail.ts b/src/controllers/mail.ts new file mode 100644 index 0000000..3283297 --- /dev/null +++ b/src/controllers/mail.ts @@ -0,0 +1,51 @@ +import Elysia, { t } from 'elysia'; +import nodemailer from 'nodemailer'; + +export const mailController = new Elysia({ prefix: '/mail' }).post( + '/', + async ({ + headers: { referer }, + body: { email, fullname, phone, products }, + }) => { + const url = new URL(referer); + + let transporter = nodemailer.createTransport({ + host: 'mail.netangels.ru', + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: 'test@graff.tech', // generated ethereal user + pass: 'ZmL0pKiDFWUyCDMq', // generated ethereal password + }, + }); + + let info = await transporter.sendMail({ + from: email, // sender address + to: 'info@graff.tech', // list of receivers + subject: `Заявка с сайта ${url.host}`, // Subject line + text: ` + Имя Фамилия: ${fullname} + Email: ${email} + Телефон: ${phone} + Продукты: ${products.join(', ')} + `, // plain text body + html: `
    +

    Имя: ${fullname}

    +

    Email: ${email}

    +

    Телефон: ${phone}

    +

    Продукты: ${products.join(', ')}

    +
    `, // html body + }); + + return info; + }, + { + headers: t.Object({ referer: t.String() }), + body: t.Object({ + fullname: t.String(), + email: t.String({ format: 'email' }), + phone: t.String(), + products: t.Array(t.String()), + }), + } +); diff --git a/src/controllers/mapVideos.ts b/src/controllers/mapVideos.ts new file mode 100644 index 0000000..b3bf5e0 --- /dev/null +++ b/src/controllers/mapVideos.ts @@ -0,0 +1,49 @@ +import { getAll } from '../services/mapVideos/getAll'; +import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; +import Elysia, { t } from 'elysia'; +import { mapVideosTable } from '../db/schema'; +import { authMiddleware } from '../middlewares/auth'; +import { createMapVideo } from '../services/mapVideos/create'; +import { updateMapVideo } from '../services/mapVideos/update'; +import { deleteMapVideo } from '../services/mapVideos/delete'; + +const getMapVideosSchema = createSelectSchema(mapVideosTable); + +const createMapVideoSchema = createInsertSchema(mapVideosTable); + +export const mapVideosController = new Elysia({ prefix: '/mapVideos' }) + .get('/', async () => await getAll(), { + response: { + 200: t.Array(getMapVideosSchema), + 500: t.ObjectString({}), + }, + }) + .use(authMiddleware) + .post('/', async ({ body }) => await createMapVideo(body), { + body: createMapVideoSchema, + response: { + 200: getMapVideosSchema, + 500: t.ObjectString({}), + }, + }) + .put( + '/:id', + async ({ body, params: { id } }) => await updateMapVideo(id, body), + { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: createMapVideoSchema, + response: { + 200: getMapVideosSchema, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ) + .delete('/:id', async ({ params: { id } }) => await deleteMapVideo(id), { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + response: { + 200: getMapVideosSchema, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }); diff --git a/src/controllers/projects.ts b/src/controllers/projects.ts new file mode 100644 index 0000000..be24ea9 --- /dev/null +++ b/src/controllers/projects.ts @@ -0,0 +1,105 @@ +import Elysia, { t } from 'elysia'; +import { + create, + getCount, + getMany, + getOne, + remove, + update, +} from '../services/projects'; +import { authMiddleware } from '../middlewares/auth'; +import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; +import { projectsTable } from '../db/schema'; + +const createProject = createInsertSchema(projectsTable, { + releaseDate: (_) => t.Date(), +}); + +const getProject = createSelectSchema(projectsTable, { + id: (_) => + t.String({ + format: 'uuid', + default: '00000000-0000-0000-0000-000000000000', + }), + companyId: (_) => + t.Optional( + t.String({ + format: 'uuid', + default: '00000000-0000-0000-0000-000000000000', + }) + ), +}); + +export const projectsController = new Elysia({ prefix: '/projects' }) + .get( + '/', + async ({ query: { tags, city, limit, companyId } }) => + await getMany(tags, city, limit, companyId), + { + query: t.Partial( + t.Object({ + city: t.String(), + tags: t.Array(t.String()), + companyId: t.String({ format: 'uuid' }), + limit: t.Number(), + }) + ), + response: { + 200: t.Array(getProject), + 500: t.ObjectString({}), + }, + } + ) + .get( + '/count', + async ({ query: { city, tags } }) => await getCount(tags, city), + { + query: t.Object({ + city: t.Optional(t.String()), + tags: t.Optional(t.Array(t.String())), + }), + response: { + 200: t.Integer(), + 500: t.ObjectString({}), + }, + } + ) + .get('/:id', async ({ params: { id } }) => await getOne(id), { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + response: { + 200: getProject, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }) + .use(authMiddleware) + .post('/', async ({ body }) => await create(body), { + body: createProject, + response: { + 200: getProject, + 422: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }) + .delete('/:id', async ({ params: { id } }) => await remove(id), { + params: t.Object({ + id: t.String({ + format: 'uuid', + default: '00000000-0000-0000-0000-000000000000', + }), + }), + response: { + 200: getProject, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }) + .put('/:id', async ({ body, params: { id } }) => await update(id, body), { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: createProject, + response: { + 200: getProject, + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + }); diff --git a/src/controllers/stories.ts b/src/controllers/stories.ts new file mode 100644 index 0000000..007f698 --- /dev/null +++ b/src/controllers/stories.ts @@ -0,0 +1,123 @@ +import Elysia, { error, t } from 'elysia'; +import { db } from '../db'; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from 'drizzle-typebox'; +import { storiesTable } from '../db/schema'; +import { asc, eq } from 'drizzle-orm'; +import { authMiddleware } from '../middlewares/auth'; + +const getStory = createSelectSchema(storiesTable); + +const createStory = createInsertSchema(storiesTable, { + createdAt: (_) => t.Date(), +}); + +const updateStory = createUpdateSchema(storiesTable, { + createdAt: (_) => t.Date(), +}); + +export const storiesController = new Elysia({ prefix: '/stories' }) + .get( + '/', + async () => { + try { + return await db.query.storiesTable.findMany({ + orderBy: asc(storiesTable.createdAt), + }); + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } + }, + { + response: { + 200: t.Array(getStory), + 500: t.ObjectString({}), + }, + } + ) + .use(authMiddleware) + .post( + '/', + async ({ body }) => { + try { + const res = await db.insert(storiesTable).values(body).returning(); + if (!res) return error(400, { error: 'Story not created' }); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } + }, + { + body: createStory, + response: { + 200: getStory, + 400: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ) + .delete( + '/:id', + async ({ params: { id } }) => { + try { + const candidate = await db.query.storiesTable.findFirst({ + where: eq(storiesTable.id, id), + }); + if (!candidate) return error(404, { error: 'Story not found' }); + const res = await db + .delete(storiesTable) + .where(eq(storiesTable.id, id)) + .returning(); + if (!res) return error(400, { error: 'Story not deleted' }); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } + }, + { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + response: { + 200: getStory, + 400: t.ObjectString({}), + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ) + .put( + '/:id', + async ({ params: { id }, body }) => { + try { + const candidate = await db.query.storiesTable.findFirst({ + where: eq(storiesTable.id, id), + }); + if (!candidate) return error(404, { error: 'Story not found' }); + const res = await db + .update(storiesTable) + .set(body) + .where(eq(storiesTable.id, id)) + .returning(); + if (!res) return error(400, { error: 'Story not updated' }); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } + }, + { + params: t.Object({ id: t.String({ format: 'uuid' }) }), + body: updateStory, + response: { + 200: getStory, + 400: t.ObjectString({}), + 404: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ); diff --git a/src/controllers/upload.ts b/src/controllers/upload.ts new file mode 100644 index 0000000..29b8979 --- /dev/null +++ b/src/controllers/upload.ts @@ -0,0 +1,43 @@ +import Elysia, { error, t } from 'elysia'; +import { authMiddleware } from '../middlewares/auth'; +import { s3client } from '../config/s3client'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { randomUUIDv7 } from 'bun'; + +export const uploadController = new Elysia({ prefix: '/upload' }) + .use(authMiddleware) + .post( + '/', + async ({ body: { dest, files } }) => { + if (!files.length) return error(422, { message: 'No files' }); + try { + const filesPaths: string[] = []; + for (const file of files) { + const title = `${randomUUIDv7()}.${file.name.split('.')[1]}`; + await s3client.send( + new PutObjectCommand({ + Bucket: process.env.S3_BUCKET, + Key: `${dest}/${title}`, + Body: Buffer.from(await file.arrayBuffer()), + }) + ); + filesPaths.push(`${dest}/${title}`); + } + return filesPaths; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (File uploading)' }); + } + }, + { + body: t.Object({ + files: t.Files({ type: ['image', 'video'] }), + dest: t.String(), + }), + response: { + 200: t.Array(t.String()), + 422: t.ObjectString({}), + 500: t.ObjectString({}), + }, + } + ); diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..bad296f --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,7 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import * as schema from './schema'; +import postgres from 'postgres'; + +const client = postgres(process.env.POSTGRES_URI); + +export const db = drizzle(client, { schema }); diff --git a/src/db/schema/admins.ts b/src/db/schema/admins.ts new file mode 100644 index 0000000..9e069d4 --- /dev/null +++ b/src/db/schema/admins.ts @@ -0,0 +1,13 @@ +import { relations } from 'drizzle-orm'; +import { pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import { tokensTable } from './tokens.ts'; + +export const adminsTable = pgTable('admins', { + id: uuid('id').defaultRandom().primaryKey(), + username: text('username').notNull().unique(), + hashedPassword: text('hashed_password').notNull(), +}); + +export const adminsTokens = relations(adminsTable, ({ many }) => ({ + tokens: many(tokensTable), +})); diff --git a/src/db/schema/articles.ts b/src/db/schema/articles.ts new file mode 100644 index 0000000..6e47ca6 --- /dev/null +++ b/src/db/schema/articles.ts @@ -0,0 +1,20 @@ +import { pgTable, uuid, text, json, timestamp } from 'drizzle-orm/pg-core'; +import { Block } from '../../types/article.ts'; +import { boolean } from 'drizzle-orm/pg-core'; + +export const articlesTable = pgTable('articles', { + id: uuid('id').defaultRandom().primaryKey(), + title: text('title').notNull().unique(), + tags: text('tags').array().notNull(), + createdAt: timestamp('created_at', { + mode: 'date', + withTimezone: true, + }) + .notNull() + .defaultNow(), + cardImage: text('card_image').notNull(), + posterImage: text('poster_image').notNull(), + blocks: json('blocks').$type().notNull(), + drafted: boolean('drafted').notNull().default(true), + slug: text('slug').unique(), +}); diff --git a/src/db/schema/companies.ts b/src/db/schema/companies.ts new file mode 100644 index 0000000..6e5378e --- /dev/null +++ b/src/db/schema/companies.ts @@ -0,0 +1,15 @@ +import { relations } from 'drizzle-orm'; +import { text, uuid, varchar, pgTable } from 'drizzle-orm/pg-core'; +import { projectsTable } from './projects'; + +export const companiesTable = pgTable('companies', { + id: uuid().defaultRandom().primaryKey(), + title: varchar('title', { length: 50 }).notNull(), + color: varchar('color', { length: 9 }).default('#ffffff'), + mapIcon: text('map_icon'), + logo: text('logo'), +}); + +export const companiesRelations = relations(companiesTable, ({ many }) => ({ + projects: many(projectsTable), +})); diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts new file mode 100644 index 0000000..81fbbc8 --- /dev/null +++ b/src/db/schema/index.ts @@ -0,0 +1,7 @@ +export * from './admins.ts'; +export * from './articles.ts'; +export * from './projects.ts'; +export * from './tokens.ts'; +export * from './companies.ts'; +export * from './stories.ts'; +export * from './mapVideos.ts'; diff --git a/src/db/schema/mapVideos.ts b/src/db/schema/mapVideos.ts new file mode 100644 index 0000000..6a8d22f --- /dev/null +++ b/src/db/schema/mapVideos.ts @@ -0,0 +1,17 @@ +import { pgTable, text, uuid, varchar } from 'drizzle-orm/pg-core'; +import { companiesTable } from './companies'; +import { relations } from 'drizzle-orm'; + +export const mapVideosTable = pgTable('map_videos', { + id: uuid('id').defaultRandom().primaryKey(), + city: varchar('city', { length: 50 }).notNull(), + video: text('video').notNull(), + companyId: uuid('company_id').references(() => companiesTable.id), +}); + +export const videosRelations = relations(mapVideosTable, ({ one }) => ({ + company: one(companiesTable, { + fields: [mapVideosTable.companyId], + references: [companiesTable.id], + }), +})); diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts new file mode 100644 index 0000000..a1ef5e9 --- /dev/null +++ b/src/db/schema/projects.ts @@ -0,0 +1,29 @@ +import { + date, + integer, + pgTable, + uuid, + text, + varchar, +} from 'drizzle-orm/pg-core'; +import { companiesTable } from './companies'; +import { relations } from 'drizzle-orm'; + +export const projectsTable = pgTable('projects', { + id: uuid('id').defaultRandom().primaryKey(), + title: varchar('title', { length: 50 }).notNull().unique(), + description: varchar('description', { length: 100 }).notNull().default(''), + city: varchar('city', { length: 50 }).notNull(), + image: text('image').notNull(), + stage: integer('stage').notNull().default(1), + releaseDate: date('release_date', { mode: 'date' }).notNull(), + tags: varchar('tags').array().notNull(), + companyId: uuid('company_id').references(() => companiesTable.id), +}); + +export const projectsRelations = relations(projectsTable, ({ one }) => ({ + company: one(companiesTable, { + fields: [projectsTable.companyId], + references: [companiesTable.id], + }), +})); diff --git a/src/db/schema/stories.ts b/src/db/schema/stories.ts new file mode 100644 index 0000000..670b40f --- /dev/null +++ b/src/db/schema/stories.ts @@ -0,0 +1,15 @@ +import { text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { pgTable } from 'drizzle-orm/pg-core'; + +export const storiesTable = pgTable('stories', { + id: uuid('id').defaultRandom().primaryKey(), + video: text('video').notNull(), + text: text('text'), + preview: text('preview'), + createdAt: timestamp('created_at', { + mode: 'date', + withTimezone: true, + }) + .notNull() + .defaultNow(), +}); diff --git a/src/db/schema/tokens.ts b/src/db/schema/tokens.ts new file mode 100644 index 0000000..2ca958d --- /dev/null +++ b/src/db/schema/tokens.ts @@ -0,0 +1,19 @@ +import { pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import { adminsTable } from './admins.ts'; +import { relations } from 'drizzle-orm'; + +export const tokensTable = pgTable('tokens', { + id: uuid('id').defaultRandom().primaryKey(), + accessToken: text('access_token').unique(), + refreshToken: text('refresh_token').unique(), + adminId: uuid('user_id') + .notNull() + .references(() => adminsTable.id), +}); + +export const tokensAdmins = relations(tokensTable, ({ one }) => ({ + admins: one(adminsTable, { + fields: [tokensTable.adminId], + references: [adminsTable.id], + }), +})); diff --git a/src/index.ts b/src/index.ts index 9c1f7a1..5d459d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,39 @@ -import { Elysia } from "elysia"; +import Elysia from 'elysia'; +import { + authController, + articlesController, + projectsController, + uploadController, +} from './controllers'; +import { cors } from '@elysiajs/cors'; +import { companiesController } from './controllers/companies'; +import { mailController } from './controllers/mail'; +import { getReionNameController } from './controllers/getRegionName'; +import { storiesController } from './controllers/stories'; +import { mapVideosController } from './controllers/mapVideos'; +import { db } from './db'; +import { projectsTable } from './db/schema'; +import { eq, ne } from 'drizzle-orm'; -const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); +try { + const app = new Elysia(); -console.log( - `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` -); + app + .use( + cors({ + origin: true, + }) + ) + .use(authController) + .use(projectsController) + .use(articlesController) + .use(uploadController) + .use(companiesController) + .use(storiesController) + .use(mapVideosController) + .use(mailController) + .use(getReionNameController) + .listen(process.env.PORT); +} catch (error) { + console.log(error); +} diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts new file mode 100644 index 0000000..6210ccb --- /dev/null +++ b/src/middlewares/auth.ts @@ -0,0 +1,54 @@ +import { eq } from 'drizzle-orm'; +import Elysia, { Context, error } from 'elysia'; +import { db } from '../db'; +import { verifyToken } from '../utils/verifyToken'; +import { adminsTable } from '../db/schema'; + +export const authMiddleware = new Elysia() + .derive({ as: 'scoped' }, async ({ headers, cookie, path }: Context) => { + const token = + (path === '/auth/refresh' + ? cookie.refreshToken.value + : cookie.accessToken.value) || headers.authorization; + + const payload = await verifyToken( + path === '/auth/refresh' ? 'refresh' : 'access', + token + ); + + if (!token || !payload) + return path === '/auth/check' + ? { adminId: '' } + : error(401, { error: 'Not authorized' }); + + const { adminId } = payload; + + try { + const user = await db.query.adminsTable.findFirst({ + where: eq(adminsTable.id, adminId), + columns: { hashedPassword: false }, + with: { tokens: true }, + }); + + if ( + !user || + user.tokens.every( + ({ accessToken, refreshToken }) => + token !== (path === '/auth/refresh' ? refreshToken : accessToken) + ) + ) { + console.log("attempt to login with someone else's token"); + return error(401, { error: 'Not authorized' }); + } + } catch (err) { + console.log((err as Error).message); + return error(500, { + error: 'Something went wrong (Postgres select)', + }); + } + + return { + adminId, + }; + }) + .onError({ as: 'scoped' }, ({ error }) => error); diff --git a/src/services/admins/getByUsername.ts b/src/services/admins/getByUsername.ts new file mode 100644 index 0000000..d4eac58 --- /dev/null +++ b/src/services/admins/getByUsername.ts @@ -0,0 +1,21 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { adminsTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getUserByUsername(username: string) { + let user; + + try { + user = await db.query.adminsTable.findFirst({ + where: eq(adminsTable.username, username), + }); + + if (!user) return error(404, { error: 'User not found' }); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres select)' }); + } + + return user; +} diff --git a/src/services/articles/create.ts b/src/services/articles/create.ts new file mode 100644 index 0000000..d7c82c7 --- /dev/null +++ b/src/services/articles/create.ts @@ -0,0 +1,20 @@ +import { error } from 'elysia'; +import { db } from '../../db'; +import { articlesTable } from '../../db/schema'; +import { slugify } from 'transliteration'; + +export async function create(input: typeof articlesTable.$inferInsert) { + try { + const res = await db + .insert(articlesTable) + .values({ ...input, slug: !input.drafted ? slugify(input.title) : null }) + .returning(); + + if (!res.length) return error(400, { error: 'Article not created' }); + + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres insert)' }); + } +} diff --git a/src/services/articles/getAll.ts b/src/services/articles/getAll.ts new file mode 100644 index 0000000..40296d4 --- /dev/null +++ b/src/services/articles/getAll.ts @@ -0,0 +1,20 @@ +import { and, arrayContains, not } from 'drizzle-orm'; +import { db } from '../../db'; +import { articlesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getAll(tags: string[] = [], offset = 0, limit = 10) { + try { + return await db.query.articlesTable.findMany({ + offset, + limit, + where: and( + not(articlesTable.drafted), + tags.length > 0 ? arrayContains(articlesTable.tags, tags) : undefined + ), + }); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres select)' }); + } +} diff --git a/src/services/articles/getCount.ts b/src/services/articles/getCount.ts new file mode 100644 index 0000000..af75a5b --- /dev/null +++ b/src/services/articles/getCount.ts @@ -0,0 +1,20 @@ +import { arrayContained, count } from 'drizzle-orm'; +import { db } from '../../db'; +import { articlesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getCount(tags: string[] = []) { + try { + return ( + await db + .select({ count: count() }) + .from(articlesTable) + .where( + tags.length > 0 ? arrayContained(articlesTable.tags, tags) : undefined + ) + )[0].count; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres select)' }); + } +} diff --git a/src/services/articles/getDrafted.ts b/src/services/articles/getDrafted.ts new file mode 100644 index 0000000..9a72e50 --- /dev/null +++ b/src/services/articles/getDrafted.ts @@ -0,0 +1,24 @@ +import { and, arrayContains } from 'drizzle-orm'; +import { db } from '../../db'; +import { articlesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getDrafted( + tags: string[] = [], + offset: number, + limit: number +) { + try { + return await db.query.articlesTable.findMany({ + offset, + limit, + where: and( + articlesTable.drafted, + tags.length > 0 ? arrayContains(articlesTable.tags, tags) : undefined + ), + }); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Internal Server Error' }); + } +} diff --git a/src/services/articles/getOne.ts b/src/services/articles/getOne.ts new file mode 100644 index 0000000..e5a3db6 --- /dev/null +++ b/src/services/articles/getOne.ts @@ -0,0 +1,17 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { articlesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getOne(slug: string) { + try { + return ( + (await db.query.articlesTable.findFirst({ + where: eq(articlesTable.slug, slug), + })) ?? error(404, { error: 'Article not found' }) + ); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres select)' }); + } +} diff --git a/src/services/articles/index.ts b/src/services/articles/index.ts new file mode 100644 index 0000000..ad09344 --- /dev/null +++ b/src/services/articles/index.ts @@ -0,0 +1,5 @@ +export * from './getAll'; +export * from './getOne'; +export * from './create'; +export * from './remove'; +export * from './update'; diff --git a/src/services/articles/remove.ts b/src/services/articles/remove.ts new file mode 100644 index 0000000..c8b112c --- /dev/null +++ b/src/services/articles/remove.ts @@ -0,0 +1,26 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { articlesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function remove(id: string) { + try { + const article = await db.query.articlesTable.findFirst({ + where: eq(articlesTable.id, id), + }); + + if (!article) return error(404, { error: 'Article not found' }); + + const res = await db + .delete(articlesTable) + .where(eq(articlesTable.id, id)) + .returning(); + + if (!res.length) return error(400, { error: 'Article not deleted' }); + + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres delete)' }); + } +} diff --git a/src/services/articles/update.ts b/src/services/articles/update.ts new file mode 100644 index 0000000..78cbbb8 --- /dev/null +++ b/src/services/articles/update.ts @@ -0,0 +1,31 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { articlesTable } from '../../db/schema'; +import { error } from 'elysia'; +import { slugify } from 'transliteration'; + +export async function update( + id: string, + input: typeof articlesTable.$inferInsert +) { + try { + const article = await db.query.articlesTable.findFirst({ + where: eq(articlesTable.id, id), + }); + + if (!article) return error(404, { error: 'Article not found' }); + + const res = await db + .update(articlesTable) + .set({ ...input, slug: !input.drafted ? slugify(input.title) : null }) + .where(eq(articlesTable.id, id)) + .returning(); + + if (!res.length) return error(400, { error: 'Article not updated' }); + + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres update)' }); + } +} diff --git a/src/services/auth/generateTokens.ts b/src/services/auth/generateTokens.ts new file mode 100644 index 0000000..90881ee --- /dev/null +++ b/src/services/auth/generateTokens.ts @@ -0,0 +1,44 @@ +import { Cookie, error } from 'elysia'; +import { generateToken } from '../../utils/generateToken'; +import { db } from '../../db'; +import { tokensTable } from '../../db/schema'; + +export async function generateTokens( + adminId: string, + cookie: Record> +) { + const accessToken = await generateToken(adminId, 'access'); + + const refreshToken = await generateToken(adminId, 'refresh'); + + try { + const result = await db + .insert(tokensTable) + .values({ adminId, accessToken, refreshToken }) + .returning(); + + if (!result.length) return error(500, { error: 'Cannot add new user' }); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres insert)' }); + } + + cookie.accessToken.set({ + value: accessToken, + httpOnly: true, + // secure: true, + maxAge: 3600 * 24 * 30, + }); + + cookie.refreshToken.set({ + value: refreshToken, + httpOnly: true, + // secure: true, + maxAge: 3600 * 24 * 30, + }); + + return { + accessToken, + refreshToken, + }; +} diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts new file mode 100644 index 0000000..f4a301b --- /dev/null +++ b/src/services/auth/index.ts @@ -0,0 +1,3 @@ +export * from './login'; +export * from './logout'; +export * from './refresh'; diff --git a/src/services/auth/login.ts b/src/services/auth/login.ts new file mode 100644 index 0000000..234188e --- /dev/null +++ b/src/services/auth/login.ts @@ -0,0 +1,25 @@ +import { Cookie } from 'elysia'; +import { ElysiaCustomStatusResponse, error } from 'elysia/error'; +import { getUserByUsername } from '../admins/getByUsername'; +import { generateTokens } from './generateTokens'; + +export async function login( + body: { username: string; password: string }, + cookie: Record> +) { + const { username, password } = body; + + const user = await getUserByUsername(username); + + if (user instanceof ElysiaCustomStatusResponse) + return error(user.code, user.response.error); + + const passwordMatches = user + ? Bun.password.verifySync(password, user.hashedPassword) + : false; + + if (!user || !passwordMatches) + return error(401, { error: 'Wrong credentials' }); + + return await generateTokens(user.id, cookie); +} diff --git a/src/services/auth/logout.ts b/src/services/auth/logout.ts new file mode 100644 index 0000000..f18934d --- /dev/null +++ b/src/services/auth/logout.ts @@ -0,0 +1,38 @@ +import { Cookie, error } from 'elysia'; +import { db } from '../../db'; +import { tokensTable } from '../../db/schema'; +import { and, eq } from 'drizzle-orm'; + +export async function logout( + cookie: Record> & { + accessToken: Cookie; + refreshToken: Cookie; + }, + adminId: string +) { + try { + const result = await db + .delete(tokensTable) + .where( + and( + eq(tokensTable.adminId, adminId), + eq(tokensTable.accessToken, cookie.accessToken.value!) + ) + ) + .returning(); + + if (result.length === 0) return error(400, { error: 'Cannot logout user' }); + } catch (err) { + console.log((err as Error).message); + return error(500, { + error: 'Something went wrong (Postgres delete)', + }); + } + + cookie.accessToken.remove(); + cookie.refreshToken.remove(); + + return { + success: true, + }; +} diff --git a/src/services/auth/refresh.ts b/src/services/auth/refresh.ts new file mode 100644 index 0000000..b7a2952 --- /dev/null +++ b/src/services/auth/refresh.ts @@ -0,0 +1,31 @@ +import { Cookie, error } from 'elysia'; +import { generateToken } from '../../utils/generateToken'; +import { db } from '../../db'; +import { tokensTable } from '../../db/schema'; +import { eq } from 'drizzle-orm'; + +export async function refresh( + cookie: Record>, + userId: string +) { + const accessToken = await generateToken(userId, 'access'); + + try { + await db + .update(tokensTable) + .set({ accessToken }) + .where(eq(tokensTable.accessToken, cookie.accessToken.value!)); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres update)' }); + } + + cookie.accessToken.set({ + value: accessToken, + httpOnly: true, + // secure: true, + maxAge: 3600 * 24 * 30, + }); + + return { accessToken }; +} diff --git a/src/services/companies/create.ts b/src/services/companies/create.ts new file mode 100644 index 0000000..277a77a --- /dev/null +++ b/src/services/companies/create.ts @@ -0,0 +1,11 @@ +import { error } from 'elysia'; +import { db } from '../../db'; +import { companiesTable } from '../../db/schema'; + +export async function create(body: typeof companiesTable.$inferInsert) { + try { + return (await db.insert(companiesTable).values(body).returning())[0]; + } catch (err) { + return error(500, { error: 'Something went wrong (Postgres insert)' }); + } +} diff --git a/src/services/companies/getByCity.ts b/src/services/companies/getByCity.ts new file mode 100644 index 0000000..52f032e --- /dev/null +++ b/src/services/companies/getByCity.ts @@ -0,0 +1,22 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { companiesTable, projectsTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getByCity(city: string) { + try { + return ( + await db + .selectDistinctOn([projectsTable.companyId]) + .from(projectsTable) + .where(eq(projectsTable.city, city)) + .innerJoin( + companiesTable, + eq(projectsTable.companyId, companiesTable.id) + ) + ).map(({ companies }) => companies); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Internal Server Error' }); + } +} diff --git a/src/services/companies/getCount.ts b/src/services/companies/getCount.ts new file mode 100644 index 0000000..d1b6485 --- /dev/null +++ b/src/services/companies/getCount.ts @@ -0,0 +1,21 @@ +import { count, eq, getTableColumns } from 'drizzle-orm'; +import { db } from '../../db'; +import { companiesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getCount(city?: string) { + try { + // const res = await db + // .select() + // .from(projectsTable) + // .where(city ? eq(projectsTable.city, city) : undefined) + // .groupBy(projectsTable.companyId, projectsTable.id); + // console.log('res', res); + return ( + await db.select({ count: count() }).from(companiesTable).limit(1) + )[0].count; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres select)' }); + } +} diff --git a/src/services/companies/getMany.ts b/src/services/companies/getMany.ts new file mode 100644 index 0000000..6fbf600 --- /dev/null +++ b/src/services/companies/getMany.ts @@ -0,0 +1,46 @@ +import { error } from 'elysia'; +import { db } from '../../db'; +import { companiesTable, projectsTable } from '../../db/schema'; +import { count, desc, eq, getTableColumns } from 'drizzle-orm'; +import { aggregateOneToMany } from '../../utils/aggregateOneToMany'; + +export async function getMany(city?: string) { + try { + const res = await db + .select({ ...companiesTable, projects: projectsTable } as ReturnType< + typeof getTableColumns + > & { + projects: ReturnType>; + }) + .from(companiesTable) + .orderBy( + desc( + db + .select({ count: count() }) + .from(projectsTable) + .where(eq(companiesTable.id, projectsTable.companyId)) + ) + ) + .leftJoin(projectsTable, eq(companiesTable.id, projectsTable.companyId)); + + return aggregateOneToMany(res, 'projects'); + + // or + + // return await db.query.companiesTable.findMany({ + // with: { projects: true }, + // where: title ? eq(companiesTable.title, title) : undefined, + // orderBy: (companiesTable, { desc }) => [ + // desc( + // db + // .select({ count: count() }) + // .from(projectsTable) + // .where(eq(projectsTable.companyId, companiesTable.id)) + // ), + // ], + // }); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres select)' }); + } +} diff --git a/src/services/companies/index.ts b/src/services/companies/index.ts new file mode 100644 index 0000000..fcf3531 --- /dev/null +++ b/src/services/companies/index.ts @@ -0,0 +1,5 @@ +export * from './getMany'; +export * from './getCount'; +export * from './create'; +export * from './update'; +export * from './remove'; diff --git a/src/services/companies/remove.ts b/src/services/companies/remove.ts new file mode 100644 index 0000000..aea4fca --- /dev/null +++ b/src/services/companies/remove.ts @@ -0,0 +1,19 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { companiesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function remove(id: string) { + try { + const res = await db + .delete(companiesTable) + .where(eq(companiesTable.id, id)) + .returning(); + + if (!res.length) return error(404, 'Not Found'); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } +} diff --git a/src/services/companies/update.ts b/src/services/companies/update.ts new file mode 100644 index 0000000..b58935e --- /dev/null +++ b/src/services/companies/update.ts @@ -0,0 +1,23 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { companiesTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function update( + id: string, + data: typeof companiesTable.$inferInsert +) { + try { + const res = await db + .update(companiesTable) + .set(data) + .where(eq(companiesTable.id, id)) + .returning(); + + if (!res.length) return error(404, { error: 'Projects not found' }); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Internal Server Error' }); + } +} diff --git a/src/services/mapVideos/create.ts b/src/services/mapVideos/create.ts new file mode 100644 index 0000000..742f6dc --- /dev/null +++ b/src/services/mapVideos/create.ts @@ -0,0 +1,16 @@ +import { error } from 'elysia'; +import { db } from '../../db'; +import { mapVideosTable } from '../../db/schema'; + +export async function createMapVideo( + paylaod: typeof mapVideosTable.$inferInsert +) { + try { + const res = await db.insert(mapVideosTable).values(paylaod).returning(); + if (!res) return error(500, 'Internal Server Error'); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } +} diff --git a/src/services/mapVideos/delete.ts b/src/services/mapVideos/delete.ts new file mode 100644 index 0000000..79aa005 --- /dev/null +++ b/src/services/mapVideos/delete.ts @@ -0,0 +1,18 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { mapVideosTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function deleteMapVideo(id: string) { + try { + const res = await db + .delete(mapVideosTable) + .where(eq(mapVideosTable.id, id)) + .returning(); + if (!res.length) return error(404, 'Not Found'); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } +} diff --git a/src/services/mapVideos/getAll.ts b/src/services/mapVideos/getAll.ts new file mode 100644 index 0000000..a04f3fa --- /dev/null +++ b/src/services/mapVideos/getAll.ts @@ -0,0 +1,11 @@ +import { error } from 'elysia'; +import { db } from '../../db'; + +export async function getAll() { + try { + return await db.query.mapVideosTable.findMany({ with: { company: true } }); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Internal Server Error' }); + } +} diff --git a/src/services/mapVideos/update.ts b/src/services/mapVideos/update.ts new file mode 100644 index 0000000..9230b68 --- /dev/null +++ b/src/services/mapVideos/update.ts @@ -0,0 +1,24 @@ +import { error } from 'elysia'; +import { db } from '../../db'; +import { mapVideosTable } from '../../db/schema'; +import { eq } from 'drizzle-orm'; + +export async function updateMapVideo( + id: string, + payload: typeof mapVideosTable.$inferInsert +) { + try { + const candidate = await db.query.mapVideosTable.findFirst(); + if (!candidate) return error(404, 'Not Found'); + const res = await db + .update(mapVideosTable) + .set(payload) + .where(eq(mapVideosTable.id, id)) + .returning(); + if (!res) return error(500, 'Internal Server Error'); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, 'Internal Server Error'); + } +} diff --git a/src/services/projects/create.ts b/src/services/projects/create.ts new file mode 100644 index 0000000..3b2cd77 --- /dev/null +++ b/src/services/projects/create.ts @@ -0,0 +1,15 @@ +import { error } from 'elysia'; +import { db } from '../../db'; +import { projectsTable } from '../../db/schema'; + +export async function create(project: typeof projectsTable.$inferInsert) { + try { + const res = await db.insert(projectsTable).values(project).returning(); + + if (res.length === 0) return error(422, { error: 'Unprocessable Content' }); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong' }); + } +} diff --git a/src/services/projects/getCount.ts b/src/services/projects/getCount.ts new file mode 100644 index 0000000..72bcda0 --- /dev/null +++ b/src/services/projects/getCount.ts @@ -0,0 +1,27 @@ +import { and, arrayContains, count, eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { projectsTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getCount(tags: string[] = [], city?: string) { + let res; + + try { + res = await db + .select({ count: count() }) + .from(projectsTable) + .where( + and( + tags.length > 0 ? arrayContains(projectsTable.tags, tags) : undefined, + city ? eq(projectsTable.city, city) : undefined + ) + ) + .limit(1); + + return res[0].count; + } catch (err) { + console.log((err as Error).message); + + return error(500, { error: 'Something went wrong' }); + } +} diff --git a/src/services/projects/getMany.ts b/src/services/projects/getMany.ts new file mode 100644 index 0000000..4cce3d4 --- /dev/null +++ b/src/services/projects/getMany.ts @@ -0,0 +1,27 @@ +import { and, arrayContains, desc, eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { projectsTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getMany( + tags: string[] = [], + city?: string, + limit?: number, + companyId?: string +) { + try { + return await db.query.projectsTable.findMany({ + where: and( + tags.length > 0 ? arrayContains(projectsTable.tags, tags) : undefined, + city ? eq(projectsTable.city, city) : undefined, + companyId ? eq(projectsTable.companyId, companyId) : undefined + ), + with: { company: true }, + orderBy: desc(projectsTable.releaseDate), + limit, + }); + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Internal server error' }); + } +} diff --git a/src/services/projects/getOne.ts b/src/services/projects/getOne.ts new file mode 100644 index 0000000..d99ff2b --- /dev/null +++ b/src/services/projects/getOne.ts @@ -0,0 +1,22 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { projectsTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function getOne(id: string) { + let project; + + try { + project = await db.query.projectsTable.findFirst({ + where: eq(projectsTable.id, id), + with: { company: true }, + }); + + if (!project) return error(404, { error: 'Not found' }); + + return project; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres select)' }); + } +} diff --git a/src/services/projects/index.ts b/src/services/projects/index.ts new file mode 100644 index 0000000..121a0b3 --- /dev/null +++ b/src/services/projects/index.ts @@ -0,0 +1,6 @@ +export * from './create'; +export * from './getCount'; +export * from './getMany'; +export * from './getOne'; +export * from './remove'; +export * from './update'; diff --git a/src/services/projects/remove.ts b/src/services/projects/remove.ts new file mode 100644 index 0000000..cc7c5f5 --- /dev/null +++ b/src/services/projects/remove.ts @@ -0,0 +1,30 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { projectsTable } from '../../db/schema'; +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { s3client } from '../../config/s3client'; +import { error } from 'elysia'; + +export async function remove(id: string) { + try { + const res = await db + .delete(projectsTable) + .where(eq(projectsTable.id, id)) + .returning(); + + if (!res.length) return error(404, { error: 'Project not found' }); + + const project = res[0]; + + await s3client.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: project.image, + }) + ); + return project; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Something went wrong (Postgres delete)' }); + } +} diff --git a/src/services/projects/update.ts b/src/services/projects/update.ts new file mode 100644 index 0000000..cbdfd47 --- /dev/null +++ b/src/services/projects/update.ts @@ -0,0 +1,23 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { projectsTable } from '../../db/schema'; +import { error } from 'elysia'; + +export async function update( + id: string, + data: typeof projectsTable.$inferInsert +) { + try { + const res = await db + .update(projectsTable) + .set(data) + .where(eq(projectsTable.id, id)) + .returning(); + + if (!res.length) return error(404, { error: 'Project not found' }); + return res[0]; + } catch (err) { + console.log((err as Error).message); + return error(500, { error: 'Internal Server Error' }); + } +} diff --git a/src/types/article.ts b/src/types/article.ts new file mode 100644 index 0000000..efe0f0a --- /dev/null +++ b/src/types/article.ts @@ -0,0 +1,44 @@ +export interface IContent { + type: 'Content'; + content: string; +} + +export interface IImage { + img: string; +} + +export interface ISlider { + type: 'Slider'; + images: IImage[]; +} + +export interface IVideo { + type: 'Video'; + src: string; +} + +export interface IQuote { + type: 'Quote'; + avatar: string; + name: string; + position: string; + text: string; +} + +export interface IButtonLink { + type: 'ButtonLink'; + title: string; + link: string; +} + +export type Block = IContent | ISlider | IVideo | IQuote | IButtonLink; + +export interface IArticle { + id: number; + title: string; + description: string; + tags: string[]; + createdAt: Date; + cardImage: string; + blocks: Block[]; +} diff --git a/src/utils/aggregateOneToMany.ts b/src/utils/aggregateOneToMany.ts new file mode 100644 index 0000000..0975a60 --- /dev/null +++ b/src/utils/aggregateOneToMany.ts @@ -0,0 +1,21 @@ +export function aggregateOneToMany< + TOne extends { id: string }, + TMany extends { id: string }, + TManyKey extends keyof TOne +>( + joined: (TOne & { [manyKey in TManyKey]: TMany | null })[], + manyKey: TManyKey +) { + return joined.reduce<(TOne & { [key in TManyKey]: TMany[] })[]>( + (acc, row) => { + if (!acc.some(({ id }) => id === row.id)) + acc.push({ ...row, [manyKey]: [] }); + if (row[manyKey]) + acc.find(({ id }) => id === row.id)?.[manyKey].push(row[manyKey]); + return acc; + }, + [] + ); +} + +// [{ id:string, ..., : [{ id:string, ... }] }] diff --git a/src/utils/generateToken.ts b/src/utils/generateToken.ts new file mode 100644 index 0000000..caf656c --- /dev/null +++ b/src/utils/generateToken.ts @@ -0,0 +1,23 @@ +import { SignJWT } from 'jose'; + +export async function generateToken( + adminId: string, + type: 'access' | 'refresh' +) { + return await new SignJWT({ + adminId, + }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime( + type === 'access' + ? process.env.JWT_ACCESS_EXP_TIME + : process.env.JWT_REFRESH_EXP_TIME + ) + .sign( + new TextEncoder().encode( + type === 'access' + ? process.env.JWT_ACCESS_SECRET + : process.env.JWT_REFRESH_SECRET + ) + ); +} diff --git a/src/utils/uploadMedia.ts b/src/utils/uploadMedia.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/verifyToken.ts b/src/utils/verifyToken.ts new file mode 100644 index 0000000..3bd8a9b --- /dev/null +++ b/src/utils/verifyToken.ts @@ -0,0 +1,18 @@ +import { jwtVerify } from 'jose'; + +export async function verifyToken(type: 'access' | 'refresh', token?: string) { + try { + return ( + await jwtVerify<{ adminId: string }>( + token ?? '', + new TextEncoder().encode( + type === 'access' + ? process.env.JWT_ACCESS_SECRET + : process.env.JWT_REFRESH_SECRET + ) + ) + ).payload; + } catch (e) { + return null; + } +} diff --git a/tsconfig.json b/tsconfig.json index 1ca2350..1dfa956 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ @@ -25,14 +25,16 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "ES2022", /* Specify what module code is generated. */ + "module": "NodeNext" /* Specify what module code is generated. */, // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "nodenext" /* Specify how TypeScript looks up a file from a given module specifier. */, // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ + "types": [ + "bun-types" + ] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "resolveJsonModule": true, /* Enable importing .json files. */ @@ -51,7 +53,7 @@ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ // "outDir": "./", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ + "noEmit": true /* Disable emitting files from a compilation. */, // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ @@ -71,12 +73,13 @@ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + "allowImportingTsExtensions": true, /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ + "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ @@ -98,6 +101,14 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": [ + "./drizzle.config.ts", + "./env.d.ts", + "./src/**/*.ts", + "./bun.config.ts" + // "src/controllers/getRegionName.ts" + ], + "exclude": ["node_modules"] }