This commit is contained in:
Aldhie Gandia 2024-10-09 13:48:24 +07:00
parent 3f044867e9
commit 03296b4ae9
42 changed files with 6505 additions and 210 deletions

319
.drone.yml Normal file
View File

@ -0,0 +1,319 @@
kind: pipeline
type: docker
name: default
volumes:
- name: cache
host:
path: /tmp/drone/cache
environment:
DB_HOST: mariadb
DB_PORT: 3306
DB_USERNAME: root
DB_PASSWORD: productzilla
DB_NAME: productzilla
services:
- name: mariadb
image: mariadb:10.3.10
environment:
MYSQL_USER: productzilla
MYSQL_ROOT_PASSWORD: productzilla
MYSQL_DATABASE: productzilla
MYSQL_PASSWORD: productzilla
steps:
- name: prepare mariadb
image: mariadb:10.3.10
commands:
- until mysql -u root -p'productzilla' -e 'select version()' -h mariadb; do sleep 1; done;
- mysql -u root -p'productzilla' -h mariadb -e 'CREATE DATABASE IF NOT EXISTS identity'
- name: restore build cache
image: drillster/drone-volume-cache
settings:
restore: 'true'
mount:
- ./node_modules
- ./.pkg
- ./.scannerwork
- ./.sonar
- ./.nexe
volumes:
- name: cache
path: /cache
- name: build web-pz
image: node:18.19.0-alpine
commands:
- yarn install
- yarn build
- name: migrate web-pz
image: node:18.19.0-alpine
commands:
- export DB_HOST=$DB_HOST
- export DB_PORT=$DB_PORT
- export DB_USERNAME=$DB_USERNAME
- export DB_PASSWORD=$DB_PASSWORD
- export DB_NAME=$DB_NAME
- yarn typeorm:run
- name: analyze code using sonarqube
image: oeoen/drone-sonar-plugin:5.0.1-pr-enabled
settings:
url:
from_secret: sonar_url
token:
from_secret: sonar_token
when:
status:
- success
- failure
- name: rebuild build cache
image: drillster/drone-volume-cache
settings:
rebuild: 'true'
mount:
- ./node_modules
- ./.scannerwork
- ./.sonar
- ./.nexe
volumes:
- name: cache
path: /cache
when:
status:
- success
- failure
trigger:
branch:
exclude:
# - develop
- master
event:
exclude:
- custom
include:
- push
---
kind: pipeline
type: docker
name: semantic_versioning
steps:
- name: semantic-release
image: ilhamfadhilah/drone-semantic-release
settings:
semantic_release: true # enable or disable semantic release
mode: release
git_method: cr
git_user_email: hello@productzillaacademy.com
git_host:
from_secret: gitea_host
git_host_proto:
from_secret: gitea_host_proto
git_login:
from_secret: gitea_login
git_password:
from_secret: gitea_password
trigger:
branch:
- master
event:
exclude:
- custom
include:
- push
---
kind: pipeline
type: docker
name: publish develop image to docker registry
volumes:
- name: cache
host:
path: /tmp/drone/cache
steps:
- name: restore build cache
image: drillster/drone-volume-cache
settings:
restore: 'true'
mount:
- ./node_modules
- ./.pkg
- ./.scannerwork
- ./.sonar
- ./.nexe
volumes:
- name: cache
path: /cache
- name: build and publish image
image: plugins/docker
settings:
repo: 'productzilla/web-pz'
username:
from_secret: ci_registry_user
password:
from_secret: ci_registry_password
dockerfile: misc/docker/dockerfile
tag: dev
- name: rebuild build cache
image: drillster/drone-volume-cache
settings:
rebuild: 'true'
mount:
- ./node_modules
- ./.scannerwork
- ./.sonar
- ./.nexe
volumes:
- name: cache
path: /cache
when:
status:
- success
- failure
- name: deploy to staging
image: alpine:latest
environment:
SERVER_HOST:
from_secret: k8s_staging_server_host
CONTEXT:
from_secret: k8s_staging_context
USER:
from_secret: k8s_staging_user
TOKEN:
from_secret: k8s_staging_token
NAMESPACE:
from_secret: k8s_staging_namespace
commands:
- apk add curl
- curl -LO https://dl.k8s.io/release/v1.22.0/bin/linux/amd64/kubectl
- chmod u+x kubectl && mv kubectl /bin/kubectl
- mkdir -p /root/.kube
- cat misc/deployment/config.yml | sed "s#{{server_host}}#$SERVER_HOST#g" | sed "s#{{context}}#$CONTEXT#g" | sed "s#{{user}}#$USER#g" | sed "s#{{token}}#$TOKEN#g" > '/root/.kube/config'
- cat misc/deployment/k8s.template.staging.yml | sed 's/{{tags}}/dev/g' | sed "s/{{namespace}}/$NAMESPACE/g" > 'misc/deployment/dev.template.yml'
- kubectl -n $NAMESPACE apply -f 'misc/deployment/dev.template.yml'
- kubectl -n $NAMESPACE rollout restart deployment/productzilla-web-pz
trigger:
branch:
- develop
event:
exclude:
- pull_request
depends_on:
- default
---
kind: pipeline
type: docker
name: build production image
volumes:
- name: cache
host:
path: /tmp/drone/cache
steps:
- name: restore build cache
image: drillster/drone-volume-cache
settings:
restore: 'true'
mount:
- ./node_modules
- ./.pkg
- ./.scannerwork
- ./.sonar
- ./.nexe
volumes:
- name: cache
path: /cache
- name: generate tag for image publish
image: alpine:3.13.6
commands:
- echo -n "${DRONE_TAG}" > .tags
- name: build and publish image
image: plugins/docker
settings:
repo: 'productzilla/web-pz'
username:
from_secret: ci_registry_user
password:
from_secret: ci_registry_password
dockerfile: misc/docker/dockerfile
- name: rebuild build cache
image: drillster/drone-volume-cache
settings:
rebuild: 'true'
mount:
- ./node_modules
- ./.scannerwork
- ./.sonar
- ./.nexe
volumes:
- name: cache
path: /cache
when:
status:
- success
- failure
trigger:
ref:
- refs/tags/*
---
kind: pipeline
type: docker
name: deploy build production image to kubernetes
steps:
- name: generate tag for image publish
image: alpine:3.13.6
commands:
- if [ "$IMAGE_VERSION" == "" ]; then exit 1; fi;
- echo -n "${IMAGE_VERSION}" > .tags
- name: deploy
image: alpine:latest
environment:
SERVER_HOST:
from_secret: k8s_staging_server_host
CONTEXT:
from_secret: k8s_staging_context
USER:
from_secret: k8s_staging_user
TOKEN:
from_secret: k8s_staging_token
NAMESPACE:
from_secret: k8s_staging_namespace
commands:
- apk add curl
- curl -LO https://dl.k8s.io/release/v1.22.0/bin/linux/amd64/kubectl
- chmod u+x kubectl && mv kubectl /bin/kubectl
- mkdir -p /root/.kube
- cat misc/deployment/config.yml | sed "s#{{server_host}}#$SERVER_HOST#g" | sed "s#{{context}}#$CONTEXT#g" | sed "s#{{user}}#$USER#g" | sed "s#{{token}}#$TOKEN#g" > '/root/.kube/config'
- export IMAGE_VERSION=$(cat .tags)
- cat misc/deployment/k8s.template.yml | sed "s/{{tags}}/$IMAGE_VERSION/g" | sed "s/{{namespace}}/$NAMESPACE/g" > "misc/deployment/$IMAGE_VERSION.template.yml"
- kubectl -n $NAMESPACE apply -f "misc/deployment/$IMAGE_VERSION.template.yml"
trigger:
branch:
- master
event:
- custom

5
.env Normal file
View File

@ -0,0 +1,5 @@
DB_HOST=localhost
DB_PORT=31360
DB_USERNAME=root
DB_PASSWORD=productzilla
DB_NAME=productzilla

6
.gitignore vendored
View File

@ -34,3 +34,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
misc/docker/volumes
.certs
.vscode/launch.json
.next
.scannerwork

3
@types/generic.ts Normal file
View File

@ -0,0 +1,3 @@
export interface Generic {
id?: string;
}

7
@types/next.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
import { UserEntity } from '../infrastructure/database/user/user.entity';
declare module 'next' {
interface NextApiRequest {
session: { user?: UserEntity | null };
}
}

19
@types/pagination.ts Normal file
View File

@ -0,0 +1,19 @@
import { IsNumberString } from 'class-validator';
export class PaginationParam<T = any> {
@IsNumberString()
page!: number;
@IsNumberString()
size!: number;
search?: T | Partial<T>;
}
export interface Paginated<T> {
items: T[];
totalSize: number;
totalPages: number;
page: number;
size: number;
}

12
config/index.ts Normal file
View File

@ -0,0 +1,12 @@
export const config = {
db: {
type: 'mysql',
host: process.env.DB_HOST ?? 'localhost',
port: parseInt(process.env.DB_PORT ?? '') ?? 3306,
username: process.env.DB_USERNAME ?? 'root',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME,
maxPoolConnection: process.env.DB_MAX_POOL_CONNECTION ?? 10,
},
jwtSecret: process.env.JWT_SECRET ?? 'super-secret-jwt-key',
};

View File

@ -0,0 +1,18 @@
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: {{server_host}}/api/endpoints/1/kubernetes
name: {{context}}
contexts:
- context:
cluster: {{context}}
user: {{user}}
name: {{context}}
current-context: {{context}}
kind: Config
preferences: {}
users:
- name: {{user}}
user:
token: {{token}}

View File

@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: productzilla-web-pz
namespace: {{namespace}}
labels:
app: web-pz
spec:
replicas: 1
selector:
matchLabels:
app: web-pz
template:
metadata:
labels:
app: web-pz
spec:
containers:
- name: web-pz
image: productzilla/web-pz:{{tags}}
imagePullPolicy: Always
resources:
requests:
memory: '64Mi'
cpu: '250m'
limits:
memory: '256Mi'
cpu: '500m'
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: production
- name: DB_HOST
valueFrom:
secretKeyRef:
name: productzilla-secret
key: mariadb-host
- name: DB_PORT
valueFrom:
secretKeyRef:
name: productzilla-secret
key: mariadb-port
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: productzilla-password-secret
key: mariadb-username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: productzilla-password-secret
key: mariadb-password
- name: DB_NAME
value: productzilla_web-pz_staging
---
apiVersion: v1
kind: Service
metadata:
name: web-pz-svc
namespace: {{namespace}}
spec:
selector:
app: web-pz
ports:
- protocol: TCP
port: 3000
targetPort: 3000 # should match containerPort

View File

@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: productzilla-web-pz
namespace: {{namespace}}
labels:
app: web-pz
spec:
replicas: 1
selector:
matchLabels:
app: web-pz
template:
metadata:
labels:
app: web-pz
spec:
containers:
- name: web-pz
image: productzilla/web-pz:{{tags}}
imagePullPolicy: Always
resources:
requests:
memory: '64Mi'
cpu: '250m'
limits:
memory: '256Mi'
cpu: '500m'
ports:
- containerPort: 3000
env:
- name: NODE_ENV
value: production
- name: DB_HOST
valueFrom:
secretKeyRef:
name: productzilla-secret
key: mariadb-host
- name: DB_PORT
valueFrom:
secretKeyRef:
name: productzilla-secret
key: mariadb-port
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: productzilla-password-secret
key: mariadb-username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: productzilla-password-secret
key: mariadb-password
- name: DB_NAME
value: web-pz
---
apiVersion: v1
kind: Service
metadata:
name: web-pz-svc
namespace: {{namespace}}
spec:
selector:
app: web-pz
ports:
- protocol: TCP
port: 3000
targetPort: 3000 # should match containerPort

View File

@ -0,0 +1,33 @@
version: '3.4'
services:
########################
# SERVICE DEPENDENCIES #
########################
mariadb:
image: mariadb:10.3.10
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=productzilla
- MYSQL_DATABASE=productzilla
- MYSQL_USER=productzilla
- MYSQL_PASSWORD=productzilla
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '--silent']
start_period: 30s
ports:
- 31360:3306
networks:
default:
volumes:
- ./volumes/mariadb:/var/lib/mysql
phpmyadmin:
image: phpmyadmin/phpmyadmin
depends_on:
- mariadb
restart: unless-stopped
ports:
- 31361:80
environment:
- PMA_HOST=mariadb
- UPLOAD_LIMIT=100M

1726
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,21 +6,41 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"docker-compose": "docker-compose -f misc/docker/docker-compose.yml",
"typeorm": "cross-env DOTENV_CONFIG_PATH=./.env node -r tsconfig-paths/register -r ts-node/register -r dotenv/config ./node_modules/typeorm/cli",
"typeorm:create": "npm run typeorm migration:create -o ./src/backend/infrastructure/database/provider/migrations/$NAME",
"typeorm:generate": "npm run typeorm migration:generate -- -d ./src/backend/infrastructure/database/provider/index.ts ./src/backend/infrastructure/database/provider/migrations/$NAME",
"typeorm:run": "npm run typeorm migration:run -- -d ./src/backend/infrastructure/database/provider/index.ts",
"typeorm:revert": "npm run typeorm migration:revert -- -d ./src/backend/infrastructure/database/provider/index.ts",
"typeorm:show": "npm run typeorm migration:show -- -d ./src/backend/infrastructure/database/provider/index.ts"
},
"dependencies": {
"@types/swagger-ui-react": "^4.18.3",
"axios": "^1.7.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
"mysql": "^2.18.1",
"next": "14.2.15",
"next-api-decorators": "^2.0.2",
"path-to-regexp": "^8.2.0",
"react": "^18",
"react-dom": "^18",
"next": "14.2.15"
"ts-node": "^10.9.2",
"typeorm": "^0.3.20"
},
"devDependencies": {
"typescript": "^5",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.15",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.15"
"typescript": "^5.6.3"
}
}

View File

@ -0,0 +1,34 @@
import React from 'react';
const features = [
{
icon: 'path/to/icon1.svg',
title: 'Mentor Praktisi Profesional',
description: 'Dapatkan arahan dari mentor profesional di berbagai bidang industri.',
},
{
icon: 'path/to/icon2.svg',
title: 'Sertifikat Diakui Industri',
description: 'Dapatkan sertifikat yang diakui oleh perusahaan besar.',
},
// Add other features here...
];
const Features = () => {
return (
<div className="max-w-7xl mx-auto py-12">
<h2 className="text-3xl font-bold text-center mb-8">Kenapa Memilih Productzilla?</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature, index) => (
<div key={index} className="text-center">
<img className="mx-auto h-16" src={feature.icon} alt={feature.title} />
<h3 className="mt-4 text-lg font-semibold">{feature.title}</h3>
<p className="mt-2 text-gray-600">{feature.description}</p>
</div>
))}
</div>
</div>
);
};
export default Features;

View File

@ -0,0 +1,30 @@
import React from 'react';
const Footer = () => {
return (
<footer className="bg-gray-900 text-white py-12">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 className="text-lg font-semibold">Productzilla Academy</h3>
<p className="mt-4">Jalan Abdul Regency, Bandung, Indonesia</p>
</div>
<div>
<h3 className="text-lg font-semibold">Perusahaan</h3>
<ul>
<li><a href="#" className="hover:underline">Tentang Kami</a></li>
<li><a href="#" className="hover:underline">Kontak Kami</a></li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold">Ikuti Kami</h3>
<ul className="flex space-x-4 mt-4">
<li><a href="#"><img src="/facebook.svg" alt="Facebook" /></a></li>
<li><a href="#"><img src="/instagram.svg" alt="Instagram" /></a></li>
</ul>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@ -0,0 +1,25 @@
import React from 'react';
const Header = () => {
return (
<header className="bg-white shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between items-center h-16">
<div className="flex-shrink-0">
<img className="h-8 w-8" src="/logo.png" alt="Productzilla" />
</div>
<nav className="flex space-x-4">
<a href="#" className="text-gray-900">Kursus</a>
<a href="#" className="text-gray-900">Bootcamps</a>
<a href="#" className="text-gray-900">For Company</a>
<a href="#" className="text-gray-900">Tentang Kami</a>
</nav>
<div className="flex space-x-4">
<a href="#" className="text-gray-900">Login</a>
<a href="#" className="bg-blue-500 text-white px-4 py-2 rounded">Register</a>
</div>
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,22 @@
import React from 'react';
const Hero = () => {
return (
<div className="bg-blue-500 text-white py-24">
<div className="max-w-7xl mx-auto text-center">
<h1 className="text-4xl font-bold">Tingkatkan Karirmu di Dunia Industri</h1>
<p className="mt-4 text-lg">
Pelajari keterampilan baru yang dibutuhkan oleh perusahaan dari expert dengan sertifikat yang diakui.
</p>
<a
href="#"
className="mt-8 inline-block bg-white text-blue-500 px-6 py-3 rounded-md font-medium"
>
Explore our program
</a>
</div>
</div>
);
};
export default Hero;

View File

@ -0,0 +1,36 @@
const Programs = () => {
const programs = [
{
title: "Bootcamp Fullstack Software Development",
description: "Masyarakat tentang...",
price: "Rp 10,000,000",
date: "September 2024 - December 2024"
},
{
title: "Bootcamp Digital Marketing From Scratch",
description: "Menguasai tentang konsep pemasaran...",
price: "Rp 15,000,000",
date: "September 2024 - December 2024"
}
];
return (
<section className="py-20 bg-gray-100">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-10">Our Programs</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{programs.map((program, index) => (
<div key={index} className="bg-white p-6 shadow rounded">
<h3 className="text-xl font-bold">{program.title}</h3>
<p className="mt-2">{program.description}</p>
<p className="mt-4 font-bold">{program.price}</p>
<p>{program.date}</p>
</div>
))}
</div>
</div>
</section>
);
};
export default Programs;

View File

@ -1,27 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@ -1,101 +1,17 @@
import Image from "next/image";
import Features from "./components/Features";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Hero from "./components/Hero";
import Programs from "./components/Program";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="https://nextjs.org/icons/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
<div>
<Header />
<Hero />
<Programs />
<Features />
<Footer />
</div>
);
}

View File

@ -0,0 +1,61 @@
import { NotFoundException, UnauthorizedException } from 'next-api-decorators';
import { CreateUserRequest } from 'src/pages/api/users/request';
import { UserRepository } from 'src/backend/infrastructure/database/user/user.repository';
import { hashString } from 'src/utils/hash';
import { encode, validate } from 'src/utils/jwt';
import { UserEntity } from 'src/backend/infrastructure/database/user/user.entity';
import { LoginRequest } from '@/src/pages/api/auth/request';
export class UserService {
public static readonly service: UserService = new UserService();
static getService(): UserService {
return UserService.service;
}
async login(loginRequest: LoginRequest) {
const user = await UserRepository.getRepository().findOne({
where: { email: loginRequest.email },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.password !== hashString(loginRequest.password)) {
throw new UnauthorizedException('Incorrect Credentials');
}
return {
token: encode({ id: user.id, email: user.email }),
user: {
id: user.id,
email: user.email,
},
};
}
async me(token: string) {
const userData = validate(token) as UserEntity;
const user = await UserRepository.getRepository().findOne({
where: { id: userData.id },
select: {
id: true,
email: true,
username: true,
phone: true,
lastSignInAt: true,
},
});
return user;
}
async create(user: CreateUserRequest) {
const u = new UserEntity({
email: user.email,
username: user.username,
password: hashString(user.password),
phone: user.phoneNumber,
});
return UserRepository.getRepository().save(u);
}
}

View File

@ -0,0 +1,42 @@
import {
EntityTarget,
FindManyOptions,
ObjectLiteral,
Repository,
} from 'typeorm';
import { DatabaseProvider } from '.';
import { Paginated, PaginationParam } from '@/@types/pagination';
export class BaseRepository<E extends ObjectLiteral> extends Repository<E> {
constructor(entity: EntityTarget<E>) {
const baseRepository =
DatabaseProvider.getDatasource().getRepository<E>(entity);
super(
baseRepository.target,
baseRepository.manager,
baseRepository.queryRunner,
);
}
async getPaginated(
{ page, size }: PaginationParam = { page: 1, size: 10 },
options: FindManyOptions<E> = {},
): Promise<Paginated<E>> {
page = parseInt(page.toString()) || 1;
size = parseInt(size.toString()) || 10;
const [items, totalItems] = await this.findAndCount({
skip: (page - 1) * size,
take: size,
...options,
});
const totalPages = Math.ceil(totalItems / size);
const totalSize = totalItems;
return {
items,
page,
size,
totalPages,
totalSize,
};
}
}

View File

@ -0,0 +1,25 @@
import { DataSource } from 'typeorm';
import { dataSourceOptions } from './typeorm.config';
export class DatabaseProvider {
static datasource: NonNullable<DataSource>;
static getDatasource = () => {
if (!DatabaseProvider.datasource) {
DatabaseProvider.datasource = new DataSource(dataSourceOptions);
}
return DatabaseProvider.datasource;
};
static async initialize() {
const datasource = DatabaseProvider.getDatasource();
if (!datasource.isInitialized) {
await DatabaseProvider.getDatasource().initialize();
}
}
static async close() {
const datasource = DatabaseProvider.getDatasource();
datasource.manager.connection.destroy();
}
}
export const OrmConfig = DatabaseProvider.getDatasource();

View File

@ -0,0 +1,46 @@
import { ObjectLiteral, Repository } from 'typeorm';
import { DatabaseProvider } from '.';
/**
* these functions do not related to the database connection
*/
const excludeFunctions = [
'createQueryBuilder',
'hasId',
'getId',
'create',
'merge',
'preload',
'recover',
'extend',
];
/**
* since we are using typeorm, we need to initialize the database before
* any repository method is called. This decorator will inject the
* initialize method to all repository methods.
* @param target Repository class
*/
export const InjectInitializeDatabaseOnAllProps = <T extends ObjectLiteral>(
target: typeof Repository<T>,
) => {
const parentClass = Object.getPrototypeOf(target);
if (parentClass.name) {
InjectInitializeDatabaseOnAllProps(parentClass);
}
const properties = Object.getOwnPropertyDescriptors(target.prototype);
for (const name of Object.keys(properties)) {
const descriptor = Object.getOwnPropertyDescriptor(target.prototype, name);
const isMethod = descriptor?.value instanceof Function;
if (!isMethod) continue;
if (excludeFunctions.includes(name)) continue;
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
await DatabaseProvider.initialize();
const result = await originalMethod.apply(this, args);
return result;
};
Object.defineProperty(target.prototype, name, descriptor);
}
};

