refactor: move everything to one repo

This commit is contained in:
isra el
2023-03-10 17:59:41 +03:00
parent 76cf4fd161
commit 78cce57091
130 changed files with 32360 additions and 1 deletions

View File

View File

View File

View File

@@ -57,7 +57,7 @@ public class MainActivity extends AppCompatActivity {
private static final int SEND_SMS_PERMISSION_REQUEST_CODE = 0;
private static final int SCAN_QR_REQUEST_CODE = 49374;
private static final String API_BASE_URL = "https://vernu-sms.herokuapp.com/api/v1/";
private static final String API_BASE_URL = "https://api.sms.real.et/api/v1/";
private String deviceId = null;

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 982 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

View File

10
api/.env.example Normal file
View File

@@ -0,0 +1,10 @@
PORT=
MONGO_URI=
JWT_SECRET=secret
FIREBASE_PROJECT_ID=
FIREBASE_PRIVATE_KEY_ID=
FIREBASE_PRIVATE_KEY=
FIREBASE_CLIENT_EMAIL=
FIREBASE_CLIENT_ID=
FIREBASE_CLIENT_C509_CERT_URL=

24
api/.eslintrc.js Normal file
View File

@@ -0,0 +1,24 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

38
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.env

5
api/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": false
}

1
api/Procfile Normal file
View File

@@ -0,0 +1 @@
web: npm run start:prod

73
api/README.md Normal file
View File

@@ -0,0 +1,73 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo_text.svg" width="320" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

4
api/nest-cli.json Normal file
View File

@@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

17979
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

84
api/package.json Normal file
View File

@@ -0,0 +1,84 @@
{
"name": "sms-gateway-backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/jwt": "^8.0.0",
"@nestjs/mongoose": "^9.0.2",
"@nestjs/passport": "^8.2.1",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.2.1",
"bcryptjs": "^2.4.3",
"dotenv": "^16.0.0",
"firebase-admin": "^10.0.2",
"mongoose": "^6.2.4",
"passport": "^0.5.2",
"passport-jwt": "^4.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"swagger-ui-express": "^4.3.0",
"uuid": "^8.3.2"
},
"devDependencies": {
"@nestjs/cli": "^8.0.0",
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0",
"@types/express": "^4.17.13",
"@types/jest": "27.0.2",
"@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5",
"prettier": "^2.3.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.10.1",
"typescript": "^4.3.5"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing'
import { AppController } from './app.controller'
import { AppService } from './app.service'
describe('AppController', () => {
let appController: AppController
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile()
appController = app.get<AppController>(AppController)
})
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!')
})
})
})

View File

@@ -0,0 +1,5 @@
import { Controller, Get } from '@nestjs/common'
import { AppService } from './app.service'
@Controller()
export class AppController {}

19
api/src/app.module.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common'
import { AppController } from './app.controller'
import { AppService } from './app.service'
import { MongooseModule } from '@nestjs/mongoose'
import { GatewayModule } from './gateway/gateway.module'
import { AuthModule } from './auth/auth.module'
import { UsersModule } from './users/users.module'
@Module({
imports: [
MongooseModule.forRoot(process.env.MONGO_URI),
AuthModule,
UsersModule,
GatewayModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

6
api/src/app.service.ts Normal file
View File

@@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common'
@Injectable()
export class AppService {
constructor() {}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { AuthController } from './auth.controller'
describe('AuthController', () => {
let controller: AuthController
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
}).compile()
controller = module.get<AuthController>(AuthController)
})
it('should be defined', () => {
expect(controller).toBeDefined()
})
})

View File

