diff --git a/infra/k6/package.json b/infra/k6/package.json index b82ea65..9e8a28f 100644 --- a/infra/k6/package.json +++ b/infra/k6/package.json @@ -8,7 +8,10 @@ "test:teams": "k6 run scenarios/teams.js", "test:projects": "k6 run scenarios/projects.js", "test:users": "k6 run scenarios/users.js", - "test:board": "k6 run scenarios/board-full.js", + "test:boards:all": "k6 run scenarios/boards.js && k6 run scenarios/boards-columns.js && k6 run scenarios/boards-views.js", + "test:boards": "k6 run scenarios/boards.js", + "test:boards:columns": "k6 run scenarios/boards-columns.js", + "test:boards:views": "k6 run scenarios/boards-views.js", "test:tasks": "k6 run scenarios/tasks.js", "smoke": "k6 run smoke.js" }, diff --git a/infra/k6/scenarios/boards-columns.js b/infra/k6/scenarios/boards-columns.js new file mode 100644 index 0000000..133fd36 --- /dev/null +++ b/infra/k6/scenarios/boards-columns.js @@ -0,0 +1,134 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:post-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:get-boards-columns-id}': ['p(95)<333'], + 'http_req_duration{name:patch-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:delete-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_columns_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 columns scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:slug/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_columns_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().id; + + check(createBoardRes, { + 'POST /projects/:id/boards: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get all columns --- + const listRes = client.get( + `/boards/${boardId}/columns`, + {}, + { tags: { name: 'get-boards-columns' } }, + ); + check(listRes, { 'GET /boards/:id/columns: array': (r) => Array.isArray(r.json()) }); + + sleep(1); + + // --- create column --- + const columnPayload = { + name: `k6_column_${randomStr(6)}`, + position: 4000, + color: '#22c55e', + }; + const createColumnRes = client.post(`/boards/${boardId}/columns`, columnPayload, { + tags: { name: 'post-boards-columns' }, + }); + const columnId = createColumnRes.json().id; + + check(createColumnRes, { + 'POST /boards/:id/columns: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get column --- + client.get( + `/boards/${boardId}/columns/${columnId}`, + {}, + { tags: { name: 'get-boards-columns-id' } }, + ); + + sleep(1); + + // --- update column --- + const updatedColumn = { + name: `k6_column_${randomStr(7)}`, + color: '#3b82f6', + }; + client.patch(`/boards/${boardId}/columns/${columnId}`, updatedColumn, { + tags: { name: 'patch-boards-columns' }, + }); + + sleep(1); + + // --- delete column --- + client.delete(`/boards/${boardId}/columns/${columnId}`, { + tags: { name: 'delete-boards-columns' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.slug}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} diff --git a/infra/k6/scenarios/boards-views.js b/infra/k6/scenarios/boards-views.js new file mode 100644 index 0000000..6b7128c --- /dev/null +++ b/infra/k6/scenarios/boards-views.js @@ -0,0 +1,130 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-boards-views}': ['p(95)<333'], + 'http_req_duration{name:post-boards-views}': ['p(95)<333'], + 'http_req_duration{name:get-boards-views-id}': ['p(95)<333'], + 'http_req_duration{name:patch-boards-views}': ['p(95)<333'], + 'http_req_duration{name:delete-boards-views}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_views_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 views scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:slug/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_views_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().id; + + check(createBoardRes, { + 'POST /projects/:id/boards: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get all views --- + const listRes = client.get( + `/boards/${boardId}/views`, + {}, + { tags: { name: 'get-boards-views' } }, + ); + check(listRes, { 'GET /boards/:id/views: array': (r) => Array.isArray(r.json()) }); + + sleep(1); + + // --- create view --- + const viewPayload = { + type: 'kanban', + name: `k6_view_${randomStr(6)}`, + position: 4000, + settings: { mock: 'k6' }, + }; + const createViewRes = client.post(`/boards/${boardId}/views`, viewPayload, { + tags: { name: 'post-boards-views' }, + }); + const viewId = createViewRes.json().id; + + check(createViewRes, { + 'POST /boards/:id/views: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get view --- + client.get(`/boards/${boardId}/views/${viewId}`, {}, { tags: { name: 'get-boards-views-id' } }); + + sleep(1); + + // --- update view --- + const updatedView = { + name: `k6_view_${randomStr(7)}`, + }; + client.patch(`/boards/${boardId}/views/${viewId}`, updatedView, { + tags: { name: 'patch-boards-views' }, + }); + + sleep(1); + + // --- delete view --- + client.delete(`/boards/${boardId}/views/${viewId}`, { + tags: { name: 'delete-boards-views' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.slug}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} diff --git a/infra/k6/scenarios/boards.js b/infra/k6/scenarios/boards.js new file mode 100644 index 0000000..6f78e6f --- /dev/null +++ b/infra/k6/scenarios/boards.js @@ -0,0 +1,115 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-projects-boards-id}': ['p(95)<333'], + 'http_req_duration{name:patch-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:delete-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_board_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 boards scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.slug}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:slug/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().id; + + check(createBoardRes, { + 'POST /projects/:id/boards: has id': (r) => r.json().id !== undefined, + }); + + sleep(1); + + // --- get all boards --- + const listRes = client.get( + `/projects/${projectId}/boards`, + {}, + { tags: { name: 'get-projects-boards' } }, + ); + check(listRes, { 'GET /projects/:id/boards: array': (r) => Array.isArray(r.json()) }); + + sleep(1); + + // --- get board --- + client.get( + `/projects/${projectId}/boards/${boardId}`, + {}, + { tags: { name: 'get-projects-boards-id' } }, + ); + + sleep(1); + + // --- update board --- + const updatedBoard = { + name: `k6_board_${randomStr(7)}`, + }; + client.patch(`/projects/${projectId}/boards/${boardId}`, updatedBoard, { + tags: { name: 'patch-projects-boards' }, + }); + + sleep(1); + + // --- delete board --- + client.delete(`/projects/${projectId}/boards/${boardId}`, { + tags: { name: 'delete-projects-boards' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.slug}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} diff --git a/migrations/0006_new_omega_red.sql b/migrations/0006_new_omega_red.sql new file mode 100644 index 0000000..3dfc12b --- /dev/null +++ b/migrations/0006_new_omega_red.sql @@ -0,0 +1,41 @@ +CREATE TYPE "base"."board_type" AS ENUM('kanban', 'calendar', 'gantt_matrix');--> statement-breakpoint +CREATE TYPE "base"."column_status" AS ENUM('backlog', 'todo', 'in_progress', 'done', 'canceled');--> statement-breakpoint +CREATE TABLE "base"."board_columns" ( + "id" text PRIMARY KEY NOT NULL, + "board_id" text NOT NULL, + "name" varchar(50) NOT NULL, + "status" "base"."column_status" DEFAULT 'backlog' NOT NULL, + "visibility" boolean DEFAULT true NOT NULL, + "position" double precision NOT NULL, + "color" varchar(7) DEFAULT '#64748b' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "base"."boards_views" ( + "id" text PRIMARY KEY NOT NULL, + "board_id" text NOT NULL, + "type" "base"."board_type" DEFAULT 'kanban' NOT NULL, + "name" varchar(100) NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "position" double precision NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "base"."boards" ( + "id" text PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "project_id" text NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "position" double precision NOT NULL, + "owner_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "base"."board_columns" ADD CONSTRAINT "board_columns_board_id_boards_id_fk" FOREIGN KEY ("board_id") REFERENCES "base"."boards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "base"."boards_views" ADD CONSTRAINT "boards_views_board_id_boards_id_fk" FOREIGN KEY ("board_id") REFERENCES "base"."boards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "base"."boards" ADD CONSTRAINT "boards_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "base"."boards" ADD CONSTRAINT "boards_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "project_board_name_idx" ON "base"."boards" USING btree ("project_id","name"); \ No newline at end of file diff --git a/migrations/meta/0006_snapshot.json b/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..7cf3011 --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,1448 @@ +{ + "id": "dc5b8c53-d095-42c8-bceb-e8105f8f2ad8", + "prevId": "4cc11042-2c5e-4ffe-bf71-faedea5219e3", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.tags": { + "name": "tags", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "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", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_active_slug_idx": { + "name": "team_active_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"teams\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams_to_tags": { + "name": "teams_to_tags", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "teams_to_tags_tag_id_idx": { + "name": "teams_to_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_to_tags_team_id_teams_id_fk": { + "name": "teams_to_tags_team_id_teams_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_to_tags_tag_id_tags_id_fk": { + "name": "teams_to_tags_tag_id_tags_id_fk", + "tableFrom": "teams_to_tags", + "tableTo": "tags", + "schemaTo": "base", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "teams_to_tags_team_id_tag_id_pk": { + "name": "teams_to_tags_team_id_tag_id_pk", + "columns": [ + "team_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_shares": { + "name": "project_shares", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_project_id_idx": { + "name": "project_share_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_shares_project_id_projects_id_fk": { + "name": "project_shares_project_id_projects_id_fk", + "tableFrom": "project_shares", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_shares_token_unique": { + "name": "project_shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "task_sequence": { + "name": "task_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_key_idx": { + "name": "project_team_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_name_idx": { + "name": "project_team_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.board_columns": { + "name": "board_columns", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "column_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "visibility": { + "name": "visibility", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#64748b'" + }, + "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": { + "board_columns_board_id_boards_id_fk": { + "name": "board_columns_board_id_boards_id_fk", + "tableFrom": "board_columns", + "tableTo": "boards", + "schemaTo": "base", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.boards_views": { + "name": "boards_views", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "board_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "position": { + "name": "position", + "type": "double precision", + "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": {}, + "foreignKeys": { + "boards_views_board_id_boards_id_fk": { + "name": "boards_views_board_id_boards_id_fk", + "tableFrom": "boards_views", + "tableTo": "boards", + "schemaTo": "base", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.boards": { + "name": "boards", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "position": { + "name": "position", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_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": { + "project_board_name_idx": { + "name": "project_board_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "boards_project_id_projects_id_fk": { + "name": "boards_project_id_projects_id_fk", + "tableFrom": "boards", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boards_owner_id_users_id_fk": { + "name": "boards_owner_id_users_id_fk", + "tableFrom": "boards", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.board_type": { + "name": "board_type", + "schema": "base", + "values": [ + "kanban", + "calendar", + "gantt_matrix" + ] + }, + "base.column_status": { + "name": "column_status", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "done", + "canceled" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index baeab62..0c1a548 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1776614072462, "tag": "0005_calm_vivisector", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1778174900584, + "tag": "0006_new_omega_red", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index c3705c0..780be13 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "k6:teams": "pnpm --filter @project/performance-tests test:teams", "k6:projects": "pnpm --filter @project/performance-tests test:projects", "k6:users": "pnpm --filter @project/performance-tests test:users", - "k6:board": "pnpm --filter @project/performance-tests test:board", + "k6:boards": "pnpm --filter @project/performance-tests test:boards:all", "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", "k6:smoke": "pnpm --filter @project/performance-tests smoke", "k6:seed": "npx tsx infra/k6/scripts/seed-k6-data.ts", diff --git a/src/app.module.ts b/src/app.module.ts index 24a612d..68a863f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { TeamsModule } from './teams'; import { ProjectsModule } from './projects'; +import { BoardsModule } from './boards'; @Module({ imports: [ @@ -54,6 +55,7 @@ import { ProjectsModule } from './projects'; UserModule, TeamsModule, ProjectsModule, + BoardsModule, BullBoardModule.forRoot({ route: '/queues', adapter: FastifyAdapter, diff --git a/src/boards/application/boards.facade.ts b/src/boards/application/boards.facade.ts new file mode 100644 index 0000000..2ea8250 --- /dev/null +++ b/src/boards/application/boards.facade.ts @@ -0,0 +1,145 @@ +import { Injectable } from '@nestjs/common'; +import { + CreateBoardUseCase, + DeleteBoardUseCase, + GetBoardQuery, + GetBoardsQuery, + UpdateBoardUseCase, + CreateBoardColumnUseCase, + UpdateBoardColumnUseCase, + DeleteBoardColumnUseCase, + GetBoardColumnsQuery, + GetBoardColumnQuery, + CreateBoardViewUseCase, + UpdateBoardViewUseCase, + DeleteBoardViewUseCase, + GetBoardViewsQuery, + GetBoardViewQuery, +} from './use-cases'; +import { + CreateBoardDto, + CreateBoardColumnDto, + CreateBoardViewDto, + UpdateBoardColumnDto, + UpdateBoardDto, + UpdateBoardViewDto, +} from './dtos'; +import type { BoardColumn, BoardView, BoardWithRelations } from '@core/boards/domain/entities'; + +@Injectable() +export class BoardsFacade { + constructor( + private readonly createBoardUC: CreateBoardUseCase, + private readonly updateBoardUC: UpdateBoardUseCase, + private readonly deleteBoardUC: DeleteBoardUseCase, + private readonly getBoardQ: GetBoardQuery, + private readonly getBoardsQ: GetBoardsQuery, + + private readonly createBoardColumnUC: CreateBoardColumnUseCase, + private readonly updateBoardColumnUC: UpdateBoardColumnUseCase, + private readonly deleteBoardColumnUC: DeleteBoardColumnUseCase, + private readonly getBoardColumnsQ: GetBoardColumnsQuery, + private readonly getBoardColumnQ: GetBoardColumnQuery, + + private readonly createBoardViewUC: CreateBoardViewUseCase, + private readonly updateBoardViewUC: UpdateBoardViewUseCase, + private readonly deleteBoardViewUC: DeleteBoardViewUseCase, + private readonly getBoardViewsQ: GetBoardViewsQuery, + private readonly getBoardViewQ: GetBoardViewQuery, + ) {} + + public async create( + projectId: string, + userId: string, + dto: CreateBoardDto, + ): Promise { + return this.createBoardUC.execute(projectId, userId, dto); + } + + public async update( + id: string, + projectId: string, + userId: string, + dto: UpdateBoardDto, + ): Promise { + return this.updateBoardUC.execute(id, projectId, userId, dto); + } + + public async delete(id: string, projectId: string, userId: string): Promise { + return this.deleteBoardUC.execute(id, projectId, userId); + } + + public async getOne( + id: string, + projectId: string, + userId: string, + ): Promise { + return this.getBoardQ.execute(id, projectId, userId); + } + + public async getAll(projectId: string, userId: string): Promise { + return this.getBoardsQ.execute(projectId, userId); + } + + public async createColumn( + boardId: string, + userId: string, + dto: CreateBoardColumnDto, + ): Promise { + return this.createBoardColumnUC.execute(boardId, userId, dto); + } + + public async updateColumn( + id: string, + boardId: string, + userId: string, + dto: UpdateBoardColumnDto, + ): Promise { + return this.updateBoardColumnUC.execute(id, boardId, userId, dto); + } + + public async deleteColumn(id: string, boardId: string, userId: string): Promise { + return this.deleteBoardColumnUC.execute(id, boardId, userId); + } + + public async getColumn( + id: string, + boardId: string, + userId: string, + ): Promise { + return this.getBoardColumnQ.execute(id, boardId, userId); + } + + public async getColumns(boardId: string, userId: string): Promise { + return this.getBoardColumnsQ.execute(boardId, userId); + } + + public async createView( + boardId: string, + userId: string, + dto: CreateBoardViewDto, + ): Promise { + return this.createBoardViewUC.execute(boardId, userId, dto); + } + + public async updateView( + id: string, + boardId: string, + userId: string, + dto: UpdateBoardViewDto, + ): Promise { + return this.updateBoardViewUC.execute(id, boardId, userId, dto); + } + + public async deleteView(id: string, boardId: string, userId: string): Promise { + return this.deleteBoardViewUC.execute(id, boardId, userId); + } + + public async getView(id: string, boardId: string, userId: string): Promise { + return this.getBoardViewQ.execute(id, boardId, userId); + } + + public async getViews(boardId: string, userId: string): Promise { + return this.getBoardViewsQ.execute(boardId, userId); + } +} diff --git a/src/boards/application/controller/boards/controller.ts b/src/boards/application/controller/boards/controller.ts new file mode 100644 index 0000000..7175d47 --- /dev/null +++ b/src/boards/application/controller/boards/controller.ts @@ -0,0 +1,67 @@ +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { BoardsFacade } from '@core/boards/application/boards.facade'; +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateBoardDto, UpdateBoardDto } from '@core/boards/application/dtos'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { + CreateBoardSwagger, + FindAllBoardsSwagger, + FindOneBoardSwagger, + RemoveBoardSwagger, + UpdateBoardSwagger, +} from './swagger'; + +@ApiBaseController('projects/:projectId/boards', 'Boards', true) +export class BoardsController { + constructor(private readonly facade: BoardsFacade) {} + + @Get() + @FindAllBoardsSwagger() + async findAll( + @Param('projectId') projectId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getAll(projectId, userId); + } + + @Get(':id') + @FindOneBoardSwagger() + async findOne( + @Param('id') id: string, + @Param('projectId') projectId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getOne(id, projectId, userId); + } + + @Post() + @CreateBoardSwagger() + async create( + @Param('projectId') projectId: string, + @GetUserId() userId: string, + @Body() dto: CreateBoardDto, + ): Promise { + return this.facade.create(projectId, userId, dto); + } + + @Patch(':id') + @UpdateBoardSwagger() + async update( + @Param('id') id: string, + @Param('projectId') projectId: string, + @GetUserId() userId: string, + @Body() dto: UpdateBoardDto, + ): Promise { + return this.facade.update(id, projectId, userId, dto); + } + + @Delete(':id') + @RemoveBoardSwagger() + async remove( + @Param('id') id: string, + @Param('projectId') projectId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.delete(id, projectId, userId); + } +} diff --git a/src/boards/application/controller/boards/swagger.ts b/src/boards/application/controller/boards/swagger.ts new file mode 100644 index 0000000..dbfb6e4 --- /dev/null +++ b/src/boards/application/controller/boards/swagger.ts @@ -0,0 +1,79 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { BoardResponse, CreateBoardDto, UpdateBoardDto } from '../../dtos'; + +export const FindAllBoardsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список досок проекта' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Список досок получен', + type: [BoardResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const FindOneBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить доску по ID' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'id', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Данные доски получены', + type: BoardResponse.Output, + }), + ApiNotFound('Доска не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const CreateBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать доску в проекте' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiBody({ type: CreateBoardDto.Output }), + ApiResponse({ + status: 201, + description: 'Доска успешно создана', + type: BoardResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить доску' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'id', description: 'ID доски', type: 'string' }), + ApiBody({ type: UpdateBoardDto.Output }), + ApiResponse({ + status: 200, + description: 'Доска обновлена', + type: BoardResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Доска не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveBoardSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить доску' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'id', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Доска удалена', + type: Boolean, + }), + ApiNotFound('Доска не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/boards/application/controller/columns/controller.ts b/src/boards/application/controller/columns/controller.ts new file mode 100644 index 0000000..e244594 --- /dev/null +++ b/src/boards/application/controller/columns/controller.ts @@ -0,0 +1,67 @@ +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { BoardsFacade } from '@core/boards/application/boards.facade'; +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateBoardColumnDto, UpdateBoardColumnDto } from '@core/boards/application/dtos'; +import type { BoardColumn } from '@core/boards/domain/entities'; +import { + CreateBoardColumnSwagger, + FindAllBoardColumnsSwagger, + FindOneBoardColumnSwagger, + RemoveBoardColumnSwagger, + UpdateBoardColumnSwagger, +} from './swagger'; + +@ApiBaseController('boards/:boardId/columns', 'Board Columns', true) +export class ColumnsController { + constructor(private readonly facade: BoardsFacade) {} + + @Get() + @FindAllBoardColumnsSwagger() + async findAll( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getColumns(boardId, userId); + } + + @Get(':id') + @FindOneBoardColumnSwagger() + async findOne( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getColumn(id, boardId, userId); + } + + @Post() + @CreateBoardColumnSwagger() + async create( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: CreateBoardColumnDto, + ): Promise { + return this.facade.createColumn(boardId, userId, dto); + } + + @Patch(':id') + @UpdateBoardColumnSwagger() + async update( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: UpdateBoardColumnDto, + ): Promise { + return this.facade.updateColumn(id, boardId, userId, dto); + } + + @Delete(':id') + @RemoveBoardColumnSwagger() + async remove( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.deleteColumn(id, boardId, userId); + } +} diff --git a/src/boards/application/controller/columns/swagger.ts b/src/boards/application/controller/columns/swagger.ts new file mode 100644 index 0000000..65a2f55 --- /dev/null +++ b/src/boards/application/controller/columns/swagger.ts @@ -0,0 +1,84 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { BoardColumnResponse, CreateBoardColumnDto, UpdateBoardColumnDto } from '../../dtos'; + +export const FindAllBoardColumnsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список колонок доски' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Список колонок получен', + type: [BoardColumnResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const FindOneBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить колонку по ID' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID колонки', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Колонка получена', + type: BoardColumnResponse.Output, + }), + ApiNotFound('Колонка не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const CreateBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать колонку в доске' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiBody({ type: CreateBoardColumnDto.Output }), + ApiResponse({ + status: 201, + description: 'Колонка создана', + type: BoardColumnResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить колонку' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID колонки', type: 'string' }), + ApiBody({ type: UpdateBoardColumnDto.Output }), + ApiResponse({ + status: 200, + description: 'Колонка обновлена', + type: BoardColumnResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Колонка не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveBoardColumnSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить колонку' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID колонки', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Колонка удалена', + type: Boolean, + }), + ApiNotFound('Колонка не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/boards/application/controller/index.ts b/src/boards/application/controller/index.ts new file mode 100644 index 0000000..6e3a48f --- /dev/null +++ b/src/boards/application/controller/index.ts @@ -0,0 +1,3 @@ +export { BoardsController } from './boards/controller'; +export { ColumnsController } from './columns/controller'; +export { ViewsController } from './views/controller'; diff --git a/src/boards/application/controller/views/controller.ts b/src/boards/application/controller/views/controller.ts new file mode 100644 index 0000000..7c75a31 --- /dev/null +++ b/src/boards/application/controller/views/controller.ts @@ -0,0 +1,67 @@ +import { Body, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { BoardsFacade } from '@core/boards/application/boards.facade'; +import { CreateBoardViewDto, UpdateBoardViewDto } from '@core/boards/application/dtos'; +import type { BoardView } from '@core/boards/domain/entities'; +import { + CreateBoardViewSwagger, + FindAllBoardViewsSwagger, + FindOneBoardViewSwagger, + RemoveBoardViewSwagger, + UpdateBoardViewSwagger, +} from './swagger'; + +@ApiBaseController('boards/:boardId/views', 'Board Views', true) +export class ViewsController { + constructor(private readonly facade: BoardsFacade) {} + + @Get() + @FindAllBoardViewsSwagger() + async findAll( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getViews(boardId, userId); + } + + @Get(':id') + @FindOneBoardViewSwagger() + async findOne( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.getView(id, boardId, userId); + } + + @Post() + @CreateBoardViewSwagger() + async create( + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: CreateBoardViewDto, + ): Promise { + return this.facade.createView(boardId, userId, dto); + } + + @Patch(':id') + @UpdateBoardViewSwagger() + async update( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + @Body() dto: UpdateBoardViewDto, + ): Promise { + return this.facade.updateView(id, boardId, userId, dto); + } + + @Delete(':id') + @RemoveBoardViewSwagger() + async remove( + @Param('id') id: string, + @Param('boardId') boardId: string, + @GetUserId() userId: string, + ): Promise { + return this.facade.deleteView(id, boardId, userId); + } +} diff --git a/src/boards/application/controller/views/swagger.ts b/src/boards/application/controller/views/swagger.ts new file mode 100644 index 0000000..ef06acf --- /dev/null +++ b/src/boards/application/controller/views/swagger.ts @@ -0,0 +1,84 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { BoardViewResponse, CreateBoardViewDto, UpdateBoardViewDto } from '../../dtos'; + +export const FindAllBoardViewsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список представлений доски' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Список представлений получен', + type: [BoardViewResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const FindOneBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить представление по ID' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID представления', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Представление получено', + type: BoardViewResponse.Output, + }), + ApiNotFound('Представление не найдено'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const CreateBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать представление в доске' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiBody({ type: CreateBoardViewDto.Output }), + ApiResponse({ + status: 201, + description: 'Представление создано', + type: BoardViewResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить представление' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID представления', type: 'string' }), + ApiBody({ type: UpdateBoardViewDto.Output }), + ApiResponse({ + status: 200, + description: 'Представление обновлено', + type: BoardViewResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Представление не найдено'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveBoardViewSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить представление' }), + ApiParam({ name: 'projectId', description: 'ID проекта', type: 'string' }), + ApiParam({ name: 'boardId', description: 'ID доски', type: 'string' }), + ApiParam({ name: 'id', description: 'ID представления', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Представление удалено', + type: Boolean, + }), + ApiNotFound('Представление не найдено'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/boards/application/dtos/boards.dto.ts b/src/boards/application/dtos/boards.dto.ts new file mode 100644 index 0000000..26aca46 --- /dev/null +++ b/src/boards/application/dtos/boards.dto.ts @@ -0,0 +1,111 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { boardTypeEnum, columnStatusEnum } from '@core/boards/infrastructure/persistence/models'; + +export const CreateBoardSchema = z.object({ + name: z + .string() + .min(1, 'Название доски не может быть пустым') + .max(100, 'Название доски не должно превышать 100 символов'), + position: z.number().finite().optional(), + settings: z.record(z.string(), z.unknown()).optional(), +}); + +export class CreateBoardDto extends createZodDto(CreateBoardSchema) {} + +export const UpdateBoardSchema = CreateBoardSchema.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }, +); + +export class UpdateBoardDto extends createZodDto(UpdateBoardSchema) {} + +export const CreateBoardColumnSchema = z.object({ + name: z + .string() + .min(1, 'Название колонки не может быть пустым') + .max(50, 'Название колонки не должно превышать 50 символов'), + position: z.number().finite(), + color: z + .string() + .regex(/^#[A-Fa-f0-9]{6}$/, 'Цвет должен быть в формате HEX (например, #FFFFFF)') + .optional(), +}); + +export class CreateBoardColumnDto extends createZodDto(CreateBoardColumnSchema) {} + +export const UpdateBoardColumnSchema = CreateBoardColumnSchema.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }, +); + +export class UpdateBoardColumnDto extends createZodDto(UpdateBoardColumnSchema) {} + +export const CreateBoardViewSchema = z.object({ + type: z.enum(boardTypeEnum.enumValues), + name: z + .string() + .min(1, 'Название представления не может быть пустым') + .max(100, 'Название представления не должно превышать 100 символов'), + settings: z.record(z.string(), z.unknown()).optional(), + position: z.number().finite(), +}); + +export class CreateBoardViewDto extends createZodDto(CreateBoardViewSchema) {} + +export const UpdateBoardViewSchema = CreateBoardViewSchema.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }, +); + +export class UpdateBoardViewDto extends createZodDto(UpdateBoardViewSchema) {} + +export const BoardColumnResponseSchema = z.object({ + id: z.string().describe('ID колонки'), + boardId: z.string().describe('ID доски'), + name: z.string().describe('Название колонки'), + position: z.number().describe('Позиция колонки'), + status: z.enum(columnStatusEnum.enumValues), + color: z.string().describe('Цвет колонки в HEX'), + createdAt: z.string().datetime().describe('Дата создания'), + updatedAt: z.string().datetime().describe('Дата обновления'), +}); + +export class BoardColumnResponse extends createZodDto(BoardColumnResponseSchema) {} + +export const BoardViewResponseSchema = z.object({ + id: z.string().describe('ID представления'), + boardId: z.string().describe('ID доски'), + type: z.enum(boardTypeEnum.enumValues).describe('Тип представления'), + name: z.string().describe('Название представления'), + settings: z.record(z.string(), z.unknown()).describe('Настройки представления'), + position: z.number().describe('Позиция представления'), + createdAt: z.string().datetime().describe('Дата создания'), + updatedAt: z.string().datetime().describe('Дата обновления'), +}); + +export class BoardViewResponse extends createZodDto(BoardViewResponseSchema) {} + +export const BoardResponseSchema = z.object({ + id: z.string().describe('ID доски'), + name: z.string().describe('Название доски'), + projectId: z.string().describe('ID проекта'), + settings: z.record(z.string(), z.unknown()).describe('Настройки доски'), + position: z.number().describe('Позиция доски'), + ownerId: z.string().nullable().describe('ID владельца доски'), + createdAt: z.string().datetime().describe('Дата создания'), + updatedAt: z.string().datetime().describe('Дата обновления'), + boardColumns: z.array(BoardColumnResponseSchema).describe('Колонки доски'), + boardViews: z.array(BoardViewResponseSchema).describe('Представления доски'), +}); + +export class BoardResponse extends createZodDto(BoardResponseSchema) {} diff --git a/src/boards/application/dtos/index.ts b/src/boards/application/dtos/index.ts new file mode 100644 index 0000000..851839c --- /dev/null +++ b/src/boards/application/dtos/index.ts @@ -0,0 +1 @@ +export * from './boards.dto'; diff --git a/src/boards/application/mappers/boards.mapper.ts b/src/boards/application/mappers/boards.mapper.ts new file mode 100644 index 0000000..dd130c3 --- /dev/null +++ b/src/boards/application/mappers/boards.mapper.ts @@ -0,0 +1 @@ +export class BoardsMapper {} diff --git a/src/boards/application/mappers/index.ts b/src/boards/application/mappers/index.ts new file mode 100644 index 0000000..c89e9d2 --- /dev/null +++ b/src/boards/application/mappers/index.ts @@ -0,0 +1 @@ +export { BoardsMapper } from './boards.mapper'; diff --git a/src/boards/application/use-cases/create-board-column.use-case.ts b/src/boards/application/use-cases/create-board-column.use-case.ts new file mode 100644 index 0000000..e55c079 --- /dev/null +++ b/src/boards/application/use-cases/create-board-column.use-case.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { CreateBoardColumnDto } from '@core/boards/application/dtos'; +import type { BoardColumn } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class CreateBoardColumnUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute( + boardId: string, + userId: string, + dto: CreateBoardColumnDto, + ): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + + return this.boardsRepo.createColumn({ boardId, ...dto }); + } +} diff --git a/src/boards/application/use-cases/create-board-view.use-case.ts b/src/boards/application/use-cases/create-board-view.use-case.ts new file mode 100644 index 0000000..d1007bd --- /dev/null +++ b/src/boards/application/use-cases/create-board-view.use-case.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { CreateBoardViewDto } from '@core/boards/application/dtos'; +import type { BoardView } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class CreateBoardViewUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute( + boardId: string, + userId: string, + dto: CreateBoardViewDto, + ): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + + return this.boardsRepo.createView({ boardId, ...dto }); + } +} diff --git a/src/boards/application/use-cases/create-board.use-case.ts b/src/boards/application/use-cases/create-board.use-case.ts new file mode 100644 index 0000000..813d9fc --- /dev/null +++ b/src/boards/application/use-cases/create-board.use-case.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { CreateBoardDto } from '@core/boards/application/dtos'; +import { BoardFactory } from '@core/boards/domain/factories/board.factory'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class CreateBoardUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute( + projectId: string, + userId: string, + dto: CreateBoardDto, + ): Promise { + await this.boardAccess.validateProjectAccess(projectId, userId); + + const { board, columns, views } = BoardFactory.createBoard(projectId, userId, dto); + + return this.boardsRepo.create(board, columns, views); + } +} diff --git a/src/boards/application/use-cases/delete-board-column.use-case.ts b/src/boards/application/use-cases/delete-board-column.use-case.ts new file mode 100644 index 0000000..de6eaef --- /dev/null +++ b/src/boards/application/use-cases/delete-board-column.use-case.ts @@ -0,0 +1,18 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class DeleteBoardColumnUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateColumnAccess(id, userId, boardId); + + return this.boardsRepo.removeColumn(id); + } +} diff --git a/src/boards/application/use-cases/delete-board-view.use-case.ts b/src/boards/application/use-cases/delete-board-view.use-case.ts new file mode 100644 index 0000000..571f571 --- /dev/null +++ b/src/boards/application/use-cases/delete-board-view.use-case.ts @@ -0,0 +1,18 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class DeleteBoardViewUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateViewAccess(id, userId, boardId); + + return this.boardsRepo.removeView(id); + } +} diff --git a/src/boards/application/use-cases/delete-board.use-case.ts b/src/boards/application/use-cases/delete-board.use-case.ts new file mode 100644 index 0000000..e1f667b --- /dev/null +++ b/src/boards/application/use-cases/delete-board.use-case.ts @@ -0,0 +1,18 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class DeleteBoardUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(id: string, projectId: string, userId: string): Promise { + await this.boardAccess.validateBoardAccess(id, userId, projectId); + + return this.boardsRepo.remove(id); + } +} diff --git a/src/boards/application/use-cases/get-board-column.query.ts b/src/boards/application/use-cases/get-board-column.query.ts new file mode 100644 index 0000000..a7bc3b0 --- /dev/null +++ b/src/boards/application/use-cases/get-board-column.query.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { BoardColumn } from '@core/boards/domain/entities'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class GetBoardColumnQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateColumnAccess(id, userId, boardId); + + return this.boardsRepo.findColumnById(id); + } +} diff --git a/src/boards/application/use-cases/get-board-columns.query.ts b/src/boards/application/use-cases/get-board-columns.query.ts new file mode 100644 index 0000000..22245b4 --- /dev/null +++ b/src/boards/application/use-cases/get-board-columns.query.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardColumn } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class GetBoardColumnsQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(boardId: string, userId: string): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + + return this.boardsRepo.findColumns(boardId); + } +} diff --git a/src/boards/application/use-cases/get-board-view.query.ts b/src/boards/application/use-cases/get-board-view.query.ts new file mode 100644 index 0000000..7155658 --- /dev/null +++ b/src/boards/application/use-cases/get-board-view.query.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { BoardView } from '@core/boards/domain/entities'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class GetBoardViewQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(id: string, boardId: string, userId: string): Promise { + await this.boardAccess.validateViewAccess(id, userId, boardId); + + return this.boardsRepo.findViewById(id); + } +} diff --git a/src/boards/application/use-cases/get-board-views.query.ts b/src/boards/application/use-cases/get-board-views.query.ts new file mode 100644 index 0000000..06ddda0 --- /dev/null +++ b/src/boards/application/use-cases/get-board-views.query.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardView } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class GetBoardViewsQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(boardId: string, userId: string): Promise { + await this.boardAccess.validateBoardAccess(boardId, userId); + + return this.boardsRepo.findViews(boardId); + } +} diff --git a/src/boards/application/use-cases/get-board.query.ts b/src/boards/application/use-cases/get-board.query.ts new file mode 100644 index 0000000..fce5f45 --- /dev/null +++ b/src/boards/application/use-cases/get-board.query.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class GetBoardQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute( + id: string, + projectId: string, + userId: string, + ): Promise { + await this.boardAccess.validateBoardAccess(id, userId, projectId); + + return this.boardsRepo.findOne(id); + } +} diff --git a/src/boards/application/use-cases/get-boards.query.ts b/src/boards/application/use-cases/get-boards.query.ts new file mode 100644 index 0000000..72b01f4 --- /dev/null +++ b/src/boards/application/use-cases/get-boards.query.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class GetBoardsQuery { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute(projectId: string, userId: string): Promise { + await this.boardAccess.validateProjectAccess(projectId, userId); + + return this.boardsRepo.findAll(projectId); + } +} diff --git a/src/boards/application/use-cases/index.ts b/src/boards/application/use-cases/index.ts new file mode 100644 index 0000000..8cf712f --- /dev/null +++ b/src/boards/application/use-cases/index.ts @@ -0,0 +1,51 @@ +import { CreateBoardUseCase } from './create-board.use-case'; +import { UpdateBoardUseCase } from './update-board.use-case'; +import { DeleteBoardUseCase } from './delete-board.use-case'; +import { GetBoardQuery } from './get-board.query'; +import { GetBoardsQuery } from './get-boards.query'; +import { CreateBoardColumnUseCase } from './create-board-column.use-case'; +import { UpdateBoardColumnUseCase } from './update-board-column.use-case'; +import { DeleteBoardColumnUseCase } from './delete-board-column.use-case'; +import { GetBoardColumnsQuery } from './get-board-columns.query'; +import { GetBoardColumnQuery } from './get-board-column.query'; +import { CreateBoardViewUseCase } from './create-board-view.use-case'; +import { UpdateBoardViewUseCase } from './update-board-view.use-case'; +import { DeleteBoardViewUseCase } from './delete-board-view.use-case'; +import { GetBoardViewsQuery } from './get-board-views.query'; +import { GetBoardViewQuery } from './get-board-view.query'; + +export * from './create-board.use-case'; +export * from './update-board.use-case'; +export * from './delete-board.use-case'; +export * from './get-board.query'; +export * from './get-boards.query'; +export * from './create-board-column.use-case'; +export * from './update-board-column.use-case'; +export * from './delete-board-column.use-case'; +export * from './get-board-columns.query'; +export * from './get-board-column.query'; +export * from './create-board-view.use-case'; +export * from './update-board-view.use-case'; +export * from './delete-board-view.use-case'; +export * from './get-board-views.query'; +export * from './get-board-view.query'; + +export const BoardUseCases = [ + CreateBoardUseCase, + UpdateBoardUseCase, + DeleteBoardUseCase, + CreateBoardColumnUseCase, + UpdateBoardColumnUseCase, + DeleteBoardColumnUseCase, + CreateBoardViewUseCase, + UpdateBoardViewUseCase, + DeleteBoardViewUseCase, +]; +export const BoardQueries = [ + GetBoardQuery, + GetBoardsQuery, + GetBoardColumnsQuery, + GetBoardColumnQuery, + GetBoardViewsQuery, + GetBoardViewQuery, +]; diff --git a/src/boards/application/use-cases/update-board-column.use-case.ts b/src/boards/application/use-cases/update-board-column.use-case.ts new file mode 100644 index 0000000..86b87de --- /dev/null +++ b/src/boards/application/use-cases/update-board-column.use-case.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { UpdateBoardColumnDto } from '@core/boards/application/dtos'; +import type { BoardColumn } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class UpdateBoardColumnUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute( + id: string, + boardId: string, + userId: string, + dto: UpdateBoardColumnDto, + ): Promise { + await this.boardAccess.validateColumnAccess(id, userId, boardId); + + return this.boardsRepo.updateColumn(id, dto); + } +} diff --git a/src/boards/application/use-cases/update-board-view.use-case.ts b/src/boards/application/use-cases/update-board-view.use-case.ts new file mode 100644 index 0000000..b8b9e0e --- /dev/null +++ b/src/boards/application/use-cases/update-board-view.use-case.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { UpdateBoardViewDto } from '@core/boards/application/dtos'; +import type { BoardView } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class UpdateBoardViewUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute( + id: string, + boardId: string, + userId: string, + dto: UpdateBoardViewDto, + ): Promise { + await this.boardAccess.validateViewAccess(id, userId, boardId); + + return this.boardsRepo.updateView(id, dto); + } +} diff --git a/src/boards/application/use-cases/update-board.use-case.ts b/src/boards/application/use-cases/update-board.use-case.ts new file mode 100644 index 0000000..1e517e6 --- /dev/null +++ b/src/boards/application/use-cases/update-board.use-case.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { UpdateBoardDto } from '@core/boards/application/dtos'; +import type { BoardWithRelations } from '@core/boards/domain/entities'; +import { BoardAccessPolicy } from '@core/boards/domain/policy'; + +@Injectable() +export class UpdateBoardUseCase { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly boardAccess: BoardAccessPolicy, + ) {} + + public async execute( + id: string, + projectId: string, + userId: string, + dto: UpdateBoardDto, + ): Promise { + await this.boardAccess.validateBoardAccess(id, userId, projectId); + + return this.boardsRepo.update(id, dto); + } +} diff --git a/src/boards/boards.module.ts b/src/boards/boards.module.ts new file mode 100644 index 0000000..4cd8363 --- /dev/null +++ b/src/boards/boards.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ProjectsModule } from '@core/projects'; +import { BoardsRepository } from './infrastructure/persistence/repositories'; +import { BoardsController, ColumnsController, ViewsController } from './application/controller'; +import { BoardsFacade } from './application/boards.facade'; +import { BoardQueries, BoardUseCases } from './application/use-cases'; +import { BoardAccessPolicy } from './domain/policy'; + +const REPOSITORY = { + provide: 'IBoardsRepository', + useClass: BoardsRepository, +}; + +@Module({ + imports: [ProjectsModule], + controllers: [BoardsController, ColumnsController, ViewsController], + providers: [REPOSITORY, BoardAccessPolicy, BoardsFacade, ...BoardUseCases, ...BoardQueries], +}) +export class BoardsModule {} diff --git a/src/boards/domain/entities/boards.domain.ts b/src/boards/domain/entities/boards.domain.ts new file mode 100644 index 0000000..edb7813 --- /dev/null +++ b/src/boards/domain/entities/boards.domain.ts @@ -0,0 +1,25 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { + boards, + boardViews, + boardTypeEnum, + boardColumns, + columnStatusEnum, +} from '@core/boards/infrastructure/persistence/models'; + +export type BoardType = (typeof boardTypeEnum.enumValues)[number]; +export type BoardColumnStatus = (typeof columnStatusEnum.enumValues)[number]; + +export type Board = InferSelectModel; +export type NewBoard = InferInsertModel; + +export type BoardColumn = InferSelectModel; +export type NewBoardColumn = InferInsertModel; + +export type BoardView = InferSelectModel; +export type NewBoardView = InferInsertModel; + +export type BoardWithRelations = Board & { + boardColumns: BoardColumn[]; + boardViews: BoardView[]; +}; diff --git a/src/boards/domain/entities/index.ts b/src/boards/domain/entities/index.ts new file mode 100644 index 0000000..1da2d74 --- /dev/null +++ b/src/boards/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './boards.domain'; diff --git a/src/boards/domain/factories/board.factory.ts b/src/boards/domain/factories/board.factory.ts new file mode 100644 index 0000000..69db0a0 --- /dev/null +++ b/src/boards/domain/factories/board.factory.ts @@ -0,0 +1,118 @@ +import { BoardType, NewBoard, NewBoardColumn, NewBoardView } from '@core/boards/domain/entities'; +import { createId } from '@paralleldrive/cuid2'; +import { CreateBoardDto } from '@core/boards/application/dtos'; + +export class BoardFactory { + static createView(props: { + boardId: string; + type: BoardType; + name?: string; + position: number; + settings?: Record; + }): NewBoardView { + return { + id: createId(), + boardId: props.boardId, + type: props.type, + name: props.name ?? this.getDefaultViewName(props.type), + position: props.position, + settings: props.settings ?? this.getDefaultSettings(props.type), + }; + } + + static createBoard( + projectId: string, + ownerId: string, + dto: CreateBoardDto, + ): { board: NewBoard; columns: NewBoardColumn[]; views: NewBoardView[] } { + const boardId = createId(); + const boardPosition = dto.position ?? Date.now(); + const board: NewBoard = { + id: boardId, + name: dto.name, + projectId, + ownerId, + position: boardPosition, + settings: dto.settings ?? {}, + }; + + const defaultViewTypes: BoardType[] = ['kanban', 'calendar', 'gantt_matrix']; + + const views = defaultViewTypes.map((type, index) => + this.createView({ + boardId, + type, + position: (index + 1) * 1000, + }), + ); + + const columns: NewBoardColumn[] = [ + { + id: createId(), + boardId, + name: 'Беклог', + position: 1000, + color: '#bd6f2b', + status: 'backlog', + }, + { + id: createId(), + boardId, + name: 'К выполнению', + position: 2000, + color: '#2d62ae', + status: 'todo', + }, + { + id: createId(), + boardId, + name: 'В работе', + position: 3000, + color: '#c1ab38', + status: 'in_progress', + }, + { + id: createId(), + boardId, + name: 'Готово', + position: 4000, + color: '#22c55e', + status: 'done', + }, + { + id: createId(), + boardId, + name: 'Отменено', + position: 99999, + color: '#78817b', + status: 'canceled', + visibility: false, + }, + ]; + + return { board, columns, views }; + } + + private static getDefaultViewName(type: BoardType): string { + const names: Record = { + kanban: 'Доска', + calendar: 'Календарь', + gantt_matrix: 'Гант', + }; + return names[type]; + } + + private static getDefaultSettings(type: BoardType): Record { + switch (type) { + case 'kanban': + return { mock: 'kanban_mock_setting' }; + case 'calendar': + return { mock: 'calendar_mock_setting' }; + case 'gantt_matrix': + return { mock: 'gantt_mock_setting' }; + default: + const exhaustiveCheck: never = type; + return exhaustiveCheck; + } + } +} diff --git a/src/boards/domain/policy/board-access.policy.ts b/src/boards/domain/policy/board-access.policy.ts new file mode 100644 index 0000000..415a5d6 --- /dev/null +++ b/src/boards/domain/policy/board-access.policy.ts @@ -0,0 +1,112 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '@core/boards/domain/repository'; +import { BaseException } from '@shared/error'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class BoardAccessPolicy { + constructor( + @Inject('IBoardsRepository') + private readonly boardsRepo: IBoardsRepository, + private readonly projectAccessPolicy: ProjectAccessPolicy, + ) {} + + public async validateProjectAccess(projectId: string, userId: string): Promise { + await this.projectAccessPolicy.validateProjectAccessById(projectId, userId, 'viewer'); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к проекту' }, + HttpStatus.FORBIDDEN, + ); + } + } + + public async validateBoardAccess( + boardId: string, + userId: string, + expectedProjectId?: string, + ): Promise { + const board = await this.boardsRepo.findBoardById(boardId); + + if (!board || (expectedProjectId && board.projectId !== expectedProjectId)) { + throw new BaseException( + { + code: 'BOARD_NOT_FOUND', + message: 'Доска не найдена', + details: [{ target: 'boardId', value: boardId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateProjectAccess(board.projectId, userId); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к доске' }, + HttpStatus.FORBIDDEN, + ); + } + } + + public async validateColumnAccess( + columnId: string, + userId: string, + expectedBoardId?: string, + ): Promise { + const column = await this.boardsRepo.findColumnById(columnId); + + if (!column || (expectedBoardId && column.boardId !== expectedBoardId)) { + throw new BaseException( + { + code: 'BOARD_COLUMN_NOT_FOUND', + message: 'Колонка не найдена', + details: [{ target: 'columnId', value: columnId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateBoardAccess(column.boardId, userId); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к колонке' }, + HttpStatus.FORBIDDEN, + ); + } + } + + public async validateViewAccess( + viewId: string, + userId: string, + expectedBoardId?: string, + ): Promise { + const view = await this.boardsRepo.findViewById(viewId); + + if (!view || (expectedBoardId && view.boardId !== expectedBoardId)) { + throw new BaseException( + { + code: 'BOARD_VIEW_NOT_FOUND', + message: 'Представление не найдено', + details: [{ target: 'viewId', value: viewId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.validateBoardAccess(view.boardId, userId); + + const permissions = true; + if (!permissions) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Недостаточно прав для доступа к представлению' }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/boards/domain/policy/index.ts b/src/boards/domain/policy/index.ts new file mode 100644 index 0000000..29116c3 --- /dev/null +++ b/src/boards/domain/policy/index.ts @@ -0,0 +1 @@ +export * from './board-access.policy'; diff --git a/src/boards/domain/repository/boards.repository.interface.ts b/src/boards/domain/repository/boards.repository.interface.ts new file mode 100644 index 0000000..e495796 --- /dev/null +++ b/src/boards/domain/repository/boards.repository.interface.ts @@ -0,0 +1,32 @@ +import { + Board, + BoardColumn, + BoardView, + BoardWithRelations, + NewBoard, + NewBoardColumn, + NewBoardView, +} from '@core/boards/domain/entities'; + +export interface IBoardsRepository { + findAll(projectId: string): Promise; + findOne(id: string): Promise; + findBoardById(id: string): Promise; + create( + board: NewBoard, + columns: NewBoardColumn[], + views: NewBoardView[], + ): Promise; + update(id: string, data: Partial): Promise; + remove(id: string): Promise; + findColumns(boardId: string): Promise; + findColumnById(id: string): Promise; + createColumn(column: NewBoardColumn): Promise; + updateColumn(id: string, data: Partial): Promise; + removeColumn(id: string): Promise; + findViews(boardId: string): Promise; + findViewById(id: string): Promise; + createView(view: NewBoardView): Promise; + updateView(id: string, data: Partial): Promise; + removeView(id: string): Promise; +} diff --git a/src/boards/domain/repository/index.ts b/src/boards/domain/repository/index.ts new file mode 100644 index 0000000..a28a6ac --- /dev/null +++ b/src/boards/domain/repository/index.ts @@ -0,0 +1 @@ +export * from './boards.repository.interface'; diff --git a/src/boards/index.ts b/src/boards/index.ts new file mode 100644 index 0000000..0d0f5fb --- /dev/null +++ b/src/boards/index.ts @@ -0,0 +1 @@ +export { BoardsModule } from './boards.module'; diff --git a/src/boards/infrastructure/persistence/models/boards.model.ts b/src/boards/infrastructure/persistence/models/boards.model.ts new file mode 100644 index 0000000..c664430 --- /dev/null +++ b/src/boards/infrastructure/persistence/models/boards.model.ts @@ -0,0 +1,66 @@ +import { + text, + varchar, + timestamp, + jsonb, + doublePrecision, + uniqueIndex, + boolean, +} from 'drizzle-orm/pg-core'; +import { baseSchema, projects, users } from '@shared/entities'; +import { columnStatusEnum, boardTypeEnum } from './enums'; +import { createId } from '@paralleldrive/cuid2'; + +export const boards = baseSchema.table( + 'boards', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + name: varchar('name', { length: 100 }).notNull(), + projectId: text('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull(), + settings: jsonb('settings').default({}).notNull(), + position: doublePrecision('position').notNull(), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => ({ + projectBoardNameIdx: uniqueIndex('project_board_name_idx').on(table.projectId, table.name), + }), +); + +export const boardColumns = baseSchema.table('board_columns', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + boardId: text('board_id') + .references(() => boards.id, { onDelete: 'cascade' }) + .notNull(), + name: varchar('name', { length: 50 }).notNull(), + status: columnStatusEnum('status').default('backlog').notNull(), + visibility: boolean('visibility').default(true).notNull(), + position: doublePrecision('position').notNull(), + + color: varchar('color', { length: 7 }).default('#64748b').notNull(), + + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const boardViews = baseSchema.table('boards_views', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + boardId: text('board_id') + .references(() => boards.id, { onDelete: 'cascade' }) + .notNull(), + type: boardTypeEnum('type').default('kanban').notNull(), + name: varchar('name', { length: 100 }).notNull(), + settings: jsonb('settings').default({}).notNull(), + position: doublePrecision('position').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); diff --git a/src/boards/infrastructure/persistence/models/enums.ts b/src/boards/infrastructure/persistence/models/enums.ts new file mode 100644 index 0000000..7d4a308 --- /dev/null +++ b/src/boards/infrastructure/persistence/models/enums.ts @@ -0,0 +1,11 @@ +import { baseSchema } from '@shared/entities'; + +export const boardTypeEnum = baseSchema.enum('board_type', ['kanban', 'calendar', 'gantt_matrix']); + +export const columnStatusEnum = baseSchema.enum('column_status', [ + 'backlog', + 'todo', + 'in_progress', + 'done', + 'canceled', +]); diff --git a/src/boards/infrastructure/persistence/models/index.ts b/src/boards/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..fc4838d --- /dev/null +++ b/src/boards/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export * from './boards.model'; +export * from './enums'; diff --git a/src/boards/infrastructure/persistence/repositories/boards.repository.ts b/src/boards/infrastructure/persistence/repositories/boards.repository.ts new file mode 100644 index 0000000..d2ef6b6 --- /dev/null +++ b/src/boards/infrastructure/persistence/repositories/boards.repository.ts @@ -0,0 +1,259 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBoardsRepository } from '../../../domain/repository'; +import { + Board, + BoardColumn, + BoardView, + BoardWithRelations, + NewBoard, + NewBoardColumn, + NewBoardView, +} from '@core/boards/domain/entities'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import * as schema from '../models'; +import { asc, eq, inArray } from 'drizzle-orm'; + +@Injectable() +export class BoardsRepository implements IBoardsRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + async findAll(projectId: string): Promise { + const boards = await this.db + .select() + .from(schema.boards) + .where(eq(schema.boards.projectId, projectId)) + .orderBy(asc(schema.boards.position)); + + if (boards.length === 0) { + return []; + } + + const boardIds = boards.map((board) => board.id); + const [columns, views] = await Promise.all([ + this.db + .select() + .from(schema.boardColumns) + .where(inArray(schema.boardColumns.boardId, boardIds)) + .orderBy(asc(schema.boardColumns.position)), + this.db + .select() + .from(schema.boardViews) + .where(inArray(schema.boardViews.boardId, boardIds)) + .orderBy(asc(schema.boardViews.position)), + ]); + + const columnsByBoardId = this.groupByBoardId(columns); + const viewsByBoardId = this.groupByBoardId(views); + + return boards.map((board) => ({ + ...board, + boardColumns: columnsByBoardId.get(board.id) ?? [], + boardViews: viewsByBoardId.get(board.id) ?? [], + })); + } + + async findOne(id: string): Promise { + const [board] = await this.db.select().from(schema.boards).where(eq(schema.boards.id, id)); + + if (!board) { + return null; + } + + const [boardColumns, boardViews] = await Promise.all([ + this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.boardId, id)) + .orderBy(asc(schema.boardColumns.position)), + this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.boardId, id)) + .orderBy(asc(schema.boardViews.position)), + ]); + + return { + ...board, + boardColumns, + boardViews, + }; + } + + async findBoardById(id: string): Promise { + const [board] = await this.db.select().from(schema.boards).where(eq(schema.boards.id, id)); + + return board ?? null; + } + + async create( + board: NewBoard, + columns: NewBoardColumn[], + views: NewBoardView[], + ): Promise { + return await this.db.transaction(async (tx) => { + const [newBoard] = await tx + .insert(schema.boards) + .values({ + id: board.id, + name: board.name, + projectId: board.projectId, + ownerId: board.ownerId, + position: board.position, + settings: board.settings, + }) + .returning(); + + const boardViews = await tx.insert(schema.boardViews).values(views).returning(); + + const boardColumns = await tx.insert(schema.boardColumns).values(columns).returning(); + + return { + ...newBoard, + boardViews, + boardColumns, + }; + }); + } + + async update(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(schema.boards) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.boards.id, id)) + .returning(); + + if (!updated) { + return null; + } + + const [boardColumns, boardViews] = await Promise.all([ + this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.boardId, id)) + .orderBy(asc(schema.boardColumns.position)), + this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.boardId, id)) + .orderBy(asc(schema.boardViews.position)), + ]); + + return { + ...updated, + boardColumns, + boardViews, + }; + } + + async remove(id: string): Promise { + const result = await this.db + .delete(schema.boards) + .where(eq(schema.boards.id, id)) + .returning({ id: schema.boards.id }); + + return result.length > 0; + } + + async findColumns(boardId: string): Promise { + return this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.boardId, boardId)) + .orderBy(asc(schema.boardColumns.position)); + } + + async findColumnById(id: string): Promise { + const [column] = await this.db + .select() + .from(schema.boardColumns) + .where(eq(schema.boardColumns.id, id)); + + return column ?? null; + } + + async createColumn(column: NewBoardColumn): Promise { + const [created] = await this.db.insert(schema.boardColumns).values(column).returning(); + + return created; + } + + async updateColumn(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(schema.boardColumns) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.boardColumns.id, id)) + .returning(); + + return updated ?? null; + } + + async removeColumn(id: string): Promise { + const result = await this.db + .delete(schema.boardColumns) + .where(eq(schema.boardColumns.id, id)) + .returning({ id: schema.boardColumns.id }); + + return result.length > 0; + } + + async findViews(boardId: string): Promise { + return this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.boardId, boardId)) + .orderBy(asc(schema.boardViews.position)); + } + + async findViewById(id: string): Promise { + const [view] = await this.db + .select() + .from(schema.boardViews) + .where(eq(schema.boardViews.id, id)); + + return view ?? null; + } + + async createView(view: NewBoardView): Promise { + const [created] = await this.db.insert(schema.boardViews).values(view).returning(); + + return created; + } + + async updateView(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(schema.boardViews) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.boardViews.id, id)) + .returning(); + + return updated ?? null; + } + + async removeView(id: string): Promise { + const result = await this.db + .delete(schema.boardViews) + .where(eq(schema.boardViews.id, id)) + .returning({ id: schema.boardViews.id }); + + return result.length > 0; + } + + private groupByBoardId(items: T[]): Map { + const grouped = new Map(); + + for (const item of items) { + const current = grouped.get(item.boardId); + if (current) { + current.push(item); + } else { + grouped.set(item.boardId, [item]); + } + } + + return grouped; + } +} diff --git a/src/boards/infrastructure/persistence/repositories/index.ts b/src/boards/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..4b4bb14 --- /dev/null +++ b/src/boards/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { BoardsRepository } from './boards.repository'; diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts index 7001ebf..89a4df9 100644 --- a/src/projects/domain/policy/project-access.policy.ts +++ b/src/projects/domain/policy/project-access.policy.ts @@ -75,4 +75,51 @@ export class ProjectAccessPolicy { return { project, member, team }; } + + /** + * Проверка доступа к проекту по projectId (без slug) + */ + public async validateProjectAccessById( + projectId: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const project = await this.projectsRepo.findOne(projectId); + if (!project) { + throw new BaseException( + { code: 'PROJECT_NOT_FOUND', message: 'Проект не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.findTeamMemberQ.execute(project.teamId, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_TEAM_MEMBER', message: 'Вы не участник команды' }, + HttpStatus.FORBIDDEN, + ); + } + + // TODO: replace with project members query + const isProjectMember = true; + if (!isProjectMember) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Вы не являетесь участником этого проекта' }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Требуется роль ${minRole} или выше`, + details: [{ target: 'role', current: member.role, required: minRole }], + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member }; + } } diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 4a74316..3689739 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -3,7 +3,7 @@ import { ProjectsRepository } from './infrastructure/persistence/repositories'; import { TeamsModule } from '@core/teams'; import { ProjectsController } from './application/controller'; import { FindProjectQuery, ProjectQueries, ProjectUseCases } from './application/use-cases'; -import { POLICIES } from './domain/policy'; +import { POLICIES, ProjectAccessPolicy } from './domain/policy'; import { ProjectsFacade } from './application/projects.facade'; const REPOSITORY = { @@ -15,6 +15,6 @@ const REPOSITORY = { imports: [forwardRef(() => TeamsModule)], controllers: [ProjectsController], providers: [REPOSITORY, ...POLICIES, ...ProjectUseCases, ...ProjectQueries, ProjectsFacade], - exports: [FindProjectQuery], + exports: [FindProjectQuery, ProjectAccessPolicy], }) export class ProjectsModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index b50a6a2..524e3f8 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -3,3 +3,4 @@ export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; export * from '../../teams/infrastructure/persistence/models'; export * from '../../projects/infrastructure/persistence/models'; +export * from '../../boards/infrastructure/persistence/models';