From 18c35ac8b988682da40648f812983eb2bc94969b Mon Sep 17 00:00:00 2001 From: Alex Mikhalev Date: Tue, 4 Sep 2018 14:46:26 -0600 Subject: [PATCH] feat: Add user command to manage users --- common/ErrorCode.ts | 4 +- common/tsconfig.json | 2 +- server/Database.ts | 20 ++- server/ManageCommand.ts | 21 +++ server/UniqueConstraintError.ts | 14 ++ server/commands/manage.ts | 11 -- server/commands/user.ts | 129 ++++++++++++++++++ server/entities/User.ts | 9 +- server/index.ts | 1 + .../SprinklersDeviceRepository.ts | 16 ++- server/repositories/UserRepository.ts | 37 +++-- server/state.ts | 13 +- 12 files changed, 242 insertions(+), 35 deletions(-) create mode 100644 server/ManageCommand.ts create mode 100644 server/UniqueConstraintError.ts delete mode 100644 server/commands/manage.ts create mode 100644 server/commands/user.ts diff --git a/common/ErrorCode.ts b/common/ErrorCode.ts index a564c56..4e98158 100644 --- a/common/ErrorCode.ts +++ b/common/ErrorCode.ts @@ -7,9 +7,10 @@ export enum ErrorCode { BadToken = 105, Unauthorized = 106, NoPermission = 107, - NotImplemented = 108, NotFound = 109, + NotUnique = 110, Internal = 200, + NotImplemented = 201, Timeout = 300, ServerDisconnected = 301, BrokerDisconnected = 302 @@ -22,6 +23,7 @@ export function toHttpStatus(errorCode: ErrorCode): number { case ErrorCode.Parse: case ErrorCode.Range: case ErrorCode.InvalidData: + case ErrorCode.NotUnique: return 400; // Bad request case ErrorCode.Unauthorized: case ErrorCode.BadToken: diff --git a/common/tsconfig.json b/common/tsconfig.json index d4efc09..01c44ac 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -14,6 +14,6 @@ } }, "include": [ - "./**/*.ts" + "./**/*.ts", ] } \ No newline at end of file diff --git a/server/Database.ts b/server/Database.ts index 905de32..d46c206 100644 --- a/server/Database.ts +++ b/server/Database.ts @@ -3,7 +3,7 @@ import { Connection, createConnection, getConnectionOptions } from "typeorm"; import logger from "@common/logger"; -import { User } from "./entities"; +import { SprinklersDevice, User } from "./entities"; import { SprinklersDeviceRepository, UserRepository } from "./repositories/"; export class Database { @@ -24,16 +24,21 @@ export class Database { Object.assign(options, { entities: [path.resolve(__dirname, "entities", "*.js")] }); + if (options.synchronize) { + logger.warn("synchronizing database schema"); + } this._conn = await createConnection(options); this.users = this._conn.getCustomRepository(UserRepository); this.sprinklersDevices = this._conn.getCustomRepository( SprinklersDeviceRepository ); + logger.info("connected to database"); } async disconnect() { if (this._conn) { - return this._conn.close(); + await this._conn.close(); + logger.info("disconnected from database"); } } @@ -41,18 +46,20 @@ export class Database { const NUM = 100; const users: User[] = []; for (let i = 0; i < NUM; i++) { - const username = "alex" + i; + const username = "alex" + i % 50; let user = await this.users.findByUsername(username); - if (!user) { + // if (!user) { user = await this.users.create({ name: "Alex Mikhalev" + i, username }); - } + // } await user.setPassword("kakashka" + i); users.push(user); } + const devices: SprinklersDevice[] = []; + for (let i = 0; i < NUM; i++) { const name = "Test" + i; let device = await this.sprinklersDevices.findByName(name); @@ -63,13 +70,14 @@ export class Database { name, deviceId: "grinklers" + (i === 1 ? "" : i) }); - await this.sprinklersDevices.save(device); + devices.push(device); for (let j = 0; j < 5; j++) { const userIdx = (i + j * 10) % NUM; const user = users[userIdx]; user.devices = (user.devices || []).concat([device]); } } + await this.sprinklersDevices.save(devices); logger.info("inserted/updated devices"); await this.users.save(users); diff --git a/server/ManageCommand.ts b/server/ManageCommand.ts new file mode 100644 index 0000000..9505688 --- /dev/null +++ b/server/ManageCommand.ts @@ -0,0 +1,21 @@ +import Command from "@oclif/command"; + +import { Database, ServerState } from "."; + +export default abstract class ManageCommand extends Command { + state!: ServerState; + database!: Database; + + async connect() { + this.state = new ServerState(); + await this.state.startDatabase(); + this.database = this.state.database; + } + + async finally(e: Error | undefined) { + if (this.state) { + await this.state.stopDatabase(); + } + await super.finally(e); + } +} diff --git a/server/UniqueConstraintError.ts b/server/UniqueConstraintError.ts new file mode 100644 index 0000000..75ffe17 --- /dev/null +++ b/server/UniqueConstraintError.ts @@ -0,0 +1,14 @@ +import { QueryFailedError } from "typeorm"; + +import ApiError from "@common/ApiError"; +import { ErrorCode } from "@common/ErrorCode"; + +export default class UniqueConstraintError extends ApiError { + static is(err: any): err is QueryFailedError { + return err && err.name === "QueryFailedError" && (err as any).code === 23505; // unique constraint error + } + + constructor(err: QueryFailedError) { + super(`Unsatisfied unique constraint: ${(err as any).detail}`, ErrorCode.NotUnique, err); + } +} diff --git a/server/commands/manage.ts b/server/commands/manage.ts deleted file mode 100644 index a98f627..0000000 --- a/server/commands/manage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Command from "@oclif/command"; - -import { createApp, ServerState, WebSocketApi } from "../"; - -import log from "@common/logger"; - -export default class ManageCommand extends Command { - run(): Promise { - throw new Error("Method not implemented."); - } -} diff --git a/server/commands/user.ts b/server/commands/user.ts new file mode 100644 index 0000000..c96fe3a --- /dev/null +++ b/server/commands/user.ts @@ -0,0 +1,129 @@ +import { flags } from "@oclif/command"; +import { ux } from "cli-ux"; +import { capitalize } from "lodash"; +import { FindConditions } from "typeorm"; + +import ManageCommand from "../ManageCommand"; + +import { Input } from "@oclif/parser/lib/flags"; +import { User } from "@server/entities"; + +type UserFlags = (typeof UserCommand)["flags"] extends Input + ? F + : never; + +type Action = "create" | "update" | "delete"; + +export default class UserCommand extends ManageCommand { + static description = "Manage users"; + + static flags = { + create: flags.boolean({ + char: "c", + exclusive: ["update", "delete", "id"], + dependsOn: ["username"], + description: "Create a new user" + }), + update: flags.boolean({ + char: "u", + exclusive: ["create", "delete"], + description: "Update an existing user (by --id or --username)" + }), + delete: flags.boolean({ + char: "d", + exclusive: ["create", "update"], + description: "Delete a user (by --id or --username)" + }), + id: flags.integer({ + description: "The id of the user to update or delete", + }), + username: flags.string({ + description: "The username of the user to create or update" + }), + name: flags.string({ + description: "The name of the user, when creating or updating" + }), + passwordPrompt: flags.boolean({ + char: "p", + description: + "Prompts for the password of the user when creating or updating" + }) + }; + + getAction(flags: UserFlags): Action { + if (flags.create) return "create"; + else if (flags.update) return "update"; + else if (flags.delete) return "delete"; + else { + this.error("Must specify an action (--create, --update, --delete)", { + exit: false + }); + return this._help(); + } + } + + getFindConditions(flags: UserFlags, action: Action): FindConditions { + if (flags.id != null) { + return { id: flags.id }; + } else if (flags.username) { + return { username: flags.username }; + } else { + this.error(`Must specify either --id or --username to ${action}`, { + exit: false + }); + return this._help(); + } + } + + async getOrDeleteUser(flags: UserFlags, action: Action): Promise { + if (action === "create") { + return this.database.users.create(); + } else { + const findConditions = this.getFindConditions(flags, action); + if (action === "delete") { + this.log("findConditions: ", findConditions) + const result = await this.database.users.delete(findConditions); + this.log(`Deleted user: `, result); + return this.exit(); + } else { + const user = await this.database.users.findOneUser(findConditions); + if (!user) { + return this.error(`The specified user does not exist`); + } + return user; + } + } + } + + async run() { + const parseResult = this.parse(UserCommand); + + const flags = parseResult.flags; + const action = this.getAction(flags); + + await this.connect(); + + const user = await this.getOrDeleteUser(flags, action); + + if (flags.id != null && flags.username) { + user.username = flags.username; + } + if (flags.name) { + user.name = flags.name; + } + if (flags.passwordPrompt || flags.create) { + const password = await ux.prompt("Enter a password to assign the user", { + type: "hide" + }); + await user.setPassword(password); + } + + try { + await this.database.users.save(user); + this.log(`${capitalize(action)}d user id ${user.id} (${user.username})`); + } catch (e) { + console.log(e) + throw e; + } + } +} diff --git a/server/entities/User.ts b/server/entities/User.ts index a34c839..887675f 100644 --- a/server/entities/User.ts +++ b/server/entities/User.ts @@ -5,7 +5,11 @@ import { Entity, JoinTable, ManyToMany, - PrimaryGeneratedColumn + PrimaryGeneratedColumn, + BeforeInsert, + BeforeUpdate, + Index, + InsertEvent } from "typeorm"; import { IUser } from "@common/httpApi"; @@ -18,7 +22,8 @@ export class User implements IUser { @PrimaryGeneratedColumn() id!: number; - @Column({ unique: true }) + @Column() + @Index("user_username_unique", { unique: true }) username: string = ""; @Column() diff --git a/server/index.ts b/server/index.ts index 4c73b3d..3960206 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,5 +5,6 @@ import "./env"; import "./configureLogger"; export { ServerState } from "./state"; +export { Database } from "./Database"; export { createApp } from "./express"; export { WebSocketApi } from "./sprinklersRpc/"; diff --git a/server/repositories/SprinklersDeviceRepository.ts b/server/repositories/SprinklersDeviceRepository.ts index 9f0b9b3..f309e23 100644 --- a/server/repositories/SprinklersDeviceRepository.ts +++ b/server/repositories/SprinklersDeviceRepository.ts @@ -1,6 +1,7 @@ -import { EntityRepository, Repository } from "typeorm"; +import { DeepPartial, EntityRepository, Repository, SaveOptions } from "typeorm"; import { SprinklersDevice, User } from "@server/entities"; +import UniqueConstraintError from "@server/UniqueConstraintError"; @EntityRepository(SprinklersDevice) export class SprinklersDeviceRepository extends Repository { @@ -39,4 +40,17 @@ export class SprinklersDeviceRepository extends Repository { } return user.devices![0]; } + + save>(entities: T[], options?: SaveOptions): Promise; + save>(entity: T, options?: SaveOptions): Promise; + async save(entity: any, options?: SaveOptions): Promise { + try { + return await super.save(entity, options); + } catch (e) { + if (UniqueConstraintError.is(e)) { + throw new UniqueConstraintError(e); + } + throw e; + } + } } diff --git a/server/repositories/UserRepository.ts b/server/repositories/UserRepository.ts index 330bbe1..800d37f 100644 --- a/server/repositories/UserRepository.ts +++ b/server/repositories/UserRepository.ts @@ -1,33 +1,50 @@ -import { EntityRepository, FindOneOptions, Repository } from "typeorm"; +import { EntityRepository, FindOneOptions, Repository, DeepPartial, FindConditions, SaveOptions } from "typeorm"; +import ApiError from "@common/ApiError"; import { User } from "@server/entities"; -export interface FindUserOptions { +export interface FindUserOptions extends FindOneOptions { devices: boolean; } -function applyDefaultOptions( +function computeOptions( options?: Partial ): FindOneOptions { const opts: FindUserOptions = { devices: false, ...options }; - const relations = [opts.devices && "devices"].filter(Boolean) as string[]; - return { relations }; + const { devices, ...rest } = opts; + const relations = [devices && "devices"].filter(Boolean) as string[]; + return { relations, ...rest }; } @EntityRepository(User) export class UserRepository extends Repository { findAll(options?: Partial) { - const opts = applyDefaultOptions(options); + const opts = computeOptions(options); return super.find(opts); } + findOneUser(conditions: FindConditions, options?: Partial) { + const opts = computeOptions(options); + return super.findOne(conditions, opts); + } + findById(id: number, options?: Partial) { - const opts = applyDefaultOptions(options); - return super.findOne(id, opts); + return this.findOneUser({ id }, options); } findByUsername(username: string, options?: Partial) { - const opts = applyDefaultOptions(options); - return this.findOne({ username }, opts); + return this.findOneUser({ username }, options); } + + // async checkAndSave(entity: User): Promise { + // return this.manager.transaction(manager => { + // let query = manager.createQueryBuilder(User, "user", manager.queryRunner!); + // if (entity.id != null) { + // query = query.where("user.id <> :id", { id: entity.id }) + // } + // query + + // return manager.save(entity); + // }); + // } } diff --git a/server/state.ts b/server/state.ts index ba0efdb..0e6bca2 100644 --- a/server/state.ts +++ b/server/state.ts @@ -23,13 +23,20 @@ export class ServerState { async startDatabase() { await this.database.connect(); - logger.info("connected to database"); if (process.env.INSERT_TEST_DATA) { - await this.database.insertTestData(); - logger.info("inserted test data"); + try { + await this.database.insertTestData(); + logger.info("inserted test data"); + } catch (e) { + logger.error(e, "error inserting test data"); + } } } + + async stopDatabase() { + await this.database.disconnect(); + } async startMqtt() { this.mqttClient.username = SUPERUSER;