diff --git a/src/db/migrations/000_initial.ts b/src/db/migrations/000_initial.ts index 7b65a68..baf2688 100644 --- a/src/db/migrations/000_initial.ts +++ b/src/db/migrations/000_initial.ts @@ -207,9 +207,9 @@ async function createUserSessionsTable(query: { context: any }) { async function createUserDevicesTable(query: { context: any }) { await query.context.createTable($db.tableName('user_devices'), { id: { - type: DataTypes.INTEGER, + type: DataTypes.UUID, primaryKey: true, - autoIncrement: true + defaultValue: () => uuidv4(), }, userId: { type: DataTypes.INTEGER, diff --git a/src/db/types/UserDevice.ts b/src/db/types/UserDevice.ts index 678396b..44540c2 100644 --- a/src/db/types/UserDevice.ts +++ b/src/db/types/UserDevice.ts @@ -1,11 +1,12 @@ import { DataTypes, } from 'sequelize'; +import { v4 as uuidv4 } from 'uuid'; function UserDevice(): Record<string, any> { return { id: { - type: DataTypes.INTEGER, + type: DataTypes.UUID, primaryKey: true, - autoIncrement: true + defaultValue: () => uuidv4(), }, userId: { type: DataTypes.INTEGER, diff --git a/src/models/User.ts b/src/models/User.ts index 72f92dd..9bba38f 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -4,6 +4,7 @@ import { Op } from 'sequelize'; import { Optional } from '~/types'; import Role from './Role'; import RoleName from './RoleName'; +import UserDevice from './UserDevice'; class User { public id: number; @@ -50,6 +51,16 @@ class User { ).map((role) => new Role(role.dataValues)); } + public async devices(): Promise<UserDevice[]> { + return ( + await $db.UserDevice().findAll({ + where: { + userId: this.id, + }, + }) + ).map((device) => new UserDevice(device.dataValues)); + } + public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> { const inputRoleIds = new Set( roles.filter((role) => { diff --git a/src/models/UserDevice.ts b/src/models/UserDevice.ts new file mode 100644 index 0000000..fefd3c5 --- /dev/null +++ b/src/models/UserDevice.ts @@ -0,0 +1,48 @@ +import { Optional } from '~/types'; + +class UserDevice { + public id: string; + public userId: number; + public name: string; + public createdAt: Optional<Date>; + + constructor({ + id, + userId, + name, + createdAt = null, + }: { + id: string; + userId: number; + name: string; + createdAt?: Optional<Date>; + }) { + this.id = id; + this.name = name; + this.userId = userId; + this.createdAt = createdAt ? new Date(createdAt) : null; + } + + public async destroy() { + await $db.UserDevice().destroy({ + where: { + id: this.id, + }, + }); + } + + public async save() { + await $db.UserDevice().update( + { + name: this.name, + }, + { + where: { + id: this.id, + }, + } + ); + } +} + +export default UserDevice; diff --git a/src/models/UserSession.ts b/src/models/UserSession.ts index 5513bff..5672108 100644 --- a/src/models/UserSession.ts +++ b/src/models/UserSession.ts @@ -5,17 +5,20 @@ import { Optional } from '~/types'; class UserSession { public id: string; public userId: number; + public name?: Optional<string>; public expiresAt: Optional<Date>; public createdAt: Optional<Date>; constructor({ id, userId, + name, expiresAt = null, createdAt = null, }: any) { this.id = id; this.userId = userId; + this.name = name; this.expiresAt = expiresAt; this.createdAt = createdAt; diff --git a/src/models/index.ts b/src/models/index.ts index 363374a..883ff14 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,6 +2,7 @@ import GPSPoint from "./GPSPoint"; import Role from "./Role"; import RoleName from "./RoleName"; import User from "./User"; +import UserDevice from "./UserDevice"; import UserSession from "./UserSession"; export { @@ -9,5 +10,6 @@ export { Role, RoleName, User, + UserDevice, UserSession, }; diff --git a/src/repos/UserDevices.ts b/src/repos/UserDevices.ts new file mode 100644 index 0000000..3497950 --- /dev/null +++ b/src/repos/UserDevices.ts @@ -0,0 +1,32 @@ +import { Optional } from '~/types'; +import { UserDevice } from '../models'; + +class UserDevices { + public async get(deviceId: string): Promise<Optional<UserDevice>> { + let dbDevice = await $db.UserDevice().findByPk(deviceId) + if (!dbDevice) { + dbDevice = await $db.UserDevice().findOne({ + where: { name: deviceId } + }); + + if (!dbDevice) { + return null; + } + } + + return new UserDevice(dbDevice.dataValues); + } + + public async create(name: string, args: { + userId: number, + }): Promise<UserDevice> { + const device = await $db.UserDevice().create({ + userId: args.userId, + name: name, + }); + + return new UserDevice(device.dataValues); + } +} + +export default UserDevices; diff --git a/src/repos/index.ts b/src/repos/index.ts index 7dced50..36c9875 100644 --- a/src/repos/index.ts +++ b/src/repos/index.ts @@ -1,17 +1,20 @@ import Location from './Location'; import Users from './Users'; +import UserDevices from './UserDevices'; import UserRoles from './UserRoles'; import UserSessions from './UserSessions'; class Repositories { public location: Location; public users: Users; + public userDevices: UserDevices; public userRoles: UserRoles; public userSessions: UserSessions; constructor() { this.location = new Location(); this.users = new Users(); + this.userDevices = new UserDevices(); this.userRoles = new UserRoles(); this.userSessions = new UserSessions(); } diff --git a/src/routes/Route.ts b/src/routes/Route.ts index 55c8ffa..81da82d 100644 --- a/src/routes/Route.ts +++ b/src/routes/Route.ts @@ -1,4 +1,5 @@ import { Express, Request, Response } from 'express'; +import { UniqueConstraintError, ValidationError } from 'sequelize'; import { BadRequest, @@ -50,7 +51,14 @@ abstract class Route { return; } - if (error instanceof BadRequest) { + if (error instanceof UniqueConstraintError) { + res.status(400).json({ + error: `A record with the same [${Object.values(error.fields).join(', ')}] already exists`, + }); + return; + } + + if (error instanceof BadRequest || error instanceof ValidationError) { res.status(400).json({ error: error.message, }); @@ -66,17 +74,18 @@ abstract class Route { handlerName: string, ) => { logRequest(req); - // @ts-expect-error - const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>); - const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName]; try { + // @ts-expect-error + const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>); + const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName]; + let authInfo: Optional<AuthInfo> if (preRequestHandler) { authInfo = await preRequestHandler(req, res); } - handler(req, res, authInfo!); + await handler(req, res, authInfo!); } catch (error) { (<typeof Route> this.constructor).handleError(req, res, error); } diff --git a/src/routes/api/v1/Devices.ts b/src/routes/api/v1/Devices.ts new file mode 100644 index 0000000..38a60d1 --- /dev/null +++ b/src/routes/api/v1/Devices.ts @@ -0,0 +1,47 @@ +import { Optional } from '../../../types'; +import { Request, Response } from 'express'; + +import ApiV1Route from './Route'; +import { AuthInfo, authenticate } from '../../../auth'; +import { BadRequest } from '../../../errors'; + +class Devices extends ApiV1Route { + constructor() { + super('/devices'); + } + + /** + * GET /devices + * + * It returns a JSON object of devices associated with the authenticated user, + * in the format `{ devices: UserDevice[] }`. + */ + @authenticate() + get = async (_: Request, res: Response, auth: Optional<AuthInfo>) => { + res.json({ + devices: (await auth!.user.devices()), + }); + } + + /** + * POST /devices + * + * It creates a new device associated with the authenticated user. + * It expects a JSON object with the following properties: + * - `name` (string): The name of the device. + * It must be unique for the user. + */ + @authenticate() + post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + const { name } = req.body; + + if (!name) { + throw new BadRequest('Missing name'); + } + + const device = await $repos.userDevices.create(name, { userId: auth!.user.id }); + res.json(device); + } +} + +export default Devices; diff --git a/src/routes/api/v1/DevicesById.ts b/src/routes/api/v1/DevicesById.ts new file mode 100644 index 0000000..5ecfcfe --- /dev/null +++ b/src/routes/api/v1/DevicesById.ts @@ -0,0 +1,79 @@ +import { Optional } from '../../../types'; +import { Request, Response } from 'express'; + +import ApiV1Route from './Route'; +import { AuthInfo, authenticate } from '../../../auth'; +import { Forbidden, Unauthorized } from '../../../errors'; +import { RoleName, UserDevice } from '../../../models'; + +class DevicesById extends ApiV1Route { + constructor() { + super('/devices/:deviceId'); + } + + private async authenticatedFetch(auth: AuthInfo, deviceId: string): Promise<UserDevice> { + const device = await $repos.userDevices.get(deviceId); + + if (!device) { + // Throw a 403 instead of a 404 to avoid leaking information + throw new Forbidden('You do not have access to this device'); + } + + if (device.userId !== auth!.user.id) { + try { + authenticate([RoleName.Admin]); + } catch (e) { + throw new Unauthorized('You do not have access to this device'); + } + } + + return device; + } + + /** + * GET /devices/:deviceId + * + * It returns a JSON object with the requested device. + * Note that the device must be associated with the authenticated user, + * unless the user is an admin. + */ + @authenticate() + get = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + res.json(await this.authenticatedFetch(auth!, req.params.deviceId)); + } + + /** + * PATCH /devices/:deviceId + * + * It updates the requested device. + * Note that the device must be associated with the authenticated user, + * unless the user is an admin. + */ + @authenticate() + patch = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + const device = await this.authenticatedFetch(auth!, req.params.deviceId); + const { name } = req.body; + if (name) { + device.name = name; + } + + await device.save(); + res.json(device); + } + + /** + * DELETE /devices/:deviceId + * + * It deletes the requested device. + * Note that the device must be associated with the authenticated user, + * unless the user is an admin. + */ + @authenticate() + delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + const device = await this.authenticatedFetch(auth!, req.params.deviceId); + await device.destroy(); + res.status(204).send(); + } +} + +export default DevicesById; diff --git a/src/routes/api/v1/index.ts b/src/routes/api/v1/index.ts index 3832ced..e0f345c 100644 --- a/src/routes/api/v1/index.ts +++ b/src/routes/api/v1/index.ts @@ -1,4 +1,6 @@ import Auth from "./Auth"; +import Devices from "./Devices"; +import DevicesById from "./DevicesById"; import GPSData from "./GPSData"; import Routes from "../../Routes"; import UserSelf from "./UserSelf"; @@ -6,6 +8,8 @@ import UserSelf from "./UserSelf"; class ApiV1Routes extends Routes { public routes = [ new Auth(), + new Devices(), + new DevicesById(), new GPSData(), new UserSelf(), ];