@@ -0,0 +1,74 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Request,
UseGuards,
} from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
import { LoginInputDTO, RegisterInputDTO } from './auth.dto'
import { AuthGuard } from './auth.guard'
import { AuthService } from './auth.service'
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@ApiOperation({ summary: 'Login' })
@Post('/login')
async login(@Body() input: LoginInputDTO) {
const data = await this.authService.login(input)
return { data }
}
@ApiOperation({ summary: 'Register' })
@Post('/register')
async register(@Body() input: RegisterInputDTO) {
const data = await this.authService.register(input)
return { data }
}
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Generate Api Key' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@ApiBearerAuth()
@Post('/api-keys')
async generateApiKey(@Request() req) {
const { apiKey, message } = await this.authService.generateApiKey(req.user)
return { data: apiKey, message }
}
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Get Api Key List (masked***)' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@ApiBearerAuth()
@Get('/api-keys')
async getApiKey(@Request() req) {
const data = await this.authService.getUserApiKeys(req.user)
return { data }
}
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Generate Api Key' })
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@Delete('/api-keys/:id')
async deleteApiKey(@Param() params) {
await this.authService.deleteApiKey(params.id)
return { message: 'API Key Deleted' }
}
}

23
api/src/auth/auth.dto.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger'
export class RegisterInputDTO {
@ApiProperty({ type: String, required: true })
name: string
@ApiProperty({ type: String, required: true })
email: string
@ApiProperty({ type: String })
primaryPhone?: string
@ApiProperty({ type: String, required: true })
password: string
}
export class LoginInputDTO {
@ApiProperty({ type: String, required: true })
email: string
@ApiProperty({ type: String, required: true })
password: string
}

View File

@@ -0,0 +1,57 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { UsersService } from 'src/users/users.service'
import { AuthService } from './auth.service'
import * as bcrypt from 'bcryptjs'
@Injectable()
// Guard for authenticating users by either jwt token or api key
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private usersService: UsersService,
private authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
var userId
const request = context.switchToHttp().getRequest()
if (request.headers.authorization?.startsWith('Bearer ')) {
const bearerToken = request.headers.authorization.split(' ')[1]
const payload = this.jwtService.verify(bearerToken)
userId = payload.sub
}
// check apiKey in query params
else if (request.query.apiKey) {
const apiKeyStr = request.query.apiKey
if (apiKeyStr) {
var regex = new RegExp(`^${apiKeyStr.substr(0, 17)}`, 'g')
const apiKey = await this.authService.findApiKeys({
apiKey: { $regex: regex },
})
if (apiKey && bcrypt.compareSync(apiKeyStr, apiKey.hashedApiKey)) {
userId = apiKey.user
}
}
}
if (userId) {
const user = await this.authService.validateUser(userId)
if (user) {
request.user = user
return true
}
}
throw new HttpException({ error: 'Unauthorized' }, HttpStatus.UNAUTHORIZED)
}
}

View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { MongooseModule } from '@nestjs/mongoose'
import { PassportModule } from '@nestjs/passport'
import { UsersModule } from 'src/users/users.module'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { JwtStrategy } from './jwt.strategy'
import { ApiKey, ApiKeySchema } from './schemas/api-key.schema'
@Module({
imports: [
MongooseModule.forFeature([
{
name: ApiKey.name,
schema: ApiKeySchema,
},
]),
UsersModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '30d' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, MongooseModule],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { AuthService } from './auth.service'
describe('AuthService', () => {
let service: AuthService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
}).compile()
service = module.get<AuthService>(AuthService)
})
it('should be defined', () => {
expect(service).toBeDefined()
})
})

View File