View File

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitUsersDatabase1713768656950 implements MigrationInterface {
name = 'InitUsersDatabase1713768656950';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE \`users\` (\`id\` varchar(36) NOT NULL, \`email\` varchar(255) NOT NULL, \`username\` varchar(255) NOT NULL, \`password\` varchar(255) NOT NULL, \`emailConfirmedAt\` timestamp NULL, \`confirmationToken\` varchar(255) NOT NULL DEFAULT '', \`confirmationSentAt\` timestamp NULL, \`confirmedAt\` timestamp NULL, \`recoveryToken\` varchar(255) NULL, \`recoverySentAt\` timestamp NULL, \`lastSignInAt\` timestamp NULL, \`rawUserMetadata\` text NOT NULL DEFAULT '{}', \`phone\` varchar(255) NULL, \`phoneConfirmedAt\` timestamp NULL, \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deletedAt\` timestamp(6) NULL, UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`), UNIQUE INDEX \`IDX_fe0bb3f6520ee0469504521e71\` (\`username\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX \`IDX_fe0bb3f6520ee0469504521e71\` ON \`users\``,
);
await queryRunner.query(
`DROP INDEX \`IDX_97672ac88f789774dd47f7c8be\` ON \`users\``,
);
await queryRunner.query(`DROP TABLE \`users\``);
}
}

