diff --git a/apps/api/.env.development b/apps/api/.env.development index e413cd8..9ed7ecc 100644 --- a/apps/api/.env.development +++ b/apps/api/.env.development @@ -1,3 +1,10 @@ NODE_ENV=development PORT=8000 -DATABASE_URL="mysql://root@localhost:3306/mydb" \ No newline at end of file +DATABASE_URL="mysql://root:D4nau$unter@localhost:3306/online_groceries" +CORS=http://localhost:3000 +ACC_SECRET_KEY=finpro-akses +REFR_SECRET_KEY=finpro-refresh +FP_SECRET_KEY=finpro-forgot +VERIF_SECRET_KEY=finpro-verif + +RAJAONGKIR_API_KEY='84d2023f491aa0b631bbf096b319d4a7' \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 46f92ab..abc0323 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -3,8 +3,11 @@ "version": "1.0.0", "description": "", "main": "src/index.ts", + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "scripts": { - "dev": "cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register src/index.ts", + "dev": "cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register --files src/index.ts", "build": "tsc && tsc-alias", "serve": "cross-env NODE_ENV=production node dist/index.js", "test": "echo \"Error: no test specified\" && exit 1", @@ -13,19 +16,42 @@ "author": "", "license": "ISC", "dependencies": { - "@prisma/client": "^5.7.1", + "@prisma/client": "^5.16.1", + "axios": "^1.7.2", + "bcrypt": "^5.1.1", "cors": "^2.8.5", "cross-env": "^7.0.3", + "date-fns": "^3.6.0", "dotenv": "^16.3.1", - "express": "^4.18.2", + "express": "^4.19.2", + "fs": "^0.0.1-security", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "mustache": "^4.2.0", + "nanoid": "^5.0.7", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.13", + "nodemon": "^3.1.4", + "sharp": "^0.33.4", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" + "typescript": "^5.5.3", + "voucher-code-generator": "^1.3.0", + "zod": "^3.23.8" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "prisma": "^5.7.1", - "tsconfig-paths": "^4.2.0" + "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.11", + "@types/mustache": "^4.2.5", + "@types/node": "^20.14.9", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.15", + "@types/voucher-code-generator": "^1.1.3", + "prisma": "^5.16.1", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.16.2" } } diff --git a/apps/api/prisma/data/address.ts b/apps/api/prisma/data/address.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/prisma/data/categories.ts b/apps/api/prisma/data/categories.ts new file mode 100644 index 0000000..14874f9 --- /dev/null +++ b/apps/api/prisma/data/categories.ts @@ -0,0 +1,16 @@ +import { Prisma } from '@prisma/client'; + +export const categories: Prisma.CategoryCreateManyInput[] = [ + { id: 1, name: 'Sayur', created_at: new Date(), updated_at: new Date() }, + { id: 2, name: 'Buah', created_at: new Date(), updated_at: new Date() }, + { id: 3, name: 'Protein', created_at: new Date(), updated_at: new Date() }, + { id: 4, name: 'Sayur', created_at: new Date(), updated_at: new Date() }, + { id: 5, name: 'Sembako', created_at: new Date(), updated_at: new Date() }, + { id: 6, name: 'Siap Saji', created_at: new Date(), updated_at: new Date() }, + { + id: 7, + name: 'Susu & Olahan', + created_at: new Date(), + updated_at: new Date(), + }, +]; diff --git a/apps/api/prisma/data/cities.ts b/apps/api/prisma/data/cities.ts new file mode 100644 index 0000000..2d74e37 --- /dev/null +++ b/apps/api/prisma/data/cities.ts @@ -0,0 +1,15 @@ +import axios from 'axios'; + +export async function copyCities() { + const res = await axios.get('https://api.rajaongkir.com/starter/city', { + headers: { + key: process.env.RAJAONGKIR_API_KEY, + }, + }); + const { + data: { + rajaongkir: { results }, + }, + } = res; + return results; +} diff --git a/apps/api/prisma/data/images.ts b/apps/api/prisma/data/images.ts new file mode 100644 index 0000000..8b42dfb --- /dev/null +++ b/apps/api/prisma/data/images.ts @@ -0,0 +1 @@ +export const images = []; diff --git a/apps/api/prisma/data/images/categories.zip b/apps/api/prisma/data/images/categories.zip new file mode 100644 index 0000000..ca96d81 Binary files /dev/null and b/apps/api/prisma/data/images/categories.zip differ diff --git a/apps/api/prisma/data/images/categories/Buah.webp b/apps/api/prisma/data/images/categories/Buah.webp new file mode 100644 index 0000000..aac3749 Binary files /dev/null and b/apps/api/prisma/data/images/categories/Buah.webp differ diff --git a/apps/api/prisma/data/images/categories/BumbuDapur.webp b/apps/api/prisma/data/images/categories/BumbuDapur.webp new file mode 100644 index 0000000..7477d6d Binary files /dev/null and b/apps/api/prisma/data/images/categories/BumbuDapur.webp differ diff --git a/apps/api/prisma/data/images/categories/MakananRingan.webp b/apps/api/prisma/data/images/categories/MakananRingan.webp new file mode 100644 index 0000000..ba67dc1 Binary files /dev/null and b/apps/api/prisma/data/images/categories/MakananRingan.webp differ diff --git a/apps/api/prisma/data/images/categories/Protein.webp b/apps/api/prisma/data/images/categories/Protein.webp new file mode 100644 index 0000000..33eb3f0 Binary files /dev/null and b/apps/api/prisma/data/images/categories/Protein.webp differ diff --git a/apps/api/prisma/data/images/categories/Sayur.webp b/apps/api/prisma/data/images/categories/Sayur.webp new file mode 100644 index 0000000..3bd567e Binary files /dev/null and b/apps/api/prisma/data/images/categories/Sayur.webp differ diff --git a/apps/api/prisma/data/images/categories/Sembako.webp b/apps/api/prisma/data/images/categories/Sembako.webp new file mode 100644 index 0000000..3e9d5e2 Binary files /dev/null and b/apps/api/prisma/data/images/categories/Sembako.webp differ diff --git a/apps/api/prisma/data/images/categories/SiapSaji.webp b/apps/api/prisma/data/images/categories/SiapSaji.webp new file mode 100644 index 0000000..233246d Binary files /dev/null and b/apps/api/prisma/data/images/categories/SiapSaji.webp differ diff --git a/apps/api/prisma/data/images/categories/SusuDanOlahan.webp b/apps/api/prisma/data/images/categories/SusuDanOlahan.webp new file mode 100644 index 0000000..a9afb46 Binary files /dev/null and b/apps/api/prisma/data/images/categories/SusuDanOlahan.webp differ diff --git a/apps/api/prisma/data/images/products/buah/BuahNagaMerah-2Kg.webp b/apps/api/prisma/data/images/products/buah/BuahNagaMerah-2Kg.webp new file mode 100644 index 0000000..067e909 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/BuahNagaMerah-2Kg.webp differ diff --git a/apps/api/prisma/data/images/products/buah/ManggaHarumManis-1Kg.webp b/apps/api/prisma/data/images/products/buah/ManggaHarumManis-1Kg.webp new file mode 100644 index 0000000..c0feb04 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/ManggaHarumManis-1Kg.webp differ diff --git a/apps/api/prisma/data/images/products/buah/PirMaduImpor-400g.webp b/apps/api/prisma/data/images/products/buah/PirMaduImpor-400g.webp new file mode 100644 index 0000000..d9d7feb Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/PirMaduImpor-400g.webp differ diff --git a/apps/api/prisma/data/images/products/buah/PirMaduImpor-800g.webp b/apps/api/prisma/data/images/products/buah/PirMaduImpor-800g.webp new file mode 100644 index 0000000..26b8342 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/PirMaduImpor-800g.webp differ diff --git a/apps/api/prisma/data/images/products/buah/PisangCavendish-1pcs.webp b/apps/api/prisma/data/images/products/buah/PisangCavendish-1pcs.webp new file mode 100644 index 0000000..365d318 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/PisangCavendish-1pcs.webp differ diff --git a/apps/api/prisma/data/images/products/buah/PisangCavendish-500g.webp b/apps/api/prisma/data/images/products/buah/PisangCavendish-500g.webp new file mode 100644 index 0000000..dbd1c78 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/PisangCavendish-500g.webp differ diff --git a/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-2pcs.webp b/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-2pcs.webp new file mode 100644 index 0000000..a5d4646 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-2pcs.webp differ diff --git a/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-4pcs.webp b/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-4pcs.webp new file mode 100644 index 0000000..8c8dc35 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-4pcs.webp differ diff --git a/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-6pcs.webp b/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-6pcs.webp new file mode 100644 index 0000000..421a979 Binary files /dev/null and b/apps/api/prisma/data/images/products/buah/ZepriKiwiGoldImpor-6pcs.webp differ diff --git a/apps/api/prisma/data/images/products/protein/ayam-1.webp b/apps/api/prisma/data/images/products/protein/ayam-1.webp new file mode 100644 index 0000000..2a92b16 Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/ayam-1.webp differ diff --git a/apps/api/prisma/data/images/products/protein/daging-1.webp b/apps/api/prisma/data/images/products/protein/daging-1.webp new file mode 100644 index 0000000..0927ecc Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/daging-1.webp differ diff --git a/apps/api/prisma/data/images/products/protein/ikan-1.webp b/apps/api/prisma/data/images/products/protein/ikan-1.webp new file mode 100644 index 0000000..1c83afa Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/ikan-1.webp differ diff --git a/apps/api/prisma/data/images/products/protein/ikan-2.webp b/apps/api/prisma/data/images/products/protein/ikan-2.webp new file mode 100644 index 0000000..77b1637 Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/ikan-2.webp differ diff --git a/apps/api/prisma/data/images/products/protein/ikan-dori-1.webp b/apps/api/prisma/data/images/products/protein/ikan-dori-1.webp new file mode 100644 index 0000000..681ffb7 Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/ikan-dori-1.webp differ diff --git a/apps/api/prisma/data/images/products/protein/ikan-dori-2.webp b/apps/api/prisma/data/images/products/protein/ikan-dori-2.webp new file mode 100644 index 0000000..e5556de Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/ikan-dori-2.webp differ diff --git a/apps/api/prisma/data/images/products/protein/kerang-1.webp b/apps/api/prisma/data/images/products/protein/kerang-1.webp new file mode 100644 index 0000000..62eea69 Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/kerang-1.webp differ diff --git a/apps/api/prisma/data/images/products/protein/kerang-2.webp b/apps/api/prisma/data/images/products/protein/kerang-2.webp new file mode 100644 index 0000000..4bd4a46 Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/kerang-2.webp differ diff --git a/apps/api/prisma/data/images/products/protein/kerang-3.webp b/apps/api/prisma/data/images/products/protein/kerang-3.webp new file mode 100644 index 0000000..0cfa07a Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/kerang-3.webp differ diff --git a/apps/api/prisma/data/images/products/protein/telur-1.webp b/apps/api/prisma/data/images/products/protein/telur-1.webp new file mode 100644 index 0000000..265ec04 Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/telur-1.webp differ diff --git a/apps/api/prisma/data/images/products/protein/telur-2.webp b/apps/api/prisma/data/images/products/protein/telur-2.webp new file mode 100644 index 0000000..eb60afa Binary files /dev/null and b/apps/api/prisma/data/images/products/protein/telur-2.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/BawangMerah-100g.webp b/apps/api/prisma/data/images/products/sayur/BawangMerah-100g.webp new file mode 100644 index 0000000..647d7a8 Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/BawangMerah-100g.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/BawangMerah-200g.webp b/apps/api/prisma/data/images/products/sayur/BawangMerah-200g.webp new file mode 100644 index 0000000..6a0a36c Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/BawangMerah-200g.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-150g.webp b/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-150g.webp new file mode 100644 index 0000000..73e664e Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-150g.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-1Kg.webp b/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-1Kg.webp new file mode 100644 index 0000000..a10654e Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-1Kg.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-250g.webp b/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-250g.webp new file mode 100644 index 0000000..0807d53 Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/CabaiMerahBesar-250g.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/JahePutih-200g.webp b/apps/api/prisma/data/images/products/sayur/JahePutih-200g.webp new file mode 100644 index 0000000..a9abe11 Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/JahePutih-200g.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/TalasBelitungPotong-250g.webp b/apps/api/prisma/data/images/products/sayur/TalasBelitungPotong-250g.webp new file mode 100644 index 0000000..23449e4 Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/TalasBelitungPotong-250g.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/UbiMurasakiUngu-1Kg.webp b/apps/api/prisma/data/images/products/sayur/UbiMurasakiUngu-1Kg.webp new file mode 100644 index 0000000..6d7248c Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/UbiMurasakiUngu-1Kg.webp differ diff --git a/apps/api/prisma/data/images/products/sayur/UbiMurasakiUngu-500g.webp b/apps/api/prisma/data/images/products/sayur/UbiMurasakiUngu-500g.webp new file mode 100644 index 0000000..5756f0a Binary files /dev/null and b/apps/api/prisma/data/images/products/sayur/UbiMurasakiUngu-500g.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/ayam-1.webp b/apps/api/prisma/data/images/products/siap-saji/ayam-1.webp new file mode 100644 index 0000000..fe149a5 Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/ayam-1.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/ayam-2.webp b/apps/api/prisma/data/images/products/siap-saji/ayam-2.webp new file mode 100644 index 0000000..badaf80 Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/ayam-2.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/cireng-1.webp b/apps/api/prisma/data/images/products/siap-saji/cireng-1.webp new file mode 100644 index 0000000..e411f7f Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/cireng-1.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/cireng-2.webp b/apps/api/prisma/data/images/products/siap-saji/cireng-2.webp new file mode 100644 index 0000000..97e7d32 Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/cireng-2.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/daging-1.webp b/apps/api/prisma/data/images/products/siap-saji/daging-1.webp new file mode 100644 index 0000000..4523a0f Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/daging-1.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/daging-2.webp b/apps/api/prisma/data/images/products/siap-saji/daging-2.webp new file mode 100644 index 0000000..492e87b Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/daging-2.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/sambal-1.webp b/apps/api/prisma/data/images/products/siap-saji/sambal-1.webp new file mode 100644 index 0000000..e316d6c Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/sambal-1.webp differ diff --git a/apps/api/prisma/data/images/products/siap-saji/sosis-1.webp b/apps/api/prisma/data/images/products/siap-saji/sosis-1.webp new file mode 100644 index 0000000..1669732 Binary files /dev/null and b/apps/api/prisma/data/images/products/siap-saji/sosis-1.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/biskuat-coklat-50g.webp b/apps/api/prisma/data/images/products/snacks/biskuat-coklat-50g.webp new file mode 100644 index 0000000..babfb29 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/biskuat-coklat-50g.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/biskuat-ori-50g.webp b/apps/api/prisma/data/images/products/snacks/biskuat-ori-50g.webp new file mode 100644 index 0000000..056241d Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/biskuat-ori-50g.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/momogi-coklat.webp b/apps/api/prisma/data/images/products/snacks/momogi-coklat.webp new file mode 100644 index 0000000..234bc71 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/momogi-coklat.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/momogi-jagung.webp b/apps/api/prisma/data/images/products/snacks/momogi-jagung.webp new file mode 100644 index 0000000..23d0b70 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/momogi-jagung.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/momogi-keju.webp b/apps/api/prisma/data/images/products/snacks/momogi-keju.webp new file mode 100644 index 0000000..dea1996 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/momogi-keju.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/oreo-choco-133g.webp b/apps/api/prisma/data/images/products/snacks/oreo-choco-133g.webp new file mode 100644 index 0000000..5f84254 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/oreo-choco-133g.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/oreo-doublestuf-133g.webp b/apps/api/prisma/data/images/products/snacks/oreo-doublestuf-133g.webp new file mode 100644 index 0000000..5af4cc0 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/oreo-doublestuf-133g.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/oreo-ori-133g.webp b/apps/api/prisma/data/images/products/snacks/oreo-ori-133g.webp new file mode 100644 index 0000000..8438433 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/oreo-ori-133g.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/slim-fit-dark-chocolate.webp b/apps/api/prisma/data/images/products/snacks/slim-fit-dark-chocolate.webp new file mode 100644 index 0000000..079b260 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/slim-fit-dark-chocolate.webp differ diff --git a/apps/api/prisma/data/images/products/snacks/slim-fit-raisin-cinnamon.webp b/apps/api/prisma/data/images/products/snacks/slim-fit-raisin-cinnamon.webp new file mode 100644 index 0000000..e7fb102 Binary files /dev/null and b/apps/api/prisma/data/images/products/snacks/slim-fit-raisin-cinnamon.webp differ diff --git a/apps/api/prisma/data/products.ts b/apps/api/prisma/data/products.ts new file mode 100644 index 0000000..a805b40 --- /dev/null +++ b/apps/api/prisma/data/products.ts @@ -0,0 +1 @@ +export const products = []; diff --git a/apps/api/prisma/data/schedule.ts b/apps/api/prisma/data/schedule.ts new file mode 100644 index 0000000..101aa4b --- /dev/null +++ b/apps/api/prisma/data/schedule.ts @@ -0,0 +1,28 @@ +import { StoreSchedule } from '@prisma/client'; + +export const schedule: StoreSchedule[] = [ + { + id: 'cly5uxlq100020cl5giq7e62r', + name: 'Operational', + start_time: new Date(2024, 3, 7, 9, 0, 0), + end_time: new Date(2024, 3, 7, 18, 0, 0), + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'cly5uwfce00010cl59ze5fnh1', + name: 'Setengah hari', + start_time: new Date(2024, 3, 7, 9, 0, 0), + end_time: new Date(2024, 3, 7, 15, 0, 0), + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'cly5uv68000000cl5ggdf1crx', + name: 'Libur nasional', + start_time: null, + end_time: null, + created_at: new Date(), + updated_at: new Date(), + }, +]; diff --git a/apps/api/prisma/data/users.ts b/apps/api/prisma/data/users.ts new file mode 100644 index 0000000..bbd6fff --- /dev/null +++ b/apps/api/prisma/data/users.ts @@ -0,0 +1,81 @@ +import { hashPassword } from '@/libs/bcrypt'; +import { Gender, Role, User } from '@prisma/client'; + +export const users: User[] = [ + { + id: 'cly5vk76k00000dmg78mgfo4y', + email: 'super@mail.com', + password: '$2a$12$ONRX/7qOG1nqV8XU7WrLQ.PO11Htfo5Y0aKaWfaEJh.xGmlPGpPwm', + reset_token: null, + is_verified: true, + role: Role.super_admin, + full_name: 'Rama Naufal Alim', + gender: Gender.male, + dob: null, + phone_no: null, + store_id: null, + created_at: new Date(), + updated_at: new Date(), + avatar_id: null, + voucher_id: null, + referral_code: null, + reference_code: null, + }, + { + id: 'cly5vx4zh00000cju1d1ig61c', + email: 'store@mail.com', + password: '$2a$12$I/3WZPa1bq3ETdiFOihsjeuZQlIzRYDUJrZHyJkq7nLUEhFHum1Bi', + reset_token: null, + is_verified: true, + role: Role.store_admin, + full_name: 'Be Liau', + gender: Gender.male, + dob: null, + phone_no: null, + store_id: null, + created_at: new Date(), + updated_at: new Date(), + avatar_id: null, + voucher_id: null, + referral_code: null, + reference_code: null, + }, + { + id: 'cly5vyacc00010cju7mdgd60n', + email: 'andrew@mail.com', + password: '$2a$12$kRSKn8F2mQ4K/x.DolwzTOZxYKzdBJYvw31C0FqNCLFg/KBLqKvay', + reset_token: null, + is_verified: false, + role: Role.customer, + full_name: 'An Drew', + gender: Gender.male, + dob: null, + phone_no: null, + store_id: null, + created_at: new Date(), + updated_at: new Date(), + avatar_id: null, + voucher_id: null, + referral_code: null, + reference_code: null, + }, + { + id: 'cly5w0lzg00020cjugmwqa7zf', + email: 'frangky@mail.com', + password: '$2a$12$J0/YV0ZnTXyvj3Ql5J3koeCErpJWKifyZHuYA2idDUm8m9kC5DU32', + reset_token: null, + is_verified: true, + role: Role.customer, + full_name: 'Frangky Sihombing', + gender: Gender.male, + dob: null, + phone_no: null, + store_id: null, + created_at: new Date(), + updated_at: new Date(), + avatar_id: null, + voucher_id: null, + referral_code: 'AzaGE', + reference_code: null, + }, +]; diff --git a/apps/api/prisma/data/variants.ts b/apps/api/prisma/data/variants.ts new file mode 100644 index 0000000..d37ac53 --- /dev/null +++ b/apps/api/prisma/data/variants.ts @@ -0,0 +1 @@ +export const variants = []; diff --git a/apps/api/prisma/migrations/20240703132502_init_migration_of_online_groceries_final_project_schema/migration.sql b/apps/api/prisma/migrations/20240703132502_init_migration_of_online_groceries_final_project_schema/migration.sql new file mode 100644 index 0000000..9ac03f7 --- /dev/null +++ b/apps/api/prisma/migrations/20240703132502_init_migration_of_online_groceries_final_project_schema/migration.sql @@ -0,0 +1,322 @@ +/* + Warnings: + + - You are about to drop the `samples` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE `samples`; + +-- CreateTable +CREATE TABLE `users` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(85) NOT NULL, + `password` VARCHAR(191) NULL, + `reset_token` TEXT NULL, + `is_verified` BOOLEAN NOT NULL DEFAULT false, + `role` ENUM('customer', 'super_admin', 'store_admin') NOT NULL DEFAULT 'customer', + `store_id` VARCHAR(191) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `users_email_key`(`email`), + INDEX `users_id_email_role_is_verified_idx`(`id`, `email`, `role`, `is_verified`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_details` ( + `id` VARCHAR(191) NOT NULL, + `full_name` VARCHAR(100) NULL, + `gender` ENUM('male', 'female') NOT NULL DEFAULT 'male', + `dob` DATETIME(3) NULL, + `phone_no` VARCHAR(25) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `user_details_id_full_name_dob_gender_idx`(`id`, `full_name`, `dob`, `gender`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `addresses` ( + `id` VARCHAR(191) NOT NULL, + `user_id` VARCHAR(191) NOT NULL, + `address` VARCHAR(191) NOT NULL, + `city_id` INTEGER NOT NULL, + `type` ENUM('personal', 'store') NOT NULL DEFAULT 'personal', + `details` VARCHAR(100) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `addresses_id_address_idx`(`id`, `address`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `cities` ( + `city_id` INTEGER NOT NULL AUTO_INCREMENT, + `province_id` INTEGER NOT NULL, + `province` VARCHAR(191) NOT NULL, + `type` ENUM('Kota', 'Kabupaten') NOT NULL, + `city_name` VARCHAR(100) NOT NULL, + `postal_code` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `cities_city_id_city_name_type_postal_code_idx`(`city_id`, `city_name`, `type`, `postal_code`), + PRIMARY KEY (`city_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `stores` ( + `address_id` VARCHAR(191) NOT NULL, + `store_admin_id` VARCHAR(191) NULL, + `schedule_id` VARCHAR(191) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `stores_address_id_idx`(`address_id`), + PRIMARY KEY (`address_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `store_schedules` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `start_time` TIME NULL, + `end_time` TIME NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `store_schedules_id_name_start_time_end_time_idx`(`id`, `name`, `start_time`, `end_time`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `store_stock` ( + `id` VARCHAR(191) NOT NULL, + `store_id` VARCHAR(191) NOT NULL, + `variant_id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `stock_history` ( + `id` VARCHAR(191) NOT NULL, + `store_stock_id` VARCHAR(191) NOT NULL, + `start_qty_at` INTEGER NOT NULL, + `qty_change` INTEGER NOT NULL DEFAULT 0, + `reference` VARCHAR(191) NOT NULL, + `transaction_id` VARCHAR(191) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `images` ( + `id` VARCHAR(191) NOT NULL, + `blob` BLOB NOT NULL, + `type` ENUM('avatar', 'store', 'product', 'promotion', 'discount', 'voucher', 'category') NOT NULL DEFAULT 'product', + `user_id` VARCHAR(191) NULL, + `product_id` VARCHAR(191) NULL, + `is_active` BOOLEAN NOT NULL DEFAULT true, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `images_type_idx`(`type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `products` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(80) NOT NULL, + `description` VARCHAR(191) NULL, + `shelf_life` VARCHAR(80) NULL, + `nutrition_facts` VARCHAR(191) NULL, + `storage_instructions` VARCHAR(191) NULL, + `category_id` INTEGER NOT NULL, + `sub_category_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `products_id_name_category_id_idx`(`id`, `name`, `category_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `product_variants` ( + `id` VARCHAR(191) NOT NULL, + `type` ENUM('weight', 'volume', 'size', 'flavour', 'pcs') NOT NULL, + `name` VARCHAR(100) NOT NULL, + `unit_price` DOUBLE NOT NULL, + `discount` INTEGER NULL, + `product_id` VARCHAR(191) NOT NULL, + `promo_id` VARCHAR(191) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `product_variants_id_type_name_discount_idx`(`id`, `type`, `name`, `discount`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `categories` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `image_id` VARCHAR(191) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `categories_image_id_key`(`image_id`), + INDEX `categories_id_name_idx`(`id`, `name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `sub_categories` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `category_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `sub_categories_id_name_idx`(`id`, `name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `promotions` ( + `id` VARCHAR(191) NOT NULL, + `title` VARCHAR(100) NOT NULL, + `description` VARCHAR(191) NOT NULL, + `type` ENUM('discount', 'voucher', 'cashback', 'free_shipping') NOT NULL, + `amount` DOUBLE NOT NULL, + `min_transaction` DOUBLE NOT NULL, + `expiry_date` DATETIME(3) NOT NULL, + `is_valid` BOOLEAN NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `promotions_id_title_type_is_valid_idx`(`id`, `title`, `type`, `is_valid`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `shopping_carts` ( + `id` VARCHAR(191) NOT NULL, + `user_id` VARCHAR(191) NOT NULL, + `store_stock_id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `order_details` ( + `id` VARCHAR(191) NOT NULL, + `user_id` VARCHAR(191) NOT NULL, + `store_stock_id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + `transaction_id` VARCHAR(191) NOT NULL, + `created_at` DATETIME(3) NOT NULL, + `updated_at` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `customer_orders` ( + `id` VARCHAR(191) NOT NULL, + `inv_no` VARCHAR(191) NOT NULL, + `payment_proof` BLOB NULL, + `promotion_id` VARCHAR(191) NULL, + `discount` INTEGER NULL, + `origin_id` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `users` ADD CONSTRAINT `users_store_id_fkey` FOREIGN KEY (`store_id`) REFERENCES `stores`(`address_id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_details` ADD CONSTRAINT `user_details_id_fkey` FOREIGN KEY (`id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `addresses` ADD CONSTRAINT `addresses_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `addresses` ADD CONSTRAINT `addresses_city_id_fkey` FOREIGN KEY (`city_id`) REFERENCES `cities`(`city_id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `stores` ADD CONSTRAINT `stores_address_id_fkey` FOREIGN KEY (`address_id`) REFERENCES `addresses`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `stores` ADD CONSTRAINT `stores_schedule_id_fkey` FOREIGN KEY (`schedule_id`) REFERENCES `store_schedules`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `store_stock` ADD CONSTRAINT `store_stock_store_id_fkey` FOREIGN KEY (`store_id`) REFERENCES `stores`(`address_id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `store_stock` ADD CONSTRAINT `store_stock_variant_id_fkey` FOREIGN KEY (`variant_id`) REFERENCES `product_variants`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `stock_history` ADD CONSTRAINT `stock_history_store_stock_id_fkey` FOREIGN KEY (`store_stock_id`) REFERENCES `store_stock`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `stock_history` ADD CONSTRAINT `stock_history_transaction_id_fkey` FOREIGN KEY (`transaction_id`) REFERENCES `customer_orders`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `images` ADD CONSTRAINT `images_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `images` ADD CONSTRAINT `images_product_id_fkey` FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `products` ADD CONSTRAINT `products_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `products` ADD CONSTRAINT `products_sub_category_id_fkey` FOREIGN KEY (`sub_category_id`) REFERENCES `sub_categories`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `product_variants` ADD CONSTRAINT `product_variants_product_id_fkey` FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `product_variants` ADD CONSTRAINT `product_variants_promo_id_fkey` FOREIGN KEY (`promo_id`) REFERENCES `promotions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `categories` ADD CONSTRAINT `categories_image_id_fkey` FOREIGN KEY (`image_id`) REFERENCES `images`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `sub_categories` ADD CONSTRAINT `sub_categories_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `shopping_carts` ADD CONSTRAINT `shopping_carts_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `shopping_carts` ADD CONSTRAINT `shopping_carts_store_stock_id_fkey` FOREIGN KEY (`store_stock_id`) REFERENCES `store_stock`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `order_details` ADD CONSTRAINT `order_details_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `order_details` ADD CONSTRAINT `order_details_store_stock_id_fkey` FOREIGN KEY (`store_stock_id`) REFERENCES `store_stock`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `order_details` ADD CONSTRAINT `order_details_transaction_id_fkey` FOREIGN KEY (`transaction_id`) REFERENCES `customer_orders`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_orders` ADD CONSTRAINT `customer_orders_promotion_id_fkey` FOREIGN KEY (`promotion_id`) REFERENCES `promotions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_orders` ADD CONSTRAINT `customer_orders_origin_id_fkey` FOREIGN KEY (`origin_id`) REFERENCES `stores`(`address_id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20240703133216_change_user_store_id_to_optional/migration.sql b/apps/api/prisma/migrations/20240703133216_change_user_store_id_to_optional/migration.sql new file mode 100644 index 0000000..b614db1 --- /dev/null +++ b/apps/api/prisma/migrations/20240703133216_change_user_store_id_to_optional/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE `users` DROP FOREIGN KEY `users_store_id_fkey`; + +-- AlterTable +ALTER TABLE `users` MODIFY `store_id` VARCHAR(191) NULL; + +-- AddForeignKey +ALTER TABLE `users` ADD CONSTRAINT `users_store_id_fkey` FOREIGN KEY (`store_id`) REFERENCES `stores`(`address_id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20240703152335_change_user_model_and_image_relationship_to_product_and_user/migration.sql b/apps/api/prisma/migrations/20240703152335_change_user_model_and_image_relationship_to_product_and_user/migration.sql new file mode 100644 index 0000000..70b9769 --- /dev/null +++ b/apps/api/prisma/migrations/20240703152335_change_user_model_and_image_relationship_to_product_and_user/migration.sql @@ -0,0 +1,58 @@ +/* + Warnings: + + - You are about to drop the column `province_id` on the `cities` table. All the data in the column will be lost. + - You are about to drop the column `product_id` on the `images` table. All the data in the column will be lost. + - You are about to drop the column `user_id` on the `images` table. All the data in the column will be lost. + - You are about to drop the `user_details` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[image_id]` on the table `product_variants` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[avatar_id]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE `images` DROP FOREIGN KEY `images_product_id_fkey`; + +-- DropForeignKey +ALTER TABLE `images` DROP FOREIGN KEY `images_user_id_fkey`; + +-- DropForeignKey +ALTER TABLE `user_details` DROP FOREIGN KEY `user_details_id_fkey`; + +-- AlterTable +ALTER TABLE `cities` DROP COLUMN `province_id`; + +-- AlterTable +ALTER TABLE `images` DROP COLUMN `product_id`, + DROP COLUMN `user_id`; + +-- AlterTable +ALTER TABLE `product_variants` ADD COLUMN `image_id` VARCHAR(191) NULL; + +-- AlterTable +ALTER TABLE `promotions` MODIFY `type` ENUM('discount', 'voucher', 'referral_voucher', 'cashback', 'free_shipping') NOT NULL; + +-- AlterTable +ALTER TABLE `users` ADD COLUMN `avatar_id` VARCHAR(191) NULL, + ADD COLUMN `dob` DATETIME(3) NULL, + ADD COLUMN `full_name` VARCHAR(100) NULL, + ADD COLUMN `gender` ENUM('male', 'female') NULL DEFAULT 'male', + ADD COLUMN `phone_no` VARCHAR(25) NULL, + ADD COLUMN `voucher_id` VARCHAR(191) NULL; + +-- DropTable +DROP TABLE `user_details`; + +-- CreateIndex +CREATE UNIQUE INDEX `product_variants_image_id_key` ON `product_variants`(`image_id`); + +-- CreateIndex +CREATE UNIQUE INDEX `users_avatar_id_key` ON `users`(`avatar_id`); + +-- AddForeignKey +ALTER TABLE `users` ADD CONSTRAINT `users_avatar_id_fkey` FOREIGN KEY (`avatar_id`) REFERENCES `images`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `users` ADD CONSTRAINT `users_voucher_id_fkey` FOREIGN KEY (`voucher_id`) REFERENCES `promotions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `product_variants` ADD CONSTRAINT `product_variants_image_id_fkey` FOREIGN KEY (`image_id`) REFERENCES `images`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20240703152610_add_referral_code_and_reference_code_columns_to_user_and_make_them_optional_as_admins_won_t_have_one/migration.sql b/apps/api/prisma/migrations/20240703152610_add_referral_code_and_reference_code_columns_to_user_and_make_them_optional_as_admins_won_t_have_one/migration.sql new file mode 100644 index 0000000..853ce74 --- /dev/null +++ b/apps/api/prisma/migrations/20240703152610_add_referral_code_and_reference_code_columns_to_user_and_make_them_optional_as_admins_won_t_have_one/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[referral_code]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE `users` ADD COLUMN `reference_code` VARCHAR(100) NULL, + ADD COLUMN `referral_code` VARCHAR(191) NULL; + +-- CreateIndex +CREATE UNIQUE INDEX `users_referral_code_key` ON `users`(`referral_code`); diff --git a/apps/api/prisma/migrations/20240704073039_add_is_banned_boolean_for_soft_delete_in_user_table/migration.sql b/apps/api/prisma/migrations/20240704073039_add_is_banned_boolean_for_soft_delete_in_user_table/migration.sql new file mode 100644 index 0000000..bb66bc5 --- /dev/null +++ b/apps/api/prisma/migrations/20240704073039_add_is_banned_boolean_for_soft_delete_in_user_table/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `store_admin_id` on the `stores` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE `stores` DROP COLUMN `store_admin_id`; + +-- AlterTable +ALTER TABLE `users` ADD COLUMN `is_banned` BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/migrations/20240705035747_add_name_column_to_images_table_add_coordinates_columns_in_address_table_add_unique_constraint_to_phone_no_column_in_users_table_try_omit_preview_feature_in_prisma_schema/migration.sql b/apps/api/prisma/migrations/20240705035747_add_name_column_to_images_table_add_coordinates_columns_in_address_table_add_unique_constraint_to_phone_no_column_in_users_table_try_omit_preview_feature_in_prisma_schema/migration.sql new file mode 100644 index 0000000..f6364a9 --- /dev/null +++ b/apps/api/prisma/migrations/20240705035747_add_name_column_to_images_table_add_coordinates_columns_in_address_table_add_unique_constraint_to_phone_no_column_in_users_table_try_omit_preview_feature_in_prisma_schema/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - The primary key for the `shopping_carts` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `shopping_carts` table. All the data in the column will be lost. + - A unique constraint covering the columns `[name]` on the table `images` will be added. If there are existing duplicate values, this will fail. + - Added the required column `latitude` to the `addresses` table without a default value. This is not possible if the table is not empty. + - Added the required column `longitude` to the `addresses` table without a default value. This is not possible if the table is not empty. + - Added the required column `name` to the `images` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `addresses` ADD COLUMN `latitude` DOUBLE NOT NULL, + ADD COLUMN `longitude` DOUBLE NOT NULL; + +-- AlterTable +ALTER TABLE `images` ADD COLUMN `name` VARCHAR(191) NOT NULL, + MODIFY `blob` MEDIUMBLOB NOT NULL; + +-- AlterTable +ALTER TABLE `shopping_carts` DROP PRIMARY KEY, + DROP COLUMN `id`, + ADD PRIMARY KEY (`user_id`, `store_stock_id`); + +-- CreateIndex +CREATE UNIQUE INDEX `images_name_key` ON `images`(`name`); diff --git a/apps/api/prisma/migrations/20240706035350_add_new_enum_destination_to_address_model_and_make_coordinates_column_optional/migration.sql b/apps/api/prisma/migrations/20240706035350_add_new_enum_destination_to_address_model_and_make_coordinates_column_optional/migration.sql new file mode 100644 index 0000000..dbcc68a --- /dev/null +++ b/apps/api/prisma/migrations/20240706035350_add_new_enum_destination_to_address_model_and_make_coordinates_column_optional/migration.sql @@ -0,0 +1,45 @@ +/* + Warnings: + + - You are about to drop the column `is_active` on the `images` table. All the data in the column will be lost. + - You are about to drop the column `discount` on the `product_variants` table. All the data in the column will be lost. + - You are about to drop the column `promo_id` on the `product_variants` table. All the data in the column will be lost. + - You are about to drop the column `unit_price` on the `product_variants` table. All the data in the column will be lost. + - Added the required column `unit_price` to the `store_stock` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE `product_variants` DROP FOREIGN KEY `product_variants_promo_id_fkey`; + +-- DropIndex +DROP INDEX `product_variants_id_type_name_discount_idx` ON `product_variants`; + +-- AlterTable +ALTER TABLE `addresses` MODIFY `type` ENUM('personal', 'store', 'destination') NOT NULL DEFAULT 'personal', + MODIFY `latitude` DOUBLE NULL, + MODIFY `longitude` DOUBLE NULL; + +-- AlterTable +ALTER TABLE `customer_orders` ADD COLUMN `user_id` VARCHAR(191) NULL; + +-- AlterTable +ALTER TABLE `images` DROP COLUMN `is_active`; + +-- AlterTable +ALTER TABLE `product_variants` DROP COLUMN `discount`, + DROP COLUMN `promo_id`, + DROP COLUMN `unit_price`; + +-- AlterTable +ALTER TABLE `store_stock` ADD COLUMN `discount` INTEGER NULL, + ADD COLUMN `promo_id` VARCHAR(191) NULL, + ADD COLUMN `unit_price` DOUBLE NOT NULL; + +-- CreateIndex +CREATE INDEX `product_variants_id_type_name_idx` ON `product_variants`(`id`, `type`, `name`); + +-- AddForeignKey +ALTER TABLE `store_stock` ADD CONSTRAINT `store_stock_promo_id_fkey` FOREIGN KEY (`promo_id`) REFERENCES `promotions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_orders` ADD CONSTRAINT `customer_orders_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b8cc63b..2e86062 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1,8 +1,12 @@ // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["omitApi"] } datasource db { @@ -10,12 +14,318 @@ datasource db { url = env("DATABASE_URL") } -model Sample { - id Int @id @default(autoincrement()) - name String - code String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +enum Role { + customer + super_admin + store_admin +} + +enum Gender { + male + female +} + +model User { + id String @id @default(cuid()) + email String @unique @db.VarChar(85) + password String? + avatar_id String? @unique + avatar Image? @relation(fields: [avatar_id], references: [id]) + reset_token String? @db.Text + referral_code String? @unique + reference_code String? @db.VarChar(100) + is_verified Boolean @default(false) + role Role @default(customer) + addresses Address[] + store_id String? + store Store? @relation(fields: [store_id], references: [address_id]) + full_name String? @db.VarChar(100) + gender Gender? @default(male) + dob DateTime? + phone_no String? @db.VarChar(25) + voucher_id String? + voucher Promotion? @relation(fields: [voucher_id], references: [id]) + is_banned Boolean @default(false) + customer_orders CustomerOrders[] + cart Cart[] + order_detail OrderDetail[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, email, role, is_verified]) + @@map("users") +} + +enum AddressType { + personal + store + destination +} + +model Address { + id String @id @default(cuid()) + user_id String + user User @relation(fields: [user_id], references: [id]) + address String + city_id Int + city City @relation(fields: [city_id], references: [city_id]) + type AddressType @default(personal) + details String? @db.VarChar(100) + longitude Float? + latitude Float? + store Store[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, address]) + @@map("addresses") +} + +enum CityType { + Kota + Kabupaten +} + +model City { + city_id Int @id @default(autoincrement()) + province String + type CityType + city_name String @db.VarChar(100) + postal_code Int + address Address[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([city_id, city_name, type, postal_code]) + @@map("cities") +} + +model Store { + address_id String @id @default(cuid()) + address Address @relation(fields: [address_id], references: [id]) + store_admin User[] + product_stock StoreStock[] + schedule_id String? + schedule StoreSchedule? @relation(fields: [schedule_id], references: [id]) + customer_orders CustomerOrders[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([address_id]) + @@map("stores") +} + +model StoreSchedule { + id String @id @default(cuid()) + name String @db.VarChar(100) + start_time DateTime? @db.Time() + end_time DateTime? @db.Time() + store Store[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, name, start_time, end_time]) + @@map("store_schedules") +} + +model StoreStock { + id String @id @default(cuid()) + store_id String + store Store @relation(fields: [store_id], references: [address_id]) + variant_id String + product ProductVariants @relation(fields: [variant_id], references: [id]) + stock_history StockHistory[] + unit_price Float @db.Double + discount Int? + promo_id String? + promo Promotion? @relation(fields: [promo_id], references: [id]) + quantity Int + order_details OrderDetail[] + cart Cart[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@map("store_stock") +} + +model StockHistory { + id String @id @default(cuid()) + store_stock_id String + store_stock StoreStock @relation(fields: [store_stock_id], references: [id]) + start_qty_at Int + qty_change Int @default(0) + reference String + transaction_id String? + transaction CustomerOrders? @relation(fields: [transaction_id], references: [id]) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@map("stock_history") +} + +enum ImageType { + avatar + store + product + promotion + discount + voucher + category +} + +model Image { + id String @id @default(cuid()) + name String @unique + blob Bytes @db.MediumBlob + type ImageType @default(product) + user User? + product_variants ProductVariants? + category Category? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([type]) + @@map("images") +} + +model Product { + id String @id @default(cuid()) + name String @db.VarChar(80) + description String? + shelf_life String? @db.VarChar(80) + nutrition_facts String? + storage_instructions String? + category_id Int + category Category @relation(fields: [category_id], references: [id]) + sub_category_id Int + sub_category SubCategory @relation(fields: [sub_category_id], references: [id]) + variants ProductVariants[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, name, category_id]) + @@map("products") +} + +enum Variants { + weight + volume + size + flavour + pcs +} + +model ProductVariants { + id String @id @default(cuid()) + type Variants + image_id String? @unique + images Image? @relation(fields: [image_id], references: [id]) + name String @db.VarChar(100) + store_stock StoreStock[] + product_id String + product Product @relation(fields: [product_id], references: [id]) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, type, name]) + @@map("product_variants") +} + +model Category { + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + image_id String? @unique + image Image? @relation(fields: [image_id], references: [id]) + product Product[] + sub_categories SubCategory[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, name]) + @@map("categories") +} + +model SubCategory { + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + category_id Int + category Category @relation(fields: [category_id], references: [id]) + products Product[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, name]) + @@map("sub_categories") +} + +enum PromoType { + discount + voucher + referral_voucher + cashback + free_shipping +} + +model Promotion { + id String @id @default(cuid()) + title String @db.VarChar(100) + description String + type PromoType + amount Float @db.Double + min_transaction Float @db.Double + expiry_date DateTime + is_valid Boolean + store_stock StoreStock[] + customer_orders CustomerOrders[] + user User[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([id, title, type, is_valid]) + @@map("promotions") +} + +model Cart { + user_id String + user User @relation(fields: [user_id], references: [id]) + store_stock_id String + store_stock StoreStock @relation(fields: [store_stock_id], references: [id]) + quantity Int + created_at DateTime + updated_at DateTime + + @@id([user_id, store_stock_id]) + @@map("shopping_carts") +} + +model OrderDetail { + id String @id @default(cuid()) + user_id String + user User @relation(fields: [user_id], references: [id]) + store_stock_id String + store_stock StoreStock @relation(fields: [store_stock_id], references: [id]) + quantity Int + transaction_id String + transaction CustomerOrders @relation(fields: [transaction_id], references: [id]) + created_at DateTime + updated_at DateTime + + @@map("order_details") +} + +model CustomerOrders { + id String @id @default(cuid()) + inv_no String + payment_proof Bytes? @db.Blob + promotion_id String? + promotion Promotion? @relation(fields: [promotion_id], references: [id]) + discount Int? + user_id String? + user User? @relation(fields: [user_id], references: [id]) + origin_id String + origin Store @relation(fields: [origin_id], references: [address_id]) + order_details OrderDetail[] + stock_histories StockHistory[] - @@map("samples") // if you want to use snake_case format + @@map("customer_orders") } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..bcb4f9a --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,26 @@ +import { schedule } from './data/schedule'; +import { copyCities } from './data/cities'; +import { users } from './data/users'; +import prisma from '@/prisma'; + +async function main() { + await prisma.$transaction(async (prisma) => { + try { + await prisma.storeSchedule.createMany({ data: schedule }); + await prisma.user.createMany({ data: users }); + const cities = await copyCities(); + await prisma.city.createMany({ + data: cities.map((city: any) => ({ + ...city, + city_id: Number(city.city_id), + province_id: Number(city.province_id), + postal_code: Number(city.postal_code), + })), + }); + } catch (error) { + if (error instanceof Error) console.log(error.message); + } + }); +} + +main(); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bb85ee1..5fc8539 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,11 +5,13 @@ import express, { Request, Response, NextFunction, - Router, } from 'express'; import cors from 'cors'; -import { PORT } from './config'; -import { SampleRouter } from './routers/sample.router'; +import { corsOptions, PORT } from './config'; +import { SuperAdminRouter } from './routers/super-admin.router'; +import { CustomError } from './utils/error'; +import { ZodError } from 'zod'; +import { CitiesRouter } from './routers/cities.router'; export default class App { private app: Express; @@ -22,7 +24,7 @@ export default class App { } private configure(): void { - this.app.use(cors()); + this.app.use(cors(corsOptions)); this.app.use(json()); this.app.use(urlencoded({ extended: true })); } @@ -42,7 +44,16 @@ export default class App { (err: Error, req: Request, res: Response, next: NextFunction) => { if (req.path.includes('/api/')) { console.error('Error : ', err.stack); - res.status(500).send('Error !'); + if (err instanceof ZodError) { + const errorMessage = err.errors.map((err) => ({ + message: `${err.path.join('.')} is ${err.message}`, + })); + } + if (err instanceof CustomError) { + res.status(err.statusCode).send({ message: err.message }); + } else { + res.status(500).send({ message: err.message }); + } } else { next(); } @@ -51,13 +62,14 @@ export default class App { } private routes(): void { - const sampleRouter = new SampleRouter(); - + const superAdminRouter = new SuperAdminRouter(); + const citiesRouter = new CitiesRouter(); this.app.get('/api', (req: Request, res: Response) => { res.send(`Hello, Purwadhika Student API!`); }); - this.app.use('/api/samples', sampleRouter.getRouter()); + this.app.use('/api/admin', superAdminRouter.getRouter()); + this.app.use('/api/cities', citiesRouter.getRouter()); } public start(): void { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 279bf19..7fa469f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -1,3 +1,4 @@ +import { CorsOptions } from 'cors'; import { config } from 'dotenv'; import { resolve } from 'path'; @@ -12,3 +13,13 @@ config({ path: resolve(__dirname, `../${envFile}.local`), override: true }); export const PORT = process.env.PORT || 8000; export const DATABASE_URL = process.env.DATABASE_URL || ''; + +export const ACC_SECRET_KEY = process.env.ACC_SECRET_KEY || ''; +export const REFR_SECRET_KEY = process.env.REFR_SECRET_KEY || ''; +export const FP_SECRET_KEY = process.env.FP_SECRET_KEY || ''; +export const VERIF_SECRET_KEY = process.env.VERIF_SECRET_KEY || ''; + +export const corsOptions: CorsOptions = { + origin: [`${process.env.CORS}`], + credentials: true, +}; diff --git a/apps/api/src/constants/image.constants.ts b/apps/api/src/constants/image.constants.ts new file mode 100644 index 0000000..59dbb1e --- /dev/null +++ b/apps/api/src/constants/image.constants.ts @@ -0,0 +1,16 @@ +import { generateSlug } from '@/utils/generate'; +import { Prisma } from '@prisma/client'; +import sharp from 'sharp'; + +export const imageCreateInput = async ( + file: Express.Multer.File, + id: string, +): Promise => { + const blob = await sharp(file.buffer).webp().toBuffer(); + const name = `${generateSlug(file.fieldname)}-${id}`; + return { + name, + blob, + type: 'avatar', + }; +}; diff --git a/apps/api/src/controllers/admin.auth.controller.ts b/apps/api/src/controllers/admin.auth.controller.ts new file mode 100644 index 0000000..6ca18f8 --- /dev/null +++ b/apps/api/src/controllers/admin.auth.controller.ts @@ -0,0 +1,39 @@ +import { NextFunction, Request, Response } from 'express'; +import adminAuthService from '@/services/admin.auth.service'; +import { Role } from '@prisma/client'; + +export class AdminAuthController { + async adminLogin(req: Request, res: Response, next: NextFunction) { + try { + const { accessToken, refreshToken } = (await adminAuthService.adminLogin( + req, + )) as { + accessToken: string; + refreshToken: string; + }; + res + .cookie('access_token', accessToken) + .cookie('refresh_token', refreshToken) + .send({ message: 'login success' }); + } catch (error) { + next(error); + } + } + async validateAdminRefreshToken( + req: Request, + res: Response, + next: NextFunction, + ) { + try { + const { accessToken, role, isVerified } = + (await adminAuthService.validateAdminRefreshToken(req)) as { + accessToken: string; + role: Role; + isVerified: boolean; + }; + res.send({ message: 'success', role, accessToken, isVerified }); + } catch (error) { + next(error); + } + } +} diff --git a/apps/api/src/controllers/cities.controller.ts b/apps/api/src/controllers/cities.controller.ts new file mode 100644 index 0000000..5a7db79 --- /dev/null +++ b/apps/api/src/controllers/cities.controller.ts @@ -0,0 +1,13 @@ +import citiesService from '@/services/cities.service'; +import { NextFunction, Request, Response } from 'express'; + +export class CitiesController { + async getAllCities(req: Request, res: Response, next: NextFunction) { + try { + const results = await citiesService.getAllCities(req); + res.send({ message: 'Fetch all cities data.', results }); + } catch (error) { + next(error); + } + } +} diff --git a/apps/api/src/controllers/sample.controller.ts b/apps/api/src/controllers/sample.controller.ts deleted file mode 100644 index b551673..0000000 --- a/apps/api/src/controllers/sample.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Request, Response } from 'express'; -import prisma from '@/prisma'; - -export class SampleController { - async getSampleData(req: Request, res: Response) { - const sampleData = await prisma.sample.findMany(); - - return res.status(200).send(sampleData); - } - - async getSampleDataById(req: Request, res: Response) { - const { id } = req.params; - - const sample = await prisma.sample.findUnique({ - where: { id: Number(id) }, - }); - - if (!sample) { - return res.send(404); - } - - return res.status(200).send(sample); - } - - async createSampleData(req: Request, res: Response) { - const { name, code } = req.body; - - const newSampleData = await prisma.sample.create({ - data: { name, code }, - }); - - return res.status(201).send(newSampleData); - } -} diff --git a/apps/api/src/controllers/super-admin.controller.ts b/apps/api/src/controllers/super-admin.controller.ts new file mode 100644 index 0000000..762fcd8 --- /dev/null +++ b/apps/api/src/controllers/super-admin.controller.ts @@ -0,0 +1,45 @@ +import superAdminServices from '@/services/super-admin.services'; +import { NextFunction, Request, Response } from 'express'; + +export class SuperAdminController { + async getAllCustomers(req: Request, res: Response, next: NextFunction) { + try { + const results = await superAdminServices.getAllCustomers(req); + res.send({ message: 'Fetch all customers.', results }); + } catch (error) { + next(error); + } + } + async getAllStoreAdmins(req: Request, res: Response, next: NextFunction) { + try { + const results = await superAdminServices.getAllStoreAdmins(req); + res.send({ message: 'Fetch all store admins.', results }); + } catch (error) { + next(error); + } + } + async createStoreAdmin(req: Request, res: Response, next: NextFunction) { + try { + await superAdminServices.createStoreAdmin(req); + res.status(201).send({ message: 'Success.' }); + } catch (error) { + next(error); + } + } + async updateStoreAdmin(req: Request, res: Response, next: NextFunction) { + try { + await superAdminServices.updateStoreAdmin(req); + res.send({ message: 'Success.' }); + } catch (error) { + next(error); + } + } + async deleteStoreAdmin(req: Request, res: Response, next: NextFunction) { + try { + await superAdminServices.deleteStoreAdmin(req); + res.status(200).send({ message: 'Success.' }); + } catch (error) { + next(error); + } + } +} diff --git a/apps/api/src/libs/bcrypt.ts b/apps/api/src/libs/bcrypt.ts new file mode 100644 index 0000000..fd27947 --- /dev/null +++ b/apps/api/src/libs/bcrypt.ts @@ -0,0 +1,10 @@ +import { compare, genSalt, hash } from "bcrypt"; + +export const hashPassword = async (password: string) => { + const salt = await genSalt(10); + return await hash(password, salt); +}; + +export const comparePassword = async (hashPass: string, password: string) => { + return await compare(password, hashPass); +}; diff --git a/apps/api/src/libs/jwt.ts b/apps/api/src/libs/jwt.ts new file mode 100644 index 0000000..d070e21 --- /dev/null +++ b/apps/api/src/libs/jwt.ts @@ -0,0 +1,7 @@ +import { sign } from 'jsonwebtoken'; + +export const createToken = (payload: any, key: string, expiresIn: string) => { + return sign(payload, key, { + expiresIn, + }); +}; diff --git a/apps/api/src/libs/multer.ts b/apps/api/src/libs/multer.ts new file mode 100644 index 0000000..9db4ac1 --- /dev/null +++ b/apps/api/src/libs/multer.ts @@ -0,0 +1,59 @@ +import multer, { FileFilterCallback } from 'multer'; +import { join } from 'path'; +import { Request } from 'express'; +import { DestinationCallback, FilenameCallback } from '../models/multer.model'; +import dayjs from 'dayjs'; + +const mB = 1072864; +export const maxSize = 1 * mB; + +const multerConfig: multer.Options = { + fileFilter: ( + req: Request, + file: Express.Multer.File, + cb: FileFilterCallback, + ) => { + if (file.mimetype.split('/')[0] !== 'image') { + return cb(new Error("file type isn't image")); + } + if (file.size > maxSize) { + return cb(new Error('max size 1mb')); + } + return cb(null, true); + }, +}; + +export function uploader( + filePrefix: string, + fileSize: number, + folderName?: string, +) { + const defaultDir = join(__dirname, '../public/images/'); + const storage = multer.diskStorage({ + destination: ( + req: Request, + file: Express.Multer.File, + cb: DestinationCallback, + ) => { + const destination = folderName ? defaultDir + folderName : defaultDir; + cb(null, destination); + }, + filename: ( + req: Request, + file: Express.Multer.File, + cb: FilenameCallback, + ) => { + const originalNameParts = file.originalname.split('.'); + const fileExtension = originalNameParts[originalNameParts.length - 1]; + const newFileName = `${filePrefix}-${req.user.id}-${dayjs().format( + 'YYYYMMDD-HHmmss', + )}.${fileExtension}`; + cb(null, newFileName); + }, + }); + return multer({ storage, ...multerConfig, limits: { fileSize } }); +} + +export function blobUploader() { + return multer({ ...multerConfig }); +} diff --git a/apps/api/src/libs/node-cron.ts b/apps/api/src/libs/node-cron.ts new file mode 100644 index 0000000..8a92174 --- /dev/null +++ b/apps/api/src/libs/node-cron.ts @@ -0,0 +1,9 @@ +import cron from 'node-cron'; +// import { changeStatus } from "../utils/scheduler"; + +// export function updateStatusEvent() { +// return cron.schedule("*/3 * * * * *", async () => { +// // console.log("test"); +// await changeStatus(); +// }); +// } diff --git a/apps/api/src/libs/nodemailer.ts b/apps/api/src/libs/nodemailer.ts new file mode 100644 index 0000000..7ce449d --- /dev/null +++ b/apps/api/src/libs/nodemailer.ts @@ -0,0 +1,38 @@ +// import nodemailer from 'nodemailer'; +// // import { user, pass } from "../config/config"; +// import fs from 'fs'; +// import { join } from 'path'; +// import { render } from 'mustache'; + +// export const transporter = nodemailer.createTransport({ +// service: 'gmail', +// auth: { +// user, +// pass, +// }, +// }); + +// export async function sendEmail({ +// email_to, +// template_dir, +// href, +// subject, +// }: { +// email_to: string; +// template_dir: string; +// href: string; +// subject: string; +// }) { +// const template = fs.readFileSync(join(__dirname, template_dir)).toString(); +// if (template) { +// const html = render(template, { +// email: email_to, +// href, +// }); +// await transporter.sendMail({ +// to: email_to, +// subject, +// html, +// }); +// } +// } diff --git a/apps/api/src/libs/prisma/address.args.ts b/apps/api/src/libs/prisma/address.args.ts new file mode 100644 index 0000000..8bb7d2d --- /dev/null +++ b/apps/api/src/libs/prisma/address.args.ts @@ -0,0 +1,53 @@ +import { AddressType, Prisma } from '@prisma/client'; +import { Request } from 'express'; + +export function addressFindMany(req: Request): Prisma.AddressFindManyArgs { + const { search } = req.query; + console.log(search); + + return { + where: { + type: AddressType.personal, + AND: { + OR: [ + { address: { contains: String(search) } }, + { + city: { + OR: [ + { city_name: { contains: String(search) } }, + { province: { contains: String(search) } }, + ], + }, + }, + ], + }, + }, + include: { + city: true, + }, + }; +} + +export function storeAddressFindFirst(req: Request): Prisma.StoreFindFirstArgs { + const { search, store_city, store_province } = req.query; + return { + where: { + address: { + type: AddressType.store, + AND: { + OR: [ + { address: { contains: String(search) } }, + { + city: { + OR: [ + { city_name: { equals: String(store_city) } }, + { province: { equals: String(store_province) } }, + ], + }, + }, + ], + }, + }, + }, + }; +} diff --git a/apps/api/src/libs/prisma/images.args.ts b/apps/api/src/libs/prisma/images.args.ts new file mode 100644 index 0000000..548649c --- /dev/null +++ b/apps/api/src/libs/prisma/images.args.ts @@ -0,0 +1,18 @@ +import { generateSlug } from '@/utils/generate'; +import { ImageType, Prisma } from '@prisma/client'; +import { Request } from 'express'; +import sharp from 'sharp'; + +export async function imageCreate( + req: Request, + id: string, + mimetype: 'png' | 'jpeg' | 'webp' | 'gif', + type: ImageType, +): Promise { + const file = req.file as Express.Multer.File; + return { + name: `${generateSlug(file.fieldname)}-${id}`, + blob: await sharp(file.buffer)[`${mimetype}`]().toBuffer(), + type, + }; +} diff --git a/apps/api/src/libs/prisma/user.args.ts b/apps/api/src/libs/prisma/user.args.ts new file mode 100644 index 0000000..df5e436 --- /dev/null +++ b/apps/api/src/libs/prisma/user.args.ts @@ -0,0 +1,55 @@ +import { Prisma, Role } from '@prisma/client'; +import { Request } from 'express'; +import { addressFindMany, storeAddressFindFirst } from './address.args'; + +export function userFindMany( + role: Role, + req: Request, +): Prisma.UserFindManyArgs { + const { search } = req.query; + return { + where: { + role, + AND: { + is_banned: false, + OR: [ + { full_name: { contains: String(search) } }, + { email: { contains: String(search) } }, + ], + }, + }, + orderBy: { created_at: 'desc' }, + include: { + addresses: addressFindMany(req), + store: storeAddressFindFirst(req), + }, + omit: { + password: true, + }, + }; +} + +export const adminOmit: Prisma.UserOmit = { + avatar_id: true, + reset_token: true, + referral_code: true, + reference_code: true, + voucher_id: true, +}; + +export function adminFindFirst(req: Request): Prisma.UserFindFirstArgs { + const { email } = req.body; + return { + where: { + email, + AND: { + OR: [{ role: Role.super_admin }, { role: Role.store_admin }], + }, + }, + include: { + addresses: true, + store: true, + }, + omit: adminOmit, + }; +} diff --git a/apps/api/src/libs/zod-schemas/store-admin.schema.ts b/apps/api/src/libs/zod-schemas/store-admin.schema.ts new file mode 100644 index 0000000..b931757 --- /dev/null +++ b/apps/api/src/libs/zod-schemas/store-admin.schema.ts @@ -0,0 +1,76 @@ +import { Gender } from '@prisma/client'; +import { z } from 'zod'; + +export const storeAdminSchema = z.object({ + email: z + .string() + .min(10, { message: 'Email must have min. 10 characters.' }) + .trim() + .toLowerCase() + .email({ message: 'Invalid email type.' }), + password: z + .string() + .trim() + .min(8, { message: 'Password must have min. 8 characters' }) + .regex(new RegExp('^(?=.*?[A-Z])(?=.*?[a-z])'), { + message: 'Password must have min. 1 uppercase.', + }), + store_id: z.string().optional(), + full_name: z + .string() + .trim() + .min(3, { message: 'Full name must have min. 3 characters.' }), + gender: z.enum([Gender.male, Gender.female]), + dob: z.string(), + phone_no: z + .string() + .trim() + .regex(new RegExp('^[+]?[(]?[0-9]{3}[)]?[-s.]?[0-9]{3}[-s.]?[0-9]{4,6}$'), { + message: 'Invalid phone number format.', + }), +}); + +export const storeAdminAddressSchema = z.object({ + address: z.string().trim(), + city_id: z.number().positive().int(), + details: z.string().trim().optional(), +}); + +export const storeAdminUpdateSchema = z.object({ + email: z + .string() + .min(10, { message: 'Email must have min. 10 characters.' }) + .trim() + .toLowerCase() + .email({ message: 'Invalid email type.' }) + .optional(), + password: z + .string() + .trim() + .min(8, { message: 'Password must have min. 8 characters' }) + .regex(new RegExp('^(?=.*?[A-Z])(?=.*?[a-z])'), { + message: 'Password must have min. 1 uppercase.', + }) + .optional(), + store_id: z.string().optional(), + full_name: z + .string() + .trim() + .min(3, { message: 'Full name must have min. 3 characters.' }) + .optional(), + gender: z.enum([Gender.male, Gender.female]).optional(), + dob: z.date({ message: 'Invalid date format.' }).optional(), + phone_no: z + .string() + .trim() + .regex(new RegExp('^[+]?[(]?[0-9]{3}[)]?[-s.]?[0-9]{3}[-s.]?[0-9]{4,6}$'), { + message: 'Invalid phone number format.', + }) + .optional(), +}); + +export const storeAdminAddressUpdateSchema = z.object({ + address: z.string().trim().optional(), + city_id: z.number().positive().int().optional(), + details: z.string().trim().optional(), +}); diff --git a/apps/api/src/middlewares/admin.middleware.ts b/apps/api/src/middlewares/admin.middleware.ts new file mode 100644 index 0000000..7b1b458 --- /dev/null +++ b/apps/api/src/middlewares/admin.middleware.ts @@ -0,0 +1,209 @@ +import { ACC_SECRET_KEY, REFR_SECRET_KEY } from '@/config'; +import { hashPassword } from '@/libs/bcrypt'; +import { + storeAdminAddressSchema, + storeAdminAddressUpdateSchema, + storeAdminSchema, + storeAdminUpdateSchema, +} from '@/libs/zod-schemas/store-admin.schema'; +import { TAddress } from '@/models/address.model'; +import { TUser } from '@/models/user.model'; +import prisma from '@/prisma'; +import { AuthError, BadRequestError, InvalidDataError } from '@/utils/error'; +import { adminFindFirst } from '@/libs/prisma/user.args'; +import { reqBodyReducer } from '@/utils/req.body.helper'; +import { Role } from '@prisma/client'; +import { compare } from 'bcrypt'; +import { NextFunction, Request, Response } from 'express'; +import { verify } from 'jsonwebtoken'; +import { ZodError } from 'zod'; + +export async function verifyAdminPassword( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const { password } = req.body; + const isAdminExist = await prisma.user.findFirst(adminFindFirst(req)); + if (!isAdminExist) throw new InvalidDataError('Invalid email/password!'); + if (isAdminExist.role === Role.customer) + throw new AuthError('Unauthorized Access.'); + const comparePassword: boolean | null = + isAdminExist && (await compare(password, isAdminExist.password || '')); + if (!comparePassword) throw new InvalidDataError('Invalid Password!'); + req.user = isAdminExist as TUser; + delete req.user.password; + req.user.role === Role.super_admin && delete req.user.store_id; + next(); + } catch (error) { + next(error); + } +} + +export async function isAdminExist( + req: Request, + res: Response, + next: NextFunction, +) { + const { email, phone_no } = req.body; + try { + const isAdminExist = await prisma.user.findFirst({ + where: { + OR: [{ email }, { phone_no }], + }, + }); + if (!isAdminExist) + throw new BadRequestError('Email/phone number already exist.'); + next(); + } catch (error) { + next(error); + } +} + +export async function verifyAdminAccToken( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const token = req.header('Authorization')?.split(' ')[1] || ''; + console.log(token); + const verifiedAdmin = verify(token, ACC_SECRET_KEY); + if (!token || !verifiedAdmin) throw new AuthError('Unauthorized access'); + req.user = verifiedAdmin as TUser; + next(); + } catch (error) { + next(error); + } +} + +export async function verifyAdminRefreshToken( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const token = req.headers.authorization?.split(' ')[1] || ''; + const verifiedAdminID = verify(token, REFR_SECRET_KEY); + if (!token || !verifiedAdminID) throw new AuthError('Unauthorized access'); + req.user = verifiedAdminID as TUser; + next(); + } catch (error) { + next(error); + } +} + +export async function authorizeSuperAdmin( + req: Request, + res: Response, + next: NextFunction, +) { + try { + if (req.user.role !== Role.super_admin) + throw new AuthError('Unauthorized access to super admin privileges.'); + next(); + } catch (error) { + next(error); + } +} + +export async function authorizeStoreAdmin( + req: Request, + res: Response, + next: NextFunction, +) { + try { + if (req.user.role === Role.customer) + throw new AuthError('Unauthorized access to admin privileges.'); + next(); + } catch (error) { + next(error); + } +} + +export async function validateStoreAdminDetails( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const details = { ...req.body }; + delete details.address; + delete details.city_id; + delete details.details; + const validate = storeAdminSchema.safeParse(details); + if (!validate.success) throw new ZodError(validate.error.errors); + validate.data.password = await hashPassword(validate.data.password); + req.store_admin = { + ...validate.data, + dob: new Date(validate.data.dob), + role: Role.store_admin, + is_verified: true, + store_id: !validate.data.store_id ? null : validate.data.store_id, + }; + next(); + } catch (error) { + next(error); + } +} + +export async function validateStoreAdminAddress( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const { address, city_id, details } = req.body; + const storeAdminAddress = { address, city_id: Number(city_id), details }; + const validateAddress = + storeAdminAddressSchema.safeParse(storeAdminAddress); + if (!validateAddress.success) + throw new ZodError(validateAddress.error.errors); + req.store_admin_address = validateAddress.data as TAddress; + next(); + } catch (error) { + next(error); + } +} + +export async function validateStoreAdminUpdateDetails( + req: Request, + res: Response, + next: NextFunction, +) { + try { + if (req.body.password) + req.body.password = await hashPassword(req.body.password); + if (req.body.dob) req.body.dob = new Date(req.body.dob); + const details = { ...req.body }; + delete details.address; + delete details.city_id; + delete details.details; + const data = reqBodyReducer(details); + const validate = storeAdminUpdateSchema.safeParse(data); + if (!validate.success) throw new ZodError(validate.error.errors); + req.store_admin = validate.data as TUser; + next(); + } catch (error) { + next(error); + } +} + +export async function validateStoreAdminUpdateAddress( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const { address, city_id, details } = req.body; + const storeAdminAddress = { address, city_id: Number(city_id), details }; + const data = reqBodyReducer(storeAdminAddress); + const validate = storeAdminAddressUpdateSchema.safeParse(data); + if (!validate.success) throw new ZodError(validate.error.errors); + req.store_admin_address = validate.data as TAddress; + next(); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/models/address.model.ts b/apps/api/src/models/address.model.ts new file mode 100644 index 0000000..843392b --- /dev/null +++ b/apps/api/src/models/address.model.ts @@ -0,0 +1,14 @@ +import { AddressType } from '@prisma/client'; + +export type TAddress = { + id?: string; + user_id: string; + address: string; + city_id: number; + type: AddressType; + details?: string; + longitude: number; + latitude: number; + created_at: Date; + updated_at: Date; +}; diff --git a/apps/api/src/models/category.model.ts b/apps/api/src/models/category.model.ts new file mode 100644 index 0000000..510f4f1 --- /dev/null +++ b/apps/api/src/models/category.model.ts @@ -0,0 +1,23 @@ +import { TImage } from './image.model'; +import { TProduct } from './products.model'; + +export type TCategory = { + id: number; + name: string; + image_id?: string; + image?: TImage; + product: TProduct[]; + sub_categories: TSubCategory[]; + created_at?: Date; + updated_at?: Date; +}; + +export type TSubCategory = { + id?: number; + name: string; + category_id: number; + category: TCategory; + products: TProduct[]; + created_at?: Date; + updated_at?: Date; +}; diff --git a/apps/api/src/models/global.d.ts b/apps/api/src/models/global.d.ts new file mode 100644 index 0000000..2dbde97 --- /dev/null +++ b/apps/api/src/models/global.d.ts @@ -0,0 +1,12 @@ +import { TAddress } from './address.model'; +import { TStoreAdmin, TUser } from './user.model'; + +declare global { + namespace Express { + interface Request { + user: TUser; + store_admin: TUser; + store_admin_address: TAddress; + } + } +} diff --git a/apps/api/src/models/image.model.ts b/apps/api/src/models/image.model.ts new file mode 100644 index 0000000..1f47955 --- /dev/null +++ b/apps/api/src/models/image.model.ts @@ -0,0 +1,25 @@ +import { TCategory } from './category.model'; +import { TVariant } from './products.model'; +import { TUser } from './user.model'; + +export enum ImageType { + avatar = 'avatar', + store = 'store', + product = 'product', + promotion = 'promotion', + discount = 'discount', + voucher = 'voucher', + category = 'category', +} + +export type TImage = { + id?: string; + name?: string; + blob: Buffer; + type: ImageType; + user?: TUser; + ProductVariants: TVariant; + category?: TCategory; + created_at?: Date; + updated_at?: Date; +}; diff --git a/apps/api/src/models/multer.model.ts b/apps/api/src/models/multer.model.ts new file mode 100644 index 0000000..4c06ac5 --- /dev/null +++ b/apps/api/src/models/multer.model.ts @@ -0,0 +1,5 @@ +export type DestinationCallback = ( + error: Error | null, + destination: string +) => void; +export type FilenameCallback = (error: Error | null, filename: string) => void; diff --git a/apps/api/src/models/products.model.ts b/apps/api/src/models/products.model.ts new file mode 100644 index 0000000..8018c07 --- /dev/null +++ b/apps/api/src/models/products.model.ts @@ -0,0 +1,40 @@ +import { TCategory, TSubCategory } from './category.model'; +import { TImage } from './image.model'; +import { TStoreStock } from './store.model'; + +export type TProduct = { + id?: string; + name: string; + description?: string; + shelf_life?: string; + nutrition_facts?: string; + storage_instructions?: string; + category_id: number; + sub_category_id: number; + category?: TCategory; + sub_category?: TSubCategory; + variants?: TVariant[]; + created_at?: Date; + updated_at?: Date; +}; + +export enum Variants { + weight = 'weight', + volume = 'volume', + size = 'size', + flavour = 'flavour', + pcs = 'pcs', +} + +export type TVariant = { + id?: string; + type: Variants; + image_id?: string; + image: TImage; + name: string; + store_stock?: TStoreStock[]; + product_id?: string; + product: TProduct; + created_at?: Date; + updated_at?: Date; +}; diff --git a/apps/api/src/models/promo.models.ts b/apps/api/src/models/promo.models.ts new file mode 100644 index 0000000..b723756 --- /dev/null +++ b/apps/api/src/models/promo.models.ts @@ -0,0 +1,26 @@ +import { TStoreStock } from './store.model'; +import { TUser } from './user.model'; + +export enum PromoType { + discount = 'discount', + voucher = 'voucher', + referral_voucher = 'referral_voucher', + cashback = 'cashback', + free_shipping = 'free_shipping', +} + +export type TPromotion = { + id?: string; + title: string; + description?: string; + type: PromoType; + amount: number; + min_transaction: number; + expiry_date: Date; + is_valid: Boolean; + store_stock: TStoreStock[]; + // CustomerOrders CustomerOrders[] + user?: TUser[]; + created_at?: Date; + updated_at?: Date; +}; diff --git a/apps/api/src/models/store.model.ts b/apps/api/src/models/store.model.ts new file mode 100644 index 0000000..af7a8f9 --- /dev/null +++ b/apps/api/src/models/store.model.ts @@ -0,0 +1,32 @@ +import { TAddress } from './address.model'; +import { TVariant } from './products.model'; +import { TPromotion } from './promo.models'; +import { TUser } from './user.model'; + +export type TStore = { + address_id: string; + address: TAddress; + store_admin: TUser[]; + product_stock: TStoreStock[]; + schedule_id?: string; + // schedule?: TStoreSchedule + // customer_orders: TCustomerOrders[] + created_at?: Date; + updated_at?: Date; +}; + +export type TStoreStock = { + id: string; + store_id: string; + store: TStore; + variant_id: string; + product: TVariant; + // stock_history: TStockHistory[]; + unit_price: number; + discount?: number; + promo_id?: string; + promo?: TPromotion; + quantity: number; + created_at?: Date; + updated_at?: Date; +}; diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts new file mode 100644 index 0000000..8015468 --- /dev/null +++ b/apps/api/src/models/user.model.ts @@ -0,0 +1,22 @@ +import { dateOpt, stringOpt } from '@/utils/optionals.type'; +import { Gender, Role } from '@prisma/client'; + +export type TUser = { + id?: string; + email: string; + password?: stringOpt; + avatar_id?: stringOpt; + reset_token?: stringOpt; + referral_code?: stringOpt; + reference_code?: stringOpt; + is_verified: boolean; + role: Role; + store_id?: stringOpt; + full_name?: stringOpt; + gender?: Gender; + dob?: dateOpt; + phone_no?: stringOpt; + voucher_id?: stringOpt; + created_at?: dateOpt; + updated_at?: dateOpt; +}; diff --git a/apps/api/src/routers/cities.router.ts b/apps/api/src/routers/cities.router.ts new file mode 100644 index 0000000..da1242c --- /dev/null +++ b/apps/api/src/routers/cities.router.ts @@ -0,0 +1,18 @@ +import { CitiesController } from '@/controllers/cities.controller'; +import { Router } from 'express'; + +export class CitiesRouter { + private router: Router; + private citiesController: CitiesController; + constructor() { + this.citiesController = new CitiesController(); + this.router = Router(); + this.initializeRoutes(); + } + private initializeRoutes(): void { + this.router.get('/', this.citiesController.getAllCities); + } + getRouter(): Router { + return this.router; + } +} diff --git a/apps/api/src/routers/sample.router.ts b/apps/api/src/routers/sample.router.ts deleted file mode 100644 index 4bce75c..0000000 --- a/apps/api/src/routers/sample.router.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { SampleController } from '@/controllers/sample.controller'; -import { Router } from 'express'; - -export class SampleRouter { - private router: Router; - private sampleController: SampleController; - - constructor() { - this.sampleController = new SampleController(); - this.router = Router(); - this.initializeRoutes(); - } - - private initializeRoutes(): void { - this.router.get('/', this.sampleController.getSampleData); - this.router.get('/:id', this.sampleController.getSampleDataById); - this.router.post('/', this.sampleController.createSampleData); - } - - getRouter(): Router { - return this.router; - } -} diff --git a/apps/api/src/routers/super-admin.router.ts b/apps/api/src/routers/super-admin.router.ts new file mode 100644 index 0000000..d61a3de --- /dev/null +++ b/apps/api/src/routers/super-admin.router.ts @@ -0,0 +1,65 @@ +import { AdminAuthController } from '@/controllers/admin.auth.controller'; +import { SuperAdminController } from '@/controllers/super-admin.controller'; +import { + validateStoreAdminDetails, + validateStoreAdminAddress, + validateStoreAdminUpdateDetails, + validateStoreAdminUpdateAddress, + verifyAdminPassword, + isAdminExist, + verifyAdminAccToken, +} from '@/middlewares/admin.middleware'; +import { Router } from 'express'; + +export class SuperAdminRouter { + private router: Router; + private adminAuthController: AdminAuthController; + private superAdminController: SuperAdminController; + constructor() { + this.adminAuthController = new AdminAuthController(); + this.superAdminController = new SuperAdminController(); + this.router = Router(); + this.initializeRoutes(); + } + private initializeRoutes(): void { + this.router.post( + '/auth/v1', + verifyAdminPassword, + this.adminAuthController.adminLogin, + ); + this.router.get( + '/users/customers', + verifyAdminAccToken, + this.superAdminController.getAllCustomers, + ); + this.router.get( + '/users/store-admins', + verifyAdminAccToken, + this.superAdminController.getAllStoreAdmins, + ); + this.router.post( + '/users/store-admins', + verifyAdminAccToken, + isAdminExist, + validateStoreAdminDetails, + validateStoreAdminAddress, + this.superAdminController.createStoreAdmin, + ); + this.router.patch( + '/users/store-admins/:id', + verifyAdminAccToken, + validateStoreAdminUpdateDetails, + validateStoreAdminUpdateAddress, + this.superAdminController.updateStoreAdmin, + ); + this.router.delete( + '/users/store-admins/:id', + verifyAdminAccToken, + this.superAdminController.deleteStoreAdmin, + ); + } + + getRouter(): Router { + return this.router; + } +} diff --git a/apps/api/src/services/admin.auth.service.ts b/apps/api/src/services/admin.auth.service.ts new file mode 100644 index 0000000..36c66da --- /dev/null +++ b/apps/api/src/services/admin.auth.service.ts @@ -0,0 +1,41 @@ +import { ACC_SECRET_KEY, REFR_SECRET_KEY } from '@/config'; +import { createToken } from '@/libs/jwt'; +import { TUser } from '@/models/user.model'; +import prisma from '@/prisma'; +import { catchAllErrors } from '@/utils/error'; +import { adminOmit } from '@/libs/prisma/user.args'; +import { Request } from 'express'; + +class AdminAuthService { + async adminLogin(req: Request) { + try { + const accessToken = createToken(req?.user, ACC_SECRET_KEY, '1h'); + const refreshToken = createToken( + { id: req?.user.id }, + REFR_SECRET_KEY, + '10h', + ); + return { accessToken, refreshToken }; + } catch (error) { + catchAllErrors(error); + } + } + async validateAdminRefreshToken(req: Request) { + try { + const isUserExist: TUser = (await prisma.user.findFirst({ + where: { id: req?.user.id }, + omit: adminOmit, + })) as TUser; + const access_token = createToken(isUserExist, ACC_SECRET_KEY, '1h'); + return { + accessToken: access_token, + role: isUserExist?.role, + isVerified: isUserExist?.is_verified, + }; + } catch (error) { + catchAllErrors(error); + } + } +} + +export default new AdminAuthService(); diff --git a/apps/api/src/services/categories.service.ts b/apps/api/src/services/categories.service.ts new file mode 100644 index 0000000..25f40bc --- /dev/null +++ b/apps/api/src/services/categories.service.ts @@ -0,0 +1,3 @@ +class CategoryService {} + +export default new CategoryService(); diff --git a/apps/api/src/services/cities.service.ts b/apps/api/src/services/cities.service.ts new file mode 100644 index 0000000..9c2c328 --- /dev/null +++ b/apps/api/src/services/cities.service.ts @@ -0,0 +1,22 @@ +import prisma from '@/prisma'; +import { catchAllErrors, InternalServerError } from '@/utils/error'; +import { CityType } from '@prisma/client'; +import { Request } from 'express'; + +class CitiesService { + async getAllCities(req: Request) { + try { + const cities = await prisma.city.findMany({ + where: { type: CityType.Kota }, + orderBy: { city_name: 'asc' }, + omit: { created_at: true, updated_at: true }, + }); + if (!cities) throw new InternalServerError('Unable to fetch cities.'); + return cities; + } catch (error) { + catchAllErrors(error); + } + } +} + +export default new CitiesService(); diff --git a/apps/api/src/services/products.service.ts b/apps/api/src/services/products.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/services/super-admin.services.ts b/apps/api/src/services/super-admin.services.ts new file mode 100644 index 0000000..3b05447 --- /dev/null +++ b/apps/api/src/services/super-admin.services.ts @@ -0,0 +1,103 @@ +import { TUser } from '@/models/user.model'; +import prisma from '@/prisma'; +import { catchAllErrors, NotFoundError } from '@/utils/error'; +import { countTotalPage, paginate } from '@/utils/pagination'; +import { userFindMany } from '@/libs/prisma/user.args'; +import { Address, AddressType, Role, User } from '@prisma/client'; +import { Request } from 'express'; + +class SuperAdminService { + async getAllCustomers(req: Request) { + try { + const { page, show } = req.query; + const queries = userFindMany(Role.customer, req); + const users = (await prisma.user.findMany({ + ...queries, + ...paginate(Number(show), Number(page)), + })) as TUser[]; + if (!users) throw new NotFoundError('Customers not found.'); + const count = await prisma.user.count({ where: queries.where }); + return { users, totalPage: countTotalPage(count, Number(show)) }; + } catch (error) { + catchAllErrors(error); + } + } + async getAllStoreAdmins(req: Request) { + try { + const { page, show } = req.query; + const queries = userFindMany(Role.store_admin, req); + const users = (await prisma.user.findMany({ + ...queries, + ...paginate(Number(show), Number(page)), + })) as TUser[]; + if (!users) throw new NotFoundError('Store Admin not found.'); + const count = await prisma.user.count({ where: queries.where }); + return { users, totalPage: countTotalPage(count, Number(show)) }; + } catch (error) { + catchAllErrors(error); + } + } + async createStoreAdmin(req: Request) { + try { + await prisma.$transaction(async (prisma) => { + const storeAdmin = await prisma.user.create({ + data: req.store_admin as User, + }); + await prisma.address.create({ + data: { + ...(req.store_admin_address as Address), + user_id: storeAdmin.id, + type: AddressType.personal, + }, + }); + }); + } catch (error) { + catchAllErrors(error); + } + } + async updateStoreAdmin(req: Request) { + const { id } = req.params; + console.log(id); + + try { + await prisma.$transaction(async (prisma) => { + const addressID = await prisma.user.update({ + where: { id, AND: { role: Role.store_admin } }, + data: req.store_admin as User, + select: { + addresses: { + select: { + id: true, + }, + }, + }, + }); + await prisma.address.update({ + where: { id: addressID.addresses[0].id }, + data: req.store_admin_address as Address, + }); + }); + } catch (error) { + catchAllErrors(error); + } + } + async deleteStoreAdmin(req: Request) { + try { + const { id } = req.params; + const isExist = await prisma.user.findFirst({ + where: { id }, + }); + if (!isExist) throw new NotFoundError("Store admin doesn't exist"); + await prisma.user.update({ + where: { id, AND: { role: Role.store_admin } }, + data: { + is_banned: true, + }, + }); + } catch (error) { + catchAllErrors(error); + } + } +} + +export default new SuperAdminService(); diff --git a/apps/api/src/utils/error.ts b/apps/api/src/utils/error.ts new file mode 100644 index 0000000..9d0a3c1 --- /dev/null +++ b/apps/api/src/utils/error.ts @@ -0,0 +1,59 @@ +export class CustomError extends Error { + public statusCode: number = 500; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export class BadRequestError extends CustomError { + public statusCode: number; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.statusCode = 400; + } +} + +export class InvalidDataError extends CustomError { + public statusCode: number; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.statusCode = 422; + } +} + +export class PaymentError extends CustomError { + public statusCode: number; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.statusCode = 402; + } +} + +export class AuthError extends CustomError { + public statusCode: number; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.statusCode = 401; + } +} + +export class NotFoundError extends CustomError { + public statusCode: number; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.statusCode = 404; + } +} + +export class InternalServerError extends CustomError { + public statusCode: number; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.statusCode = 500; + } +} + +export function catchAllErrors(error: unknown) { + if (error instanceof CustomError) throw new CustomError(error.message); + if (error instanceof Error) throw new Error(error.message); +} diff --git a/apps/api/src/utils/generate.ts b/apps/api/src/utils/generate.ts new file mode 100644 index 0000000..de87480 --- /dev/null +++ b/apps/api/src/utils/generate.ts @@ -0,0 +1,18 @@ +import { generate } from 'voucher-code-generator'; + +export const generateReferral = () => { + const referral = generate({ + pattern: '####-####', + count: 1, + charset: 'alphanumeric', + }); + + return referral[0]; +}; + +export const generateSlug = (str: string): string => { + return str + .toLocaleLowerCase() + .replace(/ /g, '-') + .replace(/[^\w-]+/g, ''); +}; diff --git a/apps/api/src/utils/message.ts b/apps/api/src/utils/message.ts new file mode 100644 index 0000000..600fc1f --- /dev/null +++ b/apps/api/src/utils/message.ts @@ -0,0 +1,11 @@ +const messageResponse = (message: string, description: string | undefined) => ({ + message, + ...(description && { description }), +}); + +const errorResponse = (message: string, cause: unknown = '') => ({ + message, + cause, +}); + +export { messageResponse, errorResponse }; diff --git a/apps/api/src/utils/optionals.type.ts b/apps/api/src/utils/optionals.type.ts new file mode 100644 index 0000000..902e871 --- /dev/null +++ b/apps/api/src/utils/optionals.type.ts @@ -0,0 +1,3 @@ +export type stringOpt = string | null; +export type dateOpt = Date | null; +export type numOpt = number | null; diff --git a/apps/api/src/utils/pagination.ts b/apps/api/src/utils/pagination.ts new file mode 100644 index 0000000..9fd332f --- /dev/null +++ b/apps/api/src/utils/pagination.ts @@ -0,0 +1,7 @@ +export function paginate(show: number, page: number) { + return { take: show, skip: (page - 1) * show }; +} + +export function countTotalPage(count: number, show: number) { + return Math.ceil(count / show); +} diff --git a/apps/api/src/utils/req.body.helper.ts b/apps/api/src/utils/req.body.helper.ts new file mode 100644 index 0000000..b063654 --- /dev/null +++ b/apps/api/src/utils/req.body.helper.ts @@ -0,0 +1,7 @@ +export function reqBodyReducer(data: Record) { + const entries = Object.entries(data).reduce((arr: any[], [key, value]) => { + value && arr.push([key, value]); + return arr; + }, []); + return Object.fromEntries(entries); +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index fdb18ad..49ac2e6 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2015", - "module": "commonjs", + "target": "ES2023", + "module": "CommonJS", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/web/.env b/apps/web/.env deleted file mode 100644 index 20e2e27..0000000 --- a/apps/web/.env +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_BASE_API_URL=http://localhost:8000/api/ \ No newline at end of file diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json index bffb357..a2ceebe 100644 --- a/apps/web/.eslintrc.json +++ b/apps/web/.eslintrc.json @@ -1,3 +1,3 @@ { - "extends": "next/core-web-vitals" + "extends": ["next/babel", "next/core-web-vitals"] } diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..7559f63 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 191f916..ce951e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,18 +10,52 @@ "test": "npx cypress verify & cypress run --component" }, "dependencies": { - "next": "14.0.4", + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.2", + "@tanstack/react-table": "^8.19.2", + "axios": "^1.7.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cookies-next": "^4.2.1", + "cross-env": "^7.0.3", + "date-fns": "^3.6.0", + "jwt-decode": "^4.0.0", + "lucide-react": "^0.400.0", + "next": "^14.2.4", + "next-themes": "^0.3.0", "react": "^18", + "react-day-picker": "^8.10.1", "react-dom": "^18", - "cross-env": "^7.0.3" + "react-hook-form": "^7.52.1", + "sonner": "^1.5.0", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "use-debounce": "^10.0.1", + "zod": "^3.23.8", + "zustand": "^4.5.4" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "autoprefixer": "^10.4.19", "cypress": "^13.6.2", "eslint": "^8", "eslint-config-next": "14.0.4", + "postcss": "^8", + "prettier": "^3.3.2", + "prettier-plugin-tailwindcss": "^0.6.5", + "tailwindcss": "^3.4.4", "typescript": "^5" } } diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/web/prettier.config.js b/apps/web/prettier.config.js new file mode 100644 index 0000000..f56ed1d --- /dev/null +++ b/apps/web/prettier.config.js @@ -0,0 +1,7 @@ +/** @type {import('prettier').Config} */ +module.exports = { + plugins: ["prettier-plugin-tailwindcss"], + // tailwindcss + tailwindAttributes: ["theme"], + tailwindFunctions: ["twMerge", "createTheme", "clsx", "tw"], +}; diff --git a/apps/web/public/logo/Farm2Door-logo.png b/apps/web/public/logo/Farm2Door-logo.png new file mode 100644 index 0000000..cdfff7b Binary files /dev/null and b/apps/web/public/logo/Farm2Door-logo.png differ diff --git a/apps/web/src/app/dashboard/admin/_component/admin.crud.dialog.tsx b/apps/web/src/app/dashboard/admin/_component/admin.crud.dialog.tsx new file mode 100644 index 0000000..65ad03d --- /dev/null +++ b/apps/web/src/app/dashboard/admin/_component/admin.crud.dialog.tsx @@ -0,0 +1,21 @@ +import { + DialogHeader, + DialogContent, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; + +type Props = { title: string; desc: string; children: React.ReactNode }; +export default function AdminCRUDDialog({ title, desc, children }: Props) { + return ( + + + {title} + {desc} + +
+ {children} +
+
+ ); +} diff --git a/apps/web/src/app/dashboard/admin/_component/admin.main-nav.tsx b/apps/web/src/app/dashboard/admin/_component/admin.main-nav.tsx new file mode 100644 index 0000000..5db5fbe --- /dev/null +++ b/apps/web/src/app/dashboard/admin/_component/admin.main-nav.tsx @@ -0,0 +1,48 @@ +"use client"; +import { Menu } from "lucide-react"; +import { Button } from "../../../../components/ui/button"; +import { + Sheet, + SheetContent, + SheetTrigger, +} from "../../../../components/ui/sheet"; +import AdminNavLinks from "./admin.nav-links"; +import AdminMenu from "./admin.menu"; +import { Separator } from "@/components/ui/separator"; +import useAuthStore from "@/stores/auth.store"; +import { cn } from "@/lib/utils"; + +type Props = {}; +export default function AdminMainNav({}: Props) { + const { user } = useAuthStore((s) => s); + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/apps/web/src/app/dashboard/admin/_component/admin.menu.tsx b/apps/web/src/app/dashboard/admin/_component/admin.menu.tsx new file mode 100644 index 0000000..2350647 --- /dev/null +++ b/apps/web/src/app/dashboard/admin/_component/admin.menu.tsx @@ -0,0 +1,64 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { UserPlus2, LogOut } from "lucide-react"; +import AdminCRUDDialog from "./admin.crud.dialog"; +import StoreAdminCreateForm from "./store-admin.create.form"; +import { TCity } from "@/models/address.model"; +import useAuthStore from "@/stores/auth.store"; +import { Role } from "@/models/user.model"; +import { useEffect, useState } from "react"; +import { fetchAllCities } from "@/utils/fetch/cities.fetch"; + +type Props = {}; +export default function AdminMenu({}: Props) { + const { user, logout } = useAuthStore((s) => s); + const [cities, setCities] = useState([]); + async function fillCities() { + setCities([...(await fetchAllCities())]); + } + useEffect(() => { + fillCities(); + }, []); + return ( +
+
+ + + + + + + + + + +
+

+ Welcome,{" "} + + {user.role === Role.super_admin ? "Super Admin" : "Store Admin"} + +

+

{user.full_name}

+

{user.email}

+
+
+
+ ); +} diff --git a/apps/web/src/app/dashboard/admin/_component/admin.nav-links.tsx b/apps/web/src/app/dashboard/admin/_component/admin.nav-links.tsx new file mode 100644 index 0000000..590d82e --- /dev/null +++ b/apps/web/src/app/dashboard/admin/_component/admin.nav-links.tsx @@ -0,0 +1,53 @@ +"use client"; +import Image from "next/image"; +import Link from "next/link"; + +type Props = {}; +export default function AdminNavLinks({}: Props) { + return ( + <> + + Farm2Door logo + + + Users + + + Products + + + Inventory + + + Orders + + + Analytics + + + ); +} diff --git a/apps/web/src/app/dashboard/admin/_component/store-admin.create.form.tsx b/apps/web/src/app/dashboard/admin/_component/store-admin.create.form.tsx new file mode 100644 index 0000000..0af7b93 --- /dev/null +++ b/apps/web/src/app/dashboard/admin/_component/store-admin.create.form.tsx @@ -0,0 +1,124 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { Form } from "@/components/ui/form"; +import { storeAdminSchema } from "@/lib/zod-schemas/store-admin.schema"; +import { TCity } from "@/models/address.model"; +import { Gender } from "@/models/user.model"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import FormInput from "@/components/form/form.field.input"; +import { SelectItem } from "@/components/ui/select"; +import FormSelect from "@/components/form/form.field.select"; +import FormTextArea from "@/components/form/form.field.textarea"; +import FormDatepicker from "@/components/form/form.field.datepicker"; +import { subYears } from "date-fns"; +import { createStoreAdmin } from "@/utils/fetch/admin.client-fetch"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type Props = { cities: TCity[] }; +export default function StoreAdminCreateForm({ cities }: Props) { + const form = useForm>({ + resolver: zodResolver(storeAdminSchema), + defaultValues: { + email: "", + password: "", + full_name: "", + gender: Gender.male, + dob: `${subYears(new Date(), 18).toDateString()}`, + phone_no: "", + address: "", + city_id: String(cities[0].city_id), + details: "", + }, + }); + function onSubmit(data: z.infer) { + createStoreAdmin(data); + } + return ( +
+ + + + + + {Gender.male} + {Gender.female} + + + + + + {cities.map((city) => ( + + {city.city_name} + + ))} + + + + + + ); +} diff --git a/apps/web/src/app/dashboard/admin/_component/store-admin.edit.dialog.tsx b/apps/web/src/app/dashboard/admin/_component/store-admin.edit.dialog.tsx new file mode 100644 index 0000000..ee5cbf7 --- /dev/null +++ b/apps/web/src/app/dashboard/admin/_component/store-admin.edit.dialog.tsx @@ -0,0 +1,26 @@ +"use client"; +import { TUser } from "@/models/user.model"; +import StoreAdminEditForm from "./store-admin.edit.form"; +import { fetchAllCities } from "@/utils/fetch/cities.fetch"; +import { useEffect, useState } from "react"; +import { TCity } from "@/models/address.model"; +import AdminCRUDDialog from "./admin.crud.dialog"; + +type Props = { user: TUser }; +export default function StoreAdminEdit({ user }: Props) { + const [cities, setCities] = useState([]); + async function fillCities() { + setCities([...(await fetchAllCities())]); + } + useEffect(() => { + fillCities(); + }, []); + return ( + + + + ); +} diff --git a/apps/web/src/app/dashboard/admin/_component/store-admin.edit.form.tsx b/apps/web/src/app/dashboard/admin/_component/store-admin.edit.form.tsx new file mode 100644 index 0000000..60317b3 --- /dev/null +++ b/apps/web/src/app/dashboard/admin/_component/store-admin.edit.form.tsx @@ -0,0 +1,85 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { Form } from "@/components/ui/form"; +import { storeAdminUpdateSchema } from "@/lib/zod-schemas/store-admin.schema"; +import { TCity } from "@/models/address.model"; +import { Gender, TUser } from "@/models/user.model"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import FormInput from "@/components/form/form.field.input"; +import { SelectItem } from "@/components/ui/select"; +import FormDatepicker from "@/components/form/form.field.datepicker"; +import FormSelect from "@/components/form/form.field.select"; +import { updateStoreAdmin } from "@/utils/fetch/admin.client-fetch"; +import FormTextArea from "@/components/form/form.field.textarea"; +import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type Props = { user: TUser; cities: TCity[] }; +export default function StoreAdminEditForm({ user, cities }: Props) { + const form = useForm>({ + resolver: zodResolver(storeAdminUpdateSchema), + defaultValues: { + email: user.email, + full_name: user.full_name, + gender: user.gender, + dob: user.dob, + phone_no: user.phone_no, + address: user.addresses?.length ? user.addresses[0]?.address : "", + city_id: user.addresses?.length ? String(user.addresses[0]?.city_id) : "", + details: user.addresses?.length ? user.addresses[0]?.details : "", + }, + }); + function onSubmit(data: z.infer) { + updateStoreAdmin(user.id, data); + } + return ( +
+ + + + + {Gender.male} + {Gender.female} + + + + + + {cities.map((city) => ( + + {city.city_name} + + ))} + + + + + + ); +} diff --git a/apps/web/src/app/dashboard/admin/login/_component/admin.login.form.tsx b/apps/web/src/app/dashboard/admin/login/_component/admin.login.form.tsx new file mode 100644 index 0000000..6ff3c1f --- /dev/null +++ b/apps/web/src/app/dashboard/admin/login/_component/admin.login.form.tsx @@ -0,0 +1,67 @@ +"use client"; +import FormInput from "@/components/form/form.field.input"; +import { Button } from "@/components/ui/button"; +import { CardContent, CardFooter } from "@/components/ui/card"; +import { Form } from "@/components/ui/form"; +import { cn } from "@/lib/utils"; +import useAuthStore from "@/stores/auth.store"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +type Props = {}; +export default function AdminLoginForm({}: Props) { + const { adminLogin } = useAuthStore((state) => state); + const router = useRouter(); + const schema = z.object({ + email: z.string().trim().email({ message: " Enter valid email" }), + password: z.string().trim(), + }); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + email: "", + password: "", + }, + }); + async function onSubmit(values: { email: string; password: string }) { + adminLogin(values.email, values.password); + } + return ( +
+ + + + + + + + +
+ + ); +} diff --git a/apps/web/src/app/dashboard/admin/login/page.tsx b/apps/web/src/app/dashboard/admin/login/page.tsx new file mode 100644 index 0000000..2286d2b --- /dev/null +++ b/apps/web/src/app/dashboard/admin/login/page.tsx @@ -0,0 +1,22 @@ +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import AdminLoginForm from "./_component/admin.login.form"; +export default function AdminLoginPage() { + return ( +
+ + + Administrator Login + + Enter your administrator email below to login to your account. + + + + +
+ ); +} diff --git a/apps/web/src/app/dashboard/admin/template.tsx b/apps/web/src/app/dashboard/admin/template.tsx new file mode 100644 index 0000000..ec5419b --- /dev/null +++ b/apps/web/src/app/dashboard/admin/template.tsx @@ -0,0 +1,11 @@ +import AdminMainNav from "./_component/admin.main-nav"; + +type Props = { children: React.ReactNode }; +export default function AdminDashboardTemplate({ children }: Props) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/src/app/dashboard/admin/users/customers.column.tsx b/apps/web/src/app/dashboard/admin/users/customers.column.tsx new file mode 100644 index 0000000..83d61ff --- /dev/null +++ b/apps/web/src/app/dashboard/admin/users/customers.column.tsx @@ -0,0 +1,123 @@ +"use client"; +import HeaderSortBtn from "@/components/table/header.sort.button"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { TUser } from "@/models/user.model"; +import { tableDateFormat } from "@/utils/formatter"; +import { ColumnDef } from "@tanstack/react-table"; +import { ChevronsUpDown } from "lucide-react"; + +export const customersColumns: ColumnDef[] = [ + { + accessorKey: "full_name", + header: "Full Name", + id: "Fullname", + }, + { + accessorKey: "email", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + }, + { + accessorKey: "gender", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + }, + { + accessorKey: "dob", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + cell: ({ row }) => + new Intl.DateTimeFormat("id-ID", tableDateFormat).format( + new Date(row.getValue("dob")), + ), + }, + { + accessorKey: "addresses", + id: "address", + accessorFn: (user) => + user.addresses?.length ? user.addresses[0]?.address : "-", + header: "Address", + }, + { + accessorKey: "addresses", + id: "details", + accessorFn: (user) => + user.addresses?.length ? user.addresses[0]?.details : "-", + header: "Details", + }, + { + accessorKey: "addresses", + id: "city", + accessorFn: (user) => + user.addresses?.length ? user.addresses[0]?.city.city_name : "-", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + }, + { + accessorKey: "is_verified", + header: "Verification Status", + id: "verification", + cell: ({ row }) => { + const isActive = row.getValue("verification"); + return ( + + {isActive ? "Unverified" : "Verified"} + + ); + }, + }, + { + accessorKey: "created_at", + id: "join", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => + new Intl.DateTimeFormat("id-ID", tableDateFormat).format( + new Date(row.getValue("join")), + ), + }, + { + accessorKey: "is_banned", + id: "status", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + cell: ({ row }) => { + const isActive = row.getValue("is_banned"); + return ( + + {isActive ? "Resigned" : "Active"} + + ); + }, + }, +]; diff --git a/apps/web/src/app/dashboard/admin/users/page.tsx b/apps/web/src/app/dashboard/admin/users/page.tsx new file mode 100644 index 0000000..5aee644 --- /dev/null +++ b/apps/web/src/app/dashboard/admin/users/page.tsx @@ -0,0 +1,65 @@ +import { StoreAdminSearchParams } from "@/models/search.params"; +import { + fetchCustomersData, + fetchStoreAdminData, +} from "@/utils/fetch/admin.fetch"; +import { DataTable } from "../../../../components/table/data-table"; +import { storeAdminColumns } from "./storeAdmin.columns"; +import { Suspense } from "react"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../../../../components/ui/tabs"; +import Pagination from "@/components/pagination"; +import { customersColumns } from "./customers.column"; +import Spinner from "@/components/ui/spinner"; + +type Props = { + searchParams: StoreAdminSearchParams; +}; +export default async function AdminDashboard({ searchParams }: Props) { + const storeAdmins = await fetchStoreAdminData(searchParams); + const customers = await fetchCustomersData(searchParams); + return ( +
+ + + Store Admins + Customers + + +

