diff --git a/package.json b/package.json index 7960251cf..d45ccb9c7 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "@aws-sdk/client-s3": "^3.577.0", "@aws-sdk/s3-request-presigner": "^3.577.0", "@blocknote/react": "^0.12.2", - "@hono/swagger-ui": "^0.3.0", - "@hono/zod-openapi": "^0.14.8", + "@hono/swagger-ui": "^0.4.0", + "@hono/zod-openapi": "^0.15.1", "@hookform/resolvers": "^3.9.0", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^5.13.0", @@ -76,13 +76,14 @@ "@types/bcryptjs": "^2.4.6", "@types/papaparse": "^5.3.14", "@wojtekmaj/react-hooks": "^1.20.0", + "base-x": "^5.0.0", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "cookie": "^0.6.0", "dayjs": "^1.11.11", - "hono": "^4.5.1", + "hono": "^4.5.3", "html-to-image": "^1.11.11", "input-otp": "^1.1.0", "jose": "^5.3.0", @@ -99,6 +100,7 @@ "pg-boss": "^9.0.3", "pino": "^9.3.1", "pino-pretty": "^11.2.1", + "prisma-extension-pagination": "^0.7.4", "prisma-json-types-generator": "^3.0.4", "pushmodal": "^1.0.4", "react": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59a5c29ac..fabbbdb68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^0.12.2 version: 0.12.4(@tiptap/pm@2.4.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) '@hono/swagger-ui': - specifier: ^0.3.0 - version: 0.3.0(hono@4.5.1) + specifier: ^0.4.0 + version: 0.4.0(hono@4.5.3) '@hono/zod-openapi': - specifier: ^0.14.8 - version: 0.14.8(hono@4.5.1)(zod@3.23.8) + specifier: ^0.15.1 + version: 0.15.1(hono@4.5.3)(zod@3.23.8) '@hookform/resolvers': specifier: ^3.9.0 version: 3.9.0(react-hook-form@7.52.1(react@18.3.1)) @@ -146,6 +146,9 @@ importers: '@wojtekmaj/react-hooks': specifier: ^1.20.0 version: 1.20.0(@types/react@18.3.3)(react@18.3.1) + base-x: + specifier: ^5.0.0 + version: 5.0.0 bcryptjs: specifier: ^2.4.3 version: 2.4.3 @@ -165,8 +168,8 @@ importers: specifier: ^1.11.11 version: 1.11.11 hono: - specifier: ^4.5.1 - version: 4.5.1 + specifier: ^4.5.3 + version: 4.5.3 html-to-image: specifier: ^1.11.11 version: 1.11.11 @@ -215,6 +218,9 @@ importers: pino-pretty: specifier: ^11.2.1 version: 11.2.1 + prisma-extension-pagination: + specifier: ^0.7.4 + version: 0.7.4(@prisma/client@5.14.0(prisma@5.14.0)) prisma-json-types-generator: specifier: ^3.0.4 version: 3.0.4(prisma@5.14.0)(typescript@5.4.5) @@ -323,7 +329,7 @@ importers: version: 1.4.0 husky: specifier: ^9.1.3 - version: 9.1.3 + version: 9.1.4 inquirer: specifier: ^9.2.22 version: 9.2.22 @@ -1414,13 +1420,13 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} - '@hono/swagger-ui@0.3.0': - resolution: {integrity: sha512-2Hp7XYFx5wDJoc6gKJAnV7RA5TWYgYl2G1k5b3BlaF6uxsePgqqCBkxhbJPe3oPu2XRB4Bsm2V7Dp9jq8LI+dg==} + '@hono/swagger-ui@0.4.0': + resolution: {integrity: sha512-8lF+dexzgV2HRM1R/gf49E5djroq4jVMYMSwLkSF9pT0I6sYhuqirFDCRFrBtbbLCBsKzw6f2MF5rS+WY3d7Nw==} peerDependencies: hono: '*' - '@hono/zod-openapi@0.14.8': - resolution: {integrity: sha512-wlgJiHwomODOdG79+Z0bMbkheLykI4jWV1D9Z4B2q0UNtmcgq+sn6ViSPM1JGpPtrtuCq18F+4DAqv8Meh+2IA==} + '@hono/zod-openapi@0.15.1': + resolution: {integrity: sha512-2Un3D5xD1j4tIvUwzQ/XkB6xwrEA0Ne23TRjB8UVw0PgUWzsB3xiB8Hl/y2ZEMfcIfrA15/ga4P6Bkct8uYaLg==} engines: {node: '>=16.0.0'} peerDependencies: hono: '>=4.3.6' @@ -4427,6 +4433,9 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + base-x@5.0.0: + resolution: {integrity: sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -5579,8 +5588,8 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.5.1: - resolution: {integrity: sha512-6q8AugoWG5wlrjdGG8OFFiqEsPlPGjODjUik48sEJeko4Tae1UsLS2vUiYHLEJx1gJvOZa4BWkQC+urwDmkEvQ==} + hono@4.5.3: + resolution: {integrity: sha512-r26WwwbKD3BAYdfB294knNnegNda7VfV1tVn66D9Kvl9WQTdrR+5eKdoeaQNHQcC3Gr0KBikzAtjd6VsRGVSaw==} engines: {node: '>=16.0.0'} hookable@5.5.3: @@ -5635,8 +5644,8 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} - husky@9.1.3: - resolution: {integrity: sha512-ET3TQmQgdIu0pt+jKkpo5oGyg/4MQZpG6xcam5J5JyNJV+CBT23OBpCF15bKHKycRyMH9k6ONy8g2HdGIsSkMQ==} + husky@9.1.4: + resolution: {integrity: sha512-bho94YyReb4JV7LYWRWxZ/xr6TtOTt8cMfmQ39MQYJ7f/YE268s3GdghGwi+y4zAeqewE5zYLvuhV0M0ijsDEA==} engines: {node: '>=18'} hasBin: true @@ -7006,10 +7015,6 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.4.39: - resolution: {integrity: sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.4.40: resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==} engines: {node: ^10 || ^12 || >=14} @@ -7069,6 +7074,11 @@ packages: resolution: {integrity: sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==} engines: {node: '>=0.10.0'} + prisma-extension-pagination@0.7.4: + resolution: {integrity: sha512-MmfyinDbcTucvttlO8UOrLqKW7DxlXe3ToiUZcLbvhGHHPewHKKWvucVnLHsYSzEIbEE1YfK/hsYdmpoRzkAAg==} + peerDependencies: + '@prisma/client': ^4.9.0 || ^5.0.0 + prisma-json-types-generator@3.0.4: resolution: {integrity: sha512-W53OpjBdGZxCsYv7MlUX69d7TPA9lEsQbDf9ddF0J93FX5EvaIRDMexdFPe0KTxiuquGvZTDJgeNXb3gIqEhJw==} engines: {node: '>=14.0'} @@ -8769,7 +8779,7 @@ snapshots: '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) '@aws-sdk/client-sts': 3.577.0 '@aws-sdk/core': 3.576.0 - '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-bucket-endpoint': 3.577.0 '@aws-sdk/middleware-expect-continue': 3.577.0 '@aws-sdk/middleware-flexible-checksums': 3.577.0 @@ -8830,7 +8840,7 @@ snapshots: '@aws-crypto/sha256-js': 3.0.0 '@aws-sdk/client-sts': 3.577.0 '@aws-sdk/core': 3.576.0 - '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 '@aws-sdk/middleware-logger': 3.577.0 '@aws-sdk/middleware-recursion-detection': 3.577.0 @@ -8919,7 +8929,7 @@ snapshots: '@aws-crypto/sha256-js': 3.0.0 '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) '@aws-sdk/core': 3.576.0 - '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/credential-provider-node': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/middleware-host-header': 3.577.0 '@aws-sdk/middleware-logger': 3.577.0 '@aws-sdk/middleware-recursion-detection': 3.577.0 @@ -8987,12 +8997,12 @@ snapshots: '@smithy/util-stream': 3.0.1 tslib: 2.6.2 - '@aws-sdk/credential-provider-ini@3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))(@aws-sdk/client-sts@3.577.0)': + '@aws-sdk/credential-provider-ini@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0)': dependencies: '@aws-sdk/client-sts': 3.577.0 '@aws-sdk/credential-provider-env': 3.577.0 '@aws-sdk/credential-provider-process': 3.577.0 - '@aws-sdk/credential-provider-sso': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0)) + '@aws-sdk/credential-provider-sso': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/credential-provider-web-identity': 3.577.0(@aws-sdk/client-sts@3.577.0) '@aws-sdk/types': 3.577.0 '@smithy/credential-provider-imds': 3.0.0 @@ -9004,13 +9014,13 @@ snapshots: - '@aws-sdk/client-sso-oidc' - aws-crt - '@aws-sdk/credential-provider-node@3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))(@aws-sdk/client-sts@3.577.0)': + '@aws-sdk/credential-provider-node@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0)': dependencies: '@aws-sdk/credential-provider-env': 3.577.0 '@aws-sdk/credential-provider-http': 3.577.0 - '@aws-sdk/credential-provider-ini': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))(@aws-sdk/client-sts@3.577.0) + '@aws-sdk/credential-provider-ini': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0)(@aws-sdk/client-sts@3.577.0) '@aws-sdk/credential-provider-process': 3.577.0 - '@aws-sdk/credential-provider-sso': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0)) + '@aws-sdk/credential-provider-sso': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/credential-provider-web-identity': 3.577.0(@aws-sdk/client-sts@3.577.0) '@aws-sdk/types': 3.577.0 '@smithy/credential-provider-imds': 3.0.0 @@ -9031,10 +9041,10 @@ snapshots: '@smithy/types': 3.0.0 tslib: 2.6.2 - '@aws-sdk/credential-provider-sso@3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))': + '@aws-sdk/credential-provider-sso@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)': dependencies: '@aws-sdk/client-sso': 3.577.0 - '@aws-sdk/token-providers': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0)) + '@aws-sdk/token-providers': 3.577.0(@aws-sdk/client-sso-oidc@3.577.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.0.0 '@smithy/shared-ini-file-loader': 3.0.0 @@ -9171,7 +9181,7 @@ snapshots: '@smithy/types': 3.0.0 tslib: 2.6.2 - '@aws-sdk/token-providers@3.577.0(@aws-sdk/client-sso-oidc@3.577.0(@aws-sdk/client-sts@3.577.0))': + '@aws-sdk/token-providers@3.577.0(@aws-sdk/client-sso-oidc@3.577.0)': dependencies: '@aws-sdk/client-sso-oidc': 3.577.0(@aws-sdk/client-sts@3.577.0) '@aws-sdk/types': 3.577.0 @@ -10008,20 +10018,20 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/swagger-ui@0.3.0(hono@4.5.1)': + '@hono/swagger-ui@0.4.0(hono@4.5.3)': dependencies: - hono: 4.5.1 + hono: 4.5.3 - '@hono/zod-openapi@0.14.8(hono@4.5.1)(zod@3.23.8)': + '@hono/zod-openapi@0.15.1(hono@4.5.3)(zod@3.23.8)': dependencies: '@asteasolutions/zod-to-openapi': 7.1.1(zod@3.23.8) - '@hono/zod-validator': 0.2.2(hono@4.5.1)(zod@3.23.8) - hono: 4.5.1 + '@hono/zod-validator': 0.2.2(hono@4.5.3)(zod@3.23.8) + hono: 4.5.3 zod: 3.23.8 - '@hono/zod-validator@0.2.2(hono@4.5.1)(zod@3.23.8)': + '@hono/zod-validator@0.2.2(hono@4.5.3)(zod@3.23.8)': dependencies: - hono: 4.5.1 + hono: 4.5.3 zod: 3.23.8 '@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.3.1))': @@ -14023,6 +14033,8 @@ snapshots: balanced-match@2.0.0: {} + base-x@5.0.0: {} + base64-js@1.5.1: {} bcp-47-match@2.0.3: {} @@ -15450,7 +15462,7 @@ snapshots: dependencies: react-is: 16.13.1 - hono@4.5.1: {} + hono@4.5.3: {} hookable@5.5.3: {} @@ -15511,7 +15523,7 @@ snapshots: human-signals@5.0.0: {} - husky@9.1.3: {} + husky@9.1.4: {} hyphen@1.10.4: {} @@ -17207,13 +17219,6 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-import@15.1.0(postcss@8.4.39): - dependencies: - postcss: 8.4.39 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.8 - postcss-import@15.1.0(postcss@8.4.40): dependencies: postcss: 8.4.40 @@ -17221,23 +17226,11 @@ snapshots: read-cache: 1.0.0 resolve: 1.22.8 - postcss-js@4.0.1(postcss@8.4.39): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.4.39 - postcss-js@4.0.1(postcss@8.4.40): dependencies: camelcase-css: 2.0.1 postcss: 8.4.40 - postcss-load-config@4.0.2(postcss@8.4.39): - dependencies: - lilconfig: 3.1.1 - yaml: 2.4.2 - optionalDependencies: - postcss: 8.4.39 - postcss-load-config@4.0.2(postcss@8.4.40): dependencies: lilconfig: 3.1.1 @@ -17245,11 +17238,6 @@ snapshots: optionalDependencies: postcss: 8.4.40 - postcss-nested@6.0.1(postcss@8.4.39): - dependencies: - postcss: 8.4.39 - postcss-selector-parser: 6.0.16 - postcss-nested@6.0.1(postcss@8.4.40): dependencies: postcss: 8.4.40 @@ -17279,12 +17267,6 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 - postcss@8.4.39: - dependencies: - nanoid: 3.3.7 - picocolors: 1.0.1 - source-map-js: 1.2.0 - postcss@8.4.40: dependencies: nanoid: 3.3.7 @@ -17340,6 +17322,10 @@ snapshots: extend-shallow: 2.0.1 js-beautify: 1.15.1 + prisma-extension-pagination@0.7.4(@prisma/client@5.14.0(prisma@5.14.0)): + dependencies: + '@prisma/client': 5.14.0(prisma@5.14.0) + prisma-json-types-generator@3.0.4(prisma@5.14.0)(typescript@5.4.5): dependencies: '@prisma/generator-helper': 5.9.1 @@ -18472,11 +18458,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.1 - postcss: 8.4.39 - postcss-import: 15.1.0(postcss@8.4.39) - postcss-js: 4.0.1(postcss@8.4.39) - postcss-load-config: 4.0.2(postcss@8.4.39) - postcss-nested: 6.0.1(postcss@8.4.39) + postcss: 8.4.40 + postcss-import: 15.1.0(postcss@8.4.40) + postcss-js: 4.0.1(postcss@8.4.40) + postcss-load-config: 4.0.2(postcss@8.4.40) + postcss-nested: 6.0.1(postcss@8.4.40) postcss-selector-parser: 6.0.16 resolve: 1.22.8 sucrase: 3.35.0 diff --git a/prisma/migrations/20240719112312_refactor_api_key/migration.sql b/prisma/migrations/20240719112312_refactor_api_key/migration.sql new file mode 100644 index 000000000..3ac5b133d --- /dev/null +++ b/prisma/migrations/20240719112312_refactor_api_key/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `hashedToken` on the `ApiKey` table. All the data in the column will be lost. + - You are about to drop the column `keyId` on the `ApiKey` table. All the data in the column will be lost. + - A unique constraint covering the columns `[hashedKey]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail. + - Added the required column `hashedKey` to the `ApiKey` table without a default value. This is not possible if the table is not empty. + - Added the required column `partialKey` to the `ApiKey` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "ApiKey_hashedToken_key"; + +-- DropIndex +DROP INDEX "ApiKey_keyId_active_idx"; + +-- DropIndex +DROP INDEX "ApiKey_keyId_key"; + +-- DropIndex +DROP INDEX "ApiKey_membershipId_active_idx"; + +-- AlterTable +ALTER TABLE "ApiKey" DROP COLUMN "hashedToken", +DROP COLUMN "keyId", +ADD COLUMN "hashedKey" TEXT NOT NULL, +ADD COLUMN "partialKey" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); diff --git a/prisma/migrations/20240731021954_add_access_token/migration.sql b/prisma/migrations/20240731021954_add_access_token/migration.sql new file mode 100644 index 000000000..342ab7c45 --- /dev/null +++ b/prisma/migrations/20240731021954_add_access_token/migration.sql @@ -0,0 +1,27 @@ +/* + Warnings: + + - You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "ApiKey"; + +-- CreateTable +CREATE TABLE "AccessToken" ( + "id" TEXT NOT NULL, + "name" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "partialToken" TEXT NOT NULL, + "hashedToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3), + "lastUsed" TIMESTAMP(3), + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AccessToken_userId_idx" ON "AccessToken"("userId"); diff --git a/prisma/migrations/20240807040118_add_client_id_and_secret/migration.sql b/prisma/migrations/20240807040118_add_client_id_and_secret/migration.sql new file mode 100644 index 000000000..075f695d6 --- /dev/null +++ b/prisma/migrations/20240807040118_add_client_id_and_secret/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `AccessToken` table. All the data in the column will be lost. + - Added the required column `clientId` to the `AccessToken` table without a default value. This is not possible if the table is not empty. + - Added the required column `clientSecret` to the `AccessToken` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "AccessTokenType" AS ENUM ('Signature token', 'Data room token', 'API access token', 'Investor update token'); + +-- AlterTable +ALTER TABLE "AccessToken" DROP COLUMN "name", +ADD COLUMN "clientId" TEXT NOT NULL, +ADD COLUMN "clientSecret" TEXT NOT NULL, +ADD COLUMN "typeEnum" "AccessTokenType" NOT NULL DEFAULT 'API access token'; + +-- CreateIndex +CREATE INDEX "AccessToken_typeEnum_clientId_idx" ON "AccessToken"("typeEnum", "clientId"); diff --git a/prisma/migrations/20240807042922_remove_partial_and_hashed_token/migration.sql b/prisma/migrations/20240807042922_remove_partial_and_hashed_token/migration.sql new file mode 100644 index 000000000..1c2392b83 --- /dev/null +++ b/prisma/migrations/20240807042922_remove_partial_and_hashed_token/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `hashedToken` on the `AccessToken` table. All the data in the column will be lost. + - You are about to drop the column `partialToken` on the `AccessToken` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "AccessToken" DROP COLUMN "hashedToken", +DROP COLUMN "partialToken"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 03bd297ab..008d0c68f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,12 +57,14 @@ model User { accounts Account[] sessions Session[] - companies Member[] + memberships Member[] passkeys Passkey[] verificationToken VerificationToken[] lastSignedIn DateTime @default(now()) identityProvider String? + + accessTokens AccessToken[] } enum CredentialDeviceTypeEnum { @@ -156,8 +158,8 @@ model Company { eSignAudits EsignAudit[] billingCustomers BillingCustomer[] customRoles CustomRole[] - apiKeys ApiKey[] - BankAccount BankAccount[] + + BankAccount BankAccount[] @@unique([publicId]) } @@ -230,7 +232,6 @@ model Member { updates Update[] dataRooms DataRoomRecipient[] UpdateRecipient UpdateRecipient[] - ApiKey ApiKey[] @@unique([companyId, userId]) @@index([companyId]) @@ -1118,24 +1119,28 @@ model BillingCustomer { @@index([companyId]) } -model ApiKey { - id String @id @default(cuid()) - name String? - active Boolean @default(true) - keyId String @unique - hashedToken String @unique // scrypt hashed token - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastUsed DateTime? +enum AccessTokenType { + sig @map("Signature token") + doc @map("Data room token") + api @map("API access token") + upd @map("Investor update token") +} - membershipId String - member Member @relation(fields: [membershipId], references: [id], onDelete: Cascade) +model AccessToken { + id String @id @default(cuid()) + active Boolean @default(true) + clientId String + clientSecret String // Hashed with scrypt + typeEnum AccessTokenType @default(api) - company Company @relation(fields: [companyId], references: [id]) - companyId String + user User @relation(fields: [userId], references: [id]) + userId String - @@index([membershipId]) - @@index([keyId, active]) - @@index([membershipId, active]) - @@index([companyId]) + expiresAt DateTime? + lastUsed DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([typeEnum, clientId]) } diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/settings/api/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/settings/api/page.tsx deleted file mode 100644 index 068bdde75..000000000 --- a/src/app/(authenticated)/(dashboard)/[publicId]/settings/api/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import EmptyState from "@/components/common/empty-state"; -import { UnAuthorizedState } from "@/components/ui/un-authorized-state"; -import { serverAccessControl } from "@/lib/rbac/access-control"; -import { api } from "@/trpc/server"; -import { RiTerminalBoxFill } from "@remixicon/react"; -import type { Metadata } from "next"; -import { Fragment } from "react"; -import CreateApiKey from "./components/create-key"; -import ApiKeysTable from "./components/table"; - -export const metadata: Metadata = { - title: "API Keys", -}; -const ApiSettingsPage = async () => { - const { allow } = await serverAccessControl(); - - const data = await allow(api.apiKey.listAll.query(), ["api-keys", "read"]); - - if (!data) { - return ; - } - - return ( - - {data.apiKeys.length === 0 ? ( - } - title="API Keys" - subtitle="Create and manage API keys" - > - - - ) : ( -
-
-
-

