diff --git a/backend/Dockerfile b/backend/Dockerfile index 23683d5..7e7c35d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,6 +4,7 @@ COPY package.json bun.lock ./ RUN bun install --frozen-lockfile FROM deps AS dev +ENV NODE_ENV=development COPY . . EXPOSE 3000 CMD ["bun", "run", "start:dev"] @@ -13,6 +14,7 @@ COPY . . RUN bun run build FROM node:22-alpine AS prod +ENV NODE_ENV=production WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules diff --git a/backend/bun.lock b/backend/bun.lock index 034902f..5f550b5 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "Dispatch", @@ -7,8 +8,11 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.3", "@thallesp/nestjs-better-auth": "^2.6.0", "better-auth": "^1.6.10", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", "drizzle-orm": "^0.45.2", "pg": "^8.20.0", "reflect-metadata": "^0.2.2", @@ -324,6 +328,8 @@ "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@nestjs/cli": ["@nestjs/cli@11.0.21", "", { "dependencies": { "@angular-devkit/core": "19.2.24", "@angular-devkit/schematics": "19.2.24", "@angular-devkit/schematics-cli": "19.2.24", "@inquirer/prompts": "7.10.1", "@nestjs/schematics": "^11.0.1", "ansis": "4.2.0", "chokidar": "4.0.3", "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.1.0", "glob": "13.0.6", "node-emoji": "1.11.0", "ora": "5.4.1", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", "typescript": "5.9.3", "webpack": "5.106.0", "webpack-node-externals": "3.0.0" }, "peerDependencies": { "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", "@swc/core": "^1.3.62" }, "optionalPeers": ["@swc/cli", "@swc/core"], "bin": { "nest": "bin/nest.js" } }, "sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A=="], @@ -332,10 +338,14 @@ "@nestjs/core": ["@nestjs/core@11.1.19", "", { "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw=="], + "@nestjs/mapped-types": ["@nestjs/mapped-types@2.1.1", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A=="], + "@nestjs/platform-express": ["@nestjs/platform-express@11.1.19", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.1.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0" } }, "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg=="], "@nestjs/schematics": ["@nestjs/schematics@11.1.0", "", { "dependencies": { "@angular-devkit/core": "19.2.24", "@angular-devkit/schematics": "19.2.24", "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "peerDependencies": { "prettier": "^3.0.0", "typescript": ">=4.8.2" }, "optionalPeers": ["prettier"] }, "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw=="], + "@nestjs/swagger": ["@nestjs/swagger@11.4.3", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "@nestjs/mapped-types": "2.1.1", "js-yaml": "4.1.1", "lodash": "4.18.1", "path-to-regexp": "8.4.2", "swagger-ui-dist": "5.32.6" }, "peerDependencies": { "@fastify/static": "^8.0.0 || ^9.0.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["@fastify/static", "class-transformer", "class-validator"] }, "sha512-LR4BuOj+iBFzhGRnNP0OHjmrPXliDEjrmniXtLsfLDIELjkuUXYCTGjZMqgDdOY+QSabeF59LndaDzOOe+vMmw=="], + "@nestjs/testing": ["@nestjs/testing@11.1.19", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express"] }, "sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -352,6 +362,8 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], @@ -432,6 +444,8 @@ "@types/supertest": ["@types/supertest@6.0.3", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w=="], + "@types/validator": ["@types/validator@13.15.10", "", {}, "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="], + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], @@ -636,6 +650,10 @@ "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "class-transformer": ["class-transformer@0.5.1", "", {}, "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="], + + "class-validator": ["class-validator@0.15.1", "", { "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", "validator": "^13.15.22" } }, "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], @@ -1014,6 +1032,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "libphonenumber-js": ["libphonenumber-js@1.13.2", "", {}, "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "load-esm": ["load-esm@1.0.3", "", {}, "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA=="], @@ -1288,6 +1308,8 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "swagger-ui-dist": ["swagger-ui-dist@5.32.6", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA=="], + "symbol-observable": ["symbol-observable@4.0.0", "", {}, "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ=="], "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], @@ -1364,6 +1386,8 @@ "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "validator": ["validator@13.15.35", "", {}, "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], diff --git a/backend/drizzle/0001_tranquil_typhoid_mary.sql b/backend/drizzle/0001_tranquil_typhoid_mary.sql new file mode 100644 index 0000000..b66161b --- /dev/null +++ b/backend/drizzle/0001_tranquil_typhoid_mary.sql @@ -0,0 +1,98 @@ +CREATE TABLE "invitation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "email" text NOT NULL, + "role" text, + "status" text DEFAULT 'pending' NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "inviter_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "member" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "created_at" timestamp NOT NULL, + "metadata" text, + CONSTRAINT "organization_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "email" text NOT NULL, + "full_name" text NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "roles_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "team_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "team_id" uuid NOT NULL, + "member_id" uuid NOT NULL, + "role_id" uuid NOT NULL, + "joined_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "teams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "session" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "verification" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "verification" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "session" ADD COLUMN "active_organization_id" text;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "members" ADD CONSTRAINT "members_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "members" ADD CONSTRAINT "members_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "invitation_organizationId_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "member_organizationId_idx" ON "member" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "member_userId_idx" ON "member" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "organization_slug_uidx" ON "organization" USING btree ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX "members_org_email_uidx" ON "members" USING btree ("organization_id","email");--> statement-breakpoint +CREATE INDEX "members_organization_id_idx" ON "members" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "roles_name_uidx" ON "roles" USING btree ("name");--> statement-breakpoint +CREATE UNIQUE INDEX "team_members_team_member_uidx" ON "team_members" USING btree ("team_id","member_id");--> statement-breakpoint +CREATE INDEX "team_members_team_id_idx" ON "team_members" USING btree ("team_id");--> statement-breakpoint +CREATE INDEX "team_members_member_id_idx" ON "team_members" USING btree ("member_id");--> statement-breakpoint +CREATE INDEX "teams_organization_id_idx" ON "teams" USING btree ("organization_id"); \ No newline at end of file diff --git a/backend/drizzle/0002_high_mach_iv.sql b/backend/drizzle/0002_high_mach_iv.sql new file mode 100644 index 0000000..d0386ac --- /dev/null +++ b/backend/drizzle/0002_high_mach_iv.sql @@ -0,0 +1,10 @@ +-- Roles become organization-scoped. Existing global rows have no owning org, +-- so we drop them; the org-creation hook re-seeds defaults per-org. +TRUNCATE "team_members" CASCADE;--> statement-breakpoint +TRUNCATE "roles" CASCADE;--> statement-breakpoint +ALTER TABLE "roles" DROP CONSTRAINT "roles_name_unique";--> statement-breakpoint +DROP INDEX "roles_name_uidx";--> statement-breakpoint +ALTER TABLE "roles" ADD COLUMN "organization_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "roles" ADD CONSTRAINT "roles_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "roles_org_name_uidx" ON "roles" USING btree ("organization_id","name");--> statement-breakpoint +CREATE INDEX "roles_organization_id_idx" ON "roles" USING btree ("organization_id"); \ No newline at end of file diff --git a/backend/drizzle/0003_salty_silver_sable.sql b/backend/drizzle/0003_salty_silver_sable.sql new file mode 100644 index 0000000..00395dc --- /dev/null +++ b/backend/drizzle/0003_salty_silver_sable.sql @@ -0,0 +1 @@ +ALTER TABLE "roles" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..79a0e57 --- /dev/null +++ b/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1115 @@ +{ + "id": "ae35b950-d05d-44d6-9723-668f4201f988", + "prevId": "d15c87c6-8292-4d97-acfc-3b98669911e0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_org_email_uidx": { + "name": "members_org_email_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organization_id_fk": { + "name": "members_organization_id_organization_id_fk", + "tableFrom": "members", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_user_id_fk": { + "name": "members_user_id_user_id_fk", + "tableFrom": "members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_name_uidx": { + "name": "roles_name_uidx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_member_uidx": { + "name": "team_members_team_member_uidx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_member_id_idx": { + "name": "team_members_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_member_id_members_id_fk": { + "name": "team_members_member_id_members_id_fk", + "tableFrom": "team_members", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_role_id_roles_id_fk": { + "name": "team_members_role_id_roles_id_fk", + "tableFrom": "team_members", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organization_id_fk": { + "name": "teams_organization_id_organization_id_fk", + "tableFrom": "teams", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0002_snapshot.json b/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..564136b --- /dev/null +++ b/backend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1148 @@ +{ + "id": "2d45d4cb-f9f1-4af3-8645-41aef58fb353", + "prevId": "ae35b950-d05d-44d6-9723-668f4201f988", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_org_email_uidx": { + "name": "members_org_email_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organization_id_fk": { + "name": "members_organization_id_organization_id_fk", + "tableFrom": "members", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_user_id_fk": { + "name": "members_user_id_user_id_fk", + "tableFrom": "members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_org_name_uidx": { + "name": "roles_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "roles_organization_id_idx": { + "name": "roles_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_organization_id_organization_id_fk": { + "name": "roles_organization_id_organization_id_fk", + "tableFrom": "roles", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_member_uidx": { + "name": "team_members_team_member_uidx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_member_id_idx": { + "name": "team_members_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_member_id_members_id_fk": { + "name": "team_members_member_id_members_id_fk", + "tableFrom": "team_members", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_role_id_roles_id_fk": { + "name": "team_members_role_id_roles_id_fk", + "tableFrom": "team_members", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organization_id_fk": { + "name": "teams_organization_id_organization_id_fk", + "tableFrom": "teams", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0003_snapshot.json b/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..d9398d0 --- /dev/null +++ b/backend/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1155 @@ +{ + "id": "e282c26f-75dc-4c96-9d50-1a4aba4762dd", + "prevId": "2d45d4cb-f9f1-4af3-8645-41aef58fb353", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_org_email_uidx": { + "name": "members_org_email_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organization_id_fk": { + "name": "members_organization_id_organization_id_fk", + "tableFrom": "members", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_user_id_fk": { + "name": "members_user_id_user_id_fk", + "tableFrom": "members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_org_name_uidx": { + "name": "roles_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "roles_organization_id_idx": { + "name": "roles_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_organization_id_organization_id_fk": { + "name": "roles_organization_id_organization_id_fk", + "tableFrom": "roles", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_member_uidx": { + "name": "team_members_team_member_uidx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_member_id_idx": { + "name": "team_members_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_member_id_members_id_fk": { + "name": "team_members_member_id_members_id_fk", + "tableFrom": "team_members", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_role_id_roles_id_fk": { + "name": "team_members_role_id_roles_id_fk", + "tableFrom": "team_members", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organization_id_fk": { + "name": "teams_organization_id_organization_id_fk", + "tableFrom": "teams", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 04e4cb1..0b912fa 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -8,6 +8,27 @@ "when": 1778422651631, "tag": "0000_stale_sentinel", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1779046771537, + "tag": "0001_tranquil_typhoid_mary", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779121795687, + "tag": "0002_high_mach_iv", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1779122482956, + "tag": "0003_salty_silver_sable", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 55a709d..b556f8a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,8 +29,11 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.3", "@thallesp/nestjs-better-auth": "^2.6.0", "better-auth": "^1.6.10", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", "drizzle-orm": "^0.45.2", "pg": "^8.20.0", "reflect-metadata": "^0.2.2", diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index cce879e..4effa5f 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,11 +1,22 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AllowAnonymous } from '@thallesp/nestjs-better-auth'; + import { AppService } from './app.service'; +@ApiTags('App') @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() + @AllowAnonymous() + @ApiOperation({ + summary: 'Root liveness probe', + description: + 'Returns a static hello string. Public — does not require authentication (used by health checks and load balancers).', + }) + @ApiResponse({ status: 200, description: 'Service is up.' }) getHello(): string { return this.appService.getHello(); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3e8dca3..6bf8109 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,9 +4,18 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { DatabaseModule } from './database/database.module'; +import { MembersModule } from './modules/members/members.module'; +import { RolesModule } from './modules/roles/roles.module'; +import { TeamsModule } from './modules/teams/teams.module'; @Module({ - imports: [AuthModule, DatabaseModule], + imports: [ + AuthModule, + DatabaseModule, + MembersModule, + RolesModule, + TeamsModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 950b826..90fc070 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -1,18 +1,31 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { organization } from 'better-auth/plugins'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; +import * as schema from '../database/schema'; +import { seedDefaultRoles } from '../database/seeds/roles.seed'; + if (!process.env.DATABASE_URL) { throw new Error('DATABASE_URL environment variable is required'); } const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -const db = drizzle(pool); +const db = drizzle(pool, { schema }); export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }), emailAndPassword: { enabled: true, }, + plugins: [ + organization({ + organizationHooks: { + afterCreateOrganization: async ({ organization: org }) => { + await seedDefaultRoles(db, org.id); + }, + }, + }), + ], }); diff --git a/backend/src/common/db/sortable.ts b/backend/src/common/db/sortable.ts new file mode 100644 index 0000000..d86bdf8 --- /dev/null +++ b/backend/src/common/db/sortable.ts @@ -0,0 +1,20 @@ +import type { SQL } from 'drizzle-orm'; +import { asc, desc } from 'drizzle-orm'; +import type { PgColumn } from 'drizzle-orm/pg-core'; + +export type SortableMap = Record; + +/** + * Picks the column to sort on from a whitelist, falling back to a default + * when the requested key is missing/unknown. Returns the wrapped asc/desc + * SQL fragment ready to pass to `.orderBy()`. + */ +export function resolveOrderBy( + columns: SortableMap, + sort: string | undefined, + order: 'asc' | 'desc' | undefined, + fallback: PgColumn, +): SQL { + const column = sort && sort in columns ? columns[sort] : fallback; + return order === 'desc' ? desc(column) : asc(column); +} diff --git a/backend/src/common/decorators/active-org-id.decorator.ts b/backend/src/common/decorators/active-org-id.decorator.ts new file mode 100644 index 0000000..deb66ea --- /dev/null +++ b/backend/src/common/decorators/active-org-id.decorator.ts @@ -0,0 +1,21 @@ +import type { ExecutionContext } from '@nestjs/common'; +import { createParamDecorator, ForbiddenException } from '@nestjs/common'; +import type { Request } from 'express'; + +interface RequestWithSession extends Request { + session?: { + session?: { activeOrganizationId?: string | null }; + } | null; +} + +export const ActiveOrgId = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + const orgId = request.session?.session?.activeOrganizationId; + + if (!orgId) { + throw new ForbiddenException('No active organization'); + } + return orgId; + }, +); diff --git a/backend/src/common/dto/pagination.dto.ts b/backend/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..c21c375 --- /dev/null +++ b/backend/src/common/dto/pagination.dto.ts @@ -0,0 +1,71 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class PaginationDto { + @ApiPropertyOptional({ + description: 'Page number (1-based)', + minimum: 1, + default: 1, + example: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ + description: 'Number of items per page', + minimum: 1, + maximum: 100, + default: 20, + example: 20, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; + + @ApiPropertyOptional({ + description: 'Column to sort by (resource-specific)', + example: 'createdAt', + }) + @IsOptional() + @IsString() + sort?: string; + + @ApiPropertyOptional({ + description: 'Sort direction', + enum: ['asc', 'desc'], + default: 'asc', + }) + @IsOptional() + @IsIn(['asc', 'desc']) + order: 'asc' | 'desc' = 'asc'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export function buildPaginatedResult( + data: T[], + total: number, + page: number, + limit: number, +): PaginatedResult { + return { + data, + total, + page, + limit, + totalPages: Math.max(1, Math.ceil(total / limit)), + }; +} diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..c95e23b --- /dev/null +++ b/backend/src/common/filters/http-exception.filter.ts @@ -0,0 +1,62 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +interface ErrorResponseBody { + statusCode: number; + error: string; + message: string | string[]; + timestamp: string; + path: string; +} + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const isHttp = exception instanceof HttpException; + const status = isHttp + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + let message: string | string[] = 'Internal server error'; + let error = 'InternalServerError'; + + if (isHttp) { + const raw = exception.getResponse(); + if (typeof raw === 'string') { + message = raw; + error = exception.name; + } else { + const body = raw as { message?: string | string[]; error?: string }; + message = body.message ?? exception.message; + error = body.error ?? exception.name; + } + } else if (exception instanceof Error) { + this.logger.error(exception.message, exception.stack); + } else { + this.logger.error('Unknown exception', String(exception)); + } + + const body: ErrorResponseBody = { + statusCode: status, + error, + message, + timestamp: new Date().toISOString(), + path: request.url, + }; + + response.status(status).json(body); + } +} diff --git a/backend/src/common/testing/drizzle-mock.ts b/backend/src/common/testing/drizzle-mock.ts new file mode 100644 index 0000000..fe7f839 --- /dev/null +++ b/backend/src/common/testing/drizzle-mock.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import type { Database } from '../../database/database.module'; + +/** + * Thenable proxy: every property access and every call returns the same + * proxy, and `await`ing anywhere in the chain resolves to the provided + * value. Used to stub Drizzle query chains in unit tests. + */ +export function chainResolve(value: T): any { + const proxy: any = new Proxy(() => proxy, { + get(_, prop) { + if (prop === 'then') { + return (resolve: (v: T) => unknown) => resolve(value); + } + return () => proxy; + }, + }); + return proxy; +} + +export interface MockDb { + select: jest.Mock; + insert: jest.Mock; + update: jest.Mock; + delete: jest.Mock; +} + +export function createMockDb(): MockDb { + return { + select: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; +} + +export function asDatabase(mock: MockDb): Database { + return mock as unknown as Database; +} diff --git a/backend/src/database/schema/auth.ts b/backend/src/database/schema/auth.ts index ea5d07c..aa7db09 100644 --- a/backend/src/database/schema/auth.ts +++ b/backend/src/database/schema/auth.ts @@ -1,5 +1,12 @@ import { relations } from 'drizzle-orm'; -import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + text, + timestamp, + boolean, + index, + uniqueIndex, +} from 'drizzle-orm/pg-core'; export const user = pgTable('user', { id: text('id').primaryKey(), @@ -7,9 +14,10 @@ export const user = pgTable('user', { email: text('email').notNull().unique(), emailVerified: boolean('email_verified').default(false).notNull(), image: text('image'), - createdAt: timestamp('created_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') - .$onUpdate(() => new Date()) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }); @@ -19,15 +27,16 @@ export const session = pgTable( id: text('id').primaryKey(), expiresAt: timestamp('expires_at').notNull(), token: text('token').notNull().unique(), - createdAt: timestamp('created_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') - .$onUpdate(() => new Date()) + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), ipAddress: text('ip_address'), userAgent: text('user_agent'), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + activeOrganizationId: text('active_organization_id'), }, (table) => [index('session_userId_idx').on(table.userId)], ); @@ -48,9 +57,9 @@ export const account = pgTable( refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), scope: text('scope'), password: text('password'), - createdAt: timestamp('created_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') - .$onUpdate(() => new Date()) + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index('account_userId_idx').on(table.userId)], @@ -63,17 +72,74 @@ export const verification = pgTable( identifier: text('identifier').notNull(), value: text('value').notNull(), expiresAt: timestamp('expires_at').notNull(), - createdAt: timestamp('created_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') - .$onUpdate(() => new Date()) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index('verification_identifier_idx').on(table.identifier)], ); +export const organization = pgTable( + 'organization', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + logo: text('logo'), + createdAt: timestamp('created_at').notNull(), + metadata: text('metadata'), + }, + (table) => [uniqueIndex('organization_slug_uidx').on(table.slug)], +); + +export const member = pgTable( + 'member', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + role: text('role').default('member').notNull(), + createdAt: timestamp('created_at').notNull(), + }, + (table) => [ + index('member_organizationId_idx').on(table.organizationId), + index('member_userId_idx').on(table.userId), + ], +); + +export const invitation = pgTable( + 'invitation', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + role: text('role'), + status: text('status').default('pending').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + inviterId: text('inviter_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + }, + (table) => [ + index('invitation_organizationId_idx').on(table.organizationId), + index('invitation_email_idx').on(table.email), + ], +); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), + members: many(member), + invitations: many(invitation), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -89,3 +155,30 @@ export const accountRelations = relations(account, ({ one }) => ({ references: [user.id], }), })); + +export const organizationRelations = relations(organization, ({ many }) => ({ + members: many(member), + invitations: many(invitation), +})); + +export const memberRelations = relations(member, ({ one }) => ({ + organization: one(organization, { + fields: [member.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [member.userId], + references: [user.id], + }), +})); + +export const invitationRelations = relations(invitation, ({ one }) => ({ + organization: one(organization, { + fields: [invitation.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [invitation.inviterId], + references: [user.id], + }), +})); diff --git a/backend/src/database/schema/index.ts b/backend/src/database/schema/index.ts index 269586e..e8bbbda 100644 --- a/backend/src/database/schema/index.ts +++ b/backend/src/database/schema/index.ts @@ -1 +1,2 @@ export * from './auth'; +export * from './routing'; diff --git a/backend/src/database/schema/routing.ts b/backend/src/database/schema/routing.ts new file mode 100644 index 0000000..a04247f --- /dev/null +++ b/backend/src/database/schema/routing.ts @@ -0,0 +1,149 @@ +import { relations } from 'drizzle-orm'; +import { + boolean, + index, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; + +import { organization, user } from './auth'; + +export const teams = pgTable( + 'teams', + { + id: uuid('id').defaultRandom().primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + description: text('description'), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index('teams_organization_id_idx').on(table.organizationId)], +); + +export const members = pgTable( + 'members', + { + id: uuid('id').defaultRandom().primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + fullName: text('full_name').notNull(), + isActive: boolean('is_active').default(true).notNull(), + userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + uniqueIndex('members_org_email_uidx').on(table.organizationId, table.email), + index('members_organization_id_idx').on(table.organizationId), + ], +); + +export const roles = pgTable( + 'roles', + { + id: uuid('id').defaultRandom().primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + description: text('description'), + isDefault: boolean('is_default').default(false).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + uniqueIndex('roles_org_name_uidx').on(table.organizationId, table.name), + index('roles_organization_id_idx').on(table.organizationId), + ], +); + +export const teamMembers = pgTable( + 'team_members', + { + id: uuid('id').defaultRandom().primaryKey(), + teamId: uuid('team_id') + .notNull() + .references(() => teams.id, { onDelete: 'cascade' }), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + roleId: uuid('role_id') + .notNull() + .references(() => roles.id, { onDelete: 'restrict' }), + joinedAt: timestamp('joined_at').defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + uniqueIndex('team_members_team_member_uidx').on( + table.teamId, + table.memberId, + ), + index('team_members_team_id_idx').on(table.teamId), + index('team_members_member_id_idx').on(table.memberId), + ], +); + +export const teamsRelations = relations(teams, ({ one, many }) => ({ + organization: one(organization, { + fields: [teams.organizationId], + references: [organization.id], + }), + teamMembers: many(teamMembers), +})); + +export const membersRelations = relations(members, ({ one, many }) => ({ + organization: one(organization, { + fields: [members.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [members.userId], + references: [user.id], + }), + teamMemberships: many(teamMembers), +})); + +export const rolesRelations = relations(roles, ({ one, many }) => ({ + organization: one(organization, { + fields: [roles.organizationId], + references: [organization.id], + }), + teamMembers: many(teamMembers), +})); + +export const teamMembersRelations = relations(teamMembers, ({ one }) => ({ + team: one(teams, { + fields: [teamMembers.teamId], + references: [teams.id], + }), + member: one(members, { + fields: [teamMembers.memberId], + references: [members.id], + }), + role: one(roles, { + fields: [teamMembers.roleId], + references: [roles.id], + }), +})); diff --git a/backend/src/database/seeds/roles.seed.ts b/backend/src/database/seeds/roles.seed.ts new file mode 100644 index 0000000..aed8195 --- /dev/null +++ b/backend/src/database/seeds/roles.seed.ts @@ -0,0 +1,37 @@ +import { Logger } from '@nestjs/common'; + +import { roles } from '../schema'; +import type { Database } from '../database.module'; + +const DEFAULT_ROLES = [ + { + name: 'owner', + description: 'Owns the team and can manage every aspect of it.', + }, + { + name: 'manager', + description: 'Manages the team and its members.', + }, + { + name: 'agent', + description: 'Regular team member receiving routed tickets.', + }, +]; + +export async function seedDefaultRoles( + db: Database, + organizationId: string, +): Promise { + const logger = new Logger('seedDefaultRoles'); + + await db + .insert(roles) + .values( + DEFAULT_ROLES.map((r) => ({ ...r, organizationId, isDefault: true })), + ) + .onConflictDoNothing({ target: [roles.organizationId, roles.name] }); + + logger.log( + `Seeded ${DEFAULT_ROLES.length} default roles for organization ${organizationId}`, + ); +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 2830de6..b01a7c6 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,9 +1,33 @@ +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; +import { AllExceptionsFilter } from './common/filters/http-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: false }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.useGlobalFilters(new AllExceptionsFilter()); + + if (process.env.NODE_ENV !== 'production') { + const config = new DocumentBuilder() + .setTitle('Dispatch API') + .setDescription('Dispatch backend API documentation') + .setVersion('0.0.1') + .addCookieAuth('better-auth.session_token') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } + await app.listen(process.env.PORT ?? 3000); } void bootstrap(); diff --git a/backend/src/modules/members/dto/create-member.dto.ts b/backend/src/modules/members/dto/create-member.dto.ts new file mode 100644 index 0000000..ae0d536 --- /dev/null +++ b/backend/src/modules/members/dto/create-member.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateMemberDto { + @ApiProperty({ + description: 'Member email address (unique per organization)', + maxLength: 255, + example: 'jane.doe@example.com', + }) + @IsEmail() + @MaxLength(255) + email!: string; + + @ApiProperty({ + description: 'Full display name', + minLength: 1, + maxLength: 200, + example: 'Jane Doe', + }) + @IsString() + @MinLength(1) + @MaxLength(200) + fullName!: string; + + @ApiPropertyOptional({ + description: + 'Better Auth user id to link this member to an existing account', + example: 'usr_01HVQ...', + }) + @IsOptional() + @IsString() + userId?: string; +} diff --git a/backend/src/modules/members/dto/update-member.dto.ts b/backend/src/modules/members/dto/update-member.dto.ts new file mode 100644 index 0000000..ef5f4b0 --- /dev/null +++ b/backend/src/modules/members/dto/update-member.dto.ts @@ -0,0 +1,51 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEmail, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class UpdateMemberDto { + @ApiPropertyOptional({ + description: 'Member email address', + maxLength: 255, + example: 'jane.doe@example.com', + }) + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @ApiPropertyOptional({ + description: 'Full display name', + minLength: 1, + maxLength: 200, + example: 'Jane Doe', + }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(200) + fullName?: string; + + @ApiPropertyOptional({ + description: 'Whether the member is active. Set to false to soft-delete.', + example: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + description: + 'Better Auth user id. Pass null to unlink from the underlying account.', + nullable: true, + example: 'usr_01HVQ...', + }) + @IsOptional() + @IsString() + userId?: string | null; +} diff --git a/backend/src/modules/members/members.controller.ts b/backend/src/modules/members/members.controller.ts new file mode 100644 index 0000000..980686b --- /dev/null +++ b/backend/src/modules/members/members.controller.ts @@ -0,0 +1,127 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { OrgRoles } from '@thallesp/nestjs-better-auth'; + +import { ActiveOrgId } from '../../common/decorators/active-org-id.decorator'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +import { CreateMemberDto } from './dto/create-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; +import { MembersService } from './members.service'; + +@ApiTags('Members') +@ApiCookieAuth('better-auth.session_token') +@Controller('members') +export class MembersController { + constructor(private readonly membersService: MembersService) {} + + @Get() + @ApiOperation({ + summary: 'List members of the active organization', + description: + "Paginated list of members scoped to the caller's active organization.", + }) + @ApiResponse({ status: 200, description: 'Paginated list of members.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'No active organization.' }) + async findAll( + @ActiveOrgId() orgId: string, + @Query() pagination: PaginationDto, + ) { + return await this.membersService.findAll(orgId, pagination); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single member by id (within active org)' }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Member id' }) + @ApiResponse({ status: 200, description: 'The member.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'No active organization.' }) + @ApiResponse({ + status: 404, + description: 'Member not found in this organization.', + }) + async findOne( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return await this.membersService.findOne(orgId, id); + } + + @Post() + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Create a member', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiResponse({ status: 201, description: 'Member created.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ + status: 409, + description: + 'A member with this email already exists in this organization.', + }) + async create(@ActiveOrgId() orgId: string, @Body() dto: CreateMemberDto) { + return await this.membersService.create(orgId, dto); + } + + @Patch(':id') + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Update a member', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Member id' }) + @ApiResponse({ status: 200, description: 'Member updated.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Member not found.' }) + async update( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateMemberDto, + ) { + return await this.membersService.update(orgId, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Soft-delete a member', + description: + 'Marks the member as inactive instead of physically deleting the row. Requires organization role `owner` or `admin`.', + }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Member id' }) + @ApiResponse({ status: 204, description: 'Member deactivated.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Member not found.' }) + async remove( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.membersService.softDelete(orgId, id); + } +} diff --git a/backend/src/modules/members/members.module.ts b/backend/src/modules/members/members.module.ts new file mode 100644 index 0000000..773b85a --- /dev/null +++ b/backend/src/modules/members/members.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MembersController } from './members.controller'; +import { MembersService } from './members.service'; + +@Module({ + controllers: [MembersController], + providers: [MembersService], + exports: [MembersService], +}) +export class MembersModule {} diff --git a/backend/src/modules/members/members.service.spec.ts b/backend/src/modules/members/members.service.spec.ts new file mode 100644 index 0000000..63ed363 --- /dev/null +++ b/backend/src/modules/members/members.service.spec.ts @@ -0,0 +1,137 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import type { MockDb } from '../../common/testing/drizzle-mock'; +import { + asDatabase, + chainResolve, + createMockDb, +} from '../../common/testing/drizzle-mock'; +import { DRIZZLE } from '../../database/database.module'; + +import { MembersService } from './members.service'; + +describe('MembersService', () => { + let service: MembersService; + let db: MockDb; + + const orgId = 'org_123'; + const memberId = '22222222-2222-2222-2222-222222222222'; + + beforeEach(async () => { + db = createMockDb(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MembersService, + { provide: DRIZZLE, useValue: asDatabase(db) }, + ], + }).compile(); + service = module.get(MembersService); + }); + + describe('create', () => { + it('inserts and returns the member when email is free', async () => { + const member = { + id: memberId, + organizationId: orgId, + email: 'pablo@acme.com', + fullName: 'Pablo', + }; + db.select.mockReturnValueOnce(chainResolve([])); + db.insert.mockReturnValueOnce(chainResolve([member])); + + const result = await service.create(orgId, { + email: 'pablo@acme.com', + fullName: 'Pablo', + }); + + expect(result).toEqual(member); + }); + + it('throws ConflictException when email exists in the org', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: 'existing' }])); + + await expect( + service.create(orgId, { email: 'pablo@acme.com', fullName: 'Pablo' }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('findAll', () => { + it('returns paginated active members scoped to org', async () => { + const members = [{ id: memberId, organizationId: orgId, isActive: true }]; + db.select.mockReturnValueOnce(chainResolve(members)); + db.select.mockReturnValueOnce(chainResolve([{ value: 1 }])); + + const result = await service.findAll(orgId, { + page: 1, + limit: 20, + order: 'asc', + }); + + expect(result.data).toEqual(members); + expect(result.total).toBe(1); + }); + }); + + describe('findOne', () => { + it('returns the member when found', async () => { + const member = { id: memberId, organizationId: orgId }; + db.select.mockReturnValueOnce(chainResolve([member])); + + const result = await service.findOne(orgId, memberId); + + expect(result).toEqual(member); + }); + + it('throws NotFoundException when missing', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect(service.findOne(orgId, memberId)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('updates and returns the member', async () => { + const member = { id: memberId, organizationId: orgId, fullName: 'Old' }; + const updated = { ...member, fullName: 'New' }; + db.select.mockReturnValueOnce(chainResolve([member])); + db.update.mockReturnValueOnce(chainResolve([updated])); + + const result = await service.update(orgId, memberId, { fullName: 'New' }); + + expect(result).toEqual(updated); + }); + + it('clears userId when set to null', async () => { + const member = { + id: memberId, + organizationId: orgId, + userId: 'user_123', + }; + const updated = { ...member, userId: null }; + db.select.mockReturnValueOnce(chainResolve([member])); + db.update.mockReturnValueOnce(chainResolve([updated])); + + const result = await service.update(orgId, memberId, { userId: null }); + + expect(result.userId).toBeNull(); + }); + }); + + describe('softDelete', () => { + it('sets isActive=false', async () => { + const member = { id: memberId, organizationId: orgId, isActive: true }; + const deleted = { ...member, isActive: false }; + db.select.mockReturnValueOnce(chainResolve([member])); + db.update.mockReturnValueOnce(chainResolve([deleted])); + + const result = await service.softDelete(orgId, memberId); + + expect(result.isActive).toBe(false); + }); + }); +}); diff --git a/backend/src/modules/members/members.service.ts b/backend/src/modules/members/members.service.ts new file mode 100644 index 0000000..19f6011 --- /dev/null +++ b/backend/src/modules/members/members.service.ts @@ -0,0 +1,128 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { and, count, eq } from 'drizzle-orm'; + +import { resolveOrderBy, SortableMap } from '../../common/db/sortable'; +import { + buildPaginatedResult, + PaginatedResult, + PaginationDto, +} from '../../common/dto/pagination.dto'; +import { DRIZZLE, type Database } from '../../database/database.module'; +import { members } from '../../database/schema'; + +import { CreateMemberDto } from './dto/create-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; + +type Member = typeof members.$inferSelect; + +const SORTABLE: SortableMap = { + email: members.email, + fullName: members.fullName, + createdAt: members.createdAt, + updatedAt: members.updatedAt, +}; + +@Injectable() +export class MembersService { + constructor(@Inject(DRIZZLE) private readonly db: Database) {} + + async create(orgId: string, dto: CreateMemberDto): Promise { + const existing = await this.db + .select({ id: members.id }) + .from(members) + .where( + and(eq(members.organizationId, orgId), eq(members.email, dto.email)), + ) + .limit(1); + + if (existing.length > 0) { + throw new ConflictException( + `Member with email ${dto.email} already exists in this organization`, + ); + } + + const [member] = await this.db + .insert(members) + .values({ + organizationId: orgId, + email: dto.email, + fullName: dto.fullName, + userId: dto.userId, + }) + .returning(); + return member; + } + + async findAll( + orgId: string, + pagination: PaginationDto, + ): Promise> { + const { page, limit, sort, order } = pagination; + const where = and( + eq(members.organizationId, orgId), + eq(members.isActive, true), + ); + + const orderBy = resolveOrderBy(SORTABLE, sort, order, members.createdAt); + + const [data, totalRows] = await Promise.all([ + this.db + .select() + .from(members) + .where(where) + .orderBy(orderBy) + .limit(limit) + .offset((page - 1) * limit), + this.db.select({ value: count() }).from(members).where(where), + ]); + + return buildPaginatedResult(data, totalRows[0]?.value ?? 0, page, limit); + } + + async findOne(orgId: string, id: string): Promise { + const rows = await this.db + .select() + .from(members) + .where(and(eq(members.id, id), eq(members.organizationId, orgId))) + .limit(1); + const member = rows.at(0); + + if (!member) { + throw new NotFoundException(`Member ${id} not found`); + } + return member; + } + + async update( + orgId: string, + id: string, + dto: UpdateMemberDto, + ): Promise { + await this.findOne(orgId, id); + + const [updated] = await this.db + .update(members) + .set(dto) + .where(and(eq(members.id, id), eq(members.organizationId, orgId))) + .returning(); + + return updated; + } + + async softDelete(orgId: string, id: string): Promise { + await this.findOne(orgId, id); + + const [deleted] = await this.db + .update(members) + .set({ isActive: false }) + .where(and(eq(members.id, id), eq(members.organizationId, orgId))) + .returning(); + + return deleted; + } +} diff --git a/backend/src/modules/roles/dto/create-role.dto.ts b/backend/src/modules/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..be8bb1c --- /dev/null +++ b/backend/src/modules/roles/dto/create-role.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateRoleDto { + @ApiProperty({ + description: 'Unique role name', + minLength: 1, + maxLength: 50, + example: 'manager', + }) + @IsString() + @MinLength(1) + @MaxLength(50) + name!: string; + + @ApiPropertyOptional({ + description: 'Human-readable description of the role', + maxLength: 500, + example: 'Manages the team and its members.', + }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; +} diff --git a/backend/src/modules/roles/dto/update-role.dto.ts b/backend/src/modules/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..bb23e64 --- /dev/null +++ b/backend/src/modules/roles/dto/update-role.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateRoleDto { + @ApiPropertyOptional({ + description: 'Unique role name', + minLength: 1, + maxLength: 50, + example: 'manager', + }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + name?: string; + + @ApiPropertyOptional({ + description: 'Human-readable description of the role', + maxLength: 500, + example: 'Manages the team and its members.', + }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; +} diff --git a/backend/src/modules/roles/roles.controller.ts b/backend/src/modules/roles/roles.controller.ts new file mode 100644 index 0000000..ad950d5 --- /dev/null +++ b/backend/src/modules/roles/roles.controller.ts @@ -0,0 +1,134 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { OrgRoles } from '@thallesp/nestjs-better-auth'; + +import { ActiveOrgId } from '../../common/decorators/active-org-id.decorator'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { RolesService } from './roles.service'; + +@ApiTags('Roles') +@ApiCookieAuth('better-auth.session_token') +@Controller('roles') +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Get() + @ApiOperation({ + summary: 'List roles', + description: + 'Roles are scoped to the active organization. Default roles (owner/manager/agent) are seeded automatically when an organization is created.', + }) + @ApiResponse({ status: 200, description: 'Paginated list of roles.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'No active organization.' }) + async findAll( + @ActiveOrgId() orgId: string, + @Query() pagination: PaginationDto, + ) { + return await this.rolesService.findAll(orgId, pagination); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single role by id (within active org)' }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Role id' }) + @ApiResponse({ status: 200, description: 'The role.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'No active organization.' }) + @ApiResponse({ status: 404, description: 'Role not found.' }) + async findOne( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return await this.rolesService.findOne(orgId, id); + } + + @Post() + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Create a role in the active organization', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiResponse({ status: 201, description: 'Role created.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ + status: 409, + description: 'A role with this name already exists in this organization.', + }) + async create(@ActiveOrgId() orgId: string, @Body() dto: CreateRoleDto) { + return await this.rolesService.create(orgId, dto); + } + + @Patch(':id') + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Update a role within the active organization', + description: + 'Requires organization role `owner` or `admin`. Default roles (owner/manager/agent) cannot be renamed but their description can be edited.', + }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Role id' }) + @ApiResponse({ status: 200, description: 'Role updated.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Role not found.' }) + @ApiResponse({ + status: 409, + description: + 'A role with this name already exists, or the role is a default role and cannot be renamed.', + }) + async update( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateRoleDto, + ) { + return await this.rolesService.update(orgId, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Delete a role within the active organization', + description: + 'Requires organization role `owner` or `admin`. Default roles (owner/manager/agent) cannot be deleted.', + }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Role id' }) + @ApiResponse({ status: 204, description: 'Role deleted.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Role not found.' }) + @ApiResponse({ + status: 409, + description: + 'Role is a default role, or is still referenced by team memberships.', + }) + async remove( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.rolesService.remove(orgId, id); + } +} diff --git a/backend/src/modules/roles/roles.module.ts b/backend/src/modules/roles/roles.module.ts new file mode 100644 index 0000000..c5046f3 --- /dev/null +++ b/backend/src/modules/roles/roles.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { RolesController } from './roles.controller'; +import { RolesService } from './roles.service'; + +@Module({ + controllers: [RolesController], + providers: [RolesService], + exports: [RolesService], +}) +export class RolesModule {} diff --git a/backend/src/modules/roles/roles.service.spec.ts b/backend/src/modules/roles/roles.service.spec.ts new file mode 100644 index 0000000..ed9ee3a --- /dev/null +++ b/backend/src/modules/roles/roles.service.spec.ts @@ -0,0 +1,208 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import type { MockDb } from '../../common/testing/drizzle-mock'; +import { + asDatabase, + chainResolve, + createMockDb, +} from '../../common/testing/drizzle-mock'; +import { DRIZZLE } from '../../database/database.module'; + +import { RolesService } from './roles.service'; + +describe('RolesService', () => { + let service: RolesService; + let db: MockDb; + + const orgId = 'org_123'; + const roleId = '33333333-3333-3333-3333-333333333333'; + + beforeEach(async () => { + db = createMockDb(); + const module: TestingModule = await Test.createTestingModule({ + providers: [RolesService, { provide: DRIZZLE, useValue: asDatabase(db) }], + }).compile(); + service = module.get(RolesService); + }); + + describe('create', () => { + it('inserts and returns the role when name is free in this org', async () => { + const role = { id: roleId, organizationId: orgId, name: 'tech-lead' }; + db.select.mockReturnValueOnce(chainResolve([])); + db.insert.mockReturnValueOnce(chainResolve([role])); + + const result = await service.create(orgId, { name: 'tech-lead' }); + + expect(result).toEqual(role); + }); + + it('throws ConflictException when name already exists in this org', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: 'existing' }])); + + await expect( + service.create(orgId, { name: 'tech-lead' }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('findAll', () => { + it('returns paginated roles scoped to the org', async () => { + const roles = [{ id: roleId, organizationId: orgId, name: 'owner' }]; + db.select.mockReturnValueOnce(chainResolve(roles)); + db.select.mockReturnValueOnce(chainResolve([{ value: 1 }])); + + const result = await service.findAll(orgId, { + page: 1, + limit: 20, + order: 'asc', + }); + + expect(result.data).toEqual(roles); + expect(result.total).toBe(1); + }); + }); + + describe('findOne', () => { + it('returns the role when found in this org', async () => { + const role = { id: roleId, organizationId: orgId, name: 'owner' }; + db.select.mockReturnValueOnce(chainResolve([role])); + + const result = await service.findOne(orgId, roleId); + + expect(result).toEqual(role); + }); + + it('throws NotFoundException when missing (or in another org)', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect(service.findOne(orgId, roleId)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('updates and returns the role', async () => { + const role = { + id: roleId, + organizationId: orgId, + name: 'tech-lead', + isDefault: false, + }; + const updated = { ...role, name: 'lead' }; + db.select.mockReturnValueOnce(chainResolve([role])); + db.select.mockReturnValueOnce(chainResolve([{ id: roleId }])); + db.update.mockReturnValueOnce(chainResolve([updated])); + + const result = await service.update(orgId, roleId, { name: 'lead' }); + + expect(result).toEqual(updated); + }); + + it('throws ConflictException when renaming to an existing name in this org', async () => { + const role = { + id: roleId, + organizationId: orgId, + name: 'tech-lead', + isDefault: false, + }; + db.select.mockReturnValueOnce(chainResolve([role])); + db.select.mockReturnValueOnce(chainResolve([{ id: 'other-role' }])); + + await expect( + service.update(orgId, roleId, { name: 'taken' }), + ).rejects.toThrow(ConflictException); + }); + + it('throws ConflictException when trying to rename a default role', async () => { + const role = { + id: roleId, + organizationId: orgId, + name: 'owner', + isDefault: true, + }; + db.select.mockReturnValueOnce(chainResolve([role])); + + await expect( + service.update(orgId, roleId, { name: 'lead' }), + ).rejects.toThrow(ConflictException); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('allows updating the description of a default role', async () => { + const role = { + id: roleId, + organizationId: orgId, + name: 'owner', + isDefault: true, + }; + const updated = { ...role, description: 'New description' }; + db.select.mockReturnValueOnce(chainResolve([role])); + db.update.mockReturnValueOnce(chainResolve([updated])); + + const result = await service.update(orgId, roleId, { + description: 'New description', + }); + + expect(result).toEqual(updated); + }); + }); + + describe('remove', () => { + it('deletes the role when unused', async () => { + const role = { + id: roleId, + organizationId: orgId, + name: 'tech-lead', + isDefault: false, + }; + db.select.mockReturnValueOnce(chainResolve([role])); // findOne + db.select.mockReturnValueOnce(chainResolve([])); // usage check + db.delete.mockReturnValueOnce(chainResolve(undefined)); + + await expect(service.remove(orgId, roleId)).resolves.toBeUndefined(); + expect(db.delete).toHaveBeenCalledTimes(1); + }); + + it('throws ConflictException when trying to delete a default role', async () => { + const role = { + id: roleId, + organizationId: orgId, + name: 'owner', + isDefault: true, + }; + db.select.mockReturnValueOnce(chainResolve([role])); // findOne + + await expect(service.remove(orgId, roleId)).rejects.toThrow( + ConflictException, + ); + expect(db.delete).not.toHaveBeenCalled(); + }); + + it('throws ConflictException when role is assigned to team members', async () => { + const role = { + id: roleId, + organizationId: orgId, + name: 'tech-lead', + isDefault: false, + }; + db.select.mockReturnValueOnce(chainResolve([role])); // findOne + db.select.mockReturnValueOnce(chainResolve([{ id: 'tm_1' }])); // usage exists + + await expect(service.remove(orgId, roleId)).rejects.toThrow( + ConflictException, + ); + expect(db.delete).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when role is missing in this org', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect(service.remove(orgId, roleId)).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/backend/src/modules/roles/roles.service.ts b/backend/src/modules/roles/roles.service.ts new file mode 100644 index 0000000..601084e --- /dev/null +++ b/backend/src/modules/roles/roles.service.ts @@ -0,0 +1,145 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { and, count, eq } from 'drizzle-orm'; + +import { resolveOrderBy, SortableMap } from '../../common/db/sortable'; +import { + buildPaginatedResult, + PaginatedResult, + PaginationDto, +} from '../../common/dto/pagination.dto'; +import { DRIZZLE, type Database } from '../../database/database.module'; +import { roles, teamMembers } from '../../database/schema'; + +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +type Role = typeof roles.$inferSelect; + +const SORTABLE: SortableMap = { + name: roles.name, + createdAt: roles.createdAt, + updatedAt: roles.updatedAt, +}; + +@Injectable() +export class RolesService { + constructor(@Inject(DRIZZLE) private readonly db: Database) {} + + async create(orgId: string, dto: CreateRoleDto): Promise { + const existing = await this.db + .select({ id: roles.id }) + .from(roles) + .where(and(eq(roles.organizationId, orgId), eq(roles.name, dto.name))) + .limit(1); + + if (existing.length > 0) { + throw new ConflictException( + `Role "${dto.name}" already exists in this organization`, + ); + } + + const [role] = await this.db + .insert(roles) + .values({ ...dto, organizationId: orgId }) + .returning(); + return role; + } + + async findAll( + orgId: string, + pagination: PaginationDto, + ): Promise> { + const { page, limit, sort, order } = pagination; + const where = eq(roles.organizationId, orgId); + const orderBy = resolveOrderBy(SORTABLE, sort, order, roles.createdAt); + + const [data, totalRows] = await Promise.all([ + this.db + .select() + .from(roles) + .where(where) + .orderBy(orderBy) + .limit(limit) + .offset((page - 1) * limit), + this.db.select({ value: count() }).from(roles).where(where), + ]); + + return buildPaginatedResult(data, totalRows[0]?.value ?? 0, page, limit); + } + + async findOne(orgId: string, id: string): Promise { + const rows = await this.db + .select() + .from(roles) + .where(and(eq(roles.id, id), eq(roles.organizationId, orgId))) + .limit(1); + const role = rows.at(0); + + if (!role) { + throw new NotFoundException(`Role ${id} not found`); + } + return role; + } + + async update(orgId: string, id: string, dto: UpdateRoleDto): Promise { + const role = await this.findOne(orgId, id); + + if (dto.name && role.isDefault) { + throw new ConflictException( + `Role "${role.name}" is a default role and cannot be renamed`, + ); + } + + if (dto.name) { + const existing = await this.db + .select({ id: roles.id }) + .from(roles) + .where(and(eq(roles.organizationId, orgId), eq(roles.name, dto.name))) + .limit(1); + const conflicting = existing.at(0); + if (conflicting && conflicting.id !== id) { + throw new ConflictException( + `Role "${dto.name}" already exists in this organization`, + ); + } + } + + const [updated] = await this.db + .update(roles) + .set(dto) + .where(and(eq(roles.id, id), eq(roles.organizationId, orgId))) + .returning(); + return updated; + } + + async remove(orgId: string, id: string): Promise { + const role = await this.findOne(orgId, id); + + if (role.isDefault) { + throw new ConflictException( + `Role "${role.name}" is a default role and cannot be deleted`, + ); + } + + const usage = await this.db + .select({ id: teamMembers.id }) + .from(teamMembers) + .where(eq(teamMembers.roleId, id)) + .limit(1); + + if (usage.length > 0) { + throw new ConflictException( + `Role ${id} is assigned to one or more team members and cannot be deleted`, + ); + } + + await this.db + .delete(roles) + .where(and(eq(roles.id, id), eq(roles.organizationId, orgId))); + } +} diff --git a/backend/src/modules/teams/dto/add-team-member.dto.ts b/backend/src/modules/teams/dto/add-team-member.dto.ts new file mode 100644 index 0000000..cc3a6ef --- /dev/null +++ b/backend/src/modules/teams/dto/add-team-member.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class AddTeamMemberDto { + @ApiProperty({ + description: 'Id of the member (from /members) to add to the team', + format: 'uuid', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsUUID() + memberId!: string; + + @ApiProperty({ + description: + 'Id of the role (from /roles) to assign to this team membership', + format: 'uuid', + example: '550e8400-e29b-41d4-a716-446655440001', + }) + @IsUUID() + roleId!: string; +} diff --git a/backend/src/modules/teams/dto/create-team.dto.ts b/backend/src/modules/teams/dto/create-team.dto.ts new file mode 100644 index 0000000..e87e9ae --- /dev/null +++ b/backend/src/modules/teams/dto/create-team.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateTeamDto { + @ApiProperty({ + description: 'Team name', + minLength: 1, + maxLength: 100, + example: 'Support — Tier 1', + }) + @IsString() + @MinLength(1) + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ + description: 'Optional team description', + maxLength: 500, + example: 'First-line support team handling inbound tickets.', + }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; +} diff --git a/backend/src/modules/teams/dto/update-team-member.dto.ts b/backend/src/modules/teams/dto/update-team-member.dto.ts new file mode 100644 index 0000000..77fad9d --- /dev/null +++ b/backend/src/modules/teams/dto/update-team-member.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class UpdateTeamMemberDto { + @ApiProperty({ + description: 'Id of the new role to assign to this team membership', + format: 'uuid', + example: '550e8400-e29b-41d4-a716-446655440001', + }) + @IsUUID() + roleId!: string; +} diff --git a/backend/src/modules/teams/dto/update-team.dto.ts b/backend/src/modules/teams/dto/update-team.dto.ts new file mode 100644 index 0000000..b6bd24e --- /dev/null +++ b/backend/src/modules/teams/dto/update-team.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class UpdateTeamDto { + @ApiPropertyOptional({ + description: 'Team name', + minLength: 1, + maxLength: 100, + example: 'Support — Tier 1', + }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + name?: string; + + @ApiPropertyOptional({ + description: 'Optional team description', + maxLength: 500, + example: 'First-line support team handling inbound tickets.', + }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @ApiPropertyOptional({ + description: 'Whether the team is active. Set to false to soft-delete.', + example: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/backend/src/modules/teams/team-members.controller.ts b/backend/src/modules/teams/team-members.controller.ts new file mode 100644 index 0000000..953871b --- /dev/null +++ b/backend/src/modules/teams/team-members.controller.ts @@ -0,0 +1,121 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { OrgRoles } from '@thallesp/nestjs-better-auth'; + +import { ActiveOrgId } from '../../common/decorators/active-org-id.decorator'; + +import { AddTeamMemberDto } from './dto/add-team-member.dto'; +import { UpdateTeamMemberDto } from './dto/update-team-member.dto'; +import { TeamMembersService } from './team-members.service'; + +@ApiTags('Team members') +@ApiCookieAuth('better-auth.session_token') +@ApiParam({ name: 'teamId', format: 'uuid', description: 'Team id' }) +@Controller('teams/:teamId/members') +export class TeamMembersController { + constructor(private readonly teamMembersService: TeamMembersService) {} + + @Get() + @ApiOperation({ summary: 'List members of a team' }) + @ApiResponse({ status: 200, description: 'List of team memberships.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'No active organization.' }) + @ApiResponse({ status: 404, description: 'Team not found.' }) + async list( + @ActiveOrgId() orgId: string, + @Param('teamId', ParseUUIDPipe) teamId: string, + ) { + return await this.teamMembersService.list(orgId, teamId); + } + + @Post() + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Add a member to a team', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiResponse({ status: 201, description: 'Member added to the team.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Team, member or role not found.' }) + @ApiResponse({ + status: 409, + description: 'Member already belongs to this team.', + }) + async add( + @ActiveOrgId() orgId: string, + @Param('teamId', ParseUUIDPipe) teamId: string, + @Body() dto: AddTeamMemberDto, + ) { + return await this.teamMembersService.add(orgId, teamId, dto); + } + + @Patch(':memberId') + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Update the role of a team member', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiParam({ + name: 'memberId', + format: 'uuid', + description: + 'Member id (note: the id of the `members` row, not the team_member id).', + }) + @ApiResponse({ status: 200, description: 'Team membership updated.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Team, member or role not found.' }) + async updateRole( + @ActiveOrgId() orgId: string, + @Param('teamId', ParseUUIDPipe) teamId: string, + @Param('memberId', ParseUUIDPipe) memberId: string, + @Body() dto: UpdateTeamMemberDto, + ) { + return await this.teamMembersService.updateRole( + orgId, + teamId, + memberId, + dto, + ); + } + + @Delete(':memberId') + @HttpCode(HttpStatus.NO_CONTENT) + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Remove a member from a team', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiParam({ name: 'memberId', format: 'uuid', description: 'Member id' }) + @ApiResponse({ status: 204, description: 'Member removed from the team.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Team or membership not found.' }) + async remove( + @ActiveOrgId() orgId: string, + @Param('teamId', ParseUUIDPipe) teamId: string, + @Param('memberId', ParseUUIDPipe) memberId: string, + ): Promise { + await this.teamMembersService.remove(orgId, teamId, memberId); + } +} diff --git a/backend/src/modules/teams/team-members.service.spec.ts b/backend/src/modules/teams/team-members.service.spec.ts new file mode 100644 index 0000000..6beb659 --- /dev/null +++ b/backend/src/modules/teams/team-members.service.spec.ts @@ -0,0 +1,160 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import type { MockDb } from '../../common/testing/drizzle-mock'; +import { + asDatabase, + chainResolve, + createMockDb, +} from '../../common/testing/drizzle-mock'; +import { DRIZZLE } from '../../database/database.module'; + +import { TeamMembersService } from './team-members.service'; + +describe('TeamMembersService', () => { + let service: TeamMembersService; + let db: MockDb; + + const orgId = 'org_123'; + const teamId = '44444444-4444-4444-4444-444444444444'; + const memberId = '55555555-5555-5555-5555-555555555555'; + const roleId = '66666666-6666-6666-6666-666666666666'; + const teamMemberId = '77777777-7777-7777-7777-777777777777'; + + beforeEach(async () => { + db = createMockDb(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TeamMembersService, + { provide: DRIZZLE, useValue: asDatabase(db) }, + ], + }).compile(); + service = module.get(TeamMembersService); + }); + + describe('list', () => { + it('returns memberships when team exists', async () => { + const memberships = [ + { + id: teamMemberId, + joinedAt: new Date(), + member: { id: memberId, email: 'p@a.com' }, + role: { id: roleId, name: 'agent' }, + }, + ]; + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.select.mockReturnValueOnce(chainResolve(memberships)); + + const result = await service.list(orgId, teamId); + + expect(result).toEqual(memberships); + }); + + it('throws NotFoundException when team is missing', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect(service.list(orgId, teamId)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('add', () => { + it('inserts a new team membership when all entities exist', async () => { + const created = { id: teamMemberId, teamId, memberId, roleId }; + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); // ensureTeam + db.select.mockReturnValueOnce(chainResolve([{ id: memberId }])); // ensureMember + db.select.mockReturnValueOnce(chainResolve([{ id: roleId }])); // ensureRole + db.select.mockReturnValueOnce(chainResolve([])); // no duplicate + db.insert.mockReturnValueOnce(chainResolve([created])); + + const result = await service.add(orgId, teamId, { memberId, roleId }); + + expect(result).toEqual(created); + }); + + it('throws ConflictException when member already in team', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.select.mockReturnValueOnce(chainResolve([{ id: memberId }])); + db.select.mockReturnValueOnce(chainResolve([{ id: roleId }])); + db.select.mockReturnValueOnce(chainResolve([{ id: teamMemberId }])); + + await expect( + service.add(orgId, teamId, { memberId, roleId }), + ).rejects.toThrow(ConflictException); + }); + + it('throws NotFoundException when team is missing', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect( + service.add(orgId, teamId, { memberId, roleId }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when member is not in org', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.select.mockReturnValueOnce(chainResolve([])); + + await expect( + service.add(orgId, teamId, { memberId, roleId }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when role does not exist', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.select.mockReturnValueOnce(chainResolve([{ id: memberId }])); + db.select.mockReturnValueOnce(chainResolve([])); + + await expect( + service.add(orgId, teamId, { memberId, roleId }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateRole', () => { + it('updates the role of a team member', async () => { + const updated = { id: teamMemberId, teamId, memberId, roleId }; + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.select.mockReturnValueOnce(chainResolve([{ id: roleId }])); + db.update.mockReturnValueOnce(chainResolve([updated])); + + const result = await service.updateRole(orgId, teamId, memberId, { + roleId, + }); + + expect(result).toEqual(updated); + }); + + it('throws NotFoundException when member not in team', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.select.mockReturnValueOnce(chainResolve([{ id: roleId }])); + db.update.mockReturnValueOnce(chainResolve([])); + + await expect( + service.updateRole(orgId, teamId, memberId, { roleId }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('removes the membership', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.delete.mockReturnValueOnce(chainResolve([{ id: teamMemberId }])); + + await expect( + service.remove(orgId, teamId, memberId), + ).resolves.toBeUndefined(); + }); + + it('throws NotFoundException when not in team', async () => { + db.select.mockReturnValueOnce(chainResolve([{ id: teamId }])); + db.delete.mockReturnValueOnce(chainResolve([])); + + await expect(service.remove(orgId, teamId, memberId)).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/backend/src/modules/teams/team-members.service.ts b/backend/src/modules/teams/team-members.service.ts new file mode 100644 index 0000000..ef07e05 --- /dev/null +++ b/backend/src/modules/teams/team-members.service.ts @@ -0,0 +1,158 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { and, eq } from 'drizzle-orm'; + +import { DRIZZLE, type Database } from '../../database/database.module'; +import { members, roles, teamMembers, teams } from '../../database/schema'; + +import { AddTeamMemberDto } from './dto/add-team-member.dto'; +import { UpdateTeamMemberDto } from './dto/update-team-member.dto'; + +type TeamMember = typeof teamMembers.$inferSelect; + +@Injectable() +export class TeamMembersService { + constructor(@Inject(DRIZZLE) private readonly db: Database) {} + + async list(orgId: string, teamId: string) { + await this.ensureTeam(orgId, teamId); + + return await this.db + .select({ + id: teamMembers.id, + joinedAt: teamMembers.joinedAt, + member: { + id: members.id, + email: members.email, + fullName: members.fullName, + isActive: members.isActive, + }, + role: { + id: roles.id, + name: roles.name, + description: roles.description, + }, + }) + .from(teamMembers) + .innerJoin(members, eq(teamMembers.memberId, members.id)) + .innerJoin(roles, eq(teamMembers.roleId, roles.id)) + .where(eq(teamMembers.teamId, teamId)); + } + + async add( + orgId: string, + teamId: string, + dto: AddTeamMemberDto, + ): Promise { + await this.ensureTeam(orgId, teamId); + await this.ensureMemberInOrg(orgId, dto.memberId); + await this.ensureRole(orgId, dto.roleId); + + const existing = await this.db + .select({ id: teamMembers.id }) + .from(teamMembers) + .where( + and( + eq(teamMembers.teamId, teamId), + eq(teamMembers.memberId, dto.memberId), + ), + ) + .limit(1); + + if (existing.length > 0) { + throw new ConflictException('Member already in this team'); + } + + const [created] = await this.db + .insert(teamMembers) + .values({ + teamId, + memberId: dto.memberId, + roleId: dto.roleId, + }) + .returning(); + return created; + } + + async updateRole( + orgId: string, + teamId: string, + memberId: string, + dto: UpdateTeamMemberDto, + ): Promise { + await this.ensureTeam(orgId, teamId); + await this.ensureRole(orgId, dto.roleId); + + const rows = await this.db + .update(teamMembers) + .set({ roleId: dto.roleId }) + .where( + and(eq(teamMembers.teamId, teamId), eq(teamMembers.memberId, memberId)), + ) + .returning(); + const updated = rows.at(0); + + if (!updated) { + throw new NotFoundException('Member not in this team'); + } + return updated; + } + + async remove(orgId: string, teamId: string, memberId: string): Promise { + await this.ensureTeam(orgId, teamId); + + const rows = await this.db + .delete(teamMembers) + .where( + and(eq(teamMembers.teamId, teamId), eq(teamMembers.memberId, memberId)), + ) + .returning({ id: teamMembers.id }); + + if (rows.length === 0) { + throw new NotFoundException('Member not in this team'); + } + } + + private async ensureTeam(orgId: string, teamId: string): Promise { + const rows = await this.db + .select({ id: teams.id }) + .from(teams) + .where(and(eq(teams.id, teamId), eq(teams.organizationId, orgId))) + .limit(1); + + if (rows.length === 0) { + throw new NotFoundException(`Team ${teamId} not found`); + } + } + + private async ensureMemberInOrg( + orgId: string, + memberId: string, + ): Promise { + const rows = await this.db + .select({ id: members.id }) + .from(members) + .where(and(eq(members.id, memberId), eq(members.organizationId, orgId))) + .limit(1); + + if (rows.length === 0) { + throw new NotFoundException(`Member ${memberId} not found`); + } + } + + private async ensureRole(orgId: string, roleId: string): Promise { + const rows = await this.db + .select({ id: roles.id }) + .from(roles) + .where(and(eq(roles.id, roleId), eq(roles.organizationId, orgId))) + .limit(1); + + if (rows.length === 0) { + throw new NotFoundException(`Role ${roleId} not found`); + } + } +} diff --git a/backend/src/modules/teams/teams.controller.ts b/backend/src/modules/teams/teams.controller.ts new file mode 100644 index 0000000..3f843f3 --- /dev/null +++ b/backend/src/modules/teams/teams.controller.ts @@ -0,0 +1,122 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { OrgRoles } from '@thallesp/nestjs-better-auth'; + +import { ActiveOrgId } from '../../common/decorators/active-org-id.decorator'; +import { PaginationDto } from '../../common/dto/pagination.dto'; + +import { CreateTeamDto } from './dto/create-team.dto'; +import { UpdateTeamDto } from './dto/update-team.dto'; +import { TeamsService } from './teams.service'; + +@ApiTags('Teams') +@ApiCookieAuth('better-auth.session_token') +@Controller('teams') +export class TeamsController { + constructor(private readonly teamsService: TeamsService) {} + + @Get() + @ApiOperation({ + summary: 'List teams of the active organization', + description: + "Paginated list of teams scoped to the caller's active organization.", + }) + @ApiResponse({ status: 200, description: 'Paginated list of teams.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'No active organization.' }) + async findAll( + @ActiveOrgId() orgId: string, + @Query() pagination: PaginationDto, + ) { + return await this.teamsService.findAll(orgId, pagination); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single team by id (within active org)' }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Team id' }) + @ApiResponse({ status: 200, description: 'The team.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'No active organization.' }) + @ApiResponse({ + status: 404, + description: 'Team not found in this organization.', + }) + async findOne( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return await this.teamsService.findOne(orgId, id); + } + + @Post() + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Create a team', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiResponse({ status: 201, description: 'Team created.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + async create(@ActiveOrgId() orgId: string, @Body() dto: CreateTeamDto) { + return await this.teamsService.create(orgId, dto); + } + + @Patch(':id') + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Update a team', + description: 'Requires organization role `owner` or `admin`.', + }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Team id' }) + @ApiResponse({ status: 200, description: 'Team updated.' }) + @ApiResponse({ status: 400, description: 'Validation error.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Team not found.' }) + async update( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTeamDto, + ) { + return await this.teamsService.update(orgId, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @OrgRoles(['owner', 'admin']) + @ApiOperation({ + summary: 'Soft-delete a team', + description: + 'Marks the team as inactive instead of physically deleting the row. Requires organization role `owner` or `admin`.', + }) + @ApiParam({ name: 'id', format: 'uuid', description: 'Team id' }) + @ApiResponse({ status: 204, description: 'Team deactivated.' }) + @ApiResponse({ status: 401, description: 'Not authenticated.' }) + @ApiResponse({ status: 403, description: 'Insufficient organization role.' }) + @ApiResponse({ status: 404, description: 'Team not found.' }) + async remove( + @ActiveOrgId() orgId: string, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.teamsService.softDelete(orgId, id); + } +} diff --git a/backend/src/modules/teams/teams.module.ts b/backend/src/modules/teams/teams.module.ts new file mode 100644 index 0000000..3fe7446 --- /dev/null +++ b/backend/src/modules/teams/teams.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { TeamMembersController } from './team-members.controller'; +import { TeamMembersService } from './team-members.service'; +import { TeamsController } from './teams.controller'; +import { TeamsService } from './teams.service'; + +@Module({ + controllers: [TeamsController, TeamMembersController], + providers: [TeamsService, TeamMembersService], + exports: [TeamsService, TeamMembersService], +}) +export class TeamsModule {} diff --git a/backend/src/modules/teams/teams.service.spec.ts b/backend/src/modules/teams/teams.service.spec.ts new file mode 100644 index 0000000..40f741c --- /dev/null +++ b/backend/src/modules/teams/teams.service.spec.ts @@ -0,0 +1,127 @@ +import { NotFoundException } from '@nestjs/common'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import type { MockDb } from '../../common/testing/drizzle-mock'; +import { + asDatabase, + chainResolve, + createMockDb, +} from '../../common/testing/drizzle-mock'; +import { DRIZZLE } from '../../database/database.module'; + +import { TeamsService } from './teams.service'; + +describe('TeamsService', () => { + let service: TeamsService; + let db: MockDb; + + const orgId = 'org_123'; + const teamId = '11111111-1111-1111-1111-111111111111'; + + beforeEach(async () => { + db = createMockDb(); + const module: TestingModule = await Test.createTestingModule({ + providers: [TeamsService, { provide: DRIZZLE, useValue: asDatabase(db) }], + }).compile(); + service = module.get(TeamsService); + }); + + describe('create', () => { + it('inserts and returns the created team', async () => { + const team = { id: teamId, organizationId: orgId, name: 'Backend' }; + db.insert.mockReturnValueOnce(chainResolve([team])); + + const result = await service.create(orgId, { + name: 'Backend', + description: 'desc', + }); + + expect(result).toEqual(team); + expect(db.insert).toHaveBeenCalledTimes(1); + }); + }); + + describe('findAll', () => { + it('returns paginated active teams scoped to org', async () => { + const teams = [{ id: teamId, organizationId: orgId, name: 'Backend' }]; + db.select.mockReturnValueOnce(chainResolve(teams)); + db.select.mockReturnValueOnce(chainResolve([{ value: 1 }])); + + const result = await service.findAll(orgId, { + page: 1, + limit: 20, + order: 'asc', + }); + + expect(result).toEqual({ + data: teams, + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }); + }); + }); + + describe('findOne', () => { + it('returns the team when found', async () => { + const team = { id: teamId, organizationId: orgId, name: 'Backend' }; + db.select.mockReturnValueOnce(chainResolve([team])); + + const result = await service.findOne(orgId, teamId); + + expect(result).toEqual(team); + }); + + it('throws NotFoundException when missing', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect(service.findOne(orgId, teamId)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('updates and returns the team', async () => { + const team = { id: teamId, organizationId: orgId, name: 'Backend' }; + const updated = { ...team, name: 'New' }; + db.select.mockReturnValueOnce(chainResolve([team])); + db.update.mockReturnValueOnce(chainResolve([updated])); + + const result = await service.update(orgId, teamId, { name: 'New' }); + + expect(result).toEqual(updated); + }); + + it('throws NotFoundException when team is missing', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect( + service.update(orgId, teamId, { name: 'x' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('softDelete', () => { + it('sets isActive=false', async () => { + const team = { id: teamId, organizationId: orgId, isActive: true }; + const deleted = { ...team, isActive: false }; + db.select.mockReturnValueOnce(chainResolve([team])); + db.update.mockReturnValueOnce(chainResolve([deleted])); + + const result = await service.softDelete(orgId, teamId); + + expect(result.isActive).toBe(false); + }); + + it('throws NotFoundException when team is missing', async () => { + db.select.mockReturnValueOnce(chainResolve([])); + + await expect(service.softDelete(orgId, teamId)).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/backend/src/modules/teams/teams.service.ts b/backend/src/modules/teams/teams.service.ts new file mode 100644 index 0000000..9f6b453 --- /dev/null +++ b/backend/src/modules/teams/teams.service.ts @@ -0,0 +1,103 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { and, count, eq } from 'drizzle-orm'; + +import { resolveOrderBy, SortableMap } from '../../common/db/sortable'; +import { + buildPaginatedResult, + PaginatedResult, + PaginationDto, +} from '../../common/dto/pagination.dto'; +import { DRIZZLE, type Database } from '../../database/database.module'; +import { teams } from '../../database/schema'; + +import { CreateTeamDto } from './dto/create-team.dto'; +import { UpdateTeamDto } from './dto/update-team.dto'; + +type Team = typeof teams.$inferSelect; + +const SORTABLE: SortableMap = { + name: teams.name, + createdAt: teams.createdAt, + updatedAt: teams.updatedAt, +}; + +@Injectable() +export class TeamsService { + constructor(@Inject(DRIZZLE) private readonly db: Database) {} + + async create(orgId: string, dto: CreateTeamDto): Promise { + const [team] = await this.db + .insert(teams) + .values({ + organizationId: orgId, + name: dto.name, + description: dto.description, + }) + .returning(); + return team; + } + + async findAll( + orgId: string, + pagination: PaginationDto, + ): Promise> { + const { page, limit, sort, order } = pagination; + const where = and( + eq(teams.organizationId, orgId), + eq(teams.isActive, true), + ); + + const orderBy = resolveOrderBy(SORTABLE, sort, order, teams.createdAt); + + const [data, totalRows] = await Promise.all([ + this.db + .select() + .from(teams) + .where(where) + .orderBy(orderBy) + .limit(limit) + .offset((page - 1) * limit), + this.db.select({ value: count() }).from(teams).where(where), + ]); + + return buildPaginatedResult(data, totalRows[0]?.value ?? 0, page, limit); + } + + async findOne(orgId: string, id: string): Promise { + const rows = await this.db + .select() + .from(teams) + .where(and(eq(teams.id, id), eq(teams.organizationId, orgId))) + .limit(1); + const team = rows.at(0); + + if (!team) { + throw new NotFoundException(`Team ${id} not found`); + } + return team; + } + + async update(orgId: string, id: string, dto: UpdateTeamDto): Promise { + await this.findOne(orgId, id); + + const [updated] = await this.db + .update(teams) + .set(dto) + .where(and(eq(teams.id, id), eq(teams.organizationId, orgId))) + .returning(); + + return updated; + } + + async softDelete(orgId: string, id: string): Promise { + await this.findOne(orgId, id); + + const [deleted] = await this.db + .update(teams) + .set({ isActive: false }) + .where(and(eq(teams.id, id), eq(teams.organizationId, orgId))) + .returning(); + + return deleted; + } +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index e0318cd..2eb3bce 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,26 +1,10 @@ -import type { INestApplication } from '@nestjs/common'; -import type { TestingModule } from '@nestjs/testing'; -import { Test } from '@nestjs/testing'; import request from 'supertest'; -import type { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3001'; - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); +describe('AppController (e2e)', () => { + it('/ (GET) is public and returns 200 without auth', async () => { + const res = await request(BASE_URL).get('/').expect(200); + expect(res.text).toBeTruthy(); }); }); diff --git a/backend/test/routing.e2e-spec.ts b/backend/test/routing.e2e-spec.ts new file mode 100644 index 0000000..c1b7ac2 --- /dev/null +++ b/backend/test/routing.e2e-spec.ts @@ -0,0 +1,288 @@ +import { randomUUID } from 'node:crypto'; + +import request from 'supertest'; +import type TestAgent from 'supertest/lib/agent'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3001'; +const ORIGIN = BASE_URL; + +interface Team { + id: string; + name: string; + isActive: boolean; + description?: string | null; +} + +interface Member { + id: string; + email: string; + fullName: string; + userId: string | null; +} + +interface Role { + id: string; + name: string; + description?: string | null; + isDefault: boolean; +} + +interface PaginatedRoles { + data: Role[]; + total: number; +} + +interface PaginatedTeams { + data: Team[]; + total: number; +} + +describe('Routing flow (e2e)', () => { + let http: TestAgent; + + let orgId: string; + let teamId: string; + let memberId: string; + let agentRoleId: string; + let ownerRoleId: string; + let customRoleId: string; + + const email = `e2e-${randomUUID()}@dispatch.test`; + const password = 'super-secret-123'; + const orgSlug = `acme-${randomUUID().slice(0, 8)}`; + + beforeAll(() => { + http = request.agent(BASE_URL).set('Origin', ORIGIN); + }); + + describe('auth bootstrap', () => { + it('signs up a new user', async () => { + const res = await http + .post('/api/auth/sign-up/email') + .send({ email, password, name: 'E2E Tester' }); + expect([200, 201]).toContain(res.status); + }); + + it('creates and activates an organization', async () => { + const createRes = await http + .post('/api/auth/organization/create') + .send({ name: 'Acme E2E', slug: orgSlug }); + expect([200, 201]).toContain(createRes.status); + const body = createRes.body as { id: string }; + orgId = body.id; + expect(orgId).toBeTruthy(); + + const setActiveRes = await http + .post('/api/auth/organization/set-active') + .send({ organizationId: orgId }); + expect([200, 201]).toContain(setActiveRes.status); + }); + }); + + describe('teams CRUD', () => { + it('lists empty teams', async () => { + const res = await http.get('/teams').expect(200); + const body = res.body as PaginatedTeams; + expect(body.data).toHaveLength(0); + expect(body.total).toBe(0); + }); + + it('creates a team', async () => { + const res = await http + .post('/teams') + .send({ name: 'Backend', description: 'API & infra' }) + .expect(201); + const body = res.body as Team; + teamId = body.id; + expect(body.name).toBe('Backend'); + expect(body.isActive).toBe(true); + }); + + it('finds the team in the list', async () => { + const res = await http.get('/teams').expect(200); + const body = res.body as PaginatedTeams; + expect(body.total).toBe(1); + expect(body.data[0].id).toBe(teamId); + }); + + it('updates the team', async () => { + const res = await http + .patch(`/teams/${teamId}`) + .send({ description: 'updated' }) + .expect(200); + const body = res.body as Team; + expect(body.description).toBe('updated'); + }); + + it('soft-deletes the team', async () => { + await http.delete(`/teams/${teamId}`).expect(204); + + const listRes = await http.get('/teams').expect(200); + const body = listRes.body as PaginatedTeams; + expect(body.total).toBe(0); + }); + }); + + describe('roles', () => { + it('lists exactly the 3 roles seeded for this org, all marked as default', async () => { + const res = await http.get('/roles').expect(200); + const body = res.body as PaginatedRoles; + expect(body.total).toBe(3); + const names = body.data.map((r) => r.name).sort(); + expect(names).toEqual(['agent', 'manager', 'owner']); + expect(body.data.every((r) => r.isDefault)).toBe(true); + agentRoleId = body.data.find((r) => r.name === 'agent')!.id; + ownerRoleId = body.data.find((r) => r.name === 'owner')!.id; + }); + + it('rejects deleting a default role with 409', async () => { + const res = await http.delete(`/roles/${ownerRoleId}`).expect(409); + const body = res.body as { statusCode: number; message: string }; + expect(body.statusCode).toBe(409); + expect(body.message).toContain('default'); + }); + + it('rejects renaming a default role with 409', async () => { + const res = await http + .patch(`/roles/${ownerRoleId}`) + .send({ name: 'super-owner' }) + .expect(409); + const body = res.body as { message: string }; + expect(body.message).toContain('default'); + }); + + it('allows updating the description of a default role', async () => { + const res = await http + .patch(`/roles/${ownerRoleId}`) + .send({ description: 'Custom org-specific description' }) + .expect(200); + const body = res.body as Role; + expect(body.description).toBe('Custom org-specific description'); + expect(body.name).toBe('owner'); + expect(body.isDefault).toBe(true); + }); + + it('creates a custom (non-default) role', async () => { + const res = await http + .post('/roles') + .send({ name: 'tech-lead', description: 'Tech leadership role' }) + .expect(201); + const body = res.body as Role; + customRoleId = body.id; + expect(body.isDefault).toBe(false); + }); + }); + + describe('members + team membership', () => { + let liveTeamId: string; + + beforeAll(async () => { + const res = await http + .post('/teams') + .send({ name: 'Frontend' }) + .expect(201); + liveTeamId = (res.body as Team).id; + }); + + it('creates a member without a Dispatch account', async () => { + const res = await http + .post('/members') + .send({ + email: `pablo-${randomUUID()}@acme.com`, + fullName: 'Pablo Garcia', + }) + .expect(201); + const body = res.body as Member; + memberId = body.id; + expect(body.userId).toBeNull(); + }); + + it('adds the member to the team with role agent', async () => { + const res = await http + .post(`/teams/${liveTeamId}/members`) + .send({ memberId, roleId: agentRoleId }) + .expect(201); + expect(res.body).toMatchObject({ + teamId: liveTeamId, + memberId, + roleId: agentRoleId, + }); + }); + + it('rejects deleting a (non-default) role assigned to a team member with 409', async () => { + // First, swap the agent's role to the custom one so it's in use + await http + .patch(`/teams/${liveTeamId}/members/${memberId}`) + .send({ roleId: customRoleId }) + .expect(200); + + const res = await http.delete(`/roles/${customRoleId}`).expect(409); + const body = res.body as { statusCode: number; message: string }; + expect(body.statusCode).toBe(409); + expect(body.message).toContain('assigned'); + + // Swap back so the rest of the test flow stays consistent + await http + .patch(`/teams/${liveTeamId}/members/${memberId}`) + .send({ roleId: agentRoleId }) + .expect(200); + }); + + it('lists members of the team with their role', async () => { + const res = await http.get(`/teams/${liveTeamId}/members`).expect(200); + const list = res.body as Array<{ + member: { id: string }; + role: { name: string }; + }>; + expect(list).toHaveLength(1); + expect(list[0].member.id).toBe(memberId); + expect(list[0].role.name).toBe('agent'); + }); + + it('removes the member from the team', async () => { + await http.delete(`/teams/${liveTeamId}/members/${memberId}`).expect(204); + + const res = await http.get(`/teams/${liveTeamId}/members`).expect(200); + expect(res.body).toHaveLength(0); + }); + }); + + describe('cross-tenant safety', () => { + it('refuses requests with no active organization (403)', async () => { + const stranger = request.agent(BASE_URL).set('Origin', ORIGIN); + const strangerEmail = `stranger-${randomUUID()}@dispatch.test`; + + await stranger + .post('/api/auth/sign-up/email') + .send({ email: strangerEmail, password, name: 'Stranger' }); + + await stranger.get('/teams').expect(403); + }); + + it('isolates roles per organization (other org cannot see this org roles)', async () => { + const otherHttp = request.agent(BASE_URL).set('Origin', ORIGIN); + const otherEmail = `other-${randomUUID()}@dispatch.test`; + const otherSlug = `beta-${randomUUID().slice(0, 8)}`; + + await otherHttp + .post('/api/auth/sign-up/email') + .send({ email: otherEmail, password, name: 'Other Tester' }); + + const createRes = await otherHttp + .post('/api/auth/organization/create') + .send({ name: 'BetaCorp E2E', slug: otherSlug }); + const otherOrgId = (createRes.body as { id: string }).id; + + await otherHttp + .post('/api/auth/organization/set-active') + .send({ organizationId: otherOrgId }); + + const rolesRes = await otherHttp.get('/roles').expect(200); + const otherRoles = rolesRes.body as PaginatedRoles; + + expect(otherRoles.total).toBe(3); + const otherIds = otherRoles.data.map((r) => r.id); + expect(otherIds).not.toContain(agentRoleId); + }); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index acb1b68..c4d12dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5