View File

@ -0,0 +1,7 @@
import { DatabaseProvider } from '.';
const main = async () => {
await DatabaseProvider.initialize();
};
main();

View File

@ -0,0 +1,19 @@
import { DataSourceOptions } from 'typeorm';
import { config } from '@/config';
import { UserEntity } from '../user/user.entity';
import { join, resolve } from 'path';
export const dataSourceOptions: DataSourceOptions = {
type: config.db.type as any,
host: config.db.host,
port: config.db.port,
username: config.db.username,
password: config.db.password,
database: config.db.database,
entities: [UserEntity],
migrations: [resolve(join(__dirname, 'migrations/*.{ts,js}'))],
synchronize: false,
logger: 'simple-console',
poolSize: config.db.maxPoolConnection as number,
charset: 'utf8mb4',
};

View File

@ -0,0 +1,66 @@
import { Profile } from '@/src/backend/models/profile';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'users' })
export class UserEntity<T = Profile> {
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255, unique: true })
email!: string;
@Column({ type: 'varchar', length: 255, unique: true })
username!: string;
@Column({ type: 'varchar', length: 255 })
password!: string;
@Column({ type: 'timestamp', nullable: true })
emailConfirmedAt?: Date;
@Column({ type: 'varchar', length: 255, default: '' })
confirmationToken?: string;
@Column({ type: 'timestamp', nullable: true })
confirmationSentAt?: Date;
@Column({ type: 'timestamp', nullable: true })
confirmedAt?: Date;
@Column({ type: 'varchar', length: 255, nullable: true})
recoveryToken!: string;
@Column({ type: 'timestamp', nullable: true })
recoverySentAt?: Date;
@Column({ type: 'timestamp', nullable: true })
lastSignInAt?: Date;
@Column({ type: 'text', default: '{}' })
rawUserMetadata?: T;
@Column({ type: 'varchar', nullable: true })
phone?: string;
@Column({ type: 'timestamp', nullable: true })
phoneConfirmedAt?: Date;
@CreateDateColumn({ type: 'timestamp' })
createdAt?: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt?: Date;
@DeleteDateColumn({ type: 'timestamp', select: false })
deletedAt?: Date;
}

View File

@ -0,0 +1,15 @@
import { type EntityTarget } from 'typeorm';
import { UserEntity } from './user.entity';
import { BaseRepository } from '../provider/base.repository';
import { InjectInitializeDatabaseOnAllProps } from '../provider/inject-db';
@InjectInitializeDatabaseOnAllProps
export class UserRepository extends BaseRepository<UserEntity> {
static repository = new UserRepository();
static getRepository(): UserRepository {
return UserRepository.repository;
}
constructor(target: EntityTarget<UserEntity> = UserEntity) {
super(target);
}
}

View File

@ -0,0 +1,20 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { NextFunction, createMiddlewareDecorator } from 'next-api-decorators';
import { UserService } from '../domains/user.service';
import { COOKIE_SESSION_NAME } from '@/src/constants/auth';
export const SessionGuard = createMiddlewareDecorator(
async (req: NextApiRequest, res: NextApiResponse, next: NextFunction) => {
const session = req.cookies[COOKIE_SESSION_NAME];
const user = await UserService.getService().me(session as string);
if (!req.session) {
req.session = {} as any;
}
if (user) {
req.session.user = user;
}
return next();
},
);

View File

@ -0,0 +1,18 @@
export class Profile {
fullname?: string;
birthdate?: string;
familyName?: string;
gender?: string;
givenName?: string;
locale?: string;
middleName?: string;
name?: string;
nickname?: string;
picture?: string;
preferredUsername?: string;
website?: string;
address?: string;
avatar?: string;
description?: string;
job?: string;
}

