initial commit

This commit is contained in:
2026-07-20 18:21:31 +02:00
commit 42ce1eecae
83 changed files with 13896 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
*.md
fixed-smart.db
.DS_Store
+61
View File
@@ -0,0 +1,61 @@
name: Build and Publish Organization Package
on:
push:
tags:
- 'v*'
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: gitea.jzitnik.dev
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Convert Owner and Repo to Lowercase
id: string_prep
run: |
FULL_REPO="${GITHUB_REPOSITORY,,}"
OWNER="${FULL_REPO%%/*}"
REPO="${FULL_REPO#*/}"
echo "owner=$OWNER" >> $GITHUB_OUTPUT
echo "repo=$REPO" >> $GITHUB_OUTPUT
- name: Extract Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.jzitnik.dev/${{ steps.string_prep.outputs.owner }}/${{ steps.string_prep.outputs.repo }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,format=short
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.db
*.db-journal
data/
.DS_Store
*.log
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
RUN apk add --no-cache dumb-init
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
RUN mkdir -p /app/data
ENV NODE_ENV=production
ENV DB_PATH=/app/data/fixed-smart.db
EXPOSE 3000
CMD ["dumb-init", "node", "dist/main.js"]
+32
View File
@@ -0,0 +1,32 @@
# Fixed Smart Backend
Implementace vibe-coded backendu aplikace Fixed Smart
Toto je vibe-coded backend implementovaný z ručně napsané API specifikace z Smali instrukcí z APK souboru.
NOTE: Některé nedůležité funkce nejsou implementované (OAuth login, Analytika, atd.)
## Deployment
Docker compose:
```yaml
services:
fixed-api:
## TODO: Add url for image
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- REGISTRATION_DISABLED=false # Disabling registration
volumes:
- fixed-data:/app/data
volumes:
fixed-data:
```
## Vibe-coded
Protože kód je 100% od LLM, nezaručuji, že bude fungovat, bude bezpečný nebo cokoli mezi tím.
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+11234
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"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": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.28",
"@nestjs/typeorm": "^11.0.3",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.11.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"multer": "^2.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sqlite3": "^6.0.1",
"typeorm": "^1.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"allowScripts": {
"bcrypt@6.0.0": true,
"sqlite3@6.0.1": true,
"better-sqlite3@12.11.1": true
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { DevicesModule } from './devices/devices.module';
import { EventsModule } from './events/events.module';
import { SafeZonesModule } from './safe-zones/safe-zones.module';
import { SharingModule } from './sharing/sharing.module';
import { LimitsModule } from './limits/limits.module';
import { PushModule } from './push/push.module';
import { NightModeModule } from './night-mode/night-mode.module';
import { GeolocationModule } from './geolocation/geolocation.module';
import { RouteModule } from './route/route.module';
import { FaqModule } from './faq/faq.module';
import { MiscModule } from './misc/misc.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: process.env.DB_PATH || 'fixed-smart.db',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
}),
AuthModule,
UsersModule,
DevicesModule,
EventsModule,
SafeZonesModule,
SharingModule,
LimitsModule,
PushModule,
NightModeModule,
GeolocationModule,
RouteModule,
FaqModule,
MiscModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
})
export class AppModule {}
+36
View File
@@ -0,0 +1,36 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { EmailDto } from './dto/email.dto';
import { Public } from '../common/decorators/public.decorator';
@Controller()
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post('user/register')
async register(@Body() dto: RegisterDto) {
await this.authService.register(dto);
}
@Public()
@Post('user/login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Public()
@Post('user/exists')
async exists(@Body() dto: EmailDto) {
return this.authService.checkExists(dto);
}
@Public()
@Post('user/forgot-password')
async forgotPassword(@Body() dto: EmailDto) {
await this.authService.forgotPassword(dto);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { User } from '../users/entities/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'fixed-smart-secret-key',
signOptions: { expiresIn: '365d' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+81
View File
@@ -0,0 +1,81 @@
import {
Injectable,
ConflictException,
UnauthorizedException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { User } from '../users/entities/user.entity';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { EmailDto } from './dto/email.dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
private jwtService: JwtService,
) {}
async register(dto: RegisterDto): Promise<void> {
if (process.env.REGISTRATION_DISABLED === 'true') {
throw new ForbiddenException('Registration is disabled');
}
const existing = await this.userRepo.findOne({
where: { email: dto.email },
});
if (existing) throw new ConflictException('User already exists');
const hashed = await bcrypt.hash(dto.password, 10);
await this.userRepo.save({
email: dto.email,
password: hashed,
name: dto.name || '',
surname: dto.surname || '',
TermsAndConditions: dto.TermsAndConditions ?? true,
PrivacyPolicy: dto.PrivacyPolicy ?? true,
});
}
async login(dto: LoginDto) {
const user = await this.userRepo.findOne({ where: { email: dto.email } });
if (!user) throw new UnauthorizedException('Invalid credentials');
const valid = await bcrypt.compare(dto.password, user.password);
if (!valid) throw new UnauthorizedException('Invalid credentials');
return this.buildUserObject(user);
}
async checkExists(dto: EmailDto) {
const user = await this.userRepo.findOne({ where: { email: dto.email } });
return { message: user ? 'User exists' : 'User not found' };
}
async forgotPassword(dto: EmailDto): Promise<void> {
const user = await this.userRepo.findOne({ where: { email: dto.email } });
if (!user) throw new NotFoundException('User not found');
}
private buildUserObject(user: User) {
const payload = { sub: user.id, email: user.email };
return {
access_token: this.jwtService.sign(payload),
email: user.email,
name: user.name,
surname: user.surname,
facebook_id: user.facebook_id,
TermsAndConditions: user.TermsAndConditions,
PrivacyPolicy: user.PrivacyPolicy,
SafeZoneSettings: {
Enabled: user.SafeZoneEnabled,
LastUpdate: new Date(user.Created).toISOString(),
},
};
}
}
+6
View File
@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class EmailDto {
@IsEmail()
email: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsNotEmpty()
password: string;
}
+35
View File
@@ -0,0 +1,35 @@
import {
IsEmail,
IsNotEmpty,
IsBoolean,
IsOptional,
IsString,
} from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsNotEmpty()
password: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
surname?: string;
@IsOptional()
@IsBoolean()
TermsAndConditions?: boolean;
@IsOptional()
@IsBoolean()
PrivacyPolicy?: boolean;
@IsOptional()
@IsString()
Platform?: string;
}
+36
View File
@@ -0,0 +1,36 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../users/entities/user.entity';
import { Request } from 'express';
const extractJwt = (req: Request): string | null => {
if (!req.headers || !req.headers.authorization) return null;
const parts = req.headers.authorization.split(' ');
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer')
return parts[1];
if (parts.length === 1) return parts[0];
return null;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
) {
super({
jwtFromRequest: extractJwt,
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'fixed-smart-secret-key',
});
}
async validate(payload: { sub: number; email: string }) {
const user = await this.userRepo.findOne({ where: { id: payload.sub } });
if (!user) throw new UnauthorizedException();
return user;
}
}
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+9
View File
@@ -0,0 +1,9 @@
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export abstract class BaseEntity {
@CreateDateColumn({ type: 'datetime' })
Created: string;
@UpdateDateColumn({ type: 'datetime' })
Updated: string;
}
@@ -0,0 +1,33 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
message =
typeof res === 'string'
? res
: (res as any).message || exception.message;
if (Array.isArray(message)) message = message[0];
} else if (exception instanceof Error) {
message = exception.message;
}
response.status(status).json({ message });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
@@ -0,0 +1,86 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { Request, Response } from 'express';
import * as crypto from 'crypto';
@Injectable()
export class RequestLoggerInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const { method, originalUrl, query, headers, body } = request;
const id = crypto.randomBytes(4).toString('hex');
const start = Date.now();
const sanitizedHeaders = { ...headers };
delete sanitizedHeaders.authorization;
delete sanitizedHeaders['authorization'];
delete sanitizedHeaders.cookie;
delete sanitizedHeaders['cookie'];
const sanitizedBody = this.sanitize(body);
this.logger.log(`[${id}] --> ${method} ${originalUrl}`);
this.logger.debug(`[${id}] Headers: ${JSON.stringify(sanitizedHeaders)}`);
if (Object.keys(query || {}).length > 0) {
this.logger.debug(`[${id}] Query: ${JSON.stringify(query)}`);
}
if (body && Object.keys(body).length > 0) {
this.logger.debug(`[${id}] Body: ${JSON.stringify(sanitizedBody)}`);
}
return next.handle().pipe(
tap({
next: (resBody: any) => {
const duration = Date.now() - start;
const status = response.statusCode;
const level = status >= 400 ? 'warn' : 'log';
const sanitizedRes = this.sanitize(resBody);
const resStr = JSON.stringify(sanitizedRes);
const truncated =
resStr.length > 1000 ? resStr.slice(0, 1000) + '...' : resStr;
this.logger[level](
`[${id}] <-- ${method} ${originalUrl} ${status} ${duration}ms`,
);
this.logger.debug(`[${id}] Response: ${truncated}`);
},
error: (err: any) => {
const duration = Date.now() - start;
this.logger.error(
`[${id}] <-- ${method} ${originalUrl} ${err.status || 500} ${duration}ms - ${err.message}`,
);
},
}),
);
}
private sanitize(obj: any): any {
if (!obj || typeof obj !== 'object') return obj;
const sensitive = [
'password',
'token',
'access_token',
'secret',
'authorization',
];
const clone = Array.isArray(obj) ? [...obj] : { ...obj };
for (const key of Object.keys(clone)) {
if (sensitive.some((s) => key.toLowerCase().includes(s.toLowerCase()))) {
clone[key] = '***';
} else if (typeof clone[key] === 'object' && clone[key] !== null) {
clone[key] = this.sanitize(clone[key]);
}
}
return clone;
}
}
+60
View File
@@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
} from '@nestjs/common';
import { DevicesService } from './devices.service';
import { DeviceDto } from './dto/device.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { Public } from '../common/decorators/public.decorator';
import { User } from '../users/entities/user.entity';
@Controller('devices')
export class DevicesController {
constructor(private devicesService: DevicesService) {}
@Get('device')
findAll(@CurrentUser() user: User) {
return this.devicesService.findAll(user.id);
}
@Post('device')
create(@CurrentUser() user: User, @Body() dto: DeviceDto) {
return this.devicesService.create(user.id, dto);
}
@Put('device')
update(@CurrentUser() user: User, @Body() dto: DeviceDto) {
return this.devicesService.update(user.id, dto);
}
@Put('device/batch')
batchUpdate(@CurrentUser() user: User, @Body() dtos: DeviceDto[]) {
return this.devicesService.batchUpdate(user.id, dtos);
}
@Get('device/:DeviceID')
findOne(@CurrentUser() user: User, @Param('DeviceID') deviceId: string) {
return this.devicesService.findOne(user.id, deviceId);
}
@Delete('device/:DeviceID')
delete(@CurrentUser() user: User, @Param('DeviceID') deviceId: string) {
return this.devicesService.delete(user.id, deviceId);
}
@Get('device/status/:DeviceID')
getStatus(@CurrentUser() user: User, @Param('DeviceID') deviceId: string) {
return this.devicesService.getStatus(user.id, deviceId);
}
@Public()
@Get('category')
getCategories() {
return this.devicesService.getCategories();
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
import { Device } from './entities/device.entity';
import { DeviceCategory } from './entities/device-category.entity';
@Module({
imports: [TypeOrmModule.forFeature([Device, DeviceCategory])],
controllers: [DevicesController],
providers: [DevicesService],
exports: [DevicesService],
})
export class DevicesModule {}
+111
View File
@@ -0,0 +1,111 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Device } from './entities/device.entity';
import { DeviceCategory } from './entities/device-category.entity';
import { DeviceDto } from './dto/device.dto';
@Injectable()
export class DevicesService {
constructor(
@InjectRepository(Device)
private deviceRepo: Repository<Device>,
@InjectRepository(DeviceCategory)
private categoryRepo: Repository<DeviceCategory>,
) {}
async findAll(userId: number) {
const devices = await this.deviceRepo.find({
where: { userId },
relations: { Category: true },
order: { Order: 'ASC' },
});
return devices.map((d) => ({
DeviceID: d.DeviceID,
Name: d.Name,
DeviceType: d.DeviceType,
CategoryID: d.CategoryID,
Authorization: d.Authorization,
Order: d.Order,
Category: d.Category || null,
Share: d.Share,
Created: d.Created,
FirmwareRevision: d.FirmwareRevision,
HardwareRevision: d.HardwareRevision,
}));
}
async create(userId: number, dto: DeviceDto) {
const device = this.deviceRepo.create({
...dto,
userId,
});
const saved = await this.deviceRepo.save(device);
return this.findOne(userId, saved.DeviceID);
}
async update(userId: number, dto: DeviceDto) {
const device = await this.deviceRepo.findOne({
where: { DeviceID: dto.DeviceID, userId },
});
if (!device) throw new NotFoundException('Device not found');
if (dto.Name !== undefined) device.Name = dto.Name;
if (dto.DeviceType !== undefined) device.DeviceType = dto.DeviceType;
if (dto.CategoryID !== undefined) device.CategoryID = dto.CategoryID;
if (dto.Order !== undefined) device.Order = dto.Order;
await this.deviceRepo.save(device);
return this.findOne(userId, device.DeviceID);
}
async batchUpdate(userId: number, dtos: DeviceDto[]) {
const results: DeviceDto[] = [];
for (const dto of dtos) {
const updated = await this.update(userId, dto);
results.push(updated);
}
return results;
}
async findOne(userId: number, deviceId: string) {
const device = await this.deviceRepo.findOne({
where: { DeviceID: deviceId, userId },
relations: { Category: true },
});
if (!device) throw new NotFoundException('Device not found');
return {
DeviceID: device.DeviceID,
Name: device.Name,
DeviceType: device.DeviceType,
CategoryID: device.CategoryID,
Authorization: device.Authorization,
Order: device.Order,
Category: device.Category || null,
Share: device.Share,
Created: device.Created,
FirmwareRevision: device.FirmwareRevision,
HardwareRevision: device.HardwareRevision,
};
}
async delete(userId: number, deviceId: string): Promise<void> {
const result = await this.deviceRepo.delete({
DeviceID: deviceId,
userId,
});
if (result.affected === 0) throw new NotFoundException('Device not found');
}
async getStatus(userId: number, deviceId: string) {
await this.findOne(userId, deviceId);
return [
{ Name: 'Connected', Value: 'true' },
{ Name: 'Battery', Value: '85' },
];
}
async getCategories() {
return this.categoryRepo.find();
}
}
+28
View File
@@ -0,0 +1,28 @@
import { IsOptional, IsString, IsNumber, IsBoolean } from 'class-validator';
export class DeviceDto {
@IsString()
DeviceID: string;
@IsString()
Name: string;
@IsString()
DeviceType: string;
@IsOptional()
@IsNumber()
CategoryID?: number;
@IsOptional()
@IsString()
Authorization?: string;
@IsOptional()
@IsNumber()
Order?: number;
@IsOptional()
@IsBoolean()
Share?: boolean;
}
@@ -0,0 +1,13 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('device_categories')
export class DeviceCategory {
@PrimaryGeneratedColumn()
Id: number;
@Column()
Name: string;
@Column({ nullable: true })
Image: string;
}
+49
View File
@@ -0,0 +1,49 @@
import {
Entity,
PrimaryColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { DeviceCategory } from './device-category.entity';
@Entity('devices')
export class Device {
@PrimaryColumn()
DeviceID: string;
@Column()
Name: string;
@Column()
DeviceType: string;
@Column({ nullable: true })
CategoryID: number;
@Column({ nullable: true })
Authorization: string;
@Column({ default: 0 })
Order: number;
@Column({ default: false })
Share: boolean;
@Column({ nullable: true })
FirmwareRevision: string;
@Column({ nullable: true })
HardwareRevision: string;
@Column()
userId: number;
@CreateDateColumn({ type: 'datetime' })
Created: string;
@ManyToOne(() => DeviceCategory)
@JoinColumn({ name: 'CategoryID' })
Category: DeviceCategory;
}
+108
View File
@@ -0,0 +1,108 @@
import {
IsOptional,
IsString,
IsNumber,
IsArray,
ValidateNested,
IsObject,
} from 'class-validator';
import { Type } from 'class-transformer';
class LocationDto {
@IsNumber()
lat: number;
@IsNumber()
lng: number;
@IsOptional()
@IsNumber()
Altitude?: number;
@IsOptional()
@IsNumber()
Accuracy?: number;
}
export class EventLogDto {
@IsString()
DeviceID: string;
@IsString()
EventType: string;
@IsOptional()
@IsNumber()
Battery?: number;
@IsString()
Time: string;
@IsOptional()
@IsNumber()
Value?: number;
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocationDto)
Location?: LocationDto;
@IsOptional()
@IsString()
OriginDeviceID?: string;
@IsOptional()
@IsString()
OriginDeviceName?: string;
@IsOptional()
@IsString()
OriginDeviceType?: string;
}
export class HistoryFilterDto {
@IsString()
DeviceID: string;
@IsString()
EventType: string;
@IsOptional()
@IsObject()
DateRange?: { From?: string; To?: string };
}
export class ValueHistoryDto {
@IsString()
DeviceID: string;
@IsString()
EventType: string;
@IsOptional()
@IsObject()
DateRange?: { From?: string; To?: string };
@IsOptional()
@IsNumber()
TimeSlice?: number;
}
export class EventFilterDto {
@IsOptional()
@IsNumber()
Count?: number;
@IsOptional()
@IsArray()
Device?: string[];
@IsOptional()
@IsArray()
EventType?: string[];
@IsOptional()
@IsObject()
DateRange?: { From?: string; To?: string };
}
+47
View File
@@ -0,0 +1,47 @@
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
@Entity('event_logs')
@Index(['DeviceID', 'EventType'])
export class EventLog {
@PrimaryGeneratedColumn()
id: number;
@Column()
DeviceID: string;
@Column()
EventType: string;
@Column({ nullable: true })
Battery: number;
@Column({ type: 'datetime' })
Time: string;
@Column({ type: 'real', nullable: true })
Value: number;
@Column({ type: 'real', nullable: true })
lat: number;
@Column({ type: 'real', nullable: true })
lng: number;
@Column({ type: 'real', nullable: true })
Altitude: number;
@Column({ type: 'real', nullable: true })
Accuracy: number;
@Column({ nullable: true })
OriginDeviceID: string;
@Column({ nullable: true })
OriginDeviceName: string;
@Column({ nullable: true })
OriginDeviceType: string;
@Column({ nullable: true })
userId: number;
}
+45
View File
@@ -0,0 +1,45 @@
import { Controller, Post, Body } from '@nestjs/common';
import { EventsService } from './events.service';
import { EventLogDto, HistoryFilterDto, ValueHistoryDto, EventFilterDto } from './dto/event-log.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '../users/entities/user.entity';
@Controller('devices/event')
export class EventsController {
constructor(private eventsService: EventsService) {}
@Post()
save(@CurrentUser() user: User, @Body() dto: EventLogDto) {
return this.eventsService.saveEvent(user.id, dto);
}
@Post('bulk')
saveBulk(@CurrentUser() user: User, @Body() dtos: EventLogDto[]) {
return this.eventsService.saveBulk(user.id, dtos);
}
@Post('bulk-data')
saveBulkData(@CurrentUser() user: User, @Body() dtos: EventLogDto[]) {
return this.eventsService.saveBulk(user.id, dtos);
}
@Post('history')
getHistory(@CurrentUser() user: User, @Body() dto: HistoryFilterDto) {
return this.eventsService.getHistory(user.id, dto);
}
@Post('value/history')
getValueHistory(@CurrentUser() user: User, @Body() dto: ValueHistoryDto) {
return this.eventsService.getValueHistory(user.id, dto);
}
@Post('filter')
getFiltered(@CurrentUser() user: User, @Body() dto: EventFilterDto) {
return this.eventsService.getFilteredEvents(user.id, dto);
}
@Post('filters')
getFilteredFlat(@CurrentUser() user: User, @Body() dto: EventFilterDto) {
return this.eventsService.getFilteredEventsFlat(user.id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { EventLog } from './entities/event-log.entity';
@Module({
imports: [TypeOrmModule.forFeature([EventLog])],
controllers: [EventsController],
providers: [EventsService],
})
export class EventsModule {}
+166
View File
@@ -0,0 +1,166 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, In } from 'typeorm';
import { EventLog } from './entities/event-log.entity';
import { EventLogDto, HistoryFilterDto, ValueHistoryDto, EventFilterDto } from './dto/event-log.dto';
@Injectable()
export class EventsService {
constructor(
@InjectRepository(EventLog)
private eventRepo: Repository<EventLog>,
) {}
async saveEvent(userId: number, dto: EventLogDto) {
const event = this.eventRepo.create({
DeviceID: dto.DeviceID,
EventType: dto.EventType,
Battery: dto.Battery,
Time: dto.Time,
Value: dto.Value,
lat: dto.Location?.lat,
lng: dto.Location?.lng,
Altitude: dto.Location?.Altitude,
Accuracy: dto.Location?.Accuracy,
OriginDeviceID: dto.OriginDeviceID,
OriginDeviceName: dto.OriginDeviceName,
OriginDeviceType: dto.OriginDeviceType,
userId,
});
const saved = await this.eventRepo.save(event);
return {
DeviceID: saved.DeviceID,
EventType: saved.EventType,
Battery: saved.Battery,
Time: saved.Time,
Value: saved.Value,
Location: saved.lat != null ? {
lat: saved.lat,
lng: saved.lng,
Altitude: saved.Altitude,
Accuracy: saved.Accuracy,
} : undefined,
OriginDeviceID: saved.OriginDeviceID,
OriginDeviceName: saved.OriginDeviceName,
OriginDeviceType: saved.OriginDeviceType,
};
}
async saveBulk(userId: number, dtos: EventLogDto[]): Promise<void> {
const events = dtos.map((dto) =>
this.eventRepo.create({
DeviceID: dto.DeviceID,
EventType: dto.EventType,
Battery: dto.Battery,
Time: dto.Time,
Value: dto.Value,
lat: dto.Location?.lat,
lng: dto.Location?.lng,
Altitude: dto.Location?.Altitude,
Accuracy: dto.Location?.Accuracy,
OriginDeviceID: dto.OriginDeviceID,
OriginDeviceName: dto.OriginDeviceName,
OriginDeviceType: dto.OriginDeviceType,
userId,
}),
);
await this.eventRepo.save(events);
}
async getHistory(userId: number, dto: HistoryFilterDto) {
const where: any = { DeviceID: dto.DeviceID, EventType: dto.EventType, userId };
if (dto.DateRange?.From && dto.DateRange?.To) {
where.Time = Between(dto.DateRange.From, dto.DateRange.To);
}
const events = await this.eventRepo.find({ where, order: { Time: 'ASC' } });
return events.map((e) => ({ Time: e.Time, Value: e.Value }));
}
async getValueHistory(userId: number, dto: ValueHistoryDto) {
const where: any = { DeviceID: dto.DeviceID, EventType: dto.EventType, userId };
if (dto.DateRange?.From && dto.DateRange?.To) {
where.Time = Between(dto.DateRange.From, dto.DateRange.To);
}
const events = await this.eventRepo.find({ where, order: { Time: 'ASC' } });
const slice = dto.TimeSlice || 60;
const buckets: { [key: string]: { sum: number; count: number } } = {};
for (const e of events) {
const d = new Date(e.Time);
const bucket = new Date(Math.floor(d.getTime() / (slice * 60000)) * (slice * 60000)).toISOString();
if (!buckets[bucket]) buckets[bucket] = { sum: 0, count: 0 };
buckets[bucket].sum += e.Value || 0;
buckets[bucket].count++;
}
return Object.entries(buckets).map(([date, { sum, count }]) => ({
date,
avg: count > 0 ? sum / count : 0,
count,
}));
}
async getFilteredEvents(userId: number, dto: EventFilterDto) {
const where: any = { userId };
if (dto.Device?.length) where.DeviceID = In(dto.Device);
if (dto.EventType?.length) where.EventType = In(dto.EventType);
if (dto.DateRange?.From && dto.DateRange?.To) {
where.Time = Between(dto.DateRange.From, dto.DateRange.To);
}
const events = await this.eventRepo.find({
where,
order: { Time: 'DESC' },
take: dto.Count || 50,
});
const grouped: any = {};
for (const e of events) {
if (!grouped[e.EventType]) grouped[e.EventType] = [];
grouped[e.EventType].push(this.mapEvent(e));
}
const defaultTypes = [
'LocationChange', 'Connected', 'Disconnected', 'BatteryChange',
'MotionDetected', 'KeyPressed', 'TemperatureChanged', 'HumidityChanged',
];
for (const t of defaultTypes) {
if (!grouped[t]) grouped[t] = [];
}
return grouped;
}
async getFilteredEventsFlat(userId: number, dto: EventFilterDto) {
const where: any = { userId };
if (dto.Device?.length) where.DeviceID = In(dto.Device);
if (dto.EventType?.length) where.EventType = In(dto.EventType);
if (dto.DateRange?.From && dto.DateRange?.To) {
where.Time = Between(dto.DateRange.From, dto.DateRange.To);
}
const events = await this.eventRepo.find({
where,
order: { Time: 'DESC' },
take: dto.Count || 50,
});
return events.map((e) => this.mapEvent(e));
}
private mapEvent(e: EventLog) {
return {
DeviceID: e.DeviceID,
EventType: e.EventType,
Battery: e.Battery,
Time: e.Time,
Value: e.Value,
Location: e.lat != null
? { lat: e.lat, lng: e.lng, Altitude: e.Altitude, Accuracy: e.Accuracy }
: undefined,
OriginDeviceID: e.OriginDeviceID,
OriginDeviceName: e.OriginDeviceName,
OriginDeviceType: e.OriginDeviceType,
};
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Controller, Get, Param } from '@nestjs/common';
import { FaqService } from './faq.service';
import { Public } from '../common/decorators/public.decorator';
@Controller()
export class FaqController {
constructor(private faqService: FaqService) {}
@Public()
@Get('faq/:type')
getByType(@Param('type') type: string) {
return this.faqService.getByType(type);
}
@Public()
@Get('new-faq')
getAll() {
return this.faqService.getAll();
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { FaqController } from './faq.controller';
import { FaqService } from './faq.service';
@Module({
controllers: [FaqController],
providers: [FaqService],
})
export class FaqModule {}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class FaqService {
async getByType(type: string) {
return [
{ title: `What is ${type}?`, text: `Answer about ${type}.`, order: 1 },
];
}
async getAll() {
return [
{
category: 'General',
faq: [
{ title: 'What is Fixed?', text: 'Fixed is a tracking device.', order: 1 },
],
},
];
}
}
+9
View File
@@ -0,0 +1,9 @@
import { IsNumber } from 'class-validator';
export class GeolocateDto {
@IsNumber()
Latitude: number;
@IsNumber()
Longitude: number;
}
+13
View File
@@ -0,0 +1,13 @@
import { Controller, Post, Body } from '@nestjs/common';
import { GeolocationService } from './geolocation.service';
import { GeolocateDto } from './dto/geolocation.dto';
@Controller('geolocate')
export class GeolocationController {
constructor(private geolocationService: GeolocationService) {}
@Post()
reverseGeocode(@Body() dto: GeolocateDto) {
return this.geolocationService.reverseGeocode(dto);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { GeolocationController } from './geolocation.controller';
import { GeolocationService } from './geolocation.service';
@Module({
controllers: [GeolocationController],
providers: [GeolocationService],
})
export class GeolocationModule {}
+17
View File
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { GeolocateDto } from './dto/geolocation.dto';
@Injectable()
export class GeolocationService {
async reverseGeocode(dto: GeolocateDto) {
return {
formatted_address: `${dto.Latitude}, ${dto.Longitude}`,
street_number: '',
route: '',
locality: '',
political: '',
postal_code: '',
country: '',
};
}
}
+52
View File
@@ -0,0 +1,52 @@
import { IsOptional, IsString, IsNumber } from 'class-validator';
export class NewLimitDto {
@IsString()
DeviceID: string;
@IsString()
EventType: string;
@IsOptional()
@IsString()
Type?: string;
@IsNumber()
Value: number;
@IsOptional()
@IsNumber()
Active?: number;
}
export class UpdateLimitDto {
@IsOptional()
@IsString()
Type?: string;
@IsOptional()
@IsNumber()
Value?: number;
@IsOptional()
@IsNumber()
Active?: number;
}
export class FilterLimitDto {
@IsOptional()
@IsString()
DeviceID?: string;
@IsOptional()
@IsString()
EventType?: string;
@IsOptional()
@IsString()
Type?: string;
@IsOptional()
@IsNumber()
Active?: number;
}
@@ -0,0 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('device_limits')
export class DeviceLimit {
@PrimaryGeneratedColumn()
Id: number;
@Column()
DeviceID: string;
@Column()
EventType: string;
@Column({ default: 'UP' })
Type: string;
@Column({ type: 'real' })
Value: number;
@Column({ default: 1 })
Active: number;
@Column()
userId: number;
@CreateDateColumn({ type: 'datetime' })
Created: string;
@CreateDateColumn({ type: 'datetime' })
Updated: string;
}
+40
View File
@@ -0,0 +1,40 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { LimitsService } from './limits.service';
import { NewLimitDto, UpdateLimitDto, FilterLimitDto } from './dto/limit.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '../users/entities/user.entity';
@Controller('device/limit')
export class LimitsController {
constructor(private limitsService: LimitsService) {}
@Post()
create(@CurrentUser() user: User, @Body() dto: NewLimitDto) {
return this.limitsService.create(user.id, dto);
}
@Get()
findAll(@CurrentUser() user: User) {
return this.limitsService.findAll(user.id);
}
@Get(':id')
findOne(@CurrentUser() user: User, @Param('id') id: number) {
return this.limitsService.findOne(user.id, id);
}
@Put(':id')
update(@CurrentUser() user: User, @Param('id') id: number, @Body() dto: UpdateLimitDto) {
return this.limitsService.update(user.id, id, dto);
}
@Post('filter')
filter(@CurrentUser() user: User, @Body() dto: FilterLimitDto) {
return this.limitsService.filter(user.id, dto);
}
@Delete(':id')
delete(@CurrentUser() user: User, @Param('id') id: number) {
return this.limitsService.delete(user.id, id);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LimitsController } from './limits.controller';
import { LimitsService } from './limits.service';
import { DeviceLimit } from './entities/device-limit.entity';
@Module({
imports: [TypeOrmModule.forFeature([DeviceLimit])],
controllers: [LimitsController],
providers: [LimitsService],
})
export class LimitsModule {}
+73
View File
@@ -0,0 +1,73 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeviceLimit } from './entities/device-limit.entity';
import { NewLimitDto, UpdateLimitDto, FilterLimitDto } from './dto/limit.dto';
@Injectable()
export class LimitsService {
constructor(
@InjectRepository(DeviceLimit)
private limitRepo: Repository<DeviceLimit>,
) {}
private mapLimit(l: DeviceLimit) {
return {
Id: l.Id,
DeviceID: l.DeviceID,
EventType: l.EventType,
Type: l.Type,
Value: l.Value,
Active: l.Active,
Created: l.Created,
Updated: l.Updated,
};
}
async create(userId: number, dto: NewLimitDto) {
const limit = this.limitRepo.create({
...dto,
userId,
Type: dto.Type || 'UP',
Active: dto.Active ?? 1,
});
const saved = await this.limitRepo.save(limit);
return this.mapLimit(saved);
}
async findAll(userId: number) {
const limits = await this.limitRepo.find({ where: { userId } });
return limits.map((l) => this.mapLimit(l));
}
async findOne(userId: number, id: number) {
const limit = await this.limitRepo.findOne({ where: { Id: id, userId } });
if (!limit) throw new NotFoundException('Limit not found');
return this.mapLimit(limit);
}
async update(userId: number, id: number, dto: UpdateLimitDto) {
const limit = await this.limitRepo.findOne({ where: { Id: id, userId } });
if (!limit) throw new NotFoundException('Limit not found');
if (dto.Type !== undefined) limit.Type = dto.Type;
if (dto.Value !== undefined) limit.Value = dto.Value;
if (dto.Active !== undefined) limit.Active = dto.Active;
await this.limitRepo.save(limit);
return this.mapLimit(limit);
}
async filter(userId: number, dto: FilterLimitDto) {
const where: any = { userId };
if (dto.DeviceID) where.DeviceID = dto.DeviceID;
if (dto.EventType) where.EventType = dto.EventType;
if (dto.Type) where.Type = dto.Type;
if (dto.Active !== undefined) where.Active = dto.Active;
const limits = await this.limitRepo.find({ where });
return limits.map((l) => this.mapLimit(l));
}
async delete(userId: number, id: number): Promise<void> {
const result = await this.limitRepo.delete({ Id: id, userId });
if (result.affected === 0) throw new NotFoundException('Limit not found');
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { DataSource } from 'typeorm';
import { RequestLoggerInterceptor } from './common/interceptors/logger.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['log', 'error', 'warn', 'debug', 'verbose'],
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: false,
}),
);
app.useGlobalInterceptors(new RequestLoggerInterceptor());
app.setGlobalPrefix('api/v1');
app.enableCors();
const ds = app.get(DataSource);
await ds.query(
`INSERT OR IGNORE INTO device_categories (Id, Name, Image) VALUES
(1, 'Trackers', ''),
(2, 'Pets', ''),
(3, 'Keys', ''),
(4, 'Vehicles', ''),
(5, 'Luggage', '')`,
);
await app.listen(3000, '0.0.0.0');
console.log('API running on http://0.0.0.0:3000');
}
bootstrap().catch(console.error);
+20
View File
@@ -0,0 +1,20 @@
import { Controller, Get } from '@nestjs/common';
import { MiscService } from './misc.service';
import { Public } from '../common/decorators/public.decorator';
@Controller()
export class MiscController {
constructor(private miscService: MiscService) {}
@Public()
@Get('languages')
getLanguages() {
return this.miscService.getLanguages();
}
@Public()
@Get('shops')
getShops() {
return this.miscService.getShops();
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MiscController } from './misc.controller';
import { MiscService } from './misc.service';
@Module({
controllers: [MiscController],
providers: [MiscService],
})
export class MiscModule {}
+14
View File
@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class MiscService {
async getLanguages() {
return { en: 'English', cs: 'Čeština' };
}
async getShops() {
return [
{ Name: 'Fixed Shop', Image: '', Link: 'https://example.com/shop' },
];
}
}
+12
View File
@@ -0,0 +1,12 @@
import { IsBoolean, IsNumber } from 'class-validator';
export class NightModeDto {
@IsBoolean()
Allow: boolean;
@IsNumber()
From: number;
@IsNumber()
To: number;
}
@@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('night_mode')
export class NightMode {
@PrimaryGeneratedColumn()
id: number;
@Column()
userId: number;
@Column({ default: false })
Allow: boolean;
@Column({ default: 22 })
From: number;
@Column({ default: 7 })
To: number;
}
+20
View File
@@ -0,0 +1,20 @@
import { Controller, Get, Put, Body } from '@nestjs/common';
import { NightModeService } from './night-mode.service';
import { NightModeDto } from './dto/night-mode.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '../users/entities/user.entity';
@Controller('user/night-mode')
export class NightModeController {
constructor(private nightModeService: NightModeService) {}
@Get()
get(@CurrentUser() user: User) {
return this.nightModeService.get(user.id);
}
@Put()
update(@CurrentUser() user: User, @Body() dto: NightModeDto) {
return this.nightModeService.update(user.id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NightModeController } from './night-mode.controller';
import { NightModeService } from './night-mode.service';
import { NightMode } from './entities/night-mode.entity';
@Module({
imports: [TypeOrmModule.forFeature([NightMode])],
controllers: [NightModeController],
providers: [NightModeService],
})
export class NightModeModule {}
+34
View File
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NightMode } from './entities/night-mode.entity';
import { NightModeDto } from './dto/night-mode.dto';
@Injectable()
export class NightModeService {
constructor(
@InjectRepository(NightMode)
private nightModeRepo: Repository<NightMode>,
) {}
async get(userId: number) {
let nm = await this.nightModeRepo.findOne({ where: { userId } });
if (!nm) {
nm = this.nightModeRepo.create({ userId });
await this.nightModeRepo.save(nm);
}
return { Allow: nm.Allow, From: nm.From, To: nm.To };
}
async update(userId: number, dto: NightModeDto) {
let nm = await this.nightModeRepo.findOne({ where: { userId } });
if (!nm) {
nm = this.nightModeRepo.create({ userId });
}
nm.Allow = dto.Allow;
nm.From = dto.From;
nm.To = dto.To;
await this.nightModeRepo.save(nm);
return { Allow: nm.Allow, From: nm.From, To: nm.To };
}
}
+33
View File
@@ -0,0 +1,33 @@
import { IsString, IsOptional } from 'class-validator';
export class PushRegisterDto {
@IsString()
DeviceID: string;
@IsString()
Token: string;
@IsString()
Platform: string;
}
export class PushRequestBeepDto {
@IsString()
DeviceID: string;
@IsString()
SenderDeviceID: string;
}
export class PushRequestRefreshDto {
@IsString()
SenderDeviceID: string;
}
export class PushRemoteDto {
@IsString()
DeviceID: string;
@IsString()
Action: string;
}
+35
View File
@@ -0,0 +1,35 @@
import { Controller, Post, Get, Body, Query } from '@nestjs/common';
import { PushService } from './push.service';
import { PushRegisterDto, PushRequestBeepDto, PushRequestRefreshDto, PushRemoteDto } from './dto/push.dto';
import { Public } from '../common/decorators/public.decorator';
@Controller('push')
export class PushController {
constructor(private pushService: PushService) {}
@Post('register')
register(@Body() dto: PushRegisterDto) {
return this.pushService.register(dto);
}
@Post('request-beep')
requestBeep(@Body() dto: PushRequestBeepDto) {
return this.pushService.requestBeep(dto);
}
@Post('request-refresh')
requestRefresh(@Body() dto: PushRequestRefreshDto) {
return this.pushService.requestRefresh(dto);
}
@Post('remote-push')
remotePush(@Body() dto: PushRemoteDto) {
return this.pushService.remotePush(dto);
}
@Public()
@Get('test')
test(@Query('random') random: string) {
return this.pushService.test();
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PushController } from './push.controller';
import { PushService } from './push.service';
@Module({
controllers: [PushController],
providers: [PushService],
})
export class PushModule {}
+28
View File
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { PushRegisterDto, PushRequestBeepDto, PushRequestRefreshDto, PushRemoteDto } from './dto/push.dto';
@Injectable()
export class PushService {
private tokens: any[] = [];
async register(dto: PushRegisterDto) {
this.tokens.push(dto);
return dto;
}
async requestBeep(dto: PushRequestBeepDto) {
return { Count: 1, SuccessCount: 1, SuccessFailed: 0 };
}
async requestRefresh(dto: PushRequestRefreshDto) {
return { Count: 1, SuccessCount: 1, SuccessFailed: 0 };
}
async remotePush(dto: PushRemoteDto) {
return { Count: 1, SuccessCount: 1, SuccessFailed: 0 };
}
async test() {
return { Count: 1, SuccessCount: 1, SuccessFailed: 0 };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { IsString, IsOptional, IsObject } from 'class-validator';
export class RouteBodyDto {
@IsString()
DeviceID: string;
@IsOptional()
@IsObject()
DateRange?: { From?: string; To?: string };
}
+13
View File
@@ -0,0 +1,13 @@
import { Controller, Post, Body } from '@nestjs/common';
import { RouteService } from './route.service';
import { RouteBodyDto } from './dto/route.dto';
@Controller('route')
export class RouteController {
constructor(private routeService: RouteService) {}
@Post('list')
getRoutes(@Body() dto: RouteBodyDto) {
return this.routeService.getRoutes(dto);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RouteController } from './route.controller';
import { RouteService } from './route.service';
@Module({
controllers: [RouteController],
providers: [RouteService],
})
export class RouteModule {}
+9
View File
@@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
import { RouteBodyDto } from './dto/route.dto';
@Injectable()
export class RouteService {
async getRoutes(dto: RouteBodyDto) {
return [];
}
}
+34
View File
@@ -0,0 +1,34 @@
import { IsOptional, IsString, IsNumber, IsBoolean, IsObject } from 'class-validator';
import { Type } from 'class-transformer';
class SafeZoneLocationDto {
@IsNumber()
Lat: number;
@IsNumber()
Lng: number;
}
export class SafeZoneDto {
@IsOptional()
@IsString()
Name?: string;
@IsOptional()
@IsObject()
@Type(() => SafeZoneLocationDto)
Location?: SafeZoneLocationDto;
@IsOptional()
@IsNumber()
Radius?: number;
@IsOptional()
@IsString()
WifiName?: string;
}
export class SafeZoneSettingsDto {
@IsBoolean()
Enabled: boolean;
}
@@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('safe_zones')
export class SafeZone {
@PrimaryGeneratedColumn()
Id: number;
@Column()
Name: string;
@Column({ type: 'real' })
Lat: number;
@Column({ type: 'real' })
Lng: number;
@Column({ default: 100 })
Radius: number;
@Column({ nullable: true })
WifiName: string;
@Column()
userId: number;
@CreateDateColumn({ type: 'datetime' })
Created: string;
}
+57
View File
@@ -0,0 +1,57 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Query,
} from '@nestjs/common';
import { SafeZonesService } from './safe-zones.service';
import { SafeZoneDto, SafeZoneSettingsDto } from './dto/safe-zone.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '../users/entities/user.entity';
@Controller('user/safe-zone')
export class SafeZonesController {
constructor(private safeZonesService: SafeZonesService) {}
@Get()
findAll(@CurrentUser() user: User) {
return this.safeZonesService.findAll(user.id);
}
@Post()
create(@CurrentUser() user: User, @Body() dto: SafeZoneDto) {
return this.safeZonesService.create(user.id, dto);
}
@Put()
update(
@CurrentUser() user: User,
@Query('id') id: number,
@Body() dto: SafeZoneDto,
) {
return this.safeZonesService.update(user.id, id, dto);
}
@Delete()
delete(@CurrentUser() user: User, @Query('id') id: number) {
return this.safeZonesService.delete(user.id, id);
}
@Get('settings')
getSettings(@CurrentUser() user: User) {
return this.safeZonesService.getSettings(user.id);
}
@Put('settings')
updateSettings(@CurrentUser() user: User, @Body() dto: SafeZoneSettingsDto) {
return this.safeZonesService.updateSettings(user.id, dto);
}
@Get('default-radius')
getDefaultRadius() {
return this.safeZonesService.getDefaultRadius();
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SafeZonesController } from './safe-zones.controller';
import { SafeZonesService } from './safe-zones.service';
import { SafeZone } from './entities/safe-zone.entity';
import { User } from '../users/entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([SafeZone, User])],
controllers: [SafeZonesController],
providers: [SafeZonesService],
})
export class SafeZonesModule {}
+104
View File
@@ -0,0 +1,104 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SafeZone } from './entities/safe-zone.entity';
import { SafeZoneDto, SafeZoneSettingsDto } from './dto/safe-zone.dto';
import { User } from '../users/entities/user.entity';
@Injectable()
export class SafeZonesService {
constructor(
@InjectRepository(SafeZone)
private zoneRepo: Repository<SafeZone>,
@InjectRepository(User)
private userRepo: Repository<User>,
) {}
async findAll(userId: number) {
const zones = await this.zoneRepo.find({
where: { userId },
order: { Created: 'DESC' },
});
return zones.map((z) => ({
Id: z.Id,
Name: z.Name,
Location: { Lat: z.Lat, Lng: z.Lng },
Radius: z.Radius,
WifiName: z.WifiName,
Created: z.Created,
}));
}
async create(userId: number, dto: SafeZoneDto) {
const zone = this.zoneRepo.create({
Name: dto.Name || '',
Lat: dto.Location?.Lat || 0,
Lng: dto.Location?.Lng || 0,
Radius: dto.Radius || 100,
WifiName: dto.WifiName,
userId,
});
const saved = await this.zoneRepo.save(zone);
return {
Id: saved.Id,
Name: saved.Name,
Location: { Lat: saved.Lat, Lng: saved.Lng },
Radius: saved.Radius,
WifiName: saved.WifiName,
Created: saved.Created,
};
}
async update(userId: number, id: number, dto: SafeZoneDto) {
const zone = await this.zoneRepo.findOne({ where: { Id: id, userId } });
if (!zone) throw new NotFoundException('Safe zone not found');
if (dto.Name !== undefined) zone.Name = dto.Name;
if (dto.Location) {
if (dto.Location.Lat !== undefined) zone.Lat = dto.Location.Lat;
if (dto.Location.Lng !== undefined) zone.Lng = dto.Location.Lng;
}
if (dto.Radius !== undefined) zone.Radius = dto.Radius;
if (dto.WifiName !== undefined) zone.WifiName = dto.WifiName;
await this.zoneRepo.save(zone);
return {
Id: zone.Id,
Name: zone.Name,
Location: { Lat: zone.Lat, Lng: zone.Lng },
Radius: zone.Radius,
WifiName: zone.WifiName,
Created: zone.Created,
};
}
async delete(userId: number, id: number): Promise<void> {
const result = await this.zoneRepo.delete({ Id: id, userId });
if (result.affected === 0)
throw new NotFoundException('Safe zone not found');
}
async getSettings(userId: number) {
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');
return {
Enabled: user.SafeZoneEnabled,
LastUpdate: new Date(user.Created as any).toISOString(),
};
}
async updateSettings(userId: number, dto: SafeZoneSettingsDto) {
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');
user.SafeZoneEnabled = dto.Enabled;
await this.userRepo.save(user);
return {
Enabled: user.SafeZoneEnabled,
LastUpdate: new Date().toISOString(),
};
}
async getDefaultRadius() {
return { Radius: 100 };
}
}
+69
View File
@@ -0,0 +1,69 @@
import { IsOptional, IsString, IsNumber, IsBoolean } from 'class-validator';
export class NewShareDto {
@IsString()
DeviceID: string;
@IsString()
Email: string;
@IsOptional()
@IsString()
Start?: string;
@IsOptional()
@IsString()
End?: string;
@IsOptional()
@IsBoolean()
Push?: boolean;
}
export class ShareListInDto {
@IsString()
DeviceID: string;
@IsOptional()
@IsNumber()
Active?: number;
@IsOptional()
@IsString()
Type?: string;
}
export class ShareListHistoryInDto {
@IsString()
DeviceID: string;
@IsOptional()
@IsString()
Type?: string;
}
export class PutShareDto {
@IsOptional()
@IsString()
Name?: string;
@IsOptional()
@IsString()
Start?: string;
@IsOptional()
@IsString()
End?: string;
}
export class RemoveShareDto {
@IsNumber()
ID: number;
@IsString()
DeviceID: string;
@IsOptional()
@IsBoolean()
Push?: boolean;
}
+37
View File
@@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('shares')
export class Share {
@PrimaryGeneratedColumn()
ID: number;
@Column()
DeviceID: string;
@Column()
ownerId: number;
@Column()
sharedWithEmail: string;
@Column({ default: true })
Active: boolean;
@Column({ nullable: true })
Start: string;
@Column({ nullable: true })
End: string;
@Column({ nullable: true })
Name: string;
@Column({ nullable: true })
OriginalName: string;
@Column({ default: 'Me' })
Type: string;
@CreateDateColumn({ type: 'datetime' })
Created: string;
}
+35
View File
@@ -0,0 +1,35 @@
import { Controller, Post, Put, Body, Param } from '@nestjs/common';
import { SharingService } from './sharing.service';
import { NewShareDto, ShareListInDto, ShareListHistoryInDto, PutShareDto, RemoveShareDto } from './dto/share.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '../users/entities/user.entity';
@Controller('device/share')
export class SharingController {
constructor(private sharingService: SharingService) {}
@Post()
create(@CurrentUser() user: User, @Body() dto: NewShareDto) {
return this.sharingService.create(user.id, dto);
}
@Post('list')
list(@CurrentUser() user: User, @Body() dto: ShareListInDto) {
return this.sharingService.list(user.id, dto);
}
@Post('list/history')
listHistory(@CurrentUser() user: User, @Body() dto: ShareListHistoryInDto) {
return this.sharingService.listHistory(user.id, dto);
}
@Put(':id')
update(@CurrentUser() user: User, @Param('id') id: number, @Body() dto: PutShareDto) {
return this.sharingService.update(user.id, id, dto);
}
@Post('remove')
remove(@CurrentUser() user: User, @Body() dto: RemoveShareDto) {
return this.sharingService.remove(user.id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SharingController } from './sharing.controller';
import { SharingService } from './sharing.service';
import { Share } from './entities/share.entity';
@Module({
imports: [TypeOrmModule.forFeature([Share])],
controllers: [SharingController],
providers: [SharingService],
})
export class SharingModule {}
+74
View File
@@ -0,0 +1,74 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Share } from './entities/share.entity';
import { NewShareDto, ShareListInDto, ShareListHistoryInDto, PutShareDto, RemoveShareDto } from './dto/share.dto';
@Injectable()
export class SharingService {
constructor(
@InjectRepository(Share)
private shareRepo: Repository<Share>,
) {}
private mapShare(s: Share) {
return {
ID: s.ID,
DeviceID: s.DeviceID,
Active: s.Active,
Created: s.Created,
Start: s.Start,
End: s.End,
Name: s.Name || '',
OriginalName: s.OriginalName || '',
Type: s.Type,
ShareFrom: { ID: s.ownerId, Email: '' },
ShareTo: { ID: 0, Email: s.sharedWithEmail },
};
}
async create(userId: number, dto: NewShareDto) {
const share = this.shareRepo.create({
DeviceID: dto.DeviceID,
ownerId: userId,
sharedWithEmail: dto.Email,
Start: dto.Start,
End: dto.End,
Name: '',
OriginalName: '',
Type: 'Me',
});
const saved = await this.shareRepo.save(share);
return this.mapShare(saved);
}
async list(userId: number, dto: ShareListInDto) {
const where: any = { DeviceID: dto.DeviceID, ownerId: userId };
if (dto.Active !== undefined) where.Active = dto.Active === 1;
if (dto.Type) where.Type = dto.Type;
const shares = await this.shareRepo.find({ where });
return shares.map((s) => this.mapShare(s));
}
async listHistory(userId: number, dto: ShareListHistoryInDto) {
const where: any = { DeviceID: dto.DeviceID, ownerId: userId };
if (dto.Type) where.Type = dto.Type;
const shares = await this.shareRepo.find({ where });
return shares.map((s) => this.mapShare(s));
}
async update(userId: number, id: number, dto: PutShareDto) {
const share = await this.shareRepo.findOne({ where: { ID: id, ownerId: userId } });
if (!share) throw new NotFoundException('Share not found');
if (dto.Name !== undefined) share.Name = dto.Name;
if (dto.Start !== undefined) share.Start = dto.Start;
if (dto.End !== undefined) share.End = dto.End;
await this.shareRepo.save(share);
return this.mapShare(share);
}
async remove(userId: number, dto: RemoveShareDto): Promise<void> {
const result = await this.shareRepo.delete({ ID: dto.ID, DeviceID: dto.DeviceID, ownerId: userId });
if (result.affected === 0) throw new NotFoundException('Share not found');
}
}
+6
View File
@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class EmailModelDto {
@IsEmail()
email: string;
}
+23
View File
@@ -0,0 +1,23 @@
import { IsOptional, IsString, IsBoolean } from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
surname?: string;
@IsOptional()
@IsString()
password?: string;
@IsOptional()
@IsBoolean()
TermsAndConditions?: boolean;
@IsOptional()
@IsBoolean()
PrivacyPolicy?: boolean;
}
+39
View File
@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column({ default: '' })
name: string;
@Column({ default: '' })
surname: string;
@Column({ default: false })
TermsAndConditions: boolean;
@Column({ default: false })
PrivacyPolicy: boolean;
@Column({ default: 0 })
facebook_id: number;
@Column({ default: true })
SafeZoneEnabled: boolean;
@CreateDateColumn({ type: 'datetime' })
Created: string;
}
+43
View File
@@ -0,0 +1,43 @@
import {
Controller,
Get,
Put,
Delete,
Post,
Body,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { EmailModelDto } from './dto/email-model.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from './entities/user.entity';
@Controller('user')
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
getProfile(@CurrentUser() user: User) {
return this.usersService.getProfile(user.id);
}
@Put()
updateProfile(@CurrentUser() user: User, @Body() dto: UpdateUserDto) {
return this.usersService.updateProfile(user.id, dto);
}
@Delete('account')
deleteAccount(@CurrentUser() user: User) {
return this.usersService.deleteAccount(user.id);
}
@Get('backup')
getBackup() {
return { FileName: 'backup.json', Expire: new Date(Date.now() + 7 * 86400000).toISOString() };
}
@Post('email')
changeEmail(@CurrentUser() user: User, @Body() dto: EmailModelDto) {
return this.usersService.changeEmail(user.id, dto);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+60
View File
@@ -0,0 +1,60 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { UpdateUserDto } from './dto/update-user.dto';
import { EmailModelDto } from './dto/email-model.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
) {}
async getProfile(userId: number) {
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');
return {
email: user.email,
name: user.name,
surname: user.surname,
TermsAndConditions: user.TermsAndConditions,
PrivacyPolicy: user.PrivacyPolicy,
Created: new Date(user.Created as any).toISOString(),
SafeZoneSettings: {
Enabled: user.SafeZoneEnabled,
LastUpdate: new Date(user.Created as any).toISOString(),
},
};
}
async updateProfile(userId: number, dto: UpdateUserDto) {
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');
if (dto.name !== undefined) user.name = dto.name;
if (dto.surname !== undefined) user.surname = dto.surname;
if (dto.TermsAndConditions !== undefined)
user.TermsAndConditions = dto.TermsAndConditions;
if (dto.PrivacyPolicy !== undefined) user.PrivacyPolicy = dto.PrivacyPolicy;
if (dto.password) user.password = await bcrypt.hash(dto.password, 10);
await this.userRepo.save(user);
return this.getProfile(userId);
}
async deleteAccount(userId: number): Promise<void> {
const result = await this.userRepo.delete(userId);
if (result.affected === 0) throw new NotFoundException('User not found');
}
async changeEmail(userId: number, dto: EmailModelDto) {
const user = await this.userRepo.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');
user.email = dto.email;
await this.userRepo.save(user);
return user;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}