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 0000000..654d87b Binary files /dev/null and b/bun.lockb differ 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"] }