@@ -0,0 +1,100 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { UsersService } from 'src/users/users.service'
import { JwtService } from '@nestjs/jwt'
import * as bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { InjectModel } from '@nestjs/mongoose'
import { ApiKey, ApiKeyDocument } from './schemas/api-key.schema'
import { Model } from 'mongoose'
import { User } from 'src/users/schemas/user.schema'
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
@InjectModel(ApiKey.name) private apiKeyModel: Model<ApiKeyDocument>,
) {}
async validateUser(_id: string): Promise<User | null> {
const user = await this.usersService.findOne({ _id })
if (user) {
return user
}
return null
}
async login(userData: any) {
const user = await this.usersService.findOne({ email: userData.email })
if (!user) {
throw new HttpException(
{ error: 'User not found' },
HttpStatus.UNAUTHORIZED,
)
}
if (!(await bcrypt.compare(userData.password, user.password))) {
throw new HttpException(
{ error: 'Invalid credentials' },
HttpStatus.UNAUTHORIZED,
)
}
const payload = { email: user.email, sub: user._id }
return {
accessToken: this.jwtService.sign(payload),
user,
}
}
async register(userData: any) {
const hashedPassword = await bcrypt.hash(userData.password, 10)
const user = await this.usersService.create({
...userData,
password: hashedPassword,
})
const payload = { email: user.email, sub: user._id }
return {
accessToken: this.jwtService.sign(payload),
user,
}
}
async generateApiKey(currentUser: User) {
const apiKey = uuidv4()
const hashedApiKey = await bcrypt.hash(apiKey, 10)
const newApiKey = new this.apiKeyModel({
apiKey: apiKey.substr(0, 17) + '******************',
hashedApiKey,
user: currentUser._id,
})
await newApiKey.save()
return { apiKey, message: 'Save this key, it wont be shown again ;)' }
}
async getUserApiKeys(currentUser: User) {
return this.apiKeyModel.find({ user: currentUser._id })
}
async findApiKeys(params) {
return this.apiKeyModel.findOne(params)
}
async deleteApiKey(apiKeyId: string) {
const apiKey = await this.apiKeyModel.findOne({ _id: apiKeyId })
if (!apiKey) {
throw new HttpException(
{
error: 'Api key not found',
},
HttpStatus.NOT_FOUND,
)
}
await this.apiKeyModel.deleteOne({ _id: apiKeyId })
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,26 @@
import { ExtractJwt, Strategy } from 'passport-jwt'
import { PassportStrategy } from '@nestjs/passport'
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { UsersService } from 'src/users/users.service'
import { User } from 'src/users/schemas/user.schema'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
})
}
async validate(payload: any): Promise<User> {
const userId = payload.sub
const user = await this.usersService.findOne({ _id: userId })
if (!user) {
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED)
} else {
return user
}
}
}

View File

@@ -0,0 +1,21 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { User } from 'src/users/schemas/user.schema'
export type ApiKeyDocument = ApiKey & Document
@Schema({ timestamps: true })
export class ApiKey {
_id?: Types.ObjectId
@Prop({ type: String })
apiKey: string // save first few chars only [ abc123****** ]
@Prop({ type: String })
hashedApiKey: string
@Prop({ type: Types.ObjectId, ref: User.name })
user: User
}
export const ApiKeySchema = SchemaFactory.createForClass(ApiKey)

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { GatewayController } from './gateway.controller'
describe('GatewayController', () => {
let controller: GatewayController
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GatewayController],
}).compile()
controller = module.get<GatewayController>(GatewayController)
})
it('should be defined', () => {
expect(controller).toBeDefined()
})
})

View File