Store Admin Data

+ + +
+ } + > + + +
+ +
+ + +

Customers Data

+ + + + } + > + + +
+ +
+
+ + + ); +} diff --git a/apps/web/src/app/dashboard/admin/users/storeAdmin.columns.tsx b/apps/web/src/app/dashboard/admin/users/storeAdmin.columns.tsx new file mode 100644 index 0000000..fa32bd6 --- /dev/null +++ b/apps/web/src/app/dashboard/admin/users/storeAdmin.columns.tsx @@ -0,0 +1,179 @@ +"use client"; +import HeaderSortBtn from "@/components/table/header.sort.button"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { TUser } from "@/models/user.model"; +import { deleteStoreAdmin } from "@/utils/fetch/admin.client-fetch"; +import { tableDateFormat } from "@/utils/formatter"; +import { ColumnDef } from "@tanstack/react-table"; +import { ChevronsUpDown, MoreHorizontal, X } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "sonner"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import StoreAdminEdit from "../_component/store-admin.edit.dialog"; +import { AlertDialog, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import ApprovalDialog from "@/components/approval-dialog"; + +export const storeAdminColumns: ColumnDef[] = [ + { + accessorKey: "full_name", + header: "Full Name", + id: "Fullname", + }, + { + accessorKey: "email", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + }, + { + accessorKey: "gender", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + }, + { + accessorKey: "dob", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + cell: ({ row }) => + new Intl.DateTimeFormat("id-ID", tableDateFormat).format( + new Date(row.getValue("dob")), + ), + }, + { + accessorKey: "addresses", + id: "address", + accessorFn: (user) => + user.addresses?.length ? user.addresses[0]?.address : "-", + header: "Address", + }, + { + accessorKey: "addresses", + id: "details", + accessorFn: (user) => + user.addresses?.length ? user.addresses[0]?.details : "-", + header: "Details", + }, + { + accessorKey: "addresses", + id: "city", + accessorFn: (user) => + user.addresses?.length ? user.addresses[0]?.city.city_name : "-", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + }, + { + accessorKey: "created_at", + id: "join", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => + new Intl.DateTimeFormat("id-ID", tableDateFormat).format( + new Date(row.getValue("join")), + ), + }, + { + accessorKey: "is_banned", + id: "status", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + /> + ), + cell: ({ row }) => { + const isActive = row.getValue("status"); + return ( + + {isActive ? "Resigned" : "Active"} + + ); + }, + }, + { + id: "action", + cell: ({ row }) => { + const users = row.original; + return ( + + + + + + + + Actions + { + navigator.clipboard.writeText(users?.email || ""); + toast.success("Email copied to clipboard.", { + duration: 1000, + }); + }} + > + Copy user email + + + + + + + + + + + + + + + + deleteStoreAdmin(users.id)} /> + + + ); + }, + }, +]; diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..66ff5b5 --- /dev/null +++ b/apps/web/src/app/dashboard/layout.tsx @@ -0,0 +1,4 @@ +type Props = { children: React.ReactNode }; +export default function DashboardLayout({ children }: Props) { + return <>{children};; +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index d4f491e..e881c76 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,107 +1,67 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', - 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', - 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; +@tailwind base; +@tailwind components; +@tailwind utilities; - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; - - --primary-glow: conic-gradient( - from 180deg at 50% 50%, - #16abff33 0deg, - #0885ff33 55deg, - #54d6ff33 120deg, - #0071ff33 160deg, - transparent 360deg - ); - --secondary-glow: radial-gradient( - rgba(255, 255, 255, 1), - rgba(255, 255, 255, 0) - ); - - --tile-start-rgb: 239, 245, 249; - --tile-end-rgb: 228, 232, 233; - --tile-border: conic-gradient( - #00000080, - #00000040, - #00000030, - #00000020, - #00000010, - #00000010, - #00000080 - ); - - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: 180, 185, 188; - --card-border-rgb: 131, 134, 135; -} - -@media (prefers-color-scheme: dark) { +@layer base { :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - - --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); - --secondary-glow: linear-gradient( - to bottom right, - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0.3) - ); - - --tile-start-rgb: 2, 13, 46; - --tile-end-rgb: 2, 5, 19; - --tile-border: conic-gradient( - #ffffff80, - #ffffff40, - #ffffff30, - #ffffff20, - #ffffff10, - #ffffff10, - #ffffff80 - ); - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 142.1 76.2% 36.3%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 142.1 76.2% 36.3%; + --radius: 0.5rem; } -} - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} -html, -body { - max-width: 100vw; - overflow-x: hidden; + .dark { + --background: 20 14.3% 4.1%; + --foreground: 0 0% 95%; + --card: 24 9.8% 10%; + --card-foreground: 0 0% 95%; + --popover: 0 0% 9%; + --popover-foreground: 0 0% 95%; + --primary: 142.1 70.6% 45.3%; + --primary-foreground: 144.9 80.4% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 15%; + --muted-foreground: 240 5% 64.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 142.4 71.8% 29.2%; + } } -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } -a { - color: inherit; - text-decoration: none; +.rdp [aria-hidden="true"] { + @apply hidden; } -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } +.rdp-vhidden { + @apply hidden; } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 53d92db..339ed6b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,27 +1,40 @@ -import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; -import './globals.css'; -import { Header } from '@/components/Header'; -import { Footer } from '@/components/Footer'; +import type { Metadata } from "next"; +import { Toaster } from "@/components/ui/sonner"; +import { Header } from "@/components/Header"; +import { Footer } from "@/components/Footer"; +import "./globals.css"; +import { Inter as FontSans } from "next/font/google"; +import { cn } from "@/lib/utils"; +import AuthProvider from "@/components/providers/auth.provider"; -const inter = Inter({ subsets: ['latin'] }); +const fontSans = FontSans({ + subsets: ["latin"], + variable: "--font-sans", +}); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: "Farm2Door", + description: "Fresh Online Groceries", }; -export default function RootLayout({ - children, -}: { +type RootLayoutProps = { children: React.ReactNode; -}) { +}; + +export default function RootLayout({ children }: RootLayoutProps) { return ( - - -
- {children} -