Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/KAYA-52-Build-sync-users-endpoint #30

Merged
merged 3 commits into from
Mar 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions auth-server/src/bin/www/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ async function init() {
}

process.on("unhandledRejection", (error) => {
logSystem.error(error);
logSystem.error("UnhandledRejection", error);
process.exit(1);
});

process.on("uncaughtException", (error) => {
logSystem.error(error);
logSystem.error('UncaughtException', error);
process.exit(1);
});

Expand Down
36 changes: 36 additions & 0 deletions auth-server/src/handlers/user-sync-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Users } from "@prisma/client";
import { RequestHandler } from "express";
import validator from "validator";
import prisma from "../lib/prisma.js";

export type ResBody = Record<'accepted', Array<string>> & Record<'rejected', Array<string>>;
export type ReqBody = Record<'data', Array<Users>>;
export type ReqQuery = qs.ParsedQs & Record<string, unknown>;
export type UserSyncRequestHandler = RequestHandler<unknown, ResBody, ReqBody, ReqQuery>;

const userSyncRequestHandler: UserSyncRequestHandler = async (req, res) => {
const { data } = req.body;
const accepted: Array<string> = [];
const rejected: Array<string> = [];
for (const user of data) {
let isValid = true;
isValid &&= validator.isEmail(user.email);
isValid &&= !validator.isEmpty(user.lastName) && validator.isAlpha(user.firstName);
isValid &&= !user.middleName || validator.isAlpha(user.lastName);
isValid &&= !validator.isEmpty(user.lastName) && validator.isAlpha(user.lastName);

if (isValid) {
const upsertedUserEmail = await prisma.users.upsert({
where: { email: user.email },
update: user,
create: {...user, password: ''},
}).then(document => document.email);
accepted.push(upsertedUserEmail);
} else {
rejected.push(user.email);
}
}
res.send({ accepted, rejected });
}

export default userSyncRequestHandler;
2 changes: 1 addition & 1 deletion auth-server/src/middlewares/require-access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function (): AccessTokenEnforcer {
throw httpErrors.Unauthorized('Missing access token');
}
if (!validator.isJWT(accessToken)) {
throw httpErrors.Unauthorized('Invalid access token');
throw httpErrors.Unauthorized(`Invalid access token: ${accessToken}`);
}
const accessTokenData = verifyAccessToken(accessToken);
response.locals.accessTokenData = accessTokenData;
Expand Down
2 changes: 1 addition & 1 deletion auth-server/src/middlewares/require-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function (): ApplicationEnforcer {
}

if (!validator.isUUID(applicationSecret)) {
throw httpErrors.Unauthorized('Invalid application secret');
throw httpErrors.Unauthorized(`Invalid application secret: ${applicationSecret}`);
}