@@ -0,0 +1,63 @@
import {
Body,
Controller,
Param,
Patch,
Post,
UseGuards,
Request,
} from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
import { AuthGuard } from 'src/auth/auth.guard'
import { RegisterDeviceInputDTO, SendSMSInputDTO } from './gateway.dto'
import { GatewayService } from './gateway.service'
@ApiTags('gateway')
@ApiBearerAuth()
@Controller('gateway')
export class GatewayController {
constructor(private readonly gatewayService: GatewayService) {}
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Register device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@Post('/devices')
async registerDevice(@Body() input: RegisterDeviceInputDTO, @Request() req) {
const data = await this.gatewayService.registerDevice(input, req.user)
return { data }
}
@ApiOperation({ summary: 'Update device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@Patch('/devices/:id')
async updateDevice(
@Param('id') deviceId: string,
@Body() input: RegisterDeviceInputDTO,
) {
const data = await this.gatewayService.updateDevice(deviceId, input)
return { data }
}
@ApiOperation({ summary: 'Send SMS to a device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@Post('/devices/:id/sendSMS')
async sendSMS(
@Param('id') deviceId: string,
@Body() smsData: SendSMSInputDTO,
) {
const data = await this.gatewayService.sendSMS(deviceId, smsData)
return { data }
}
}

View File

@@ -0,0 +1,54 @@
import { ApiProperty } from '@nestjs/swagger'
export class RegisterDeviceInputDTO {
@ApiProperty({ type: Boolean })
enabled?: boolean
@ApiProperty({ type: String })
fcmToken?: string
@ApiProperty({ type: String })
brand?: string
@ApiProperty({ type: String })
manufacturer?: string
@ApiProperty({ type: String })
model?: string
@ApiProperty({ type: String })
serial?: string
@ApiProperty({ type: String })
buildId?: string
@ApiProperty({ type: String })
os?: string
@ApiProperty({ type: String })
osVersion?: string
@ApiProperty({ type: String })
appVersionName?: string
@ApiProperty({ type: String })
appVersionCode?: number
}
export class ISMSData {
@ApiProperty({
type: String,
required: true,
description: 'SMS text',
})
smsBody: string
@ApiProperty({
type: Array,
required: true,
description: 'Array of phone numbers',
example: ['+2519xxxxxxxx', '+2517xxxxxxxx'],
})
receivers: string[]
}
export class SendSMSInputDTO extends ISMSData {}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { Device, DeviceSchema } from './schemas/device.schema'
import { GatewayController } from './gateway.controller'
import { GatewayService } from './gateway.service'
import { AuthModule } from 'src/auth/auth.module'
import { UsersModule } from 'src/users/users.module'
@Module({
imports: [
MongooseModule.forFeature([
{
name: Device.name,
schema: DeviceSchema,
},
]),
AuthModule,
UsersModule,
],
controllers: [GatewayController],
providers: [GatewayService],
exports: [MongooseModule, GatewayService],
})
export class GatewayModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { GatewayService } from './gateway.service'
describe('GatewayService', () => {
let service: GatewayService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GatewayService],
}).compile()
service = module.get<GatewayService>(GatewayService)
})
it('should be defined', () => {
expect(service).toBeDefined()
})
})

View File

@@ -0,0 +1,88 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Device, DeviceDocument } from './schemas/device.schema'
import { Model } from 'mongoose'
import * as firebaseAdmin from 'firebase-admin'
import { RegisterDeviceInputDTO, SendSMSInputDTO } from './gateway.dto'
import { User } from 'src/users/schemas/user.schema'
@Injectable()
export class GatewayService {
constructor(
@InjectModel(Device.name) private deviceModel: Model<DeviceDocument>,
) {}
async registerDevice(
input: RegisterDeviceInputDTO,
user: User,
): Promise<any> {
return await this.deviceModel.create({ ...input, user })
}
async updateDevice(
deviceId: string,
input: RegisterDeviceInputDTO,
): Promise<any> {
const device = await this.deviceModel.findById(deviceId)
if (!device) {
throw new HttpException(
{
error: 'Device not found',
},
HttpStatus.NOT_FOUND,
)
}
return await this.deviceModel.findByIdAndUpdate(
deviceId,
{ $set: input },
{ new: true },
)
}
async sendSMS(deviceId: string, smsData: SendSMSInputDTO): Promise<any> {
const device = await this.deviceModel.findById(deviceId)
if (!device) {
throw new HttpException(
{
error: 'Device not found',
},
HttpStatus.NOT_FOUND,
)
}
if (!device.enabled) {
throw new HttpException(
{
success: false,
error: 'Device is disabled',
},
HttpStatus.BAD_REQUEST,
)
}
const payload: any = {
// notification: {
// title: 'SMS',
// body: 'message',
// },
data: {
smsData: JSON.stringify(smsData),
},
}
try {
const response = await firebaseAdmin
.messaging()
.sendToDevice(device.fcmToken, payload, { priority: 'high' })
return response
} catch (e) {
throw new HttpException(
{
error: 'Failed to send SMS',
},
HttpStatus.BAD_REQUEST,
)
}
}
}

View File