API Keys

-

- Create and manage API keys -

-
- -
- -
-
- - -
- )} -
- ); -}; - -export default ApiSettingsPage; diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/settings/api/components/create-key.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/create-access-token.tsx similarity index 61% rename from src/app/(authenticated)/(dashboard)/[publicId]/settings/api/components/create-key.tsx rename to src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/create-access-token.tsx index 959af689e..e2837997a 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/settings/api/components/create-key.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/create-access-token.tsx @@ -11,25 +11,24 @@ import { Fragment, useState } from "react"; import { toast } from "sonner"; import { useCopyToClipboard } from "usehooks-ts"; -const CreateApiKey = () => { +const CreateAccessToken = () => { const router = useRouter(); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); - const [apiKey, setApiKey] = useState(""); + const [accessToken, setAccessToken] = useState(""); const [_copied, copy] = useCopyToClipboard(); - const createMutation = api.apiKey.create.useMutation({ - onSuccess: ({ keyId, token }) => { - const key = `${keyId}:${token}` as string; - setApiKey(key); - copy(key); - toast.success("API key copied to clipboard!"); + const createMutation = api.accessToken.create.useMutation({ + onSuccess: ({ token }) => { + setAccessToken(token); + copy(token); + toast.success("Access token copied to clipboard."); setOpen(true); }, onError: (error) => { console.error(error); - toast.error("An error occurred while creating the API key."); + toast.error("An error occurred while creating the access token."); }, onSettled: () => { @@ -44,20 +43,23 @@ const CreateApiKey = () => { onClick={(e: React.MouseEvent) => { e.preventDefault(); setLoading(true); - createMutation.mutate(); + createMutation.mutate({ + typeEnum: "api", + }); }} loading={loading} > - Create an API Key + Create an access token } @@ -69,18 +71,20 @@ const CreateApiKey = () => { }} > - Your API Key + Your access token { - copy(apiKey as string); - toast.success("API key copied to clipboard!"); + copy(accessToken as string); + toast.success("Access token copied to clipboard."); }} > - {apiKey} + + {accessToken} + - Click the API key above to copy + Click the access token above to copy @@ -88,4 +92,4 @@ const CreateApiKey = () => { ); }; -export default CreateApiKey; +export default CreateAccessToken; diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/settings/api/components/table.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/table.tsx similarity index 74% rename from src/app/(authenticated)/(dashboard)/[publicId]/settings/api/components/table.tsx rename to src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/table.tsx index 87a657f5e..efeddc9f4 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/settings/api/components/table.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/table.tsx @@ -31,21 +31,22 @@ import { TableRow, } from "@/components/ui/table"; import { api } from "@/trpc/react"; +import type { RouterOutputs } from "@/trpc/shared"; import { RiMore2Fill } from "@remixicon/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; interface DeleteDialogProps { - keyId: string; + tokenId: string; open: boolean; setOpen: (val: boolean) => void; } -function DeleteKey({ keyId, open, setOpen }: DeleteDialogProps) { +function DeleteKey({ tokenId, open, setOpen }: DeleteDialogProps) { const router = useRouter(); - const deleteMutation = api.apiKey.delete.useMutation({ + const deleteMutation = api.accessToken.delete.useMutation({ onSuccess: ({ message }) => { toast.success(message); router.refresh(); @@ -53,7 +54,7 @@ function DeleteKey({ keyId, open, setOpen }: DeleteDialogProps) { onError: (error) => { console.error(error); - toast.error("An error occurred while creating the API key."); + toast.error("An error occurred while creating the access token."); }, }); return ( @@ -62,15 +63,15 @@ function DeleteKey({ keyId, open, setOpen }: DeleteDialogProps) { Are you sure? - Are you sure you want to delete this key? This action cannot be - undone and you will loose the access if this API key is currently - being used. + Are you sure you want to delete this access token? This action + cannot be undone and you will loose the access if this access token + is currently being used. Cancel deleteMutation.mutateAsync({ keyId })} + onClick={() => deleteMutation.mutateAsync({ tokenId })} > Continue @@ -80,13 +81,9 @@ function DeleteKey({ keyId, open, setOpen }: DeleteDialogProps) { ); } -interface ApiKey { - keyId: string; - createdAt: Date; - lastUsed: Date | null; -} +type AccessTokens = RouterOutputs["accessToken"]["listAll"]["accessTokens"]; -const ApiKeysTable = ({ keys }: { keys: ApiKey[] }) => { +const AccessTokenTable = ({ tokens }: { tokens: AccessTokens }) => { const [open, setOpen] = useState(false); return ( @@ -94,7 +91,7 @@ const ApiKeysTable = ({ keys }: { keys: ApiKey[] }) => {
@@ -102,25 +99,23 @@ const ApiKeysTable = ({ keys }: { keys: ApiKey[] }) => { - Key + Access token Created Last used - {keys.map((key) => ( - + {tokens.map((token: AccessTokens[number]) => ( + - - {`${key.keyId.slice(0, 3)}...${key.keyId.slice(-3)}:****`} - + {`${token.clientId}:***`} - {dayjsExt().to(key.createdAt)} + {dayjsExt().to(token.createdAt)} - {key.lastUsed ? dayjsExt().to(key.lastUsed) : "Never"} + {token.lastUsed ? dayjsExt().to(token.lastUsed) : "Never"} @@ -137,7 +132,7 @@ const ApiKeysTable = ({ keys }: { keys: ApiKey[] }) => { Rotate key - + {(allow) => ( { setOpen(val)} - keyId={key.keyId} + tokenId={token.id} /> @@ -164,4 +159,4 @@ const ApiKeysTable = ({ keys }: { keys: ApiKey[] }) => { ); }; -export default ApiKeysTable; +export default AccessTokenTable; diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/page.tsx new file mode 100644 index 000000000..c5a338ce6 --- /dev/null +++ b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/page.tsx @@ -0,0 +1,62 @@ +import EmptyState from "@/components/common/empty-state"; +import { api } from "@/trpc/server"; +import { RiTerminalBoxFill } from "@remixicon/react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { Fragment } from "react"; +import CreateAccessToken from "./components/create-access-token"; +import AccessTokenTable from "./components/table"; + +export const metadata: Metadata = { + title: "Developer settings", +}; +const AccessTokenPage = async () => { + const data = await api.accessToken.listAll.query({ + typeEnum: "api", + }); + + return ( + + {data.accessTokens.length === 0 ? ( + } + title="Access tokens" + subtitle={ +

+ Create an access token to get access to the API.{" "} + + Learn more + +

+ } + > + +
+ ) : ( +
+
+
+

Access tokens

+

+ Create and manage access tokens +

+
+ +
+ +
+
+ + +
+ )} +
+ ); +}; + +export default AccessTokenPage; diff --git a/src/app/api/v1/[[...route]]/route.ts b/src/app/api/v1/[[...route]]/route.ts index 0a1e5f046..3531aae86 100644 --- a/src/app/api/v1/[[...route]]/route.ts +++ b/src/app/api/v1/[[...route]]/route.ts @@ -3,4 +3,10 @@ import { handle } from "hono/vercel"; const handler = handle(api); -export { handler as GET, handler as POST, handler as PUT, handler as DELETE }; +export { + handler as GET, + handler as POST, + handler as PUT, + handler as DELETE, + handler as PATCH, +}; diff --git a/src/components/settings/settings-sidebar.tsx b/src/components/settings/settings-sidebar.tsx index fbe0a04ef..d4c8a8e37 100644 --- a/src/components/settings/settings-sidebar.tsx +++ b/src/components/settings/settings-sidebar.tsx @@ -51,12 +51,6 @@ const companyNav = [ activeIcon: RiBankCardFill, }, - { - name: "API Keys", - href: "/settings/api", - icon: RiTerminalBoxLine, - activeIcon: RiTerminalBoxFill, - }, { name: "Bank Accounts", href: "/settings/bank-accounts", @@ -79,6 +73,13 @@ const accountNav = [ activeIcon: RiLock2Fill, }, + { + name: "Developer", + href: "/settings/developer", + icon: RiTerminalBoxLine, + activeIcon: RiTerminalBoxFill, + }, + { name: "Notifications", href: "/settings/notifications", diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 41ff88e0a..77903006a 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,4 +1,4 @@ -import { isSentryEnabled } from "./constants/sentry"; +import { isSentryEnabled } from "@/constants/sentry"; export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index b8302b9bb..147f61d7c 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,4 +1,5 @@ import { randomBytes, scryptSync, subtle } from "node:crypto"; +import { customId } from "@/common/id"; export const createHash = async (key: string) => { const data = new TextEncoder().encode(key); @@ -10,20 +11,30 @@ export const createHash = async (key: string) => { .toString(); }; -export const createApiToken = () => { - return randomBytes(32).toString("base64url"); +export const initializeAccessToken = ({ + prefix = "api", +}: { prefix?: string }) => { + const clientId = `${prefix}_${customId(8)}`; + const clientSecret = randomBytes(24) + .toString("base64url") + .replace(/[+/=_-]/g, customId(1)); + + return { + clientId, + clientSecret, + }; }; -export const createSecureHash = (key: string) => { - const data = new TextEncoder().encode(key); - const salt = randomBytes(16).toString("hex"); +export const createSecureHash = (secret: string) => { + const data = new TextEncoder().encode(secret); + const salt = randomBytes(32).toString("hex"); const derivedKey = scryptSync(data, salt, 64); return `${salt}:${derivedKey.toString("hex")}`; }; -export const verifySecureHash = (key: string, hash: string) => { - const data = new TextEncoder().encode(key); +export const verifySecureHash = (secret: string, hash: string) => { + const data = new TextEncoder().encode(secret); const [salt, storedHash] = hash.split(":"); const derivedKey = scryptSync(data, String(salt), 64); diff --git a/src/lib/rbac/subjects.ts b/src/lib/rbac/subjects.ts index 0fe9db07c..e8ad79f9f 100644 --- a/src/lib/rbac/subjects.ts +++ b/src/lib/rbac/subjects.ts @@ -6,7 +6,7 @@ export const SUBJECTS = [ "audits", "documents", "company", - "api-keys", + "developer", "bank-accounts", ] as const; export type TSubjects = (typeof SUBJECTS)[number]; diff --git a/src/server/api/auth.ts b/src/server/api/auth.ts deleted file mode 100644 index 3e98a14eb..000000000 --- a/src/server/api/auth.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { verifySecureHash } from "@/lib/crypto"; -import { ApiError } from "@/server/api/error"; -import { db } from "@/server/db"; -import type { Context } from "hono"; - -export const withMemberAuth = async (c: Context) => { - const bearerToken = await getBearerToken(c); - const user = await verifyBearerToken(bearerToken); - const membership = await db.member.findMany({ - where: { userId: user.id, status: "ACTIVE" }, - }); - - if (!membership.length) { - throw new ApiError({ - code: "FORBIDDEN", - message: "You do not have access to any companies", - }); - } - - return { user, membership }; -}; - -export const withCompanyAuth = async (c: Context) => { - const params = c.req.param(); - const id = params.id as string; - - const bearerToken = await getBearerToken(c); - const user = await verifyBearerToken(bearerToken); - const member = await db.member.findFirst({ - where: { - userId: user.id, - companyId: id, - }, - }); - - if (!member) { - throw new ApiError({ - code: "FORBIDDEN", - message: "You do not have access to this company", - }); - } - - const company = await db.company.findUnique({ - where: { id }, - }); - - if (!company) { - throw new ApiError({ - code: "NOT_FOUND", - message: "Company not found", - }); - } - - return { user, company, member }; -}; - -export const getBearerToken = async (c: Context) => { - const bearerToken = await c.req.header("Authorization"); - - if (!bearerToken) { - throw new ApiError({ - code: "UNAUTHORIZED", - message: "No bearer token provided", - }); - } - - return bearerToken; -}; - -const verifyBearerToken = async (bearerToken: string | null | undefined) => { - if (!bearerToken) { - throw new ApiError({ - code: "UNAUTHORIZED", - message: "No bearer token provided", - }); - } - - const bearer = bearerToken.split(" ")[1]; - if (!bearer) { - throw new ApiError({ - code: "UNAUTHORIZED", - message: "No bearer token provided", - }); - } - - const [keyId, token] = bearer.split(":"); - - if (!keyId || !token) { - throw new ApiError({ - code: "UNAUTHORIZED", - message: "Bearer token is invalid", - }); - } - - const apiKey = await db.apiKey.findUnique({ - where: { keyId, active: true }, - select: { - id: true, - keyId: true, - hashedToken: true, - member: { select: { user: true } }, - }, - }); - - if (!apiKey) { - throw new ApiError({ - code: "UNAUTHORIZED", - message: "Bearer token is invalid", - }); - } - - const isValid = await verifySecureHash(token, apiKey.hashedToken); - - if (!isValid) { - throw new ApiError({ - code: "UNAUTHORIZED", - message: "Bearer token is invalid", - }); - } - - await db.apiKey.update({ - where: { keyId }, - data: { lastUsed: new Date() }, - }); - - return apiKey.member.user; -}; diff --git a/src/server/api/const.ts b/src/server/api/const.ts index 9975390fe..86b6666b0 100644 --- a/src/server/api/const.ts +++ b/src/server/api/const.ts @@ -1 +1,2 @@ -export const LIMIT = 50; +export const DEFAULT_PAGINATION_LIMIT = 50; +export const SECURITY_SCHEME_NAME = "Bearer"; diff --git a/src/server/api/error.ts b/src/server/api/error.ts index f1c41392b..685b9c006 100644 --- a/src/server/api/error.ts +++ b/src/server/api/error.ts @@ -49,13 +49,26 @@ interface generateErrorResponseOptions { status: StatusCode; } +const errorRegistryMap: Record, string> = { + BAD_REQUEST: "BadRequest", + FORBIDDEN: "Forbidden", + INTERNAL_SERVER_ERROR: "InternalServerError", + RATE_LIMITED: "RateLimited", + METHOD_NOT_ALLOWED: "MethodNotAllowed", + NOT_FOUND: "NotFound", + NOT_UNIQUE: "NotUnique", + UNAUTHORIZED: "Unauthorized", +}; + const generateErrorResponse = (options: generateErrorResponseOptions) => { const { description, status } = options; return { [status]: { content: { "application/json": { - schema: errorSchemaFactory(z.enum([statusToCode(status)])), + schema: errorSchemaFactory(z.enum([statusToCode(status)])).openapi( + errorRegistryMap[statusToCode(status)], + ), }, }, description, diff --git a/src/server/api/hono.ts b/src/server/api/hono.ts index 2c666ab95..3a99f6361 100644 --- a/src/server/api/hono.ts +++ b/src/server/api/hono.ts @@ -3,20 +3,28 @@ import { handleError, handleZodError } from "@/server/api/error"; import type { TPrisma } from "@/server/db"; import { swaggerUI } from "@hono/swagger-ui"; import { OpenAPIHono } from "@hono/zod-openapi"; -import type { Context as GenericContext, MiddlewareHandler } from "hono"; - -export type HonoEnv = { - Bindings: { - NODE_ENV: (typeof env)["NODE_ENV"]; - }; - - Variables: { - db: TPrisma; - }; -}; +import type { Audit } from "../audit"; +import type { checkMembership } from "../auth"; +import { SECURITY_SCHEME_NAME } from "./const"; + +declare module "hono" { + interface ContextVariableMap { + services: { + db: TPrisma; + audit: typeof Audit; + client: { + requestIp: string; + userAgent: string; + }; + }; + session: { + membership: Awaited>; + }; + } +} export function PublicAPI() { - const api = new OpenAPIHono({ + const api = new OpenAPIHono({ defaultHook: handleZodError, }).basePath("/api"); @@ -31,15 +39,17 @@ export function PublicAPI() { servers: [{ url: `${env.NEXTAUTH_URL}` }], })); - api.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { - type: "http", - scheme: "bearer", - }); + api.openAPIRegistry.registerComponent( + "securitySchemes", + SECURITY_SCHEME_NAME, + { + type: "http", + scheme: "bearer", + }, + ); api.get("/v1/swagger", swaggerUI({ url: "/api/v1/schema" })); return api; } export type PublicAPI = ReturnType; -export type Context = GenericContext; -export type Middleware = MiddlewareHandler; diff --git a/src/server/api/index.ts b/src/server/api/index.ts index d47a20038..7e919c248 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -1,12 +1,12 @@ import { PublicAPI } from "./hono"; -import { initMiddleware } from "./middlewares/init"; +import { middlewareServices } from "./middlewares/services"; import { registerCompanyRoutes } from "./routes/company"; -import { registerShareRoutes } from "./routes/company/share"; -import { registerStakeholderRoutes } from "./routes/company/stakeholder"; +import { registerShareRoutes } from "./routes/share"; +import { registerStakeholderRoutes } from "./routes/stakeholder"; const api = PublicAPI(); -api.use("*", initMiddleware()); +api.use("*", middlewareServices()); // Register RESTful routes registerCompanyRoutes(api); diff --git a/src/server/api/middlewares/bearer-token.ts b/src/server/api/middlewares/bearer-token.ts new file mode 100644 index 000000000..625576ba8 --- /dev/null +++ b/src/server/api/middlewares/bearer-token.ts @@ -0,0 +1,143 @@ +import { verifySecureHash } from "@/lib/crypto"; +import type { Context } from "hono"; +import { createMiddleware } from "hono/factory"; +import { ApiError } from "../error"; + +export type accessTokenAuthMiddlewareOptions = + | { + withoutMembershipCheck?: boolean; + } + | undefined; + +export const accessTokenAuthMiddleware = ( + option?: accessTokenAuthMiddlewareOptions, +) => + createMiddleware(async (c, next) => { + const authHeader = c.req.header("Authorization"); + const bearerToken = authHeader?.replace("Bearer ", "").trim() ?? null; + + if (!bearerToken) { + throw new ApiError({ + code: "UNAUTHORIZED", + message: "Bearer token is invalid", + }); + } + + await authenticateWithAccessToken( + bearerToken, + c, + option?.withoutMembershipCheck, + ); + + await next(); + }); + +async function authenticateWithAccessToken( + bearerToken: string, + c: Context, + withoutMembershipCheck: undefined | boolean, +) { + const [clientId, clientSecret] = bearerToken.split(":") as [string, string]; + + if (!clientId || !clientSecret) { + throw new ApiError({ + code: "UNAUTHORIZED", + message: "Bearer token is invalid", + }); + } + + const accessToken = await findAccessToken(clientId, c); + + if (!accessToken) { + throw new ApiError({ + code: "UNAUTHORIZED", + message: "Bearer token is invalid", + }); + } + + const isAccessTokenValid = await verifySecureHash( + clientSecret, + accessToken.clientSecret, + ); + + if (!isAccessTokenValid) { + throw new ApiError({ + code: "UNAUTHORIZED", + message: "Bearer token is invalid", + }); + } + + if (withoutMembershipCheck) { + c.set("session", { + // @ts-expect-error + membership: { + userId: accessToken.userId, + }, + }); + } + + if (!withoutMembershipCheck) { + const { id: memberId, ...rest } = await checkMembership( + accessToken.userId, + c, + ); + c.set("session", { + membership: { memberId, ...rest }, + }); + } +} + +async function checkMembership(userId: string, c: Context) { + const { db } = c.get("services"); + const companyId = c.req.param("companyId"); + + if (!companyId || companyId === "") { + throw new ApiError({ + code: "BAD_REQUEST", + message: "Company id should be in the path", + }); + } + + const membership = await db.member.findFirst({ + where: { companyId, userId }, + select: { + id: true, + companyId: true, + role: true, + customRoleId: true, + userId: true, + user: { + select: { + name: true, + email: true, + }, + }, + }, + }); + + if (!membership) { + throw new ApiError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this resource", + }); + } + + return membership; +} + +function findAccessToken(clientId: string, c: Context) { + const { db } = c.get("services"); + + return db.accessToken.findFirst({ + where: { + clientId, + typeEnum: "api", + active: true, + }, + select: { + clientId: true, + clientSecret: true, + userId: true, + }, + }); +} diff --git a/src/server/api/middlewares/init.ts b/src/server/api/middlewares/init.ts deleted file mode 100644 index dbee7b419..000000000 --- a/src/server/api/middlewares/init.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { db } from "@/server/db"; -import type { Middleware } from "../hono"; - -export function initMiddleware(): Middleware { - return async (c, next) => { - c.set("db", db); - - await next(); - }; -} diff --git a/src/server/api/middlewares/services.ts b/src/server/api/middlewares/services.ts new file mode 100644 index 000000000..c63df10af --- /dev/null +++ b/src/server/api/middlewares/services.ts @@ -0,0 +1,18 @@ +import { Audit } from "@/server/audit"; +import { db } from "@/server/db"; +import { createMiddleware } from "hono/factory"; +import { getConnInfo } from "hono/vercel"; + +export const middlewareServices = () => + createMiddleware(async (c, next) => { + const req = c.req; + const info = getConnInfo(c); + const client = { + requestIp: info.remote.address ?? "Unknown IP", + userAgent: req.header("User-Agent") || "", + }; + + c.set("services", { db, audit: Audit, client }); + + await next(); + }); diff --git a/src/server/api/middlewares/session-token.ts b/src/server/api/middlewares/session-token.ts new file mode 100644 index 000000000..4cc0ecf89 --- /dev/null +++ b/src/server/api/middlewares/session-token.ts @@ -0,0 +1,88 @@ +import { invariant } from "@/lib/error"; +import { getPermissions } from "@/lib/rbac/access-control"; +import type { Context } from "hono"; +import { getCookie } from "hono/cookie"; +import { createMiddleware } from "hono/factory"; +import type { Session } from "next-auth"; +import { ApiError } from "../error"; + +export const sessionCookieAuthMiddleware = () => + createMiddleware(async (c, next) => { + await authenticateWithSessionCookie(c); + await next(); + }); + +export async function authenticateWithSessionCookie(c: Context) { + try { + const authUrl = process.env.NEXTAUTH_URL; + invariant(authUrl); + + const nextAuthcookieName = determineCookieName(authUrl); + const nextAuthCookie = getCookie(c, nextAuthcookieName); + + if (!nextAuthCookie) { + throw new Error("Session cookie not found"); + } + + await validateSessionCookie(authUrl, c); + } catch (_error) { + throw new ApiError({ + code: "UNAUTHORIZED", + message: "Failed to authenticate with session cookie", + }); + } +} + +function determineCookieName(authUrl: string): string { + return authUrl.startsWith("https://") + ? "__Secure-next-auth.session-token" + : "next-auth.session-token"; +} + +async function validateSessionCookie(authUrl: string, c: Context) { + const session = await fetchSessionFromAuthUrl(authUrl, c); + const companyIdParam = c.req.param("companyId"); + const { db } = c.get("services"); + + const { err, val } = await getPermissions({ + db, + session: { + ...session, + user: { + ...session.user, + ...(companyIdParam && { companyId: companyIdParam }), + }, + }, + }); + + if (err) { + throw err; + } + + c.set("session", { membership: val.membership }); +} + +async function fetchSessionFromAuthUrl( + authUrl: string, + c: Context, +): Promise { + const rawRequest = c.req.raw; + const clonedRequest = rawRequest.clone(); + const newUrl = new URL("/api/auth/session", authUrl).toString(); + + const response = await fetch( + new Request(newUrl, { + method: "GET", + headers: clonedRequest.headers, + body: clonedRequest.body, + }), + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error("Failed to fetch session from auth service"); + } + + return data as Session; +} diff --git a/src/server/api/pagination/find-many.proxy.ts b/src/server/api/pagination/find-many.proxy.ts deleted file mode 100644 index d1d3c3daa..000000000 --- a/src/server/api/pagination/find-many.proxy.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { PaginationData, ProxyFunctions } from "./types"; - -interface Paginated { - data: T[]; - count: number; - total: number | null; - cursor: string | null; -} - -/** - * FindManyPaginated - * - * type of the findManyPaginated method - */ -export type FindManyPaginated = { - findManyPaginated: ( - data: Omit[0], "take" | "skip" | "cursor">, - pagination: PaginationData, - ) => Promise>[0]>>; -}; - -/** - * makeFindManyPaginated - * - * factory function that creates the findManyPaginated method. - * this method is used to paginate the results of a findMany method. - * this method implements js proxy to intercept the call to findMany and add the pagination logic. - */ -export function makeFindManyPaginated(model: ProxyFunctions) { - return new Proxy(model.findMany, { - apply: async (target, thisArg, [data, paginationInfo]) => { - const take = paginationInfo?.take; - const cursor = paginationInfo?.cursor; - - const query = data || {}; - query.take = take; - if (cursor) { - query.cursor = { id: cursor }; // Assuming `id` is the cursor field - query.skip = 1; // Skip the cursor item itself - } - - const totalCount = await model.count({ - where: query.where, - }); - - //@ts-ignore - const docs = await target.apply(thisArg, [query]); - - const nextCursor = docs.length === take ? docs[docs.length - 1].id : null; // Assuming `id` is the cursor field - - return { - data: docs, - count: docs.length, - total: totalCount, - cursor: nextCursor, - }; - }, - }); -} diff --git a/src/server/api/pagination/prisma-proxy.ts b/src/server/api/pagination/prisma-proxy.ts deleted file mode 100644 index ec490a956..000000000 --- a/src/server/api/pagination/prisma-proxy.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - type FindManyPaginated, - makeFindManyPaginated, -} from "./find-many.proxy"; -import type { ProxyFunctions } from "./types"; - -/** - * ProxyPrismaModel - * - * type of a prisma model with custom methods. to date, only findManyPaginated is implemented - */ -type ProxyPrismaModel = F & FindManyPaginated; - -/** - * ProxyPrismaModel - * - * the factory function that creates a ProxyPrismaModel. to date, only findManyPaginated is implemented. - */ -export function ProxyPrismaModel( - model: F, -): ProxyPrismaModel { - Reflect.set(model, "findManyPaginated", makeFindManyPaginated(model)); - // Reflect.set(model, 'anotherProxiedMethod', makeAnotherProxyMethod(model)); - // ... - return model as ProxyPrismaModel; -} diff --git a/src/server/api/pagination/types.ts b/src/server/api/pagination/types.ts deleted file mode 100644 index 7a807219a..000000000 --- a/src/server/api/pagination/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Pagination information - */ -export type PaginationData = { - take: number; - cursor?: string; -}; - -/** - * Proxy functions - * - * used to create custom methods for prisma models - */ -export type ProxyFunctions = { - // biome-ignore lint/suspicious/noExplicitAny: - findMany: (params: any, pagination: PaginationData) => Promise; - // biome-ignore lint/suspicious/noExplicitAny: - count: (params: any) => Promise; -}; diff --git a/src/server/api/routes/company/getMany.ts b/src/server/api/routes/company/getMany.ts index 41c6d606f..d025b41e9 100644 --- a/src/server/api/routes/company/getMany.ts +++ b/src/server/api/routes/company/getMany.ts @@ -1,54 +1,40 @@ -import { withMemberAuth } from "@/server/api/auth"; -import { ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { ApiCompanySchema } from "@/server/api/schema/company"; -import { db } from "@/server/db"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Company } from "@prisma/client"; -import type { Context } from "hono"; +import { CompanySchema } from "@/server/api/schema/company"; +import { z } from "@hono/zod-openapi"; +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; -const route = createRoute({ - summary: "Get companies", - description: "Get list of companies", - tags: ["Company"], - method: "get", - path: "/v1/companies", - responses: { - 200: { - content: { - "application/json": { - schema: z.array(ApiCompanySchema).openapi({ - description: "List of companies", - }), +export const getMany = withAuthApiV1 + .createRoute({ + method: "get", + path: "/v1/companies", + tags: ["Company"], + summary: "List companies", + description: "Retrieve a list of membership companies.", + middleware: [authMiddleware({ withoutMembershipCheck: true })], + responses: { + 200: { + content: { + "application/json": { + schema: z.array(CompanySchema), + }, }, + description: "A list of companies with their details.", }, - description: "List companies", }, + }) + .handler(async (c) => { + const { db } = c.get("services"); + const { membership } = c.get("session"); - ...ErrorResponses, - }, -}); -const getMany = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { membership } = await withMemberAuth(c); - const companyIds = membership.map((m) => m.companyId); + const userMemberships = await db.member.findMany({ + where: { userId: membership.userId }, + select: { companyId: true }, + }); - const companies = (await db.company.findMany({ + const companies = await db.company.findMany({ where: { - id: { - in: companyIds, - }, + id: { in: userMemberships.map((item) => item.companyId) }, }, - })) as Company[]; - - const response = companies.map((company) => ({ - ...company, - logo: company.logo ?? undefined, - website: company.website ?? undefined, - })); + }); - return c.json(response, 200); + return c.json(companies, 200); }); -}; - -export default getMany; diff --git a/src/server/api/routes/company/getOne.ts b/src/server/api/routes/company/getOne.ts index dcffaf4f3..ff200a25d 100644 --- a/src/server/api/routes/company/getOne.ts +++ b/src/server/api/routes/company/getOne.ts @@ -1,59 +1,66 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { ApiCompanySchema } from "@/server/api/schema/company"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; +import { ApiError } from "@/server/api/error"; +import { CompanySchema } from "@/server/api/schema/company"; +import { z } from "@hono/zod-openapi"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; export const RequestSchema = z.object({ - id: z - .string() - .cuid() - .openapi({ - description: "Company ID", - param: { - name: "id", - in: "path", - }, + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); - example: "clxwbok580000i7nge8nm1ry0", - }), +const ResponseSchema = z.object({ + data: CompanySchema, }); -const route = createRoute({ - summary: "Get a companies", - description: "Get a company by ID", - tags: ["Company"], - method: "get", - path: "/v1/companies/{id}", - request: { params: RequestSchema }, - responses: { - 200: { - content: { - "application/json": { - schema: ApiCompanySchema, +export const getOne = withAuthApiV1 + .createRoute({ + method: "get", + path: "/v1/companies/{id}", + tags: ["Company"], + summary: "Get a company", + description: "Fetch details of a single company by its ID.", + middleware: [authMiddleware({ withoutMembershipCheck: true })], + request: { params: RequestSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, }, + description: "Details of the requested company.", }, - description: "Get a company by ID", }, + }) + .handler(async (c) => { + const { db } = c.get("services"); + const { membership } = c.get("session"); + const { id: companyId } = c.req.valid("param"); - ...ErrorResponses, - }, -}); -const getOne = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - // biome-ignore lint/suspicious/noExplicitAny: - const { company } = (await withCompanyAuth(c)) as { company: any }; + const member = await db.member.findFirst({ + where: { companyId, id: membership.memberId }, + select: { companyId: true }, + }); - if (!company) { + if (!member) { throw new ApiError({ - code: "NOT_FOUND", - message: "Company not found", + code: "UNAUTHORIZED", + message: `user is not a member of the company id:${companyId}`, }); } - return c.json(company, 200); + const company = await db.company.findFirstOrThrow({ + where: { + id: member.companyId, + }, + }); + return c.json({ data: company }, 200); }); -}; - -export default getOne; diff --git a/src/server/api/routes/company/index.ts b/src/server/api/routes/company/index.ts index 8ae61cacb..f57879ee9 100644 --- a/src/server/api/routes/company/index.ts +++ b/src/server/api/routes/company/index.ts @@ -1,8 +1,8 @@ import type { PublicAPI } from "@/server/api/hono"; -import getMany from "./getMany"; -import getOne from "./getOne"; +import { getMany } from "./getMany"; +import { getOne } from "./getOne"; export const registerCompanyRoutes = (api: PublicAPI) => { - getOne(api); - getMany(api); + api.openapi(getMany.route, getMany.handler); + api.openapi(getOne.route, getOne.handler); }; diff --git a/src/server/api/routes/company/share/create.ts b/src/server/api/routes/company/share/create.ts deleted file mode 100644 index c65fa806f..000000000 --- a/src/server/api/routes/company/share/create.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { - CreateShareSchema, - type ShareSchemaType, -} from "@/server/api/schema/shares"; -import { getHonoUserAgent, getIp } from "@/server/api/utils"; -import { addShare } from "@/server/services/shares/add-share"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; - -const ParamsSchema = z.object({ - id: z - .string() - .cuid() - .openapi({ - description: "Company ID", - param: { - name: "id", - in: "path", - }, - - example: "clycjihpy0002c5fzcyf4gjjc", - }), -}); - -const ResponseSchema = z.object({ - message: z.string(), - data: CreateShareSchema, -}); - -const route = createRoute({ - method: "post", - path: "/v1/companies/{id}/shares", - summary: "Issue shares", - description: "Issue shares to a stakeholder in a company.", - tags: ["Shares"], - request: { - params: ParamsSchema, - body: { - content: { - "application/json": { - schema: CreateShareSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Issue shares", - }, - ...ErrorResponses, - }, -}); - -const create = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company, member, user } = await withCompanyAuth(c); - const body = await c.req.json(); - - const response = await addShare({ - ...body, - companyId: company.id, - memberId: member.id, - requestIP: getIp(c.req), - userAgent: getHonoUserAgent(c.req), - user: { - id: user.id, - name: user.name, - }, - }); - - const data = response?.data; - const success = response?.success; - const message: string = response?.message.toString(); - - if (!success || !data) { - throw new ApiError({ - code: "INTERNAL_SERVER_ERROR", - message: "Something went wrong, please try again or contact support.", - }); - } - - // Ensure data matches ResponseSchema - const responseData: ShareSchemaType = { - status: data.status, // Cast to string if necessary - certificateId: data.certificateId, - quantity: data.quantity, - pricePerShare: data.pricePerShare ?? 0, - capitalContribution: data.capitalContribution ?? 0, - ipContribution: data.ipContribution ?? 0, - debtCancelled: data.debtCancelled ?? 0, - otherContributions: data.otherContributions ?? 0, - cliffYears: data.cliffYears ?? 0, - vestingYears: data.vestingYears ?? 0, - companyLegends: data.companyLegends ?? "", // Add missing fields - issueDate: data.issueDate - ? data.issueDate.toISOString() - : new Date().toISOString(), // Add missing fields - rule144Date: data.rule144Date - ? data.rule144Date.toISOString() - : new Date().toISOString(), // Convert rule144Date to string - vestingStartDate: data.vestingStartDate - ? data.vestingStartDate.toISOString() - : new Date().toISOString(), // Add missing fields - boardApprovalDate: data.boardApprovalDate - ? data.boardApprovalDate.toISOString() - : new Date().toISOString(), // Add boardApprovalDate - stakeholderId: data.stakeholderId ?? "", // Add stakeholderId - shareClassId: data.shareClassId, - }; - - return c.json({ message, data: responseData }, 200); - }); -}; - -export default create; diff --git a/src/server/api/routes/company/share/delete.ts b/src/server/api/routes/company/share/delete.ts deleted file mode 100644 index 4e97eddf4..000000000 --- a/src/server/api/routes/company/share/delete.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { - ApiError, - type ErrorCodeType, - ErrorResponses, -} from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { getHonoUserAgent, getIp } from "@/server/api/utils"; -import { deleteShare } from "@/server/services/shares/delete-share"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; -import { RequestParamsSchema } from "./update"; - -const ResponseSchema = z - .object({ - message: z.string(), - }) - .openapi({ - description: "Delete a Share by ID", - }); - -const route = createRoute({ - method: "delete", - path: "/v1/companies/{id}/shares/{shareId}", - summary: "Delete issued shares", - description: "Delete a Share by ID", - tags: ["Shares"], - request: { - params: RequestParamsSchema, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Delete a Share by ID", - }, - ...ErrorResponses, - }, -}); - -const deleteOne = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company, user } = await withCompanyAuth(c); - const { shareId: id } = c.req.param(); - - const { success, code, message } = await deleteShare({ - companyId: company.id, - requestIp: getIp(c.req), - userAgent: getHonoUserAgent(c.req), - shareId: id as string, - user: { id: user.id, name: user.name || "" }, - }); - - if (!success) { - throw new ApiError({ - code: code as ErrorCodeType, - message, - }); - } - - return c.json( - { - message: message, - }, - 200, - ); - }); -}; - -export default deleteOne; diff --git a/src/server/api/routes/company/share/getMany.ts b/src/server/api/routes/company/share/getMany.ts deleted file mode 100644 index 38cb8768a..000000000 --- a/src/server/api/routes/company/share/getMany.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { - DEFAULT_PAGINATION_LIMIT, - PaginationQuerySchema, - PaginationResponseSchema, -} from "@/server/api/schema/pagination"; -import { ShareSchema } from "@/server/api/schema/shares"; -import { getPaginatedShares } from "@/server/services/shares/get-shares"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; - -const ParamsSchema = z.object({ - id: z - .string() - .cuid() - .openapi({ - description: "Company ID", - param: { - name: "id", - in: "path", - }, - - example: "clxwbok580000i7nge8nm1ry0", - }), -}); - -const ResponseSchema = z - .object({ - data: z.array(ShareSchema), - meta: PaginationResponseSchema, - }) - .openapi({ - description: "Get Shares by Company ID", - }); - -const route = createRoute({ - method: "get", - path: "/v1/companies/{id}/shares", - summary: "Get list of issued shares", - description: "Get list of issued shares for a company", - tags: ["Shares"], - request: { - params: ParamsSchema, - query: PaginationQuerySchema, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Retrieve the shares for the company", - }, - ...ErrorResponses, - }, -}); - -const getMany = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company } = await withCompanyAuth(c); - - const { take, cursor, total } = c.req.query(); - - const { data, meta } = await getPaginatedShares({ - companyId: company.id, - take: Number(take || DEFAULT_PAGINATION_LIMIT), - cursor, - total: Number(total), - }); - - return c.json( - { - data, - meta, - }, - 200, - ); - }); -}; - -export default getMany; diff --git a/src/server/api/routes/company/share/getOne.ts b/src/server/api/routes/company/share/getOne.ts deleted file mode 100644 index a85ee6489..000000000 --- a/src/server/api/routes/company/share/getOne.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { ShareSchema, type ShareSchemaType } from "@/server/api/schema/shares"; -import { db } from "@/server/db"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; - -const ParamsSchema = z.object({ - id: z - .string() - .cuid() - .openapi({ - description: "Company ID", - param: { - name: "id", - in: "path", - }, - - example: "clxwbok580000i7nge8nm1ry0", - }), - shareId: z - .string() - .cuid() - .openapi({ - description: "Share ID", - param: { - name: "shareId", - in: "path", - }, - - example: "clyd3i9sw000008ij619eabva", - }), -}); - -const ResponseSchema = z - .object({ - data: ShareSchema, - }) - .openapi({ - description: "Get a single Share by ID", - }); - -const route = createRoute({ - method: "get", - path: "/v1/companies/{id}/shares/{shareId}", - summary: "Get an issued share by ID", - description: "Get a single issued share record by ID", - tags: ["Shares"], - request: { - params: ParamsSchema, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Retrieve the share for the company", - }, - ...ErrorResponses, - }, -}); - -const getOne = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company } = await withCompanyAuth(c); - - // id destructured to companyId and shareId destructured to id - const { shareId: id } = c.req.param(); - - const share = (await db.share.findUnique({ - where: { - id, - companyId: company.id, - }, - })) as unknown as ShareSchemaType; - - if (!share) { - throw new ApiError({ - code: "NOT_FOUND", - message: "Share not found", - }); - } - - return c.json( - { - data: share, - }, - 200, - ); - }); -}; - -export default getOne; diff --git a/src/server/api/routes/company/share/index.ts b/src/server/api/routes/company/share/index.ts deleted file mode 100644 index 5de504824..000000000 --- a/src/server/api/routes/company/share/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PublicAPI } from "@/server/api/hono"; -import create from "./create"; -import deleteOne from "./delete"; -import getMany from "./getMany"; -import getOne from "./getOne"; -import update from "./update"; - -export const registerShareRoutes = (api: PublicAPI) => { - create(api); - getOne(api); - getMany(api); - update(api); - deleteOne(api); -}; diff --git a/src/server/api/routes/company/share/update.ts b/src/server/api/routes/company/share/update.ts deleted file mode 100644 index 467025a82..000000000 --- a/src/server/api/routes/company/share/update.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { - UpdateShareSchema, - type UpdateShareSchemaType, -} from "@/server/api/schema/shares"; -import { getHonoUserAgent, getIp } from "@/server/api/utils"; -import { updateShare } from "@/server/services/shares/update-share"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; - -export const RequestParamsSchema = z - .object({ - id: z - .string() - .cuid() - .openapi({ - description: "Company ID", - param: { - name: "id", - in: "path", - }, - - example: "clxwbok580000i7nge8nm1ry0", - }), - shareId: z - .string() - .cuid() - .openapi({ - description: "Share ID", - param: { - name: "shareId", - in: "path", - }, - - example: "clyd3i9sw000008ij619eabva", - }), - }) - .openapi({ - description: "Update a Share by ID", - }); - -const ResponseSchema = z - .object({ - message: z.string(), - data: UpdateShareSchema, - }) - .openapi({ - description: "Update a Share by ID", - }); - -const route = createRoute({ - method: "put", - path: "/v1/companies/{id}/shares/{shareId}", - summary: "Update issued shares by ID", - description: "Update issued shares by share ID", - tags: ["Shares"], - request: { - params: RequestParamsSchema, - body: { - content: { - "application/json": { - schema: UpdateShareSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Update the Share by ID", - }, - ...ErrorResponses, - }, -}); - -const getOne = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company, user } = await withCompanyAuth(c); - const { shareId } = c.req.param(); - const body = await c.req.json(); - - const payload = { - shareId: shareId as string, - companyId: company.id, - requestIp: getIp(c.req), - userAgent: getHonoUserAgent(c.req), - data: body as UpdateShareSchemaType, - user: { - id: user.id, - name: user.name as string, - }, - }; - - const { success, message, data } = await updateShare(payload); - - if (!success) { - throw new ApiError({ - code: "NOT_FOUND", - message, - }); - } - - return c.json( - { - message: message, - data: { - ...data, - issueDate: data?.issueDate.toISOString(), // Convert Date to string - } as UpdateShareSchemaType, - }, - 200, - ); - }); -}; - -export default getOne; diff --git a/src/server/api/routes/company/stakeholder/create.ts b/src/server/api/routes/company/stakeholder/create.ts deleted file mode 100644 index 4230ba17e..000000000 --- a/src/server/api/routes/company/stakeholder/create.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import { - AddStakeholderSchema, - type TAddStakeholderSchema, -} from "@/server/api/schema/stakeholder"; -import { getHonoUserAgent, getIp } from "@/server/api/utils"; -import { addStakeholders } from "@/server/services/stakeholder/add-stakeholders"; -import { createRoute, z } from "@hono/zod-openapi"; -import { RequestParamsSchema } from "./getMany"; - -import type { PublicAPI } from "@/server/api/hono"; -import { Prisma } from "@prisma/client"; -import type { Context } from "hono"; - -function uniqueEmails(stakeholders: TAddStakeholderSchema) { - const emails = stakeholders.map((stakeholder) => stakeholder.email); - const uniqueEmails = new Set(emails); - return uniqueEmails.size === emails.length; -} - -const RequestBodyAttributes = AddStakeholderSchema.element - .omit({ - id: true, - createdAt: true, - updatedAt: true, - }) - .array(); - -const RequestBodySchema = RequestBodyAttributes.refine(uniqueEmails, { - message: "Please provide unique email addresses.", -}); - -const ResponseSchema = z.object({ - message: z.string(), - data: AddStakeholderSchema, -}); - -const route = createRoute({ - method: "post", - path: "/v1/companies/{id}/stakeholders", - summary: "Create stakeholders", - description: "Create one or stakeholders account in a company.", - tags: ["Stakeholder"], - request: { - params: RequestParamsSchema, - body: { - content: { - "application/json": { - schema: RequestBodySchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Add many stakeholders in a company.", - }, - - ...ErrorResponses, - }, -}); - -const create = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company, user } = await withCompanyAuth(c); - - const body = await c.req.json(); - - const payload = { - companyId: company.id, - requestIp: getIp(c.req), - userAgent: getHonoUserAgent(c.req), - user: { - id: user.id, - name: user.name as string, - }, - data: body as TAddStakeholderSchema, - }; - try { - const stakeholders = await addStakeholders(payload); - - return c.json( - { - message: "Stakeholders successfully created.", - data: stakeholders.map((s) => ({ - ...s, - institutionName: s.institutionName ?? undefined, - streetAddress: s.streetAddress ?? undefined, - city: s.city ?? undefined, - state: s.state ?? undefined, - zipcode: s.zipcode ?? undefined, - country: s.country ?? undefined, - createdAt: s.createdAt.toISOString(), - updatedAt: s.updatedAt.toISOString(), - })), - }, - 200, - ); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") { - throw new ApiError({ - code: "BAD_REQUEST", - message: "Stakeholder with the provided email already exists.", - }); - } - } - throw new ApiError({ - code: "BAD_REQUEST", - message: - error instanceof Error - ? error.message - : "Something went wrong. Please try again later.", - }); - } - }); -}; - -export default create; diff --git a/src/server/api/routes/company/stakeholder/delete.ts b/src/server/api/routes/company/stakeholder/delete.ts deleted file mode 100644 index a1ab58a41..000000000 --- a/src/server/api/routes/company/stakeholder/delete.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { getHonoUserAgent, getIp } from "@/server/api/utils"; -import { db } from "@/server/db"; -import { deleteStakeholder } from "@/server/services/stakeholder/delete-stakeholder"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; - -export const RequestParamsSchema = z.object({ - id: z.string().openapi({ - param: { - name: "id", - in: "path", - }, - description: "Company ID", - type: "string", - example: "clxwbok580000i7nge8nm1ry0", - }), - stakeholderId: z.string().openapi({ - param: { - name: "stakeholderId", - in: "path", - }, - description: "Stakeholder ID", - type: "string", - example: "clyabgufg004u5tbtnz0r4cax", - }), -}); - -const ResponseSchema = z - .object({ - message: z.string(), - }) - .openapi({ - description: "Delete a stakeholder by ID in a company.", - }); - -const route = createRoute({ - summary: "Delete stakeholder", - description: "Delete a stakeholder by ID in a company.", - tags: ["Stakeholder"], - method: "delete", - path: "/v1/companies/{id}/stakeholders/{stakeholderId}", - request: { params: RequestParamsSchema }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Delete a stakeholder by ID in a company.", - }, - - ...ErrorResponses, - }, -}); - -const deleteOne = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company, user } = await withCompanyAuth(c); - const { stakeholderId } = c.req.param(); - - const foundStakeholder = await db.stakeholder.findUnique({ - where: { - id: stakeholderId, - companyId: company.id, - }, - }); - - if (!foundStakeholder) { - throw new ApiError({ - code: "NOT_FOUND", - message: "No stakeholder with the provided Id", - }); - } - - const payload = { - companyId: company.id, - stakeholderId: stakeholderId as string, - user: { id: user.id, name: user.name as string }, - requestIp: getIp(c.req), - userAgent: getHonoUserAgent(c.req), - }; - - await deleteStakeholder(payload); - - return c.json( - { - message: "Stakeholder deleted successfully", - }, - 200, - ); - }); -}; - -export default deleteOne; diff --git a/src/server/api/routes/company/stakeholder/getMany.ts b/src/server/api/routes/company/stakeholder/getMany.ts deleted file mode 100644 index b8e97973e..000000000 --- a/src/server/api/routes/company/stakeholder/getMany.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { - PaginationQuerySchema, - PaginationResponseSchema, -} from "@/server/api/schema/pagination"; -import { - StakeholderSchema, - type TStakeholderSchema, -} from "@/server/api/schema/stakeholder"; -import { getPaginatedStakeholders } from "@/server/services/stakeholder/get-stakeholders"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; - -export const RequestParamsSchema = z.object({ - id: z.string().openapi({ - param: { - name: "id", - in: "path", - }, - description: "Company ID", - type: "string", - example: "clxwbok580000i7nge8nm1ry0", - }), -}); - -const ResponseSchema = z.object({ - data: z.array(StakeholderSchema), - meta: PaginationResponseSchema, -}); - -const route = createRoute({ - summary: "Get stakeholders", - description: "Get paginated stakeholders from a company.", - tags: ["Stakeholder"], - method: "get", - path: "/v1/companies/{id}/stakeholders", - request: { - params: RequestParamsSchema, - query: PaginationQuerySchema, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Get paginated stakeholders from a company.", - }, - ...ErrorResponses, - }, -}); - -const getMany = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company } = await withCompanyAuth(c); - const query = c.req.query(); - - const { data, meta } = await getPaginatedStakeholders({ - take: Number(query.limit) || 10, - cursor: query?.cursor, - companyId: company.id, - }); - - return c.json( - { - data: data as unknown as TStakeholderSchema[], - meta, - }, - 200, - ); - }); -}; - -export default getMany; diff --git a/src/server/api/routes/company/stakeholder/getOne.ts b/src/server/api/routes/company/stakeholder/getOne.ts deleted file mode 100644 index 9d09ab3e7..000000000 --- a/src/server/api/routes/company/stakeholder/getOne.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { - StakeholderSchema, - type TStakeholderSchema, -} from "@/server/api/schema/stakeholder"; -import { db } from "@/server/db"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; -import { RequestParamsSchema } from "./delete"; - -const ResponseSchema = z - .object({ - data: StakeholderSchema, - }) - .openapi({ - description: "Get a single stakeholder by ID", - }); - -const route = createRoute({ - summary: "Get a stakeholder", - description: "Get a single stakeholder by ID", - tags: ["Stakeholder"], - method: "get", - path: "/v1/companies/{id}/stakeholders/{stakeholderId}", - request: { params: RequestParamsSchema }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Get a single stakeholder by ID", - }, - - ...ErrorResponses, - }, -}); - -const getOne = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company } = await withCompanyAuth(c); - - const { stakeholderId } = c.req.param(); - - const stakeholder = (await db.stakeholder.findFirst({ - where: { - id: stakeholderId as string, - companyId: company.id, - }, - })) as unknown as TStakeholderSchema; - - if (!stakeholder) { - throw new ApiError({ - code: "NOT_FOUND", - message: "Stakeholder not found", - }); - } - - return c.json({ data: stakeholder }, 200); - }); -}; - -export default getOne; diff --git a/src/server/api/routes/company/stakeholder/index.ts b/src/server/api/routes/company/stakeholder/index.ts deleted file mode 100644 index 3106f5f61..000000000 --- a/src/server/api/routes/company/stakeholder/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PublicAPI } from "@/server/api/hono"; -import create from "./create"; -import deleteOne from "./delete"; -import getMany from "./getMany"; -import getOne from "./getOne"; -import update from "./update"; - -export const registerStakeholderRoutes = (api: PublicAPI) => { - create(api); - getOne(api); - getMany(api); - update(api); - deleteOne(api); -}; diff --git a/src/server/api/routes/company/stakeholder/update.ts b/src/server/api/routes/company/stakeholder/update.ts deleted file mode 100644 index 57968ece7..000000000 --- a/src/server/api/routes/company/stakeholder/update.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { withCompanyAuth } from "@/server/api/auth"; -import { ApiError, ErrorResponses } from "@/server/api/error"; -import type { PublicAPI } from "@/server/api/hono"; -import { - StakeholderSchema, - type TStakeholderSchema, - type TUpdateStakeholderSchema, - UpdateStakeholderSchema, -} from "@/server/api/schema/stakeholder"; -import { getHonoUserAgent, getIp } from "@/server/api/utils"; -import { db } from "@/server/db"; -import { updateStakeholder } from "@/server/services/stakeholder/update-stakeholder"; -import { createRoute, z } from "@hono/zod-openapi"; -import type { Context } from "hono"; -import { RequestParamsSchema } from "./delete"; - -const ResponseSchema = z - .object({ - message: z.string(), - data: StakeholderSchema, - }) - .openapi({ - description: "Update a stakeholder by ID", - }); - -const route = createRoute({ - summary: "Update stakeholder", - description: "Update a stakeholder by ID", - tags: ["Stakeholder"], - method: "put", - path: "/v1/companies/{id}/stakeholders/{stakeholderId}", - request: { - params: RequestParamsSchema, - body: { - content: { - "application/json": { - schema: UpdateStakeholderSchema, - }, - }, - }, - }, - responses: { - 200: { - content: { - "application/json": { - schema: ResponseSchema, - }, - }, - description: "Update stakeholder in a company", - }, - - ...ErrorResponses, - }, -}); - -const update = (app: PublicAPI) => { - app.openapi(route, async (c: Context) => { - const { company, user } = await withCompanyAuth(c); - - const params = c.req.param(); - const stakeholderId = params.stakeholderId as string; - - const body = await c.req.json(); - - const foundStakeholder = await db.stakeholder.findFirst({ - where: { - id: stakeholderId, - }, - }); - - if (!foundStakeholder) { - throw new ApiError({ - code: "NOT_FOUND", - message: "No stakeholder with provided id", - }); - } - - const payload = { - stakeholderId: stakeholderId, - companyId: company.id, - requestIp: getIp(c.req), - userAgent: getHonoUserAgent(c.req), - data: body as TUpdateStakeholderSchema, - user: { - id: user.id, - name: user.name as string, - }, - }; - - const { updatedStakeholder } = await updateStakeholder(payload); - - if (!updatedStakeholder) { - throw new ApiError({ - code: "BAD_REQUEST", - message: "Stakeholder not updated.", - }); - } - - return c.json( - { - message: "Stakeholder updated successfully", - data: updatedStakeholder as unknown as TStakeholderSchema, - }, - 200, - ); - }); -}; - -export default update; diff --git a/src/server/api/routes/share/create.ts b/src/server/api/routes/share/create.ts new file mode 100644 index 000000000..c0768ea4f --- /dev/null +++ b/src/server/api/routes/share/create.ts @@ -0,0 +1,110 @@ +import { z } from "@hono/zod-openapi"; +import { + CreateShareSchema, + type CreateShareSchemaType, +} from "../../schema/shares"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ResponseSchema = z.object({ + message: z.string(), + data: CreateShareSchema, +}); + +const ParamsSchema = z.object({ + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +export const create = withAuthApiV1 + .createRoute({ + method: "post", + path: "/v1/{companyId}/shares", + summary: "Create shares", + description: "Issue shares to a stakeholder in a company.", + tags: ["Shares"], + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + body: { + content: { + "application/json": { + schema: CreateShareSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation of shares issued with relevant details.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client; + + const body = c.req.valid("json"); + + const share = await db.$transaction(async (tx) => { + const share = await tx.share.create({ + data: { ...body, companyId: membership.companyId }, + }); + + // if (documents && documents.length > 0) { + // const bulkDocuments = documents.map((doc) => ({ + // companyId: input.companyId, + // uploaderId: input.memberId, + // publicId: generatePublicId(), + // name: doc.name, + // bucketId: doc.bucketId, + // shareId: share.id, + // })); + + // await tx.document.createMany({ + // data: bulkDocuments, + // skipDuplicates: true, + // }); + // } + + await audit.create( + { + action: "share.created", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "share", id: share.id }], + summary: `${membership.user.name} added share for stakeholder ${share.stakeholderId}`, + }, + tx, + ); + + return share; + }); + + const data: CreateShareSchemaType = { + ...share, + issueDate: share.issueDate.toISOString(), + boardApprovalDate: share.boardApprovalDate.toISOString(), + rule144Date: share.rule144Date?.toISOString(), + vestingStartDate: share.vestingStartDate?.toISOString(), + }; + + return c.json({ message: "Share successfully created.", data }, 200); + }); diff --git a/src/server/api/routes/share/delete.ts b/src/server/api/routes/share/delete.ts new file mode 100644 index 000000000..3880c7036 --- /dev/null +++ b/src/server/api/routes/share/delete.ts @@ -0,0 +1,111 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Share ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + message: z.string().openapi({ + description: + "A text providing details about the API request result, including success, error, or warning messages.", + }), +}); + +export const _delete = withAuthApiV1 + .createRoute({ + method: "delete", + path: "/v1/{companyId}/shares/{id}", + summary: "Delete a share", + description: "Remove an issued share by its ID.", + tags: ["Shares"], + middleware: [authMiddleware()], + request: { params: ParamsSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation that the issued share has been removed.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client as { + requestIp: string; + userAgent: string; + }; + + const { id } = c.req.valid("param"); + + await db.$transaction(async (tx) => { + const share = await tx.share.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + select: { + id: true, + stakeholderId: true, + }, + }); + + if (!share) { + throw new ApiError({ + code: "NOT_FOUND", + message: `Share with ID ${id} not found`, + }); + } + + await tx.share.delete({ + where: { + id: share.id, + }, + }); + + await audit.create( + { + action: "share.deleted", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "share", id }], + summary: `${membership.user.name} Deleted the share for stakeholder ${share.stakeholderId}`, + }, + tx, + ); + }); + + return c.json( + { + message: "Share deleted successfully", + }, + 200, + ); + }); diff --git a/src/server/api/routes/share/getMany.ts b/src/server/api/routes/share/getMany.ts new file mode 100644 index 000000000..9b1003b44 --- /dev/null +++ b/src/server/api/routes/share/getMany.ts @@ -0,0 +1,76 @@ +import { z } from "@hono/zod-openapi"; +import { + PaginationQuerySchema, + PaginationResponseSchema, +} from "../../schema/pagination"; +import { ShareSchema } from "../../schema/shares"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ResponseSchema = z.object({ + data: z.array(ShareSchema), + meta: PaginationResponseSchema, +}); + +const ParamsSchema = z.object({ + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +export const getMany = withAuthApiV1 + .createRoute({ + summary: "List shares", + description: "Retrieve a list of issued shares for the company.", + tags: ["Shares"], + method: "get", + path: "/v1/{companyId}/shares", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + query: PaginationQuerySchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "A list of issued shares with their details.", + }, + }, + }) + .handler(async (c) => { + const { membership } = c.get("session"); + const { db } = c.get("services"); + const query = c.req.valid("query"); + + const [data, meta] = await db.share + .paginate({ where: { companyId: membership.companyId } }) + .withCursor({ + limit: query.limit, + after: query.cursor, + }); + + const response: z.infer = { + meta, + data: data.map((i) => ({ + ...i, + createdAt: i.createdAt.toISOString(), + updatedAt: i.updatedAt.toISOString(), + issueDate: i.issueDate.toISOString(), + rule144Date: i.rule144Date?.toISOString(), + vestingStartDate: i.vestingStartDate?.toISOString(), + boardApprovalDate: i.boardApprovalDate?.toISOString(), + })), + }; + + return c.json(response, 200); + }); diff --git a/src/server/api/routes/share/getOne.ts b/src/server/api/routes/share/getOne.ts new file mode 100644 index 000000000..ad07c2c7e --- /dev/null +++ b/src/server/api/routes/share/getOne.ts @@ -0,0 +1,89 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; +import { ShareSchema, type ShareSchemaType } from "../../schema/shares"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Share ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + data: ShareSchema, +}); + +export const getOne = withAuthApiV1 + .createRoute({ + summary: "Get a share", + description: "Fetch a single issued share record by its ID.", + tags: ["Shares"], + method: "get", + path: "/v1/{companyId}/shares/{id}", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Details of the requested issued share.", + }, + }, + }) + .handler(async (c) => { + const { db } = c.get("services"); + const { membership } = c.get("session"); + const { id } = c.req.valid("param"); + + const share = await db.share.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + }); + + if (!share) { + throw new ApiError({ + code: "NOT_FOUND", + message: `No share with the provided Id ${id}`, + }); + } + + const data: ShareSchemaType = { + ...share, + createdAt: share.createdAt.toISOString(), + updatedAt: share.updatedAt.toISOString(), + issueDate: share.issueDate.toISOString(), + rule144Date: share.rule144Date?.toISOString(), + vestingStartDate: share.vestingStartDate?.toISOString(), + boardApprovalDate: share.boardApprovalDate?.toISOString(), + }; + + return c.json( + { + data, + }, + 200, + ); + }); diff --git a/src/server/api/routes/share/index.ts b/src/server/api/routes/share/index.ts new file mode 100644 index 000000000..96bed96d2 --- /dev/null +++ b/src/server/api/routes/share/index.ts @@ -0,0 +1,14 @@ +import type { PublicAPI } from "@/server/api/hono"; +import { create } from "./create"; +import { _delete } from "./delete"; +import { getMany } from "./getMany"; +import { getOne } from "./getOne"; +import { update } from "./update"; + +export const registerShareRoutes = (api: PublicAPI) => { + api.openapi(_delete.route, _delete.handler); + api.openapi(getOne.route, getOne.handler); + api.openapi(getMany.route, getMany.handler); + api.openapi(create.route, create.handler); + api.openapi(update.route, update.handler); +}; diff --git a/src/server/api/routes/share/update.ts b/src/server/api/routes/share/update.ts new file mode 100644 index 000000000..55f90d0c5 --- /dev/null +++ b/src/server/api/routes/share/update.ts @@ -0,0 +1,132 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; + +import { + ShareSchema, + type ShareSchemaType, + UpdateShareSchema, +} from "../../schema/shares"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Share ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + message: z.string(), + data: ShareSchema, +}); + +export const update = withAuthApiV1 + .createRoute({ + summary: "Update Issued Shares", + description: "Update details of an issued share by its ID.", + tags: ["Shares"], + method: "patch", + path: "/v1/{companyId}/stakeholders/{id}", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + body: { + content: { + "application/json": { + schema: UpdateShareSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation of updated issued share details.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client; + const { id } = c.req.valid("param"); + + const body = c.req.valid("json"); + + const updatedShare = await db.$transaction(async (tx) => { + const share = await db.share.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + }); + + if (!share) { + throw new ApiError({ + code: "NOT_FOUND", + message: `No share with the provided Id ${id}`, + }); + } + + const updatedShare = await tx.share.update({ + where: { + id: share.id, + }, + data: body, + }); + + await audit.create( + { + action: "share.updated", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + userAgent: userAgent, + requestIp: requestIp, + }, + target: [{ type: "share", id: share.id }], + summary: `${membership.user.name} updated share the share ID ${updatedShare.id}`, + }, + tx, + ); + + return updatedShare; + }); + + const data: ShareSchemaType = { + ...updatedShare, + createdAt: updatedShare.createdAt.toISOString(), + updatedAt: updatedShare.updatedAt.toISOString(), + issueDate: updatedShare.issueDate.toISOString(), + rule144Date: updatedShare.rule144Date?.toISOString(), + vestingStartDate: updatedShare.vestingStartDate?.toISOString(), + boardApprovalDate: updatedShare.boardApprovalDate?.toISOString(), + }; + + return c.json( + { + message: "Stakeholder updated successfully", + data, + }, + 200, + ); + }); diff --git a/src/server/api/routes/stakeholder/create.ts b/src/server/api/routes/stakeholder/create.ts new file mode 100644 index 000000000..3f2a572d8 --- /dev/null +++ b/src/server/api/routes/stakeholder/create.ts @@ -0,0 +1,109 @@ +import { z } from "@hono/zod-openapi"; +import { + CreateStakeholderSchema, + StakeholderSchema, +} from "../../schema/stakeholder"; +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ResponseSchema = z.object({ + message: z.string(), + data: z.array(StakeholderSchema.pick({ id: true, name: true })), +}); + +const ParamsSchema = z.object({ + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +export const create = withAuthApiV1 + .createRoute({ + method: "post", + path: "/v1/{companyId}/stakeholders", + summary: "Create stakeholders", + description: "Add one or more stakeholder accounts to a company.", + tags: ["Stakeholder"], + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + body: { + content: { + "application/json": { + schema: CreateStakeholderSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: + "Confirmation of stakeholder created with relevant details.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client as { + requestIp: string; + userAgent: string; + }; + + const body = c.req.valid("json"); + + const stakeholders = await db.$transaction(async (tx) => { + const inputDataWithCompanyId = body.map((stakeholder) => ({ + ...stakeholder, + companyId: membership.companyId, + })); + + const addedStakeholders = await tx.stakeholder.createManyAndReturn({ + data: inputDataWithCompanyId, + select: { + id: true, + name: true, + }, + }); + + const auditPromises = addedStakeholders.map((stakeholder) => + audit.create( + { + action: "stakeholder.added", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "stakeholder", id: stakeholder.id }], + summary: `${membership.user.name} added the stakholder in the company : ${stakeholder.name}`, + }, + tx, + ), + ); + await Promise.all(auditPromises); + + return addedStakeholders; + }); + + const data: z.infer["data"] = stakeholders; + + return c.json( + { + data, + message: "Stakeholders successfully created.", + }, + 200, + ); + }); diff --git a/src/server/api/routes/stakeholder/delete.ts b/src/server/api/routes/stakeholder/delete.ts new file mode 100644 index 000000000..48f49c4e5 --- /dev/null +++ b/src/server/api/routes/stakeholder/delete.ts @@ -0,0 +1,105 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; + +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Stakeholder ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + message: z.string(), +}); + +export const _delete = withAuthApiV1 + .createRoute({ + summary: "Delete a stakeholder", + description: "Remove a stakeholder from a company by ID.", + tags: ["Stakeholder"], + method: "delete", + path: "/v1/{companyId}/stakeholders/{id}", + middleware: [authMiddleware()], + request: { params: ParamsSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation that the stakeholder has been removed.", + }, + }, + }) + .handler(async (c) => { + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client; + const { id } = c.req.valid("param"); + + await db.$transaction(async (tx) => { + const stakeholder = await tx.stakeholder.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + select: { + id: true, + companyId: true, + name: true, + }, + }); + + if (!stakeholder) { + throw new ApiError({ + code: "NOT_FOUND", + message: "No stakeholder with the provided Id", + }); + } + + await tx.stakeholder.delete({ + where: { + id: stakeholder.id, + }, + }); + + await audit.create( + { + action: "stakeholder.deleted", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "stakeholder", id }], + summary: `${membership.user.name} deleted the stakeholder ${stakeholder.name} - ${stakeholder.id}`, + }, + tx, + ); + }); + + return c.json( + { + message: "Stakeholder deleted successfully", + }, + 200, + ); + }); diff --git a/src/server/api/routes/stakeholder/getMany.ts b/src/server/api/routes/stakeholder/getMany.ts new file mode 100644 index 000000000..c34554d41 --- /dev/null +++ b/src/server/api/routes/stakeholder/getMany.ts @@ -0,0 +1,72 @@ +import { z } from "@hono/zod-openapi"; +import { + PaginationQuerySchema, + PaginationResponseSchema, +} from "../../schema/pagination"; +import { StakeholderSchema } from "../../schema/stakeholder"; +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ResponseSchema = z.object({ + data: z.array(StakeholderSchema), + meta: PaginationResponseSchema, +}); + +const ParamsSchema = z.object({ + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +export const getMany = withAuthApiV1 + .createRoute({ + summary: "List stakeholders", + description: + "Retrieve a paginated list of all stakeholders in the company.", + tags: ["Stakeholder"], + method: "get", + path: "/v1/{companyId}/stakeholders", + middleware: [authMiddleware()], + request: { + query: PaginationQuerySchema, + params: ParamsSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "A list of stakeholders in a company with their details.", + }, + }, + }) + .handler(async (c) => { + const { membership } = c.get("session"); + const { db } = c.get("services"); + const query = c.req.valid("query"); + + const [data, meta] = await db.stakeholder + .paginate({ where: { companyId: membership.companyId } }) + .withCursor({ + limit: query.limit, + after: query.cursor, + }); + + const response: z.infer = { + data: data.map((stakeholder) => ({ + ...stakeholder, + createdAt: stakeholder.createdAt.toISOString(), + updatedAt: stakeholder.updatedAt.toISOString(), + })), + meta, + }; + + return c.json(response, 200); + }); diff --git a/src/server/api/routes/stakeholder/getOne.ts b/src/server/api/routes/stakeholder/getOne.ts new file mode 100644 index 000000000..812128892 --- /dev/null +++ b/src/server/api/routes/stakeholder/getOne.ts @@ -0,0 +1,87 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; +import { + StakeholderSchema, + type TStakeholderSchema, +} from "../../schema/stakeholder"; +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Stakeholder ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + data: StakeholderSchema, +}); + +export const getOne = withAuthApiV1 + .createRoute({ + summary: "Get a stakeholder", + description: "Fetch details of a single stakeholder by their ID.", + tags: ["Stakeholder"], + method: "get", + path: "/v1/{companyId}/stakeholders/{id}", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Details of the requested stakeholder.", + }, + }, + }) + .handler(async (c) => { + const { db } = c.get("services"); + const { membership } = c.get("session"); + const { id } = c.req.valid("param"); + + const stakeholder = await db.stakeholder.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + }); + + if (!stakeholder) { + throw new ApiError({ + code: "NOT_FOUND", + message: "No stakeholder with the provided Id", + }); + } + + const data: TStakeholderSchema = { + ...stakeholder, + createdAt: stakeholder.createdAt.toISOString(), + updatedAt: stakeholder.updatedAt.toISOString(), + }; + + return c.json( + { + data, + }, + 200, + ); + }); diff --git a/src/server/api/routes/stakeholder/index.ts b/src/server/api/routes/stakeholder/index.ts new file mode 100644 index 000000000..ce936807c --- /dev/null +++ b/src/server/api/routes/stakeholder/index.ts @@ -0,0 +1,14 @@ +import type { PublicAPI } from "@/server/api/hono"; +import { create } from "./create"; +import { _delete } from "./delete"; +import { getMany } from "./getMany"; +import { getOne } from "./getOne"; +import { update } from "./update"; + +export const registerStakeholderRoutes = (api: PublicAPI) => { + api.openapi(getOne.route, getOne.handler); + api.openapi(update.route, update.handler); + api.openapi(_delete.route, _delete.handler); + api.openapi(create.route, create.handler); + api.openapi(getMany.route, getMany.handler); +}; diff --git a/src/server/api/routes/stakeholder/update.ts b/src/server/api/routes/stakeholder/update.ts new file mode 100644 index 000000000..6fbae1f60 --- /dev/null +++ b/src/server/api/routes/stakeholder/update.ts @@ -0,0 +1,135 @@ +import { z } from "@hono/zod-openapi"; +import { ApiError } from "../../error"; +import { + StakeholderSchema, + type TStakeholderSchema, + type TUpdateStakeholderSchema, + UpdateStakeholderSchema, +} from "../../schema/stakeholder"; +import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator"; + +const ParamsSchema = z.object({ + id: z.string().openapi({ + param: { + name: "id", + in: "path", + }, + description: "Stakeholder ID", + type: "string", + example: "clyabgufg004u5tbtnz0r4cax", + }), + companyId: z.string().openapi({ + param: { + name: "companyId", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z.object({ + message: z.string(), + data: StakeholderSchema, +}); + +export const update = withAuthApiV1 + .createRoute({ + summary: "Update a stakeholder", + description: "Modify the details of a stakeholder by their ID.", + tags: ["Stakeholder"], + method: "patch", + path: "/v1/{companyId}/stakeholders/{id}", + middleware: [authMiddleware()], + request: { + params: ParamsSchema, + body: { + content: { + "application/json": { + schema: UpdateStakeholderSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Confirmation of updated stakeholder details.", + }, + }, + }) + .handler(async (c) => { + const { id } = c.req.valid("param"); + const { db, audit, client } = c.get("services"); + const { membership } = c.get("session"); + const { requestIp, userAgent } = client as { + requestIp: string; + userAgent: string; + }; + + const body = await c.req.json(); + + const updatedStakeHolder = await db.$transaction(async (tx) => { + const stakeholder = await tx.stakeholder.findUnique({ + where: { + id, + companyId: membership.companyId, + }, + select: { + id: true, + companyId: true, + name: true, + }, + }); + + if (!stakeholder) { + throw new ApiError({ + code: "NOT_FOUND", + message: "No stakeholder with the provided Id", + }); + } + + const updatedStakeHolder = await tx.stakeholder.update({ + where: { + id: stakeholder.id, + }, + data: body, + }); + + await audit.create( + { + action: "stakeholder.updated", + companyId: membership.companyId, + actor: { type: "user", id: membership.userId }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "stakeholder", id: stakeholder.id }], + summary: `${membership.user.name} updated the stakeholder details in the company : ${updatedStakeHolder.name}`, + }, + tx, + ); + + return updatedStakeHolder; + }); + + const data: TStakeholderSchema = { + ...updatedStakeHolder, + createdAt: updatedStakeHolder.createdAt.toISOString(), + updatedAt: updatedStakeHolder.updatedAt.toISOString(), + }; + + return c.json( + { + message: "Stakeholder updated successfully", + data, + }, + 200, + ); + }); diff --git a/src/server/api/schema/company.ts b/src/server/api/schema/company.ts index f37db4592..4d4fbe0df 100644 --- a/src/server/api/schema/company.ts +++ b/src/server/api/schema/company.ts @@ -1,80 +1,82 @@ -import { z } from "zod"; - -export const ApiCompanySchema = z.object({ - id: z.string().cuid().openapi({ - description: "Company ID", - example: "clxwbok580000i7nge8nm1ry0", - }), - - name: z.string().openapi({ - description: "Company name", - example: "Acme Inc.", - }), - - logo: z.string().optional().openapi({ - description: "Company logo", - example: "https://example.com/logo.png", - }), - - website: z.string().optional().openapi({ - description: "Company website", - example: "https://example.com", - }), - - incorporationType: z.string().optional().openapi({ - description: "Company incorporation type", - example: "LLC", - }), - - incorporationDate: z.string().optional().openapi({ - description: "Company incorporation date", - example: "2024-01-01", - }), - - incorporationState: z.string().optional().openapi({ - description: "Company incorporation state", - example: "CA", - }), - - incorporationCountry: z.string().optional().openapi({ - description: "Company incorporation country", - example: "USA", - }), - - streetAddress: z.string().optional().openapi({ - description: "Company street address", - example: "123 Main St.", - }), - - city: z.string().optional().openapi({ - description: "Company city", - example: "San Francisco", - }), - - state: z.string().optional().openapi({ - description: "Company state", - example: "CA", - }), - - country: z.string().optional().openapi({ - description: "Company country", - example: "USA", - }), - - zipcode: z.string().optional().openapi({ - description: "Company zipcode", - example: "94105", - }), - - createdAt: z.string().openapi({ - description: "Company creation date", - example: "2024-01-01T00:00:00Z", - }), - - updatedAt: z.string().openapi({ - description: "Company last updated date", - example: "2024-01-01T00:00:00Z", - }), -}); - -export type ApiCompanyType = z.infer; +import { z } from "@hono/zod-openapi"; + +export const CompanySchema = z + .object({ + id: z.string().cuid().openapi({ + description: "Company ID", + example: "clxwbok580000i7nge8nm1ry0", + }), + + name: z.string().openapi({ + description: "Company name", + example: "Acme Inc.", + }), + + logo: z.string().nullable().openapi({ + description: "Company logo", + example: "https://example.com/logo.png", + }), + + website: z.string().nullable().openapi({ + description: "Company website", + example: "https://example.com", + }), + + incorporationType: z.string().optional().openapi({ + description: "Company incorporation type", + example: "LLC", + }), + + incorporationDate: z.string().optional().openapi({ + description: "Company incorporation date", + example: "2024-01-01", + }), + + incorporationState: z.string().optional().openapi({ + description: "Company incorporation state", + example: "CA", + }), + + incorporationCountry: z.string().optional().openapi({ + description: "Company incorporation country", + example: "USA", + }), + + streetAddress: z.string().optional().openapi({ + description: "Company street address", + example: "123 Main St.", + }), + + city: z.string().optional().openapi({ + description: "Company city", + example: "San Francisco", + }), + + state: z.string().optional().openapi({ + description: "Company state", + example: "CA", + }), + + country: z.string().optional().openapi({ + description: "Company country", + example: "USA", + }), + + zipcode: z.string().optional().openapi({ + description: "Company zipcode", + example: "94105", + }), + + createdAt: z.string().openapi({ + description: "Company creation date", + example: "2024-01-01T00:00:00Z", + }), + + updatedAt: z.string().openapi({ + description: "Company last updated date", + example: "2024-01-01T00:00:00Z", + }), + }) + .openapi("Company"); + +export type TCompanySchema = z.infer; diff --git a/src/server/api/schema/pagination.ts b/src/server/api/schema/pagination.ts index f1dd39b4e..36e608c72 100644 --- a/src/server/api/schema/pagination.ts +++ b/src/server/api/schema/pagination.ts @@ -1,6 +1,5 @@ -import { z } from "zod"; - -export const DEFAULT_PAGINATION_LIMIT = 50; +import { z } from "@hono/zod-openapi"; +import { DEFAULT_PAGINATION_LIMIT } from "../const"; export const PaginationQuerySchema = z.object({ limit: z @@ -43,19 +42,30 @@ export const PaginationQuerySchema = z.object({ export type TPaginationQuerySchema = z.infer; -export const PaginationResponseSchema = z.object({ - count: z.number().int().positive().openapi({ - description: "Number of records returned", - example: 50, - }), +export const PaginationResponseSchema = z + .object({ + hasPreviousPage: z.boolean().openapi({ + description: + "Indicates if there is a previous page available in the pagination. `true` if there are more pages before the current one, `false` otherwise.", + example: true, + }), - total: z.number().int().positive().nullable().openapi({ - description: "Total number of records", - example: 100, - }), + hasNextPage: z.boolean().openapi({ + description: + "Indicates if there is a next page available in the pagination. `true` if there are more pages after the current one, `false` otherwise.", + example: false, + }), - cursor: z.string().nullable().openapi({ - description: "Next page cursor", - example: "cly151kxq0000i7ngb3erchgo", - }), -}); + startCursor: z.string().nullable().openapi({ + description: + "A cursor representing the starting point of the current page. Useful for querying the first item of the current page or the last item of the previous page.", + example: "cly151kxq0000i7ngb3erchgo", + }), + + endCursor: z.string().nullable().openapi({ + description: + "A cursor representing the end point of the current page. Useful for querying the last item of the current page or the first item of the next page.", + example: "cly151kxq0000i7ngb3erchgo", + }), + }) + .openapi("Pagination"); diff --git a/src/server/api/schema/shares.ts b/src/server/api/schema/shares.ts index 3165b7c92..a8401eef6 100644 --- a/src/server/api/schema/shares.ts +++ b/src/server/api/schema/shares.ts @@ -1,13 +1,13 @@ +import { SecuritiesStatusEnum, ShareLegendsEnum } from "@/prisma/enums"; import { z } from "@hono/zod-openapi"; -import { SecuritiesStatusEnum, ShareLegendsEnum } from "@prisma/client"; const ShareLegendsArr = Object.values(ShareLegendsEnum) as [ - string, - ...string[], + ShareLegendsEnum, + ...ShareLegendsEnum[], ]; const SecuritiesStatusArr = Object.values(SecuritiesStatusEnum) as [ - string, - ...string[], + SecuritiesStatusEnum, + ...SecuritiesStatusEnum[], ]; export const ShareSchema = z @@ -22,7 +22,7 @@ export const ShareSchema = z example: "DRAFT", }), - certificateId: z.string().nullish().openapi({ + certificateId: z.string().openapi({ description: "Certificate ID", example: "123", }), @@ -57,12 +57,11 @@ export const ShareSchema = z example: 0, }), - cliffYears: z.number().nullish().openapi({ + cliffYears: z.number().openapi({ description: "Cliff Years", example: 1, }), - - vestingYears: z.number().nullish().openapi({ + vestingYears: z.number().openapi({ description: "Vesting Years", example: 4, }), @@ -70,14 +69,12 @@ export const ShareSchema = z companyLegends: z .enum(ShareLegendsArr) .array() - .default([]) - .nullish() .openapi({ description: "Company Legends", example: ["US_SECURITIES_ACT", "SALE_AND_ROFR"], }), - issueDate: z.string().datetime().nullish().openapi({ + issueDate: z.string().datetime().openapi({ description: "Issued Date", example: "2024-01-01T00:00:00.000Z", }), @@ -92,7 +89,7 @@ export const ShareSchema = z example: "2024-01-01T00:00:00.000Z", }), - boardApprovalDate: z.string().datetime().nullish().openapi({ + boardApprovalDate: z.string().datetime().openapi({ description: "Board Approval Date", example: "2024-01-01T00:00:00.000Z", }), @@ -122,9 +119,7 @@ export const ShareSchema = z example: "2024-01-01T00:00:00.000Z", }), }) - .openapi({ - description: "Get a Single Share by the ID", - }); + .openapi("Shares"); export const CreateShareSchema = ShareSchema.omit({ id: true, diff --git a/src/server/api/schema/stakeholder.ts b/src/server/api/schema/stakeholder.ts index 283e2fd73..854ff7470 100644 --- a/src/server/api/schema/stakeholder.ts +++ b/src/server/api/schema/stakeholder.ts @@ -1,8 +1,8 @@ import { StakeholderRelationshipEnum, StakeholderTypeEnum, -} from "@prisma/client"; -import { z } from "zod"; +} from "@/prisma/enums"; +import { z } from "@hono/zod-openapi"; const StakeholderTypeArray = Object.values(StakeholderTypeEnum) as [ StakeholderTypeEnum, @@ -12,74 +12,82 @@ const StakeholderRelationshipArray = Object.values( StakeholderRelationshipEnum, ) as [StakeholderRelationshipEnum, ...StakeholderRelationshipEnum[]]; -export const StakeholderSchema = z.object({ - id: z.string().cuid().optional().openapi({ - description: "Stakeholder ID", - example: "cly13ipa40000i7ng42mv4x7b", +export const StakeholderSchema = z + .object({ + id: z.string().cuid().openapi({ + description: "Stakeholder ID", + example: "cly13ipa40000i7ng42mv4x7b", + }), + + name: z.string().openapi({ + description: "Stakeholder name", + example: "John Doe", + }), + + email: z.string().email().openapi({ + description: "Stakeholder email", + example: "email@example.com", + }), + + institutionName: z.string().nullish().openapi({ + description: "Institution name", + example: "ACME Corp", + }), + + stakeholderType: z.enum(StakeholderTypeArray).openapi({ + description: "Stakeholder type", + example: "INDIVIDUAL", + }), + + currentRelationship: z.enum(StakeholderRelationshipArray).openapi({ + description: "Current relationship with the company", + example: "EMPLOYEE", + }), + + streetAddress: z.string().nullish().openapi({ + description: "Street address", + example: "123 Main St", + }), + + city: z.string().nullish().openapi({ + description: "City", + example: "San Francisco", + }), + + state: z.string().nullish().openapi({ + description: "State", + example: "CA", + }), + + zipcode: z.string().nullish().openapi({ + description: "Zip code", + example: "94105", + }), + + country: z.string().optional().openapi({ + description: "Country", + example: "USA", + }), + + createdAt: z.string().date().openapi({ + description: "Date the stakeholder was created", + example: "2022-01-01T00:00:00Z", + }), + + updatedAt: z.string().date().openapi({ + description: "Date the stakeholder was last updated", + example: "2022-01-01T00:00:00Z", + }), + }) + .openapi("Stakeholder"); + +export const CreateStakeholderSchema = z.array( + StakeholderSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, }), - - name: z.string().openapi({ - description: "Stakeholder name", - example: "John Doe", - }), - - email: z.string().email().openapi({ - description: "Stakeholder email", - example: "email@example.com", - }), - - institutionName: z.string().optional().openapi({ - description: "Institution name", - example: "ACME Corp", - }), - - stakeholderType: z.enum(StakeholderTypeArray).openapi({ - description: "Stakeholder type", - example: "INDIVIDUAL", - }), - - currentRelationship: z.enum(StakeholderRelationshipArray).openapi({ - description: "Current relationship with the company", - example: "EMPLOYEE", - }), - - streetAddress: z.string().optional().openapi({ - description: "Street address", - example: "123 Main St", - }), - - city: z.string().optional().openapi({ - description: "City", - example: "San Francisco", - }), - - state: z.string().optional().openapi({ - description: "State", - example: "CA", - }), - - zipcode: z.string().optional().openapi({ - description: "Zip code", - example: "94105", - }), - - country: z.string().optional().openapi({ - description: "Country", - example: "USA", - }), - - createdAt: z.string().date().optional().openapi({ - description: "Date the stakeholder was created", - example: "2022-01-01T00:00:00Z", - }), - - updatedAt: z.string().date().optional().openapi({ - description: "Date the stakeholder was last updated", - example: "2022-01-01T00:00:00Z", - }), -}); - -export const AddStakeholderSchema = z.array(StakeholderSchema); +); export const UpdateStakeholderSchema = StakeholderSchema.omit({ id: true, createdAt: true, @@ -99,5 +107,5 @@ export const UpdateStakeholderSchema = StakeholderSchema.omit({ }); export type TStakeholderSchema = z.infer; -export type TAddStakeholderSchema = z.infer; +export type TCreateStakeholderSchema = z.infer; export type TUpdateStakeholderSchema = z.infer; diff --git a/src/server/api/utils.ts b/src/server/api/utils.ts deleted file mode 100644 index 6af377234..000000000 --- a/src/server/api/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { HonoRequest } from "hono"; - -export const getIp = (req: HonoRequest) => { - return ( - req.header("x-forwarded-for") || req.header("remoteAddr") || "Unknown IP" - ); -}; - -export const getHonoUserAgent = (req: HonoRequest) => { - return req.header("User-Agent") || ""; -}; diff --git a/src/server/api/utils/endpoint-creator.ts b/src/server/api/utils/endpoint-creator.ts index 13b1dbe86..d22d9f158 100644 --- a/src/server/api/utils/endpoint-creator.ts +++ b/src/server/api/utils/endpoint-creator.ts @@ -2,15 +2,43 @@ import { createRoute as OpenApiCreateRoute, type createRoute as OpenApiCreateRouteType, type RouteHandler, + z, } from "@hono/zod-openapi"; +import { some } from "hono/combine"; import { ErrorResponses } from "../error"; -import type { HonoEnv } from "../hono"; + +import type { Env } from "hono"; + +import { + accessTokenAuthMiddleware, + type accessTokenAuthMiddlewareOptions, +} from "../middlewares/bearer-token"; +import { sessionCookieAuthMiddleware } from "../middlewares/session-token"; type RouteConfig = Parameters[0]; +export const SECURITY_SCHEME_NAME = "Bearer"; + type Version = "v1" | "v2"; -const createApi = (_version: V) => { +const AuthHeaderSchema = z.object({ + authorization: z + .string() + .regex(/^Bearer [a-zA-Z0-9_]+/) + .openapi({ + description: "Bearer token to authorize the request", + example: "Bearer api_x0X0x0X0x0X0x0X0x0X0x0X", + }), +}); + +type AuthHeaders = { + headers: typeof AuthHeaderSchema; +}; + +const createApi = ( + _version: V, + authRequired?: L, +) => { const createRoute = < T extends Omit, P extends `/${V}/${string}`, @@ -18,8 +46,30 @@ const createApi = (_version: V) => { >( routeConfig: U, ) => { + type Request = Omit & AuthHeaders; + + const request = { + ...(routeConfig?.request && { ...routeConfig?.request }), + ...(authRequired && { + headers: routeConfig.request?.headers + ? // @ts-expect-error + AuthHeaderSchema.merge(routeConfig.request.headers) + : AuthHeaderSchema, + }), + } as Request; + const updatedRouteConfig: U = { ...routeConfig, + ...(authRequired + ? { + security: [ + { + [SECURITY_SCHEME_NAME]: [], + }, + ], + } + : {}), + request, responses: { ...(routeConfig?.responses && { ...routeConfig.responses }), ...ErrorResponses, @@ -29,7 +79,7 @@ const createApi = (_version: V) => { const route = OpenApiCreateRoute(updatedRouteConfig); const handler = ( - callback: RouteHandler, + callback: RouteHandler, ) => ({ handler: callback, route: route, @@ -41,4 +91,9 @@ const createApi = (_version: V) => { return { createRoute }; }; -export const v1Api = createApi("v1"); +export const authMiddleware = (option?: accessTokenAuthMiddlewareOptions) => + some(sessionCookieAuthMiddleware(), accessTokenAuthMiddleware(option)); + +export const ApiV1 = createApi("v1"); + +export const withAuthApiV1 = createApi("v1", true); diff --git a/src/server/audit/index.ts b/src/server/audit/index.ts index 806f510ed..53fcdb5ca 100644 --- a/src/server/audit/index.ts +++ b/src/server/audit/index.ts @@ -20,15 +20,8 @@ // }, // }); -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { - type AuditSchemaType, - type TEsignAuditSchema, -} from "@/server/audit/schema"; -import { type TPrismaOrTransaction } from "@/server/db"; +import type { AuditSchemaType, TEsignAuditSchema } from "@/server/audit/schema"; +import type { TPrismaOrTransaction } from "@/server/db"; const create = (data: AuditSchemaType, tx: TPrismaOrTransaction) => { return tx.audit.create({ diff --git a/src/server/audit/schema.ts b/src/server/audit/schema.ts index 57645c184..d952ead73 100644 --- a/src/server/audit/schema.ts +++ b/src/server/audit/schema.ts @@ -58,8 +58,8 @@ export const AuditSchema = z.object({ "update.shared", "update.unshared", - "apiKey.created", - "apiKey.deleted", + "accessToken.created", + "accessToken.deleted", "bucket.created", @@ -96,7 +96,7 @@ export const AuditSchema = z.object({ "share", "update", "stakeholder", - "apiKey", + "accessToken", "bucket", "stripeSession", "stripeBillingPortalSession", diff --git a/src/server/auth.ts b/src/server/auth.ts index 35f507906..1284f0fcf 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -144,6 +144,7 @@ export const authOptions: NextAuthOptions = { return token; }, }, + // @ts-expect-error adapter: PrismaAdapter(db), secret: env.NEXTAUTH_SECRET ?? "secret", session: { @@ -337,6 +338,13 @@ export async function checkMembership({ session, tx }: checkMembershipOptions) { companyId: true, role: true, customRoleId: true, + userId: true, + user: { + select: { + name: true, + email: true, + }, + }, }, }); diff --git a/src/server/db.ts b/src/server/db.ts index c3b6017e3..f66e629b8 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -2,6 +2,7 @@ import { PrismaClient } from "@prisma/client"; import { env } from "@/env"; import type { TTemplateFieldMetaType } from "@/trpc/routers/template-field-router/schema"; +import { pagination } from "prisma-extension-pagination"; declare global { namespace PrismaJson { @@ -9,22 +10,25 @@ declare global { } } +function getExtendedClient() { + return new PrismaClient({ + log: env.LOGS ? ["query", "error", "warn"] : ["error"], + }).$extends(pagination()); +} + const globalForPrisma = globalThis as unknown as { - prisma: PrismaClient | undefined; + prisma: ExtendedPrismaClient | undefined; }; -export const db = - globalForPrisma.prisma ?? - new PrismaClient({ - log: env.LOGS ? ["query", "error", "warn"] : ["error"], - }); +export const db = globalForPrisma.prisma ?? getExtendedClient(); if (env.NODE_ENV !== "production") globalForPrisma.prisma = db; export type PrismaTransactionalClient = Parameters< - Parameters[0] + Parameters[0] >[0]; export type TPrisma = typeof db; export type TPrismaOrTransaction = TPrisma | PrismaTransactionalClient; +export type ExtendedPrismaClient = ReturnType; diff --git a/src/server/member.ts b/src/server/member.ts index 100221205..45203e790 100644 --- a/src/server/member.ts +++ b/src/server/member.ts @@ -1,8 +1,6 @@ -import { env } from "@/env"; import { createHash } from "@/lib/crypto"; -import type { Prisma } from "@prisma/client"; import { nanoid } from "nanoid"; -import { db } from "./db"; +import { type TPrismaOrTransaction, db } from "./db"; export const checkVerificationToken = async ( token: string, @@ -62,7 +60,7 @@ export async function generateInviteToken() { interface revokeExistingInviteTokensOptions { memberId: string; email: string; - tx?: Prisma.TransactionClient; + tx?: TPrismaOrTransaction; } export async function revokeExistingInviteTokens({ diff --git a/src/server/services/shares/add-share.ts b/src/server/services/shares/add-share.ts deleted file mode 100644 index 1a9f33a25..000000000 --- a/src/server/services/shares/add-share.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { generatePublicId } from "@/common/id"; -import { Audit } from "@/server/audit"; -import { db } from "@/server/db"; -import type { TypeZodAddShareMutationSchema } from "@/trpc/routers/securities-router/schema"; - -export interface AddShareType extends TypeZodAddShareMutationSchema { - companyId: string; - memberId: string; - requestIP: string; - userAgent: string; - user: { - id: string; - name: string; - }; -} - -export const addShare = async (input: AddShareType) => { - try { - const share = await db.$transaction(async (tx) => { - const documents = input.documents; - - const data = { - companyId: input.companyId, - stakeholderId: input.stakeholderId, - shareClassId: input.shareClassId, - status: input.status, - certificateId: input.certificateId, - quantity: input.quantity, - pricePerShare: input.pricePerShare, - capitalContribution: input.capitalContribution, - ipContribution: input.ipContribution, - debtCancelled: input.debtCancelled, - otherContributions: input.otherContributions, - cliffYears: input.cliffYears, - vestingYears: input.vestingYears, - companyLegends: input.companyLegends, - issueDate: new Date(input.issueDate), - rule144Date: new Date(input.rule144Date), - vestingStartDate: new Date(input.vestingStartDate), - boardApprovalDate: new Date(input.boardApprovalDate), - }; - - const share = await tx.share.create({ data }); - - if (documents && documents.length > 0) { - const bulkDocuments = documents.map((doc) => ({ - companyId: input.companyId, - uploaderId: input.memberId, - publicId: generatePublicId(), - name: doc.name, - bucketId: doc.bucketId, - shareId: share.id, - })); - - await tx.document.createMany({ - data: bulkDocuments, - skipDuplicates: true, - }); - } - - await Audit.create( - { - action: "share.created", - companyId: input.companyId, - actor: { type: "user", id: input.user.id }, - context: { - userAgent: input.userAgent, - requestIp: input.requestIP, - }, - target: [{ type: "share", id: share.id }], - summary: `${input.user.name} added share for stakeholder ${input.stakeholderId}`, - }, - tx, - ); - - return share; - }); - - return { - success: true, - message: "🎉 Successfully added a share", - data: share, - }; - } catch (error) { - console.error("Error adding shares: ", error); - return { - success: false, - message: "Please use unique Certificate Id.", - }; - } -}; diff --git a/src/server/services/shares/delete-share.ts b/src/server/services/shares/delete-share.ts deleted file mode 100644 index 56bd05c4f..000000000 --- a/src/server/services/shares/delete-share.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Audit } from "@/server/audit"; -import { db } from "@/server/db"; - -interface DeleteShareType { - user: { - id: string; - name: string; - }; - shareId: string; - companyId: string; - userAgent: string; - requestIp: string; -} - -export const deleteShare = async ({ - shareId, - companyId, - requestIp, - userAgent, - user, -}: DeleteShareType) => { - try { - const existingShare = await db.share.findUnique({ - where: { - id: shareId, - }, - }); - - if (!existingShare) { - return { - success: false, - code: "NOT_FOUND", - message: `Share with ID ${shareId} not found`, - }; - } - - const share = await db.$transaction(async (tx) => { - const share = await tx.share.delete({ - where: { - id: shareId, - }, - }); - - const { stakeholderId } = share; - - await Audit.create( - { - action: "share.deleted", - companyId, - actor: { type: "user", id: user.id }, - context: { - userAgent, - requestIp, - }, - target: [{ type: "share", id: shareId }], - summary: `${user.name} Deleted the share for stakeholder ${stakeholderId}`, - }, - tx, - ); - - return share; - }); - - return { - success: true, - message: "🎉 Successfully Deleted the share", - share, - }; - } catch (error) { - console.error("Error Deleting the share: ", error); - return { - success: false, - code: "INTERNAL_SERVER_ERROR", - message: "Error deleting the share, please try again or contact support.", - }; - } -}; diff --git a/src/server/services/shares/get-shares.ts b/src/server/services/shares/get-shares.ts deleted file mode 100644 index 815ed07d9..000000000 --- a/src/server/services/shares/get-shares.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ProxyPrismaModel } from "@/server/api/pagination/prisma-proxy"; -import { db } from "@/server/db"; - -type GetPaginatedShares = { - companyId: string; - take: number; - cursor?: string; - total?: number; -}; - -export const getPaginatedShares = async (payload: GetPaginatedShares) => { - const queryCriteria = { - where: { - companyId: payload.companyId, - }, - orderBy: { - createdAt: "desc", - }, - }; - - const paginationData = { - take: payload.take, - cursor: payload.cursor, - total: payload.total, - }; - - const prismaModel = ProxyPrismaModel(db.share); - - const { data, count, total, cursor } = await prismaModel.findManyPaginated( - queryCriteria, - paginationData, - ); - - return { - data, - meta: { - count, - total, - cursor, - }, - }; -}; diff --git a/src/server/services/shares/update-share.ts b/src/server/services/shares/update-share.ts deleted file mode 100644 index 4d7ca7cc4..000000000 --- a/src/server/services/shares/update-share.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ApiError } from "@/server/api/error"; -import type { UpdateShareSchemaType } from "@/server/api/schema/shares"; -import { Audit } from "@/server/audit"; -import { db } from "@/server/db"; -import type { ShareLegendsEnum } from "@prisma/client"; - -export type UpdateSharePayloadType = { - shareId: string; - companyId: string; - requestIp: string; - userAgent: string; - user: { - id: string; - name: string; - }; - data: UpdateShareSchemaType; -}; - -export const updateShare = async (payload: UpdateSharePayloadType) => { - const { shareId, companyId, requestIp, userAgent, user, data } = payload; - - try { - const existingShare = await db.share.findUnique({ - where: { id: shareId }, - }); - - if (!existingShare) { - return { - success: false, - message: `Share with ID ${shareId} not be found`, - }; - } - - const shareData = { - ...existingShare, - ...data, - }; - - const share = await db.$transaction(async (tx) => { - const share = await tx.share.update({ - where: { id: shareId }, - // @ts-ignore - data: shareData, - }); - - await Audit.create( - { - action: "share.updated", - companyId: companyId, - actor: { type: "user", id: user.id }, - context: { - userAgent: userAgent, - requestIp: requestIp, - }, - target: [{ type: "share", id: share.id }], - summary: `${user.name} updated share the share ID ${shareId}`, - }, - tx, - ); - - return share; - }); - - return { - success: true, - message: "🎉 Successfully updated share.", - data: share, - }; - } catch (error) { - console.error("updateShare", error); - throw new ApiError({ - code: "INTERNAL_SERVER_ERROR", - message: "Something went wrong, please try again or contact support", - }); - } -}; diff --git a/src/server/services/stakeholder/add-stakeholders.ts b/src/server/services/stakeholder/add-stakeholders.ts deleted file mode 100644 index a1d7ad881..000000000 --- a/src/server/services/stakeholder/add-stakeholders.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { TAddStakeholderSchema } from "@/server/api/schema/stakeholder"; -import { Audit } from "@/server/audit"; -import { db } from "@/server/db"; - -type AddStakeholderOptions = { - companyId: string; - requestIp: string; - userAgent: string; - user: { - id: string; - name: string; - }; - data: TAddStakeholderSchema; -}; - -export const addStakeholders = async (payload: AddStakeholderOptions) => { - const { companyId, requestIp, userAgent, user, data } = payload; - - const stakeholders = await db.$transaction(async (tx) => { - const inputDataWithCompanyId = data.map((stakeholder) => ({ - ...stakeholder, - companyId, - })); - - const addedStakeholders = await tx.stakeholder.createManyAndReturn({ - data: inputDataWithCompanyId, - }); - - const auditPromises = addedStakeholders.map((stakeholder) => - Audit.create( - { - action: "stakeholder.added", - companyId: companyId, - actor: { type: "user", id: user.id }, - context: { - requestIp, - userAgent, - }, - target: [{ type: "stakeholder", id: stakeholder.id }], - summary: `${user.name} added the stakholder in the company : ${stakeholder.name}`, - }, - tx, - ), - ); - - await Promise.all(auditPromises); - return addedStakeholders; - }); - - return stakeholders; -}; diff --git a/src/server/services/stakeholder/delete-stakeholder.ts b/src/server/services/stakeholder/delete-stakeholder.ts deleted file mode 100644 index f132d8379..000000000 --- a/src/server/services/stakeholder/delete-stakeholder.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Audit } from "@/server/audit"; -import { db } from "@/server/db"; - -type DeleteStakeholderOption = { - companyId: string; - stakeholderId: string; - requestIp: string; - userAgent: string; - user: { - id: string; - name: string; - }; -}; - -export const deleteStakeholder = async (payload: DeleteStakeholderOption) => { - const { companyId, stakeholderId, requestIp, userAgent, user } = payload; - - const { deletedStakeholder } = await db.$transaction(async (tx) => { - const deletedStakeholder = await tx.stakeholder.delete({ - where: { - id: stakeholderId, - companyId, - }, - }); - await Audit.create( - { - action: "stakeholder.deleted", - companyId: payload.companyId, - actor: { type: "user", id: payload.user.id }, - context: { - requestIp, - userAgent, - }, - target: [{ type: "stakeholder", id: deletedStakeholder.id }], - summary: `${user.name} deleted the stakholder from the company : ${deletedStakeholder.name}`, - }, - tx, - ); - return { deletedStakeholder }; - }); - return { deletedStakeholder }; -}; diff --git a/src/server/services/stakeholder/get-stakeholder-by-id.ts b/src/server/services/stakeholder/get-stakeholder-by-id.ts deleted file mode 100644 index 24392e758..000000000 --- a/src/server/services/stakeholder/get-stakeholder-by-id.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db } from "@/server/db"; - -type GetStakeholderById = { - stakeholderId: string; -}; - -export const getStakeholderById = async ({ - stakeholderId, -}: GetStakeholderById) => { - return await db.stakeholder.findUniqueOrThrow({ - where: { - id: stakeholderId, - }, - include: { - company: { - select: { - name: true, - }, - }, - }, - }); -}; diff --git a/src/server/services/stakeholder/get-stakeholders.ts b/src/server/services/stakeholder/get-stakeholders.ts deleted file mode 100644 index 69234bf61..000000000 --- a/src/server/services/stakeholder/get-stakeholders.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ProxyPrismaModel } from "@/server/api/pagination/prisma-proxy"; -import { db } from "@/server/db"; - -type GetPaginatedStakeholders = { - companyId: string; - take: number; - cursor?: string; -}; - -export const getPaginatedStakeholders = async ( - payload: GetPaginatedStakeholders, -) => { - const queryCriteria = { - where: { - companyId: payload.companyId, - }, - orderBy: { - createdAt: "desc", - }, - }; - - const { take, cursor: _cursor } = payload; - - const paginationData = { - take, - cursor: _cursor, - }; - - const prismaModel = ProxyPrismaModel(db.stakeholder); - - const { data, count, total, cursor } = await prismaModel.findManyPaginated( - queryCriteria, - paginationData, - ); - - return { - data, - meta: { - count, - total, - cursor, - }, - }; -}; diff --git a/src/server/services/stakeholder/update-stakeholder.ts b/src/server/services/stakeholder/update-stakeholder.ts deleted file mode 100644 index 1b2dbb898..000000000 --- a/src/server/services/stakeholder/update-stakeholder.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TUpdateStakeholderSchema } from "@/server/api/schema/stakeholder"; -import { Audit } from "@/server/audit"; -import { db } from "@/server/db"; - -export type UpdateStakeholderPayloadType = { - stakeholderId: string; - companyId: string; - requestIp: string; - userAgent: string; - user: { - id: string; - name: string; - }; - data: TUpdateStakeholderSchema; -}; - -export const updateStakeholder = async ( - payload: UpdateStakeholderPayloadType, -) => { - const { requestIp, userAgent, user } = payload; - const data = payload.data; - - const { updatedStakeholder } = await db.$transaction(async (tx) => { - const updatedStakeholder = await db.stakeholder.update({ - where: { - id: payload.stakeholderId, - companyId: payload.companyId, - }, - data, - }); - await Audit.create( - { - action: "stakeholder.updated", - companyId: payload.companyId, - actor: { type: "user", id: user.id }, - context: { - requestIp, - userAgent, - }, - target: [{ type: "stakeholder", id: updatedStakeholder.id }], - summary: `${user.name} updated the stakholder details in the company : ${updatedStakeholder.name}`, - }, - tx, - ); - return { updatedStakeholder }; - }); - return { updatedStakeholder }; -}; diff --git a/src/trpc/api/root.ts b/src/trpc/api/root.ts index 998682a07..47c697db3 100644 --- a/src/trpc/api/root.ts +++ b/src/trpc/api/root.ts @@ -1,5 +1,5 @@ import { createTRPCRouter } from "@/trpc/api/trpc"; -import { apiKeyRouter } from "../routers/api-key/router"; +import { accessTokenRouter } from "../routers/access-token/router"; import { auditRouter } from "../routers/audit-router/router"; import { authRouter } from "../routers/auth/router"; import { bankAccountsRouter } from "../routers/bank-accounts/router"; @@ -52,7 +52,7 @@ export const appRouter = createTRPCRouter({ security: securityRouter, billing: billingRouter, rbac: rbacRouter, - apiKey: apiKeyRouter, + accessToken: accessTokenRouter, bankAccounts: bankAccountsRouter, }); diff --git a/src/trpc/routers/api-key/router.ts b/src/trpc/routers/access-token/router.ts similarity index 52% rename from src/trpc/routers/api-key/router.ts rename to src/trpc/routers/access-token/router.ts index 7549aa20a..adc33320e 100644 --- a/src/trpc/routers/api-key/router.ts +++ b/src/trpc/routers/access-token/router.ts @@ -1,24 +1,27 @@ -import { generatePublicId } from "@/common/id"; -import { createApiToken, createSecureHash } from "@/lib/crypto"; +import { createSecureHash, initializeAccessToken } from "@/lib/crypto"; +import { AccessTokenType } from "@/prisma/enums"; import { Audit } from "@/server/audit"; + import { createTRPCRouter, withAccessControl } from "@/trpc/api/trpc"; import { TRPCError } from "@trpc/server"; import z from "zod"; -export const apiKeyRouter = createTRPCRouter({ +export const accessTokenRouter = createTRPCRouter({ listAll: withAccessControl - .meta({ policies: { "api-keys": { allow: ["read"] } } }) - .query(async ({ ctx }) => { + .input(z.object({ typeEnum: z.nativeEnum(AccessTokenType) })) + .query(async ({ ctx, input }) => { const { db, - membership: { companyId, memberId }, + membership: { userId }, } = ctx; - const apiKeys = await db.apiKey.findMany({ + const { typeEnum } = input; + + const accessTokens = await db.accessToken.findMany({ where: { active: true, - companyId, - membershipId: memberId, + userId, + typeEnum, }, orderBy: { @@ -27,95 +30,99 @@ export const apiKeyRouter = createTRPCRouter({ select: { id: true, - keyId: true, + clientId: true, createdAt: true, lastUsed: true, }, }); return { - apiKeys, + accessTokens, }; }), + create: withAccessControl - .meta({ policies: { "api-keys": { allow: ["create"] } } }) - .mutation(async ({ ctx }) => { + .input(z.object({ typeEnum: z.nativeEnum(AccessTokenType) })) + .mutation(async ({ ctx, input }) => { const { db, - membership: { companyId, memberId }, + membership: { userId, companyId }, userAgent, requestIp, session, } = ctx; - const token = createApiToken(); - const keyId = generatePublicId(); - const hashedToken = createSecureHash(token); + const { typeEnum } = input; + + const { clientId, clientSecret } = initializeAccessToken({ + prefix: typeEnum, + }); + const user = session.user; + const hashedClientSecret = await createSecureHash(clientSecret); - const key = await db.apiKey.create({ + const key = await db.accessToken.create({ data: { - keyId, - companyId, - membershipId: memberId, - hashedToken, + userId, + typeEnum, + clientId, + clientSecret: hashedClientSecret, }, }); await Audit.create( { - action: "apiKey.created", + action: "accessToken.created", companyId, actor: { type: "user", id: user.id }, context: { userAgent, requestIp, }, - target: [{ type: "apiKey", id: key.id }], - summary: `${user.name} created the apiKey ${key.name}`, + target: [{ type: "accessToken", id: key.id }], + summary: `${user.name} created an access token - ${clientId}`, }, db, ); return { - token, - keyId: key.keyId, + token: `${clientId}:${clientSecret}`, + partialKey: clientId, createdAt: key.createdAt, }; }), delete: withAccessControl - .input(z.object({ keyId: z.string() })) - .meta({ policies: { "api-keys": { allow: ["delete"] } } }) + .input(z.object({ tokenId: z.string() })) .mutation(async ({ ctx, input }) => { const { db, - membership: { memberId, companyId }, + membership: { userId, companyId }, session, requestIp, userAgent, } = ctx; - const { keyId } = input; + const { tokenId } = input; const { user } = session; try { - const key = await db.apiKey.delete({ + const key = await db.accessToken.delete({ where: { - keyId, - membershipId: memberId, - companyId, + id: tokenId, + userId, }, }); + await Audit.create( { - action: "apiKey.deleted", + action: "accessToken.deleted", companyId, actor: { type: "user", id: user.id }, context: { userAgent, requestIp, }, - target: [{ type: "apiKey", id: key.id }], - summary: `${user.name} deleted the apiKey ${key.name}`, + target: [{ type: "accessToken", id: key.id }], + summary: `${user.name} deleted an access token - ${key.clientId}`, }, db, ); @@ -125,7 +132,7 @@ export const apiKeyRouter = createTRPCRouter({ message: "Key deleted Successfully.", }; } catch (error) { - console.error("Error deleting the api key :", error); + console.error("Error deleting the access token :", error); if (error instanceof TRPCError) { return { success: false, diff --git a/src/trpc/routers/bank-accounts/router.ts b/src/trpc/routers/bank-accounts/router.ts index b0eecf80b..a85ab30c8 100644 --- a/src/trpc/routers/bank-accounts/router.ts +++ b/src/trpc/routers/bank-accounts/router.ts @@ -1,5 +1,3 @@ -import { generatePublicId } from "@/common/id"; -import { createApiToken, createSecureHash } from "@/lib/crypto"; import { createTRPCRouter, withAccessControl } from "@/trpc/api/trpc"; import { TRPCError } from "@trpc/server"; import z from "zod";