1
src/constants/auth.ts Normal file
View File

@ -0,0 +1 @@
export const COOKIE_SESSION_NAME = 'pz-fr-session'

View File

@ -0,0 +1,47 @@
import {
Body,
createHandler,
Get,
HttpCode,
Post,
Req,
Res,
} from 'next-api-decorators';
import { LoginRequest } from './request';
import { UserService } from 'src/backend/domains/user.service';
import { type NextApiRequest, type NextApiResponse } from 'next';
import { SessionGuard } from 'src/backend/middlewares/AuthGuard';
import { COOKIE_SESSION_NAME } from 'src/constants/auth';
class AuthHandler {
@Post('/login')
@HttpCode(200)
async login(@Body() body: LoginRequest, @Res() res: NextApiResponse) {
const response = await UserService.getService().login(body);
res.setHeader(
'Set-Cookie',
`${COOKIE_SESSION_NAME}=${response.token}; Path=/; HttpOnly`,
);
return response;
}
@Get('/me')
@SessionGuard()
@HttpCode(200)
async me(@Req() req: NextApiRequest) {
return req.session.user;
}
@Post('/logout')
@SessionGuard()
@HttpCode(200)
async logout(@Req() req: NextApiRequest, @Res() res: NextApiResponse) {
req.session.user = null;
res.setHeader('Set-Cookie', `${COOKIE_SESSION_NAME}=; Path=/; HttpOnly`);
return { message: 'Logout success' };
}
}
export default createHandler(AuthHandler);

