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"]
}