const application = await prisma.applications.findFirst({
Expand Down
17 changes: 13 additions & 4 deletions auth-server/src/routers/user-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import requireUserApplicationLink from "../middlewares/require-userApplication-l
import requireApplication from "../middlewares/require-application.js";
import requireUser from "../middlewares/require-user.js";
import requireAccessToken from "../middlewares/require-access-token.js";
import userSyncRequestHandler, { ReqBody as UserSyncBody } from "../handlers/user-sync-handler.js";

const userRouter = Router();

Expand All @@ -20,16 +21,14 @@ interface CreateUserBody {
readonly email: string;
readonly password: string;
}

type RequiredKeys = "firstName" | "lastName" | "email" | "password";

type UserCreateRequiredBodyKeys = "firstName" | "lastName" | "email" | "password";
userRouter.post(
'/users/register',
requireApplication(),
requireAccessToken(),
requireUser(),
requireUserApplicationLink(),
requireBody<CreateUserBody, RequiredKeys>("firstName", "lastName", "email", "password"),
requireBody<CreateUserBody, UserCreateRequiredBodyKeys>("firstName", "lastName", "email", "password"),
async (req, res, next) => {
try {
const { firstName, middleName, lastName, email, password } = req.body;
Expand Down Expand Up @@ -66,4 +65,14 @@ userRouter.post(
}
);

userRouter.post(
'/users/sync',
requireApplication(),
requireAccessToken(),
requireUser(),
requireUserApplicationLink(),
requireBody<UserSyncBody, 'data'>("data"),
userSyncRequestHandler
);

export default userRouter;
7 changes: 7 additions & 0 deletions hr-resource/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ input OrganizationInput {
webLink: String
}

# User sync
type UserSyncResult {
accepted: Int!
rejected: Int!
}

type Query {
currentUser: User
roles: [Role!]!
Expand All @@ -95,4 +101,5 @@ type Query {
type Mutation {
createRole(input: RoleInput!) : Int!
createUser(input: CreateUserInput!): Int!
syncUsers(force: Boolean): UserSyncResult!
}
19 changes: 19 additions & 0 deletions hr-resource/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions hr-resource/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@graphql-codegen/typescript": "4.0.6",
"@graphql-codegen/typescript-resolvers": "4.0.6",
"@types/chai": "^4.3.11",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/mocha": "^10.0.6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,31 @@ CREATE TABLE `Users` (
`status` VARCHAR(191) NULL,
`type` VARCHAR(191) NULL,
`organizationId` INTEGER NOT NULL,
`syncStatus` ENUM('NEVER', 'OK', 'FAIL') NOT NULL DEFAULT 'NEVER',

UNIQUE INDEX `Users_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `UserRoles` (
CREATE TABLE `Roles` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`roleId` INTEGER NOT NULL,
`code` VARCHAR(191) NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`hourlyWage` DECIMAL(65, 30) NOT NULL,

UNIQUE INDEX `Roles_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `Roles` (
CREATE TABLE `UserRoles` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(191) NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`hourlyWage` DECIMAL(65, 30) NOT NULL,
`userId` INTEGER NOT NULL,
`roleId` INTEGER NOT NULL,

UNIQUE INDEX `UserRoles_userId_roleId_key`(`userId`, `roleId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Expand Down
7 changes: 7 additions & 0 deletions hr-resource/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ datasource db {
url = env("DATABASE_URL")
}

enum SyncStatus {
NEVER
OK
FAIL
}

model Users {
id Int @id @default(autoincrement())
firstName String
Expand All @@ -28,6 +34,7 @@ model Users {
status String?
type String?
organizationId Int
syncStatus SyncStatus @default(NEVER)
organization Organizations @relation(fields: [organizationId], references: [id], onUpdate: Cascade, onDelete: Restrict)
UserRoles UserRoles[]
Schedule Schedules[]
Expand Down
2 changes: 2 additions & 0 deletions hr-resource/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express from "express";
import cors, { CorsOptions } from "cors";
import cookieParser from "cookie-parser";
import { expressMiddleware } from "@apollo/server/express4";
import { httpLogStream } from "./lib/logger.js";
import apolloServer, { ApolloServerContext, apolloServerContextFn } from "./lib/apollo.js";
Expand All @@ -24,6 +25,7 @@ const corsOptions: CorsOptions = {

app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
app.use(httpLogStream);

const bindExpressMiddleware = () => app.use(
Expand Down
33 changes: 19 additions & 14 deletions hr-resource/src/lib/apollo.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,35 @@
import { ApolloServer, ApolloServerOptions, BaseContext } from "@apollo/server";
import { readFileSync } from "fs";
import { Resolvers } from "./gql-codegen/graphql.js";
import { mResolverCreateUser, qResolverCurrentUser, qResolverRoles } from "./resolvers.js";
import { ExpressMiddlewareOptions } from "@apollo/server/express4";
import { verifyIdentity } from "./fetch-requests.js";
import httpErrors, { HttpError } from "http-errors";
import { GraphQLError } from "graphql";
import { ApolloServer, ApolloServerOptions, BaseContext } from "@apollo/server";
import { ExpressMiddlewareOptions } from "@apollo/server/express4";
import { Organizations, Roles, Users } from "@prisma/client";
import { Resolvers } from "./gql-codegen/graphql.js";
import { qResolverCurrentUser, qResolverRoles } from "./query-resolvers.js";
import { mResolverCreateUser, mResolverSyncUsers } from "./mutation-resolvers.js";
import { getHeaders, verifyIdentity } from "./fetch-requests.js";
import prisma from "./prisma.js";

export interface ApolloServerContext extends BaseContext {
readonly user: Users;
readonly applicationId: string;
readonly accessToken: string;
readonly organization: Organizations | null;
readonly roles: Roles[];
}

export const apolloServerContextFn: ExpressMiddlewareOptions<ApolloServerContext>['context'] = async ({ req }) => {
try {
// Append all headers from the request to the headers object
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (key.match(/^content-length$/i)) continue;
headers.append(key, Array.isArray(value) ? value.join(',') : value);
}
const headers = getHeaders(req);

const verificationResponse = await verifyIdentity({ headers });
if (!verificationResponse.ok) {
const errorBody = await verificationResponse.json() as Error;
throw httpErrors(verificationResponse.status, errorBody.message);
}

const responseBody = await verificationResponse.json() as { id: number };
const responseBody = await verificationResponse.json() as { id: number, application: string };
const user = await prisma.users.findUnique({
where: { id: responseBody.id },
include: { organization: true, UserRoles: { include: { role: true } } },
Expand All @@ -41,7 +39,13 @@ export const apolloServerContextFn: ExpressMiddlewareOptions<ApolloServerContext
}

const { UserRoles, organization, ...rest } = user;
return { user: rest, organization, roles: UserRoles.map(userRole => userRole.role) };
return {
user: rest,
applicationId: responseBody.application,
accessToken: req.cookies['access_token'],
organization,
roles: UserRoles.map(userRole => userRole.role)
};
} catch (error) {
if (httpErrors.isHttpError(error)) {
throw new GraphQLError((error as HttpError).message, { extensions: { code: (error as HttpError).statusCode } });
Expand All @@ -57,7 +61,8 @@ const resolvers: Resolvers<ApolloServerContext> = {
roles: qResolverRoles,
},
Mutation: {
createUser: mResolverCreateUser
createUser: mResolverCreateUser,
syncUsers: mResolverSyncUsers,
}
}

Expand Down
22 changes: 22 additions & 0 deletions hr-resource/src/lib/fetch-requests.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { Users } from "@prisma/client";
import { Api } from "../config/environment.js";
import type { Request } from 'express';

export const getHeaders = (req: Request) => {
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (key.match(/^content-length$/i)) continue;
headers.append(key, Array.isArray(value) ? value.join(',') : value);
}

return headers;
}

export const verifyIdentity = (requestInit: RequestInit) => fetch(
`${Api.authDomain.href}auth/verify`,
{ method: 'GET', ...requestInit }
);

export const syncUsers = (
body: Array<Pick<Users, 'firstName' | 'middleName' | 'lastName' | 'email'>>,
force = false,
requestInit?: RequestInit
) => fetch(
`${Api.authDomain.href}users/sync?force=${force}`,
{ method: 'POST', body: JSON.stringify({ data: body }), ...requestInit }
);
Loading