View File

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class LoginRequest {
@IsString()
email!: string;
@IsString()
password!: string;
}

View File

@ -0,0 +1,15 @@
import { Body, createHandler, HttpCode, Post } from 'next-api-decorators';
import { UserService } from 'src/backend/domains/user.service';
import { CreateUserRequest } from './request';
class UserHandler {
@Post('/')
@HttpCode(200)
async create(@Body() body: CreateUserRequest) {
const response = await UserService.getService().create(body);
return response;
}
}
export default createHandler(UserHandler);

View File

@ -0,0 +1,18 @@
import { IsString } from 'class-validator';
export class CreateUserRequest {
@IsString()
email!: string;
@IsString()
username!: string;
@IsString()
password!: string;
@IsString()
phoneNumber!: string;
@IsString()
companyName?: string;
}

View File

@ -0,0 +1,11 @@
export const awaitToError = async <E = Error, T = any>(
p: Promise<T>,
): Promise<[E | null, T | null]> => {
try {
const r = await Promise.resolve<T>(p);
return [null, r];
} catch (e: any) {
return [e, null];
}
};

8
src/utils/hash.ts Normal file
View File

@ -0,0 +1,8 @@
import * as crypto from 'crypto';
export const hashString = (inputString: string) => {
const hash = crypto.createHash('sha256');
hash.update(inputString);
return hash.digest('hex');
};

12
src/utils/jwt.ts Normal file
View File

@ -0,0 +1,12 @@
import jwt from 'jsonwebtoken';
import { config } from '@/config';
export const encode = (data: any) => {
return jwt.sign(data, config.jwtSecret, {
expiresIn: '1d',
});
};
export const validate = (token: string) => {
return jwt.verify(token, config.jwtSecret);
};

5
src/utils/request.ts Normal file
View File

@ -0,0 +1,5 @@
import axios from 'axios';
export const apiRequest = axios.create({
baseURL: '/api',
});

View File

@ -1,4 +1,11 @@
{
"ts-node": {
// these options are overrides used only by ts-node
// same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable
"compilerOptions": {
"module": "commonjs"
}
},
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
@ -17,9 +24,13 @@
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
"@/*": ["./*"],
"src/*": ["./src/*"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]

3643
yarn.lock Normal file

File diff suppressed because it is too large Load Diff