@@ -0,0 +1,48 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { User } from 'src/users/schemas/user.schema'
export type DeviceDocument = Device & Document
@Schema({ timestamps: true })
export class Device {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: User.name })
user: User
@Prop({ type: Boolean, default: false })
enabled: boolean
@Prop({ type: String })
fcmToken: string
@Prop({ type: String })
brand: string
@Prop({ type: String })
manufacturer: string
@Prop({ type: String })
model: string
@Prop({ type: String })
serial: string
@Prop({ type: String })
buildId: string
@Prop({ type: String })
os: string
@Prop({ type: String })
osVersion: string
@Prop({ type: String })
appVersionName: string
@Prop({ type: Number })
appVersionCode: number
}
export const DeviceSchema = SchemaFactory.createForClass(Device)

View File

@@ -0,0 +1,23 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { ApiKey } from 'src/auth/schemas/api-key.schema'
import { User } from 'src/users/schemas/user.schema'
import { Device } from './device.schema'
export type SMSDocument = SMS & Document
@Schema({ timestamps: true })
export class SMS {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: Device.name })
device: Device
@Prop({ type: String, required: true })
message: string
@Prop({ type: String, required: true })
to: string
}
export const SMSSchema = SchemaFactory.createForClass(SMS)

51
api/src/main.ts Normal file
View File

@@ -0,0 +1,51 @@
import 'dotenv/config'
import { VersioningType } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import * as firebase from 'firebase-admin'
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const PORT = process.env.PORT || 3005
app.setGlobalPrefix('api')
app.enableVersioning({
defaultVersion: '1',
type: VersioningType.URI,
})
const config = new DocumentBuilder()
.setTitle('VERNU SMS Gateway api docs')
.setDescription('api docs')
.setVersion('1.0')
.addBearerAuth()
.build()
const document = SwaggerModule.createDocument(app, config)
SwaggerModule.setup('', app, document, {
swaggerOptions: {
persistAuthorization: true,
},
})
const firebaseConfig = {
type: 'service_account',
projectId: process.env.FIREBASE_PROJECT_ID,
privateKeyId: process.env.FIREBASE_PRIVATE_KEY_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
clientId: process.env.FIREBASE_CLIENT_ID,
authUri: 'https://accounts.google.com/o/oauth2/auth',
tokenUri: 'https://oauth2.googleapis.com/token',
authProviderX509CertUrl: 'https://www.googleapis.com/oauth2/v1/certs',
clientC509CertUrl: process.env.FIREBASE_CLIENT_C509_CERT_URL,
}
firebase.initializeApp({
credential: firebase.credential.cert(firebaseConfig),
})
app.enableCors()
await app.listen(PORT)
}
bootstrap()

View File

@@ -0,0 +1,27 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { UserRole } from '../user-roles.enum'
export type UserDocument = User & Document
@Schema({ timestamps: true })
export class User {
_id?: Types.ObjectId
@Prop({ type: String })
name: string
@Prop({ type: String, required: true, unique: true, lowercase: true })
email: string
@Prop({ type: String, trim: true })
primaryPhone: string
@Prop({ type: String, required: true })
password: string
@Prop({ type: String, default: UserRole.REGULAR })
role: string
}
export const UserSchema = SchemaFactory.createForClass(User)

View File

@@ -0,0 +1,4 @@
export enum UserRole {
ADMIN = 'ADMIN',
REGULAR = 'REGULAR',
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { UsersController } from './users.controller'
describe('UsersController', () => {
let controller: UsersController
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
}).compile()
controller = module.get<UsersController>(UsersController)
})
it('should be defined', () => {
expect(controller).toBeDefined()
})
})

View File

@@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common'
@Controller('users')
export class UsersController {}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { User, UserSchema } from './schemas/user.schema'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'
@Module({
imports: [
MongooseModule.forFeature([
{
name: User.name,
schema: UserSchema,
},
]),
],
controllers: [UsersController],
providers: [UsersService],
exports: [MongooseModule, UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { UsersService } from './users.service'
describe('UsersService', () => {
let service: UsersService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
}).compile()
service = module.get<UsersService>(UsersService)
})
it('should be defined', () => {
expect(service).toBeDefined()
})
})

View File

