diff --git a/src/auth.ts b/src/auth.ts index 5fc588e..1266230 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,10 +4,14 @@ import { getToken } from "./tokens/token.service"; import { Claim } from "./utils/claims"; import { OAuth2Namespace } from "@fastify/oauth2"; import { getSession } from "./auth/auth.service"; +import { roles, rules } from "./utils/roles"; export type AuthenticatedUser = { sid?: string; + type: string; userId?: string; + orgId?: string; + role?: string; tenantId: string; claims: Array; }; @@ -47,6 +51,7 @@ export async function authHandler(req: FastifyRequest, res: FastifyReply) { if (!valid) return res.code(401).send({ error: "invalid_token" }); req.user = { + type: "token", tenantId: tokenInDb.tenantId, claims: tokenInDb.claims as Array, }; @@ -60,14 +65,25 @@ export async function authHandler(req: FastifyRequest, res: FastifyReply) { return res.code(401).send({ error: "session_expired" }); } + //@ts-ignore + if (!rules[sessionInDb.user.role]) { + return res.code(401).send({ error: "no role" }); + } + req.user = { sid: authHeader, //@ts-ignore + type: sessionInDb.user.type, + //@ts-ignore userId: sessionInDb.user.id, //@ts-ignore tenantId: sessionInDb.user.tenantId, //@ts-ignore - claims: sessionInDb.user.claims, + orgId: sessionInDb.user.orgId, + //@ts-ignore + role: sessionInDb.user.role, + //@ts-ignore + claims: rules[sessionInDb.user.role].claims, }; } } @@ -96,3 +112,46 @@ export async function authorize(req: FastifyRequest, res: FastifyReply) { .code(401) .send({ error: "Missing permissions", params: requiredClaims }); } + +export function hideFields(resource: string) { + return async function ( + req: FastifyRequest, + res: FastifyReply, + payload: string + ) { + if (![200, 201].includes(res.statusCode)) return payload; + + const userRole = req.user.role; + if (!userRole) return payload; + + const hiddenFields = rules[userRole].hiddenFields[resource]; + const newRes = deleteFields(payload, hiddenFields); + return newRes; + }; +} + +function deleteFields(payload: string, hiddenFields: Array) { + if (!payload) return; + + const updatedPayload = JSON.parse(payload); + + function recursiveDelete(obj: Object | Array) { + if (Array.isArray(obj)) { + for (const item of obj) { + recursiveDelete(item); + } + } else { + for (const key in obj) { + if (hiddenFields.includes(key)) { + delete obj[key]; + } else if (typeof obj[key] == "object" || Array.isArray(obj[key])) { + recursiveDelete(obj[key]); + } + } + } + } + + recursiveDelete(updatedPayload); + + return JSON.stringify(updatedPayload); +} diff --git a/src/organization/organization.route.ts b/src/organization/organization.route.ts index ad0c380..fee9f90 100644 --- a/src/organization/organization.route.ts +++ b/src/organization/organization.route.ts @@ -7,6 +7,7 @@ import { listOrgsHandler, updateOrgHandler, } from "./organization.controller"; +import { hideFields } from "../auth"; export default function organizationRoutes(fastify: FastifyInstance) { fastify.post( @@ -14,9 +15,6 @@ export default function organizationRoutes(fastify: FastifyInstance) { { schema: { body: $org("createOrgInput"), - response: { - 201: $org("createOrgResponse"), - }, }, config: { requiredClaims: ["org:write"] }, preHandler: [fastify.authorize], @@ -46,7 +44,6 @@ export default function organizationRoutes(fastify: FastifyInstance) { { schema: { querystring: $org("pageQueryParams"), - response: { 200: $org("listOrgResponse") }, }, config: { requiredClaims: ["org:read"] }, preHandler: [fastify.authorize], @@ -60,9 +57,6 @@ export default function organizationRoutes(fastify: FastifyInstance) { schema: { params: { type: "object", properties: { orgId: { type: "string" } } }, body: $org("updateOrgInput"), - response: { - 200: $org("createOrgResponse"), - }, }, config: { requiredClaims: ["org:write"] }, preHandler: [fastify.authorize], @@ -81,4 +75,6 @@ export default function organizationRoutes(fastify: FastifyInstance) { }, deleteOrgHandler ); + + fastify.addHook("onSend", hideFields("orgs")); } diff --git a/src/permit/permit.controller.ts b/src/permit/permit.controller.ts index aadc80a..43a4b4b 100644 --- a/src/permit/permit.controller.ts +++ b/src/permit/permit.controller.ts @@ -60,10 +60,15 @@ export async function updatePermitHandler( const { permitId } = req.params as { permitId: string }; try { - const updatedOrg = await updatePermit(input, permitId, req.user.tenantId); - if (!updatedOrg) return res.code(404).send({ error: "resource not found" }); + const updatedPermit = await updatePermit( + input, + permitId, + req.user.tenantId + ); + if (!updatedPermit) + return res.code(404).send({ error: "resource not found" }); - return res.code(200).send(updatedOrg); + return res.code(200).send(updatedPermit); } catch (err) { return err; } diff --git a/src/permit/permit.route.ts b/src/permit/permit.route.ts index d050c9c..0b529bb 100644 --- a/src/permit/permit.route.ts +++ b/src/permit/permit.route.ts @@ -7,6 +7,7 @@ import { updatePermitHandler, } from "./permit.controller"; import { $permit } from "./permit.schema"; +import { hideFields } from "../auth"; export async function permitRoutes(fastify: FastifyInstance) { fastify.post( @@ -14,9 +15,6 @@ export async function permitRoutes(fastify: FastifyInstance) { { schema: { body: $permit("createPermitInput"), - response: { - 201: $permit("createPermitResponse"), - }, }, config: { requiredClaims: ["permit:write"] }, preHandler: [fastify.authorize], @@ -46,9 +44,6 @@ export async function permitRoutes(fastify: FastifyInstance) { { schema: { querystring: $permit("pageQueryParams"), - response: { - 200: $permit("listPermitResponse"), - }, }, config: { requiredClaims: ["permit:read"] }, @@ -66,9 +61,6 @@ export async function permitRoutes(fastify: FastifyInstance) { properties: { permitId: { type: "string" } }, }, body: $permit("updatePermitInput"), - response: { - 200: $permit("getPermitResponse"), - }, }, }, updatePermitHandler @@ -88,4 +80,6 @@ export async function permitRoutes(fastify: FastifyInstance) { }, deletePermitHandler ); + + fastify.addHook("onSend", hideFields("permits")); } diff --git a/src/routes.ts b/src/routes.ts index a930c41..ab87a47 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -3,8 +3,9 @@ import userRoutes from "./user/user.route"; import organizationRoutes from "./organization/organization.route"; import { tokenRoutes } from "./tokens/token.route"; import { permitRoutes } from "./permit/permit.route"; -import { authHandler } from "./auth"; +import { authHandler, hideFields } from "./auth"; import { fileRoutes } from "./file/file.route"; +import { rtsRoutes } from "./rts/rts.route"; export default async function routes(fastify: FastifyInstance) { fastify.addHook("preHandler", authHandler); @@ -13,4 +14,5 @@ export default async function routes(fastify: FastifyInstance) { fastify.register(tokenRoutes, { prefix: "/tokens" }); fastify.register(permitRoutes, { prefix: "/permits" }); fastify.register(fileRoutes, { prefix: "/files" }); + fastify.register(rtsRoutes, { prefix: "/rts" }); } diff --git a/src/rts/rts.controller.ts b/src/rts/rts.controller.ts new file mode 100644 index 0000000..b702c3e --- /dev/null +++ b/src/rts/rts.controller.ts @@ -0,0 +1,88 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { CreateRtsInput, UpdateRtsInput, UploadRtsInput } from "./rts.schema"; +import { + createRts, + deleteRts, + getRts, + listRts, + newUpload, + updateRts, +} from "./rts.service"; +import { PageQueryParams } from "../pagination"; + +export async function createRtsHandler(req: FastifyRequest, res: FastifyReply) { + const input = req.body as CreateRtsInput; + + try { + const rts = await createRts(input, req.user); + return res.code(201).send(rts); + } catch (err) { + return err; + } +} + +export async function getRtsHandler(req: FastifyRequest, res: FastifyReply) { + const { rtsId } = req.params as { rtsId: string }; + + try { + const rts = await getRts(rtsId, req.user.tenantId); + if (rts == null) return res.code(404).send({ error: "resource not found" }); + + return res.code(200).send(rts); + } catch (err) { + return err; + } +} + +export async function listRtsHandler(req: FastifyRequest, res: FastifyReply) { + const queryParams = req.query as PageQueryParams; + + try { + const rtsList = await listRts(queryParams, req.user.tenantId); + return res.code(200).send(rtsList); + } catch (err) { + return err; + } +} + +export async function updateRtsHandler(req: FastifyRequest, res: FastifyReply) { + const input = req.body as UpdateRtsInput; + const { rtsId } = req.params as { rtsId: string }; + + try { + const updatedRts = await updateRts(rtsId, input, req.user.tenantId); + if (!updatedRts) return res.code(404).send({ error: "resource not found" }); + + return res.code(200).send(updateRts); + } catch (err) { + return err; + } +} + +export async function deleteRtsHandler(req: FastifyRequest, res: FastifyReply) { + const { rtsId } = req.params as { rtsId: string }; + + try { + const deleteResult = await deleteRts(rtsId, req.user.tenantId); + if (deleteResult.deletedCount == 0) + return res.code(404).send({ error: "resource not found" }); + + return res.code(204).send(); + } catch (err) { + return err; + } +} + +export async function newFilesHandler(req: FastifyRequest, res: FastifyReply) { + const input = req.body as UploadRtsInput; + const { rtsId } = req.params as { rtsId: string }; + + try { + const updatedRts = await newUpload(rtsId, input, req.user); + if (!updatedRts) return res.code(404).send({ error: "resource not found" }); + + return res.code(200).send(updateRts); + } catch (err) { + return err; + } +} diff --git a/src/rts/rts.route.ts b/src/rts/rts.route.ts new file mode 100644 index 0000000..89f40d2 --- /dev/null +++ b/src/rts/rts.route.ts @@ -0,0 +1,98 @@ +import { FastifyInstance } from "fastify"; +import { $rts } from "./rts.schema"; +import { + createRtsHandler, + deleteRtsHandler, + getRtsHandler, + listRtsHandler, + newFilesHandler, + updateRtsHandler, +} from "./rts.controller"; +import { hideFields } from "../auth"; + +export async function rtsRoutes(fastify: FastifyInstance) { + fastify.post( + "/", + { + schema: { + body: $rts("rtsCreateInput"), + }, + config: { requiredClaims: ["rts:write"] }, + preHandler: [fastify.authorize], + }, + createRtsHandler + ); + + fastify.get( + "/:rtsId", + { + schema: { + params: { + type: "object", + properties: { + rtsId: { type: "string" }, + }, + }, + }, + config: { requiredClaims: ["rts:read"] }, + preHandler: [fastify.authorize], + }, + getRtsHandler + ); + + fastify.get( + "/", + { + schema: { + querystring: $rts("pageQueryParams"), + }, + + config: { requiredClaims: ["rts:read"] }, + preHandler: [fastify.authorize], + }, + listRtsHandler + ); + + fastify.patch( + "/:rtsId", + { + schema: { + params: { + type: "object", + properties: { rtsId: { type: "string" } }, + }, + body: $rts("rtsUpdateInput"), + }, + }, + updateRtsHandler + ); + + fastify.delete( + "/:rtsId", + { + schema: { + params: { + type: "object", + properties: { rtsId: { type: "string" } }, + }, + }, + config: { requiredClaims: ["rts:delete"] }, + preHandler: [fastify.authorize], + }, + deleteRtsHandler + ); + + fastify.post( + "/:rtsId/files", + { + schema: { + body: $rts("rtsNewUpload"), + }, + config: { requiredClaims: ["rts:write"] }, + preHandler: [fastify.authorize], + }, + newFilesHandler + ); + + fastify.addHook("onSend", hideFields("rts")); +} diff --git a/src/rts/rts.schema.ts b/src/rts/rts.schema.ts new file mode 100644 index 0000000..2c0c3ab --- /dev/null +++ b/src/rts/rts.schema.ts @@ -0,0 +1,115 @@ +import { buildJsonSchemas } from "fastify-zod"; +import mongoose from "mongoose"; +import z from "zod"; +import { pageQueryParams } from "../pagination"; + +const rtsSchema = new mongoose.Schema({ + tenantId: { type: String, required: true }, + pid: { + type: String, + required: true, + unique: true, + }, + documents: [ + new mongoose.Schema( + { + files: Array, + createdAt: Date, + createdBy: { + type: mongoose.Types.ObjectId, + ref: "user", + }, + }, + { _id: false } + ), + ], + county: { + type: mongoose.Types.ObjectId, + required: true, + ref: "organization", + }, + client: { + type: mongoose.Types.ObjectId, + ref: "organization", + }, + statusPipeline: new mongoose.Schema( + { + currentStage: Number, + stages: [ + { + name: String, + date: Date, + description: String, + }, + ], + }, + { _id: false } + ), + createdAt: Date, + createdBy: { + type: mongoose.Types.ObjectId, + ref: "user", + }, +}); + +export const rtsFields = Object.keys(rtsSchema.paths).filter( + (path) => path !== "__v" +); + +export const rtsModel = mongoose.model("rts", rtsSchema, "rts"); + +const files = z + .object({ + pid: z.string().optional(), + name: z.string(), + type: z.enum(["folder", "file"]), + size: z.number().optional(), + files: z.array(z.lazy(() => files)).optional(), + }) + .superRefine((data, ctx) => { + const validateRecursive = (file: any) => { + if (file.type === "file" && !file.pid) { + ctx.addIssue({ + path: ["pid"], + message: 'pid is required when type is "file"', + code: z.ZodIssueCode.custom, + }); + } + if (file.files) { + file.files.forEach((nestedFile: any) => { + validateRecursive(nestedFile); + }); + } + }; + + validateRecursive(data); + }); + +const rtsCreateInput = z.object({ + county: z.string(), + client: z.string().optional(), + files: z.array(files).optional(), +}); + +const rtsUpdateInput = z.object({ + county: z.string().optional(), + client: z.string().optional(), +}); + +const rtsNewUpload = z.object({ + files: z.array(files), +}); + +export type CreateRtsInput = z.infer; +export type UpdateRtsInput = z.infer; +export type UploadRtsInput = z.infer; + +export const { schemas: rtsSchemas, $ref: $rts } = buildJsonSchemas( + { + rtsCreateInput, + rtsUpdateInput, + rtsNewUpload, + pageQueryParams, + }, + { $id: "rts" } +); diff --git a/src/rts/rts.service.ts b/src/rts/rts.service.ts new file mode 100644 index 0000000..feee853 --- /dev/null +++ b/src/rts/rts.service.ts @@ -0,0 +1,154 @@ +import { + CreateRtsInput, + rtsFields, + rtsModel, + UpdateRtsInput, + UploadRtsInput, +} from "./rts.schema"; +import { AuthenticatedUser } from "../auth"; +import { generateId } from "../utils/id"; +import { getFilterObject, getSortObject, PageQueryParams } from "../pagination"; + +export async function createRts( + input: CreateRtsInput, + user: AuthenticatedUser +) { + if (!input.files) { + return await rtsModel.create({ + ...input, + tenantId: user.tenantId, + pid: generateId(), + createdAt: new Date(), + createdBy: user.userId ?? null, + }); + } else { + return await rtsModel.create({ + tenantId: user.tenantId, + pid: generateId(), + county: input.county, + client: input.client, + documents: [ + { + files: input.files, + createdAt: new Date(), + createdBy: user.userId ?? null, + }, + ], + createdAt: new Date(), + createdBy: user.userId ?? null, + }); + } +} + +export async function getRts(id: string, tenantId: string) { + return await rtsModel + .findOne({ pid: id, tenantId: tenantId }) + .populate({ path: "createdBy", select: "pid name avatar" }); +} + +export async function listRts(params: PageQueryParams, tenantId: string) { + const page = params.page || 1; + const pageSize = params.pageSize || 10; + const sortObj = getSortObject(params, rtsFields); + const filterObj = getFilterObject(params, rtsFields); + + const rtsList = await rtsModel.aggregate([ + { + $match: { $and: [{ tenantId: tenantId }, ...filterObj] }, + }, + { + $lookup: { + from: "organizations", + localField: "county", + foreignField: "_id", + as: "countyRec", + pipeline: [{ $project: { _id: 1, pid: 1, name: 1, type: 1 } }], + }, + }, + { + $lookup: { + from: "organizations", + localField: "client", + foreignField: "_id", + as: "clientRec", + pipeline: [{ $project: { _id: 1, pid: 1, name: 1, type: 1 } }], + }, + }, + { + $lookup: { + from: "users", + localField: "createdBy", + foreignField: "_id", + as: "createdRec", + pipeline: [{ $project: { _id: 1, pid: 1, name: 1, avatar: 1 } }], + }, + }, + { + $project: { + _id: 1, + pid: 1, + county: { $arrayElemAt: ["$countyRec", 0] }, + client: { $arrayElemAt: ["$clientRec", 0] }, + statusPipeline: 1, + createdAt: 1, + createdBy: { $arrayElemAt: ["$createdRec", 0] }, + }, + }, + { + $facet: { + metadata: [{ $count: "count" }], + data: [ + { $skip: (page - 1) * pageSize }, + { $limit: pageSize }, + { $sort: sortObj }, + ], + }, + }, + ]); + + if (rtsList[0].data.length === 0) + return { rts: [], metadata: { count: 0, page, pageSize } }; + + return { + rts: rtsList[0]?.data, + metadata: { + count: rtsList[0].metadata[0].count, + page, + pageSize, + }, + }; +} + +export async function updateRts( + id: string, + input: UpdateRtsInput, + tenantId: string +) { + return await rtsModel.findOneAndUpdate( + { pid: id, tenantId: tenantId }, + input + ); +} + +export async function deleteRts(id: string, tenantId: string) { + return await rtsModel.deleteOne({ pid: id, tenantId: tenantId }); +} + +export async function newUpload( + id: string, + newUpload: UploadRtsInput, + user: AuthenticatedUser +) { + return await rtsModel.findOneAndUpdate( + { pid: id, tenantId: user.tenantId }, + { + $push: { + documents: { + files: newUpload.files, + createdAt: new Date(), + createdBy: user.userId, + }, + }, + } + ); +} diff --git a/src/server.ts b/src/server.ts index c59f09a..fbfbb3a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { permitSchemas } from "./permit/permit.schema"; import { fileSchemas } from "./file/file.schema"; import { oauth } from "./oauth"; import { authRoutes } from "./auth/auth.route"; +import { rtsSchemas } from "./rts/rts.schema"; const app = fastify({ logger: true }); @@ -37,6 +38,7 @@ for (const schema of [ ...tokenSchemas, ...permitSchemas, ...fileSchemas, + ...rtsSchemas, ]) { app.addSchema(schema); } diff --git a/src/user/user.schema.ts b/src/user/user.schema.ts index 93f9372..d2b96b0 100644 --- a/src/user/user.schema.ts +++ b/src/user/user.schema.ts @@ -1,6 +1,7 @@ import { buildJsonSchemas } from "fastify-zod"; import mongoose from "mongoose"; import { z } from "zod"; +import { roles } from "../utils/roles"; export const userModel = mongoose.model( "user", @@ -14,6 +15,7 @@ export const userModel = mongoose.model( unique: true, required: true, }, + orgId: mongoose.Types.ObjectId, firstName: String, lastName: String, name: String, @@ -24,7 +26,7 @@ export const userModel = mongoose.model( }, avatar: String, status: String, - claims: [String], + role: String, createdAt: Date, createdBy: mongoose.Types.ObjectId, lastLogin: Date, @@ -41,12 +43,23 @@ const userCore = { }) .email(), avatar: z.string().url().optional(), - claims: z.array(z.string()).optional(), + role: z.enum(roles), + orgId: z.string().optional(), }; -const createUserInput = z.object({ - ...userCore, -}); +const createUserInput = z + .object({ + ...userCore, + }) + .superRefine((data, ctx) => { + if (data.role == "builder" && !data.orgId) { + ctx.addIssue({ + path: ["orgId"], + message: 'orgId is required when role is "builder"', + code: z.ZodIssueCode.custom, + }); + } + }); const createUserResponse = z.object({ pid: z.string().cuid2(), diff --git a/src/utils/claims.ts b/src/utils/claims.ts index cb4b752..2ff8ed7 100644 --- a/src/utils/claims.ts +++ b/src/utils/claims.ts @@ -13,4 +13,10 @@ export type Claim = | "file:delete" | "token:read" | "token:write" - | "token:delete"; + | "token:delete" + | "rts:read" + | "rts:write" + | "rts:delete" + | "task:read" + | "task:write" + | "task:delete"; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 59853bd..ee00885 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -13,6 +13,7 @@ export function errorHandler( if (error.validation) { const errMsg = { type: "validation_error", + path: error.validation[0].instancePath, context: error.validationContext, msg: error.validation[0].message, params: error.validation[0].params, diff --git a/src/utils/roles.ts b/src/utils/roles.ts new file mode 100644 index 0000000..5fb68a6 --- /dev/null +++ b/src/utils/roles.ts @@ -0,0 +1,92 @@ +import { Claim } from "./claims"; + +export const rules: Record< + string, + { claims: Claim[]; hiddenFields: Record> } +> = { + admin: { + claims: [ + "user:read", + "user:write", + "user:delete", + "org:read", + "org:write", + "org:delete", + "permit:read", + "permit:write", + "permit:delete", + "file:upload", + "file:download", + "file:delete", + "rts:read", + "rts:write", + "rts:delete", + "task:read", + "task:write", + "task:delete", + ], + hiddenFields: { + orgs: ["__v"], + permits: ["__v"], + rts: ["__v"], + tasks: ["__v"], + users: ["__v"], + }, + }, + builder: { + claims: ["permit:read", "file:upload", "file:download", "org:read"], + hiddenFields: { + orgs: ["__v", "isClient", "name"], + permits: ["__v"], + rts: ["__v"], + tasks: ["__v"], + users: ["__v"], + }, + }, + staff: { + claims: [ + "org:read", + "org:write", + "org:delete", + "permit:read", + "permit:write", + "permit:delete", + "file:upload", + "file:download", + "file:delete", + ], + hiddenFields: { + orgs: [], + permits: [], + rts: [], + tasks: [], + users: [], + }, + }, + supervisor: { + claims: [ + "user:read", + "org:read", + "org:write", + "org:delete", + "permit:read", + "permit:write", + "permit:delete", + "file:upload", + "file:download", + "file:delete", + ], + hiddenFields: { + orgs: [], + permits: [], + rts: [], + tasks: [], + users: [], + }, + }, +}; + +export const roles = Object.keys(rules) as [ + keyof typeof rules, + ...(keyof typeof rules)[] +]; diff --git a/tsconfig.json b/tsconfig.json index acbfeeb..cf86e65 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -83,7 +83,7 @@ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ + // "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */