Download as pdf or txt
Download as pdf or txt
You are on page 1of 30

‭Authorization model add for multi-tenancy‬

‭ iff --git a/package-lock.json b/package-lock.json‬


d
‭index 8c396bae..bd127c10 100644‬
‭--- a/package-lock.json‬
‭+++ b/package-lock.json‬
‭@@ -26,6 +26,7 @@‬
‭"date-fns": "^2.30.0",‬
‭"iron-session": "^6.3.1",‬
‭"lodash": "^4.17.21",‬
‭+ "lru-cache": "^10.0.1",‬
‭"next": "13.0.2",‬
‭"pg": "^8.11.0",‬
‭"pino": "^8.11.0",‬
‭@@ -196,6 +197,15 @@‬
‭"@babel/core": "^7.0.0"‬
‭}‬
‭},‬
‭+ "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {‬
‭+ "version": "5.1.1",‬
‭+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",‬
‭+ "integrity":‬
‭"sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4‬
‭WP2n6gI8vN1aesBFgo9w==",‬
‭+ "dev": true,‬
‭+ "dependencies": {‬
‭+ "yallist": "^3.0.2"‬
‭+ }‬
‭+ },‬
‭"node_modules/@babel/helper-compilation-targets/node_modules/semver": {‬
‭"version": "6.3.1",‬
‭"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",‬
‭@@ -6998,12 +7008,11 @@‬
‭}‬
‭},‬
‭"node_modules/lru-cache": {‬
‭- "version": "5.1.1",‬
‭- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",‬
‭- "integrity":‬
‭"sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4‬
‭WP2n6gI8vN1aesBFgo9w==",‬
‭- "dev": true,‬
-‭ "dependencies": {‬
‭- "yallist": "^3.0.2"‬
‭+ "version": "10.0.1",‬
‭+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz",‬
‭+ "integrity":‬
‭"sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1r‬
‭V1O0sJFszx75g==",‬
‭+ "engines": {‬
‭+ "node": "14 || >=16.14"‬
‭}‬
‭},‬
‭"node_modules/make-dir": {‬
‭@@ -7136,9 +7145,15 @@‬
‭"dev": true‬
‭},‬
‭"node_modules/nanoid": {‬
‭- "version": "3.3.4",‬
‭- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",‬
‭- "integrity":‬
‭"sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20x‬
‭s4siNPm8naNotSD6RBw==",‬
‭+ "version": "3.3.6",‬
‭+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",‬
‭+ "integrity":‬
‭"sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9‬
‭sL+FAiRiXMgsyxQ1DIDA==",‬
‭+ "funding": [‬
‭+ {‬
‭+ "type": "github",‬
‭+ "url": "https://github.com/sponsors/ai"‬
‭+ }‬
‭+ ],‬
‭"bin": {‬
‭"nanoid": "bin/nanoid.cjs"‬
‭},‬
‭@@ -7907,9 +7922,9 @@‬
‭}‬
‭},‬
‭"node_modules/postcss": {‬
‭- "version": "8.4.21",‬
‭- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",‬
‭- "integrity":‬
‭"sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeL‬
‭m2kIBUNlZe3zgb4Zg==",‬

+ "version": "8.4.31",‬
‭+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",‬
‭+ "integrity":‬
‭"sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36Rm‬
‭ARn41bC0AZmn+rR0OVpQ==",‬
‭"funding": [‬
‭{‬
‭"type": "opencollective",‬
‭@@ -7918,10 +7933,14 @@‬
‭{‬
‭"type": "tidelift",‬
‭"url": "https://tidelift.com/funding/github/npm/postcss"‬
‭+ },‬
‭+ {‬
‭+ "type": "github",‬
‭+ "url": "https://github.com/sponsors/ai"‬
‭}‬
‭],‬
‭"dependencies": {‬
‭- "nanoid": "^3.3.4",‬
‭+ "nanoid": "^3.3.6",‬
‭"picocolors": "^1.0.0",‬
‭"source-map-js": "^1.0.2"‬
‭},‬
‭@@ -9815,9 +9834,9 @@‬
‭}‬
‭},‬
‭"node_modules/zod": {‬
‭- "version": "3.21.2",‬
‭- "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.2.tgz",‬
‭- "integrity":‬
‭"sha512-0Ygy2/IZNIxHterZdHjE5Vb8hp1fUHJD/BGvSHj8QJx+UipEVNvo9WLchoyBpz5JIaN6K‬
‭mdGDGYdloGzpFK98g==",‬
‭+ "version": "3.22.4",‬
‭+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",‬
‭+ "integrity":‬
‭"sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8‬
‭VTVLKwp9EDkx+ryxIWmg==",‬
‭"funding": {‬
‭"url": "https://github.com/sponsors/colinhacks"‬
‭}‬
‭@@ -9947,6 +9966,15 @@‬
‭"semver": "^6.3.0"‬
‭},‬
‭"dependencies": {‬
‭+ "lru-cache": {‬
‭+ "version": "5.1.1",‬
‭+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",‬
‭+ "integrity":‬
‭"sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4‬
‭WP2n6gI8vN1aesBFgo9w==",‬
‭+ "dev": true,‬
‭+ "requires": {‬
‭+ "yallist": "^3.0.2"‬
‭+ }‬
‭+ },‬
‭"semver": {‬
‭"version": "6.3.1",‬
‭"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",‬
‭@@ -14898,13 +14926,9 @@‬
‭}‬
‭},‬
‭"lru-cache": {‬
‭- "version": "5.1.1",‬
‭- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",‬
‭- "integrity":‬
‭"sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4‬
‭WP2n6gI8vN1aesBFgo9w==",‬
‭- "dev": true,‬
‭- "requires": {‬
‭- "yallist": "^3.0.2"‬
‭- }‬
‭+ "version": "10.0.1",‬
‭+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz",‬
‭+ "integrity":‬
‭"sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1r‬
‭V1O0sJFszx75g=="‬
‭},‬
‭"make-dir": {‬
‭"version": "3.1.0",‬
‭@@ -15005,9 +15029,9 @@‬
‭"dev": true‬
‭},‬
‭"nanoid": {‬
‭- "version": "3.3.4",‬
‭- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",‬
-‭ "integrity":‬
‭"sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20x‬
‭s4siNPm8naNotSD6RBw=="‬
‭+ "version": "3.3.6",‬
‭+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",‬
‭+ "integrity":‬
‭"sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9‬
‭sL+FAiRiXMgsyxQ1DIDA=="‬
‭},‬
‭"natural-compare": {‬
‭"version": "1.4.0",‬
‭@@ -15541,11 +15565,11 @@‬
‭}‬
‭},‬
‭"postcss": {‬
‭- "version": "8.4.21",‬
‭- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",‬
‭- "integrity":‬
‭"sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeL‬
‭m2kIBUNlZe3zgb4Zg==",‬
‭+ "version": "8.4.31",‬
‭+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",‬
‭+ "integrity":‬
‭"sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36Rm‬
‭ARn41bC0AZmn+rR0OVpQ==",‬
‭"requires": {‬
‭- "nanoid": "^3.3.4",‬
‭+ "nanoid": "^3.3.6",‬
‭"picocolors": "^1.0.0",‬
‭"source-map-js": "^1.0.2"‬
‭}‬
‭@@ -16887,9 +16911,9 @@‬
‭"dev": true‬
‭},‬
‭"zod": {‬
‭- "version": "3.21.2",‬
‭- "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.2.tgz",‬
‭- "integrity":‬
‭"sha512-0Ygy2/IZNIxHterZdHjE5Vb8hp1fUHJD/BGvSHj8QJx+UipEVNvo9WLchoyBpz5JIaN6K‬
‭mdGDGYdloGzpFK98g=="‬
‭+ "version": "3.22.4",‬
‭+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",‬

+ "integrity":‬
‭"sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8‬
‭VTVLKwp9EDkx+ryxIWmg=="‬
‭},‬
‭"zustand": {‬
‭"version": "4.3.6",‬
‭diff --git a/package.json b/package.json‬
‭index a6c39b14..5a97214d 100644‬
‭--- a/package.json‬
‭+++ b/package.json‬
‭@@ -41,6 +41,7 @@‬
‭"date-fns": "^2.30.0",‬
‭"iron-session": "^6.3.1",‬
‭"lodash": "^4.17.21",‬
‭+ "lru-cache": "^10.0.1",‬
‭"next": "13.0.2",‬
‭"pg": "^8.11.0",‬
‭"pino": "^8.11.0",‬
‭diff --git a/prisma/migrations/20231013133805_authorization/migration.sql‬
‭b/prisma/migrations/20231013133805_authorization/migration.sql‬
‭new file mode 100644‬
‭index 00000000..6ba208f7‬
‭--- /dev/null‬
‭+++ b/prisma/migrations/20231013133805_authorization/migration.sql‬
‭@@ -0,0 +1,71 @@‬
‭+/*‬
‭+ Warnings:‬
‭+‬
‭+ - You are about to drop the column `customer_name` on the `facility` table. All the data in the‬
‭column will be lost.‬
‭+ - Added the required column `customer_id` to the `facility` table without a default value. This‬
‭is not possible if the table is not empty.‬
‭+‬
‭+*/‬
‭+-- CreateEnum‬
‭+CREATE TYPE "UserCustomerRole" AS ENUM ('USER', 'ADMIN');‬
‭+‬
‭+-- CreateEnum‬
‭+CREATE TYPE "UserFacilityRole" AS ENUM ('USER', 'ADMIN');‬
‭+‬
‭+-- AlterTable‬
‭+ALTER TABLE "facility" ADD COLUMN "customer_id" TEXT NULL;‬
‭+‬
‭+‬
‭ -- CreateTable‬
+
‭+CREATE TABLE "user_facility_relations" (‬
‭+ "id" TEXT NOT NULL,‬
‭+ "user_id" TEXT NOT NULL,‬
‭+ "facility_id" TEXT NOT NULL,‬
‭+ "role" "UserFacilityRole" NOT NULL DEFAULT 'USER',‬
‭+‬
‭+ CONSTRAINT "user_facility_relations_pkey" PRIMARY KEY ("id")‬
‭+);‬
‭+‬
‭+-- CreateTable‬
‭+CREATE TABLE "user_customer_relations" (‬
‭+ "id" TEXT NOT NULL,‬
‭+ "user_id" TEXT NOT NULL,‬
‭+ "customer_id" TEXT NOT NULL,‬
‭+ "role" "UserCustomerRole" NOT NULL DEFAULT 'USER',‬
‭+‬
‭+ CONSTRAINT "user_customer_relations_pkey" PRIMARY KEY ("id")‬
‭+);‬
‭+‬
‭+-- CreateTable‬
‭+CREATE TABLE "customer" (‬
‭+ "id" TEXT NOT NULL,‬
‭+ "name" TEXT NOT NULL,‬
‭+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,‬
‭+ "updated_at" TIMESTAMPTZ(3) NOT NULL,‬
‭+‬
‭+ CONSTRAINT "customer_pkey" PRIMARY KEY ("id")‬
‭+);‬
‭+‬
‭+INSERT INTO "customer" SELECT DISTINCT gen_random_uuid()::text, "customer_name",‬
‭"created_at", "updated_at" FROM "facility";‬
‭+‬
‭+UPDATE "facility" SET "customer_id" = (SELECT "customer"."id" FROM "customer" WHERE‬
‭"customer"."name" = "facility"."customer_name");‬
‭+‬
‭+ALTER TABLE "facility" ALTER COLUMN "customer_id" SET NOT NULL;‬
‭+‬
‭+-- AddForeignKey‬
‭+ALTER TABLE "user_facility_relations" ADD CONSTRAINT‬
‭"user_facility_relations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id")‬
‭ON DELETE RESTRICT ON UPDATE CASCADE;‬
‭+‬
‭+-- AddForeignKey‬
‭ ALTER TABLE "user_facility_relations" ADD CONSTRAINT‬
+
‭"user_facility_relations_facility_id_fkey" FOREIGN KEY ("facility_id") REFERENCES‬
‭"facility"("id") ON DELETE RESTRICT ON UPDATE CASCADE;‬
‭+‬
‭+-- AddForeignKey‬
‭+ALTER TABLE "user_customer_relations" ADD CONSTRAINT‬
‭"user_customer_relations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id")‬
‭ON DELETE RESTRICT ON UPDATE CASCADE;‬
‭+‬
‭+-- AddForeignKey‬
‭+ALTER TABLE "user_customer_relations" ADD CONSTRAINT‬
‭"user_customer_relations_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES‬
‭"customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;‬
‭+‬
‭+-- AddForeignKey‬
‭+ALTER TABLE "facility" ADD CONSTRAINT "facility_customer_id_fkey" FOREIGN KEY‬
‭("customer_id") REFERENCES "customer"("id") ON DELETE RESTRICT ON UPDATE‬
‭CASCADE;‬
‭+‬
‭+ALTER TABLE "facility" DROP COLUMN "customer_name";‬
‭+‬
‭+INSERT INTO "user_facility_relations" SELECT DISTINCT gen_random_uuid()::text, "id",‬
‭"facility_id", 'ADMIN'::"UserFacilityRole" FROM "user";‬
‭diff --git a/prisma/schema.prisma b/prisma/schema.prisma‬
‭index 62ae8998..84a32628 100644‬
‭--- a/prisma/schema.prisma‬
‭+++ b/prisma/schema.prisma‬
‭@@ -25,16 +25,62 @@ model User {‬
‭isActive Boolean @default(true) @map("is_active")‬

‭facility Facility? @relation(fields: [facilityId], references: [id])‬


‭+ userCustomerRelations UserCustomerRelations[]‬
‭+ userFacilityRelations UserFacilityRelations[]‬

‭@@map("user")‬
‭}‬

‭ enum UserCustomerRole {‬
+
‭+ USER‬
‭+ ADMIN‬
‭+}‬
‭+enum UserFacilityRole{‬
‭+ USER‬
‭+ ADMIN‬
‭ }‬
+
‭+‬
‭+model UserFacilityRelations{‬
‭+ id String @id @default(cuid())‬
‭+ userId String @map("user_id")‬
‭+ facilityId String @map("facility_id")‬
‭+ role UserFacilityRole @default(USER)‬
‭+‬
‭+ user User @relation(fields: [userId], references: [id])‬
‭+ facility Facility @relation(fields: [facilityId], references: [id])‬
‭+ @@map("user_facility_relations")‬
‭+}‬
‭+‬
‭+model UserCustomerRelations{‬
‭+ id String @id @default(cuid())‬
‭+ userId String @map("user_id")‬
‭+ customerId String @map("customer_id")‬
‭+ role UserCustomerRole @default(USER)‬
‭+‬
‭+ user User @relation(fields: [userId], references: [id])‬
‭+ customer Customer @relation(fields: [customerId], references: [id])‬
‭+‬
‭+ @@map("user_customer_relations")‬
‭+}‬
‭+‬
‭+model Customer{‬
‭+ id String @id @default(cuid())‬
‭+ name String‬
‭+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)‬
‭+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)‬
‭+‬
‭+ facilities Facility[]‬
‭+ userCustomerRelations UserCustomerRelations[]‬
‭+‬
‭+ @@map("customer")‬
‭+}‬
‭+‬
‭model Facility {‬
‭id String @id @default(cuid())‬
‭createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)‬
‭updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)‬
‭name String‬
‭- customerName String @map("customer_name")‬
‭+ customerId String @map("customer_id")‬
l‭ocation String @map("location")‬
‭targetScore Decimal @map("target_score")‬

