Skip to content

Commit

Permalink
Merge pull request #15 from icefoganalytics/issue-12/add-basic-login-…
Browse files Browse the repository at this point in the history
…setup-to-app

Add Basic Login Setup To App
  • Loading branch information
klondikemarlen authored Jan 16, 2024
2 parents f0f7eb0 + 93cfbc6 commit 10baa80
Show file tree
Hide file tree
Showing 30 changed files with 1,237 additions and 41 deletions.
78 changes: 78 additions & 0 deletions api/src/controllers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Controllers

These files map api routes to services.
See https://guides.rubyonrails.org/routing.html#crud-verbs-and-actions

e.g.

```typescript
router.route("/api/forms").post(FormsController.create)
```

maps the `/api/forms` POST endpoint to the `FormsController#create` instance method.

Controllers are advantageous because they provide a suite of helper methods to access various request methods. .e.g. `currentUser`, or `params`. They also provide a location to perform policy checks.

Controllers should implement the BaseController, and provide instance methods.
The `BaseController` provides the magic that lets those methods map to an appropriate route.

## Namespacing

If you need an action that generates estimates for a given form, a POST route `/api/forms/:formId/estimates/generate` is the best way to avoid future conflicts and refactors. To implement this you need to "namespace/modularize" the controller. Generally speaking, it is more flexible to keep all routes as CRUD actions, and nest controllers as needed, than it is to add custom routes to a given controller.

e.g. `Forms.Estimates.GenerateController.create` is preferred to `FormsController#generateEstimates` because once you start using non-CRUD actions, your controllers will quickly expand beyond human readability and comprehension. Opting to use PascalCase for namespaces as that is the best way to avoid conflicts with local variables.

This is how you would create a namespaced controller:

```bash
api/
|-- src/
| |-- controllers/
| |-- forms/
| |-- estimates/
| |-- generate-controller.ts
| |-- index.ts
```

```typescript
// api/src/controllers/forms/estimates/generate-controller.ts
import BaseController from "@/base-controller"

export class GenerateController extends BaseController {
async static create() {
// Logic for generating estimates here...
}
}
```

```typescript
// api/src/controllers/forms/estimates/index.ts
export * from "./generate-controller"

export default undefined
```

```typescript
// api/src/controllers/forms/index.ts
import * as Estimates from "./estimates"

export { Estimates }
```

```typescript
// api/src/controllers/index.ts
import * as Forms from "./forms"

export { Forms }
```

```typescript
// api/src/routes/index.ts
import { Router } from "express"

import { Forms } from "@/controllers"

const router = Router()

router.route("/api/forms/:formId/estimates/generate").post(Forms.Estimates.GenerateController.create)
```
116 changes: 116 additions & 0 deletions api/src/controllers/base-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { NextFunction, Request, Response } from "express"

import User from "@/models/user"

export type Actions = "index" | "show" | "new" | "edit" | "create" | "update" | "destroy"

type ControllerRequest = Request & {
currentUser: User
}

// See https://guides.rubyonrails.org/routing.html#crud-verbs-and-actions
export class BaseController {
protected request: ControllerRequest
protected response: Response
protected next: NextFunction

constructor(req: Request, res: Response, next: NextFunction) {
// Assumes authorization has occured first in
// api/src/middlewares/jwt-middleware.ts and api/src/middlewares/authorization-middleware.ts
// At some future point it would make sense to do all that logic as
// controller actions to avoid the need for hack
this.request = req as ControllerRequest
this.response = res
this.next = next
}

static get index() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.index().catch(next)
}
}

// Usage app.post("/api/users", UsersController.create)
// maps /api/users to UsersController#create()
static get create() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.create().catch(next)
}
}

static get show() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.show().catch(next)
}
}

static get update() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.update().catch(next)
}
}

static get destroy() {
return async (req: Request, res: Response, next: NextFunction) => {
const controllerInstance = new this(req, res, next)
return controllerInstance.destroy().catch(next)
}
}

index(): Promise<any> {
throw new Error("Not Implemented")
}

create(): Promise<any> {
throw new Error("Not Implemented")
}

show(): Promise<any> {
throw new Error("Not Implemented")
}

update(): Promise<any> {
throw new Error("Not Implemented")
}

destroy(): Promise<any> {
throw new Error("Not Implemented")
}

// Internal helpers

// This should have been loaded in the authorization middleware
// Currently assuming that authorization happens before this controller gets called.
// Child controllers that are on an non-authorizable route should override this method
// and return undefined
get currentUser(): User {
return this.request.currentUser
}

get params() {
return this.request.params
}

get query() {
return this.request.query
}

get pagination() {
const page = parseInt(this.query.page?.toString() || "") || 1
const perPage = parseInt(this.query.perPage?.toString() || "") || 10
const limit = Math.max(10, Math.min(perPage, 1000)) // restrict max limit to 1000 for safety
const offset = (page - 1) * limit
return {
page,
perPage,
limit,
offset,
}
}
}

export default BaseController
13 changes: 13 additions & 0 deletions api/src/controllers/current-user-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import BaseController from "./base-controller"

import { UserSerializers } from "@/serializers"

export class CurrentUserController extends BaseController {
async show() {
// TODO: consider changing interface to Users.AsDetailedSerializer.perform()?
const serializedUser = UserSerializers.asDetailed(this.currentUser)
return this.response.status(200).json({ user: serializedUser })
}
}

export default CurrentUserController
1 change: 1 addition & 0 deletions api/src/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CurrentUserController } from "./current-user-controller"
1 change: 1 addition & 0 deletions api/src/db/db-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const SEQUELIZE_CONFIG: Options = {
define: {
underscored: true,
timestamps: true, // This is actually the default, but making it explicit for clarity.
paranoid: true, // adds deleted_at column
},
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { DataTypes } from "sequelize"

import type { Migration } from "@/db/umzug"
import { MssqlSimpleTypes } from "@/db/utils/mssql-simple-types"

export const up: Migration = async ({ context: queryInterface }) => {
await queryInterface.createTable("users", {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
allowNull: false,
autoIncrement: true,
},
email: {
type: DataTypes.STRING(100),
allowNull: false,
},
auth0_subject: {
type: DataTypes.STRING(100),
allowNull: false,
},
first_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
last_name: {
type: DataTypes.STRING(100),
allowNull: true,
},
position: {
type: DataTypes.STRING(100),
allowNull: true,
},
department: {
type: DataTypes.STRING(100),
allowNull: true,
},
division: {
type: DataTypes.STRING(100),
allowNull: true,
},
branch: {
type: DataTypes.STRING(100),
allowNull: true,
},
unit: {
type: DataTypes.STRING(100),
allowNull: true,
},
created_at: {
type: MssqlSimpleTypes.DATETIME2(0),
allowNull: false,
defaultValue: MssqlSimpleTypes.NOW,
},
updated_at: {
type: MssqlSimpleTypes.DATETIME2(0),
allowNull: false,
defaultValue: MssqlSimpleTypes.NOW,
},
deleted_at: {
type: MssqlSimpleTypes.DATETIME2(0),
allowNull: true,
},
})

await queryInterface.addIndex("users", ["email"], {
unique: true,
name: "unique_users_email",
where: {
deleted_at: null,
},
})

await queryInterface.addIndex("users", ["auth0_subject"], {
unique: true,
name: "unique_users_auth0_subject",
where: {
deleted_at: null,
},
})

await queryInterface.createTable("roles", {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
allowNull: false,
autoIncrement: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: "users",
key: "id",
},
},
role: {
type: DataTypes.STRING(100),
allowNull: false,
},
created_at: {
type: MssqlSimpleTypes.DATETIME2(0),
allowNull: false,
defaultValue: MssqlSimpleTypes.NOW,
},
updated_at: {
type: MssqlSimpleTypes.DATETIME2(0),
allowNull: false,
defaultValue: MssqlSimpleTypes.NOW,
},
deleted_at: {
type: MssqlSimpleTypes.DATETIME2(0),
allowNull: true,
},
})

await queryInterface.addIndex("roles", ["user_id", "role"], {
unique: true,
name: "unique_roles_user_id_role",
where: {
deleted_at: null,
},
})
}

export const down: Migration = async ({ context: queryInterface }) => {
await queryInterface.dropTable("users")
await queryInterface.dropTable("roles")
}
4 changes: 4 additions & 0 deletions api/src/integrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# api/src/integrations/README.md

Integrations are api integrations with external services.
They might package a bunch wrapped api calls, or just one.
Loading

0 comments on commit 10baa80

Please sign in to comment.