init
This commit is contained in:
parent
3f044867e9
commit
03296b4ae9
319
.drone.yml
Normal file
319
.drone.yml
Normal 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
5
.env
Normal file
@ -0,0 +1,5 @@
|
||||
DB_HOST=localhost
|
||||
DB_PORT=31360
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=productzilla
|
||||
DB_NAME=productzilla
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
3
@types/generic.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface Generic {
|
||||
id?: string;
|
||||
}
|
||||
7
@types/next.d.ts
vendored
Normal file
7
@types/next.d.ts
vendored
Normal 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
19
@types/pagination.ts
Normal 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
12
config/index.ts
Normal 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',
|
||||
};
|
||||
18
misc/deployment/config.yml
Normal file
18
misc/deployment/config.yml
Normal 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}}
|
||||
68
misc/deployment/k8s.template.staging.yml
Normal file
68
misc/deployment/k8s.template.staging.yml
Normal 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
|
||||
68
misc/deployment/k8s.template.yml
Normal file
68
misc/deployment/k8s.template.yml
Normal 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
|
||||
33
misc/docker/docker-compose.yml
Normal file
33
misc/docker/docker-compose.yml
Normal 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
1726
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
34
src/app/components/Features.tsx
Normal file
34
src/app/components/Features.tsx
Normal 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;
|
||||
30
src/app/components/Footer.tsx
Normal file
30
src/app/components/Footer.tsx
Normal 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;
|
||||
25
src/app/components/Header.tsx
Normal file
25
src/app/components/Header.tsx
Normal 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;
|
||||
22
src/app/components/Hero.tsx
Normal file
22
src/app/components/Hero.tsx
Normal 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;
|
||||
36
src/app/components/Program.tsx
Normal file
36
src/app/components/Program.tsx
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
106
src/app/page.tsx
106
src/app/page.tsx
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/backend/domains/user.service.ts
Normal file
61
src/backend/domains/user.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/backend/infrastructure/database/provider/index.ts
Normal file
25
src/backend/infrastructure/database/provider/index.ts
Normal 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();
|
||||
46
src/backend/infrastructure/database/provider/inject-db.ts
Normal file
46
src/backend/infrastructure/database/provider/inject-db.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@ -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\``);
|
||||
}
|
||||
}
|
||||
7
src/backend/infrastructure/database/provider/seeder.ts
Normal file
7
src/backend/infrastructure/database/provider/seeder.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { DatabaseProvider } from '.';
|
||||
|
||||
const main = async () => {
|
||||
await DatabaseProvider.initialize();
|
||||
};
|
||||
|
||||
main();
|
||||
@ -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',
|
||||
};
|
||||
66
src/backend/infrastructure/database/user/user.entity.ts
Normal file
66
src/backend/infrastructure/database/user/user.entity.ts
Normal 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;
|
||||
}
|
||||
15
src/backend/infrastructure/database/user/user.repository.ts
Normal file
15
src/backend/infrastructure/database/user/user.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/backend/middlewares/AuthGuard.ts
Normal file
20
src/backend/middlewares/AuthGuard.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
18
src/backend/models/profile.ts
Normal file
18
src/backend/models/profile.ts
Normal 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
1
src/constants/auth.ts
Normal file
@ -0,0 +1 @@
|
||||
export const COOKIE_SESSION_NAME = 'pz-fr-session'
|
||||
47
src/pages/api/auth/[[...params]].ts
Normal file
47
src/pages/api/auth/[[...params]].ts
Normal 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);
|
||||
9
src/pages/api/auth/request.ts
Normal file
9
src/pages/api/auth/request.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class LoginRequest {
|
||||
@IsString()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
15
src/pages/api/users/[[...params]].ts
Normal file
15
src/pages/api/users/[[...params]].ts
Normal 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);
|
||||
18
src/pages/api/users/request.ts
Normal file
18
src/pages/api/users/request.ts
Normal 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;
|
||||
}
|
||||
11
src/utils/await-to-error.ts
Normal file
11
src/utils/await-to-error.ts
Normal 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
8
src/utils/hash.ts
Normal 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
12
src/utils/jwt.ts
Normal 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
5
src/utils/request.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const apiRequest = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
@ -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"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user