‭@@ -42,6 +88,8 @@ model Facility {‬


‭users User[]‬
‭receivingLots ReceivingLot[]‬
‭dailyShipments DailyShipmentForecast[]‬
‭+ customer Customer @relation(fields: [customerId], references: [id])‬
‭+ userFacilityRelations UserFacilityRelations[]‬

‭ @unique([name])‬
@
‭@@map("facility")‬
‭diff --git a/prisma/seed/dev.ts b/prisma/seed/dev.ts‬
‭index 972040f9..58cf951a 100644‬
‭--- a/prisma/seed/dev.ts‬
‭+++ b/prisma/seed/dev.ts‬
‭@@ -16,11 +16,23 @@ import { prisma } from "@/server/db/client";‬
‭import "./devices/add_devices";‬

‭ xport async function seedDev() {‬


e
‭+ const strellaCustomer = await prisma.customer.create({‬
‭+ data: {‬
‭+ name: "Strella",‬
‭+ }‬
‭+ });‬
‭+‬
‭+ const sobeysCustomer = await prisma.customer.create({‬
‭+ data: {‬
‭+ name: "Sobeys",‬
‭+ }‬
‭+ });‬
‭+‬
‭// **** FACILITY + USER ****‬
‭const adminFacility = await prisma.facility.create({‬
‭data: {‬
‭name: "Admin",‬
‭- customerName: "Strella",‬
‭+ customerId: strellaCustomer.id,‬
‭location: "Admin",‬
‭targetScore: 3,‬
‭},‬
‭@@ -33,11 +45,10 @@ export async function seedDev() {‬
‭facilityId: adminFacility.id,‬
‭},‬
‭});‬
‭-‬
‭const customerFacility = await prisma.facility.create({‬
‭data: {‬
‭name: "Calgary",‬
‭- customerName: "Sobeys",‬
‭+ customerId: sobeysCustomer.id,‬
‭location: "Calgary",‬
‭targetScore: 3,‬
‭},‬
‭@@ -48,6 +59,12 @@ export async function seedDev() {‬
‭hashedPassword: _seedHashPassword("strella_devs"),‬
‭role: "USER",‬
‭facilityId: customerFacility.id,‬
‭+ userFacilityRelations: {‬
‭+ create: {‬
‭+ role: "ADMIN",‬
‭+ facilityId: customerFacility.id,‬
‭+ }‬
‭+ }‬
‭},‬
‭});‬

‭ iff --git a/prisma/seed/prod.ts b/prisma/seed/prod.ts‬


d
‭index 24c1a983..4effd42b 100644‬
‭--- a/prisma/seed/prod.ts‬
‭+++ b/prisma/seed/prod.ts‬
‭@@ -1,3 +1,4 @@‬
‭+/* eslint-disable */‬
‭/* eslint-disable @typescript-eslint/no-unsafe-assignment */‬
‭import { ActionType } from "@prisma/client";‬
‭import bcrypt from "bcryptjs";‬
‭@@ -8,16 +9,19 @@ import { prisma } from "@/server/db/client";‬
‭function _seedHashPassword(password: string) {‬
‭return bcrypt.hashSync(password, 10);‬
‭}‬
‭-‬
‭+export function seedProd() {‬
‭+ return Promise.resolve();‬
‭+}‬
‭/**‬
‭* Prod data seed. This seed only sets up facilities, rooms, room zones, and a default recipe.‬
‭*‬
‭* Additional users should be created via the admin endpoints in the postman collection.‬
*‭ /‬
‭+/*‬
‭export async function seedProd() {‬
‭- /**‬
‭+ /!**‬
‭* FACILITY + ROOM/ROOM_ZONES SEED‬
‭- */‬
‭+ *!/‬

/‭/ admin‬
‭const adminFacility = await prisma.facility‬
‭@@ -197,9 +201,9 @@ export async function seedProd() {‬
‭});‬
‭});‬

-‭ /**‬
‭+ /!**‬
‭* RECIPE SEED‬
‭- */‬
‭+ *!/‬
‭await prisma.recipe.create({‬
‭data: {‬
‭name: "Default Recipe",‬
‭@@ -262,3 +266,5 @@ export async function seedProd() {‬
‭},‬
‭});‬
‭}‬
‭+*/‬
‭+/* eslint-enable */‬
‭diff --git a/src/authorization/authorizationModel.ts b/src/authorization/authorizationModel.ts‬
‭new file mode 100644‬
‭index 00000000..7ec7d42d‬
‭--- /dev/null‬
‭+++ b/src/authorization/authorizationModel.ts‬
‭@@ -0,0 +1,19 @@‬
‭+import { hasFacilityPermission } from "./facilityPolicies";‬
‭+import { hasCustomerPermission } from "./customerPolicies";‬
‭+import { PolicyObjectType } from "@/common/dtos/permissions";‬
‭+import { type accessRequestSchema } from "@/common/schemas/permissions.schemas";‬
‭+import type * as z from "zod";‬
‭+‬
‭+export async function hasPermission(‬
‭+ user: string,‬
‭+ policy: z.infer<typeof accessRequestSchema>,‬
‭ ) {‬
+
‭+ switch (policy.resourceType) {‬
‭+ case PolicyObjectType.facility:‬
‭+ return await hasFacilityPermission(user, policy.resource, policy.permission);‬
‭+ case PolicyObjectType.customer:‬
‭+ return await hasCustomerPermission(user, policy.resource, policy.permission);‬
‭+ default:‬
‭+ throw new Error(`Typescript types problem. Unknown resourceType in policy:‬
‭${JSON.stringify(policy)}`);‬
‭+ }‬
‭+}‬
‭diff --git a/src/authorization/customerPolicies.ts b/src/authorization/customerPolicies.ts‬
‭new file mode 100644‬
‭index 00000000..401595b2‬
‭--- /dev/null‬
‭+++ b/src/authorization/customerPolicies.ts‬
‭@@ -0,0 +1,50 @@‬
‭+import { UserCustomerRole } from ".prisma/client";‬
‭+import { getServerLogger } from "@/utils/logging";‬
‭+import { CustomerPolicies } from "@/common/dtos/permissions";‬
‭+‬
‭+const logger = getServerLogger("authorization/customerPolicies");‬
‭+‬
‭+const customerPermissions = new Map<string, (user: string, id: string) =>‬
‭Promise<boolean>>();‬
‭+customerPermissions.set(UserCustomerRole.USER, async (user: string, id: string) => {‬
‭+ return (‬
‭+ (await hasRole(user, id, UserCustomerRole.USER)) ||‬
‭+ (await hasCustomerPermission(user, id, UserCustomerRole.ADMIN))‬
‭+ );‬
‭+});‬
‭+‬
‭+customerPermissions.set(UserCustomerRole.ADMIN, async (user: string, id: string) => {‬
‭+ return hasRole(user, id, UserCustomerRole.ADMIN);‬
‭+});‬
‭+‬
‭+async function hasRole(user: string, id: string, role: UserCustomerRole) {‬
‭+ if (!prisma) return false;‬
‭+ //TODO: 1 minute cache‬
‭+ const customerRoles = await prisma.userCustomerRelations.findMany({‬
‭+ where: {‬
‭+ userId: user,‬
‭+ customerId: id,‬
‭+ },‬
‭ });‬
+
‭+ return customerRoles.some((r) => r.role === role);‬
‭+}‬
‭+‬
‭+export async function hasCustomerPermission(‬
‭+ user: string,‬
‭+ object: string,‬
‭+ relation: CustomerPolicies | UserCustomerRole,‬
‭+) {‬
‭+ const permissionsRule = customerPermissions.get(relation as string);‬
‭+ if (permissionsRule) {‬
‭+ return await permissionsRule(user, object);‬
‭+ } else {‬
‭+ logger.warn(`Unknown customer permission ${relation}`);‬
‭+ return false; //TODO: or throw error?‬
‭+ }‬
‭+}‬
‭+‬
‭+//Sanity check‬
‭+for (const permission of Object.values(CustomerPolicies)) {‬
‭+ if (!customerPermissions.has(permission)) {‬
‭+ logger.error(`Customer permission ${permission} is not registered`);‬
‭+ }‬
‭+}‬
‭diff --git a/src/authorization/facilityPolicies.ts b/src/authorization/facilityPolicies.ts‬
‭new file mode 100644‬
‭index 00000000..32ad82ec‬
‭--- /dev/null‬
‭+++ b/src/authorization/facilityPolicies.ts‬
‭@@ -0,0 +1,107 @@‬
‭+import { UserCustomerRole, UserFacilityRole } from ".prisma/client";‬
‭+import { hasCustomerPermission } from "./customerPolicies";‬
‭+import { getServerLogger } from "@/utils/logging";‬
‭+import { type CustomerPolicies, FacilityPolicies } from "@/common/dtos/permissions";‬
‭+import { LRUCache } from "lru-cache";‬
‭+‬
‭+const userRolesCache = new LRUCache<string, UserFacilityRole[]>({‬
‭+ ttl: 1000, // 1 minute‬
‭+ ttlAutopurge: true, //TODO: check performance‬
‭+});‬
‭+const customerCache = new LRUCache<string, string>({‬
‭+ ttl: 1000 * 60 * 24, // 1 day‬
‭+ ttlAutopurge: true, //TODO: check performance‬
‭+});‬
‭ const logger = getServerLogger("authorization/facilityPolicies");‬
+
‭+‬
‭+const facilityPermissions = new Map<string, (user: string, id: string) => Promise<boolean>>();‬
‭+‬
‭+function register(ruleName: string, inheritedFrom: FacilityPolicies | UserCustomerRole) {‬
‭+ facilityPermissions.set(ruleName, async (user: string, id: string) => {‬
‭+ return await hasFacilityPermission(user, id, inheritedFrom);‬
‭+ });‬
‭+}‬
‭+‬
‭+register(FacilityPolicies.canViewRooms, UserFacilityRole.USER);‬
‭+register(FacilityPolicies.canViewLots, UserFacilityRole.USER);‬
‭+register(FacilityPolicies.canViewForecast, UserFacilityRole.USER);‬
‭+register(FacilityPolicies.canEditForecast, UserFacilityRole.ADMIN);‬
‭+‬
‭+facilityPermissions.set(UserFacilityRole.USER, async (user: string, id: string) => {‬
‭+ return (‬
‭+ (await hasRole(user, id, UserFacilityRole.USER)) ||‬
‭+ (await hasFacilityPermission(user, id, UserFacilityRole.ADMIN)) ||‬
‭+ (await checkCustomerPermission(user, id, UserCustomerRole.USER))‬
‭+ );‬
‭+});‬
‭+facilityPermissions.set(UserFacilityRole.ADMIN, async (user: string, id: string) => {‬
‭+ return (‬
‭+ (await hasRole(user, id, UserFacilityRole.ADMIN)) ||‬
‭+ checkCustomerPermission(user, id, UserCustomerRole.ADMIN)‬
‭+ );‬
‭+});‬
‭+‬
‭+async function checkCustomerPermission(‬
‭+ user: string,‬
‭+ facilityId: string,‬
‭+ customerPermission: CustomerPolicies | UserCustomerRole,‬
‭+) {‬
‭+ const customerId = await getCustomer(facilityId);‬
‭+ if (!customerId) return false;‬
‭+ return await hasCustomerPermission(user, customerId, customerPermission);‬
‭+}‬
‭+‬
‭+async function getCustomer(facilityId: string) {‬
‭+ const cached = customerCache.get(facilityId);‬
‭+ if (cached) return cached;‬
‭+ if (!prisma) return null;‬
‭+‬
‭ const facility = await prisma.facility.findUnique({‬
+
‭+ where: {‬
‭+ id: facilityId,‬
‭+ },‬
‭+ });‬
‭+ if (!facility) {‬
‭+ logger.error(`Permission was requested for non-existing facility ${facilityId}`);‬
‭+ return null;‬
‭+ }‬
‭+ customerCache.set(facilityId, facility.customerId);‬
‭+ return facility.customerId;‬
‭+}‬
‭+‬
‭+async function hasRole(user: string, id: string, role: UserFacilityRole) {‬
‭+ let facilityRoles = userRolesCache.get(user);‬
‭+ if (!prisma) return false;‬
‭+‬
‭+ if(!facilityRoles) {‬
‭+ facilityRoles = (await prisma.userFacilityRelations.findMany({‬
‭+ where: {‬
‭+ userId: user,‬
‭+ facilityId: id,‬
‭+ },‬
‭+ })).map(x => x.role);‬
‭+ userRolesCache.set(user, facilityRoles);‬
‭+ }‬
‭+ return facilityRoles.some((r) => r === role);‬
‭+}‬
‭+‬
‭+export async function hasFacilityPermission(‬
‭+ user: string,‬
‭+ object: string,‬
‭+ relation: FacilityPolicies | UserFacilityRole,‬
‭+) {‬
‭+ const permissionsRule = facilityPermissions.get(relation);‬
‭+ if (permissionsRule) {‬
‭+ return await permissionsRule(user, object);‬
‭+ } else {‬
‭+ logger.warn(`Unknown facility permission ${relation}`);‬
‭+ return false; //TODO: or throw error?‬
‭+ }‬
‭+}‬
‭+‬
‭+//Sanity check‬
‭ for (const permission of Object.values(FacilityPolicies)) {‬
+
‭+ if (!facilityPermissions.has(permission)) {‬
‭+ logger.error(`Facility permission ${permission} is not registered`);‬
‭+ }‬
‭+}‬
‭diff --git a/src/common/dtos/permissions.ts b/src/common/dtos/permissions.ts‬
‭new file mode 100644‬
‭index 00000000..02003ab4‬
‭--- /dev/null‬
‭+++ b/src/common/dtos/permissions.ts‬
‭@@ -0,0 +1,14 @@‬
‭+export enum CustomerPolicies {‬
‭+ testPermission = "testPermission",‬
‭+}‬
‭+export enum FacilityPolicies {‬
‭+ canViewRooms = "canViewRooms",‬
‭+ canEditForecast = "canEditForecast",‬
‭+ canViewForecast = "canViewForecast",‬
‭+ canViewLots = "canViewLots",‬
‭+}‬
‭+‬
‭+export enum PolicyObjectType {‬
‭+ facility = "facility",‬
‭+ customer = "customer",‬
‭+}‬
‭diff --git a/src/common/schemas/permissions.schemas.ts‬
‭b/src/common/schemas/permissions.schemas.ts‬
‭new file mode 100644‬
‭index 00000000..8583fec6‬
‭--- /dev/null‬
‭+++ b/src/common/schemas/permissions.schemas.ts‬
‭@@ -0,0 +1,25 @@‬
‭+import { z } from "zod";‬
‭+import { CustomerPolicies, FacilityPolicies, PolicyObjectType } from‬
‭"@/common/dtos/permissions";‬
‭+‬
‭+export const customerAccessRequestSchema = z.object({‬
‭+ permission: z.nativeEnum(CustomerPolicies),‬
‭+ resource: z.string(),‬
‭+ resourceType: z.enum([PolicyObjectType.customer]),‬
‭+});‬
‭+‬
‭+export const facilityAccessRequestSchema = z.object({‬
‭+ permission: z.nativeEnum(FacilityPolicies),‬
‭ resource: z.string(),‬
+
‭+ resourceType: z.enum([PolicyObjectType.facility]),‬
‭+});‬
‭+‬
‭+export const accessRequestSchema = z.union([‬
‭+ customerAccessRequestSchema,‬
‭+ facilityAccessRequestSchema,‬
‭+]);‬
‭+export const accessRequestSchemaWithoutResource = z.union([‬
‭+ customerAccessRequestSchema.omit({ resource: true }),‬
‭+ facilityAccessRequestSchema.omit({ resource: true }),‬
‭+]);‬
‭+‬
‭+export type AccessRequest = z.infer<typeof accessRequestSchema>;‬
‭diff --git a/src/components/AppBar/NavLinks.tsx b/src/components/AppBar/NavLinks.tsx‬
‭index 8eed45e0..3eb65dbd 100644‬
‭--- a/src/components/AppBar/NavLinks.tsx‬
‭+++ b/src/components/AppBar/NavLinks.tsx‬
‭@@ -3,21 +3,50 @@ import { useRouter } from "next/router";‬