@@ -0,0 +1,36 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { User, UserDocument } from './schemas/user.schema'
import { Model } from 'mongoose'
@Injectable()
export class UsersService {
constructor(@InjectModel(User.name) private userModel: Model<UserDocument>) {}
async findOne(params) {
return await this.userModel.findOne(params)
}
async findAll() {
return await this.userModel.find()
}
async create(userData: any) {
const { name, email, password } = userData
if (await this.findOne({ email })) {
throw new HttpException(
{
error: 'user exists with the same email',
},
HttpStatus.BAD_REQUEST,
)
}
const newUser = new this.userModel({
name,
email,
password,
})
return await newUser.save()
}
}

24
api/test/app.e2e-spec.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from './../src/app.module'
describe('AppController (e2e)', () => {
let app: INestApplication
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile()
app = moduleFixture.createNestApplication()
await app.init()
})
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!')
})
})

9
api/test/jest-e2e.json Normal file
View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

4
api/tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
api/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

3
web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

38
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
.env

34
web/README.md Normal file
View File

@@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

118
web/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,118 @@
import {
Box,
Flex,
Avatar,
Button,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
useColorModeValue,
Stack,
useColorMode,
Center,
Image,
} from '@chakra-ui/react'
import Link from 'next/link'
import { MoonIcon, SunIcon } from '@chakra-ui/icons'
import Router from 'next/router'
import { useDispatch, useSelector } from 'react-redux'
import { logout, selectAuth } from '../store/authSlice'
export default function Navbar() {
const dispatch = useDispatch()
const { colorMode, toggleColorMode } = useColorMode()
const { user } = useSelector(selectAuth)
return (
<>
<Box bg={useColorModeValue('gray.100', 'gray.900')} px={4}>
<Flex h={16} alignItems={'center'} justifyContent={'space-between'}>
<Link href='/' passHref>
<Flex alignItems={'center'}>
<Image
alt={'Hero Image'}
fit={'cover'}
w={'30px'}
h={'30px'}
src={'/images/sms-gateway-logo.png'}
/>
<Box style={{ cursor: 'pointer', marginLeft: '5px' }}>
VERNU SMS
</Box>
</Flex>
</Link>
<Flex alignItems={'center'}>
<Stack direction={'row'} spacing={7}>
<Button onClick={toggleColorMode}>
{colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
</Button>
{!user ? (
<>
<Menu>
<Link href='/login' passHref>
<MenuButton>Login</MenuButton>
</Link>
<Link href='/register' passHref>
<MenuButton>Register</MenuButton>
</Link>
</Menu>
</>
) : (
<Menu>
<MenuButton
as={Button}
rounded={'full'}
variant={'link'}
cursor={'pointer'}
minW={0}
>
<Avatar
size={'sm'}
src={'https://avatars.dicebear.com/api/male/username.svg'}
/>
</MenuButton>
<MenuList alignItems={'center'}>
<br />
<Center>
<Avatar
size={'xl'}
src={
'https://avatars.dicebear.com/api/male/username.svg'
}
/>
</Center>
<br />
<Center>
<p>{user?.name}</p>
</Center>
<br />
<MenuDivider />
<MenuItem
onClick={() => {
Router.push('/dashboard')
}}
>
Dashboard
</MenuItem>
<MenuItem>Account Settings</MenuItem>
<MenuItem
onClick={() => {
dispatch(logout())
}}
>
Logout
</MenuItem>
</MenuList>
</Menu>
)}
</Stack>
</Flex>
</Flex>
</Box>
</>
)
}

View File

@@ -0,0 +1,72 @@
import { DeleteIcon } from '@chakra-ui/icons'
import {
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tooltip,
Tr,
useToast,
} from '@chakra-ui/react'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { deleteApiKeyRequest, getApiKeyListRequest } from '../../services'
import { selectAuth } from '../../store/authSlice'
const ApiKeyList = () => {
const [apiKeyList, setApiKeyList] = useState([])
const toast = useToast()
const { user, accessToken } = useSelector(selectAuth)
useEffect(() => {
if (user && accessToken) {
getApiKeyListRequest().then((apiKeys) => {
setApiKeyList(apiKeys)
})
}
}, [user, accessToken])
const onDelete = (apiKeyId: string) => {
deleteApiKeyRequest(apiKeyId)
setApiKeyList(apiKeyList.filter((apiKey) => apiKey._id !== apiKeyId))
toast({
title: 'Success',
description: 'API Key deleted',
})
}
return (
<TableContainer>
<Table variant='simple'>
<Thead>
<Tr>
<Th>Your API Keys</Th>
<Th>Status</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{apiKeyList.map((apiKey) => (
<Tr key={apiKey}>
<Td>{apiKey.apiKey}</Td>
<Td>{apiKey.status}</Td>
<Td>
<Tooltip label='Double Click to delete'>
<DeleteIcon
onDoubleClick={(e) => {
onDelete(apiKey._id)
}}
/>
</Tooltip>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)
}
export default ApiKeyList

View File

@@ -0,0 +1,154 @@
import {
Button,
chakra,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useColorModeValue,
useToast,
} from '@chakra-ui/react'
import { useState } from 'react'
import QRCode from 'react-qr-code'
import { generateApiKeyRequest } from '../../services'
const NewApiKeyGeneratedModal = ({
isOpen = false,
generatedApiKey,
onClose,
showQR = false,
...props
}) => {
const toast = useToast()
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Api Key Generated</ModalHeader>
<ModalCloseButton />
<ModalBody>
{showQR && (
<>
<chakra.h1
fontSize='md'
fontWeight='bold'
mt={2}
// color={useColorModeValue('gray.800', 'white')}
>
Open the SMS Gateway App and scan this QR to get started
</chakra.h1>
<Flex justifyContent='center'>
<QRCode value={generatedApiKey} />{' '}
</Flex>
</>
)}
<chakra.h1
fontSize='lg'
fontWeight='bold'
mt={2}
// color={useColorModeValue('gray.800', 'white')}
>
{generatedApiKey}
</chakra.h1>
<chakra.h1
fontSize='lg'
fontWeight='bold'
mt={2}
color={useColorModeValue('red.800', 'white')}
>
{'Save this key, it wont be shown again ;)'}
</chakra.h1>
</ModalBody>
<ModalFooter>
<Button
variant='ghost'
onClick={() => {
navigator.clipboard.writeText(generatedApiKey)
toast({
title: 'Copied to clipboard',
status: 'success',
})
}}
>
Copy to Clipboard
</Button>{' '}
<Button
colorScheme='blue'
mr={3}
onClick={() => {
onClose()
}}
>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default function GenerateApiKey() {
const [generatedApiKey, setGeneratedApiKey] = useState(null)
const [generatingApiKey, setGeneratingApiKey] = useState(null)
const [showGeneratedApiKeyModal, setShowGeneratedApiKeyModal] =
useState(false)
const generateApiKey = async () => {
setGeneratingApiKey(true)
const newApiKey = await generateApiKeyRequest()
setGeneratedApiKey(newApiKey)
setShowGeneratedApiKeyModal(true)
setGeneratingApiKey(false)
}
return (
<>
{' '}
<Flex justifyContent='center'>
<Button
/* flex={1} */
px={4}
fontSize={'sm'}
rounded={'full'}
bg={'blue.400'}
color={'white'}
boxShadow={
'0px 1px 25px -5px rgb(66 153 225 / 48%), 0 10px 10px -5px rgb(66 153 225 / 43%)'
}
_hover={{
bg: 'blue.500',
}}
_focus={{
bg: 'blue.500',
}}
onClick={generateApiKey}
disabled={generatingApiKey}
>
{generatingApiKey
? 'generating... '
: 'Generate Api Key/ Register Device'}
</Button>
</Flex>
{generatedApiKey && (
<>
{
<NewApiKeyGeneratedModal
isOpen={showGeneratedApiKeyModal}
generatedApiKey={generatedApiKey}
showQR={true}
onClose={() => {
setShowGeneratedApiKeyModal(false)
}}
/>
}
</>
)}
</>
)
}

Some files were not shown because too many files have changed in this diff Show More