i‭mport { Button } from "@/components/Button";‬


‭import { trpc } from "@/utils/trpc";‬
‭+import { type UserSessionData } from "@/server/modules/common/auth";‬
‭+import { PermissionEnforcer } from "@/components/PermissionEnforcer/PermissionEnforcer";‬
‭+import { type AccessRequest } from "@/common/schemas/permissions.schemas";‬
‭+import { FacilityPolicies, PolicyObjectType } from "@/common/dtos/permissions";‬

-‭ const LINKS = (isAdmin: boolean): { href: string; title: string }[] => {‬
‭- if (isAdmin) {‬
‭+const LINKS = (data?: UserSessionData): (AccessRequest & { href: string; title: string })[] => {‬
‭+ if (!data) return [];‬
‭+ if (data.role === "ADMIN")‬
‭return [‬
‭- { href: "/admin/recipes", title: "Recipes" },‬
‭- { href: "/admin/users", title: "Users" },‬
‭- { href: "/admin/devices", title: "Devices" },‬
‭+ // { href: "/admin/recipes", title: "Recipes", resource: "recipe", action: "read" },‬
‭+ // /*{ href: "/admin/users", title: "Users", resource: "user", action: "read" },*/‬
‭+ // { href: "/admin/devices", title: "Devices", resource: "device", action: "read" },‬
‭+ ];‬
‭+ else‬
‭+ return [‬
‭+ {‬
‭+ href: "/rooms",‬

+ title: "Rooms",‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ resource: `${data.facilityId}`,‬
‭+ permission: FacilityPolicies.canViewRooms,‬
‭+ },‬
‭+ {‬
‭+ href: "/lots",‬
‭+ title: "Receiving",‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ resource: `${data.facilityId}`,‬
‭+ permission: FacilityPolicies.canViewLots,‬
‭+ },‬
‭+ {‬
‭+ href: "/forecast",‬
‭+ title: "Forecast",‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ resource: `${data.facilityId}`,‬
‭+ permission: FacilityPolicies.canViewForecast,‬
‭+ },‬
‭+ {‬
‭+ href: "/ship-out-forecast",‬
‭+ title: "Ship out",‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ resource: `${data.facilityId}`,‬
‭+ permission: FacilityPolicies.canViewForecast,‬
‭+ },‬
‭];‬
-‭ }‬
‭- return [‬
‭- { href: "/rooms", title: "Rooms" },‬
‭- { href: "/lots", title: "Receiving" },‬
‭- { href: "/forecast", title: "Forecast" },‬
‭- { href: "/ship-out-forecast", title: "Ship out" },‬
‭- ];‬
‭};‬

‭ xport const NavLinks = () => {‬


e
‭@@ -27,17 +56,21 @@ export const NavLinks = () => {‬

‭return (‬
‭<div className="flex space-x-4">‬
‭- {LINKS(data?.role === "ADMIN").map(({ href, title }) => (‬
‭- <div‬
‭- key={`${title.toLowerCase()}-link-container`}‬
-‭ className={`pb-1 ${‬
‭- isActive(href) ? "border-b-2 border-b-brand-periwinkle font-semibold text-gray-700" : ""‬
‭- }`}‬
‭- >‬
‭- <Link key={`${title.toLowerCase()}-link`} href={href}>‬
‭- <Button.Root variant="link">{title}</Button.Root>‬
‭- </Link>‬
‭- </div>‬
‭+ {LINKS(data).map(({ href, title, ...request }) => (‬
‭+ <PermissionEnforcer key={title} {...request}>‬
‭+ <div‬
‭+ key={`${title.toLowerCase()}-link-container`}‬
‭+ className={`pb-1 ${‬
‭+ isActive(href)‬
‭+ ? "border-b-2 border-b-brand-periwinkle font-semibold text-gray-700"‬
‭+ : ""‬
‭+ }`}‬
‭+ >‬
‭+ <Link key={`${title.toLowerCase()}-link`} href={href}>‬
‭+ <Button.Root variant="link">{title}</Button.Root>‬
‭+ </Link>‬
‭+ </div>‬
‭+ </PermissionEnforcer>‬
‭))}‬
‭</div>‬
‭);‬
‭diff --git a/src/components/PermissionEnforcer/PermissionEnforcer.tsx‬
‭b/src/components/PermissionEnforcer/PermissionEnforcer.tsx‬
‭new file mode 100644‬
‭index 00000000..bbdeed50‬
‭--- /dev/null‬
‭+++ b/src/components/PermissionEnforcer/PermissionEnforcer.tsx‬
‭@@ -0,0 +1,10 @@‬
‭+import { trpc } from "@/utils/trpc";‬
‭+import { type AccessRequest } from "@/common/schemas/permissions.schemas";‬
‭+‬
‭+type PermissionEnforcerProps = React.PropsWithChildren<AccessRequest>;‬
‭+‬
‭+export const PermissionEnforcer = ({ children, ...request }: PermissionEnforcerProps) => {‬
‭+ const { data: isAllowed } = trpc.permissions.hasPermission.useQuery(request);‬
‭+ if (!isAllowed) return null;‬
‭+ return <>{children}</>;‬
‭+};‬
‭diff --git a/src/env/schema.mjs b/src/env/schema.mjs‬
i‭ndex fa7b26f6..75199fc2 100644‬
‭--- a/src/env/schema.mjs‬
‭+++ b/src/env/schema.mjs‬
‭@@ -1,5 +1,5 @@‬
‭// @ts-check‬
‭-import { z } from "zod";‬
‭+import {z} from "zod";‬

/‭**‬
‭* Specify your server-side environment variables schema here.‬
‭diff --git a/src/pages/forecast/index.tsx b/src/pages/forecast/index.tsx‬
‭index 2ddafbf9..6712cf4c 100644‬
‭--- a/src/pages/forecast/index.tsx‬
‭+++ b/src/pages/forecast/index.tsx‬
‭@@ -25,6 +25,8 @@ import { PencilIcon } from "@heroicons/react/24/outline";‬
‭import { getClientLogger } from "@/utils/logging";‬
‭import { DeleteConfirmationModal } from "@/components/Modal/DeleteConfirmationModal";‬
‭import { TrashIcon } from "@heroicons/react/20/solid";‬
‭+import { PermissionEnforcer } from "@/components/PermissionEnforcer/PermissionEnforcer";‬
‭+import {FacilityPolicies, PolicyObjectType} from "@/common/dtos/permissions";‬

‭interface DaysCount {‬
‭Sunday?: number;‬
‭@@ -53,6 +55,7 @@ interface WeekTableValues extends DaysCount {‬

‭const ForecastPage: NextPageWithLayout<SSRPageProps> = ({ user }) => {‬


‭const logger = getClientLogger("ForecastPage");‬
‭+‬
‭const startOfWeek = useMemo(() => {‬
‭const date = new Date();‬
‭return getStartOfWeek(date);‬
‭@@ -183,8 +186,10 @@ const ForecastPage: NextPageWithLayout<SSRPageProps> = ({‬
‭user }) => {‬
‭},‬
‭});‬

-‭ const handleCloseDeleteModal = () => { setShowDeleteModal((prevState) => !prevState);‬


‭setWeekToDelete(""); };‬
‭-‬
‭+ const handleCloseDeleteModal = () => {‬
‭+ setShowDeleteModal((prevState) => !prevState);‬
‭+ setWeekToDelete("");‬
‭+ };‬
‭return (‬
‭<Page.Container>‬
‭@@ -195,9 +200,15 @@ const ForecastPage: NextPageWithLayout<SSRPageProps> = ({‬
‭user }) => {‬
‭onClose={handleCloseDeleteModal}‬
‭onConfirm={handleDeleteConfirm}‬
‭/>‬
‭- <Button.Root variant="primary" onClick={handleAddForecast}>‬
‭- Add Forecast‬
‭- </Button.Root>‬
‭+ <PermissionEnforcer‬
‭+ resourceType={PolicyObjectType.facility}‬
‭+ resource={`${user?.facilityId || ""}`}‬
‭+ permission={FacilityPolicies.canEditForecast}‬
‭+ >‬
‭+ <Button.Root variant="primary" onClick={handleAddForecast}>‬
‭+ Add Forecast‬
‭+ </Button.Root>‬
‭+ </PermissionEnforcer>‬
‭</div>‬
‭{sortedWeeks.map((week, index) => (‬
‭<div key={week}>‬
‭@@ -206,23 +217,35 @@ const ForecastPage: NextPageWithLayout<SSRPageProps> = ({‬
‭user }) => {‬
‭<h2>Week {getWeekIndex(parseDateOnlyStringISO(week))}</h2>‬
‭<div className="mr-10 space-x-2">‬
‭{sortedWeeks.length === index + 1 && (‬
‭- <Button.Root‬
‭+ <PermissionEnforcer‬
‭+ resourceType={PolicyObjectType.facility}‬
‭+ resource={`${user?.facilityId || ""}`}‬
‭+ permission={FacilityPolicies.canEditForecast}‬
‭+ >‬
‭+ <Button.Root‬
‭variant={"primary"}‬
‭withIcon‬
‭onClick={() => handleDeleteForecast(week)}‬
‭>‬
‭- <TrashIcon className="h-5 w-5" aria-hidden="true" />‬
‭- Delete‬
‭- </Button.Root>‬
‭+ <TrashIcon className="h-5 w-5" aria-hidden="true" />‬
‭+ Delete‬
‭+ </Button.Root>‬
‭+ </PermissionEnforcer>‬
‭)}‬
‭- <Button.Root‬
‭- variant={"primary"}‬
‭- withIcon‬
‭- onClick={() => handleEditForecast(week)}‬
‭+ <PermissionEnforcer‬
‭+ resourceType={PolicyObjectType.facility}‬
‭+ resource={`${user?.facilityId || ""}`}‬
‭+ permission={FacilityPolicies.canEditForecast}‬
‭>‬
‭- <PencilIcon className="h-5 w-5" aria-hidden="true" />‬
‭- Edit‬
‭- </Button.Root>‬
‭+ <Button.Root‬
‭+ variant={"primary"}‬
‭+ withIcon‬
‭+ onClick={() => handleEditForecast(week)}‬
‭+ >‬
‭+ <PencilIcon className="h-5 w-5" aria-hidden="true" />‬
‭+ Edit‬
‭+ </Button.Root>‬
‭+ </PermissionEnforcer>‬
‭</div>‬
‭</Card.Header>‬
‭<Card.Body>‬
‭diff --git a/src/server/modules/common/auth/types.ts b/src/server/modules/common/auth/types.ts‬
‭index 8087b994..52c3aa50 100644‬
‭--- a/src/server/modules/common/auth/types.ts‬
‭+++ b/src/server/modules/common/auth/types.ts‬
‭@@ -9,7 +9,7 @@ export type UserSessionData = {‬

/‭** Data passed to Pages after getServerSideProps has run. */‬


‭export type SSRPageProps = {‬
‭- user?: UserSessionData;‬
‭+ user?: UserSessionData; //TODO: why it is optional?‬
‭};‬

‭ eclare module "iron-session" {‬


d
‭diff --git a/src/server/modules/facility/facility.mapper.ts‬
‭b/src/server/modules/facility/facility.mapper.ts‬
‭index a0e67002..a4a7bd5f 100644‬
‭--- a/src/server/modules/facility/facility.mapper.ts‬
‭+++ b/src/server/modules/facility/facility.mapper.ts‬
‭ @ -2,13 +2,13 @@ import type { Facility } from "@prisma/client";‬
@
‭import type { FacilityDTO } from "./interfaces";‬

‭ xport const facilityMapper = {‬


e
‭- toDTO: (dbRecord: Facility): FacilityDTO => ({‬
‭+ toDTO: (dbRecord: Facility & {customer: {name: string}}): FacilityDTO => ({‬
‭id: dbRecord.id,‬
‭createdAt: dbRecord.createdAt,‬
‭updatedAt: dbRecord.updatedAt,‬
‭name: dbRecord.name,‬
‭location: dbRecord.location,‬
‭- customerName: dbRecord.customerName,‬
‭+ customerName: dbRecord.customer.name,‬
‭targetScore: dbRecord.targetScore.toNumber(),‬
‭}),‬
‭};‬
‭diff --git a/src/server/modules/facility/facility.repo.ts b/src/server/modules/facility/facility.repo.ts‬
‭index 28cb4d4d..d4216565 100644‬
‭--- a/src/server/modules/facility/facility.repo.ts‬
‭+++ b/src/server/modules/facility/facility.repo.ts‬
‭@@ -1,12 +1,18 @@‬
‭import { prisma } from "@/server/db/client";‬
‭-import type { Prisma, Facility } from "@prisma/client";‬
‭+import type { Facility, Customer } from "@prisma/client";‬
‭+import { type CreateFacilityRequestDTO } from "@/server/modules/facility/interfaces";‬

-‭ async function findAll(): Promise<Facility[]> {‬


‭- return await prisma.facility.findMany();‬
‭+async function findAll(): Promise<(Facility & { customer: Customer })[]> {‬
‭+ return await prisma.facility.findMany({‬
‭+ include: { customer: true },‬
‭+ });‬
‭}‬

-‭ async function findOneById(id: string): Promise<Facility | null> {‬


‭- return await prisma.facility.findFirst({ where: { id } });‬
‭+async function findOneById(id: string): Promise<(Facility & { customer: Customer }) | null> {‬
‭+ return await prisma.facility.findFirst({‬
‭+ where: { id },‬
‭+ include: { customer: true },‬
‭+ });‬
‭}‬

‭async function findOneByNameLocation({‬


‭@@ -19,8 +25,28 @@ async function findOneByNameLocation({‬
‭return await prisma.facility.findFirst({ where: { name, location } });‬
‭}‬

-‭ async function insert(data: Prisma.FacilityCreateInput): Promise<Facility> {‬


‭- return await prisma.facility.create({ data });‬
‭+async function insert(data: CreateFacilityRequestDTO): Promise<Facility> {‬
‭+ let customer = await prisma.customer.findFirst({‬
‭+ where: {‬
‭+ name: data.customerName,‬
‭+ },‬
‭+ });‬
‭+ if (!customer) {‬
‭+ customer = await prisma.customer.create({‬
‭+ data: {‬
‭+ name: data.customerName,‬
‭+ },‬
‭+ });‬
‭+ }‬
‭+ const customerId = customer.id;‬
‭+ return await prisma.facility.create({‬
‭+ data: {‬
‭+ name: data.name,‬
‭+ customerId: customerId,‬
‭+ targetScore: data.targetScore,‬
‭+ location: data.location,‬
‭+ },‬
‭+ });‬
‭}‬

‭ sync function update(‬


a
‭@@ -31,11 +57,16 @@ async function update(‬
‭customerName?: string;‬
‭targetScore?: number;‬
‭},‬
‭-): Promise<Facility> {‬
‭- return await prisma.facility.update({‬
‭+): Promise<Facility & { customer: Customer }> {‬
‭+ const facility = await prisma.facility.update({‬
‭where: { id },‬
‭data: { ...data },‬
‭});‬
‭+ const customer = await prisma.customer.update({‬
‭+ where: { id: facility.customerId },‬
‭ data: { name: data.customerName },‬
+
‭+ });‬
‭+ return { ...facility, customer };‬
‭}‬

‭ xport const facilityRepo = {‬


e
‭diff --git a/src/server/trpc/router/_app.ts b/src/server/trpc/router/_app.ts‬
‭index f91cd718..ab3036c6 100644‬
‭--- a/src/server/trpc/router/_app.ts‬
‭+++ b/src/server/trpc/router/_app.ts‬
‭@@ -10,6 +10,7 @@ import { appEvent } from "./appEvent";‬
‭import {deviceRouter} from "@/server/trpc/router/device";‬
‭import {facilityRouter} from "@/server/trpc/router/facility";‬
‭import {dailyShipmentForecastRouter} from "@/server/trpc/router/forecast";‬
‭+import {permissionsRouter} from "@/server/trpc/router/permissions";‬

‭export const appRouter = router({‬


‭user: userRouter,‬
‭@@ -23,6 +24,7 @@ export const appRouter = router({‬
‭recommendations: recommendationsRouter,‬
‭dailyShipmentForecast: dailyShipmentForecastRouter,‬
‭event: appEvent,‬
‭+ permissions: permissionsRouter,‬
‭});‬

/‭/ export type definition of API‬


‭diff --git a/src/server/trpc/router/forecast.ts b/src/server/trpc/router/forecast.ts‬
‭index 48c7ff80..33dfd3d5 100644‬
‭--- a/src/server/trpc/router/forecast.ts‬
‭+++ b/src/server/trpc/router/forecast.ts‬
‭@@ -7,10 +7,11 @@ import {‬
‭shipoutForecastOutputSchema,‬
‭} from "@/common/schemas";‬
‭import { formatTRPCError } from "@/server/modules/common/trpc";‬
‭-import { protectedProcedure, router } from "@/server/trpc/trpc";‬
‭+import { protectedProcedure, requireAccess, router } from "@/server/trpc/trpc";‬
‭import { dateOnlyStringISO, parseDateOnlyStringISO } from "@/utils/date-utils";‬
‭import { RipeningCycleManager } from "@/server/modules/CycleStateManager";‬
‭import { differenceInDays, startOfToday } from "date-fns";‬
‭+import { FacilityPolicies, PolicyObjectType } from "@/common/dtos/permissions";‬

‭export const dailyShipmentForecastRouter = router({‬


‭getRoomShipoutForecastByFacilityId: protectedProcedure‬
‭@@ -202,6 +203,16 @@ export const dailyShipmentForecastRouter = router({‬
‭}),‬

‭getForecastStartingFromDate: protectedProcedure‬
‭+ .use(‬
‭+ requireAccess(‬
‭+ {‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ permission: FacilityPolicies.canViewForecast,‬
‭+ },‬
‭+ (input) => input.facilityId,‬
‭+ dailyForecastRequestSchema,‬
‭+ ),‬
‭+ )‬
‭.input(dailyForecastRequestSchema)‬
‭.output(dailyForecastsSchema)‬
‭.query(async ({ input, ctx }) => {‬
‭@@ -224,11 +235,27 @@ export const dailyShipmentForecastRouter = router({‬
‭}),‬

‭create: protectedProcedure‬
‭+ .use(‬
‭+ requireAccess(‬
‭+ {‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ permission: FacilityPolicies.canEditForecast,‬
‭+ },‬
‭+ (input, user) => user.facilityId,‬
‭+ ),‬
‭+ )‬
‭.input(createDailyForecastInputSchema)‬
‭.mutation(async ({ input, ctx }): Promise<void> => {‬
‭try {‬
‭+ if (!ctx.session.user) throw formatTRPCError(new Error("User not found")); //should never‬
‭happen because of middlewares‬
‭+ const facilityId = ctx.session.user.facilityId;‬
‭+‬
‭await ctx.prisma.dailyShipmentForecast.createMany({‬
‭- data: input.map((item) => ({ ...item, date: parseDateOnlyStringISO(item.date) })),‬
‭+ data: input.map((item) => ({‬
‭+ ...item,‬
‭+ facilityId: facilityId,‬
‭+ date: parseDateOnlyStringISO(item.date),‬
‭+ })),‬
‭});‬
‭} catch (error) {‬
‭throw formatTRPCError(error);‬
‭@@ -236,14 +263,25 @@ export const dailyShipmentForecastRouter = router({‬
‭}),‬

‭update: protectedProcedure‬
‭+ .use(‬
‭+ requireAccess(‬
‭+ {‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ permission: FacilityPolicies.canEditForecast,‬
‭+ },‬
‭+ (input, user) => user.facilityId,‬
‭+ ),‬
‭+ )‬
‭.input(createDailyForecastInputSchema)‬
‭.mutation(async ({ input, ctx }): Promise<void> => {‬
‭try {‬
‭+ if (!ctx.session.user) throw formatTRPCError(new Error("User not found")); //should never‬
‭happen because of middlewares‬
‭+‬
‭for (const item of input) {‬
‭await ctx.prisma.dailyShipmentForecast.update({‬
‭where: {‬
‭date_bananaType_facilityId: {‬
‭- facilityId: item.facilityId,‬
‭+ facilityId: ctx.session.user.facilityId,‬
‭date: parseDateOnlyStringISO(item.date),‬
‭bananaType: item.bananaType,‬
‭},‬
‭@@ -259,6 +297,16 @@ export const dailyShipmentForecastRouter = router({‬
‭}),‬

‭delete: protectedProcedure‬
‭+ .use(‬
‭+ requireAccess(‬
‭+ {‬
‭+ resourceType: PolicyObjectType.facility,‬
‭+ permission: FacilityPolicies.canEditForecast,‬
‭+ },‬
‭+ (input, user) => user.facilityId,‬
‭+ deleteDailyForecastInputSchema,‬
‭+ ),‬
‭+ )‬
.‭input(deleteDailyForecastInputSchema)‬
‭.mutation(async ({ input, ctx }): Promise<void> => {‬
‭try {‬
‭diff --git a/src/server/trpc/router/permissions.ts b/src/server/trpc/router/permissions.ts‬
‭new file mode 100644‬
‭index 00000000..a6fa2e7f‬
‭--- /dev/null‬
‭+++ b/src/server/trpc/router/permissions.ts‬
‭@@ -0,0 +1,21 @@‬
‭+import { protectedProcedure, router } from "@/server/trpc/trpc";‬
‭+import { accessRequestSchema } from "@/common/schemas/permissions.schemas";‬
‭+import { TRPCError } from "@trpc/server";‬
‭+import { hasPermission } from "../../../authorization/authorizationModel";‬
‭+‬
‭+export const permissionsRouter = router({‬
‭+ hasPermission: protectedProcedure‬
‭+ .input(accessRequestSchema)‬
‭+ .query(async ({ ctx, input }) => {‬
‭+ if (!ctx.session.user)‬
‭+ throw new TRPCError({‬
‭+ code: "UNAUTHORIZED",‬
‭+ message: "You must be logged in to access this resource.",‬
‭+ }); //should never happen‬
‭+‬
‭+ return await hasPermission(‬
‭+ `${ctx.session.user?.id}`,‬
‭+ input‬
‭+ );‬
‭+ }),‬
‭+});‬
‭diff --git a/src/server/trpc/trpc.ts b/src/server/trpc/trpc.ts‬
‭index 13423f57..ccd13f14 100644‬
‭--- a/src/server/trpc/trpc.ts‬
‭+++ b/src/server/trpc/trpc.ts‬
‭@@ -5,6 +5,9 @@ import { type Context } from "./context";‬

i‭mport { z } from "zod";‬


‭import { getServerLogger } from "@/utils/logging";‬
‭+import { type UserSessionData } from "@/server/modules/common/auth";‬
‭+import { hasPermission } from "../../authorization/authorizationModel";‬
‭+import { type accessRequestSchemaWithoutResource } from‬
‭"@/common/schemas/permissions.schemas";‬

‭const logger = getServerLogger("server/trpc");‬


‭@@ -85,3 +88,39 @@ export const publicProcedure = t.procedure.use(loggerMiddleware);‬
‭* Middleware is called in the order it was added by `.use()`‬
‭**/‬
‭export const protectedProcedure = t.procedure.use(loggerMiddleware).use(isAuthed);‬
‭+‬
‭+export function requireAccess<TInput = void>(‬
‭+ policy: z.infer<typeof accessRequestSchemaWithoutResource>,‬
‭+ resourceName: string | ((input: TInput, user: UserSessionData) => string),‬
‭+ inputSchema?: z.Schema<TInput>,‬
‭+) {‬
‭+ return t.middleware(async ({ ctx, rawInput, next }) => {‬
‭+ if (!ctx.session.user?.id) {‬
‭+ throw new TRPCError({ code: "UNAUTHORIZED" });‬
‭+ }‬
‭+ let resource: string;‬
‭+ if (resourceName instanceof Function) {‬
‭+ if (inputSchema) {‬
‭+ const parseResult = inputSchema.safeParse(rawInput);‬
‭+ if (parseResult.success) {‬
‭+ const input = parseResult.data;‬
‭+ resource = resourceName(input, ctx.session.user);‬
‭+ } else {‬
‭+ throw new TRPCError({ code: "BAD_REQUEST", message: parseResult.error.message‬
‭});‬
‭+ }‬
‭+ } else {‬
‭+ throw new TRPCError({‬
‭+ code: "INTERNAL_SERVER_ERROR",‬
‭+ message: "Invalid configuration: inputSchema is required",‬
‭+ });‬
‭+ }‬
‭+ } else {‬
‭+ resource = resourceName;‬
‭+ }‬
‭+‬
‭+ if (await hasPermission(`${ctx.session.user?.id}`, { ...policy, resource })) {‬
‭+ throw new TRPCError({ code: "FORBIDDEN" });‬
‭+ }‬
‭+ return next();‬
‭+ });‬
‭+}‬

You might also like